| 1 | //go:build (linux || darwin || freebsd) && !nofuse |
| 2 | |
| 3 | // Unit tests for the /ipns FUSE mount. |
| 4 | // Generic writable operations are exercised by the shared suite in |
| 5 | // fusetest.RunWritableSuite. This file contains the mount factory |
| 6 | // and IPNS-specific tests only. |
| 7 | |
| 8 | package ipns |
| 9 | |
| 10 | import ( |
| 11 | "bytes" |
| 12 | "context" |
| 13 | "os" |
| 14 | "testing" |
| 15 | |
| 16 | "github.com/hanwen/go-fuse/v2/fs" |
| 17 | "github.com/hanwen/go-fuse/v2/fuse" |
| 18 | "github.com/stretchr/testify/require" |
| 19 | |
| 20 | "github.com/ipfs/kubo/config" |
| 21 | "github.com/ipfs/kubo/core" |
| 22 | coreapi "github.com/ipfs/kubo/core/coreapi" |
| 23 | iface "github.com/ipfs/kubo/core/coreiface" |
| 24 | "github.com/ipfs/kubo/fuse/fusetest" |
| 25 | fusemnt "github.com/ipfs/kubo/fuse/mount" |
| 26 | "github.com/ipfs/kubo/fuse/writable" |
| 27 | ) |
| 28 | |
| 29 | type mountWrap struct { |
| 30 | Dir string |
| 31 | Root *Root |
| 32 | server *fuse.Server |
| 33 | closed bool |
| 34 | } |
| 35 | |
| 36 | func (m *mountWrap) Close() { |
| 37 | if m.closed { |
| 38 | return |
| 39 | } |
| 40 | m.closed = true |
| 41 | if m.server != nil { |
| 42 | _ = m.server.Unmount() |
| 43 | } |
| 44 | _ = m.Root.Close() |
| 45 | } |
| 46 | |
| 47 | // fakeMount is a minimal mount.Mount that reports itself as active. |
| 48 | // This simulates the real daemon path where node.Mounts.Ipns is set |
| 49 | // after the FUSE filesystem is mounted, ensuring that checkPublishAllowed |
| 50 | // is actually exercised during tests (see issue #2168). |
| 51 | type fakeMount struct{} |
| 52 | |
| 53 | func (fakeMount) MountPoint() string { return "/fake/ipns" } |
| 54 | func (fakeMount) Unmount() error { return nil } |
| 55 | func (fakeMount) IsActive() bool { return true } |
| 56 | |
| 57 | func setupIpnsTest(t *testing.T, nd *core.IpfsNode, cfgs ...config.Mounts) (*core.IpfsNode, *mountWrap) { |
| 58 | t.Helper() |
| 59 | fusetest.SkipUnlessFUSE(t) |
| 60 | |
| 61 | var cfg config.Mounts |
| 62 | if len(cfgs) > 0 { |
| 63 | cfg = cfgs[0] |
| 64 | } |
| 65 | |
| 66 | var err error |
| 67 | if nd == nil { |
| 68 | nd, err = core.NewNode(context.Background(), &core.BuildCfg{}) |
| 69 | require.NoError(t, err) |
| 70 | |
| 71 | err = InitializeKeyspace(nd, nd.PrivateKey) |
| 72 | require.NoError(t, err) |
| 73 | } |
| 74 | |
| 75 | coreAPI, err := coreapi.NewCoreAPI(nd) |
| 76 | require.NoError(t, err) |
| 77 | |
| 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, config.Import{}) |
| 82 | require.NoError(t, err) |
| 83 | |
| 84 | mntDir := t.TempDir() |
| 85 | server, err := fs.Mount(mntDir, root, &fs.Options{ |
| 86 | NullPermissions: true, |
| 87 | UID: uint32(os.Getuid()), |
| 88 | GID: uint32(os.Getgid()), |
| 89 | EntryTimeout: &mutableCacheTime, |
| 90 | AttrTimeout: &mutableCacheTime, |
| 91 | MountOptions: fuse.MountOptions{ |
| 92 | FsName: "kubo-test", |
| 93 | MaxReadAhead: fusemnt.MaxReadAhead, |
| 94 | ExtraCapabilities: fusemnt.WritableMountCapabilities, |
| 95 | }, |
| 96 | }) |
| 97 | fusetest.MountError(t, err) |
| 98 | |
| 99 | mnt := &mountWrap{Dir: mntDir, Root: root, server: server} |
| 100 | t.Cleanup(mnt.Close) |
| 101 | |
| 102 | nd.Mounts.Ipns = fakeMount{} |
| 103 | return nd, mnt |
| 104 | } |
| 105 | |
| 106 | // newIpnsMount is the factory for the shared writable suite. It creates |
| 107 | // an IPNS mount and returns the writable /local directory path. |
| 108 | func newIpnsMount(t *testing.T, cfg writable.Config) string { |
| 109 | t.Helper() |
| 110 | mountsCfg := config.Mounts{} |
| 111 | if cfg.StoreMtime { |
| 112 | mountsCfg.StoreMtime = config.True |
| 113 | } |
| 114 | if cfg.StoreMode { |
| 115 | mountsCfg.StoreMode = config.True |
| 116 | } |
| 117 | _, mnt := setupIpnsTest(t, nil, mountsCfg) |
| 118 | return mnt.Dir + "/local" |
| 119 | } |
| 120 | |
| 121 | func TestWritableSuite(t *testing.T) { |
| 122 | fusetest.RunWritableSuite(t, newIpnsMount) |
| 123 | } |
| 124 | |
| 125 | // TestIpnsLocalLink verifies that /ipns/local is a symlink to the |
| 126 | // node's own peer ID directory. |
| 127 | func TestIpnsLocalLink(t *testing.T) { |
| 128 | nd, mnt := setupIpnsTest(t, nil) |
| 129 | |
| 130 | target, err := os.Readlink(mnt.Dir + "/local") |
| 131 | require.NoError(t, err) |
| 132 | require.Equal(t, nd.Identity.String(), target) |
| 133 | } |
| 134 | |
| 135 | // TestNamespaceRootMode verifies that the /ipns root has execute-only |
| 136 | // mode (not listable, only traversable). |
| 137 | func TestNamespaceRootMode(t *testing.T) { |
| 138 | _, mnt := setupIpnsTest(t, nil) |
| 139 | |
| 140 | info, err := os.Stat(mnt.Dir) |
| 141 | require.NoError(t, err) |
| 142 | require.Equal(t, os.FileMode(0o111), info.Mode().Perm()) |
| 143 | } |
| 144 | |
| 145 | // TestFilePersistence verifies that file data survives unmount and remount. |
| 146 | func TestFilePersistence(t *testing.T) { |
| 147 | nd, mnt := setupIpnsTest(t, nil) |
| 148 | |
| 149 | data := fusetest.RandBytes(4000) |
| 150 | require.NoError(t, os.WriteFile(mnt.Dir+"/local/persist", data, 0o644)) |
| 151 | mnt.Close() |
| 152 | |
| 153 | _, mnt = setupIpnsTest(t, nd) |
| 154 | got, err := os.ReadFile(mnt.Dir + "/local/persist") |
| 155 | require.NoError(t, err) |
| 156 | require.True(t, bytes.Equal(data, got)) |
| 157 | } |
| 158 | |
| 159 | // TestMultipleDirs verifies nested directories persist across remount. |
| 160 | func TestMultipleDirs(t *testing.T) { |
| 161 | nd, mnt := setupIpnsTest(t, nil) |
| 162 | |
| 163 | require.NoError(t, os.Mkdir(mnt.Dir+"/local/test1", 0o755)) |
| 164 | data1 := fusetest.WriteFileOrFail(t, 4000, mnt.Dir+"/local/test1/file1") |
| 165 | require.NoError(t, os.Mkdir(mnt.Dir+"/local/test1/dir2", 0o755)) |
| 166 | data2 := fusetest.WriteFileOrFail(t, 5000, mnt.Dir+"/local/test1/dir2/file2") |
| 167 | |
| 168 | mnt.Close() |
| 169 | _, mnt = setupIpnsTest(t, nd) |
| 170 | |
| 171 | fusetest.CheckExists(t, mnt.Dir+"/local/test1") |
| 172 | fusetest.VerifyFile(t, mnt.Dir+"/local/test1/file1", data1) |
| 173 | fusetest.VerifyFile(t, mnt.Dir+"/local/test1/dir2/file2", data2) |
| 174 | } |
| 175 | |
| 176 | // TestStatfs verifies that statfs on the /ipns mount reports the disk |
| 177 | // space of the repo's backing filesystem. macOS Finder refuses to copy |
| 178 | // files onto a volume that reports zero free space. |
| 179 | func TestStatfs(t *testing.T) { |
| 180 | _, mnt := setupIpnsTest(t, nil) |
| 181 | |
| 182 | // The in-memory test repo returns "" for Path(), so point RepoPath |
| 183 | // at a real directory to exercise the syscall path. |
| 184 | repoDir := t.TempDir() |
| 185 | mnt.Root.RepoPath = repoDir |
| 186 | |
| 187 | fusetest.AssertStatfsNonZero(t, mnt.Dir) |
| 188 | } |