master
go 614 lines 23.1 KB
Raw
1 package cli
2
3 import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/ipfs/kubo/config"
14 "github.com/ipfs/kubo/test/cli/harness"
15 "github.com/ipfs/kubo/test/cli/testutils"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18 )
19
20 const (
21 fixtureFile = "./fixtures/TestDagStat.car"
22 textOutputPath = "./fixtures/TestDagStatExpectedOutput.txt"
23 node1Cid = "bafyreibmdfd7c5db4kls4ty57zljfhqv36gi43l6txl44pi423wwmeskwy"
24 node2Cid = "bafyreie3njilzdi4ixumru4nzgecsnjtu7fzfcwhg7e6s4s5i7cnbslvn4"
25 fixtureCid = "bafyreifrm6uf5o4dsaacuszf35zhibyojlqclabzrms7iak67pf62jygaq"
26 )
27
28 type DagStat struct {
29 Cid string `json:"Cid"`
30 Size int `json:"Size"`
31 NumBlocks int `json:"NumBlocks"`
32 }
33
34 type Data struct {
35 UniqueBlocks int `json:"UniqueBlocks"`
36 TotalSize int `json:"TotalSize"`
37 SharedSize int `json:"SharedSize"`
38 Ratio float64 `json:"Ratio"`
39 DagStats []DagStat `json:"DagStats"`
40 }
41
42 // The Fixture file represents a dag where 2 nodes of size = 46B each, have a common child of 7B
43 // when traversing the DAG from the root's children (node1 and node2) we count (46 + 7)x2 bytes (counting redundant bytes) = 106
44 // since both nodes share a common child of 7 bytes we actually had to read (46)x2 + 7 = 99 bytes
45 // we should get a dedup ratio of 106/99 that results in approximately 1.0707071
46
47 func TestDag(t *testing.T) {
48 t.Parallel()
49
50 t.Run("ipfs dag stat --enc=json", func(t *testing.T) {
51 t.Parallel()
52 node := harness.NewT(t).NewNode().Init().StartDaemon()
53 defer node.StopDaemon()
54
55 // Import fixture
56 r, err := os.Open(fixtureFile)
57 assert.Nil(t, err)
58 defer r.Close()
59 err = node.IPFSDagImport(r, fixtureCid)
60 assert.NoError(t, err)
61 stat := node.RunIPFS("dag", "stat", "--progress=false", "--enc=json", node1Cid, node2Cid)
62 var data Data
63 err = json.Unmarshal(stat.Stdout.Bytes(), &data)
64 assert.NoError(t, err)
65
66 expectedUniqueBlocks := 3
67 expectedSharedSize := 7
68 expectedTotalSize := 99
69 expectedRatio := float64(expectedSharedSize+expectedTotalSize) / float64(expectedTotalSize)
70 expectedDagStatsLength := 2
71 // Validate UniqueBlocks
72 assert.Equal(t, expectedUniqueBlocks, data.UniqueBlocks)
73 assert.Equal(t, expectedSharedSize, data.SharedSize)
74 assert.Equal(t, expectedTotalSize, data.TotalSize)
75 assert.Equal(t, testutils.FloatTruncate(expectedRatio, 4), testutils.FloatTruncate(data.Ratio, 4))
76
77 // Validate DagStats
78 assert.Equal(t, expectedDagStatsLength, len(data.DagStats))
79 node1Output := data.DagStats[0]
80 node2Output := data.DagStats[1]
81
82 assert.Equal(t, node1Output.Cid, node1Cid)
83 assert.Equal(t, node2Output.Cid, node2Cid)
84
85 expectedNode1Size := (expectedTotalSize + expectedSharedSize) / 2
86 expectedNode2Size := (expectedTotalSize + expectedSharedSize) / 2
87 assert.Equal(t, expectedNode1Size, node1Output.Size)
88 assert.Equal(t, expectedNode2Size, node2Output.Size)
89
90 expectedNode1Blocks := 2
91 expectedNode2Blocks := 2
92 assert.Equal(t, expectedNode1Blocks, node1Output.NumBlocks)
93 assert.Equal(t, expectedNode2Blocks, node2Output.NumBlocks)
94 })
95
96 t.Run("ipfs dag stat", func(t *testing.T) {
97 t.Parallel()
98 node := harness.NewT(t).NewNode().Init().StartDaemon()
99 defer node.StopDaemon()
100 r, err := os.Open(fixtureFile)
101 assert.NoError(t, err)
102 defer r.Close()
103 f, err := os.Open(textOutputPath)
104 assert.NoError(t, err)
105 defer f.Close()
106 content, err := io.ReadAll(f)
107 assert.NoError(t, err)
108 err = node.IPFSDagImport(r, fixtureCid)
109 assert.NoError(t, err)
110 stat := node.RunIPFS("dag", "stat", "--progress=false", node1Cid, node2Cid)
111 assert.Equal(t, content, stat.Stdout.Bytes())
112 })
113 }
114
115 func TestDagImportCARv2(t *testing.T) {
116 t.Parallel()
117 // Regression test for https://github.com/ipfs/kubo/issues/9361
118 // CARv2 import fails with "operation not supported" when using the HTTP API
119 // because the multipart reader doesn't support seeking, but the boxo
120 // ReaderFile falsely advertises io.Seeker compliance.
121
122 carv2Fixture := "./fixtures/TestDagStatCARv2.car"
123
124 t.Run("CARv2 import via HTTP API (online)", func(t *testing.T) {
125 t.Parallel()
126 node := harness.NewT(t).NewNode().Init().StartDaemon()
127 defer node.StopDaemon()
128
129 r, err := os.Open(carv2Fixture)
130 require.NoError(t, err)
131 defer r.Close()
132
133 // Use Runner.Run (not MustRun) so the test captures errors
134 // instead of panicking -- this lets us assert on the result.
135 res := node.Runner.Run(harness.RunRequest{
136 Path: node.IPFSBin,
137 Args: []string{"dag", "import", "--pin-roots=false"},
138 CmdOpts: []harness.CmdOpt{
139 harness.RunWithStdin(r),
140 },
141 })
142 require.Equal(t, 0, res.ExitCode(), "CARv2 import should succeed over HTTP API, stderr: %s", res.Stderr.String())
143
144 // Verify the imported blocks are accessible
145 stat := node.RunIPFS("dag", "stat", "--progress=false", "--enc=json", fixtureCid)
146 var data Data
147 err = json.Unmarshal(stat.Stdout.Bytes(), &data)
148 require.NoError(t, err)
149 // root + node1 + node2 + shared child = 4 unique blocks
150 require.Equal(t, 4, data.UniqueBlocks)
151 })
152 }
153
154 func TestDagImportFastProvide(t *testing.T) {
155 t.Parallel()
156
157 t.Run("fast-provide-root disabled via config: verify skipped in logs", func(t *testing.T) {
158 t.Parallel()
159 node := harness.NewT(t).NewNode().Init()
160 node.UpdateConfig(func(cfg *config.Config) {
161 cfg.Import.FastProvideRoot = config.False
162 })
163
164 // Start daemon with debug logging
165 node.StartDaemonWithReq(harness.RunRequest{
166 CmdOpts: []harness.CmdOpt{
167 harness.RunWithEnv(map[string]string{
168 "GOLOG_LOG_LEVEL": "error,core/commands=debug,core/commands/cmdenv=debug",
169 }),
170 },
171 }, "")
172 defer node.StopDaemon()
173
174 // Import CAR file
175 r, err := os.Open(fixtureFile)
176 require.NoError(t, err)
177 defer r.Close()
178 err = node.IPFSDagImport(r, fixtureCid)
179 require.NoError(t, err)
180
181 // Verify fast-provide-root was disabled
182 daemonLog := node.Daemon.Stderr.String()
183 require.Contains(t, daemonLog, "fast-provide-root: skipped")
184 })
185
186 t.Run("fast-provide-root enabled with wait=false: verify async provide", func(t *testing.T) {
187 t.Parallel()
188 node := harness.NewT(t).NewNode().Init()
189 // Use default config (FastProvideRoot=true, FastProvideWait=false)
190
191 node.StartDaemonWithReq(harness.RunRequest{
192 CmdOpts: []harness.CmdOpt{
193 harness.RunWithEnv(map[string]string{
194 "GOLOG_LOG_LEVEL": "error,core/commands=debug,core/commands/cmdenv=debug",
195 }),
196 },
197 }, "")
198 defer node.StopDaemon()
199
200 // Import CAR file
201 r, err := os.Open(fixtureFile)
202 require.NoError(t, err)
203 defer r.Close()
204 err = node.IPFSDagImport(r, fixtureCid)
205 require.NoError(t, err)
206
207 daemonLog := node.Daemon.Stderr
208 // Should see async mode started
209 require.Contains(t, daemonLog.String(), "fast-provide-root: enabled")
210 require.Contains(t, daemonLog.String(), "fast-provide-root: providing asynchronously")
211 require.Contains(t, daemonLog.String(), fixtureCid) // Should log the specific CID being provided
212
213 // Wait for async completion or failure (slightly more than DefaultFastProvideTimeout)
214 // In test environment with no DHT peers, this will fail with "failed to find any peer in table"
215 timeout := config.DefaultFastProvideTimeout + time.Second
216 completedOrFailed := waitForLogMessage(daemonLog, "async provide completed", timeout) ||
217 waitForLogMessage(daemonLog, "async provide failed", timeout)
218 require.True(t, completedOrFailed, "async provide should complete or fail within timeout")
219 })
220
221 t.Run("fast-provide-root enabled with wait=true: verify sync provide", func(t *testing.T) {
222 t.Parallel()
223 node := harness.NewT(t).NewNode().Init()
224 node.UpdateConfig(func(cfg *config.Config) {
225 cfg.Import.FastProvideWait = config.True
226 })
227
228 node.StartDaemonWithReq(harness.RunRequest{
229 CmdOpts: []harness.CmdOpt{
230 harness.RunWithEnv(map[string]string{
231 "GOLOG_LOG_LEVEL": "error,core/commands=debug,core/commands/cmdenv=debug",
232 }),
233 },
234 }, "")
235 defer node.StopDaemon()
236
237 // Import CAR file - use Run instead of IPFSDagImport to handle expected error
238 r, err := os.Open(fixtureFile)
239 require.NoError(t, err)
240 defer r.Close()
241 res := node.Runner.Run(harness.RunRequest{
242 Path: node.IPFSBin,
243 Args: []string{"dag", "import", "--pin-roots=false"},
244 CmdOpts: []harness.CmdOpt{
245 harness.RunWithStdin(r),
246 },
247 })
248 // In sync mode (wait=true), provide errors propagate and fail the command.
249 // Test environment uses 'test' profile with no bootstrappers, and CI has
250 // insufficient peers for proper DHT puts, so we expect this to fail with
251 // "failed to find any peer in table" error from the DHT.
252 require.Equal(t, 1, res.ExitCode())
253 require.Contains(t, res.Stderr.String(), "Error: fast-provide: failed to find any peer in table")
254
255 daemonLog := node.Daemon.Stderr.String()
256 // Should see sync mode started
257 require.Contains(t, daemonLog, "fast-provide-root: enabled")
258 require.Contains(t, daemonLog, "fast-provide-root: providing synchronously")
259 require.Contains(t, daemonLog, fixtureCid) // Should log the specific CID being provided
260 require.Contains(t, daemonLog, "sync provide failed") // Verify the failure was logged
261 })
262
263 t.Run("fast-provide-wait ignored when root disabled", func(t *testing.T) {
264 t.Parallel()
265 node := harness.NewT(t).NewNode().Init()
266 node.UpdateConfig(func(cfg *config.Config) {
267 cfg.Import.FastProvideRoot = config.False
268 cfg.Import.FastProvideWait = config.True
269 })
270
271 node.StartDaemonWithReq(harness.RunRequest{
272 CmdOpts: []harness.CmdOpt{
273 harness.RunWithEnv(map[string]string{
274 "GOLOG_LOG_LEVEL": "error,core/commands=debug,core/commands/cmdenv=debug",
275 }),
276 },
277 }, "")
278 defer node.StopDaemon()
279
280 // Import CAR file
281 r, err := os.Open(fixtureFile)
282 require.NoError(t, err)
283 defer r.Close()
284 err = node.IPFSDagImport(r, fixtureCid)
285 require.NoError(t, err)
286
287 daemonLog := node.Daemon.Stderr.String()
288 require.Contains(t, daemonLog, "fast-provide-root: skipped")
289 // Note: dag import doesn't log wait-flag-ignored like add does
290 })
291
292 t.Run("CLI flag overrides config: flag=true overrides config=false", func(t *testing.T) {
293 t.Parallel()
294 node := harness.NewT(t).NewNode().Init()
295 node.UpdateConfig(func(cfg *config.Config) {
296 cfg.Import.FastProvideRoot = config.False
297 })
298
299 node.StartDaemonWithReq(harness.RunRequest{
300 CmdOpts: []harness.CmdOpt{
301 harness.RunWithEnv(map[string]string{
302 "GOLOG_LOG_LEVEL": "error,core/commands=debug,core/commands/cmdenv=debug",
303 }),
304 },
305 }, "")
306 defer node.StopDaemon()
307
308 // Import CAR file with flag override
309 r, err := os.Open(fixtureFile)
310 require.NoError(t, err)
311 defer r.Close()
312 err = node.IPFSDagImport(r, fixtureCid, "--fast-provide-root=true")
313 require.NoError(t, err)
314
315 daemonLog := node.Daemon.Stderr
316 // Flag should enable it despite config saying false
317 require.Contains(t, daemonLog.String(), "fast-provide-root: enabled")
318 require.Contains(t, daemonLog.String(), "fast-provide-root: providing asynchronously")
319 require.Contains(t, daemonLog.String(), fixtureCid) // Should log the specific CID being provided
320 })
321
322 t.Run("CLI flag overrides config: flag=false overrides config=true", func(t *testing.T) {
323 t.Parallel()
324 node := harness.NewT(t).NewNode().Init()
325 node.UpdateConfig(func(cfg *config.Config) {
326 cfg.Import.FastProvideRoot = config.True
327 })
328
329 node.StartDaemonWithReq(harness.RunRequest{
330 CmdOpts: []harness.CmdOpt{
331 harness.RunWithEnv(map[string]string{
332 "GOLOG_LOG_LEVEL": "error,core/commands=debug,core/commands/cmdenv=debug",
333 }),
334 },
335 }, "")
336 defer node.StopDaemon()
337
338 // Import CAR file with flag override
339 r, err := os.Open(fixtureFile)
340 require.NoError(t, err)
341 defer r.Close()
342 err = node.IPFSDagImport(r, fixtureCid, "--fast-provide-root=false")
343 require.NoError(t, err)
344
345 daemonLog := node.Daemon.Stderr.String()
346 // Flag should disable it despite config saying true
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 }