| 1 | //go:build (linux || darwin || freebsd) && !nofuse |
| 2 | |
| 3 | // End-to-end FUSE coverage with real POSIX tools. |
| 4 | // |
| 5 | // TestFUSERealWorld spins up one ipfs daemon, mounts /ipfs, /ipns, and |
| 6 | // /mfs, and exercises the writable mount through the actual binaries |
| 7 | // users invoke (cat, ls, cp, mv, rm, ln, find, dd, sha256sum, tar, |
| 8 | // rsync, vim, sh, wc). Each subtest verifies the result both via the |
| 9 | // FUSE filesystem and via the daemon's `ipfs files` view. |
| 10 | // |
| 11 | // All external tools are required: a missing binary fails the test |
| 12 | // instead of skipping, so a CI image change cannot silently turn this |
| 13 | // suite green. The whole-suite TEST_FUSE gate is the only place a |
| 14 | // developer is allowed to skip. |
| 15 | // |
| 16 | // Synthetic file payloads default to 1 MiB + 1 byte so multi-chunk |
| 17 | // read/write paths and chunk-boundary off-by-ones are exercised. |
| 18 | |
| 19 | package fuse |
| 20 | |
| 21 | import ( |
| 22 | "bytes" |
| 23 | "crypto/rand" |
| 24 | "crypto/sha256" |
| 25 | "encoding/hex" |
| 26 | "os" |
| 27 | "os/exec" |
| 28 | "path/filepath" |
| 29 | "strconv" |
| 30 | "strings" |
| 31 | "testing" |
| 32 | "time" |
| 33 | |
| 34 | "github.com/ipfs/kubo/config" |
| 35 | "github.com/ipfs/kubo/test/cli/harness" |
| 36 | "github.com/ipfs/kubo/test/cli/testutils" |
| 37 | "github.com/stretchr/testify/require" |
| 38 | ) |
| 39 | |
| 40 | // payloadSize is the default test payload size: 1 MiB + 1 byte. |
| 41 | // Forces multi-chunk DAG construction so single-chunk fast paths |
| 42 | // cannot mask cross-block bugs. |
| 43 | const payloadSize = 1024*1024 + 1 |
| 44 | |
| 45 | func TestFUSERealWorld(t *testing.T) { |
| 46 | testutils.RequiresFUSE(t) |
| 47 | |
| 48 | node := harness.NewT(t).NewNode().Init() |
| 49 | // StoreMtime/StoreMode on so rsync -a, tar -p, vim's chmod, and |
| 50 | // any other tool that round-trips POSIX metadata see consistent |
| 51 | // behaviour. The flags only affect the writable mounts. |
| 52 | node.UpdateConfig(func(cfg *config.Config) { |
| 53 | cfg.Mounts.StoreMtime = config.True |
| 54 | cfg.Mounts.StoreMode = config.True |
| 55 | }) |
| 56 | node.StartDaemon() |
| 57 | defer node.StopDaemon() |
| 58 | |
| 59 | _, _, mfsMount := mountAll(t, node) |
| 60 | |
| 61 | // requireTool fails the current subtest if bin is not in PATH. |
| 62 | // External tools are part of the test contract: a missing binary |
| 63 | // is a hidden coverage gap and we want a loud failure. |
| 64 | requireTool := func(t *testing.T, bins ...string) { |
| 65 | t.Helper() |
| 66 | for _, bin := range bins { |
| 67 | if _, err := exec.LookPath(bin); err != nil { |
| 68 | t.Fatalf("%s not in PATH; required for end-to-end FUSE tests", bin) |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // workdir creates a unique subdirectory under the mount for the |
| 74 | // current subtest. Subtests share one daemon and one mount; using |
| 75 | // disjoint subdirectories keeps them from colliding. |
| 76 | workdir := func(t *testing.T, name string) string { |
| 77 | t.Helper() |
| 78 | d := filepath.Join(mfsMount, name) |
| 79 | require.NoError(t, os.Mkdir(d, 0o755)) |
| 80 | return d |
| 81 | } |
| 82 | |
| 83 | // runCmd runs an external binary and fails the test on error, |
| 84 | // printing both stdout and stderr in the failure message. |
| 85 | // |
| 86 | // LC_ALL=C forces the C locale so any locale-sensitive output |
| 87 | // (date formats in `ls -l`, decimal separators in `wc` output on |
| 88 | // some locales, localized error messages, collation order from |
| 89 | // `find` and `ls`) is deterministic regardless of how the runner |
| 90 | // is configured. Without this the same test could pass on a US |
| 91 | // runner and fail on one with LC_ALL=de_DE.UTF-8. |
| 92 | runCmd := func(t *testing.T, name string, args ...string) string { |
| 93 | t.Helper() |
| 94 | cmd := exec.Command(name, args...) |
| 95 | cmd.Env = append(os.Environ(), "LC_ALL=C") |
| 96 | var stdout, stderr bytes.Buffer |
| 97 | cmd.Stdout = &stdout |
| 98 | cmd.Stderr = &stderr |
| 99 | if err := cmd.Run(); err != nil { |
| 100 | t.Fatalf("%s %v failed: %v\nstdout: %s\nstderr: %s", |
| 101 | name, args, err, stdout.String(), stderr.String()) |
| 102 | } |
| 103 | return stdout.String() |
| 104 | } |
| 105 | |
| 106 | // randBytes returns n cryptographically random bytes. |
| 107 | randBytes := func(t *testing.T, n int) []byte { |
| 108 | t.Helper() |
| 109 | b := make([]byte, n) |
| 110 | _, err := rand.Read(b) |
| 111 | require.NoError(t, err) |
| 112 | return b |
| 113 | } |
| 114 | |
| 115 | // ----- Shell and core POSIX ----- |
| 116 | |
| 117 | t.Run("echo_redirect_and_cat", func(t *testing.T) { |
| 118 | requireTool(t, "sh", "cat") |
| 119 | dir := workdir(t, "echo_redirect_and_cat") |
| 120 | path := filepath.Join(dir, "greeting") |
| 121 | |
| 122 | runCmd(t, "sh", "-c", "echo 'hello fuse' > "+path) |
| 123 | |
| 124 | got := runCmd(t, "cat", path) |
| 125 | require.Equal(t, "hello fuse\n", got, "cat output via FUSE") |
| 126 | |
| 127 | // Cross-verify via daemon's MFS view (bypasses FUSE). |
| 128 | ipfsView := node.IPFS("files", "read", "/echo_redirect_and_cat/greeting").Stdout.String() |
| 129 | require.Equal(t, "hello fuse\n", ipfsView, "ipfs files read view") |
| 130 | }) |
| 131 | |
| 132 | t.Run("seq_pipe_to_file_and_wc", func(t *testing.T) { |
| 133 | requireTool(t, "sh", "seq", "wc") |
| 134 | dir := workdir(t, "seq_pipe_to_file_and_wc") |
| 135 | path := filepath.Join(dir, "lines") |
| 136 | |
| 137 | // 200000 lines: about 1.3 MB of text, comfortably more than |
| 138 | // one UnixFS chunk under the default chunker. |
| 139 | runCmd(t, "sh", "-c", "seq 1 200000 > "+path) |
| 140 | |
| 141 | lineCount := strings.Fields(runCmd(t, "wc", "-l", path))[0] |
| 142 | require.Equal(t, "200000", lineCount) |
| 143 | |
| 144 | // File size should match: digits + newline per line. |
| 145 | // sum_{i=1..9} i*9*1 + sum_{i=10..99} i*90*2 + ... easier to |
| 146 | // just stat the file and compare against wc -c. |
| 147 | byteCount := strings.Fields(runCmd(t, "wc", "-c", path))[0] |
| 148 | info, err := os.Stat(path) |
| 149 | require.NoError(t, err) |
| 150 | require.Equal(t, strconv.FormatInt(info.Size(), 10), byteCount, |
| 151 | "wc -c and stat agree on the multi-chunk file size") |
| 152 | require.Greater(t, info.Size(), int64(payloadSize), |
| 153 | "file should be larger than one chunk") |
| 154 | }) |
| 155 | |
| 156 | t.Run("ls_l_shows_mode_and_size", func(t *testing.T) { |
| 157 | requireTool(t, "ls") |
| 158 | dir := workdir(t, "ls_l_shows_mode_and_size") |
| 159 | path := filepath.Join(dir, "file") |
| 160 | |
| 161 | data := randBytes(t, payloadSize) |
| 162 | require.NoError(t, os.WriteFile(path, data, 0o644)) |
| 163 | |
| 164 | // `ls -l` line layout: <mode> <links> <user> <group> <size> <date> <name> |
| 165 | out := runCmd(t, "ls", "-l", path) |
| 166 | fields := strings.Fields(out) |
| 167 | require.GreaterOrEqual(t, len(fields), 8, "ls -l output: %q", out) |
| 168 | |
| 169 | require.True(t, strings.HasPrefix(fields[0], "-rw-r--r--"), |
| 170 | "mode field %q should be -rw-r--r--", fields[0]) |
| 171 | require.Equal(t, strconv.Itoa(payloadSize), fields[4], |
| 172 | "size field should match payload size") |
| 173 | }) |
| 174 | |
| 175 | t.Run("stat_reports_default_mode", func(t *testing.T) { |
| 176 | requireTool(t, "stat") |
| 177 | dir := workdir(t, "stat_reports_default_mode") |
| 178 | path := filepath.Join(dir, "file") |
| 179 | |
| 180 | f, err := os.Create(path) |
| 181 | require.NoError(t, err) |
| 182 | require.NoError(t, f.Close()) |
| 183 | |
| 184 | out := strings.TrimSpace(runCmd(t, "stat", "-c", "%a %s", path)) |
| 185 | require.Equal(t, "644 0", out, "stat -c '%%a %%s' on a fresh file") |
| 186 | }) |
| 187 | |
| 188 | t.Run("cp_file_in", func(t *testing.T) { |
| 189 | requireTool(t, "cp") |
| 190 | dir := workdir(t, "cp_file_in") |
| 191 | |
| 192 | src := filepath.Join(node.Dir, "cp_file_in_src") |
| 193 | want := randBytes(t, payloadSize) |
| 194 | require.NoError(t, os.WriteFile(src, want, 0o644)) |
| 195 | |
| 196 | dst := filepath.Join(dir, "cp-in") |
| 197 | runCmd(t, "cp", src, dst) |
| 198 | |
| 199 | got, err := os.ReadFile(dst) |
| 200 | require.NoError(t, err) |
| 201 | require.True(t, bytes.Equal(want, got), "FUSE read-back differs") |
| 202 | |
| 203 | // Cross-verify via daemon. ipfs files read can return huge |
| 204 | // blobs; compare lengths first to fail fast. |
| 205 | daemonView := node.IPFS("files", "read", "/cp_file_in/cp-in").Stdout.Bytes() |
| 206 | require.Equal(t, len(want), len(daemonView), "daemon view length") |
| 207 | require.True(t, bytes.Equal(want, daemonView), "daemon view content") |
| 208 | }) |
| 209 | |
| 210 | t.Run("cp_r_tree_in", func(t *testing.T) { |
| 211 | requireTool(t, "cp") |
| 212 | dir := workdir(t, "cp_r_tree_in") |
| 213 | |
| 214 | // Build the source tree under node.Dir. |
| 215 | srcRoot := filepath.Join(node.Dir, "cp_r_tree_in_src") |
| 216 | require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "a", "b", "c"), 0o755)) |
| 217 | |
| 218 | topData := randBytes(t, payloadSize) |
| 219 | leafData := randBytes(t, payloadSize) |
| 220 | require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "top.bin"), topData, 0o644)) |
| 221 | require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "a", "b", "c", "leaf.bin"), leafData, 0o644)) |
| 222 | |
| 223 | runCmd(t, "cp", "-r", srcRoot, dir+"/") |
| 224 | |
| 225 | // Walk the FUSE side and assert both files match. |
| 226 | gotTop, err := os.ReadFile(filepath.Join(dir, "cp_r_tree_in_src", "top.bin")) |
| 227 | require.NoError(t, err) |
| 228 | require.True(t, bytes.Equal(topData, gotTop), "top file content") |
| 229 | |
| 230 | gotLeaf, err := os.ReadFile(filepath.Join(dir, "cp_r_tree_in_src", "a", "b", "c", "leaf.bin")) |
| 231 | require.NoError(t, err) |
| 232 | require.True(t, bytes.Equal(leafData, gotLeaf), "leaf file content") |
| 233 | |
| 234 | // Cross-verify the deepest file via the daemon. |
| 235 | daemonView := node.IPFS("files", "read", |
| 236 | "/cp_r_tree_in/cp_r_tree_in_src/a/b/c/leaf.bin").Stdout.Bytes() |
| 237 | require.True(t, bytes.Equal(leafData, daemonView), "daemon view of deepest leaf") |
| 238 | }) |
| 239 | |
| 240 | t.Run("cp_file_out", func(t *testing.T) { |
| 241 | requireTool(t, "cp") |
| 242 | dir := workdir(t, "cp_file_out") |
| 243 | |
| 244 | want := randBytes(t, payloadSize) |
| 245 | src := filepath.Join(dir, "payload") |
| 246 | require.NoError(t, os.WriteFile(src, want, 0o644)) |
| 247 | |
| 248 | dst := filepath.Join(node.Dir, "cp_file_out_dst") |
| 249 | runCmd(t, "cp", src, dst) |
| 250 | |
| 251 | got, err := os.ReadFile(dst) |
| 252 | require.NoError(t, err) |
| 253 | require.True(t, bytes.Equal(want, got), "exported file content") |
| 254 | }) |
| 255 | |
| 256 | t.Run("mv_atomic_save", func(t *testing.T) { |
| 257 | requireTool(t, "mv") |
| 258 | dir := workdir(t, "mv_atomic_save") |
| 259 | |
| 260 | oldData := randBytes(t, payloadSize) |
| 261 | newData := randBytes(t, payloadSize) |
| 262 | |
| 263 | target := filepath.Join(dir, "target") |
| 264 | tmp := filepath.Join(dir, ".target.tmp") |
| 265 | |
| 266 | require.NoError(t, os.WriteFile(target, oldData, 0o644)) |
| 267 | require.NoError(t, os.WriteFile(tmp, newData, 0o644)) |
| 268 | |
| 269 | runCmd(t, "mv", tmp, target) |
| 270 | |
| 271 | got, err := os.ReadFile(target) |
| 272 | require.NoError(t, err) |
| 273 | require.True(t, bytes.Equal(newData, got), "target should now hold new data") |
| 274 | |
| 275 | _, err = os.Stat(tmp) |
| 276 | require.True(t, os.IsNotExist(err), "tmp should be gone after mv") |
| 277 | }) |
| 278 | |
| 279 | t.Run("rm_rf_tree", func(t *testing.T) { |
| 280 | requireTool(t, "cp", "rm") |
| 281 | dir := workdir(t, "rm_rf_tree") |
| 282 | |
| 283 | // Build a tree the same shape as cp_r_tree_in. |
| 284 | srcRoot := filepath.Join(node.Dir, "rm_rf_tree_src") |
| 285 | require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "a", "b", "c"), 0o755)) |
| 286 | require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "top.bin"), randBytes(t, payloadSize), 0o644)) |
| 287 | require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "a", "b", "c", "leaf.bin"), randBytes(t, payloadSize), 0o644)) |
| 288 | |
| 289 | runCmd(t, "cp", "-r", srcRoot, dir+"/") |
| 290 | copied := filepath.Join(dir, "rm_rf_tree_src") |
| 291 | |
| 292 | // Sanity: tree exists. |
| 293 | _, err := os.Stat(filepath.Join(copied, "a", "b", "c", "leaf.bin")) |
| 294 | require.NoError(t, err) |
| 295 | |
| 296 | runCmd(t, "rm", "-rf", copied) |
| 297 | |
| 298 | _, err = os.Stat(copied) |
| 299 | require.True(t, os.IsNotExist(err), "copied tree should be gone") |
| 300 | |
| 301 | // Cross-verify the daemon no longer lists the subtree. |
| 302 | listing := node.IPFS("files", "ls", "/rm_rf_tree").Stdout.String() |
| 303 | require.NotContains(t, listing, "rm_rf_tree_src", |
| 304 | "ipfs files ls should not see the removed subtree") |
| 305 | }) |
| 306 | |
| 307 | t.Run("ln_s_and_readlink", func(t *testing.T) { |
| 308 | requireTool(t, "ln", "readlink", "ls") |
| 309 | dir := workdir(t, "ln_s_and_readlink") |
| 310 | link := filepath.Join(dir, "link") |
| 311 | |
| 312 | runCmd(t, "ln", "-s", "/tmp/some/target", link) |
| 313 | |
| 314 | target := strings.TrimSpace(runCmd(t, "readlink", link)) |
| 315 | require.Equal(t, "/tmp/some/target", target) |
| 316 | |
| 317 | // ls -l on a symlink starts with 'l'. |
| 318 | lsOut := runCmd(t, "ls", "-l", link) |
| 319 | require.True(t, strings.HasPrefix(lsOut, "l"), |
| 320 | "ls -l output should start with 'l' for a symlink, got: %q", lsOut) |
| 321 | |
| 322 | // Daemon view: ipfs files stat reports symlinks via the Mode |
| 323 | // field (lrwxrwxrwx). The Type field is "file" because MFS |
| 324 | // stores symlinks as TFile/TSymlink under the hood. |
| 325 | stat := node.IPFS("files", "stat", "/ln_s_and_readlink/link").Stdout.String() |
| 326 | require.Contains(t, stat, "lrwxrwxrwx", |
| 327 | "ipfs files stat mode should be lrwxrwxrwx for a symlink, got: %s", stat) |
| 328 | }) |
| 329 | |
| 330 | t.Run("find_traversal", func(t *testing.T) { |
| 331 | requireTool(t, "find", "ln") |
| 332 | dir := workdir(t, "find_traversal") |
| 333 | |
| 334 | require.NoError(t, os.WriteFile(filepath.Join(dir, "regular"), randBytes(t, payloadSize), 0o644)) |
| 335 | require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0o755)) |
| 336 | runCmd(t, "ln", "-s", "regular", filepath.Join(dir, "link")) |
| 337 | |
| 338 | // strings.Fields splits on any whitespace; this is safe here |
| 339 | // because every test filename is ASCII with no spaces. If a |
| 340 | // future maintainer adds a filename with whitespace, switch |
| 341 | // to `find -print0` and split on '\x00' instead. |
| 342 | |
| 343 | // -type f should find exactly the regular file. |
| 344 | files := strings.Fields(runCmd(t, "find", dir, "-type", "f")) |
| 345 | require.Equal(t, []string{filepath.Join(dir, "regular")}, files) |
| 346 | |
| 347 | // -type d should find dir itself plus subdir. |
| 348 | dirs := strings.Fields(runCmd(t, "find", dir, "-type", "d")) |
| 349 | require.ElementsMatch(t, []string{dir, filepath.Join(dir, "subdir")}, dirs) |
| 350 | |
| 351 | // -type l should find exactly the symlink. |
| 352 | links := strings.Fields(runCmd(t, "find", dir, "-type", "l")) |
| 353 | require.Equal(t, []string{filepath.Join(dir, "link")}, links) |
| 354 | }) |
| 355 | |
| 356 | t.Run("dd_block_write", func(t *testing.T) { |
| 357 | requireTool(t, "dd") |
| 358 | dir := workdir(t, "dd_block_write") |
| 359 | path := filepath.Join(dir, "blob") |
| 360 | |
| 361 | // 4096 * 257 = 1052672 bytes, just past the 1 MiB chunk |
| 362 | // boundary. Uses /dev/urandom to avoid pulling all-zero |
| 363 | // pages from the kernel cache. |
| 364 | runCmd(t, "dd", |
| 365 | "if=/dev/urandom", |
| 366 | "of="+path, |
| 367 | "bs=4096", |
| 368 | "count=257", |
| 369 | "status=none", |
| 370 | ) |
| 371 | |
| 372 | info, err := os.Stat(path) |
| 373 | require.NoError(t, err) |
| 374 | require.Equal(t, int64(4096*257), info.Size()) |
| 375 | }) |
| 376 | |
| 377 | t.Run("sha256sum_roundtrip", func(t *testing.T) { |
| 378 | requireTool(t, "sha256sum") |
| 379 | dir := workdir(t, "sha256sum_roundtrip") |
| 380 | path := filepath.Join(dir, "blob") |
| 381 | |
| 382 | want := randBytes(t, payloadSize) |
| 383 | require.NoError(t, os.WriteFile(path, want, 0o644)) |
| 384 | |
| 385 | hash := sha256.Sum256(want) |
| 386 | wantHex := hex.EncodeToString(hash[:]) |
| 387 | |
| 388 | out := runCmd(t, "sha256sum", path) |
| 389 | // `sha256sum` prints "<hex> <path>". |
| 390 | gotHex := strings.Fields(out)[0] |
| 391 | require.Equal(t, wantHex, gotHex, |
| 392 | "sha256sum on FUSE-read bytes should match the bytes we wrote") |
| 393 | }) |
| 394 | |
| 395 | // ----- Archives ----- |
| 396 | |
| 397 | t.Run("tar_extract_into_mfs", func(t *testing.T) { |
| 398 | requireTool(t, "tar") |
| 399 | dir := workdir(t, "tar_extract_into_mfs") |
| 400 | |
| 401 | // Build the source tree and tar it up under node.Dir. |
| 402 | srcRoot := filepath.Join(node.Dir, "tar_extract_src") |
| 403 | require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "sub"), 0o755)) |
| 404 | oneData := randBytes(t, payloadSize) |
| 405 | twoData := randBytes(t, payloadSize) |
| 406 | require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "one.bin"), oneData, 0o644)) |
| 407 | require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "sub", "two.bin"), twoData, 0o644)) |
| 408 | |
| 409 | tarPath := filepath.Join(node.Dir, "tar_extract.tar") |
| 410 | runCmd(t, "tar", "-cf", tarPath, "-C", node.Dir, "tar_extract_src") |
| 411 | |
| 412 | // Extract into the FUSE mount. |
| 413 | runCmd(t, "tar", "-xf", tarPath, "-C", dir) |
| 414 | |
| 415 | extracted := filepath.Join(dir, "tar_extract_src") |
| 416 | gotOne, err := os.ReadFile(filepath.Join(extracted, "one.bin")) |
| 417 | require.NoError(t, err) |
| 418 | require.True(t, bytes.Equal(oneData, gotOne), "one.bin content") |
| 419 | |
| 420 | gotTwo, err := os.ReadFile(filepath.Join(extracted, "sub", "two.bin")) |
| 421 | require.NoError(t, err) |
| 422 | require.True(t, bytes.Equal(twoData, gotTwo), "two.bin content") |
| 423 | }) |
| 424 | |
| 425 | t.Run("tar_create_from_mfs", func(t *testing.T) { |
| 426 | requireTool(t, "tar") |
| 427 | dir := workdir(t, "tar_create_from_mfs") |
| 428 | |
| 429 | // Populate a small tree under the FUSE mount. |
| 430 | srcRoot := filepath.Join(dir, "src") |
| 431 | require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "sub"), 0o755)) |
| 432 | oneData := randBytes(t, payloadSize) |
| 433 | twoData := randBytes(t, payloadSize) |
| 434 | require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "one.bin"), oneData, 0o644)) |
| 435 | require.NoError(t, os.WriteFile(filepath.Join(srcRoot, "sub", "two.bin"), twoData, 0o644)) |
| 436 | |
| 437 | // tar it up *from* the mount. |
| 438 | tarPath := filepath.Join(node.Dir, "tar_create.tar") |
| 439 | runCmd(t, "tar", "-cf", tarPath, "-C", dir, "src") |
| 440 | |
| 441 | // tar listing should include both leaves. |
| 442 | listing := runCmd(t, "tar", "-tf", tarPath) |
| 443 | require.Contains(t, listing, "src/one.bin") |
| 444 | require.Contains(t, listing, "src/sub/two.bin") |
| 445 | |
| 446 | // Extract back to a fresh dir off the mount and byte-compare. |
| 447 | extractDir := filepath.Join(node.Dir, "tar_create_extract") |
| 448 | require.NoError(t, os.MkdirAll(extractDir, 0o755)) |
| 449 | runCmd(t, "tar", "-xf", tarPath, "-C", extractDir) |
| 450 | |
| 451 | gotOne, err := os.ReadFile(filepath.Join(extractDir, "src", "one.bin")) |
| 452 | require.NoError(t, err) |
| 453 | require.True(t, bytes.Equal(oneData, gotOne), "one.bin survives tar round-trip") |
| 454 | |
| 455 | gotTwo, err := os.ReadFile(filepath.Join(extractDir, "src", "sub", "two.bin")) |
| 456 | require.NoError(t, err) |
| 457 | require.True(t, bytes.Equal(twoData, gotTwo), "two.bin survives tar round-trip") |
| 458 | }) |
| 459 | |
| 460 | // ----- Rsync ----- |
| 461 | |
| 462 | t.Run("rsync_archive_in", func(t *testing.T) { |
| 463 | requireTool(t, "rsync") |
| 464 | dir := workdir(t, "rsync_archive_in") |
| 465 | |
| 466 | // Build a tree under node.Dir with a known mode and mtime. |
| 467 | srcRoot := filepath.Join(node.Dir, "rsync_archive_src") |
| 468 | require.NoError(t, os.MkdirAll(filepath.Join(srcRoot, "sub"), 0o755)) |
| 469 | |
| 470 | oneData := randBytes(t, payloadSize) |
| 471 | twoData := randBytes(t, payloadSize) |
| 472 | onePath := filepath.Join(srcRoot, "one.bin") |
| 473 | twoPath := filepath.Join(srcRoot, "sub", "two.bin") |
| 474 | require.NoError(t, os.WriteFile(onePath, oneData, 0o640)) |
| 475 | require.NoError(t, os.WriteFile(twoPath, twoData, 0o640)) |
| 476 | |
| 477 | mtime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) |
| 478 | require.NoError(t, os.Chtimes(onePath, mtime, mtime)) |
| 479 | require.NoError(t, os.Chtimes(twoPath, mtime, mtime)) |
| 480 | |
| 481 | // Trailing slash on source: copy the contents of srcRoot, |
| 482 | // not the directory itself. Mirrors typical rsync usage. |
| 483 | runCmd(t, "rsync", "-a", srcRoot+"/", dir+"/copy/") |
| 484 | |
| 485 | gotOne, err := os.ReadFile(filepath.Join(dir, "copy", "one.bin")) |
| 486 | require.NoError(t, err) |
| 487 | require.True(t, bytes.Equal(oneData, gotOne), "one.bin content") |
| 488 | |
| 489 | gotTwo, err := os.ReadFile(filepath.Join(dir, "copy", "sub", "two.bin")) |
| 490 | require.NoError(t, err) |
| 491 | require.True(t, bytes.Equal(twoData, gotTwo), "two.bin content") |
| 492 | |
| 493 | // Mode preserved (StoreMode is enabled at the daemon level). |
| 494 | oneInfo, err := os.Stat(filepath.Join(dir, "copy", "one.bin")) |
| 495 | require.NoError(t, err) |
| 496 | require.Equal(t, os.FileMode(0o640), oneInfo.Mode().Perm(), |
| 497 | "mode should be preserved through rsync -a") |
| 498 | |
| 499 | // Mtime preserved (StoreMtime is enabled at the daemon level). |
| 500 | require.WithinDuration(t, mtime, oneInfo.ModTime(), time.Second, |
| 501 | "mtime should be preserved through rsync -a") |
| 502 | }) |
| 503 | |
| 504 | t.Run("rsync_inplace_overwrite", func(t *testing.T) { |
| 505 | requireTool(t, "rsync") |
| 506 | dir := workdir(t, "rsync_inplace_overwrite") |
| 507 | |
| 508 | // Initial file is larger than the replacement so the inplace |
| 509 | // path has to truncate the tail. |
| 510 | initial := randBytes(t, payloadSize+4096) |
| 511 | dst := filepath.Join(dir, "inplace") |
| 512 | require.NoError(t, os.WriteFile(dst, initial, 0o644)) |
| 513 | |
| 514 | replacement := randBytes(t, payloadSize) |
| 515 | src := filepath.Join(node.Dir, "rsync_inplace_replacement") |
| 516 | require.NoError(t, os.WriteFile(src, replacement, 0o644)) |
| 517 | |
| 518 | runCmd(t, "rsync", "--inplace", src, dst) |
| 519 | |
| 520 | got, err := os.ReadFile(dst) |
| 521 | require.NoError(t, err) |
| 522 | require.Equal(t, len(replacement), len(got), |
| 523 | "file size should shrink to replacement size after --inplace") |
| 524 | require.True(t, bytes.Equal(replacement, got), |
| 525 | "content should match the replacement after --inplace") |
| 526 | }) |
| 527 | |
| 528 | // ----- Editor ----- |
| 529 | |
| 530 | t.Run("vim_edit_file", func(t *testing.T) { |
| 531 | requireTool(t, "vim") |
| 532 | dir := workdir(t, "vim_edit_file") |
| 533 | path := filepath.Join(dir, "edit.txt") |
| 534 | |
| 535 | // Build a multi-chunk file: a header line followed by enough |
| 536 | // "world" repeats to push the total size past one UnixFS chunk. |
| 537 | const word = "world\n" |
| 538 | repeats := payloadSize/len(word) + 1 |
| 539 | var buf bytes.Buffer |
| 540 | buf.WriteString("header\n") |
| 541 | for range repeats { |
| 542 | buf.WriteString(word) |
| 543 | } |
| 544 | original := buf.Bytes() |
| 545 | require.NoError(t, os.WriteFile(path, original, 0o644)) |
| 546 | require.Greater(t, len(original), payloadSize, "file should span multiple chunks") |
| 547 | |
| 548 | // Vim in headless ex mode: substitute world->fuse globally, |
| 549 | // write, quit. -E selects ex mode, -s suppresses prompts. |
| 550 | runCmd(t, "vim", "-E", "-s", |
| 551 | "-c", "%s/world/fuse/g", |
| 552 | "-c", "wq", |
| 553 | path, |
| 554 | ) |
| 555 | |
| 556 | got, err := os.ReadFile(path) |
| 557 | require.NoError(t, err) |
| 558 | require.NotContains(t, string(got), "world", |
| 559 | "after :%%s/world/fuse/g the file should contain no 'world'") |
| 560 | gotFuses := bytes.Count(got, []byte("fuse")) |
| 561 | require.Equal(t, repeats, gotFuses, |
| 562 | "the substitution should have replaced exactly %d occurrences", repeats) |
| 563 | |
| 564 | // Cross-verify via daemon. |
| 565 | daemonView := node.IPFS("files", "read", "/vim_edit_file/edit.txt").Stdout.Bytes() |
| 566 | require.True(t, bytes.Equal(got, daemonView), |
| 567 | "daemon view should match FUSE view after vim save") |
| 568 | }) |
| 569 | } |