master
go 862 lines 22.5 KB
Raw
1 //go:build (linux || darwin || freebsd) && !nofuse
2
3 // Unit tests for the read-only /ipfs FUSE mount.
4 // These test the filesystem implementation directly without a daemon.
5 // End-to-end tests that exercise mount/unmount through a real daemon
6 // live in test/cli/fuse/.
7
8 package readonly
9
10 import (
11 "bytes"
12 "context"
13 "errors"
14 "fmt"
15 "io"
16 "math/rand"
17 "os"
18 gopath "path"
19 "strings"
20 "sync"
21 "syscall"
22 "testing"
23 "time"
24
25 "github.com/hanwen/go-fuse/v2/fs"
26 "github.com/hanwen/go-fuse/v2/fuse"
27
28 core "github.com/ipfs/kubo/core"
29 coreapi "github.com/ipfs/kubo/core/coreapi"
30 coremock "github.com/ipfs/kubo/core/mock"
31
32 chunker "github.com/ipfs/boxo/chunker"
33 "github.com/ipfs/boxo/files"
34 dag "github.com/ipfs/boxo/ipld/merkledag"
35 ft "github.com/ipfs/boxo/ipld/unixfs"
36 importer "github.com/ipfs/boxo/ipld/unixfs/importer"
37 uio "github.com/ipfs/boxo/ipld/unixfs/io"
38 "github.com/ipfs/boxo/path"
39 ipld "github.com/ipfs/go-ipld-format"
40 "github.com/ipfs/go-test/random"
41 options "github.com/ipfs/kubo/core/coreiface/options"
42 "github.com/ipfs/kubo/fuse/fusetest"
43 fusemnt "github.com/ipfs/kubo/fuse/mount"
44 "github.com/stretchr/testify/require"
45 )
46
47 func testMount(t *testing.T, root fs.InodeEmbedder) string {
48 t.Helper()
49 return fusetest.TestMount(t, root, &fs.Options{
50 AttrTimeout: &immutableAttrCacheTime,
51 EntryTimeout: &immutableAttrCacheTime,
52 MountOptions: fuse.MountOptions{
53 MaxReadAhead: fusemnt.MaxReadAhead,
54 },
55 })
56 }
57
58 func randObj(t *testing.T, nd *core.IpfsNode, size int64) (ipld.Node, []byte) {
59 buf := make([]byte, size)
60 _, err := io.ReadFull(random.NewRand(), buf)
61 if err != nil {
62 t.Fatal(err)
63 }
64 read := bytes.NewReader(buf)
65 obj, err := importer.BuildTrickleDagFromReader(nd.DAG, chunker.DefaultSplitter(read))
66 if err != nil {
67 t.Fatal(err)
68 }
69
70 return obj, buf
71 }
72
73 func setupIpfsTest(t *testing.T, node *core.IpfsNode) (*core.IpfsNode, string) {
74 t.Helper()
75
76 var err error
77 if node == nil {
78 node, err = coremock.NewMockNode()
79 if err != nil {
80 t.Fatal(err)
81 }
82 }
83
84 root := NewRoot(node)
85 mntDir := testMount(t, root)
86
87 return node, mntDir
88 }
89
90 // Test that an empty directory can be listed without errors.
91 func TestEmptyDirListing(t *testing.T) {
92 nd, mntDir := setupIpfsTest(t, nil)
93
94 // Create an empty UnixFS directory and add it to the DAG.
95 db, err := uio.NewDirectory(nd.DAG)
96 if err != nil {
97 t.Fatal(err)
98 }
99 emptyDir, err := db.GetNode()
100 if err != nil {
101 t.Fatal(err)
102 }
103 if err := nd.DAG.Add(nd.Context(), emptyDir); err != nil {
104 t.Fatal(err)
105 }
106
107 // List it via FUSE.
108 dirPath := gopath.Join(mntDir, emptyDir.Cid().String())
109 entries, err := os.ReadDir(dirPath)
110 if err != nil {
111 t.Fatal(err)
112 }
113 if len(entries) != 0 {
114 t.Fatalf("expected empty directory, got %d entries", len(entries))
115 }
116 }
117
118 // Test that a bare file CID can be read at the /ipfs mount root.
119 func TestBareFileCID(t *testing.T) {
120 nd, mntDir := setupIpfsTest(t, nil)
121
122 api, err := coreapi.NewCoreAPI(nd)
123 if err != nil {
124 t.Fatal(err)
125 }
126
127 content := []byte("bare file CID test content")
128
129 t.Run("CIDv0", func(t *testing.T) {
130 resolved, err := api.Unixfs().Add(t.Context(),
131 files.NewBytesFile(content),
132 options.Unixfs.CidVersion(0),
133 options.Unixfs.RawLeaves(false))
134 if err != nil {
135 t.Fatal(err)
136 }
137 cidStr := resolved.RootCid().String()
138 got, err := os.ReadFile(gopath.Join(mntDir, cidStr))
139 if err != nil {
140 t.Fatalf("read %s via FUSE: %v", cidStr, err)
141 }
142 if !bytes.Equal(got, content) {
143 t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(content))
144 }
145 })
146
147 t.Run("CIDv1", func(t *testing.T) {
148 resolved, err := api.Unixfs().Add(t.Context(),
149 files.NewBytesFile(content),
150 options.Unixfs.CidVersion(1),
151 options.Unixfs.RawLeaves(true))
152 if err != nil {
153 t.Fatal(err)
154 }
155 cidStr := resolved.RootCid().String()
156 got, err := os.ReadFile(gopath.Join(mntDir, cidStr))
157 if err != nil {
158 t.Fatalf("read %s via FUSE: %v", cidStr, err)
159 }
160 if !bytes.Equal(got, content) {
161 t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(content))
162 }
163 })
164 }
165
166 // Test reading a directory that contains both dag-pb and raw-leaf children.
167 // This is the typical layout produced by `ipfs add --raw-leaves`: the
168 // directory node is dag-pb, while file leaves are raw blocks.
169 func TestMixedDAGDirectory(t *testing.T) {
170 nd, mntDir := setupIpfsTest(t, nil)
171
172 api, err := coreapi.NewCoreAPI(nd)
173 if err != nil {
174 t.Fatal(err)
175 }
176
177 fileA := []byte("file in dag-pb leaf")
178 fileB := []byte("file in raw leaf")
179
180 dir := files.NewMapDirectory(map[string]files.Node{
181 "dagpb.txt": files.NewBytesFile(fileA),
182 "raw.txt": files.NewBytesFile(fileB),
183 })
184
185 // CIDv1 with raw leaves: directory is dag-pb, file leaves are raw.
186 resolved, err := api.Unixfs().Add(t.Context(), dir,
187 options.Unixfs.CidVersion(1),
188 options.Unixfs.RawLeaves(true))
189 if err != nil {
190 t.Fatal(err)
191 }
192
193 dirPath := gopath.Join(mntDir, resolved.RootCid().String())
194
195 entries, err := os.ReadDir(dirPath)
196 if err != nil {
197 t.Fatal(err)
198 }
199 if len(entries) != 2 {
200 t.Fatalf("expected 2 entries, got %d", len(entries))
201 }
202
203 for _, tc := range []struct {
204 name string
205 want []byte
206 }{
207 {"dagpb.txt", fileA},
208 {"raw.txt", fileB},
209 } {
210 got, err := os.ReadFile(gopath.Join(dirPath, tc.name))
211 if err != nil {
212 t.Fatalf("read %s: %v", tc.name, err)
213 }
214 if !bytes.Equal(got, tc.want) {
215 t.Fatalf("%s: content mismatch: got %d bytes, want %d", tc.name, len(got), len(tc.want))
216 }
217 }
218 }
219
220 // Test writing an object and reading it back through fuse.
221 func TestIpfsBasicRead(t *testing.T) {
222 nd, mntDir := setupIpfsTest(t, nil)
223
224 fi, data := randObj(t, nd, 10000)
225 k := fi.Cid()
226 fname := gopath.Join(mntDir, k.String())
227 rbuf, err := os.ReadFile(fname)
228 if err != nil {
229 t.Fatal(err)
230 }
231
232 if !bytes.Equal(rbuf, data) {
233 t.Fatal("Incorrect Read!")
234 }
235 }
236
237 func getPaths(t *testing.T, ipfs *core.IpfsNode, name string, n *dag.ProtoNode) []string {
238 if len(n.Links()) == 0 {
239 return []string{name}
240 }
241 var out []string
242 for _, lnk := range n.Links() {
243 child, err := lnk.GetNode(ipfs.Context(), ipfs.DAG)
244 if err != nil {
245 t.Fatal(err)
246 }
247
248 childpb, ok := child.(*dag.ProtoNode)
249 if !ok {
250 t.Fatal(dag.ErrNotProtobuf)
251 }
252
253 sub := getPaths(t, ipfs, gopath.Join(name, lnk.Name), childpb)
254 out = append(out, sub...)
255 }
256 return out
257 }
258
259 // Perform a large number of concurrent reads to stress the system.
260 func TestIpfsStressRead(t *testing.T) {
261 nd, mntDir := setupIpfsTest(t, nil)
262
263 api, err := coreapi.NewCoreAPI(nd)
264 if err != nil {
265 t.Fatal(err)
266 }
267
268 var nodes []ipld.Node
269 var paths []string
270
271 nobj := 50
272 ndiriter := 50
273
274 // Make a bunch of objects
275 for range nobj {
276 fi, _ := randObj(t, nd, rand.Int63n(50000))
277 nodes = append(nodes, fi)
278 paths = append(paths, fi.Cid().String())
279 }
280
281 // Now make a bunch of dirs
282 for range ndiriter {
283 db, err := uio.NewDirectory(nd.DAG)
284 if err != nil {
285 t.Fatal(err)
286 }
287 for j := 0; j < 1+rand.Intn(10); j++ {
288 name := fmt.Sprintf("child%d", j)
289
290 err := db.AddChild(nd.Context(), name, nodes[rand.Intn(len(nodes))])
291 if err != nil {
292 t.Fatal(err)
293 }
294 }
295 newdir, err := db.GetNode()
296 if err != nil {
297 t.Fatal(err)
298 }
299
300 err = nd.DAG.Add(nd.Context(), newdir)
301 if err != nil {
302 t.Fatal(err)
303 }
304
305 nodes = append(nodes, newdir)
306 npaths := getPaths(t, nd, newdir.Cid().String(), newdir.(*dag.ProtoNode))
307 paths = append(paths, npaths...)
308 }
309
310 // Now read a bunch, concurrently
311 wg := sync.WaitGroup{}
312 errs := make(chan error)
313
314 for range 4 {
315 wg.Go(func() {
316
317 for range 2000 {
318 item, err := path.NewPath("/ipfs/" + paths[rand.Intn(len(paths))])
319 if err != nil {
320 errs <- err
321 continue
322 }
323
324 relpath := strings.Replace(item.String(), item.Namespace(), "", 1)
325 fname := gopath.Join(mntDir, relpath)
326
327 rbuf, err := os.ReadFile(fname)
328 if err != nil {
329 errs <- err
330 continue
331 }
332
333 // nd.Context() is never closed which leads to
334 // hitting 8128 goroutine limit in go test -race mode
335 ctx, cancelFunc := context.WithCancel(context.Background())
336
337 read, err := api.Unixfs().Get(ctx, item)
338 if err != nil {
339 cancelFunc()
340 errs <- err
341 continue
342 }
343
344 data, err := io.ReadAll(read.(files.File))
345 if err != nil {
346 cancelFunc()
347 errs <- err
348 continue
349 }
350
351 cancelFunc()
352
353 if !bytes.Equal(rbuf, data) {
354 errs <- errors.New("incorrect read")
355 }
356 }
357 })
358 }
359
360 go func() {
361 wg.Wait()
362 close(errs)
363 }()
364
365 for err := range errs {
366 if err != nil {
367 t.Fatal(err)
368 }
369 }
370 }
371
372 // Test writing a file and reading it back.
373 func TestIpfsBasicDirRead(t *testing.T) {
374 nd, mntDir := setupIpfsTest(t, nil)
375
376 // Make a 'file'
377 fi, data := randObj(t, nd, 10000)
378
379 // Make a directory and put that file in it
380 db, err := uio.NewDirectory(nd.DAG)
381 if err != nil {
382 t.Fatal(err)
383 }
384 err = db.AddChild(nd.Context(), "actual", fi)
385 if err != nil {
386 t.Fatal(err)
387 }
388
389 d1nd, err := db.GetNode()
390 if err != nil {
391 t.Fatal(err)
392 }
393
394 err = nd.DAG.Add(nd.Context(), d1nd)
395 if err != nil {
396 t.Fatal(err)
397 }
398
399 dirname := gopath.Join(mntDir, d1nd.Cid().String())
400 fname := gopath.Join(dirname, "actual")
401 rbuf, err := os.ReadFile(fname)
402 if err != nil {
403 t.Fatal(err)
404 }
405
406 dirents, err := os.ReadDir(dirname)
407 if err != nil {
408 t.Fatal(err)
409 }
410 if len(dirents) != 1 {
411 t.Fatal("Bad directory entry count")
412 }
413 if dirents[0].Name() != "actual" {
414 t.Fatal("Bad directory entry")
415 }
416
417 if !bytes.Equal(rbuf, data) {
418 t.Fatal("Incorrect Read!")
419 }
420 }
421
422 // Test to make sure the filesystem reports file sizes correctly.
423 func TestFileSizeReporting(t *testing.T) {
424 nd, mntDir := setupIpfsTest(t, nil)
425
426 fi, data := randObj(t, nd, 10000)
427 k := fi.Cid()
428
429 fname := gopath.Join(mntDir, k.String())
430
431 finfo, err := os.Stat(fname)
432 if err != nil {
433 t.Fatal(err)
434 }
435
436 if finfo.Size() != int64(len(data)) {
437 t.Fatal("Read incorrect size from stat!")
438 }
439 }
440
441 // Test that mode and mtime stored in UnixFS metadata are reported in stat.
442 func TestUnixFSMetadataInStat(t *testing.T) {
443 nd, mntDir := setupIpfsTest(t, nil)
444
445 storedMode := os.FileMode(0o755)
446 storedMtime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
447 content := []byte("file with metadata")
448
449 // Create a UnixFS node with explicit mode and mtime.
450 pbdata := ft.FilePBDataWithStat(content, uint64(len(content)), storedMode, storedMtime)
451 node := dag.NodeWithData(pbdata)
452 if err := nd.DAG.Add(nd.Context(), node); err != nil {
453 t.Fatal(err)
454 }
455
456 fpath := gopath.Join(mntDir, node.Cid().String())
457 fi, err := os.Stat(fpath)
458 if err != nil {
459 t.Fatal(err)
460 }
461
462 if fi.Mode().Perm() != storedMode.Perm() {
463 t.Fatalf("expected mode %04o, got %04o", storedMode.Perm(), fi.Mode().Perm())
464 }
465 if !fi.ModTime().Equal(storedMtime) {
466 t.Fatalf("expected mtime %v, got %v", storedMtime, fi.ModTime())
467 }
468 }
469
470 // Test that files without UnixFS metadata get the read-only defaults.
471 func TestDefaultModeReadonly(t *testing.T) {
472 nd, mntDir := setupIpfsTest(t, nil)
473
474 // Create a plain UnixFS file (no mode/mtime metadata).
475 fi, _ := randObj(t, nd, 100)
476 fpath := gopath.Join(mntDir, fi.Cid().String())
477
478 finfo, err := os.Stat(fpath)
479 if err != nil {
480 t.Fatal(err)
481 }
482 if finfo.Mode().Perm() != fusemnt.DefaultFileModeRO.Perm() {
483 t.Fatalf("expected default mode %04o, got %04o", fusemnt.DefaultFileModeRO.Perm(), finfo.Mode().Perm())
484 }
485 }
486
487 // Test that ipfs.cid xattr returns the correct CID for files and directories.
488 func TestXattrCID(t *testing.T) {
489 nd, _ := setupIpfsTest(t, nil)
490
491 t.Run("file", func(t *testing.T) {
492 obj, _ := randObj(t, nd, 100)
493 node := &Node{ipfs: nd, nd: obj}
494
495 dest := make([]byte, 256)
496 sz, errno := node.Listxattr(t.Context(), dest)
497 if errno != 0 {
498 t.Fatalf("Listxattr: %v", errno)
499 }
500 if !bytes.Contains(dest[:sz], []byte(fusemnt.XattrCID)) {
501 t.Fatal("ipfs.cid not listed")
502 }
503
504 sz, errno = node.Getxattr(t.Context(), fusemnt.XattrCID, dest)
505 if errno != 0 {
506 t.Fatalf("Getxattr: %v", errno)
507 }
508 if string(dest[:sz]) != obj.Cid().String() {
509 t.Fatalf("expected CID %s, got %s", obj.Cid().String(), string(dest[:sz]))
510 }
511 })
512
513 t.Run("directory", func(t *testing.T) {
514 db, err := uio.NewDirectory(nd.DAG)
515 if err != nil {
516 t.Fatal(err)
517 }
518 dirNode, err := db.GetNode()
519 if err != nil {
520 t.Fatal(err)
521 }
522 if err := nd.DAG.Add(nd.Context(), dirNode); err != nil {
523 t.Fatal(err)
524 }
525 node := &Node{ipfs: nd, nd: dirNode}
526
527 dest := make([]byte, 256)
528 sz, errno := node.Listxattr(t.Context(), dest)
529 if errno != 0 {
530 t.Fatalf("Listxattr: %v", errno)
531 }
532 if !bytes.Contains(dest[:sz], []byte(fusemnt.XattrCID)) {
533 t.Fatal("ipfs.cid not listed")
534 }
535
536 sz, errno = node.Getxattr(t.Context(), fusemnt.XattrCID, dest)
537 if errno != 0 {
538 t.Fatalf("Getxattr: %v", errno)
539 }
540 if string(dest[:sz]) != dirNode.Cid().String() {
541 t.Fatalf("expected CID %s, got %s", dirNode.Cid().String(), string(dest[:sz]))
542 }
543 })
544
545 }
546
547 // Test that symlinks in UnixFS are rendered via Readlink.
548 func TestReadlink(t *testing.T) {
549 nd, mntDir := setupIpfsTest(t, nil)
550
551 // Build a directory containing a symlink.
552 db, err := uio.NewDirectory(nd.DAG)
553 if err != nil {
554 t.Fatal(err)
555 }
556
557 target := "hello.txt"
558 slData, err := ft.SymlinkData(target)
559 if err != nil {
560 t.Fatal(err)
561 }
562 symlinkNode := dag.NodeWithData(slData)
563 if err := nd.DAG.Add(nd.Context(), symlinkNode); err != nil {
564 t.Fatal(err)
565 }
566 if err := db.AddChild(nd.Context(), "link", symlinkNode); err != nil {
567 t.Fatal(err)
568 }
569
570 dirNode, err := db.GetNode()
571 if err != nil {
572 t.Fatal(err)
573 }
574 if err := nd.DAG.Add(nd.Context(), dirNode); err != nil {
575 t.Fatal(err)
576 }
577
578 linkPath := gopath.Join(mntDir, dirNode.Cid().String(), "link")
579 got, err := os.Readlink(linkPath)
580 if err != nil {
581 t.Fatal(err)
582 }
583 if got != target {
584 t.Fatalf("expected readlink %q, got %q", target, got)
585 }
586 }
587
588 // Test that readdir reports symlinks with ModeSymlink so that
589 // tools like ls -l and find -type l see the correct file type.
590 func TestReaddirSymlink(t *testing.T) {
591 nd, mntDir := setupIpfsTest(t, nil)
592
593 db, err := uio.NewDirectory(nd.DAG)
594 require.NoError(t, err)
595
596 // Regular file child.
597 fileData := []byte("hello")
598 fileNode := dag.NodeWithData(ft.FilePBData(fileData, uint64(len(fileData))))
599 require.NoError(t, nd.DAG.Add(nd.Context(), fileNode))
600 require.NoError(t, db.AddChild(nd.Context(), "regular", fileNode))
601
602 // Symlink child.
603 slData, err := ft.SymlinkData("hello")
604 require.NoError(t, err)
605 symlinkNode := dag.NodeWithData(slData)
606 require.NoError(t, nd.DAG.Add(nd.Context(), symlinkNode))
607 require.NoError(t, db.AddChild(nd.Context(), "link", symlinkNode))
608
609 dirNode, err := db.GetNode()
610 require.NoError(t, err)
611 require.NoError(t, nd.DAG.Add(nd.Context(), dirNode))
612
613 entries, err := os.ReadDir(gopath.Join(mntDir, dirNode.Cid().String()))
614 require.NoError(t, err)
615
616 found := false
617 for _, e := range entries {
618 if e.Name() == "link" {
619 require.NotZero(t, e.Type()&os.ModeSymlink, "readdir should report symlink type")
620 found = true
621 }
622 if e.Name() == "regular" {
623 require.Zero(t, e.Type()&os.ModeSymlink, "regular file should not have symlink type")
624 }
625 }
626 require.True(t, found, "symlink entry not found in readdir")
627 }
628
629 // Test reading a slice from the middle of a file, skipping both
630 // the beginning and the end.
631 func TestSeekRead(t *testing.T) {
632 nd, mntDir := setupIpfsTest(t, nil)
633
634 obj, data := randObj(t, nd, 10000)
635 fpath := gopath.Join(mntDir, obj.Cid().String())
636
637 f, err := os.Open(fpath)
638 if err != nil {
639 t.Fatal(err)
640 }
641 defer f.Close()
642
643 off := int64(3000)
644 readLen := 2000
645 if _, err := f.Seek(off, io.SeekStart); err != nil {
646 t.Fatal(err)
647 }
648
649 buf := make([]byte, readLen)
650 n, err := io.ReadFull(f, buf)
651 if err != nil {
652 t.Fatal(err)
653 }
654 if n != readLen {
655 t.Fatalf("short read: got %d, want %d", n, readLen)
656 }
657 if !bytes.Equal(buf, data[off:off+int64(readLen)]) {
658 t.Fatal("content mismatch for middle slice")
659 }
660 }
661
662 // Test that concurrent reads of the same large file produce correct data.
663 // The kernel sends multiple Read requests concurrently via readahead;
664 // without a mutex on roFileHandle the DagReader's internal state
665 // corrupts, causing data mismatches or panics.
666 func TestConcurrentLargeFileRead(t *testing.T) {
667 nd, mntDir := setupIpfsTest(t, nil)
668
669 // 1 MiB + 1 byte: large enough to span multiple DAG nodes and
670 // trigger concurrent kernel readahead requests.
671 fi, data := randObj(t, nd, 1024*1024+1)
672 fpath := gopath.Join(mntDir, fi.Cid().String())
673
674 // Multiple goroutines opening and reading the same file exercises
675 // both per-handle serialization (Seek+Read within one handle) and
676 // independent handle isolation (separate DagReaders).
677 var wg sync.WaitGroup
678 for range 8 {
679 wg.Go(func() {
680 got, err := os.ReadFile(fpath)
681 if err != nil {
682 t.Errorf("ReadFile: %v", err)
683 return
684 }
685 if !bytes.Equal(got, data) {
686 t.Errorf("data mismatch: got %d bytes, want %d", len(got), len(data))
687 }
688 })
689 }
690 wg.Wait()
691 }
692
693 // blockingDagReader is a uio.DagReader that blocks in CtxReadFull until
694 // the supplied context is cancelled. Used to verify that roFileHandle
695 // propagates cancellation from FUSE down to the underlying reader.
696 type blockingDagReader struct {
697 entered chan struct{} // closed when CtxReadFull begins blocking
698 }
699
700 func (b *blockingDagReader) CtxReadFull(ctx context.Context, _ []byte) (int, error) {
701 close(b.entered)
702 <-ctx.Done()
703 return 0, ctx.Err()
704 }
705
706 // Stub uio.DagReader methods that the test does not exercise. Returning
707 // zero values keeps roFileHandle.Read on the CtxReadFull path.
708 func (*blockingDagReader) Seek(int64, int) (int64, error) { return 0, nil }
709 func (*blockingDagReader) Read([]byte) (int, error) { return 0, io.EOF }
710 func (*blockingDagReader) Close() error { return nil }
711 func (*blockingDagReader) WriteTo(io.Writer) (int64, error) { return 0, nil }
712 func (*blockingDagReader) Size() uint64 { return 0 }
713 func (*blockingDagReader) Mode() os.FileMode { return 0 }
714 func (*blockingDagReader) ModTime() time.Time { return time.Time{} }
715
716 var _ uio.DagReader = (*blockingDagReader)(nil)
717
718 // TestReadCancellationUnblocks confirms that cancelling the context
719 // passed to roFileHandle.Read returns promptly with EINTR. This guards
720 // the "killing a stuck cat works" fix: the kernel sends FUSE_INTERRUPT
721 // when a userspace process is killed mid-read, go-fuse cancels the
722 // per-request context, and the read handler must propagate cancellation
723 // down to the DagReader instead of blocking forever on a stuck fetch.
724 func TestReadCancellationUnblocks(t *testing.T) {
725 fake := &blockingDagReader{entered: make(chan struct{})}
726 fh := &roFileHandle{r: fake}
727
728 ctx, cancel := context.WithCancel(t.Context())
729 defer cancel()
730
731 type result struct {
732 errno syscall.Errno
733 }
734 done := make(chan result, 1)
735 go func() {
736 buf := make([]byte, 4096)
737 _, errno := fh.Read(ctx, buf, 0)
738 done <- result{errno}
739 }()
740
741 // Wait for the fake reader to actually block on ctx.Done() before
742 // cancelling, so the test exercises mid-read cancellation rather
743 // than racing the goroutine start.
744 select {
745 case <-fake.entered:
746 case <-time.After(5 * time.Second):
747 t.Fatal("CtxReadFull never entered; cancellation path unreachable")
748 }
749
750 cancel() // simulates FUSE_INTERRUPT from the kernel
751
752 select {
753 case r := <-done:
754 if r.errno != syscall.EINTR {
755 t.Fatalf("expected EINTR after cancel, got errno %v", r.errno)
756 }
757 case <-time.After(5 * time.Second):
758 t.Fatal("roFileHandle.Read did not return after ctx cancel; cancellation is not propagated")
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.
837 func TestStatfs(t *testing.T) {
838 nd, err := coremock.NewMockNode()
839 require.NoError(t, err)
840
841 // Point repoPath at a real directory so Statfs has a valid target.
842 // (NewMockNode's in-memory repo returns "" for Path().)
843 repoDir := t.TempDir()
844 root := &Root{ipfs: nd, repoPath: repoDir}
845 mntDir := testMount(t, root)
846
847 fusetest.AssertStatfsNonZero(t, mntDir)
848 }
849
850 // Test that getxattr on an unknown attribute returns ENODATA (Linux) / ENOATTR.
851 func TestUnknownXattr(t *testing.T) {
852 nd, _ := setupIpfsTest(t, nil)
853
854 obj, _ := randObj(t, nd, 100)
855 node := &Node{ipfs: nd, nd: obj}
856
857 dest := make([]byte, 256)
858 _, errno := node.Getxattr(t.Context(), "user.bogus", dest)
859 if errno == 0 {
860 t.Fatal("expected error for unknown xattr, got success")
861 }
862 }