@cryptotaxi247 / kubo / commits / 8cebf5ca7

fix(fuse): accurate `st_blocks` and `st_blksize` (#11280)

* feat(fuse): accurate st_blocks and st_blksize Populate st_blocks from the UnixFS file size and advertise a chunk-aligned st_blksize so du, ls -s, and stat report real numbers on all three mounts. - fuse/mount/stat.go: new SizeToStatBlocks, DefaultBlksize (1 MiB), BlksizeFromChunker - fuse/readonly: fillAttr sets blocks and blksize for files, raw nodes, symlinks, directories - fuse/writable: Config.Blksize field + effectiveBlksize fallback; Dir, FileInode, and Symlink fillAttr populate stat fields - fuse/mfs, fuse/ipns: pass Import.UnixFSChunker into Config.Blksize via BlksizeFromChunker - tests: BlksizeFromChunker parser, DefaultBlksize anchor, effectiveBlksize zero-fallback, TestStatBlocks subtests for files, directories, symlinks on both mounts - docs/changelogs/v0.41.md: FUSE Mount Improvements entry * refactor(fuse): tighten st_blksize plumbing cap st_blksize at 16 MiB so a pathological `Import.UnixFSChunker` cannot push tools into multi-GiB per-read buffers, and parse the size suffix as uint64 so all valid numeric inputs clamp uniformly instead of silently falling back past uint32. normalize Blksize once in writable.NewDir so fillAttr reads Cfg.Blksize directly, dropping the per-call effectiveBlksize method. drop unreachable size-zero guard in fusetest.AssertStatBlocks. * refactor(fuse): cap st_blksize at fuse.MAX_KERNEL_WRITE Drop the arbitrary 16 MiB MaxBlksize ceiling and clamp directly to go-fuse's MAX_KERNEL_WRITE (1 MiB on Linux v4.20+). Hinting past this ceiling is wasted because the kernel splits any larger userspace read/write into MAX_KERNEL_WRITE-sized FUSE ops regardless. * fix(fuse): gate stat helpers to fuse-supported platforms stat.go imports go-fuse, which only builds on linux/darwin/freebsd. without a build tag it broke cross-compilation for openbsd. * docs(fuse): clarify st_blocks/st_blksize rationale

Marcin Rataj committed Apr 13, 2026 at 17:49 UTC 8cebf5ca713752dc94082e293e116cd960bf04a0
14 files changed +337 -13
docs/changelogs/v0.41.md
+1
@@ -201,6 +201,7 @@ The FUSE implementation has been rewritten on top of [`hanwen/go-fuse` v2](https
201 - **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).
202 - **`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.
203 - **`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".
204 +- **Per-entry `st_blocks` and `st_blksize` reflect UnixFS.** All three mounts fill `st_blocks` from the UnixFS file size so `du`, `ls -s`, `stat`, and "size on disk" in file managers match `ls -l`. Directories report a nominal 1 block so tools that treat 0 as "unsupported" behave correctly. `st_blksize` advertises a chunk-aligned preferred I/O size: `/mfs` and `/ipns` use [`Import.UnixFSChunker`](https://github.com/ipfs/kubo/blob/master/docs/config.md#importunixfschunker), so `cp`, `dd`, and `rsync` buffer writes at the chunker boundary; `/ipfs` uses a stable 1 MiB hint since published CIDs have no single chunker.
205 - **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.
206
207 #### 📊 Dropped high-cardinality `server.address` from HTTP metrics
fuse/fusetest/fusetest.go
+18
@@ -67,6 +67,24 @@ func AssertStatfsNonZero(t *testing.T, path string) {
67 require.LessOrEqual(t, st.Bfree, st.Blocks, "Bfree must not exceed Blocks")
68 }
69
70 +// AssertStatBlocks stats path and checks that st_blocks matches the file
71 +// size rounded up to 512-byte units (the POSIX stat convention) and that
72 +// st_blksize matches wantBlksize. These are the fields du, ls -s, and
73 +// stat read to report disk usage per entry.
74 +func AssertStatBlocks(t *testing.T, path string, wantBlksize uint32) {
75 + t.Helper()
76 + fi, err := os.Stat(path)
77 + require.NoError(t, err)
78 + st, ok := fi.Sys().(*syscall.Stat_t)
79 + require.True(t, ok, "expected *syscall.Stat_t from os.Stat on FUSE mount")
80 +
81 + wantBlocks := int64((fi.Size() + 511) / 512)
82 + require.Equal(t, wantBlocks, int64(st.Blocks),
83 + "st_blocks mismatch for %s (size=%d)", path, fi.Size())
84 + require.Equal(t, wantBlksize, uint32(st.Blksize),
85 + "st_blksize mismatch for %s", path)
86 +}
87 +
88 // MountError handles a FUSE mount error. When TEST_FUSE=1 (CI), a mount
89 // failure is fatal because the environment is expected to have working FUSE.
90 // When auto-detecting (no TEST_FUSE set), mount failures cause a skip.
fuse/ipns/ipns_test.go
+1 -1
@@ -78,7 +78,7 @@ func setupIpnsTest(t *testing.T, nd *core.IpfsNode, cfgs ...config.Mounts) (*cor
78 key, err := coreAPI.Key().Self(nd.Context())
79 require.NoError(t, err)
80
81 - root, err := CreateRoot(nd.Context(), coreAPI, map[string]iface.Key{"local": key}, "", "", nd.Repo.Path(), cfg)
81 + root, err := CreateRoot(nd.Context(), coreAPI, map[string]iface.Key{"local": key}, "", "", nd.Repo.Path(), cfg, config.Import{})
82 require.NoError(t, err)
83
84 mntDir := t.TempDir()
fuse/ipns/ipns_unix.go
+2 -1
@@ -82,12 +82,13 @@ 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.
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) {
85 +func CreateRoot(ctx context.Context, ipfs iface.CoreAPI, keys map[string]iface.Key, ipfspath, ipnspath, repoPath string, mountsCfg config.Mounts, imp config.Import, 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 + Blksize: fusemnt.BlksizeFromChunker(imp.UnixFSChunker.WithDefault(config.DefaultUnixFSChunker)),
92 }
93
94 ldirs := make(map[string]*writable.Dir)
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, ipfs.Repo.Path(), cfg.Mounts, mfsOpts...)
48 + root, err := CreateRoot(ipfs.Context(), coreAPI, map[string]iface.Key{"local": key}, ipfsmp, ipnsmp, ipfs.Repo.Path(), cfg.Mounts, cfg.Import, mfsOpts...)
49 if err != nil {
50 return nil, err
51 }
fuse/mfs/mfs_test.go
+61 -3
@@ -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"
@@ -50,7 +51,7 @@ func mfsMount(t *testing.T, cfg writable.Config) string {
51 if cfg.StoreMode {
52 mountsCfg.StoreMode = config.True
53 }
53 - root := NewFileSystem(ipfs, mountsCfg)
54 + root := NewFileSystem(ipfs, mountsCfg, config.Import{})
55 return testMount(t, root)
56 }
57
@@ -69,7 +70,7 @@ func TestPersistence(t *testing.T) {
70 require.NoError(t, err)
71
72 t.Run("write", func(t *testing.T) {
72 - root := NewFileSystem(ipfs, config.Mounts{})
73 + root := NewFileSystem(ipfs, config.Mounts{}, config.Import{})
74 mntDir := testMount(t, root)
75
76 f, err := os.Create(mntDir + "/testpersistence")
@@ -79,7 +80,7 @@ func TestPersistence(t *testing.T) {
80 require.NoError(t, f.Close())
81 })
82 t.Run("read", func(t *testing.T) {
82 - root := NewFileSystem(ipfs, config.Mounts{})
83 + root := NewFileSystem(ipfs, config.Mounts{}, config.Import{})
84 mntDir := testMount(t, root)
85
86 got, err := os.ReadFile(mntDir + "/testpersistence")
@@ -88,6 +89,63 @@ func TestPersistence(t *testing.T) {
89 })
90 }
91
92 +// TestStatBlocks verifies that stat(2) on entries in /mfs populates
93 +// st_blocks (used by du and ls -s) consistent with the file size, and
94 +// that st_blksize advertises the chunker size MFS will use for writes
95 +// so tools can align their I/O buffers.
96 +func TestStatBlocks(t *testing.T) {
97 + const chunkerStr = "size-65536"
98 + const wantBlksize uint32 = 65536
99 +
100 + ipfs, err := core.NewNode(t.Context(), &node.BuildCfg{})
101 + require.NoError(t, err)
102 +
103 + kuboCfg := config.Import{UnixFSChunker: *config.NewOptionalString(chunkerStr)}
104 + root := NewFileSystem(ipfs, config.Mounts{}, kuboCfg)
105 + mntDir := testMount(t, root)
106 +
107 + t.Run("multi-block file", func(t *testing.T) {
108 + // >1 MiB ensures the UnixFS DAG has multiple leaves under the
109 + // configured 64 KiB chunker.
110 + content := make([]byte, 1024*1024+1)
111 + _, err := rand.Read(content)
112 + require.NoError(t, err)
113 + fpath := mntDir + "/big"
114 + require.NoError(t, os.WriteFile(fpath, content, 0o644))
115 + fusetest.AssertStatBlocks(t, fpath, wantBlksize)
116 + })
117 +
118 + t.Run("small single-chunk file", func(t *testing.T) {
119 + fpath := mntDir + "/small"
120 + require.NoError(t, os.WriteFile(fpath, []byte("hello"), 0o644))
121 + fusetest.AssertStatBlocks(t, fpath, wantBlksize)
122 + })
123 +
124 + t.Run("directory", func(t *testing.T) {
125 + dpath := mntDir + "/d"
126 + require.NoError(t, os.Mkdir(dpath, 0o755))
127 + info, err := os.Stat(dpath)
128 + require.NoError(t, err)
129 + st, ok := info.Sys().(*syscall.Stat_t)
130 + require.True(t, ok)
131 + require.EqualValues(t, 1, st.Blocks, "directory should report 1 nominal block")
132 + require.EqualValues(t, wantBlksize, st.Blksize)
133 + })
134 +
135 + t.Run("symlink", func(t *testing.T) {
136 + const target = "../some/target"
137 + lpath := mntDir + "/link"
138 + require.NoError(t, os.Symlink(target, lpath))
139 + info, err := os.Lstat(lpath)
140 + require.NoError(t, err)
141 + st, ok := info.Sys().(*syscall.Stat_t)
142 + require.True(t, ok)
143 + require.EqualValues(t, len(target), st.Size)
144 + require.EqualValues(t, 1, st.Blocks)
145 + require.EqualValues(t, wantBlksize, st.Blksize)
146 + })
147 +}
148 +
149 // TestStatfs verifies that statfs on the /mfs mount reports the disk
150 // space of the repo's backing filesystem. macOS Finder refuses to copy
151 // files onto a volume that reports zero free space.
fuse/mfs/mfs_unix.go
+5 -3
@@ -7,15 +7,17 @@ package mfs
7 import (
8 "github.com/ipfs/kubo/config"
9 "github.com/ipfs/kubo/core"
10 + fusemnt "github.com/ipfs/kubo/fuse/mount"
11 "github.com/ipfs/kubo/fuse/writable"
12 )
13
14 // NewFileSystem creates a new MFS FUSE root node.
14 -func NewFileSystem(ipfs *core.IpfsNode, cfg config.Mounts) *writable.Dir {
15 +func NewFileSystem(ipfs *core.IpfsNode, mounts config.Mounts, imp config.Import) *writable.Dir {
16 return writable.NewDir(ipfs.FilesRoot.GetDirectory(), &writable.Config{
16 - StoreMtime: cfg.StoreMtime.WithDefault(config.DefaultStoreMtime),
17 - StoreMode: cfg.StoreMode.WithDefault(config.DefaultStoreMode),
17 + StoreMtime: mounts.StoreMtime.WithDefault(config.DefaultStoreMtime),
18 + StoreMode: mounts.StoreMode.WithDefault(config.DefaultStoreMode),
19 DAG: ipfs.DAG,
20 RepoPath: ipfs.Repo.Path(),
21 + Blksize: fusemnt.BlksizeFromChunker(imp.UnixFSChunker.WithDefault(config.DefaultUnixFSChunker)),
22 })
23 }
fuse/mfs/mount_unix.go
+1 -1
@@ -25,7 +25,7 @@ func Mount(ipfs *core.IpfsNode, mountpoint string) (fusemnt.Mount, error) {
25 if err != nil {
26 return nil, err
27 }
28 - root := NewFileSystem(ipfs, cfg.Mounts)
28 + root := NewFileSystem(ipfs, cfg.Mounts, cfg.Import)
29 opts := &fs.Options{
30 NullPermissions: true,
31 UID: uint32(os.Getuid()),
fuse/mount/stat.go new
+53
@@ -0,0 +1,53 @@
1 +// FUSE stat helpers. go-fuse only builds on linux, darwin, and freebsd.
2 +//go:build (linux || darwin || freebsd) && !nofuse
3 +
4 +package mount
5 +
6 +import (
7 + "strconv"
8 + "strings"
9 +
10 + "github.com/hanwen/go-fuse/v2/fuse"
11 +)
12 +
13 +// StatBlockSize is the POSIX stat(2) block unit. The st_blocks field
14 +// reports allocation in 512-byte units regardless of the filesystem's
15 +// real block size (see `man 2 stat`). Tools like `du`, `ls -s`, and
16 +// `find -size` multiply st_blocks by this constant to compute bytes.
17 +const StatBlockSize = 512
18 +
19 +// DefaultBlksize is the preferred I/O size (stat.st_blksize) FUSE mounts
20 +// advertise when no chunker-derived value applies (readonly /ipfs, or
21 +// writable /mfs with a rabin/buzhash chunker). Larger hints let tools
22 +// like cp, dd, and rsync use bigger buffers, amortizing FUSE syscall and
23 +// DAG-walk overhead. 1 MiB matches the chunk size of Kubo's
24 +// cross-implementation CID-deterministic import profile (IPIP-499).
25 +// Hardcoded instead of tracking boxo's chunker default so the stat(2)
26 +// contract stays stable across Kubo and boxo upgrades.
27 +const DefaultBlksize = 1024 * 1024
28 +
29 +// SizeToStatBlocks converts a byte size to the number of 512-byte blocks
30 +// reported by POSIX stat(2) in the st_blocks field, rounded up so a
31 +// non-empty file reports at least one block.
32 +func SizeToStatBlocks(size uint64) uint64 {
33 + return (size + StatBlockSize - 1) / StatBlockSize
34 +}
35 +
36 +// BlksizeFromChunker derives the preferred I/O size hint for the writable
37 +// mounts from the user's Import.UnixFSChunker setting. It extracts the
38 +// byte count from `size-<bytes>` and returns DefaultBlksize for rabin,
39 +// buzhash, or malformed values (where there is no single preferred size).
40 +// Values are clamped to fuse.MAX_KERNEL_WRITE because the kernel splits
41 +// any larger userspace read/write into MAX_KERNEL_WRITE-sized FUSE ops
42 +// regardless, so hinting past the ceiling just wastes userspace buffers.
43 +func BlksizeFromChunker(chunkerStr string) uint32 {
44 + if sizeStr, ok := strings.CutPrefix(chunkerStr, "size-"); ok {
45 + if size, err := strconv.ParseUint(sizeStr, 10, 64); err == nil && size > 0 {
46 + if size > fuse.MAX_KERNEL_WRITE {
47 + return fuse.MAX_KERNEL_WRITE
48 + }
49 + return uint32(size)
50 + }
51 + }
52 + return DefaultBlksize
53 +}
fuse/mount/stat_test.go new
+60
@@ -0,0 +1,60 @@
1 +//go:build (linux || darwin || freebsd) && !nofuse
2 +
3 +package mount
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/hanwen/go-fuse/v2/fuse"
9 +)
10 +
11 +// TestDefaultBlksizeAnchor pins DefaultBlksize to 1 MiB so a silent
12 +// refactor cannot drift the value FUSE mounts advertise to tools.
13 +// See stat.go for the rationale (CID-deterministic profile alignment).
14 +func TestDefaultBlksizeAnchor(t *testing.T) {
15 + if DefaultBlksize != 1024*1024 {
16 + t.Fatalf("DefaultBlksize = %d, want 1 MiB (%d)", DefaultBlksize, 1024*1024)
17 + }
18 +}
19 +
20 +func TestBlksizeFromChunker(t *testing.T) {
21 + tests := []struct {
22 + name string
23 + chunker string
24 + want uint32
25 + }{
26 + // Kubo defaults and common user choices.
27 + {"default chunker", "size-262144", 262144},
28 + {"CID-deterministic profile", "size-1048576", 1024 * 1024},
29 + {"small custom", "size-65536", 65536},
30 +
31 + // Non-size chunkers: fall back to DefaultBlksize because no
32 + // single preferred I/O size describes their variable output.
33 + {"rabin", "rabin", DefaultBlksize},
34 + {"rabin with params", "rabin-512-1024-2048", DefaultBlksize},
35 + {"buzhash", "buzhash", DefaultBlksize},
36 +
37 + // Defensive: malformed or empty input must not panic or return
38 + // a surprising value.
39 + {"empty", "", DefaultBlksize},
40 + {"size prefix only", "size-", DefaultBlksize},
41 + {"non-numeric size", "size-abc", DefaultBlksize},
42 + {"zero size", "size-0", DefaultBlksize},
43 +
44 + // Clamp: values above fuse.MAX_KERNEL_WRITE (the largest single FUSE
45 + // request the kernel delivers) are capped so tools can't be
46 + // tricked into allocating buffers the kernel will just split.
47 + {"above cap clamped", "size-2097152", fuse.MAX_KERNEL_WRITE},
48 + {"16 MiB clamped", "size-16777216", fuse.MAX_KERNEL_WRITE},
49 + {"uint32 max clamped", "size-4294967295", fuse.MAX_KERNEL_WRITE},
50 + {"beyond uint32 clamped", "size-99999999999", fuse.MAX_KERNEL_WRITE},
51 + }
52 +
53 + for _, tc := range tests {
54 + t.Run(tc.name, func(t *testing.T) {
55 + if got := BlksizeFromChunker(tc.chunker); got != tc.want {
56 + t.Fatalf("BlksizeFromChunker(%q) = %d, want %d", tc.chunker, got, tc.want)
57 + }
58 + })
59 + }
60 +}
fuse/readonly/ipfs_test.go
+72
@@ -759,6 +759,78 @@ func TestReadCancellationUnblocks(t *testing.T) {
759 }
760 }
761
762 +// TestStatBlocks verifies that stat(2) on entries in /ipfs populates
763 +// st_blocks (used by du and ls -s) consistent with the file size, and
764 +// that st_blksize advertises the FUSE preferred I/O size.
765 +func TestStatBlocks(t *testing.T) {
766 + nd, mntDir := setupIpfsTest(t, nil)
767 +
768 + t.Run("multi-block file", func(t *testing.T) {
769 + // >1 MiB spans several chunks, so the DAG has multiple leaf links.
770 + fi, data := randObj(t, nd, 1024*1024+1)
771 + require.Greater(t, len(data), 1024*1024)
772 + fusetest.AssertStatBlocks(t,
773 + gopath.Join(mntDir, fi.Cid().String()),
774 + fusemnt.DefaultBlksize)
775 + })
776 +
777 + t.Run("small single-chunk file", func(t *testing.T) {
778 + // <512 B fits in a single UnixFS chunk with no child links;
779 + // st_blocks still rounds up to 1 so du reports at least 512 B.
780 + fi, _ := randObj(t, nd, 100)
781 + fusetest.AssertStatBlocks(t,
782 + gopath.Join(mntDir, fi.Cid().String()),
783 + fusemnt.DefaultBlksize)
784 + })
785 +
786 + t.Run("directory", func(t *testing.T) {
787 + // du sums child leaves, so the directory's own st_blocks is not
788 + // arithmetically meaningful. Report a nominal 1 block so tools
789 + // that treat 0 as "unsupported" behave correctly.
790 + child, _ := randObj(t, nd, 100)
791 +
792 + db, err := uio.NewDirectory(nd.DAG)
793 + require.NoError(t, err)
794 + require.NoError(t, db.AddChild(nd.Context(), "f", child))
795 + dirNode, err := db.GetNode()
796 + require.NoError(t, err)
797 + require.NoError(t, nd.DAG.Add(nd.Context(), dirNode))
798 +
799 + info, err := os.Stat(gopath.Join(mntDir, dirNode.Cid().String()))
800 + require.NoError(t, err)
801 + st, ok := info.Sys().(*syscall.Stat_t)
802 + require.True(t, ok)
803 + require.EqualValues(t, 1, st.Blocks, "directory should report 1 nominal block")
804 + })
805 +
806 + t.Run("symlink", func(t *testing.T) {
807 + // UnixFS TSymlink node: Size is the target path length, Blocks
808 + // rounds up to 1 so tools don't see a zero-block symlink.
809 + const target = "hello.txt"
810 +
811 + slData, err := ft.SymlinkData(target)
812 + require.NoError(t, err)
813 + symNode := dag.NodeWithData(slData)
814 + require.NoError(t, nd.DAG.Add(nd.Context(), symNode))
815 +
816 + db, err := uio.NewDirectory(nd.DAG)
817 + require.NoError(t, err)
818 + require.NoError(t, db.AddChild(nd.Context(), "link", symNode))
819 + dirNode, err := db.GetNode()
820 + require.NoError(t, err)
821 + require.NoError(t, nd.DAG.Add(nd.Context(), dirNode))
822 +
823 + linkPath := gopath.Join(mntDir, dirNode.Cid().String(), "link")
824 + info, err := os.Lstat(linkPath)
825 + require.NoError(t, err)
826 + st, ok := info.Sys().(*syscall.Stat_t)
827 + require.True(t, ok)
828 + require.EqualValues(t, len(target), st.Size)
829 + require.EqualValues(t, 1, st.Blocks)
830 + require.EqualValues(t, fusemnt.DefaultBlksize, st.Blksize)
831 + })
832 +}
833 +
834 // TestStatfs verifies that statfs on the /ipfs mount reports the disk
835 // space of the repo's backing filesystem. macOS Finder refuses to copy
836 // files onto a volume that reports zero free space.
fuse/readonly/readonly_unix.go
+14 -3
@@ -180,11 +180,17 @@ type roFileHandle struct {
180 // fillAttr populates a fuse.Attr from this node's UnixFS metadata.
181 // Used by both Getattr and Lookup (to fill EntryOut.Attr so the kernel
182 // doesn't cache zero values for the entry timeout duration).
183 +//
184 +// Blocks and Blksize are set on every entry because go-fuse's setBlocks
185 +// otherwise auto-fills them from Size with a 4 KiB page-based fallback,
186 +// which clobbers the UnixFS-derived values set below.
187 func (n *Node) fillAttr(a *fuse.Attr) {
188 + a.Blksize = fusemnt.DefaultBlksize
189 +
190 if rawnd, ok := n.nd.(*mdag.RawNode); ok {
191 a.Mode = uint32(fusemnt.DefaultFileModeRO.Perm())
192 a.Size = uint64(len(rawnd.RawData()))
187 - a.Blocks = 1
193 + a.Blocks = fusemnt.SizeToStatBlocks(a.Size)
194 return
195 }
196
@@ -198,17 +204,22 @@ func (n *Node) fillAttr(a *fuse.Attr) {
204 switch n.cached.Type() {
205 case ft.TDirectory, ft.THAMTShard:
206 a.Mode = uint32(fusemnt.DefaultDirModeRO.Perm())
207 + // Nominal 1 block: du sums child leaves, so the directory's
208 + // own st_blocks is not arithmetically meaningful, but some
209 + // tools treat 0 as "unsupported" and skip the entry.
210 + a.Blocks = 1
211 case ft.TFile:
212 a.Mode = uint32(fusemnt.DefaultFileModeRO.Perm())
213 a.Size = n.cached.FileSize()
204 - a.Blocks = uint64(len(n.nd.Links()))
214 + a.Blocks = fusemnt.SizeToStatBlocks(a.Size)
215 case ft.TRaw:
216 a.Mode = uint32(fusemnt.DefaultFileModeRO.Perm())
217 a.Size = uint64(len(n.cached.Data()))
208 - a.Blocks = uint64(len(n.nd.Links()))
218 + a.Blocks = fusemnt.SizeToStatBlocks(a.Size)
219 case ft.TSymlink:
220 a.Mode = uint32(fusemnt.SymlinkMode.Perm())
221 a.Size = uint64(len(n.cached.Data()))
222 + a.Blocks = fusemnt.SizeToStatBlocks(a.Size)
223 default:
224 log.Errorf("invalid data type: %s", n.cached.Type())
225 return
fuse/writable/writable.go
+24
@@ -41,6 +41,13 @@ type Config struct {
41 // MFS data. Without it tools like macOS Finder see zero free space
42 // and refuse to copy files.
43 RepoPath string
44 + // Blksize is the preferred I/O size advertised via st_blksize on
45 + // every stat. Callers should derive it from Import.UnixFSChunker via
46 + // fusemnt.BlksizeFromChunker so the hint matches the chunker MFS
47 + // will use for writes. If zero, NewDir writes fusemnt.DefaultBlksize
48 + // into this field in place, so fillAttr on every inode can read
49 + // cfg.Blksize without a nil-check on each stat.
50 + Blksize uint32
51 }
52
53 // NewDir creates a Dir node backed by the given MFS directory.
@@ -52,6 +59,12 @@ func NewDir(d *mfs.Directory, cfg *Config) *Dir {
59 if cfg == nil || cfg.DAG == nil {
60 panic("fuse/writable: Config.DAG is required")
61 }
62 + // Tests and callers that don't plumb Import.UnixFSChunker leave
63 + // Blksize zero; fall back to the FUSE default so stat advertises a
64 + // usable st_blksize. See Config.Blksize for why we mutate in place.
65 + if cfg.Blksize == 0 {
66 + cfg.Blksize = fusemnt.DefaultBlksize
67 + }
68 return &Dir{MFSDir: d, Cfg: cfg}
69 }
70
@@ -62,8 +75,15 @@ type Dir struct {
75 Cfg *Config
76 }
77
78 +// fillAttr fills stat attributes for a directory. Blocks and Blksize
79 +// are set explicitly because go-fuse's setBlocks otherwise auto-fills
80 +// them from Size with a 4 KiB page-based fallback. For directories
81 +// Size is 0, so the fallback yields st_blocks=0, which some tools
82 +// (dedup scanners, file managers) treat as "unsupported".
83 func (d *Dir) fillAttr(a *fuse.Attr) {
84 a.Mode = uint32(fusemnt.DefaultDirModeRW.Perm())
85 + a.Blocks = 1
86 + a.Blksize = d.Cfg.Blksize
87 if m, err := d.MFSDir.Mode(); err == nil && m != 0 {
88 a.Mode = files.ModePermsToUnixPerms(m)
89 }
@@ -380,6 +400,8 @@ type FileInode struct {
400 func (fi *FileInode) fillAttr(a *fuse.Attr) {
401 size, _ := fi.MFSFile.Size()
402 a.Size = uint64(size)
403 + a.Blocks = fusemnt.SizeToStatBlocks(a.Size)
404 + a.Blksize = fi.Cfg.Blksize
405 a.Mode = uint32(fusemnt.DefaultFileModeRW.Perm())
406 if m, err := fi.MFSFile.Mode(); err == nil && m != 0 {
407 a.Mode = files.ModePermsToUnixPerms(m)
@@ -676,6 +698,8 @@ func (s *Symlink) Readlink(_ context.Context) ([]byte, syscall.Errno) {
698 func (s *Symlink) fillAttr(a *fuse.Attr) {
699 a.Mode = uint32(fusemnt.SymlinkMode.Perm())
700 a.Size = uint64(len(s.Target))
701 + a.Blocks = fusemnt.SizeToStatBlocks(a.Size)
702 + a.Blksize = s.Cfg.Blksize
703 if s.MFSFile != nil {
704 if t, err := s.MFSFile.ModTime(); err == nil && !t.IsZero() {
705 a.SetTimes(nil, &t, nil)
fuse/writable/writable_test.go
+24
@@ -6,8 +6,32 @@ import (
6 "testing"
7
8 "github.com/hanwen/go-fuse/v2/fuse"
9 + dag "github.com/ipfs/boxo/ipld/merkledag"
10 + fusemnt "github.com/ipfs/kubo/fuse/mount"
11 )
12
13 +// TestNewDirNormalizesBlksize verifies that callers who don't plumb
14 +// Import.UnixFSChunker through (e.g. test-only mounts) get the FUSE
15 +// default so stat still advertises a usable st_blksize.
16 +func TestNewDirNormalizesBlksize(t *testing.T) {
17 + t.Run("zero falls back to DefaultBlksize", func(t *testing.T) {
18 + cfg := &Config{DAG: dag.NewDAGService(nil)}
19 + NewDir(nil, cfg)
20 + if cfg.Blksize != fusemnt.DefaultBlksize {
21 + t.Fatalf("Blksize = %d, want DefaultBlksize (%d)",
22 + cfg.Blksize, fusemnt.DefaultBlksize)
23 + }
24 + })
25 +
26 + t.Run("explicit value passes through", func(t *testing.T) {
27 + cfg := &Config{DAG: dag.NewDAGService(nil), Blksize: 65536}
28 + NewDir(nil, cfg)
29 + if cfg.Blksize != 65536 {
30 + t.Fatalf("Blksize = %d, want 65536", cfg.Blksize)
31 + }
32 + })
33 +}
34 +
35 // TestSymlinkSetattrChmodNoError verifies that Setattr on a symlink
36 // with only a mode change is silently accepted. POSIX symlinks have no
37 // meaningful permission bits (access control uses the target's mode),