Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelogs/v0.41.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ The FUSE implementation has been rewritten on top of [`hanwen/go-fuse` v2](https
- **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`).
- **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).
- **`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.
- **`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".
- **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.

#### 🐹 Go 1.26, Once More with Feeling
Expand Down
24 changes: 23 additions & 1 deletion fuse/ipns/ipns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"bytes"
"context"
"os"
"syscall"
"testing"

"github.com/hanwen/go-fuse/v2/fs"
Expand Down Expand Up @@ -78,7 +79,7 @@ func setupIpnsTest(t *testing.T, nd *core.IpfsNode, cfgs ...config.Mounts) (*cor
key, err := coreAPI.Key().Self(nd.Context())
require.NoError(t, err)

root, err := CreateRoot(nd.Context(), coreAPI, map[string]iface.Key{"local": key}, "", "", cfg)
root, err := CreateRoot(nd.Context(), coreAPI, map[string]iface.Key{"local": key}, "", "", nd.Repo.Path(), cfg)
require.NoError(t, err)

mntDir := t.TempDir()
Expand Down Expand Up @@ -172,3 +173,24 @@ func TestMultipleDirs(t *testing.T) {
fusetest.VerifyFile(t, mnt.Dir+"/local/test1/file1", data1)
fusetest.VerifyFile(t, mnt.Dir+"/local/test1/dir2/file2", data2)
}

// TestStatfs verifies that statfs on the /ipns mount reports the disk
// space of the repo's backing filesystem. macOS Finder refuses to copy
// files onto a volume that reports zero free space.
func TestStatfs(t *testing.T) {
_, mnt := setupIpnsTest(t, nil)

// The in-memory test repo returns "" for Path(), so point RepoPath
// at a real directory to exercise the syscall path.
repoDir := t.TempDir()
mnt.Root.RepoPath = repoDir

var got syscall.Statfs_t
require.NoError(t, syscall.Statfs(mnt.Dir, &got))

var want syscall.Statfs_t
require.NoError(t, syscall.Statfs(repoDir, &want))

require.Equal(t, want.Blocks, got.Blocks, "total blocks should match the repo filesystem")
require.Equal(t, want.Bfree, got.Bfree, "free blocks should match the repo filesystem")
}
21 changes: 20 additions & 1 deletion fuse/ipns/ipns_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Root struct {
Roots map[string]*mfs.Root

LocalLinks map[string]*Link
RepoPath string
}

func ipnsPubFunc(ipfs iface.CoreAPI, key iface.Key) mfs.PubFunc {
Expand Down Expand Up @@ -81,11 +82,12 @@ func loadRoot(ctx context.Context, ipfs iface.CoreAPI, key iface.Key, cfg *writa
}

// CreateRoot creates the IPNS FUSE root with one writable directory per key.
func CreateRoot(ctx context.Context, ipfs iface.CoreAPI, keys map[string]iface.Key, ipfspath, ipnspath string, mountsCfg config.Mounts, mfsOpts ...mfs.Option) (*Root, error) {
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) {
cfg := &writable.Config{
StoreMtime: mountsCfg.StoreMtime.WithDefault(config.DefaultStoreMtime),
StoreMode: mountsCfg.StoreMode.WithDefault(config.DefaultStoreMode),
DAG: ipfs.Dag(),
RepoPath: repoPath,
}

ldirs := make(map[string]*writable.Dir)
Expand All @@ -111,6 +113,7 @@ func CreateRoot(ctx context.Context, ipfs iface.CoreAPI, keys map[string]iface.K
LocalDirs: ldirs,
LocalLinks: links,
Roots: roots,
RepoPath: repoPath,
}, nil
}

Expand All @@ -120,6 +123,21 @@ func (r *Root) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) sy
return 0
}

// Statfs reports disk-space statistics for the underlying filesystem.
// macOS Finder checks free space before copying; without this it
// reports "not enough free space" because go-fuse returns zeroed stats.
func (r *Root) Statfs(_ context.Context, out *fuse.StatfsOut) syscall.Errno {
if r.RepoPath == "" {
return 0
}
var s syscall.Statfs_t
if err := syscall.Statfs(r.RepoPath, &s); err != nil {
return fs.ToErrno(err)
}
out.FromStatfsT(&s)
return 0
}

func (r *Root) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
switch name {
case "mach_kernel", ".hidden", "._.":
Expand Down Expand Up @@ -174,4 +192,5 @@ var (
_ fs.NodeGetattrer = (*Root)(nil)
_ fs.NodeLookuper = (*Root)(nil)
_ fs.NodeReaddirer = (*Root)(nil)
_ fs.NodeStatfser = (*Root)(nil)
)
2 changes: 1 addition & 1 deletion fuse/ipns/mount_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func Mount(ipfs *core.IpfsNode, ipnsmp, ipfsmp string) (fusemnt.Mount, error) {
return nil, err
}

root, err := CreateRoot(ipfs.Context(), coreAPI, map[string]iface.Key{"local": key}, ipfsmp, ipnsmp, cfg.Mounts, mfsOpts...)
root, err := CreateRoot(ipfs.Context(), coreAPI, map[string]iface.Key{"local": key}, ipfsmp, ipnsmp, ipfs.Repo.Path(), cfg.Mounts, mfsOpts...)
if err != nil {
return nil, err
}
Expand Down
27 changes: 27 additions & 0 deletions fuse/mfs/mfs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"context"
"crypto/rand"
"os"
"syscall"
"testing"

"github.com/hanwen/go-fuse/v2/fs"
Expand Down Expand Up @@ -87,3 +88,29 @@ func TestPersistence(t *testing.T) {
require.True(t, bytes.Equal(content, got))
})
}

// TestStatfs verifies that statfs on the /mfs mount reports the disk
// space of the repo's backing filesystem. macOS Finder refuses to copy
// files onto a volume that reports zero free space.
func TestStatfs(t *testing.T) {
ipfs, err := core.NewNode(t.Context(), &node.BuildCfg{})
require.NoError(t, err)

// The default in-memory repo returns "" for Path(), so point
// RepoPath at a real directory to exercise the syscall path.
repoDir := t.TempDir()
root := writable.NewDir(ipfs.FilesRoot.GetDirectory(), &writable.Config{
DAG: ipfs.DAG,
RepoPath: repoDir,
})
mntDir := testMount(t, root)

var got syscall.Statfs_t
require.NoError(t, syscall.Statfs(mntDir, &got))

var want syscall.Statfs_t
require.NoError(t, syscall.Statfs(repoDir, &want))

require.Equal(t, want.Blocks, got.Blocks, "total blocks should match the repo filesystem")
require.Equal(t, want.Bfree, got.Bfree, "free blocks should match the repo filesystem")
}
1 change: 1 addition & 0 deletions fuse/mfs/mfs_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ func NewFileSystem(ipfs *core.IpfsNode, cfg config.Mounts) *writable.Dir {
StoreMtime: cfg.StoreMtime.WithDefault(config.DefaultStoreMtime),
StoreMode: cfg.StoreMode.WithDefault(config.DefaultStoreMode),
DAG: ipfs.DAG,
RepoPath: ipfs.Repo.Path(),
})
}
23 changes: 23 additions & 0 deletions fuse/readonly/ipfs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,29 @@ func TestReadCancellationUnblocks(t *testing.T) {
}
}

// TestStatfs verifies that statfs on the /ipfs mount reports the disk
// space of the repo's backing filesystem. macOS Finder refuses to copy
// files onto a volume that reports zero free space.
func TestStatfs(t *testing.T) {
nd, err := coremock.NewMockNode()
require.NoError(t, err)

// Point repoPath at a real directory so Statfs has a valid target.
// (NewMockNode's in-memory repo returns "" for Path().)
repoDir := t.TempDir()
root := &Root{ipfs: nd, repoPath: repoDir}
mntDir := testMount(t, root)

var got syscall.Statfs_t
require.NoError(t, syscall.Statfs(mntDir, &got))

var want syscall.Statfs_t
require.NoError(t, syscall.Statfs(repoDir, &want))

require.Equal(t, want.Blocks, got.Blocks, "total blocks should match the repo filesystem")
require.Equal(t, want.Bfree, got.Bfree, "free blocks should match the repo filesystem")
}

// Test that getxattr on an unknown attribute returns ENODATA (Linux) / ENOATTR.
func TestUnknownXattr(t *testing.T) {
nd, _ := setupIpfsTest(t, nil)
Expand Down
21 changes: 19 additions & 2 deletions fuse/readonly/readonly_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,28 @@ var immutableAttrCacheTime = 365 * 24 * time.Hour
// Root is the root object of the /ipfs filesystem tree.
type Root struct {
fs.Inode
ipfs *core.IpfsNode
ipfs *core.IpfsNode
repoPath string
}

// NewRoot constructs a new readonly root node.
func NewRoot(ipfs *core.IpfsNode) *Root {
return &Root{ipfs: ipfs}
return &Root{ipfs: ipfs, repoPath: ipfs.Repo.Path()}
}

// Statfs reports disk-space statistics for the underlying filesystem.
// macOS Finder checks free space before copying; without this it
// reports "not enough free space" because go-fuse returns zeroed stats.
func (r *Root) Statfs(_ context.Context, out *fuse.StatfsOut) syscall.Errno {
if r.repoPath == "" {
return 0
}
var s syscall.Statfs_t
if err := syscall.Statfs(r.repoPath, &s); err != nil {
return fs.ToErrno(err)
}
out.FromStatfsT(&s)
return 0
}

func (*Root) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
Expand Down Expand Up @@ -369,6 +385,7 @@ var (
_ fs.NodeGetattrer = (*Root)(nil)
_ fs.NodeLookuper = (*Root)(nil)
_ fs.NodeReaddirer = (*Root)(nil)
_ fs.NodeStatfser = (*Root)(nil)
_ fs.NodeGetattrer = (*Node)(nil)
_ fs.NodeLookuper = (*Node)(nil)
_ fs.NodeOpener = (*Node)(nil)
Expand Down
22 changes: 22 additions & 0 deletions fuse/writable/writable.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ type Config struct {
StoreMtime bool // persist mtime on create and open-for-write
StoreMode bool // persist mode on chmod
DAG ipld.DAGService // required: read-only opens use it to bypass MFS desclock
// RepoPath is the on-disk path of the IPFS repo (e.g. ~/.ipfs).
// Statfs calls syscall.Statfs on this path so that the FUSE mount
// reports how much free space is left on the volume that stores
// MFS data. Without it tools like macOS Finder see zero free space
// and refuse to copy files.
RepoPath string
}

// NewDir creates a Dir node backed by the given MFS directory.
Expand Down Expand Up @@ -71,6 +77,21 @@ func (d *Dir) Getattr(_ context.Context, _ fs.FileHandle, out *fuse.AttrOut) sys
return 0
}

// Statfs reports disk-space statistics for the underlying filesystem.
// macOS Finder checks free space before copying; without this it
// reports "not enough free space" because go-fuse returns zeroed stats.
func (d *Dir) Statfs(_ context.Context, out *fuse.StatfsOut) syscall.Errno {
if d.Cfg.RepoPath == "" {
return 0
}
var s syscall.Statfs_t
if err := syscall.Statfs(d.Cfg.RepoPath, &s); err != nil {
return fs.ToErrno(err)
}
out.FromStatfsT(&s)
return 0
}

// Setattr handles chmod and mtime changes on directories.
// Tools like tar and rsync set directory timestamps after extraction.
//
Expand Down Expand Up @@ -737,6 +758,7 @@ func SymlinkTarget(f *mfs.File) string {
// Interface compliance checks.
var (
_ fs.NodeGetattrer = (*Dir)(nil)
_ fs.NodeStatfser = (*Dir)(nil)
_ fs.NodeSetattrer = (*Dir)(nil)
_ fs.NodeLookuper = (*Dir)(nil)
_ fs.NodeReaddirer = (*Dir)(nil)
Expand Down
37 changes: 37 additions & 0 deletions fuse/writable/writable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package writable

import (
"syscall"
"testing"

"github.com/hanwen/go-fuse/v2/fuse"
Expand Down Expand Up @@ -43,3 +44,39 @@ func TestSymlinkSetattrChmodNoError(t *testing.T) {
t.Fatalf("Symlink mode = 0o%o, want 0o777", got)
}
}

// TestStatfsReportsSpace verifies that Dir.Statfs proxies the
// disk-space statistics of the repo's backing filesystem, and that an
// empty RepoPath produces zeroed (but successful) results.
func TestStatfsReportsSpace(t *testing.T) {
t.Run("matches repo filesystem", func(t *testing.T) {
dir := t.TempDir()
d := &Dir{Cfg: &Config{RepoPath: dir}}
out := &fuse.StatfsOut{}
if errno := d.Statfs(t.Context(), out); errno != 0 {
t.Fatalf("Statfs returned errno %v, want 0", errno)
}

var want syscall.Statfs_t
if err := syscall.Statfs(dir, &want); err != nil {
t.Fatal(err)
}
if out.Blocks != want.Blocks {
t.Fatalf("Blocks = %d, want %d (from repo path)", out.Blocks, want.Blocks)
}
if out.Bfree != want.Bfree {
t.Fatalf("Bfree = %d, want %d (from repo path)", out.Bfree, want.Bfree)
}
})

t.Run("empty repo path", func(t *testing.T) {
d := &Dir{Cfg: &Config{}}
out := &fuse.StatfsOut{}
if errno := d.Statfs(t.Context(), out); errno != 0 {
t.Fatalf("Statfs returned errno %v, want 0", errno)
}
if out.Blocks != 0 {
t.Fatalf("expected zeroed Blocks when RepoPath is empty, got %d", out.Blocks)
}
})
}
Loading