master
go 403 lines 14.9 KB
Raw
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 }