@cryptotaxi247 / kubo / commits / b1e70f8ec

feat(fuse): Statfs (#11261)

* fix(fuse/mfs): implement Statfs to fix "not enough space" error on macOS Co-authored-by: Marcin Rataj <lidel@lidel.org>

William Morriss committed Apr 10, 2026 at 17:55 UTC b1e70f8ecd09d5c9fa9fa6089c1634d02db1a157
10 files changed +174 -5
docs/changelogs/v0.41.md
+1
@@ -191,6 +191,7 @@ The FUSE implementation has been rewritten on top of [`hanwen/go-fuse` v2](https
191 - **UnixFS mode and mtime visible in stat.** All three mounts show POSIX mode and mtime from [UnixFS](https://specs.ipfs.tech/unixfs/) metadata when present. When absent, sensible POSIX defaults are used (files: `0644`/`0444`, directories: `0755`/`0555`).
192 - **Opt-in `Mounts.StoreMtime` and `Mounts.StoreMode`.** Writable mounts can persist mtime on file creation/write and POSIX mode on `chmod` for both files and directories. `touch` on directories also works, which tools like `tar` and `rsync` rely on. Both flags are off by default because they change the resulting CID. See [`Mounts.StoreMtime`](https://github.com/ipfs/kubo/blob/master/docs/config.md#mountsstoremtime) and [`Mounts.StoreMode`](https://github.com/ipfs/kubo/blob/master/docs/config.md#mountsstoremode).
193 - **`ipfs.cid` xattr on all mounts.** All three mounts expose the node's CID via the `ipfs.cid` extended attribute on files and directories. The legacy `ipfs_cid` xattr name (used in earlier versions of `/mfs`) is no longer supported; use `ipfs.cid` instead.
194 +- **`statfs` works.** All three mounts report the free space of the volume backing the local IPFS repo, so `/mfs` correctly reflects how much new data can be onboarded. Fixes macOS Finder refusing copies with "not enough free space".
195 - **Platform compatibility.** macOS detection updated from OSXFUSE 2.x to macFUSE 4.x. Linux no longer needs a `fusermount` symlink; [`hanwen/go-fuse`](https://github.com/hanwen/go-fuse) finds `fusermount3` natively.
196
197 #### 🐹 Go 1.26, Once More with Feeling
fuse/ipns/ipns_test.go
+23 -1
@@ -11,6 +11,7 @@ import (
11 "bytes"
12 "context"
13 "os"
14 + "syscall"
15 "testing"
16
17 "github.com/hanwen/go-fuse/v2/fs"
@@ -78,7 +79,7 @@ func setupIpnsTest(t *testing.T, nd *core.IpfsNode, cfgs ...config.Mounts) (*cor
79 key, err := coreAPI.Key().Self(nd.Context())
80 require.NoError(t, err)
81
81 - root, err := CreateRoot(nd.Context(), coreAPI, map[string]iface.Key{"local": key}, "", "", cfg)
82 + root, err := CreateRoot(nd.Context(), coreAPI, map[string]iface.Key{"local": key}, "", "", nd.Repo.Path(), cfg)
83 require.NoError(t, err)
84
85 mntDir := t.TempDir()
@@ -172,3 +173,24 @@ func TestMultipleDirs(t *testing.T) {
173 fusetest.VerifyFile(t, mnt.Dir+"/local/test1/file1", data1)
174 fusetest.VerifyFile(t, mnt.Dir+"/local/test1/dir2/file2", data2)
175 }
176 +
177 +// TestStatfs verifies that statfs on the /ipns mount reports the disk
178 +// space of the repo's backing filesystem. macOS Finder refuses to copy
179 +// files onto a volume that reports zero free space.
180 +func TestStatfs(t *testing.T) {
181 + _, mnt := setupIpnsTest(t, nil)
182 +
183 + // The in-memory test repo returns "" for Path(), so point RepoPath
184 + // at a real directory to exercise the syscall path.
185 + repoDir := t.TempDir()
186 + mnt.Root.RepoPath = repoDir
187 +
188 + var got syscall.Statfs_t
189 + require.NoError(t, syscall.Statfs(mnt.Dir, &got))
190 +
191 + var want syscall.Statfs_t
192 + require.NoError(t, syscall.Statfs(repoDir, &want))
193 +
194 + require.Equal(t, want.Blocks, got.Blocks, "total blocks should match the repo filesystem")
195 + require.Equal(t, want.Bfree, got.Bfree, "free blocks should match the repo filesystem")
196 +}
fuse/ipns/ipns_unix.go
+20 -1
@@ -43,6 +43,7 @@ type Root struct {
43 Roots map[string]*mfs.Root
44
45 LocalLinks map[string]*Link
46 + RepoPath string
47 }
48
49 func ipnsPubFunc(ipfs iface.CoreAPI, key iface.Key) mfs.PubFunc {
@@ -81,11 +82,12 @@ func loadRoot(ctx context.Context, ipfs iface.CoreAPI, key iface.Key, cfg *writa
82 }
83
84 // CreateRoot creates the IPNS FUSE root with one writable directory per key.
84 -func CreateRoot(ctx context.Context, ipfs iface.CoreAPI, keys map[string]iface.Key, ipfspath, ipnspath string, mountsCfg config.Mounts, mfsOpts ...mfs.Option) (*Root, error) {
85 +func CreateRoot(ctx context.Context, ipfs iface.CoreAPI, keys map[string]iface.Key, ipfspath, ipnspath, repoPath string, mountsCfg config.Mounts, mfsOpts ...mfs.Option) (*Root, error) {
86 cfg := &writable.Config{
87 StoreMtime: mountsCfg.StoreMtime.WithDefault(config.DefaultStoreMtime),
88 StoreMode: mountsCfg.StoreMode.WithDefault(config.DefaultStoreMode),
89 DAG: ipfs.Dag(),
90 + RepoPath: repoPath,
91 }
92
93 ldirs := make(map[string]*writable.Dir)
@@ -111,6 +113,7 @@ func CreateRoot(ctx context.Context, ipfs iface.CoreAPI, keys map[string]iface.K
113 LocalDirs: ldirs,
114 LocalLinks: links,
115 Roots: roots,
116 + RepoPath: repoPath,
117 }, nil
118 }
119
@@ -120,6 +123,21 @@ func (r *Root) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) sy
123 return 0
124 }
125
126 +// Statfs reports disk-space statistics for the underlying filesystem.
127 +// macOS Finder checks free space before copying; without this it
128 +// reports "not enough free space" because go-fuse returns zeroed stats.
129 +func (r *Root) Statfs(_ context.Context, out *fuse.StatfsOut) syscall.Errno {
130 + if r.RepoPath == "" {
131 + return 0
132 + }
133 + var s syscall.Statfs_t
134 + if err := syscall.Statfs(r.RepoPath, &s); err != nil {
135 + return fs.ToErrno(err)
136 + }
137 + out.FromStatfsT(&s)
138 + return 0
139 +}
140 +
141 func (r *Root) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
142 switch name {
143 case "mach_kernel", ".hidden", "._.":
@@ -174,4 +192,5 @@ var (
192 _ fs.NodeGetattrer = (*Root)(nil)
193 _ fs.NodeLookuper = (*Root)(nil)
194 _ fs.NodeReaddirer = (*Root)(nil)
195 + _ fs.NodeStatfser = (*Root)(nil)
196 )
fuse/ipns/mount_unix.go
+1 -1
@@ -45,7 +45,7 @@ func Mount(ipfs *core.IpfsNode, ipnsmp, ipfsmp string) (fusemnt.Mount, error) {
45 return nil, err
46 }
47
48 - root, err := CreateRoot(ipfs.Context(), coreAPI, map[string]iface.Key{"local": key}, ipfsmp, ipnsmp, cfg.Mounts, mfsOpts...)
48 + root, err := CreateRoot(ipfs.Context(), coreAPI, map[string]iface.Key{"local": key}, ipfsmp, ipnsmp, ipfs.Repo.Path(), cfg.Mounts, mfsOpts...)
49 if err != nil {
50 return nil, err
51 }
fuse/mfs/mfs_test.go
+27
@@ -12,6 +12,7 @@ import (
12 "context"
13 "crypto/rand"
14 "os"
15 + "syscall"
16 "testing"
17
18 "github.com/hanwen/go-fuse/v2/fs"
@@ -87,3 +88,29 @@ func TestPersistence(t *testing.T) {
88 require.True(t, bytes.Equal(content, got))
89 })
90 }
91 +
92 +// TestStatfs verifies that statfs on the /mfs mount reports the disk
93 +// space of the repo's backing filesystem. macOS Finder refuses to copy
94 +// files onto a volume that reports zero free space.
95 +func TestStatfs(t *testing.T) {
96 + ipfs, err := core.NewNode(t.Context(), &node.BuildCfg{})
97 + require.NoError(t, err)
98 +
99 + // The default in-memory repo returns "" for Path(), so point
100 + // RepoPath at a real directory to exercise the syscall path.
101 + repoDir := t.TempDir()
102 + root := writable.NewDir(ipfs.FilesRoot.GetDirectory(), &writable.Config{
103 + DAG: ipfs.DAG,
104 + RepoPath: repoDir,
105 + })
106 + mntDir := testMount(t, root)
107 +
108 + var got syscall.Statfs_t
109 + require.NoError(t, syscall.Statfs(mntDir, &got))
110 +
111 + var want syscall.Statfs_t
112 + require.NoError(t, syscall.Statfs(repoDir, &want))
113 +
114 + require.Equal(t, want.Blocks, got.Blocks, "total blocks should match the repo filesystem")
115 + require.Equal(t, want.Bfree, got.Bfree, "free blocks should match the repo filesystem")
116 +}
fuse/mfs/mfs_unix.go
+1
@@ -16,5 +16,6 @@ func NewFileSystem(ipfs *core.IpfsNode, cfg config.Mounts) *writable.Dir {
16 StoreMtime: cfg.StoreMtime.WithDefault(config.DefaultStoreMtime),
17 StoreMode: cfg.StoreMode.WithDefault(config.DefaultStoreMode),
18 DAG: ipfs.DAG,
19 + RepoPath: ipfs.Repo.Path(),
20 })
21 }
fuse/readonly/ipfs_test.go
+23
@@ -759,6 +759,29 @@ func TestReadCancellationUnblocks(t *testing.T) {
759 }
760 }
761
762 +// TestStatfs verifies that statfs on the /ipfs mount reports the disk
763 +// space of the repo's backing filesystem. macOS Finder refuses to copy
764 +// files onto a volume that reports zero free space.
765 +func TestStatfs(t *testing.T) {
766 + nd, err := coremock.NewMockNode()
767 + require.NoError(t, err)
768 +
769 + // Point repoPath at a real directory so Statfs has a valid target.
770 + // (NewMockNode's in-memory repo returns "" for Path().)
771 + repoDir := t.TempDir()
772 + root := &Root{ipfs: nd, repoPath: repoDir}
773 + mntDir := testMount(t, root)
774 +
775 + var got syscall.Statfs_t
776 + require.NoError(t, syscall.Statfs(mntDir, &got))
777 +
778 + var want syscall.Statfs_t
779 + require.NoError(t, syscall.Statfs(repoDir, &want))
780 +
781 + require.Equal(t, want.Blocks, got.Blocks, "total blocks should match the repo filesystem")
782 + require.Equal(t, want.Bfree, got.Bfree, "free blocks should match the repo filesystem")
783 +}
784 +
785 // Test that getxattr on an unknown attribute returns ENODATA (Linux) / ENOATTR.
786 func TestUnknownXattr(t *testing.T) {
787 nd, _ := setupIpfsTest(t, nil)
fuse/readonly/readonly_unix.go
+19 -2
@@ -36,12 +36,28 @@ var immutableAttrCacheTime = 365 * 24 * time.Hour
36 // Root is the root object of the /ipfs filesystem tree.
37 type Root struct {
38 fs.Inode
39 - ipfs *core.IpfsNode
39 + ipfs *core.IpfsNode
40 + repoPath string
41 }
42
43 // NewRoot constructs a new readonly root node.
44 func NewRoot(ipfs *core.IpfsNode) *Root {
44 - return &Root{ipfs: ipfs}
45 + return &Root{ipfs: ipfs, repoPath: ipfs.Repo.Path()}
46 +}
47 +
48 +// Statfs reports disk-space statistics for the underlying filesystem.
49 +// macOS Finder checks free space before copying; without this it
50 +// reports "not enough free space" because go-fuse returns zeroed stats.
51 +func (r *Root) Statfs(_ context.Context, out *fuse.StatfsOut) syscall.Errno {
52 + if r.repoPath == "" {
53 + return 0
54 + }
55 + var s syscall.Statfs_t
56 + if err := syscall.Statfs(r.repoPath, &s); err != nil {
57 + return fs.ToErrno(err)
58 + }
59 + out.FromStatfsT(&s)
60 + return 0
61 }
62
63 func (*Root) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
@@ -369,6 +385,7 @@ var (
385 _ fs.NodeGetattrer = (*Root)(nil)
386 _ fs.NodeLookuper = (*Root)(nil)
387 _ fs.NodeReaddirer = (*Root)(nil)
388 + _ fs.NodeStatfser = (*Root)(nil)
389 _ fs.NodeGetattrer = (*Node)(nil)
390 _ fs.NodeLookuper = (*Node)(nil)
391 _ fs.NodeOpener = (*Node)(nil)
fuse/writable/writable.go
+22
@@ -35,6 +35,12 @@ type Config struct {
35 StoreMtime bool // persist mtime on create and open-for-write
36 StoreMode bool // persist mode on chmod
37 DAG ipld.DAGService // required: read-only opens use it to bypass MFS desclock
38 + // RepoPath is the on-disk path of the IPFS repo (e.g. ~/.ipfs).
39 + // Statfs calls syscall.Statfs on this path so that the FUSE mount
40 + // reports how much free space is left on the volume that stores
41 + // MFS data. Without it tools like macOS Finder see zero free space
42 + // and refuse to copy files.
43 + RepoPath string
44 }
45
46 // NewDir creates a Dir node backed by the given MFS directory.
@@ -71,6 +77,21 @@ func (d *Dir) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) sys
77 return 0
78 }
79
80 +// Statfs reports disk-space statistics for the underlying filesystem.
81 +// macOS Finder checks free space before copying; without this it
82 +// reports "not enough free space" because go-fuse returns zeroed stats.
83 +func (d *Dir) Statfs(_ context.Context, out *fuse.StatfsOut) syscall.Errno {
84 + if d.Cfg.RepoPath == "" {
85 + return 0
86 + }
87 + var s syscall.Statfs_t
88 + if err := syscall.Statfs(d.Cfg.RepoPath, &s); err != nil {
89 + return fs.ToErrno(err)
90 + }
91 + out.FromStatfsT(&s)
92 + return 0
93 +}
94 +
95 // Setattr handles chmod and mtime changes on directories.
96 // Tools like tar and rsync set directory timestamps after extraction.
97 //
@@ -737,6 +758,7 @@ func SymlinkTarget(f *mfs.File) string {
758 // Interface compliance checks.
759 var (
760 _ fs.NodeGetattrer = (*Dir)(nil)
761 + _ fs.NodeStatfser = (*Dir)(nil)
762 _ fs.NodeSetattrer = (*Dir)(nil)
763 _ fs.NodeLookuper = (*Dir)(nil)
764 _ fs.NodeReaddirer = (*Dir)(nil)
fuse/writable/writable_test.go
+37
@@ -3,6 +3,7 @@
3 package writable
4
5 import (
6 + "syscall"
7 "testing"
8
9 "github.com/hanwen/go-fuse/v2/fuse"
@@ -43,3 +44,39 @@ func TestSymlinkSetattrChmodNoError(t *testing.T) {
44 t.Fatalf("Symlink mode = 0o%o, want 0o777", got)
45 }
46 }
47 +
48 +// TestStatfsReportsSpace verifies that Dir.Statfs proxies the
49 +// disk-space statistics of the repo's backing filesystem, and that an
50 +// empty RepoPath produces zeroed (but successful) results.
51 +func TestStatfsReportsSpace(t *testing.T) {
52 + t.Run("matches repo filesystem", func(t *testing.T) {
53 + dir := t.TempDir()
54 + d := &Dir{Cfg: &Config{RepoPath: dir}}
55 + out := &fuse.StatfsOut{}
56 + if errno := d.Statfs(t.Context(), out); errno != 0 {
57 + t.Fatalf("Statfs returned errno %v, want 0", errno)
58 + }
59 +
60 + var want syscall.Statfs_t
61 + if err := syscall.Statfs(dir, &want); err != nil {
62 + t.Fatal(err)
63 + }
64 + if out.Blocks != want.Blocks {
65 + t.Fatalf("Blocks = %d, want %d (from repo path)", out.Blocks, want.Blocks)
66 + }
67 + if out.Bfree != want.Bfree {
68 + t.Fatalf("Bfree = %d, want %d (from repo path)", out.Bfree, want.Bfree)
69 + }
70 + })
71 +
72 + t.Run("empty repo path", func(t *testing.T) {
73 + d := &Dir{Cfg: &Config{}}
74 + out := &fuse.StatfsOut{}
75 + if errno := d.Statfs(t.Context(), out); errno != 0 {
76 + t.Fatalf("Statfs returned errno %v, want 0", errno)
77 + }
78 + if out.Blocks != 0 {
79 + t.Fatalf("expected zeroed Blocks when RepoPath is empty, got %d", out.Blocks)
80 + }
81 + })
82 +}