master
go 502 lines 14.9 KB
Raw
1 //go:build (linux || darwin || freebsd) && !nofuse
2
3 // Package fuse contains end-to-end FUSE integration tests that exercise
4 // mount/unmount and filesystem operations through a real ipfs daemon.
5 //
6 // These tests complement the unit tests in fuse/readonly/, fuse/ipns/,
7 // and fuse/mfs/ which test the FUSE filesystem implementations directly
8 // (without a daemon) via fusetest.TestMount.
9 //
10 // All tests here are gated by testutils.RequiresFUSE (TEST_FUSE env var).
11 // CI runs them via `make test_fuse_cli` inside the fuse-tests job.
12 package fuse
13
14 import (
15 "bytes"
16 "crypto/rand"
17 "os"
18 "os/exec"
19 "path/filepath"
20 "runtime"
21 "sort"
22 "strings"
23 "syscall"
24 "testing"
25
26 "github.com/ipfs/kubo/config"
27 "github.com/ipfs/kubo/test/cli/harness"
28 "github.com/ipfs/kubo/test/cli/testutils"
29 "github.com/stretchr/testify/require"
30 )
31
32 func TestFUSE(t *testing.T) {
33 testutils.RequiresFUSE(t)
34 t.Parallel()
35
36 t.Run("mount and unmount work correctly", func(t *testing.T) {
37 t.Parallel()
38
39 node := harness.NewT(t).NewNode().Init()
40 node.StartDaemon()
41
42 ipfsMount, ipnsMount, mfsMount := mountAll(t, node)
43
44 // Test basic MFS functionality via FUSE mount
45 testFile := filepath.Join(mfsMount, "testfile")
46 testContent := "hello fuse world"
47
48 err := os.WriteFile(testFile, []byte(testContent), 0644)
49 require.NoError(t, err)
50
51 // Verify file appears in MFS via IPFS commands
52 result := node.IPFS("files", "ls", "/")
53 require.Contains(t, result.Stdout.String(), "testfile")
54
55 // Read content back via MFS FUSE mount
56 readContent, err := os.ReadFile(testFile)
57 require.NoError(t, err)
58 require.Equal(t, testContent, string(readContent))
59
60 // Get the CID of the MFS file
61 result = node.IPFS("files", "stat", "/testfile", "--format=<hash>")
62 fileCID := strings.TrimSpace(result.Stdout.String())
63 require.NotEmpty(t, fileCID, "should have a CID for the MFS file")
64
65 // Read the same content via IPFS FUSE mount using the CID
66 ipfsFile := filepath.Join(ipfsMount, fileCID)
67 ipfsContent, err := os.ReadFile(ipfsFile)
68 require.NoError(t, err)
69 require.Equal(t, testContent, string(ipfsContent), "content should match between MFS and IPFS mounts")
70
71 // Verify both FUSE mounts return identical data
72 require.Equal(t, readContent, ipfsContent, "MFS and IPFS FUSE mounts should return identical data")
73
74 // Test that mount directories cannot be removed while mounted
75 err = os.Remove(ipfsMount)
76 require.Error(t, err, "should not be able to remove mounted directory")
77
78 // Stop daemon, which should trigger automatic unmount
79 node.StopDaemon()
80
81 // Verify directories can now be removed (indicating successful unmount)
82 require.NoError(t, os.Remove(ipfsMount))
83 require.NoError(t, os.Remove(ipnsMount))
84 require.NoError(t, os.Remove(mfsMount))
85 })
86
87 t.Run("explicit unmount works", func(t *testing.T) {
88 t.Parallel()
89
90 node := harness.NewT(t).NewNode().Init()
91 node.StartDaemon()
92
93 ipfsMount, ipnsMount, mfsMount := mountAll(t, node)
94
95 doUnmount(t, ipfsMount, true)
96 doUnmount(t, ipnsMount, true)
97 doUnmount(t, mfsMount, true)
98
99 // Verify directories can be removed after explicit unmount
100 require.NoError(t, os.Remove(ipfsMount))
101 require.NoError(t, os.Remove(ipnsMount))
102 require.NoError(t, os.Remove(mfsMount))
103
104 node.StopDaemon()
105 })
106
107 t.Run("mount fails when dirs missing", func(t *testing.T) {
108 t.Parallel()
109
110 node := harness.NewT(t).NewNode().Init()
111 node.StartDaemon()
112
113 res := node.RunIPFS("mount", "-f=not_ipfs", "-n=not_ipns", "-m=not_mfs")
114 require.Error(t, res.Err)
115 require.Empty(t, res.Stdout.String())
116 stderr := res.Stderr.String()
117 require.True(t,
118 strings.Contains(stderr, "not_ipfs") ||
119 strings.Contains(stderr, "not_ipns") ||
120 strings.Contains(stderr, "not_mfs"),
121 "error should mention missing mount dir, got: %s", stderr)
122
123 node.StopDaemon()
124 })
125
126 t.Run("IPNS local symlink", func(t *testing.T) {
127 t.Parallel()
128
129 node := harness.NewT(t).NewNode().Init()
130 node.StartDaemon()
131
132 _, ipnsMount, _ := mountAll(t, node)
133
134 target, err := os.Readlink(filepath.Join(ipnsMount, "local"))
135 require.NoError(t, err)
136 require.Equal(t, node.PeerID().String(), filepath.Base(target))
137
138 node.StopDaemon()
139 })
140
141 t.Run("IPNS name resolution via NS map", func(t *testing.T) {
142 t.Parallel()
143
144 node := harness.NewT(t).NewNode().Init()
145
146 // Add content offline (before daemon starts)
147 expectedFile := filepath.Join(node.Dir, "expected")
148 require.NoError(t, os.WriteFile(expectedFile, []byte("ipfs"), 0644))
149 wrappedCID := node.IPFS("add", "--cid-version", "1", "-Q", "-w", expectedFile).Stdout.Trimmed()
150
151 // Set IPFS_NS_MAP so the daemon resolves welcome.example.com
152 node.Runner.Env["IPFS_NS_MAP"] = "welcome.example.com:/ipfs/" + wrappedCID
153
154 node.StartDaemon()
155 _, ipnsMount, _ := mountAll(t, node)
156
157 // Read the file through IPNS FUSE mount using the DNS name
158 content, err := os.ReadFile(filepath.Join(ipnsMount, "welcome.example.com", "expected"))
159 require.NoError(t, err)
160 require.Equal(t, "ipfs", string(content))
161
162 node.StopDaemon()
163 })
164
165 t.Run("MFS file and dir creation", func(t *testing.T) {
166 t.Parallel()
167
168 node := harness.NewT(t).NewNode().Init()
169 node.StartDaemon()
170
171 _, _, mfsMount := mountAll(t, node)
172
173 // Create file via FUSE
174 require.NoError(t, os.WriteFile(filepath.Join(mfsMount, "testfile"), []byte("content"), 0644))
175 result := node.IPFS("files", "ls", "/")
176 require.Contains(t, result.Stdout.String(), "testfile")
177
178 // Create dir via FUSE
179 require.NoError(t, os.Mkdir(filepath.Join(mfsMount, "testdir"), 0755))
180 result = node.IPFS("files", "ls", "/")
181 require.Contains(t, result.Stdout.String(), "testdir")
182
183 node.StopDaemon()
184 })
185
186 t.Run("MFS xattr", func(t *testing.T) {
187 t.Parallel()
188 if runtime.GOOS != "linux" {
189 t.Skip("xattr requires Linux")
190 }
191
192 node := harness.NewT(t).NewNode().Init()
193 node.StartDaemon()
194
195 _, _, mfsMount := mountAll(t, node)
196
197 testFile := filepath.Join(mfsMount, "testfile")
198 require.NoError(t, os.WriteFile(testFile, []byte("content"), 0644))
199
200 cid, err := getXattr(testFile, "ipfs.cid")
201 require.NoError(t, err)
202 require.NotEmpty(t, cid)
203
204 node.StopDaemon()
205 })
206
207 t.Run("files write then read via FUSE", func(t *testing.T) {
208 t.Parallel()
209
210 node := harness.NewT(t).NewNode().Init()
211 node.StartDaemon()
212
213 _, _, mfsMount := mountAll(t, node)
214
215 // Write via ipfs files write -e, read back via FUSE
216 node.PipeStrToIPFS("content3", "files", "write", "-e", "/testfile3")
217
218 got, err := os.ReadFile(filepath.Join(mfsMount, "testfile3"))
219 require.NoError(t, err)
220 require.Equal(t, "content3", string(got))
221
222 node.StopDaemon()
223 })
224
225 t.Run("add --to-files then read via FUSE", func(t *testing.T) {
226 t.Parallel()
227
228 node := harness.NewT(t).NewNode().Init()
229 node.StartDaemon()
230
231 _, _, mfsMount := mountAll(t, node)
232
233 // Create a temp file to add
234 tmpFile := filepath.Join(node.Dir, "testfile2")
235 require.NoError(t, os.WriteFile(tmpFile, []byte("content"), 0644))
236
237 node.IPFS("add", "--to-files", "/testfile2", tmpFile)
238
239 got, err := os.ReadFile(filepath.Join(mfsMount, "testfile2"))
240 require.NoError(t, err)
241 require.Equal(t, "content", string(got))
242
243 node.StopDaemon()
244 })
245
246 t.Run("file removal via FUSE", func(t *testing.T) {
247 t.Parallel()
248
249 node := harness.NewT(t).NewNode().Init()
250 node.StartDaemon()
251
252 _, _, mfsMount := mountAll(t, node)
253
254 testFile := filepath.Join(mfsMount, "testfile")
255 require.NoError(t, os.WriteFile(testFile, []byte("content"), 0644))
256
257 result := node.IPFS("files", "ls", "/")
258 require.Contains(t, result.Stdout.String(), "testfile")
259
260 require.NoError(t, os.Remove(testFile))
261
262 result = node.IPFS("files", "ls", "/")
263 require.NotContains(t, result.Stdout.String(), "testfile")
264
265 node.StopDaemon()
266 })
267
268 t.Run("nested dirs via FUSE", func(t *testing.T) {
269 t.Parallel()
270
271 node := harness.NewT(t).NewNode().Init()
272 node.StartDaemon()
273
274 _, _, mfsMount := mountAll(t, node)
275
276 nested := filepath.Join(mfsMount, "foo", "bar", "baz", "qux")
277 require.NoError(t, os.MkdirAll(nested, 0755))
278 require.NoError(t, os.WriteFile(filepath.Join(nested, "quux"), []byte("content"), 0644))
279
280 result := node.IPFS("files", "stat", "/foo/bar/baz/qux/quux")
281 require.NoError(t, result.Err)
282
283 node.StopDaemon()
284 })
285
286 t.Run("publish blocked while IPNS mounted", func(t *testing.T) {
287 t.Parallel()
288
289 node := harness.NewT(t).NewNode().Init()
290 node.StartDaemon()
291
292 // Add content and publish before mount
293 hash := node.PipeStrToIPFS("hello warld", "add", "-Q", "-w", "--stdin-name", "file").Stdout.Trimmed()
294 node.IPFS("name", "publish", hash)
295
296 // Mount all
297 _, ipnsMount, _ := mountAll(t, node)
298
299 // Publish should fail while IPNS is mounted
300 res := node.RunIPFS("name", "publish", hash)
301 require.Error(t, res.Err)
302 require.Contains(t, res.Stderr.String(), "cannot manually publish while IPNS is mounted")
303
304 // Unmount IPNS out-of-band
305 doUnmount(t, ipnsMount, true)
306
307 // Publish should work again
308 node.IPFS("name", "publish", hash)
309
310 node.StopDaemon()
311 })
312
313 // Exercises both ftruncate(fd, size) and truncate(path, size).
314 // ftruncate uses the open file handle in Setattr; truncate opens
315 // a temporary write descriptor. Both must leave the file with
316 // correct content visible via the FUSE mount and via ipfs files.
317 t.Run("truncation via FUSE", func(t *testing.T) {
318 t.Parallel()
319
320 node := harness.NewT(t).NewNode().Init()
321 node.StartDaemon()
322
323 _, _, mfsMount := mountAll(t, node)
324
325 original := make([]byte, 2000)
326 _, err := rand.Read(original)
327 require.NoError(t, err)
328
329 path := filepath.Join(mfsMount, "trunctest")
330 require.NoError(t, os.WriteFile(path, original, 0644))
331
332 // ftruncate(fd, 500): open, truncate via fd, close.
333 t.Run("ftruncate via fd", func(t *testing.T) {
334 f, err := os.OpenFile(path, os.O_WRONLY, 0644)
335 require.NoError(t, err)
336 require.NoError(t, f.Truncate(500))
337 require.NoError(t, f.Close())
338
339 info, err := os.Stat(path)
340 require.NoError(t, err)
341 require.Equal(t, int64(500), info.Size())
342
343 got, err := os.ReadFile(path)
344 require.NoError(t, err)
345 require.True(t, bytes.Equal(original[:500], got),
346 "ftruncated content should match first 500 bytes of original")
347
348 // Verify via ipfs files stat
349 stat := node.IPFS("files", "stat", "/trunctest", "--format=<size>")
350 require.Equal(t, "500", strings.TrimSpace(stat.Stdout.String()))
351 })
352
353 // truncate(path, 200): no open fd, Setattr opens a temporary
354 // write descriptor.
355 t.Run("truncate via path", func(t *testing.T) {
356 require.NoError(t, syscall.Truncate(path, 200))
357
358 info, err := os.Stat(path)
359 require.NoError(t, err)
360 require.Equal(t, int64(200), info.Size())
361
362 got, err := os.ReadFile(path)
363 require.NoError(t, err)
364 require.True(t, bytes.Equal(original[:200], got),
365 "path-truncated content should match first 200 bytes of original")
366
367 stat := node.IPFS("files", "stat", "/trunctest", "--format=<size>")
368 require.Equal(t, "200", strings.TrimSpace(stat.Stdout.String()))
369 })
370
371 // Truncate to zero and rewrite: the common open(O_TRUNC) pattern.
372 t.Run("truncate to zero and rewrite", func(t *testing.T) {
373 newContent := []byte("brand new content")
374 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0644)
375 require.NoError(t, err)
376 _, err = f.Write(newContent)
377 require.NoError(t, err)
378 require.NoError(t, f.Close())
379
380 got, err := os.ReadFile(path)
381 require.NoError(t, err)
382 require.Equal(t, newContent, got)
383 })
384
385 node.StopDaemon()
386 })
387
388 t.Run("sharded directory read via FUSE", func(t *testing.T) {
389 t.Parallel()
390
391 node := harness.NewT(t).NewNode().Init()
392
393 // Force sharding with 1B threshold
394 node.UpdateConfig(func(cfg *config.Config) {
395 cfg.Import.UnixFSHAMTDirectorySizeThreshold = *config.NewOptionalBytes("1B")
396 })
397
398 node.StartDaemon()
399 ipfsMount, _, _ := mountAll(t, node)
400
401 // Create test data directory
402 testdataDir := filepath.Join(node.Dir, "testdata")
403 require.NoError(t, os.MkdirAll(filepath.Join(testdataDir, "subdir"), 0755))
404 require.NoError(t, os.WriteFile(filepath.Join(testdataDir, "a"), []byte("a\n"), 0644))
405 require.NoError(t, os.WriteFile(filepath.Join(testdataDir, "subdir", "b"), []byte("b\n"), 0644))
406
407 // Add sharded directory
408 hash := node.IPFS("add", "-r", "-Q", testdataDir).Stdout.Trimmed()
409
410 // Read files via FUSE /ipfs mount
411 contentA, err := os.ReadFile(filepath.Join(ipfsMount, hash, "a"))
412 require.NoError(t, err)
413 require.Equal(t, "a\n", string(contentA))
414
415 contentB, err := os.ReadFile(filepath.Join(ipfsMount, hash, "subdir", "b"))
416 require.NoError(t, err)
417 require.Equal(t, "b\n", string(contentB))
418
419 // List directories via FUSE
420 entries, err := os.ReadDir(filepath.Join(ipfsMount, hash))
421 require.NoError(t, err)
422 names := make([]string, len(entries))
423 for i, e := range entries {
424 names[i] = e.Name()
425 }
426 sort.Strings(names)
427 require.Equal(t, []string{"a", "subdir"}, names)
428
429 subEntries, err := os.ReadDir(filepath.Join(ipfsMount, hash, "subdir"))
430 require.NoError(t, err)
431 require.Len(t, subEntries, 1)
432 require.Equal(t, "b", subEntries[0].Name())
433
434 node.StopDaemon()
435 })
436 }
437
438 // mountAll creates mount directories and mounts IPFS, IPNS, and MFS.
439 func mountAll(t *testing.T, node *harness.Node) (ipfsMount, ipnsMount, mfsMount string) {
440 t.Helper()
441 ipfsMount = filepath.Join(node.Dir, "ipfs")
442 ipnsMount = filepath.Join(node.Dir, "ipns")
443 mfsMount = filepath.Join(node.Dir, "mfs")
444
445 require.NoError(t, os.MkdirAll(ipfsMount, 0755))
446 require.NoError(t, os.MkdirAll(ipnsMount, 0755))
447 require.NoError(t, os.MkdirAll(mfsMount, 0755))
448
449 // Lazy-unmount any stale mounts from a previous crashed run so
450 // the mountpoint is free. Non-fatal: the dir may not be mounted.
451 lazyUnmount(ipfsMount)
452 lazyUnmount(ipnsMount)
453 lazyUnmount(mfsMount)
454
455 result := node.IPFS("mount", "-f", ipfsMount, "-n", ipnsMount, "-m", mfsMount)
456
457 // Extra space after "MFS" matches the column-aligned output produced
458 // by MountCmd in core/commands/mount_unix.go.
459 expectedOutput := "IPFS mounted at: " + ipfsMount + "\n" +
460 "IPNS mounted at: " + ipnsMount + "\n" +
461 "MFS mounted at: " + mfsMount + "\n"
462 require.Equal(t, expectedOutput, result.Stdout.String())
463
464 return
465 }
466
467 // doUnmount performs platform-specific unmount, similar to sharness do_umount.
468 // If failOnError is true, unmount errors cause test failure; otherwise errors are ignored.
469 func doUnmount(t *testing.T, mountPoint string, failOnError bool) {
470 t.Helper()
471 var cmd *exec.Cmd
472 switch runtime.GOOS {
473 case "linux":
474 if _, err := exec.LookPath("fusermount3"); err == nil {
475 cmd = exec.Command("fusermount3", "-u", mountPoint)
476 } else {
477 cmd = exec.Command("fusermount", "-u", mountPoint)
478 }
479 default:
480 cmd = exec.Command("umount", mountPoint)
481 }
482
483 err := cmd.Run()
484 if err != nil && failOnError {
485 t.Fatalf("failed to unmount %s: %v", mountPoint, err)
486 }
487 }
488
489 // lazyUnmount detaches a mount point without waiting for open files
490 // to close. Used to clean up stale mounts from crashed test runs.
491 func lazyUnmount(mountPoint string) {
492 switch runtime.GOOS {
493 case "linux":
494 if _, err := exec.LookPath("fusermount3"); err == nil {
495 _ = exec.Command("fusermount3", "-uz", mountPoint).Run()
496 } else {
497 _ = exec.Command("fusermount", "-uz", mountPoint).Run()
498 }
499 default:
500 _ = exec.Command("umount", "-l", mountPoint).Run()
501 }
502 }