| 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 | } |