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
+}