master
go 569 lines 19.9 KB
Raw
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 }