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