| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/rand" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/ipfs/kubo/test/cli/harness" |
| 15 | "github.com/stretchr/testify/assert" |
| 16 | "github.com/stretchr/testify/require" |
| 17 | ) |
| 18 | |
| 19 | const ( |
| 20 | twoMiB = 2 * 1024 * 1024 // 2097152 - bitswap spec block size limit |
| 21 | twoMiBPlus = twoMiB + 1 // 2097153 |
| 22 | maxChunkSize = twoMiB - 256 // 2096896 - max chunker value (overhead budget for protobuf framing) |
| 23 | overMaxChunk = maxChunkSize + 1 // 2096897 |
| 24 | |
| 25 | // go-libp2p v0.47.0 network.MessageSizeMax is 4194304 bytes (4MiB). |
| 26 | // A bitswap message carrying a single block has a protobuf envelope |
| 27 | // whose size depends on the CID used to represent the block. For |
| 28 | // CIDv1 with raw codec and SHA2-256 multihash (4-byte CID prefix), |
| 29 | // the envelope is 18 bytes: 2 bytes for the empty Wantlist submessage, |
| 30 | // 6 bytes for the CID prefix field, 5 bytes for field tags and the |
| 31 | // payload length varint, and 5 bytes for the data length varint and |
| 32 | // block submessage length varint. The msgio varint reader rejects |
| 33 | // messages strictly larger than MessageSizeMax, so the maximum block |
| 34 | // that fits is 4194304 - 18 = 4194286 bytes. |
| 35 | // |
| 36 | // The hard limit varies slightly depending on the CID: a longer |
| 37 | // multihash (e.g. SHA-512) increases the CID prefix and reduces the |
| 38 | // maximum block payload by the same amount. |
| 39 | libp2pMsgMax = 4 * 1024 * 1024 // 4194304 - libp2p network.MessageSizeMax |
| 40 | bsBlockEnvelope = 18 // protobuf overhead for CIDv1 + raw + SHA2-256 |
| 41 | maxTransferBlock = libp2pMsgMax - bsBlockEnvelope // 4194286 - largest block transferable via bitswap |
| 42 | overMaxTransfer = maxTransferBlock + 1 // 4194287 |
| 43 | ) |
| 44 | |
| 45 | // blockSize returns the block size in bytes for a given CID by parsing |
| 46 | // the JSON output of `ipfs block stat --enc=json <cid>`. |
| 47 | func blockSize(t *testing.T, node *harness.Node, cid string) int { |
| 48 | t.Helper() |
| 49 | res := node.IPFS("block", "stat", "--enc=json", cid) |
| 50 | var stat struct { |
| 51 | Key string |
| 52 | Size int |
| 53 | } |
| 54 | require.NoError(t, json.Unmarshal(res.Stdout.Bytes(), &stat)) |
| 55 | return stat.Size |
| 56 | } |
| 57 | |
| 58 | // allBlockCIDs returns the root CID plus all recursive refs for a DAG. |
| 59 | func allBlockCIDs(t *testing.T, node *harness.Node, root string) []string { |
| 60 | t.Helper() |
| 61 | cids := []string{root} |
| 62 | res := node.IPFS("refs", "-r", "--unique", root) |
| 63 | for line := range strings.SplitSeq(strings.TrimSpace(res.Stdout.String()), "\n") { |
| 64 | if line != "" { |
| 65 | cids = append(cids, line) |
| 66 | } |
| 67 | } |
| 68 | return cids |
| 69 | } |
| 70 | |
| 71 | // assertAllBlocksWithinLimit checks that every block in the DAG rooted at |
| 72 | // root is at most twoMiB bytes. |
| 73 | func assertAllBlocksWithinLimit(t *testing.T, node *harness.Node, root string) { |
| 74 | t.Helper() |
| 75 | for _, c := range allBlockCIDs(t, node, root) { |
| 76 | size := blockSize(t, node, c) |
| 77 | assert.LessOrEqual(t, size, twoMiB, fmt.Sprintf("block %s is %d bytes, exceeds 2MiB limit", c, size)) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | func TestBlockSizeBoundary(t *testing.T) { |
| 82 | t.Parallel() |
| 83 | |
| 84 | t.Run("block put", func(t *testing.T) { |
| 85 | t.Parallel() |
| 86 | |
| 87 | t.Run("exactly 2MiB succeeds", func(t *testing.T) { |
| 88 | t.Parallel() |
| 89 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 90 | defer node.StopDaemon() |
| 91 | |
| 92 | data := make([]byte, twoMiB) |
| 93 | cid := strings.TrimSpace( |
| 94 | node.PipeToIPFS(bytes.NewReader(data), "block", "put").Stdout.String(), |
| 95 | ) |
| 96 | got := node.IPFS("block", "get", cid) |
| 97 | assert.Len(t, got.Stdout.Bytes(), twoMiB) |
| 98 | }) |
| 99 | |
| 100 | t.Run("2MiB+1 fails without --allow-big-block", func(t *testing.T) { |
| 101 | t.Parallel() |
| 102 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 103 | defer node.StopDaemon() |
| 104 | |
| 105 | data := make([]byte, twoMiBPlus) |
| 106 | res := node.RunPipeToIPFS(bytes.NewReader(data), "block", "put") |
| 107 | assert.NotEqual(t, 0, res.ExitCode()) |
| 108 | assert.Contains(t, res.Stderr.String(), "produced block is over 2MiB: big blocks can't be exchanged with other peers. consider using UnixFS for automatic chunking of bigger files, or pass --allow-big-block to override") |
| 109 | }) |
| 110 | |
| 111 | t.Run("2MiB+1 succeeds with --allow-big-block", func(t *testing.T) { |
| 112 | t.Parallel() |
| 113 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 114 | defer node.StopDaemon() |
| 115 | |
| 116 | data := make([]byte, twoMiBPlus) |
| 117 | cid := strings.TrimSpace( |
| 118 | node.PipeToIPFS(bytes.NewReader(data), "block", "put", "--allow-big-block").Stdout.String(), |
| 119 | ) |
| 120 | got := node.IPFS("block", "get", cid) |
| 121 | assert.Len(t, got.Stdout.Bytes(), twoMiBPlus) |
| 122 | }) |
| 123 | }) |
| 124 | |
| 125 | t.Run("dag put", func(t *testing.T) { |
| 126 | t.Parallel() |
| 127 | |
| 128 | t.Run("exactly 2MiB succeeds", func(t *testing.T) { |
| 129 | t.Parallel() |
| 130 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 131 | defer node.StopDaemon() |
| 132 | |
| 133 | data := make([]byte, twoMiB) |
| 134 | cid := strings.TrimSpace( |
| 135 | node.PipeToIPFS(bytes.NewReader(data), "dag", "put", "--input-codec=raw", "--store-codec=raw").Stdout.String(), |
| 136 | ) |
| 137 | got := node.IPFS("block", "get", cid) |
| 138 | assert.Len(t, got.Stdout.Bytes(), twoMiB) |
| 139 | }) |
| 140 | |
| 141 | t.Run("2MiB+1 fails without --allow-big-block", func(t *testing.T) { |
| 142 | t.Parallel() |
| 143 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 144 | defer node.StopDaemon() |
| 145 | |
| 146 | data := make([]byte, twoMiBPlus) |
| 147 | res := node.RunPipeToIPFS(bytes.NewReader(data), "dag", "put", "--input-codec=raw", "--store-codec=raw") |
| 148 | assert.NotEqual(t, 0, res.ExitCode()) |
| 149 | assert.Contains(t, res.Stderr.String(), "produced block is over 2MiB: big blocks can't be exchanged with other peers. consider using UnixFS for automatic chunking of bigger files, or pass --allow-big-block to override") |
| 150 | }) |
| 151 | |
| 152 | t.Run("2MiB+1 succeeds with --allow-big-block", func(t *testing.T) { |
| 153 | t.Parallel() |
| 154 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 155 | defer node.StopDaemon() |
| 156 | |
| 157 | data := make([]byte, twoMiBPlus) |
| 158 | cid := strings.TrimSpace( |
| 159 | node.PipeToIPFS(bytes.NewReader(data), "dag", "put", "--input-codec=raw", "--store-codec=raw", "--allow-big-block").Stdout.String(), |
| 160 | ) |
| 161 | got := node.IPFS("block", "get", cid) |
| 162 | assert.Len(t, got.Stdout.Bytes(), twoMiBPlus) |
| 163 | }) |
| 164 | }) |
| 165 | |
| 166 | t.Run("dag import and export", func(t *testing.T) { |
| 167 | t.Parallel() |
| 168 | |
| 169 | t.Run("2MiB+1 block round-trips with --allow-big-block", func(t *testing.T) { |
| 170 | t.Parallel() |
| 171 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 172 | defer node.StopDaemon() |
| 173 | |
| 174 | // put an oversized raw block with override |
| 175 | data := make([]byte, twoMiBPlus) |
| 176 | cid := strings.TrimSpace( |
| 177 | node.PipeToIPFS(bytes.NewReader(data), "dag", "put", "--input-codec=raw", "--store-codec=raw", "--allow-big-block").Stdout.String(), |
| 178 | ) |
| 179 | |
| 180 | // export to CAR |
| 181 | carPath := filepath.Join(node.Dir, "oversized.car") |
| 182 | require.NoError(t, node.IPFSDagExport(cid, carPath)) |
| 183 | |
| 184 | // re-import without --allow-big-block should fail |
| 185 | carFile, err := os.Open(carPath) |
| 186 | require.NoError(t, err) |
| 187 | res := node.RunPipeToIPFS(carFile, "dag", "import") |
| 188 | carFile.Close() |
| 189 | assert.NotEqual(t, 0, res.ExitCode()) |
| 190 | assert.Contains(t, res.Stderr.String()+res.Stdout.String(), "produced block is over 2MiB: big blocks can't be exchanged with other peers. consider using UnixFS for automatic chunking of bigger files, or pass --allow-big-block to override") |
| 191 | |
| 192 | // re-import with --allow-big-block should succeed |
| 193 | carFile, err = os.Open(carPath) |
| 194 | require.NoError(t, err) |
| 195 | res = node.RunPipeToIPFS(carFile, "dag", "import", "--allow-big-block") |
| 196 | carFile.Close() |
| 197 | assert.Equal(t, 0, res.ExitCode()) |
| 198 | }) |
| 199 | }) |
| 200 | |
| 201 | t.Run("ipfs add non-raw-leaves", func(t *testing.T) { |
| 202 | t.Parallel() |
| 203 | |
| 204 | // The chunker enforces ChunkSizeLimit (maxChunkSize = 2MiB - 256 |
| 205 | // as of boxo 2026Q1) regardless of leaf type. It does not know at parse time whether |
| 206 | // raw or wrapped leaves will be used, so the 256-byte overhead |
| 207 | // budget is applied uniformly. |
| 208 | // |
| 209 | // With --raw-leaves=false each chunk is wrapped in protobuf, |
| 210 | // adding ~14 bytes overhead that pushes blocks past the chunk size. |
| 211 | // The overhead budget ensures the wrapped block stays within 2MiB. |
| 212 | // |
| 213 | // With --raw-leaves=true there is no protobuf wrapper, so the |
| 214 | // block is exactly the chunk size (maxChunkSize). The 256-byte |
| 215 | // budget is unused in this case but the chunker still enforces it. |
| 216 | // A full 2MiB chunk (--chunker=size-2097152) is rejected even |
| 217 | // though the resulting raw block would fit within BlockSizeLimit. |
| 218 | |
| 219 | t.Run("1MiB chunk with protobuf wrapping succeeds under 2MiB limit", func(t *testing.T) { |
| 220 | t.Parallel() |
| 221 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 222 | defer node.StopDaemon() |
| 223 | |
| 224 | data := make([]byte, twoMiB) |
| 225 | res := node.RunPipeToIPFS(bytes.NewReader(data), "add", "-q", "--chunker=size-1048576", "--raw-leaves=false") |
| 226 | require.Equal(t, 0, res.ExitCode(), "stderr: %s", res.Stderr.String()) |
| 227 | root := strings.TrimSpace(res.Stdout.String()) |
| 228 | // the last line of `ipfs add -q` is the root CID |
| 229 | lines := strings.Split(root, "\n") |
| 230 | root = lines[len(lines)-1] |
| 231 | assertAllBlocksWithinLimit(t, node, root) |
| 232 | }) |
| 233 | |
| 234 | t.Run("max chunk with protobuf wrapping stays within block limit", func(t *testing.T) { |
| 235 | t.Parallel() |
| 236 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 237 | defer node.StopDaemon() |
| 238 | |
| 239 | // maxChunkSize leaves room for protobuf framing overhead |
| 240 | data := make([]byte, maxChunkSize*2) |
| 241 | res := node.RunPipeToIPFS(bytes.NewReader(data), "add", "-q", |
| 242 | fmt.Sprintf("--chunker=size-%d", maxChunkSize), "--raw-leaves=false") |
| 243 | require.Equal(t, 0, res.ExitCode(), "stderr: %s", res.Stderr.String()) |
| 244 | lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") |
| 245 | root := lines[len(lines)-1] |
| 246 | assertAllBlocksWithinLimit(t, node, root) |
| 247 | }) |
| 248 | |
| 249 | t.Run("chunk size over limit is rejected by chunker", func(t *testing.T) { |
| 250 | t.Parallel() |
| 251 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 252 | defer node.StopDaemon() |
| 253 | |
| 254 | data := make([]byte, twoMiB+twoMiB) |
| 255 | res := node.RunPipeToIPFS(bytes.NewReader(data), "add", "-q", |
| 256 | fmt.Sprintf("--chunker=size-%d", overMaxChunk), "--raw-leaves=false") |
| 257 | assert.NotEqual(t, 0, res.ExitCode()) |
| 258 | assert.Contains(t, res.Stderr.String(), |
| 259 | fmt.Sprintf("chunker parameters may not exceed the maximum chunk size of %d", maxChunkSize)) |
| 260 | }) |
| 261 | |
| 262 | t.Run("max chunk with raw leaves succeeds", func(t *testing.T) { |
| 263 | t.Parallel() |
| 264 | node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") |
| 265 | defer node.StopDaemon() |
| 266 | |
| 267 | // raw leaves have no protobuf wrapper, so max chunk size fits easily |
| 268 | data := make([]byte, maxChunkSize*2) |
| 269 | res := node.RunPipeToIPFS(bytes.NewReader(data), "add", "-q", |
| 270 | fmt.Sprintf("--chunker=size-%d", maxChunkSize), "--raw-leaves=true") |
| 271 | require.Equal(t, 0, res.ExitCode(), "stderr: %s", res.Stderr.String()) |
| 272 | lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") |
| 273 | root := lines[len(lines)-1] |
| 274 | assertAllBlocksWithinLimit(t, node, root) |
| 275 | }) |
| 276 | }) |
| 277 | |
| 278 | t.Run("bitswap exchange", func(t *testing.T) { |
| 279 | t.Parallel() |
| 280 | |
| 281 | t.Run("2MiB raw block transfers between peers", func(t *testing.T) { |
| 282 | t.Parallel() |
| 283 | h := harness.NewT(t) |
| 284 | provider := h.NewNode().Init("--profile=unixfs-v1-2025").StartDaemon() |
| 285 | defer provider.StopDaemon() |
| 286 | requester := h.NewNode().Init("--profile=unixfs-v1-2025").StartDaemon() |
| 287 | defer requester.StopDaemon() |
| 288 | |
| 289 | data := make([]byte, twoMiB) |
| 290 | _, err := rand.Read(data) |
| 291 | require.NoError(t, err) |
| 292 | cid := strings.TrimSpace( |
| 293 | provider.PipeToIPFS(bytes.NewReader(data), "block", "put").Stdout.String(), |
| 294 | ) |
| 295 | |
| 296 | requester.Connect(provider) |
| 297 | |
| 298 | res := requester.IPFS("block", "get", cid) |
| 299 | assert.Equal(t, data, res.Stdout.Bytes(), "retrieved block should match original") |
| 300 | }) |
| 301 | |
| 302 | t.Run("unixfs-v1-2025: 2MiB file transfers between peers", func(t *testing.T) { |
| 303 | t.Parallel() |
| 304 | h := harness.NewT(t) |
| 305 | provider := h.NewNode().Init("--profile=unixfs-v1-2025").StartDaemon() |
| 306 | defer provider.StopDaemon() |
| 307 | requester := h.NewNode().Init("--profile=unixfs-v1-2025").StartDaemon() |
| 308 | defer requester.StopDaemon() |
| 309 | |
| 310 | // unixfs-v1-2025 profile uses CIDv1, raw leaves, SHA2-256, |
| 311 | // and 1MiB chunks. A 2MiB file produces two 1MiB raw leaf |
| 312 | // blocks plus a root node, all within the 2MiB spec limit. |
| 313 | data := make([]byte, twoMiB) |
| 314 | _, err := rand.Read(data) |
| 315 | require.NoError(t, err) |
| 316 | res := provider.RunPipeToIPFS(bytes.NewReader(data), "add", "-q") |
| 317 | require.Equal(t, 0, res.ExitCode(), "stderr: %s", res.Stderr.String()) |
| 318 | lines := strings.Split(strings.TrimSpace(res.Stdout.String()), "\n") |
| 319 | root := lines[len(lines)-1] |
| 320 | |
| 321 | requester.Connect(provider) |
| 322 | |
| 323 | got := requester.IPFS("cat", root) |
| 324 | assert.Equal(t, data, got.Stdout.Bytes(), "retrieved file should match original") |
| 325 | }) |
| 326 | |
| 327 | // The following two tests guard the physical hard limit of the |
| 328 | // libp2p transport layer (network.MessageSizeMax = 4MiB). This is |
| 329 | // the actual ceiling for bitswap block transfer, independent of the |
| 330 | // 2MiB soft limit from the bitswap spec. Knowing the exact hard |
| 331 | // limit is important for backward-compatible protocol and standards |
| 332 | // evolution: any future increase to the bitswap spec block size |
| 333 | // must stay within the libp2p message framing budget, or the |
| 334 | // transport layer must be updated first. |
| 335 | |
| 336 | t.Run("bitswap-over-libp2p: largest block that fits in message transfers", func(t *testing.T) { |
| 337 | t.Parallel() |
| 338 | h := harness.NewT(t) |
| 339 | provider := h.NewNode().Init("--profile=unixfs-v1-2025").StartDaemon() |
| 340 | defer provider.StopDaemon() |
| 341 | requester := h.NewNode().Init("--profile=unixfs-v1-2025").StartDaemon() |
| 342 | defer requester.StopDaemon() |
| 343 | |
| 344 | data := make([]byte, maxTransferBlock) |
| 345 | _, err := rand.Read(data) |
| 346 | require.NoError(t, err) |
| 347 | cid := strings.TrimSpace( |
| 348 | provider.PipeToIPFS(bytes.NewReader(data), "block", "put", "--allow-big-block").Stdout.String(), |
| 349 | ) |
| 350 | |
| 351 | requester.Connect(provider) |
| 352 | |
| 353 | // successful transfers complete in ~1s |
| 354 | timeout := time.After(5 * time.Second) |
| 355 | dataChan := make(chan []byte, 1) |
| 356 | |
| 357 | go func() { |
| 358 | res := requester.RunIPFS("block", "get", cid) |
| 359 | dataChan <- res.Stdout.Bytes() |
| 360 | }() |
| 361 | |
| 362 | select { |
| 363 | case got := <-dataChan: |
| 364 | assert.Equal(t, data, got, "retrieved block should match original") |
| 365 | case <-timeout: |
| 366 | t.Fatal("block get timed out: expected transfer to succeed at maxTransferBlock") |
| 367 | } |
| 368 | }) |
| 369 | |
| 370 | t.Run("bitswap-over-libp2p: one byte over message limit does not transfer", func(t *testing.T) { |
| 371 | t.Parallel() |
| 372 | h := harness.NewT(t) |
| 373 | provider := h.NewNode().Init("--profile=unixfs-v1-2025").StartDaemon() |
| 374 | defer provider.StopDaemon() |
| 375 | requester := h.NewNode().Init("--profile=unixfs-v1-2025").StartDaemon() |
| 376 | defer requester.StopDaemon() |
| 377 | |
| 378 | data := make([]byte, overMaxTransfer) |
| 379 | _, err := rand.Read(data) |
| 380 | require.NoError(t, err) |
| 381 | cid := strings.TrimSpace( |
| 382 | provider.PipeToIPFS(bytes.NewReader(data), "block", "put", "--allow-big-block").Stdout.String(), |
| 383 | ) |
| 384 | |
| 385 | requester.Connect(provider) |
| 386 | |
| 387 | timeout := time.After(5 * time.Second) |
| 388 | dataChan := make(chan []byte, 1) |
| 389 | |
| 390 | go func() { |
| 391 | res := requester.RunIPFS("block", "get", cid) |
| 392 | dataChan <- res.Stdout.Bytes() |
| 393 | }() |
| 394 | |
| 395 | select { |
| 396 | case got := <-dataChan: |
| 397 | t.Fatalf("expected timeout, but block was retrieved (%d bytes)", len(got)) |
| 398 | case <-timeout: |
| 399 | t.Log("block get timed out as expected: block exceeds libp2p message size limit") |
| 400 | } |
| 401 | }) |
| 402 | }) |
| 403 | } |