| 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 | } |