master
go 965 lines 34.6 KB
Raw
1 package cli
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 ft "github.com/ipfs/boxo/ipld/unixfs"
12 "github.com/ipfs/kubo/config"
13 "github.com/ipfs/kubo/test/cli/harness"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16 )
17
18 func TestFilesCp(t *testing.T) {
19 t.Parallel()
20
21 t.Run("files cp with valid UnixFS succeeds", func(t *testing.T) {
22 t.Parallel()
23
24 node := harness.NewT(t).NewNode().Init().StartDaemon()
25 defer node.StopDaemon()
26
27 // Create simple text file
28 data := "testing files cp command"
29 cid := node.IPFSAddStr(data)
30
31 // Copy form IPFS => MFS
32 res := node.IPFS("files", "cp", fmt.Sprintf("/ipfs/%s", cid), "/valid-file")
33 assert.NoError(t, res.Err)
34
35 // verification
36 catRes := node.IPFS("files", "read", "/valid-file")
37 assert.Equal(t, data, catRes.Stdout.Trimmed())
38 })
39
40 t.Run("files cp with unsupported DAG node type fails", func(t *testing.T) {
41 t.Parallel()
42 node := harness.NewT(t).NewNode().Init().StartDaemon()
43 defer node.StopDaemon()
44
45 // MFS UnixFS is limited to dag-pb or raw, so we create a dag-cbor node to test this
46 jsonData := `{"data": "not a UnixFS node"}`
47 tempFile := filepath.Join(node.Dir, "test.json")
48 err := os.WriteFile(tempFile, []byte(jsonData), 0644)
49 require.NoError(t, err)
50 cid := node.IPFS("dag", "put", "--input-codec=json", "--store-codec=dag-cbor", tempFile).Stdout.Trimmed()
51
52 // copy without --force
53 res := node.RunIPFS("files", "cp", fmt.Sprintf("/ipfs/%s", cid), "/invalid-file")
54 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
55 assert.Contains(t, res.Stderr.String(), "Error: cp: source must be a valid UnixFS (dag-pb or raw codec)")
56 })
57
58 t.Run("files cp with invalid UnixFS data structure fails", func(t *testing.T) {
59 t.Parallel()
60 node := harness.NewT(t).NewNode().Init().StartDaemon()
61 defer node.StopDaemon()
62
63 // Create an invalid proto file
64 data := []byte{0xDE, 0xAD, 0xBE, 0xEF} // Invalid protobuf data
65 tempFile := filepath.Join(node.Dir, "invalid-proto.bin")
66 err := os.WriteFile(tempFile, data, 0644)
67 require.NoError(t, err)
68
69 res := node.IPFS("block", "put", "--format=raw", tempFile)
70 require.NoError(t, res.Err)
71
72 // we manually changed codec from raw to dag-pb to test "bad dag-pb" scenario
73 cid := "bafybeic7pdbte5heh6u54vszezob3el6exadoiw4wc4ne7ny2x7kvajzkm"
74
75 // should fail because node cannot be read as a valid dag-pb
76 cpResNoForce := node.RunIPFS("files", "cp", fmt.Sprintf("/ipfs/%s", cid), "/invalid-proto")
77 assert.NotEqual(t, 0, cpResNoForce.ExitErr.ExitCode())
78 assert.Contains(t, cpResNoForce.Stderr.String(), "Error")
79 })
80
81 t.Run("files cp with raw node succeeds", func(t *testing.T) {
82 t.Parallel()
83 node := harness.NewT(t).NewNode().Init().StartDaemon()
84 defer node.StopDaemon()
85
86 // Create a raw node
87 data := "raw data"
88 tempFile := filepath.Join(node.Dir, "raw.bin")
89 err := os.WriteFile(tempFile, []byte(data), 0644)
90 require.NoError(t, err)
91
92 res := node.IPFS("block", "put", "--format=raw", tempFile)
93 require.NoError(t, res.Err)
94 cid := res.Stdout.Trimmed()
95
96 // Copy from IPFS to MFS (raw nodes should work without --force)
97 cpRes := node.IPFS("files", "cp", fmt.Sprintf("/ipfs/%s", cid), "/raw-file")
98 assert.NoError(t, cpRes.Err)
99
100 // Verify the file was copied correctly
101 catRes := node.IPFS("files", "read", "/raw-file")
102 assert.Equal(t, data, catRes.Stdout.Trimmed())
103 })
104
105 t.Run("files cp creates intermediate directories with -p", func(t *testing.T) {
106 t.Parallel()
107 node := harness.NewT(t).NewNode().Init().StartDaemon()
108 defer node.StopDaemon()
109
110 // Create a simple text file and add it to IPFS
111 data := "hello parent directories"
112 tempFile := filepath.Join(node.Dir, "parent-test.txt")
113 err := os.WriteFile(tempFile, []byte(data), 0644)
114 require.NoError(t, err)
115
116 cid := node.IPFS("add", "-Q", tempFile).Stdout.Trimmed()
117
118 // Copy from IPFS to MFS with parent flag
119 res := node.IPFS("files", "cp", "-p", fmt.Sprintf("/ipfs/%s", cid), "/parent/dir/file")
120 assert.NoError(t, res.Err)
121
122 // Verify the file and directories were created
123 lsRes := node.IPFS("files", "ls", "/parent/dir")
124 assert.Contains(t, lsRes.Stdout.String(), "file")
125
126 catRes := node.IPFS("files", "read", "/parent/dir/file")
127 assert.Equal(t, data, catRes.Stdout.Trimmed())
128 })
129 }
130
131 func TestFilesRm(t *testing.T) {
132 t.Parallel()
133
134 t.Run("files rm with --flush=false returns error", func(t *testing.T) {
135 // Test that files rm rejects --flush=false so user does not assume disabling flush works
136 // (rm ignored it before, better to explicitly error)
137 // See https://github.com/ipfs/kubo/issues/10842
138 t.Parallel()
139
140 node := harness.NewT(t).NewNode().Init().StartDaemon()
141 defer node.StopDaemon()
142
143 // Create a file to remove
144 node.IPFS("files", "mkdir", "/test-dir")
145
146 // Try to remove with --flush=false, should error
147 res := node.RunIPFS("files", "rm", "-r", "--flush=false", "/test-dir")
148 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
149 assert.Contains(t, res.Stderr.String(), "files rm always flushes for safety")
150 assert.Contains(t, res.Stderr.String(), "cannot be set to false")
151
152 // Verify the directory still exists (wasn't removed due to error)
153 lsRes := node.IPFS("files", "ls", "/")
154 assert.Contains(t, lsRes.Stdout.String(), "test-dir")
155 })
156
157 t.Run("files rm with --flush=true works", func(t *testing.T) {
158 t.Parallel()
159
160 node := harness.NewT(t).NewNode().Init().StartDaemon()
161 defer node.StopDaemon()
162
163 // Create a file to remove
164 node.IPFS("files", "mkdir", "/test-dir")
165
166 // Remove with explicit --flush=true, should work
167 res := node.IPFS("files", "rm", "-r", "--flush=true", "/test-dir")
168 assert.NoError(t, res.Err)
169
170 // Verify the directory was removed
171 lsRes := node.IPFS("files", "ls", "/")
172 assert.NotContains(t, lsRes.Stdout.String(), "test-dir")
173 })
174
175 t.Run("files rm without flush flag works (default behavior)", func(t *testing.T) {
176 t.Parallel()
177
178 node := harness.NewT(t).NewNode().Init().StartDaemon()
179 defer node.StopDaemon()
180
181 // Create a file to remove
182 node.IPFS("files", "mkdir", "/test-dir")
183
184 // Remove without flush flag (should use default which is true)
185 res := node.IPFS("files", "rm", "-r", "/test-dir")
186 assert.NoError(t, res.Err)
187
188 // Verify the directory was removed
189 lsRes := node.IPFS("files", "ls", "/")
190 assert.NotContains(t, lsRes.Stdout.String(), "test-dir")
191 })
192 }
193
194 func TestFilesNoFlushLimit(t *testing.T) {
195 t.Parallel()
196
197 t.Run("reaches default limit of 256 operations", func(t *testing.T) {
198 t.Parallel()
199 node := harness.NewT(t).NewNode().Init().StartDaemon()
200 defer node.StopDaemon()
201
202 // Perform 256 operations with --flush=false (should succeed)
203 for i := range 256 {
204 res := node.IPFS("files", "mkdir", "--flush=false", fmt.Sprintf("/dir%d", i))
205 assert.NoError(t, res.Err, "operation %d should succeed", i+1)
206 }
207
208 // 257th operation should fail
209 res := node.RunIPFS("files", "mkdir", "--flush=false", "/dir256")
210 require.NotNil(t, res.ExitErr, "command should have failed")
211 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
212 assert.Contains(t, res.Stderr.String(), "reached limit of 256 unflushed MFS operations")
213 assert.Contains(t, res.Stderr.String(), "run 'ipfs files flush'")
214 assert.Contains(t, res.Stderr.String(), "use --flush=true")
215 assert.Contains(t, res.Stderr.String(), "increase Internal.MFSNoFlushLimit")
216 })
217
218 t.Run("custom limit via config", func(t *testing.T) {
219 t.Parallel()
220 node := harness.NewT(t).NewNode().Init()
221
222 // Set custom limit to 5
223 node.UpdateConfig(func(cfg *config.Config) {
224 limit := config.NewOptionalInteger(5)
225 cfg.Internal.MFSNoFlushLimit = limit
226 })
227
228 node.StartDaemon()
229 defer node.StopDaemon()
230
231 // Perform 5 operations (should succeed)
232 for i := range 5 {
233 res := node.IPFS("files", "mkdir", "--flush=false", fmt.Sprintf("/dir%d", i))
234 assert.NoError(t, res.Err, "operation %d should succeed", i+1)
235 }
236
237 // 6th operation should fail
238 res := node.RunIPFS("files", "mkdir", "--flush=false", "/dir5")
239 require.NotNil(t, res.ExitErr, "command should have failed")
240 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
241 assert.Contains(t, res.Stderr.String(), "reached limit of 5 unflushed MFS operations")
242 })
243
244 t.Run("flush=true resets counter", func(t *testing.T) {
245 t.Parallel()
246 node := harness.NewT(t).NewNode().Init()
247
248 // Set limit to 3 for faster testing
249 node.UpdateConfig(func(cfg *config.Config) {
250 limit := config.NewOptionalInteger(3)
251 cfg.Internal.MFSNoFlushLimit = limit
252 })
253
254 node.StartDaemon()
255 defer node.StopDaemon()
256
257 // Do 2 operations with --flush=false
258 node.IPFS("files", "mkdir", "--flush=false", "/dir1")
259 node.IPFS("files", "mkdir", "--flush=false", "/dir2")
260
261 // Operation with --flush=true should reset counter
262 node.IPFS("files", "mkdir", "--flush=true", "/dir3")
263
264 // Now we should be able to do 3 more operations with --flush=false
265 for i := 4; i <= 6; i++ {
266 res := node.IPFS("files", "mkdir", "--flush=false", fmt.Sprintf("/dir%d", i))
267 assert.NoError(t, res.Err, "operation after flush should succeed")
268 }
269
270 // 4th operation after reset should fail
271 res := node.RunIPFS("files", "mkdir", "--flush=false", "/dir7")
272 require.NotNil(t, res.ExitErr, "command should have failed")
273 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
274 assert.Contains(t, res.Stderr.String(), "reached limit of 3 unflushed MFS operations")
275 })
276
277 t.Run("explicit flush command resets counter", func(t *testing.T) {
278 t.Parallel()
279 node := harness.NewT(t).NewNode().Init()
280
281 // Set limit to 3 for faster testing
282 node.UpdateConfig(func(cfg *config.Config) {
283 limit := config.NewOptionalInteger(3)
284 cfg.Internal.MFSNoFlushLimit = limit
285 })
286
287 node.StartDaemon()
288 defer node.StopDaemon()
289
290 // Do 2 operations with --flush=false
291 node.IPFS("files", "mkdir", "--flush=false", "/dir1")
292 node.IPFS("files", "mkdir", "--flush=false", "/dir2")
293
294 // Explicit flush should reset counter
295 node.IPFS("files", "flush")
296
297 // Now we should be able to do 3 more operations
298 for i := 3; i <= 5; i++ {
299 res := node.IPFS("files", "mkdir", "--flush=false", fmt.Sprintf("/dir%d", i))
300 assert.NoError(t, res.Err, "operation after flush should succeed")
301 }
302
303 // 4th operation should fail
304 res := node.RunIPFS("files", "mkdir", "--flush=false", "/dir6")
305 require.NotNil(t, res.ExitErr, "command should have failed")
306 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
307 assert.Contains(t, res.Stderr.String(), "reached limit of 3 unflushed MFS operations")
308 })
309
310 t.Run("limit=0 disables the feature", func(t *testing.T) {
311 t.Parallel()
312 node := harness.NewT(t).NewNode().Init()
313
314 // Set limit to 0 (disabled)
315 node.UpdateConfig(func(cfg *config.Config) {
316 limit := config.NewOptionalInteger(0)
317 cfg.Internal.MFSNoFlushLimit = limit
318 })
319
320 node.StartDaemon()
321 defer node.StopDaemon()
322
323 // Should be able to do many operations without error
324 for i := range 300 {
325 res := node.IPFS("files", "mkdir", "--flush=false", fmt.Sprintf("/dir%d", i))
326 assert.NoError(t, res.Err, "operation %d should succeed with limit disabled", i+1)
327 }
328 })
329
330 t.Run("different MFS commands count towards limit", func(t *testing.T) {
331 t.Parallel()
332 node := harness.NewT(t).NewNode().Init()
333
334 // Set limit to 5 for testing
335 node.UpdateConfig(func(cfg *config.Config) {
336 limit := config.NewOptionalInteger(5)
337 cfg.Internal.MFSNoFlushLimit = limit
338 })
339
340 node.StartDaemon()
341 defer node.StopDaemon()
342
343 // Mix of different MFS operations (5 operations to hit the limit)
344 node.IPFS("files", "mkdir", "--flush=false", "/testdir")
345 // Create a file first, then copy it
346 testCid := node.IPFSAddStr("test content")
347 node.IPFS("files", "cp", "--flush=false", fmt.Sprintf("/ipfs/%s", testCid), "/testfile")
348 node.IPFS("files", "cp", "--flush=false", "/testfile", "/testfile2")
349 node.IPFS("files", "mv", "--flush=false", "/testfile2", "/testfile3")
350 node.IPFS("files", "mkdir", "--flush=false", "/anotherdir")
351
352 // 6th operation should fail
353 res := node.RunIPFS("files", "mkdir", "--flush=false", "/another")
354 require.NotNil(t, res.ExitErr, "command should have failed")
355 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
356 assert.Contains(t, res.Stderr.String(), "reached limit of 5 unflushed MFS operations")
357 })
358 }
359
360 func TestFilesChroot(t *testing.T) {
361 t.Parallel()
362
363 // Known CIDs for testing
364 emptyDirCid := "QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn"
365
366 t.Run("requires --confirm flag", func(t *testing.T) {
367 t.Parallel()
368 node := harness.NewT(t).NewNode().Init()
369 // Don't start daemon - chroot runs offline
370
371 res := node.RunIPFS("files", "chroot")
372 require.NotNil(t, res.ExitErr)
373 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
374 assert.Contains(t, res.Stderr.String(), "pass --confirm to proceed")
375 })
376
377 t.Run("resets to empty directory", func(t *testing.T) {
378 t.Parallel()
379 node := harness.NewT(t).NewNode().Init()
380
381 // Start daemon to create MFS state
382 node.StartDaemon()
383 node.IPFS("files", "mkdir", "/testdir")
384 node.StopDaemon()
385
386 // Reset MFS to empty - should exit 0
387 res := node.RunIPFS("files", "chroot", "--confirm")
388 assert.Nil(t, res.ExitErr, "expected exit code 0")
389 assert.Contains(t, res.Stdout.String(), emptyDirCid)
390
391 // Verify daemon starts and MFS is empty
392 node.StartDaemon()
393 defer node.StopDaemon()
394 lsRes := node.IPFS("files", "ls", "/")
395 assert.Empty(t, lsRes.Stdout.Trimmed())
396 })
397
398 t.Run("replaces with valid directory CID", func(t *testing.T) {
399 t.Parallel()
400 node := harness.NewT(t).NewNode().Init()
401
402 // Start daemon to add content
403 node.StartDaemon()
404 node.IPFS("files", "mkdir", "/mydir")
405 // Create a temp file for content
406 tempFile := filepath.Join(node.Dir, "testfile.txt")
407 require.NoError(t, os.WriteFile(tempFile, []byte("hello"), 0644))
408 node.IPFS("files", "write", "--create", "/mydir/file.txt", tempFile)
409 statRes := node.IPFS("files", "stat", "--hash", "/mydir")
410 dirCid := statRes.Stdout.Trimmed()
411 node.StopDaemon()
412
413 // Reset to empty first
414 node.IPFS("files", "chroot", "--confirm")
415
416 // Set root to the saved directory - should exit 0
417 res := node.RunIPFS("files", "chroot", "--confirm", dirCid)
418 assert.Nil(t, res.ExitErr, "expected exit code 0")
419 assert.Contains(t, res.Stdout.String(), dirCid)
420
421 // Verify content
422 node.StartDaemon()
423 defer node.StopDaemon()
424 readRes := node.IPFS("files", "read", "/file.txt")
425 assert.Equal(t, "hello", readRes.Stdout.Trimmed())
426 })
427
428 t.Run("fails with non-existent CID", func(t *testing.T) {
429 t.Parallel()
430 node := harness.NewT(t).NewNode().Init()
431
432 res := node.RunIPFS("files", "chroot", "--confirm", "bafybeibdxtd5thfoitjmnfhxhywokebwdmwnuqgkzjjdjhwjz7qh77777a")
433 require.NotNil(t, res.ExitErr)
434 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
435 assert.Contains(t, res.Stderr.String(), "does not exist locally")
436 })
437
438 t.Run("fails with file CID", func(t *testing.T) {
439 t.Parallel()
440 node := harness.NewT(t).NewNode().Init()
441
442 // Add a file to get a file CID
443 node.StartDaemon()
444 fileCid := node.IPFSAddStr("hello world")
445 node.StopDaemon()
446
447 // Try to set file as root - should fail with non-zero exit
448 res := node.RunIPFS("files", "chroot", "--confirm", fileCid)
449 require.NotNil(t, res.ExitErr)
450 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
451 assert.Contains(t, res.Stderr.String(), "must be a directory")
452 })
453
454 t.Run("fails while daemon is running", func(t *testing.T) {
455 t.Parallel()
456 node := harness.NewT(t).NewNode().Init().StartDaemon()
457 defer node.StopDaemon()
458
459 res := node.RunIPFS("files", "chroot", "--confirm")
460 require.NotNil(t, res.ExitErr)
461 assert.NotEqual(t, 0, res.ExitErr.ExitCode())
462 assert.Contains(t, res.Stderr.String(), "opening repo")
463 })
464 }
465
466 // TestFilesMFSImportConfig tests that MFS operations respect Import.* configuration settings.
467 // These tests verify that `ipfs files` commands use the same import settings as `ipfs add`.
468 func TestFilesMFSImportConfig(t *testing.T) {
469 t.Parallel()
470
471 t.Run("files write respects Import.CidVersion=1", func(t *testing.T) {
472 t.Parallel()
473 node := harness.NewT(t).NewNode().Init()
474 node.UpdateConfig(func(cfg *config.Config) {
475 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
476 })
477 node.StartDaemon()
478 defer node.StopDaemon()
479
480 // Write file via MFS
481 tempFile := filepath.Join(node.Dir, "test.txt")
482 require.NoError(t, os.WriteFile(tempFile, []byte("hello"), 0644))
483 node.IPFS("files", "write", "--create", "/test.txt", tempFile)
484
485 // Get CID of written file
486 cidStr := node.IPFS("files", "stat", "--hash", "/test.txt").Stdout.Trimmed()
487
488 // Verify CIDv1 format (base32, starts with "b")
489 require.True(t, strings.HasPrefix(cidStr, "b"), "expected CIDv1 (starts with b), got: %s", cidStr)
490 })
491
492 t.Run("files write respects Import.UnixFSRawLeaves=true", func(t *testing.T) {
493 t.Parallel()
494 node := harness.NewT(t).NewNode().Init()
495 node.UpdateConfig(func(cfg *config.Config) {
496 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
497 cfg.Import.UnixFSRawLeaves = config.True
498 })
499 node.StartDaemon()
500 defer node.StopDaemon()
501
502 tempFile := filepath.Join(node.Dir, "test.txt")
503 require.NoError(t, os.WriteFile(tempFile, []byte("hello world"), 0644))
504 node.IPFS("files", "write", "--create", "/test.txt", tempFile)
505
506 cidStr := node.IPFS("files", "stat", "--hash", "/test.txt").Stdout.Trimmed()
507 codec := node.IPFS("cid", "format", "-f", "%c", cidStr).Stdout.Trimmed()
508 require.Equal(t, "raw", codec, "expected raw codec for small file with raw leaves")
509 })
510
511 // This test verifies CID parity for single-block files only.
512 // Multi-block files will have different CIDs because MFS uses trickle DAG layout
513 // while 'ipfs add' uses balanced DAG layout. See "files write vs add for multi-block" test.
514 t.Run("single-block file: files write produces same CID as ipfs add", func(t *testing.T) {
515 t.Parallel()
516 node := harness.NewT(t).NewNode().Init()
517 node.UpdateConfig(func(cfg *config.Config) {
518 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
519 cfg.Import.UnixFSRawLeaves = config.True
520 })
521 node.StartDaemon()
522 defer node.StopDaemon()
523
524 tempFile := filepath.Join(node.Dir, "test.txt")
525 require.NoError(t, os.WriteFile(tempFile, []byte("hello world"), 0644))
526 node.IPFS("files", "write", "--create", "/test.txt", tempFile)
527
528 mfsCid := node.IPFS("files", "stat", "--hash", "/test.txt").Stdout.Trimmed()
529 addCid := node.IPFSAddStr("hello world")
530 require.Equal(t, addCid, mfsCid, "MFS write should produce same CID as ipfs add for single-block files")
531 })
532
533 t.Run("files mkdir respects Import.CidVersion=1", func(t *testing.T) {
534 t.Parallel()
535 node := harness.NewT(t).NewNode().Init()
536 node.UpdateConfig(func(cfg *config.Config) {
537 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
538 })
539 node.StartDaemon()
540 defer node.StopDaemon()
541
542 node.IPFS("files", "mkdir", "/testdir")
543 cidStr := node.IPFS("files", "stat", "--hash", "/testdir").Stdout.Trimmed()
544
545 // Verify CIDv1 format
546 require.True(t, strings.HasPrefix(cidStr, "b"), "expected CIDv1 (starts with b), got: %s", cidStr)
547 })
548
549 t.Run("MFS subdirectory becomes HAMT when exceeding threshold", func(t *testing.T) {
550 t.Parallel()
551 node := harness.NewT(t).NewNode().Init()
552 node.UpdateConfig(func(cfg *config.Config) {
553 // Use small threshold for faster testing
554 cfg.Import.UnixFSHAMTDirectorySizeThreshold = *config.NewOptionalBytes("1KiB")
555 cfg.Import.UnixFSHAMTDirectorySizeEstimation = *config.NewOptionalString("block")
556 })
557 node.StartDaemon()
558 defer node.StopDaemon()
559
560 node.IPFS("files", "mkdir", "/bigdir")
561
562 content := "x"
563 tempFile := filepath.Join(node.Dir, "content.txt")
564 require.NoError(t, os.WriteFile(tempFile, []byte(content), 0644))
565
566 // Add enough files to exceed 1KiB threshold
567 for i := range 25 {
568 node.IPFS("files", "write", "--create", fmt.Sprintf("/bigdir/file%02d", i), tempFile)
569 }
570
571 cidStr := node.IPFS("files", "stat", "--hash", "/bigdir").Stdout.Trimmed()
572 fsType, err := node.UnixFSDataType(cidStr)
573 require.NoError(t, err)
574 require.Equal(t, ft.THAMTShard, fsType, "expected HAMT directory")
575 })
576
577 t.Run("MFS root directory becomes HAMT when exceeding threshold", func(t *testing.T) {
578 t.Parallel()
579 node := harness.NewT(t).NewNode().Init()
580 node.UpdateConfig(func(cfg *config.Config) {
581 cfg.Import.UnixFSHAMTDirectorySizeThreshold = *config.NewOptionalBytes("1KiB")
582 cfg.Import.UnixFSHAMTDirectorySizeEstimation = *config.NewOptionalString("block")
583 })
584 node.StartDaemon()
585 defer node.StopDaemon()
586
587 content := "x"
588 tempFile := filepath.Join(node.Dir, "content.txt")
589 require.NoError(t, os.WriteFile(tempFile, []byte(content), 0644))
590
591 // Add files directly to root /
592 for i := range 25 {
593 node.IPFS("files", "write", "--create", fmt.Sprintf("/file%02d", i), tempFile)
594 }
595
596 cidStr := node.IPFS("files", "stat", "--hash", "/").Stdout.Trimmed()
597 fsType, err := node.UnixFSDataType(cidStr)
598 require.NoError(t, err)
599 require.Equal(t, ft.THAMTShard, fsType, "expected MFS root to become HAMT")
600 })
601
602 t.Run("MFS directory reverts from HAMT to basic when items removed", func(t *testing.T) {
603 t.Parallel()
604 node := harness.NewT(t).NewNode().Init()
605 node.UpdateConfig(func(cfg *config.Config) {
606 cfg.Import.UnixFSHAMTDirectorySizeThreshold = *config.NewOptionalBytes("1KiB")
607 cfg.Import.UnixFSHAMTDirectorySizeEstimation = *config.NewOptionalString("block")
608 })
609 node.StartDaemon()
610 defer node.StopDaemon()
611
612 node.IPFS("files", "mkdir", "/testdir")
613
614 content := "x"
615 tempFile := filepath.Join(node.Dir, "content.txt")
616 require.NoError(t, os.WriteFile(tempFile, []byte(content), 0644))
617
618 // Add files to exceed threshold
619 for i := range 25 {
620 node.IPFS("files", "write", "--create", fmt.Sprintf("/testdir/file%02d", i), tempFile)
621 }
622
623 // Verify it became HAMT
624 cidStr := node.IPFS("files", "stat", "--hash", "/testdir").Stdout.Trimmed()
625 fsType, err := node.UnixFSDataType(cidStr)
626 require.NoError(t, err)
627 require.Equal(t, ft.THAMTShard, fsType, "should be HAMT after adding many files")
628
629 // Remove files to get back below threshold
630 for i := range 20 {
631 node.IPFS("files", "rm", fmt.Sprintf("/testdir/file%02d", i))
632 }
633
634 // Verify it reverted to basic directory
635 cidStr = node.IPFS("files", "stat", "--hash", "/testdir").Stdout.Trimmed()
636 fsType, err = node.UnixFSDataType(cidStr)
637 require.NoError(t, err)
638 require.Equal(t, ft.TDirectory, fsType, "should revert to basic directory after removing files")
639 })
640
641 // Note: 'files write' produces DIFFERENT CIDs than 'ipfs add' for multi-block files because
642 // MFS uses trickle DAG layout while 'ipfs add' uses balanced DAG layout.
643 // Single-block files produce the same CID (tested above in "single-block file: files write...").
644 // For multi-block CID compatibility with 'ipfs add', use 'ipfs add --to-files' instead.
645
646 t.Run("files cp preserves original CID", func(t *testing.T) {
647 t.Parallel()
648 node := harness.NewT(t).NewNode().Init()
649 node.UpdateConfig(func(cfg *config.Config) {
650 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
651 cfg.Import.UnixFSRawLeaves = config.True
652 })
653 node.StartDaemon()
654 defer node.StopDaemon()
655
656 // Add file via ipfs add
657 originalCid := node.IPFSAddStr("hello world")
658
659 // Copy to MFS
660 node.IPFS("files", "cp", fmt.Sprintf("/ipfs/%s", originalCid), "/copied.txt")
661
662 // Verify CID is preserved
663 mfsCid := node.IPFS("files", "stat", "--hash", "/copied.txt").Stdout.Trimmed()
664 require.Equal(t, originalCid, mfsCid, "files cp should preserve original CID")
665 })
666
667 t.Run("add --to-files respects Import config", func(t *testing.T) {
668 t.Parallel()
669 node := harness.NewT(t).NewNode().Init()
670 node.UpdateConfig(func(cfg *config.Config) {
671 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
672 cfg.Import.UnixFSRawLeaves = config.True
673 })
674 node.StartDaemon()
675 defer node.StopDaemon()
676
677 // Create temp file
678 tempFile := filepath.Join(node.Dir, "test.txt")
679 require.NoError(t, os.WriteFile(tempFile, []byte("hello world"), 0644))
680
681 // Add with --to-files
682 addCid := node.IPFS("add", "-Q", "--to-files=/added.txt", tempFile).Stdout.Trimmed()
683
684 // Verify MFS file has same CID
685 mfsCid := node.IPFS("files", "stat", "--hash", "/added.txt").Stdout.Trimmed()
686 require.Equal(t, addCid, mfsCid)
687
688 // Should be CIDv1 raw leaf
689 codec := node.IPFS("cid", "format", "-f", "%c", mfsCid).Stdout.Trimmed()
690 require.Equal(t, "raw", codec)
691 })
692
693 t.Run("files mkdir respects Import.UnixFSDirectoryMaxLinks", func(t *testing.T) {
694 t.Parallel()
695 node := harness.NewT(t).NewNode().Init()
696 node.UpdateConfig(func(cfg *config.Config) {
697 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
698 // Set low link threshold to trigger HAMT sharding at 5 links
699 cfg.Import.UnixFSDirectoryMaxLinks = *config.NewOptionalInteger(5)
700 // Also need size estimation enabled for switching to work
701 cfg.Import.UnixFSHAMTDirectorySizeEstimation = *config.NewOptionalString("block")
702 })
703 node.StartDaemon()
704 defer node.StopDaemon()
705
706 // Create directory with 6 files (exceeds max 5 links)
707 node.IPFS("files", "mkdir", "/testdir")
708
709 content := "x"
710 tempFile := filepath.Join(node.Dir, "content.txt")
711 require.NoError(t, os.WriteFile(tempFile, []byte(content), 0644))
712
713 for i := range 6 {
714 node.IPFS("files", "write", "--create", fmt.Sprintf("/testdir/file%d.txt", i), tempFile)
715 }
716
717 // Verify directory became HAMT sharded
718 cidStr := node.IPFS("files", "stat", "--hash", "/testdir").Stdout.Trimmed()
719 fsType, err := node.UnixFSDataType(cidStr)
720 require.NoError(t, err)
721 require.Equal(t, ft.THAMTShard, fsType, "expected HAMT directory after exceeding UnixFSDirectoryMaxLinks")
722 })
723
724 t.Run("files write respects Import.UnixFSChunker", func(t *testing.T) {
725 t.Parallel()
726 node := harness.NewT(t).NewNode().Init()
727 node.UpdateConfig(func(cfg *config.Config) {
728 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
729 cfg.Import.UnixFSRawLeaves = config.True
730 cfg.Import.UnixFSChunker = *config.NewOptionalString("size-1024") // 1KB chunks
731 })
732 node.StartDaemon()
733 defer node.StopDaemon()
734
735 // Create file larger than chunk size (3KB)
736 data := make([]byte, 3*1024)
737 for i := range data {
738 data[i] = byte(i % 256)
739 }
740 tempFile := filepath.Join(node.Dir, "large.bin")
741 require.NoError(t, os.WriteFile(tempFile, data, 0644))
742
743 node.IPFS("files", "write", "--create", "/large.bin", tempFile)
744
745 // Verify chunking: 3KB file with 1KB chunks should have multiple child blocks
746 cidStr := node.IPFS("files", "stat", "--hash", "/large.bin").Stdout.Trimmed()
747 dagStatJSON := node.IPFS("dag", "stat", "--enc=json", cidStr).Stdout.Trimmed()
748 var dagStat struct {
749 UniqueBlocks int `json:"UniqueBlocks"`
750 }
751 require.NoError(t, json.Unmarshal([]byte(dagStatJSON), &dagStat))
752 // With 1KB chunks on a 3KB file, we expect 4 blocks (3 leaf + 1 root)
753 assert.Greater(t, dagStat.UniqueBlocks, 1, "expected more than 1 block with 1KB chunker on 3KB file")
754 })
755
756 t.Run("files write with custom chunker produces same CID as ipfs add --trickle", func(t *testing.T) {
757 t.Parallel()
758 node := harness.NewT(t).NewNode().Init()
759 node.UpdateConfig(func(cfg *config.Config) {
760 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
761 cfg.Import.UnixFSRawLeaves = config.True
762 cfg.Import.UnixFSChunker = *config.NewOptionalString("size-512")
763 })
764 node.StartDaemon()
765 defer node.StopDaemon()
766
767 // Create test data (2KB to get multiple chunks)
768 data := make([]byte, 2048)
769 for i := range data {
770 data[i] = byte(i % 256)
771 }
772 tempFile := filepath.Join(node.Dir, "test.bin")
773 require.NoError(t, os.WriteFile(tempFile, data, 0644))
774
775 // Add via MFS
776 node.IPFS("files", "write", "--create", "/test.bin", tempFile)
777 mfsCid := node.IPFS("files", "stat", "--hash", "/test.bin").Stdout.Trimmed()
778
779 // Add via ipfs add with same chunker and trickle (MFS always uses trickle)
780 addCid := node.IPFS("add", "-Q", "--chunker=size-512", "--trickle", tempFile).Stdout.Trimmed()
781
782 // CIDs should match when using same chunker + trickle layout
783 require.Equal(t, addCid, mfsCid, "MFS and add --trickle should produce same CID with matching chunker")
784 })
785
786 t.Run("files mkdir respects Import.UnixFSHAMTDirectoryMaxFanout", func(t *testing.T) {
787 t.Parallel()
788 node := harness.NewT(t).NewNode().Init()
789 node.UpdateConfig(func(cfg *config.Config) {
790 // Use non-default fanout of 64 (default is 256)
791 cfg.Import.UnixFSHAMTDirectoryMaxFanout = *config.NewOptionalInteger(64)
792 // Set low link threshold to trigger HAMT at 5 links
793 cfg.Import.UnixFSDirectoryMaxLinks = *config.NewOptionalInteger(5)
794 cfg.Import.UnixFSHAMTDirectorySizeEstimation = *config.NewOptionalString("disabled")
795 })
796 node.StartDaemon()
797 defer node.StopDaemon()
798
799 node.IPFS("files", "mkdir", "/testdir")
800
801 content := "x"
802 tempFile := filepath.Join(node.Dir, "content.txt")
803 require.NoError(t, os.WriteFile(tempFile, []byte(content), 0644))
804
805 // Add 6 files (exceeds MaxLinks=5) to trigger HAMT
806 for i := range 6 {
807 node.IPFS("files", "write", "--create", fmt.Sprintf("/testdir/file%d.txt", i), tempFile)
808 }
809
810 // Verify directory became HAMT
811 cidStr := node.IPFS("files", "stat", "--hash", "/testdir").Stdout.Trimmed()
812 fsType, err := node.UnixFSDataType(cidStr)
813 require.NoError(t, err)
814 require.Equal(t, ft.THAMTShard, fsType, "expected HAMT directory")
815
816 // Verify the HAMT uses the custom fanout (64) by inspecting the UnixFS Data field.
817 fanout, err := node.UnixFSHAMTFanout(cidStr)
818 require.NoError(t, err)
819 require.Equal(t, uint64(64), fanout, "expected HAMT fanout 64")
820 })
821
822 t.Run("files mkdir respects Import.UnixFSHAMTDirectorySizeThreshold", func(t *testing.T) {
823 t.Parallel()
824 node := harness.NewT(t).NewNode().Init()
825 node.UpdateConfig(func(cfg *config.Config) {
826 // Use very small threshold (100 bytes) to trigger HAMT quickly
827 cfg.Import.UnixFSHAMTDirectorySizeThreshold = *config.NewOptionalBytes("100B")
828 cfg.Import.UnixFSHAMTDirectorySizeEstimation = *config.NewOptionalString("block")
829 })
830 node.StartDaemon()
831 defer node.StopDaemon()
832
833 node.IPFS("files", "mkdir", "/testdir")
834
835 content := "test content"
836 tempFile := filepath.Join(node.Dir, "content.txt")
837 require.NoError(t, os.WriteFile(tempFile, []byte(content), 0644))
838
839 // Add 3 files - each link adds ~40-50 bytes, so 3 should exceed 100B threshold
840 for i := range 3 {
841 node.IPFS("files", "write", "--create", fmt.Sprintf("/testdir/file%d.txt", i), tempFile)
842 }
843
844 // Verify directory became HAMT due to size threshold
845 cidStr := node.IPFS("files", "stat", "--hash", "/testdir").Stdout.Trimmed()
846 fsType, err := node.UnixFSDataType(cidStr)
847 require.NoError(t, err)
848 require.Equal(t, ft.THAMTShard, fsType, "expected HAMT directory after exceeding size threshold")
849 })
850
851 // Regression tests for https://github.com/ipfs/boxo/pull/1125
852 // CidBuilder (CID version + hash function) must be preserved across
853 // file mutations, directory creation, and daemon restarts. We use
854 // CIDv1 + sha2-512 so assertions are meaningful even if CIDv1 or a
855 // different hash becomes the default in the future.
856
857 t.Run("CidBuilder preserved across file mutation and restart", func(t *testing.T) {
858 t.Parallel()
859 node := harness.NewT(t).NewNode().Init()
860 node.UpdateConfig(func(cfg *config.Config) {
861 cfg.Import.CidVersion = *config.NewOptionalInteger(1)
862 cfg.Import.HashFunction = *config.NewOptionalString("sha2-512")
863 })
864 node.StartDaemon()
865
866 requireCidBuilder := func(mfsPath, context string) {
867 t.Helper()
868 cidStr := node.IPFS("files", "stat", "--hash", mfsPath).Stdout.Trimmed()
869 prefix := node.IPFS("cid", "format", "-f", "%V-%h", cidStr).Stdout.Trimmed()
870 require.Equal(t, "1-sha2-512", prefix, "%s: expected CIDv1+sha2-512 for %s, got %s (cid: %s)", context, mfsPath, prefix, cidStr)
871 }
872
873 // 1. files write --create: new file
874 tempFile := filepath.Join(node.Dir, "test.txt")
875 require.NoError(t, os.WriteFile(tempFile, []byte("hello world"), 0644))
876 node.IPFS("files", "write", "--create", "/test.txt", tempFile)
877 requireCidBuilder("/test.txt", "initial write")
878
879 // 2. files write --offset: mutate existing file (setNodeData)
880 cidBefore := node.IPFS("files", "stat", "--hash", "/test.txt").Stdout.Trimmed()
881 patch := filepath.Join(node.Dir, "patch.txt")
882 require.NoError(t, os.WriteFile(patch, []byte("PATCHED"), 0644))
883 node.IPFS("files", "write", "--offset", "0", "/test.txt", patch)
884 requireCidBuilder("/test.txt", "after offset write")
885 cidAfter := node.IPFS("files", "stat", "--hash", "/test.txt").Stdout.Trimmed()
886 require.NotEqual(t, cidBefore, cidAfter, "CID should change after mutation")
887
888 // 3. files mkdir -p: all intermediate directories
889 node.IPFS("files", "mkdir", "-p", "/a/b/c")
890 for _, dir := range []string{"/a", "/a/b", "/a/b/c"} {
891 requireCidBuilder(dir, "mkdir -p")
892 }
893
894 // 4. files write --create inside a subdirectory
895 node.IPFS("files", "write", "--create", "/a/b/nested.txt", tempFile)
896 requireCidBuilder("/a/b/nested.txt", "write in subdir")
897
898 // 5. root directory
899 requireCidBuilder("/", "root before restart")
900
901 // 6. daemon restart: NewRoot must preserve CidBuilder
902 node.StopDaemon()
903 node.StartDaemon()
904 defer node.StopDaemon()
905
906 requireCidBuilder("/", "root after restart")
907 requireCidBuilder("/test.txt", "file after restart")
908 requireCidBuilder("/a/b/c", "dir after restart")
909
910 // 7. new entries created after restart
911 require.NoError(t, os.WriteFile(tempFile, []byte("post-restart"), 0644))
912 node.IPFS("files", "write", "--create", "/post-restart.txt", tempFile)
913 node.IPFS("files", "mkdir", "/post-restart-dir")
914 requireCidBuilder("/post-restart.txt", "new file after restart")
915 requireCidBuilder("/post-restart-dir", "new dir after restart")
916 })
917
918 t.Run("config change takes effect after daemon restart", func(t *testing.T) {
919 t.Parallel()
920 node := harness.NewT(t).NewNode().Init()
921
922 // Start with high threshold (won't trigger HAMT)
923 node.UpdateConfig(func(cfg *config.Config) {
924 cfg.Import.UnixFSHAMTDirectorySizeThreshold = *config.NewOptionalBytes("256KiB")
925 cfg.Import.UnixFSHAMTDirectorySizeEstimation = *config.NewOptionalString("block")
926 })
927 node.StartDaemon()
928
929 // Create directory with some files
930 node.IPFS("files", "mkdir", "/testdir")
931 content := "test"
932 tempFile := filepath.Join(node.Dir, "content.txt")
933 require.NoError(t, os.WriteFile(tempFile, []byte(content), 0644))
934 for i := range 3 {
935 node.IPFS("files", "write", "--create", fmt.Sprintf("/testdir/file%d.txt", i), tempFile)
936 }
937
938 // Verify it's still a basic directory (threshold not exceeded)
939 cidStr := node.IPFS("files", "stat", "--hash", "/testdir").Stdout.Trimmed()
940 fsType, err := node.UnixFSDataType(cidStr)
941 require.NoError(t, err)
942 require.Equal(t, ft.TDirectory, fsType, "should be basic directory with high threshold")
943
944 // Stop daemon
945 node.StopDaemon()
946
947 // Change config to use very low threshold
948 node.UpdateConfig(func(cfg *config.Config) {
949 cfg.Import.UnixFSHAMTDirectorySizeThreshold = *config.NewOptionalBytes("100B")
950 })
951
952 // Restart daemon
953 node.StartDaemon()
954 defer node.StopDaemon()
955
956 // Add one more file - this should trigger HAMT conversion with new threshold
957 node.IPFS("files", "write", "--create", "/testdir/file3.txt", tempFile)
958
959 // Verify it became HAMT (new threshold applied)
960 cidStr = node.IPFS("files", "stat", "--hash", "/testdir").Stdout.Trimmed()
961 fsType, err = node.UnixFSDataType(cidStr)
962 require.NoError(t, err)
963 require.Equal(t, ft.THAMTShard, fsType, "should be HAMT after daemon restart with lower threshold")
964 })
965 }