2
3
import (
4
"encoding/json"
5
+ "fmt"
6
"io"
7
"os"
8
+ "path/filepath"
9
+ "strings"
10
"testing"
11
"time"
12
347
require.Contains(t, daemonLog, "fast-provide-root: skipped")
348
})
349
}
350
+
351
+// dagRefs returns root plus recursive ref CIDs from "ipfs refs -r --unique root".
352
+func dagRefs(node *harness.Node, root string) []string {
353
+ refsRes := node.IPFS("refs", "-r", "--unique", root)
354
+ refs := []string{root}
355
+ for _, line := range testutils.SplitLines(strings.TrimSpace(refsRes.Stdout.String())) {
356
+ if line != "" {
357
+ refs = append(refs, line)
358
+ }
359
+ }
360
+ return refs
361
+}
362
+
363
+// countCARBlocks imports the CAR at carPath onto a fresh node and returns the
364
+// number of blocks reported by `dag import --stats`. The fresh node guarantees
365
+// the count reflects what is in the CAR, not what was already in the store.
366
+func countCARBlocks(t *testing.T, carPath string) int {
367
+ t.Helper()
368
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
369
+ defer node.StopDaemon()
370
+
371
+ car, err := os.Open(carPath)
372
+ require.NoError(t, err)
373
+ defer car.Close()
374
+
375
+ res := node.Runner.Run(harness.RunRequest{
376
+ Path: node.IPFSBin,
377
+ Args: []string{"dag", "import", "--pin-roots=false", "--stats"},
378
+ CmdOpts: []harness.CmdOpt{harness.RunWithStdin(car)},
379
+ })
380
+ require.Equal(t, 0, res.ExitCode(), "dag import --stats failed: %s", res.Stderr.String())
381
+
382
+ var n int
383
+ for _, line := range testutils.SplitLines(res.Stdout.String()) {
384
+ if _, err := fmt.Sscanf(line, "Imported %d blocks", &n); err == nil {
385
+ break
386
+ }
387
+ }
388
+ require.Greater(t, n, 0, "expected 'Imported N blocks' in stdout: %q", res.Stdout.String())
389
+ return n
390
+}
391
+
392
+// shallowDAGArgs are the `ipfs add` args used by the partial-DAG helpers
393
+// below. Chunker and max-file-links are pinned so the resulting DAG shape
394
+// (root + 2 raw leaves) is independent of changes to Import.* defaults or
395
+// applied profiles.
396
+var shallowDAGArgs = []string{"--raw-leaves", "--chunker=size-262144", "--max-file-links=174"}
397
+
398
+// makePartialDAG adds a 300 KiB file with shallowDAGArgs (yielding root + 2
399
+// raw leaves) and then deletes the first leaf so the node holds a DAG with
400
+// one missing block. Returns the root CID and the CID that was removed.
401
+func makePartialDAG(t *testing.T, node *harness.Node, seed string, addArgs ...string) (root, removed string) {
402
+ t.Helper()
403
+ root = node.IPFSAddDeterministic("300KiB", seed, append(shallowDAGArgs, addArgs...)...)
404
+ refs := dagRefs(node, root)
405
+ require.Equal(t, 3, len(refs), "expected exactly root + 2 raw leaves with pinned chunker/max-links, got %v", refs)
406
+ require.Equal(t, 0, node.RunIPFS("pin", "rm", root).ExitCode())
407
+ require.Equal(t, 0, node.RunIPFS("block", "rm", refs[1]).ExitCode())
408
+ return root, refs[1]
409
+}
410
+
411
+// TestDagExportLocalOnly verifies the core promise of --local-only: a DAG
412
+// with a single missing leaf can still be exported as a partial CAR, and
413
+// the partial CAR contains exactly the full DAG minus the removed block.
414
+func TestDagExportLocalOnly(t *testing.T) {
415
+ t.Parallel()
416
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
417
+ defer node.StopDaemon()
418
+
419
+ // Snapshot the full DAG to a CAR before the block is removed, so we
420
+ // have a baseline block count to compare against.
421
+ root := node.IPFSAddDeterministic("300KiB", "dag-export-local-only", shallowDAGArgs...)
422
+ fullCarPath := filepath.Join(node.Dir, "full.car")
423
+ require.NoError(t, node.IPFSDagExport(root, fullCarPath))
424
+ fullCount := countCARBlocks(t, fullCarPath)
425
+ require.Equal(t, 3, fullCount, "expected root + 2 raw leaves (full=%d)", fullCount)
426
+
427
+ // Drop one leaf so the local DAG is partial.
428
+ refs := dagRefs(node, root)
429
+ require.Equal(t, 0, node.RunIPFS("pin", "rm", root).ExitCode())
430
+ require.Equal(t, 0, node.RunIPFS("block", "rm", refs[1]).ExitCode())
431
+
432
+ // Sanity: plain --offline (without --local-only) must fail loudly
433
+ // when a block is missing. This guards the existing behavior.
434
+ res := node.Runner.Run(harness.RunRequest{
435
+ Path: node.IPFSBin,
436
+ Args: []string{"dag", "export", "--offline", root},
437
+ CmdOpts: []harness.CmdOpt{harness.RunWithStdout(io.Discard)},
438
+ })
439
+ require.NotEqual(t, 0, res.ExitCode(), "dag export --offline must fail when a block is missing")
440
+ require.Contains(t, res.Stderr.String(), "block was not found locally")
441
+
442
+ // --local-only must succeed and produce a CAR with exactly the
443
+ // full DAG minus the one removed leaf.
444
+ partialCarPath := filepath.Join(node.Dir, "partial.car")
445
+ require.NoError(t, node.IPFSDagExport(root, partialCarPath, "--local-only", "--offline"))
446
+ partialCount := countCARBlocks(t, partialCarPath)
447
+
448
+ require.Equal(t, fullCount-1, partialCount,
449
+ "partial CAR should be exactly the full DAG minus the one removed leaf (full=%d, partial=%d)",
450
+ fullCount, partialCount)
451
+}
452
+
453
+// TestDagExportLocalOnlyImpliesOffline verifies that --local-only on its own
454
+// makes a partial-DAG export succeed: it implies --offline so the user does
455
+// not have to pass both flags.
456
+func TestDagExportLocalOnlyImpliesOffline(t *testing.T) {
457
+ t.Parallel()
458
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
459
+ defer node.StopDaemon()
460
+
461
+ root, _ := makePartialDAG(t, node, "dag-export-local-only-implies")
462
+
463
+ // Export with only --local-only (no --offline) and confirm the
464
+ // resulting CAR has the right number of blocks (full DAG minus one).
465
+ partialCarPath := filepath.Join(node.Dir, "partial.car")
466
+ require.NoError(t, node.IPFSDagExport(root, partialCarPath, "--local-only"))
467
+
468
+ // 300KiB --raw-leaves yields root + 2 leaves, so removing one leaf
469
+ // leaves 2 blocks. Asserting the exact count proves --offline was
470
+ // actually applied (without it, the export would either fetch the
471
+ // missing block or fail differently).
472
+ require.Equal(t, 2, countCARBlocks(t, partialCarPath))
473
+}
474
+
475
+// TestDagExportLocalOnlySkipsSubtree verifies that when a non-leaf block is
476
+// missing, --local-only skips the entire subtree under it, not just the
477
+// missing block. Uses a small chunk size to force a depth>1 DAG so removing
478
+// an intermediate prunes many descendant blocks.
479
+func TestDagExportLocalOnlySkipsSubtree(t *testing.T) {
480
+ t.Parallel()
481
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
482
+ defer node.StopDaemon()
483
+
484
+ // chunker=size-256 + 64 KiB → 256 leaves; max-file-links=174 forces
485
+ // at least one intermediate dag-pb layer between root and leaves
486
+ // (256 > 174). Both values are pinned so the DAG shape (and the
487
+ // counts below) survives any change to Import.* defaults or profiles.
488
+ root := node.IPFSAddDeterministic("64KiB", "dag-export-local-only-subtree",
489
+ "--raw-leaves", "--chunker=size-256", "--max-file-links=174")
490
+ fullCarPath := filepath.Join(node.Dir, "full.car")
491
+ require.NoError(t, node.IPFSDagExport(root, fullCarPath))
492
+ fullCount := countCARBlocks(t, fullCarPath)
493
+ // 1 root + 2 intermediates (174 + 82 children) + 256 leaves = 259.
494
+ require.Equal(t, 259, fullCount, "expected root + 2 intermediates + 256 leaves, got %d", fullCount)
495
+
496
+ // Find the first intermediate ref: a non-leaf whose codec is dag-pb.
497
+ // "ipfs refs -r --unique" lists CIDs depth-first; the root's first
498
+ // child in a balanced UnixFS DAG with >174 leaves is an intermediate.
499
+ refs := dagRefs(node, root)
500
+ intermediate := refs[1]
501
+ intermediateChildren := dagRefs(node, intermediate)
502
+ require.Greater(t, len(intermediateChildren), 10,
503
+ "expected refs[1] to be a non-leaf with many children, got %d", len(intermediateChildren))
504
+
505
+ // Remove the intermediate. Its subtree blocks remain locally, but
506
+ // without the intermediate the walker cannot reach them, so they
507
+ // must be skipped along with it.
508
+ require.Equal(t, 0, node.RunIPFS("pin", "rm", root).ExitCode())
509
+ require.Equal(t, 0, node.RunIPFS("block", "rm", intermediate).ExitCode())
510
+
511
+ partialCarPath := filepath.Join(node.Dir, "partial.car")
512
+ require.NoError(t, node.IPFSDagExport(root, partialCarPath, "--local-only"))
513
+ partialCount := countCARBlocks(t, partialCarPath)
514
+
515
+ expectedDropped := len(intermediateChildren) // includes the intermediate itself
516
+ require.Equal(t, fullCount-expectedDropped, partialCount,
517
+ "removing intermediate %s should drop it and its %d descendants (full=%d, partial=%d)",
518
+ intermediate, expectedDropped-1, fullCount, partialCount)
519
+}
520
+
521
+// TestDagExportLocalOnlyConflictsWithOnline verifies that explicitly asking
522
+// for online mode together with --local-only is rejected, since the two
523
+// settings contradict each other.
524
+func TestDagExportLocalOnlyConflictsWithOnline(t *testing.T) {
525
+ t.Parallel()
526
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
527
+ defer node.StopDaemon()
528
+
529
+ root := node.IPFSAddDeterministic("300KiB", "dag-export-local-only-online", "--raw-leaves")
530
+
531
+ res := node.RunIPFS("dag", "export", "--local-only", "--offline=false", root)
532
+ require.NotEqual(t, 0, res.ExitCode(), "dag export --local-only --offline=false should be rejected")
533
+ stderr := res.Stderr.String()
534
+ require.Contains(t, stderr, "--local-only")
535
+ require.Contains(t, stderr, "--offline")
536
+}
537
+
538
+// TestDagImportPartialCAR is the round-trip happy path: a partial CAR from
539
+// --local-only can be imported on a fresh node with default flags (the
540
+// IPFSDagImport harness helper passes --pin-roots=false). The helper also
541
+// confirms the root resolves offline on the receiver.
542
+func TestDagImportPartialCAR(t *testing.T) {
543
+ t.Parallel()
544
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
545
+ defer node.StopDaemon()
546
+
547
+ root, _ := makePartialDAG(t, node, "dag-import-partial")
548
+
549
+ partialCarPath := filepath.Join(node.Dir, "partial.car")
550
+ require.NoError(t, node.IPFSDagExport(root, partialCarPath, "--local-only", "--offline"))
551
+
552
+ imp := harness.NewT(t).NewNode().Init().StartDaemon()
553
+ defer imp.StopDaemon()
554
+ partialCAR, err := os.Open(partialCarPath)
555
+ require.NoError(t, err)
556
+ defer partialCAR.Close()
557
+ require.NoError(t, imp.IPFSDagImport(partialCAR, root))
558
+}
559
+
560
+// TestDagImportLocalOnlyImpliesNoPin verifies that --local-only on its own
561
+// makes a partial-CAR import succeed: it implies --pin-roots=false so the
562
+// user does not have to pass both flags.
563
+func TestDagImportLocalOnlyImpliesNoPin(t *testing.T) {
564
+ t.Parallel()
565
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
566
+ defer node.StopDaemon()
567
+
568
+ root, _ := makePartialDAG(t, node, "dag-import-local-only-implies")
569
+ partialCarPath := filepath.Join(node.Dir, "partial.car")
570
+ require.NoError(t, node.IPFSDagExport(root, partialCarPath, "--local-only", "--offline"))
571
+
572
+ imp := harness.NewT(t).NewNode().Init().StartDaemon()
573
+ defer imp.StopDaemon()
574
+ partialCAR, err := os.Open(partialCarPath)
575
+ require.NoError(t, err)
576
+ defer partialCAR.Close()
577
+
578
+ // Import with only --local-only (no --pin-roots=false). Should
579
+ // succeed because --local-only implies --pin-roots=false, and the
580
+ // receiver must not attempt to pin (pin would fail on a partial DAG).
581
+ res := imp.Runner.Run(harness.RunRequest{
582
+ Path: imp.IPFSBin,
583
+ Args: []string{"dag", "import", "--local-only"},
584
+ CmdOpts: []harness.CmdOpt{harness.RunWithStdin(partialCAR)},
585
+ })
586
+ require.Equal(t, 0, res.ExitCode(),
587
+ "dag import --local-only on a partial CAR should succeed; stderr: %s", res.Stderr.String())
588
+ require.NotContains(t, res.Stdout.String(), "Pinned root",
589
+ "import must not pin when --local-only is set")
590
+}
591
+
592
+// TestDagImportLocalOnlyPinRootsConflict verifies that --local-only is
593
+// rejected when combined with an explicit --pin-roots=true. The two are
594
+// mutually exclusive: --local-only is for partial CARs (no full DAG to pin).
595
+func TestDagImportLocalOnlyPinRootsConflict(t *testing.T) {
596
+ t.Parallel()
597
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
598
+ defer node.StopDaemon()
599
+
600
+ r, err := os.Open(fixtureFile)
601
+ require.NoError(t, err)
602
+ defer r.Close()
603
+
604
+ res := node.Runner.Run(harness.RunRequest{
605
+ Path: node.IPFSBin,
606
+ Args: []string{"dag", "import", "--local-only", "--pin-roots=true"},
607
+ CmdOpts: []harness.CmdOpt{harness.RunWithStdin(r)},
608
+ })
609
+
610
+ require.NotEqual(t, 0, res.ExitCode())
611
+ stderr := res.Stderr.String()
612
+ require.Contains(t, stderr, "--local-only")
613
+ require.Contains(t, stderr, "--pin-roots")
614
+}