master
go 914 lines 26.3 KB
Raw
1 // Reusable test suite for writable FUSE mounts.
2 //
3 // RunWritableSuite exercises all filesystem operations shared by
4 // /mfs and /ipns. Each mount provides a MountFunc that creates a
5 // fresh writable mount.
6 //
7 //go:build (linux || darwin || freebsd) && !nofuse
8
9 package fusetest
10
11 import (
12 "bytes"
13 "crypto/rand"
14 "errors"
15 "fmt"
16 "io"
17 mrand "math/rand"
18 "os"
19 "path/filepath"
20 "strconv"
21 "sync"
22 "syscall"
23 "testing"
24 "time"
25
26 racedet "github.com/ipfs/go-detect-race"
27 "github.com/ipfs/kubo/fuse/writable"
28 "github.com/stretchr/testify/require"
29 "golang.org/x/sys/unix"
30 )
31
32 // MountFunc creates a fresh writable FUSE mount and returns the root
33 // directory path. Cleanup is handled via t.Cleanup.
34 type MountFunc func(t *testing.T, cfg writable.Config) string
35
36 // RunWritableSuite runs generic writable filesystem tests against
37 // the mount produced by mount.
38 func RunWritableSuite(t *testing.T, mount MountFunc) {
39 t.Run("ReadWrite", func(t *testing.T) {
40 dir := mount(t, writable.Config{})
41 data := WriteFileOrFail(t, 500, filepath.Join(dir, "testfile"))
42 VerifyFile(t, filepath.Join(dir, "testfile"), data)
43 })
44
45 t.Run("AppendFile", func(t *testing.T) {
46 dir := mount(t, writable.Config{})
47 path := filepath.Join(dir, "appendme")
48
49 part1 := RandBytes(200)
50 require.NoError(t, os.WriteFile(path, part1, 0o644))
51
52 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
53 require.NoError(t, err)
54 part2 := RandBytes(300)
55 _, err = f.Write(part2)
56 require.NoError(t, err)
57 require.NoError(t, f.Close())
58
59 VerifyFile(t, path, append(part1, part2...))
60 })
61
62 t.Run("MultiWrite", func(t *testing.T) {
63 dir := mount(t, writable.Config{})
64 path := filepath.Join(dir, "multiwrite")
65
66 f, err := os.Create(path)
67 require.NoError(t, err)
68 var want []byte
69 for range 1001 {
70 b := []byte{byte(mrand.Intn(256))}
71 _, err := f.Write(b)
72 require.NoError(t, err)
73 want = append(want, b...)
74 }
75 require.NoError(t, f.Close())
76 VerifyFile(t, path, want)
77 })
78
79 t.Run("EmptyDirListing", func(t *testing.T) {
80 dir := mount(t, writable.Config{})
81 emptyDir := filepath.Join(dir, "emptydir")
82 require.NoError(t, os.Mkdir(emptyDir, 0o755))
83
84 entries, err := os.ReadDir(emptyDir)
85 require.NoError(t, err)
86 require.Empty(t, entries)
87 })
88
89 t.Run("Mkdir", func(t *testing.T) {
90 dir := mount(t, writable.Config{})
91 nested := filepath.Join(dir, "a", "b", "c")
92 require.NoError(t, os.MkdirAll(nested, 0o755))
93
94 info, err := os.Stat(nested)
95 require.NoError(t, err)
96 require.True(t, info.IsDir())
97 })
98
99 // Both fstat (on the open handle) and path-based stat must return
100 // the correct mode and size for a freshly created file. The kernel
101 // caches attrs from the Create response for AttrTimeout: if
102 // Dir.Create returns an empty EntryOut.Attr, fstat sees the cached
103 // zero values. A path-based stat does a fresh Lookup, which has its
104 // own attr-fill path; covering both shapes guards against future
105 // regressions on either side.
106 t.Run("CreateAttrsImmediate", func(t *testing.T) {
107 dir := mount(t, writable.Config{})
108 path := filepath.Join(dir, "freshfile")
109
110 f, err := os.Create(path)
111 require.NoError(t, err)
112 defer f.Close()
113
114 // fstat on the open handle: exercises the Create response cache.
115 fstatInfo, err := f.Stat()
116 require.NoError(t, err)
117 require.Equal(t, int64(0), fstatInfo.Size())
118 require.Equal(t, os.FileMode(0o644), fstatInfo.Mode().Perm(),
119 "fstat on new file should report default mode, not cached zero")
120
121 // Path-based stat: exercises Dir.Lookup → FileInode.fillAttr.
122 statInfo, err := os.Stat(path)
123 require.NoError(t, err)
124 require.Equal(t, int64(0), statInfo.Size())
125 require.Equal(t, os.FileMode(0o644), statInfo.Mode().Perm(),
126 "stat on new file should report default mode, not cached zero")
127 })
128
129 // Same as CreateAttrsImmediate, but for mkdir. Mkdir does not return
130 // a file handle, so we open the directory afterwards and fstat its
131 // fd to exercise the inode-level path. Path-based stat exercises
132 // Lookup. Both must report the directory mode.
133 t.Run("MkdirAttrsImmediate", func(t *testing.T) {
134 dir := mount(t, writable.Config{})
135 path := filepath.Join(dir, "freshdir")
136
137 require.NoError(t, os.Mkdir(path, 0o755))
138
139 // Path-based stat: exercises Dir.Lookup → Dir.fillAttr.
140 statInfo, err := os.Stat(path)
141 require.NoError(t, err)
142 require.True(t, statInfo.IsDir())
143 require.Equal(t, os.FileMode(0o755), statInfo.Mode().Perm(),
144 "stat on new directory should report default mode, not cached zero")
145
146 // fstat on an open directory fd: exercises Dir.Getattr.
147 f, err := os.Open(path)
148 require.NoError(t, err)
149 defer f.Close()
150 fstatInfo, err := f.Stat()
151 require.NoError(t, err)
152 require.True(t, fstatInfo.IsDir())
153 require.Equal(t, os.FileMode(0o755), fstatInfo.Mode().Perm(),
154 "fstat on new directory should report default mode, not cached zero")
155 })
156
157 t.Run("RenameFile", func(t *testing.T) {
158 dir := mount(t, writable.Config{})
159 src := filepath.Join(dir, "oldname")
160 dst := filepath.Join(dir, "newname")
161
162 data := WriteFileOrFail(t, 300, src)
163 require.NoError(t, os.Rename(src, dst))
164
165 _, err := os.Stat(src)
166 require.True(t, os.IsNotExist(err))
167 VerifyFile(t, dst, data)
168 })
169
170 t.Run("CrossDirRename", func(t *testing.T) {
171 dir := mount(t, writable.Config{})
172 require.NoError(t, os.Mkdir(filepath.Join(dir, "src"), 0o755))
173 require.NoError(t, os.Mkdir(filepath.Join(dir, "dst"), 0o755))
174
175 data := WriteFileOrFail(t, 200, filepath.Join(dir, "src", "file"))
176 require.NoError(t, os.Rename(filepath.Join(dir, "src", "file"), filepath.Join(dir, "dst", "file")))
177
178 _, err := os.Stat(filepath.Join(dir, "src", "file"))
179 require.True(t, os.IsNotExist(err))
180 VerifyFile(t, filepath.Join(dir, "dst", "file"), data)
181 })
182
183 // Renaming a directory (not just a file inside it). The contained
184 // file must still be readable under the new path.
185 t.Run("DirRename", func(t *testing.T) {
186 dir := mount(t, writable.Config{})
187 oldDir := filepath.Join(dir, "olddir")
188 newDir := filepath.Join(dir, "newdir")
189
190 require.NoError(t, os.Mkdir(oldDir, 0o755))
191 data := WriteFileOrFail(t, 200, filepath.Join(oldDir, "child"))
192
193 require.NoError(t, os.Rename(oldDir, newDir))
194
195 _, err := os.Stat(oldDir)
196 require.True(t, os.IsNotExist(err))
197 VerifyFile(t, filepath.Join(newDir, "child"), data)
198 })
199
200 t.Run("RemoveFile", func(t *testing.T) {
201 dir := mount(t, writable.Config{})
202 path := filepath.Join(dir, "removeme")
203 WriteFileOrFail(t, 100, path)
204 require.NoError(t, os.Remove(path))
205
206 _, err := os.Stat(path)
207 require.True(t, os.IsNotExist(err))
208 })
209
210 t.Run("Rmdir", func(t *testing.T) {
211 dir := mount(t, writable.Config{})
212 sub := filepath.Join(dir, "rmdir_target")
213 require.NoError(t, os.Mkdir(sub, 0o755))
214 require.NoError(t, os.Remove(sub))
215
216 _, err := os.Stat(sub)
217 require.True(t, os.IsNotExist(err))
218 })
219
220 t.Run("RemoveNonEmptyDirectory", func(t *testing.T) {
221 dir := mount(t, writable.Config{})
222 sub := filepath.Join(dir, "nonempty")
223 require.NoError(t, os.Mkdir(sub, 0o755))
224 WriteFileOrFail(t, 50, filepath.Join(sub, "child"))
225
226 err := syscall.Rmdir(sub)
227 require.Error(t, err, "expected error removing non-empty directory")
228
229 // After removing the child, rmdir succeeds.
230 require.NoError(t, os.Remove(filepath.Join(sub, "child")))
231 require.NoError(t, os.Remove(sub))
232 })
233
234 t.Run("DoubleEntryFailure", func(t *testing.T) {
235 dir := mount(t, writable.Config{})
236 sub := filepath.Join(dir, "dupdir")
237 require.NoError(t, os.Mkdir(sub, 0o755))
238 require.Error(t, os.Mkdir(sub, 0o755))
239 })
240
241 t.Run("Fsync", func(t *testing.T) {
242 dir := mount(t, writable.Config{})
243 path := filepath.Join(dir, "fsyncme")
244
245 f, err := os.Create(path)
246 require.NoError(t, err)
247 _, err = f.Write(RandBytes(500))
248 require.NoError(t, err)
249 require.NoError(t, f.Sync())
250 require.NoError(t, f.Close())
251 })
252
253 // After fsync on the writer handle, a fresh reader on a different
254 // fd must see the synced data. This is the "vim wrote and called
255 // fsync; my other process should see it immediately" scenario.
256 t.Run("FsyncCrossHandle", func(t *testing.T) {
257 dir := mount(t, writable.Config{})
258 path := filepath.Join(dir, "fsynccross")
259
260 want := RandBytes(500)
261 w, err := os.Create(path)
262 require.NoError(t, err)
263 _, err = w.Write(want)
264 require.NoError(t, err)
265 require.NoError(t, w.Sync())
266 // w is intentionally still open: the cross-handle reader must
267 // see the data after fsync, not just after close.
268
269 got, err := os.ReadFile(path)
270 require.NoError(t, err)
271 require.Equal(t, len(want), len(got),
272 "reader on fresh handle should see all bytes after fsync")
273 require.Equal(t, want, got,
274 "reader on a fresh handle should see data flushed by fsync")
275
276 require.NoError(t, w.Close())
277 })
278
279 t.Run("Ftruncate", func(t *testing.T) {
280 dir := mount(t, writable.Config{})
281 path := filepath.Join(dir, "truncme")
282
283 f, err := os.Create(path)
284 require.NoError(t, err)
285 _, err = f.Write(RandBytes(1000))
286 require.NoError(t, err)
287 require.NoError(t, f.Truncate(500))
288 require.NoError(t, f.Close())
289
290 info, err := os.Stat(path)
291 require.NoError(t, err)
292 require.Equal(t, int64(500), info.Size())
293 })
294
295 // truncate(path, size) without an open fd: uses a temporary
296 // write descriptor inside Setattr instead of ftruncate on an
297 // existing handle.
298 t.Run("TruncatePath", func(t *testing.T) {
299 dir := mount(t, writable.Config{})
300 path := filepath.Join(dir, "pathtrunc")
301
302 WriteFileOrFail(t, 1000, path)
303 require.NoError(t, syscall.Truncate(path, 500))
304
305 info, err := os.Stat(path)
306 require.NoError(t, err)
307 require.Equal(t, int64(500), info.Size())
308 })
309
310 t.Run("LargeFile", func(t *testing.T) {
311 dir := mount(t, writable.Config{})
312 path := filepath.Join(dir, "largefile")
313 size := 1024*1024 + 1 // 1 MiB + 1 byte
314 data := WriteFileOrFail(t, size, path)
315 VerifyFile(t, path, data)
316 })
317
318 t.Run("OpenTrunc", func(t *testing.T) {
319 dir := mount(t, writable.Config{})
320 path := filepath.Join(dir, "truncopen")
321
322 WriteFileOrFail(t, 500, path)
323
324 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o644)
325 require.NoError(t, err)
326 newData := RandBytes(200)
327 _, err = f.Write(newData)
328 require.NoError(t, err)
329 require.NoError(t, f.Close())
330
331 VerifyFile(t, path, newData)
332 })
333
334 t.Run("TempFileRename", func(t *testing.T) {
335 dir := mount(t, writable.Config{})
336 target := filepath.Join(dir, "target")
337 tmp := filepath.Join(dir, ".target.tmp")
338
339 WriteFileOrFail(t, 100, target)
340 newData := WriteFileOrFail(t, 200, tmp)
341 require.NoError(t, os.Rename(tmp, target))
342
343 VerifyFile(t, target, newData)
344 })
345
346 t.Run("SeekAndWrite", func(t *testing.T) {
347 dir := mount(t, writable.Config{})
348 path := filepath.Join(dir, "seekwrite")
349 data := WriteFileOrFail(t, 100, path)
350
351 f, err := os.OpenFile(path, os.O_WRONLY, 0o644)
352 require.NoError(t, err)
353 patch := []byte("PATCHED")
354 _, err = f.WriteAt(patch, 10)
355 require.NoError(t, err)
356 require.NoError(t, f.Close())
357
358 copy(data[10:], patch)
359 VerifyFile(t, path, data)
360 })
361
362 // Writing past the end of an empty file. UnixFS may not store true
363 // sparse holes, but the visible read must report the requested
364 // offset and the data we wrote, with zero bytes filling the gap.
365 t.Run("SparseWrite", func(t *testing.T) {
366 dir := mount(t, writable.Config{})
367 path := filepath.Join(dir, "sparse")
368
369 f, err := os.Create(path)
370 require.NoError(t, err)
371 payload := RandBytes(100)
372 _, err = f.WriteAt(payload, 1000)
373 require.NoError(t, err)
374 require.NoError(t, f.Close())
375
376 got, err := os.ReadFile(path)
377 require.NoError(t, err)
378 require.Equal(t, 1100, len(got), "size should include the gap before the written bytes")
379 require.True(t, bytes.Equal(payload, got[1000:]), "tail bytes should match the written payload")
380 // Bytes [0:1000] should read as zero. Don't assert byte-for-byte
381 // equality with a zero slice (would catch the same thing twice);
382 // require.NotContains over a sample is enough.
383 for _, b := range got[:1000] {
384 if b != 0 {
385 t.Fatalf("expected zero gap fill, got byte %d", b)
386 }
387 }
388 })
389
390 // O_EXCL: the second create on the same path must fail with an
391 // error that satisfies os.IsExist. Lock files, ssh-agent, and
392 // atomic file creation patterns rely on this.
393 t.Run("OExcl", func(t *testing.T) {
394 dir := mount(t, writable.Config{})
395 path := filepath.Join(dir, "exclfile")
396
397 f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
398 require.NoError(t, err)
399 require.NoError(t, f.Close())
400
401 _, err = os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
402 require.Error(t, err)
403 require.True(t, os.IsExist(err), "second O_EXCL create should fail with EEXIST, got %v", err)
404 })
405
406 t.Run("OverwriteExisting", func(t *testing.T) {
407 dir := mount(t, writable.Config{})
408 path := filepath.Join(dir, "overwrite")
409
410 WriteFileOrFail(t, 500, path)
411
412 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o644)
413 require.NoError(t, err)
414 newData := RandBytes(300)
415 _, err = f.Write(newData)
416 require.NoError(t, err)
417 require.NoError(t, f.Close())
418
419 VerifyFile(t, path, newData)
420 })
421
422 // Vim (with backupcopy=yes) save sequence: open O_TRUNC, write, fsync, chmod.
423 t.Run("VimSavePattern", func(t *testing.T) {
424 dir := mount(t, writable.Config{StoreMode: true})
425 path := filepath.Join(dir, "vimsave")
426
427 WriteFileOrFail(t, 200, path)
428
429 f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o644)
430 require.NoError(t, err)
431 newData := RandBytes(300)
432 _, err = f.Write(newData)
433 require.NoError(t, err)
434 require.NoError(t, f.Sync())
435 require.NoError(t, f.Chmod(0o644))
436 require.NoError(t, f.Close())
437
438 VerifyFile(t, path, newData)
439 })
440
441 // rsync default save: create temp file, write, rename over target.
442 t.Run("RsyncPattern", func(t *testing.T) {
443 dir := mount(t, writable.Config{})
444 target := filepath.Join(dir, "rsync_target")
445 tmp := filepath.Join(dir, ".rsync_target.XXXXXX")
446
447 WriteFileOrFail(t, 100, target)
448 newData := WriteFileOrFail(t, 200, tmp)
449 require.NoError(t, os.Rename(tmp, target))
450
451 VerifyFile(t, target, newData)
452 })
453
454 t.Run("Symlink", func(t *testing.T) {
455 dir := mount(t, writable.Config{})
456 link := filepath.Join(dir, "mylink")
457 require.NoError(t, os.Symlink("/some/target", link))
458
459 got, err := os.Readlink(link)
460 require.NoError(t, err)
461 require.Equal(t, "/some/target", got)
462 })
463
464 // Verify that readdir reports symlinks with ModeSymlink so that
465 // tools like ls -l and find -type l see the correct file type.
466 t.Run("SymlinkReaddir", func(t *testing.T) {
467 dir := mount(t, writable.Config{})
468
469 // Create a regular file and a symlink in the same directory.
470 WriteFileOrFail(t, 100, filepath.Join(dir, "regular"))
471 require.NoError(t, os.Symlink("/some/target", filepath.Join(dir, "mylink")))
472
473 entries, err := os.ReadDir(dir)
474 require.NoError(t, err)
475
476 found := false
477 for _, e := range entries {
478 if e.Name() == "mylink" {
479 require.Equal(t, os.ModeSymlink, e.Type()&os.ModeSymlink,
480 "readdir should report symlink type for mylink")
481 found = true
482 }
483 if e.Name() == "regular" {
484 require.Equal(t, os.FileMode(0), e.Type()&os.ModeSymlink,
485 "readdir should not report symlink type for regular file")
486 }
487 }
488 require.True(t, found, "symlink entry not found in readdir")
489 })
490
491 t.Run("SymlinkSetattr", func(t *testing.T) {
492 dir := mount(t, writable.Config{StoreMtime: true})
493 link := filepath.Join(dir, "mtimelink")
494 require.NoError(t, os.Symlink("/some/target", link))
495
496 mtime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
497 require.NoError(t, Lchtimes(link, mtime))
498
499 var stat unix.Stat_t
500 require.NoError(t, unix.Lstat(link, &stat))
501 gotMtime := time.Unix(stat.Mtim.Sec, stat.Mtim.Nsec)
502 require.WithinDuration(t, mtime, gotMtime, time.Second)
503 })
504
505 t.Run("FileSizeReporting", func(t *testing.T) {
506 dir := mount(t, writable.Config{})
507 path := filepath.Join(dir, "sizecheck")
508 data := WriteFileOrFail(t, 5555, path)
509
510 info, err := os.Stat(path)
511 require.NoError(t, err)
512 require.Equal(t, int64(len(data)), info.Size())
513 })
514
515 t.Run("FileAttributes", func(t *testing.T) {
516 dir := mount(t, writable.Config{})
517 path := filepath.Join(dir, "attrcheck")
518 WriteFileOrFail(t, 100, path)
519
520 info, err := os.Stat(path)
521 require.NoError(t, err)
522 require.False(t, info.IsDir())
523 require.Equal(t, "attrcheck", info.Name())
524 require.Equal(t, int64(100), info.Size())
525 })
526
527 t.Run("DefaultDirMode", func(t *testing.T) {
528 dir := mount(t, writable.Config{})
529 sub := filepath.Join(dir, "modedir")
530 require.NoError(t, os.Mkdir(sub, 0o755))
531
532 info, err := os.Stat(sub)
533 require.NoError(t, err)
534 require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
535 })
536
537 // StoreMtime tests.
538 t.Run("StoreMtime/disabled", func(t *testing.T) {
539 dir := mount(t, writable.Config{StoreMtime: false})
540 path := filepath.Join(dir, "nomtime")
541 WriteFileOrFail(t, 100, path)
542
543 // Without StoreMtime, Getattr returns mtime=0 which the
544 // kernel reports as Unix epoch start.
545 info, err := os.Stat(path)
546 require.NoError(t, err)
547 require.Equal(t, time.Unix(0, 0), info.ModTime())
548 })
549
550 t.Run("StoreMtime/enabled", func(t *testing.T) {
551 dir := mount(t, writable.Config{StoreMtime: true})
552 path := filepath.Join(dir, "withmtime")
553 WriteFileOrFail(t, 100, path)
554
555 info, err := os.Stat(path)
556 require.NoError(t, err)
557 require.False(t, info.ModTime().IsZero(), "mtime should be set when StoreMtime is on")
558 require.WithinDuration(t, time.Now(), info.ModTime(), 30*time.Second)
559 })
560
561 // StoreMode tests.
562 t.Run("StoreMode/disabled", func(t *testing.T) {
563 dir := mount(t, writable.Config{StoreMode: false})
564 path := filepath.Join(dir, "nomode")
565 WriteFileOrFail(t, 100, path)
566 // chmod should not fail, even when not persisting
567 require.NoError(t, os.Chmod(path, 0o600))
568
569 info, err := os.Stat(path)
570 require.NoError(t, err)
571 // With StoreMode off, mode stays at default 0644.
572 require.Equal(t, os.FileMode(0o644), info.Mode().Perm())
573 })
574
575 t.Run("StoreMode/enabled", func(t *testing.T) {
576 dir := mount(t, writable.Config{StoreMode: true})
577 path := filepath.Join(dir, "withmode")
578 WriteFileOrFail(t, 100, path)
579 require.NoError(t, os.Chmod(path, 0o600))
580
581 info, err := os.Stat(path)
582 require.NoError(t, err)
583 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
584 })
585
586 t.Run("SetuidBitsStripped", func(t *testing.T) {
587 dir := mount(t, writable.Config{StoreMode: true})
588 path := filepath.Join(dir, "setuid")
589 WriteFileOrFail(t, 100, path)
590
591 // Setuid, setgid, and sticky bits should be silently stripped
592 // because boxo's MFS exposes only the lower 9 permission bits.
593 require.NoError(t, os.Chmod(path, 0o4755))
594 info, err := os.Stat(path)
595 require.NoError(t, err)
596 require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
597 })
598
599 t.Run("DirMtime", func(t *testing.T) {
600 dir := mount(t, writable.Config{StoreMtime: true})
601 sub := filepath.Join(dir, "dirmtime")
602 require.NoError(t, os.Mkdir(sub, 0o755))
603
604 mtime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
605 require.NoError(t, os.Chtimes(sub, mtime, mtime))
606
607 info, err := os.Stat(sub)
608 require.NoError(t, err)
609 require.WithinDuration(t, mtime, info.ModTime(), time.Second)
610 })
611
612 t.Run("DirChmod", func(t *testing.T) {
613 dir := mount(t, writable.Config{StoreMode: true})
614 sub := filepath.Join(dir, "dirchmod")
615 require.NoError(t, os.Mkdir(sub, 0o755))
616 require.NoError(t, os.Chmod(sub, 0o700))
617
618 info, err := os.Stat(sub)
619 require.NoError(t, err)
620 require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
621 })
622
623 t.Run("XattrCID", func(t *testing.T) {
624 dir := mount(t, writable.Config{})
625 path := filepath.Join(dir, "xattrfile")
626 WriteFileOrFail(t, 100, path)
627
628 buf := make([]byte, 256)
629 n, err := unix.Getxattr(path, "ipfs.cid", buf)
630 require.NoError(t, err)
631 require.NotEmpty(t, string(buf[:n]))
632 })
633
634 t.Run("UnknownXattr", func(t *testing.T) {
635 dir := mount(t, writable.Config{})
636 path := filepath.Join(dir, "xattrunk")
637 WriteFileOrFail(t, 50, path)
638
639 buf := make([]byte, 256)
640 _, err := unix.Getxattr(path, "user.nonexistent", buf)
641 require.Error(t, err)
642 })
643
644 t.Run("ConcurrentWrites", func(t *testing.T) {
645 dir := mount(t, writable.Config{})
646 nactors := 4
647 filesPerActor := 400
648 fileSize := 2000
649
650 if racedet.WithRace() {
651 nactors = 2
652 filesPerActor = 50
653 }
654
655 data := make([][][]byte, nactors)
656 var wg sync.WaitGroup
657 for i := range nactors {
658 data[i] = make([][]byte, filesPerActor)
659 wg.Add(1)
660 go func(n int) {
661 defer wg.Done()
662 for j := range filesPerActor {
663 out, err := WriteFile(fileSize, filepath.Join(dir, fmt.Sprintf("%dFILE%d", n, j)))
664 if err != nil {
665 t.Error(err)
666 continue
667 }
668 data[n][j] = out
669 }
670 }(i)
671 }
672 wg.Wait()
673
674 for i := range nactors {
675 for j := range filesPerActor {
676 if data[i][j] == nil {
677 continue
678 }
679 VerifyFile(t, filepath.Join(dir, fmt.Sprintf("%dFILE%d", i, j)), data[i][j])
680 }
681 }
682 })
683
684 t.Run("ConcurrentRW", func(t *testing.T) {
685 dir := mount(t, writable.Config{})
686 nfiles := 5
687 readers := 5
688
689 content := make([][]byte, nfiles)
690 for i := range content {
691 content[i] = RandBytes(8196)
692 }
693
694 // Write phase.
695 var wg sync.WaitGroup
696 for i := range nfiles {
697 wg.Go(func() {
698 if err := os.WriteFile(filepath.Join(dir, strconv.Itoa(i)), content[i], 0o644); err != nil {
699 t.Error(err)
700 }
701 })
702 }
703 wg.Wait()
704
705 // Read phase.
706 for i := range nfiles * readers {
707 wg.Go(func() {
708 got, err := os.ReadFile(filepath.Join(dir, strconv.Itoa(i/readers)))
709 if err != nil {
710 t.Error(err)
711 return
712 }
713 if !bytes.Equal(content[i/readers], got) {
714 t.Error("read and write not equal")
715 }
716 })
717 }
718 wg.Wait()
719 })
720
721 // Large file concurrent reads: the kernel sends multiple Read
722 // requests via readahead on files bigger than max_read (128 KB).
723 // Without proper mutex serialization on the file handle, concurrent
724 // reads corrupt the DagReader's internal state.
725 t.Run("LargeFileConcurrentRead", func(t *testing.T) {
726 dir := mount(t, writable.Config{})
727 path := filepath.Join(dir, "largeconcurrent")
728
729 size := 1024*1024 + 1 // 1 MiB + 1 byte
730 data := WriteFileOrFail(t, size, path)
731
732 var wg sync.WaitGroup
733 for range 8 {
734 wg.Go(func() {
735 got, err := os.ReadFile(path)
736 if err != nil {
737 t.Errorf("ReadFile: %v", err)
738 return
739 }
740 if !bytes.Equal(got, data) {
741 t.Errorf("data mismatch: got %d bytes, want %d", len(got), len(data))
742 }
743 })
744 }
745 wg.Wait()
746 })
747
748 // Simulate the rsync --inplace pattern: one goroutine holds a
749 // file open for reading while another opens it for writing.
750 // MFS's desclock blocks a write-open while a read descriptor
751 // exists. The FUSE layer avoids this by creating a DagReader
752 // for read-only opens instead of going through MFS.
753 t.Run("ConcurrentReadWrite", func(t *testing.T) {
754 dir := mount(t, writable.Config{})
755 path := filepath.Join(dir, "concurrent_rw")
756
757 data := WriteFileOrFail(t, 50000, path)
758
759 // Hold the file open for reading (like rsync's generator).
760 reader, err := os.Open(path)
761 require.NoError(t, err)
762 defer reader.Close()
763
764 // Overwrite the file while the reader is still open
765 // (like rsync's receiver).
766 newData := RandBytes(60000)
767 require.NoError(t, os.WriteFile(path, newData, 0o644))
768
769 // The reader should still see the original snapshot.
770 got, err := io.ReadAll(reader)
771 require.NoError(t, err)
772 require.True(t, bytes.Equal(data, got), "reader should see original data")
773
774 // A fresh read should see the new data.
775 got2, err := os.ReadFile(path)
776 require.NoError(t, err)
777 require.True(t, bytes.Equal(newData, got2), "new reader should see updated data")
778 })
779
780 t.Run("FSThrash", func(t *testing.T) {
781 dir := mount(t, writable.Config{})
782 dirs := []string{dir}
783 dirlock := sync.RWMutex{}
784 filelock := sync.Mutex{}
785 files := make(map[string][]byte)
786
787 ndirWorkers := 2
788 nfileWorkers := 2
789 ndirs := 100
790 nfiles := 200
791
792 var wg sync.WaitGroup
793
794 for i := range ndirWorkers {
795 wg.Add(1)
796 go func(worker int) {
797 defer wg.Done()
798 for j := range ndirs {
799 dirlock.RLock()
800 n := mrand.Intn(len(dirs))
801 d := dirs[n]
802 dirlock.RUnlock()
803
804 newDir := fmt.Sprintf("%s/dir%d-%d", d, worker, j)
805 if err := os.Mkdir(newDir, os.ModeDir); err != nil {
806 t.Error(err)
807 continue
808 }
809 dirlock.Lock()
810 dirs = append(dirs, newDir)
811 dirlock.Unlock()
812 }
813 }(i)
814 }
815
816 for i := range nfileWorkers {
817 wg.Add(1)
818 go func(worker int) {
819 defer wg.Done()
820 for j := range nfiles {
821 dirlock.RLock()
822 n := mrand.Intn(len(dirs))
823 d := dirs[n]
824 dirlock.RUnlock()
825
826 name := fmt.Sprintf("%s/file%d-%d", d, worker, j)
827 data, err := WriteFile(2000+mrand.Intn(5000), name)
828 if err != nil {
829 t.Error(err)
830 continue
831 }
832 filelock.Lock()
833 files[name] = data
834 filelock.Unlock()
835 }
836 }(i)
837 }
838
839 wg.Wait()
840 for name, data := range files {
841 got, err := os.ReadFile(name)
842 if err != nil {
843 t.Errorf("reading %s: %v", name, err)
844 continue
845 }
846 if !bytes.Equal(data, got) {
847 t.Errorf("data mismatch in %s", name)
848 }
849 }
850 })
851 }
852
853 // Test helpers exported for use by mount-specific tests.
854
855 // RandBytes returns size random bytes.
856 func RandBytes(size int) []byte {
857 b := make([]byte, size)
858 if _, err := io.ReadFull(rand.Reader, b); err != nil {
859 panic(err)
860 }
861 return b
862 }
863
864 // WriteFile writes size random bytes to path and returns the data.
865 func WriteFile(size int, path string) ([]byte, error) {
866 data := RandBytes(size)
867 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o666)
868 if err != nil {
869 return nil, err
870 }
871 _, err = f.Write(data)
872 if err != nil {
873 f.Close()
874 return nil, err
875 }
876 // Go's goroutine preemption (SIGURG) can interrupt the FUSE FLUSH
877 // inside close(), returning EINTR. This is not data loss: the write
878 // already succeeded and the kernel will still send RELEASE.
879 if err := f.Close(); err != nil && !errors.Is(err, syscall.EINTR) {
880 return nil, err
881 }
882 return data, nil
883 }
884
885 // WriteFileOrFail calls WriteFile and fails the test on error.
886 func WriteFileOrFail(t *testing.T, size int, path string) []byte {
887 t.Helper()
888 data, err := WriteFile(size, path)
889 require.NoError(t, err)
890 return data
891 }
892
893 // VerifyFile reads the file at path and asserts its contents match want.
894 func VerifyFile(t *testing.T, path string, want []byte) {
895 t.Helper()
896 got, err := os.ReadFile(path)
897 require.NoError(t, err)
898 require.Equal(t, len(want), len(got), "file size mismatch")
899 require.True(t, bytes.Equal(want, got), "file content mismatch")
900 }
901
902 // CheckExists asserts that path exists.
903 func CheckExists(t *testing.T, path string) {
904 t.Helper()
905 _, err := os.Stat(path)
906 require.NoError(t, err)
907 }
908
909 // Lchtimes sets mtime on a symlink without following it (lutimes).
910 // Go's os package has no Lchtimes, so we call utimensat directly.
911 func Lchtimes(path string, mtime time.Time) error {
912 ts := unix.NsecToTimespec(mtime.UnixNano())
913 return unix.UtimesNanoAt(unix.AT_FDCWD, path, []unix.Timespec{ts, ts}, unix.AT_SYMLINK_NOFOLLOW)
914 }