decdn_bao_range/lib.rs
1//! Iroh-blobs-free bao verified-range helpers (#823, #915), shared by
2//! `decdn-cache` (origin range import + client serve) and `decdn-client`
3//! (client receive). Depends only on `bao-tree`, so linking it from the CLI's
4//! pull path keeps the iroh-blobs-free invariant (#578).
5//!
6//! [ADR 037 §Origin-tier pull-through](https://github.com/decdn/decdn/blob/main/adr/037-regional-proxy-warming.md),
7//! [ADR 038 §Wire format](https://github.com/decdn/decdn/blob/main/adr/038-bao-verified-range-streaming.md).
8//!
9//! An opaque origin backend (S3/R2/B2/HTTP/fs) serves raw bytes by hash with no
10//! bao tree, so a node that wants only a byte range `[a, b)` of a large blob
11//! historically had to pull the **whole** blob to verify any of it against the
12//! content address. When the origin also publishes the blob's pre-order bao
13//! outboard at the sibling key `{H}.obao4`, this module verifies the fetched
14//! range against the root `H` using that (untrusted) outboard and produces the
15//! bao interleaved encoding `iroh-blobs`' `import_bao_bytes` consumes — so the
16//! node imports a verified **partial** blob without whole-blob origin egress.
17//!
18//! The outboard is **self-validating against `H`**: a tampered range, a tampered
19//! outboard, or a wrong wanted-root all fail, because every leaf must hash to its
20//! anchored parent and the spine must combine to the `H` the node already wanted
21//! ([`encode_ranges_validated`] checks `ParentHashMismatch`/`LeafHashMismatch`).
22//! There is no trusted-origin assumption — the origin stays a dumb byte store.
23
24use bao_tree::io::outboard::PreOrderMemOutboard;
25use bao_tree::io::sync::encode_ranges_validated;
26use bao_tree::{BaoTree, BlockSize, ChunkNum, ChunkRanges};
27use bytes::Bytes;
28use positioned_io::ReadAt;
29
30#[cfg(feature = "test-util")]
31pub mod conformance;
32pub mod origin_seed;
33pub mod ranged_store;
34pub mod streaming;
35pub use origin_seed::{EncodedOutboard, OBAO4_SUFFIX, encode_outboard, shard_prefix};
36pub use ranged_store::{RangedFuture, RangedStore, RangedStoreError};
37
38/// `iroh-blobs`' canonical on-disk block size (16 KiB chunk groups,
39/// `from_chunk_log(4)`). Declared from the `bao-tree` primitive rather than
40/// re-exported from `iroh-blobs`, because this leaf crate must stay
41/// iroh-blobs-free (#578) so the CLI's pull path can link it. The value is
42/// **identical by construction** to `iroh_blobs::store::IROH_BLOCK_SIZE` (which
43/// is also `BlockSize::from_chunk_log(4)`); `decdn-cache` carries a lock-step
44/// test asserting the two stay equal, so a divergence across an iroh-blobs bump
45/// — which ADR 038 treats as a protocol-contract change — fails the build there.
46pub const IROH_BLOCK_SIZE: BlockSize = BlockSize::from_chunk_log(4);
47
48/// Bytes per chunk group, derived from [`IROH_BLOCK_SIZE`] (`2^chunk_log` 1 KiB
49/// chunks) — the granularity an origin range must snap to, because a bao proof
50/// anchors whole chunk groups to the root.
51///
52/// Public so a caller that must snap an offset to a group boundary WITHOUT
53/// knowing the blob's size can do so from the shared constant rather than
54/// re-deriving it (the resumable client fetch, #1120: it picks its resume offset
55/// before the signed response reveals `total_bytes`). Prefer [`align_range`] when
56/// the size is known — it also bound-checks.
57pub const CHUNK_GROUP_BYTES: u64 = 1u64 << (IROH_BLOCK_SIZE.chunk_log() + 10);
58
59/// Exact byte length of the **header-less** bao interleaved encoding a serving
60/// node emits for `chunk_ranges` of a `total_bytes`-byte blob — the sum of every
61/// proof node (64 bytes each) and data leaf (≤ chunk-group bytes) in response
62/// (pre-order) order ([ADR 038 §Wire format](https://github.com/decdn/decdn/blob/main/adr/038-bao-verified-range-streaming.md)).
63///
64/// This is the byte count that travels on `cdn/client/v1`: the wire carries the
65/// response-format stream (no 8-byte size header — `total_bytes` is already the
66/// signed `StreamResponse` field), so this excludes the header. It is computed
67/// from the same [`BaoTree`] + [`ChunkRanges`] the serve-side encoder walks, so
68/// it equals the emitted length byte-for-byte. The receiver uses it as the
69/// voucher-cadence / overrun / short-delivery bound, because paid bytes are wire
70/// bytes — content **plus** proof (ADR 038 §Payment metering) — not content
71/// bytes. `chunk_ranges` should be the [`AlignedRange::chunk_ranges`] both sides
72/// derive from [`align_range`], keeping encoder and decoder in lock-step.
73///
74/// Cost: `O(log groups)` for the group-aligned contiguous range [`align_range`]
75/// produces (a closed-form count over the tree's shape, see
76/// `aligned_wire_size`); every other range shape falls back to walking the
77/// encoder's node set, which is `O(groups in range)`. The closed form is what
78/// keeps a receiver's wire bound cheap against a peer-claimed or ceiling-sized
79/// `total_bytes` in the terabytes, where a walk would visit tens of millions of
80/// groups before the first byte arrives.
81#[must_use]
82pub fn bao_encoded_size(total_bytes: u64, chunk_ranges: &ChunkRanges) -> u64 {
83 // A 0-byte blob (#1054) has no proof and no data — zero wire bytes for ANY
84 // requested range. An empty range set likewise encodes to nothing. Short-
85 // circuit both before touching the tree: `bao-tree`'s pre-order chunk iterator
86 // `debug_assert!`s `!ranges.is_empty()` (and would otherwise mis-walk an empty
87 // set in release), and a 0-byte `BaoTree` walked over a non-empty range like
88 // `ChunkRanges::all()` is a degenerate the callers never intend to bill for.
89 if total_bytes == 0 || chunk_ranges.is_empty() {
90 return 0;
91 }
92 if let Some(size) = aligned_wire_size(total_bytes, chunk_ranges) {
93 return size;
94 }
95 walked_wire_size(total_bytes, chunk_ranges)
96}
97
98/// [`bao_encoded_size`] by walking exactly the node set the serve-side encoder
99/// emits: `encode_ranges` / `encode_ranges_validated` iterate
100/// `ranges_pre_order_chunks_iter_ref(ranges, 0)` over the real
101/// [`IROH_BLOCK_SIZE`] tree (sync.rs), so the same walk yields the same Parent
102/// (64 B) + Leaf (data) sequence — and thus the same byte count — that travels on
103/// the wire. (The `ResponseIterRef` convenience walks a block-size-zero tree and
104/// descends to 1 KiB chunks, over-counting parents on partial groups, so it must
105/// NOT be used here.) [`align_range`]'s ranges are already clamped to the blob,
106/// so the encoder's `truncate_ranges` step is a no-op and is not reachable here
107/// (its module is private upstream).
108///
109/// The reference the closed-form [`aligned_wire_size`] is checked against, and
110/// the fallback for range shapes it does not cover. `total_bytes` must be
111/// non-zero and `chunk_ranges` non-empty.
112fn walked_wire_size(total_bytes: u64, chunk_ranges: &ChunkRanges) -> u64 {
113 let tree = BaoTree::new(total_bytes, IROH_BLOCK_SIZE);
114 tree.ranges_pre_order_chunks_iter_ref(chunk_ranges.as_ref(), 0)
115 .map(|chunk| u64::try_from(chunk.without_ranges().size()).unwrap_or(u64::MAX))
116 .fold(0u64, u64::saturating_add)
117}
118
119/// Proof bytes per parent node on the wire: two 32-byte child hashes.
120const PARENT_WIRE_BYTES: u64 = 64;
121
122/// [`bao_encoded_size`] in closed form for ONE contiguous chunk range that starts
123/// on a chunk-group boundary and ends on one (or at the blob's end) — the shape
124/// [`align_range`] produces. `None` for any other shape (a sub-group start or
125/// end, several ranges, an open-ended range), which the caller walks instead.
126///
127/// What the encoder emits for such a range, per [`walked_wire_size`]'s iterator
128/// over the [`IROH_BLOCK_SIZE`]-shifted tree, whose leaves are whole chunk groups:
129///
130/// - the data of every group in the range — one leaf per group (the iterator's
131/// leaf split into 1 KiB halves only applies to a block-size-zero tree);
132/// - one parent for every internal node of the shifted tree whose group span
133/// intersects the range ([`intersecting_parents`]).
134///
135/// The shifted tree is bao's left-full binary tree over `n` groups: a lone group
136/// is a leaf, otherwise the left child is the full tree over the largest power of
137/// two below `n` and the right child the tree over the rest. Every internal node
138/// has two children, so a full tree over `n` groups has exactly `n - 1` parents.
139fn aligned_wire_size(total_bytes: u64, chunk_ranges: &ChunkRanges) -> Option<u64> {
140 let group_chunks: u64 = 1u64 << IROH_BLOCK_SIZE.chunk_log();
141 let &[start, end] = chunk_ranges.boundaries() else {
142 return None;
143 };
144 let (start, end) = (start.0, end.0);
145 let total_chunks = total_bytes.div_ceil(1024);
146 let start_on_group = start % group_chunks == 0;
147 let end_on_group = end % group_chunks == 0 || end == total_chunks;
148 if !start_on_group || !end_on_group || end <= start || end > total_chunks {
149 return None;
150 }
151 let groups = total_chunks.div_ceil(group_chunks);
152 let first_group = start / group_chunks;
153 let end_group = end.div_ceil(group_chunks);
154
155 let data_start = start.saturating_mul(1024);
156 let data_end = end.saturating_mul(1024).min(total_bytes);
157 let data = data_end.saturating_sub(data_start);
158
159 let parents = intersecting_parents(groups, 0, first_group, end_group);
160 Some(data.saturating_add(parents.saturating_mul(PARENT_WIRE_BYTES)))
161}
162
163/// Internal nodes of bao's left-full tree over `n` groups at `base` whose group
164/// span intersects `[a, b)`. A node either lies entirely outside the range (adds
165/// nothing), entirely inside it (adds every parent of its full subtree, `n - 1`),
166/// or straddles a boundary (adds itself and recurses) — so the recursion visits
167/// only the two boundary paths, `O(log n)` nodes.
168fn intersecting_parents(n: u64, base: u64, a: u64, b: u64) -> u64 {
169 let span_end = base.saturating_add(n);
170 if n <= 1 || b <= base || a >= span_end {
171 return 0;
172 }
173 if a <= base && span_end <= b {
174 return n - 1;
175 }
176 // Largest power of two strictly below `n` (`n >= 2` here): the left child's
177 // group count.
178 let left = (n - 1).isolate_highest_one();
179 1u64.saturating_add(intersecting_parents(left, base, a, b))
180 .saturating_add(intersecting_parents(
181 n - left,
182 base.saturating_add(left),
183 a,
184 b,
185 ))
186}
187
188/// Errors from preparing a verified origin range pull.
189#[derive(Debug, thiserror::Error)]
190pub enum RangeVerifyError {
191 /// `byte_offset` is at or past a non-empty blob's end, or (for the empty
192 /// blob) the request is not `(0, 0)`. [`align_range`] also raises this when
193 /// `byte_offset + byte_len` runs past the blob or overflows, rejecting the
194 /// request; [`align_range_clamped`] clamps that same case to the blob's end
195 /// instead of raising it.
196 #[error("range [{offset}, +{len}) is out of bounds for a {blob_size}-byte blob")]
197 RangeOutOfBounds {
198 /// Requested start offset.
199 offset: u64,
200 /// Requested length as supplied (`0` denotes "to end"), reported verbatim.
201 len: u64,
202 /// Known blob size from the signed `total_bytes` / manifest entry.
203 blob_size: u64,
204 },
205 /// The supplied outboard is not the length a `blob_size`-byte blob's
206 /// pre-order outboard must be — the origin served a malformed `{H}.obao4`.
207 #[error("outboard is {got} bytes, expected {expected} for a {blob_size}-byte blob")]
208 OutboardSize {
209 /// Length a correct pre-order outboard would have.
210 expected: usize,
211 /// Length actually supplied.
212 got: usize,
213 /// Known blob size.
214 blob_size: u64,
215 },
216 /// `range_data` is not exactly the aligned fetch window's length — the origin
217 /// served a 206 whose body is shorter or longer than `[fetch_start, fetch_end)`
218 /// (e.g. a corrupt/extra tail). Rejected rather than silently ignoring the
219 /// surplus (or short-reading the deficit) inside the offset-mapped reader.
220 #[error(
221 "range_data is {got} bytes, expected {expected} for the aligned fetch window \
222 of a {blob_size}-byte blob"
223 )]
224 RangeDataSize {
225 /// Aligned fetch-window length the range body must have (`fetch_len`).
226 expected: u64,
227 /// Length actually supplied.
228 got: usize,
229 /// Known blob size.
230 blob_size: u64,
231 },
232 /// The fetched range or the outboard did not verify against the wanted root
233 /// `H` — a tampered range, a tampered/foreign outboard, or a wrong root.
234 /// Carries the bao codec's typed cause so a caller can distinguish an
235 /// integrity failure (`ParentHashMismatch` / `LeafHashMismatch` — do not
236 /// retry) from a truncated origin response (`Io` short read — retryable).
237 #[error("range failed bao verification against the content root")]
238 Verification {
239 /// Underlying bao codec rejection.
240 #[source]
241 source: bao_tree::io::EncodeError,
242 },
243}
244
245/// The chunk-group-aligned byte span a node must fetch from origin to serve a
246/// requested `[byte_offset, byte_offset + byte_len)`, plus the chunk ranges to
247/// import. A bao proof covers whole 16 KiB groups, so the fetch widens to the
248/// enclosing group boundaries (the extra bytes are paid origin egress but never
249/// re-served as client bytes — the request is still scoped, not whole-blob).
250/// Fields are private and the only constructor is [`align_range`], so holding an
251/// `AlignedRange` guarantees its invariants: `fetch_start <= fetch_end`, both
252/// group-aligned (or `fetch_end == blob_size` for the final partial group), and
253/// `chunk_ranges` exactly covers `[fetch_start, fetch_end)`.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct AlignedRange {
256 fetch_start: u64,
257 fetch_end: u64,
258 chunk_ranges: ChunkRanges,
259 blob_size: u64,
260}
261
262impl AlignedRange {
263 /// Whole-blob size this range was aligned against — the signed `total_bytes`
264 /// [`align_range`] was given. Held so [`wire_len`](Self::wire_len) and
265 /// [`encode_verified_range`] cannot be handed a `blob_size` that disagrees
266 /// with the `chunk_ranges`.
267 #[must_use]
268 pub const fn blob_size(&self) -> u64 {
269 self.blob_size
270 }
271
272 /// Exact header-less bao **wire** byte count for this range (content **plus**
273 /// interleaved proof, ADR 038) — the paid quantity, and the receiver's
274 /// voucher-cadence / overrun / short-delivery bound.
275 ///
276 /// This is [`bao_encoded_size`] applied to this range's own `blob_size` and
277 /// `chunk_ranges`, so the two can never drift: a mismatched `(total, ranges)`
278 /// pair would otherwise silently yield a plausible-but-wrong payment bound
279 /// with no runtime backstop — the one misuse seam the crypto never sees.
280 #[must_use]
281 pub fn wire_len(&self) -> u64 {
282 bao_encoded_size(self.blob_size, &self.chunk_ranges)
283 }
284
285 /// First byte to fetch (the requested offset floored to a group boundary).
286 #[must_use]
287 pub const fn fetch_start(&self) -> u64 {
288 self.fetch_start
289 }
290
291 /// One past the last byte to fetch (requested end ceiled to a group
292 /// boundary, then clamped to the blob size for the final partial group).
293 #[must_use]
294 pub const fn fetch_end(&self) -> u64 {
295 self.fetch_end
296 }
297
298 /// Chunk ranges (1 KiB `ChunkNum` units) the fetched bytes cover — passed to
299 /// both [`encode_verified_range`] and `import_bao_bytes`.
300 #[must_use]
301 pub const fn chunk_ranges(&self) -> &ChunkRanges {
302 &self.chunk_ranges
303 }
304
305 /// Number of bytes to fetch from origin for this range. Non-underflowing by
306 /// construction — `align_range` guarantees `fetch_end >= fetch_start`.
307 #[must_use]
308 pub const fn fetch_len(&self) -> u64 {
309 self.fetch_end - self.fetch_start
310 }
311}
312
313/// Chunk-group-aligns `[byte_offset, end)` of a `blob_size`-byte blob into an
314/// [`AlignedRange`]. Shared tail of [`align_range`] and [`align_range_clamped`]
315/// once each has resolved its own `end` and validated its own `byte_offset`.
316fn build_aligned_range(byte_offset: u64, end: u64, blob_size: u64) -> AlignedRange {
317 let fetch_start = (byte_offset / CHUNK_GROUP_BYTES) * CHUNK_GROUP_BYTES;
318 let fetch_end = end
319 .div_ceil(CHUNK_GROUP_BYTES)
320 .saturating_mul(CHUNK_GROUP_BYTES)
321 .min(blob_size);
322 let chunk_ranges =
323 ChunkRanges::from(ChunkNum::full_chunks(fetch_start)..ChunkNum::chunks(fetch_end));
324 AlignedRange {
325 fetch_start,
326 fetch_end,
327 chunk_ranges,
328 blob_size,
329 }
330}
331
332/// Compute the chunk-group-aligned fetch span for a `[byte_offset, +byte_len)`
333/// request against a `blob_size`-byte blob. `byte_len == 0` means "to end".
334///
335/// The empty (0-byte) blob is addressable only as the whole blob — `(0, 0)` —
336/// and aligns to an empty [`AlignedRange`] (`fetch_start == fetch_end == 0`,
337/// empty `chunk_ranges`, zero [`wire_len`](AlignedRange::wire_len)). This
338/// preserves the pre-bao empty-bytes delivery on the bao path (#1054); the empty
339/// stream is still proven against the empty root `blake3::hash(&[])` by the
340/// receiver, never accepted for an arbitrary root.
341///
342/// # Errors
343///
344/// [`RangeVerifyError::RangeOutOfBounds`] if the offset is past the blob end, or
345/// the explicit end overflows / exceeds the blob size. For a 0-byte blob, any
346/// positive offset or explicit positive length is out of bounds. Use
347/// [`align_range_clamped`] when an end past the blob should be served up to the
348/// blob's end instead of refused.
349pub fn align_range(
350 byte_offset: u64,
351 byte_len: u64,
352 blob_size: u64,
353) -> Result<AlignedRange, RangeVerifyError> {
354 let oob = || RangeVerifyError::RangeOutOfBounds {
355 offset: byte_offset,
356 len: byte_len,
357 blob_size,
358 };
359 // The empty blob is served only as its whole (empty) self: offset 0, "to
360 // end". The construction below then yields an empty range. A non-empty blob
361 // still rejects any offset at or past the end (reject, never clamp — ADR 005).
362 if blob_size == 0 {
363 if byte_offset != 0 || byte_len != 0 {
364 return Err(oob());
365 }
366 } else if byte_offset >= blob_size {
367 return Err(oob());
368 }
369 let end = if byte_len == 0 {
370 blob_size
371 } else {
372 let e = byte_offset.checked_add(byte_len).ok_or_else(oob)?;
373 if e > blob_size {
374 return Err(oob());
375 }
376 e
377 };
378 Ok(build_aligned_range(byte_offset, end, blob_size))
379}
380
381/// [`align_range`], but an end past the blob clamps to the blob's end instead
382/// of being refused: a claimed size is a hint, and the planner's bound can
383/// overshoot it. Only `byte_offset >= blob_size` (for a non-empty blob) is
384/// refused, with [`RangeVerifyError::RangeOutOfBounds`]: a start past the end
385/// has no chunk group to anchor, clamped or not. `(0, 0)` means the whole
386/// blob, and the empty blob is addressable only as `(0, 0)`, exactly as in
387/// [`align_range`].
388///
389/// Both a node deciding what to serve and a client deciding what wire length to
390/// expect call this, so the two always agree on the served range.
391///
392/// # Errors
393///
394/// [`RangeVerifyError::RangeOutOfBounds`] if `byte_offset` is at or past a
395/// non-empty blob's end, or (for the empty blob) the request is not `(0, 0)`.
396pub fn align_range_clamped(
397 byte_offset: u64,
398 byte_len: u64,
399 blob_size: u64,
400) -> Result<AlignedRange, RangeVerifyError> {
401 let oob = || RangeVerifyError::RangeOutOfBounds {
402 offset: byte_offset,
403 len: byte_len,
404 blob_size,
405 };
406 if blob_size == 0 {
407 if byte_offset != 0 || byte_len != 0 {
408 return Err(oob());
409 }
410 } else if byte_offset >= blob_size {
411 return Err(oob());
412 }
413 let end = if byte_len == 0 {
414 blob_size
415 } else {
416 byte_offset
417 .checked_add(byte_len)
418 .map_or(blob_size, |e| e.min(blob_size))
419 };
420 Ok(build_aligned_range(byte_offset, end, blob_size))
421}
422
423/// Maps absolute blob offsets onto a buffer that holds only `[base, base+len)` —
424/// the bytes a ranged origin fetch returned. Reads outside the held span report
425/// EOF, which surfaces as a verification short-read rather than reading zeros.
426struct OffsetReadAt<'a> {
427 base: u64,
428 data: &'a [u8],
429}
430
431impl ReadAt for OffsetReadAt<'_> {
432 fn read_at(&self, pos: u64, buf: &mut [u8]) -> std::io::Result<usize> {
433 let Some(rel) = pos.checked_sub(self.base) else {
434 return Ok(0);
435 };
436 let Ok(rel) = usize::try_from(rel) else {
437 return Ok(0);
438 };
439 let Some(src) = self.data.get(rel..) else {
440 return Ok(0);
441 };
442 let n = src.len().min(buf.len());
443 let (Some(dst), Some(s)) = (buf.get_mut(..n), src.get(..n)) else {
444 return Ok(0);
445 };
446 dst.copy_from_slice(s);
447 Ok(n)
448 }
449}
450
451/// Verify `range_data` (covering `aligned.fetch_start..aligned.fetch_end`) and
452/// the supplied pre-order `outboard` against the wanted root `root`, returning
453/// the bao interleaved encoding for `aligned.chunk_ranges` — ready for
454/// `Blobs::import_bao_bytes(root, aligned.chunk_ranges, _)`.
455///
456/// The outboard is untrusted: verification anchors every chunk group to `root`,
457/// so a tampered range, a foreign/tampered outboard, or a wrong `root` all fail
458/// with [`RangeVerifyError::Verification`] — there is no trusted-origin path.
459///
460/// # Errors
461///
462/// - [`RangeVerifyError::RangeDataSize`] if `range_data` is not exactly
463/// `aligned.fetch_len()` bytes (a corrupt/extra or truncated origin 206 body).
464/// - [`RangeVerifyError::OutboardSize`] if `outboard` is the wrong length for the
465/// aligned range's blob size (`aligned.blob_size()`).
466/// - [`RangeVerifyError::Verification`] if the range/outboard do not verify
467/// against `root`.
468pub fn encode_verified_range(
469 root: [u8; 32],
470 aligned: &AlignedRange,
471 range_data: &[u8],
472 outboard: Bytes,
473) -> Result<Bytes, RangeVerifyError> {
474 let blob_size = aligned.blob_size();
475 // Guard the one seam the crypto never sees: the offset-mapped reader below only
476 // exposes `[fetch_start, fetch_end)`, so a `range_data` longer than the aligned
477 // fetch window would have its tail silently dropped (and a shorter one would
478 // surface only as an opaque verification short-read). Reject either up front so
479 // a malformed origin 206 is a typed error, not a silent truncation.
480 if u64::try_from(range_data.len()).ok() != Some(aligned.fetch_len()) {
481 return Err(RangeVerifyError::RangeDataSize {
482 expected: aligned.fetch_len(),
483 got: range_data.len(),
484 blob_size,
485 });
486 }
487 let tree = BaoTree::new(blob_size, IROH_BLOCK_SIZE);
488 let expected = usize::try_from(tree.outboard_size()).unwrap_or(usize::MAX);
489 if outboard.len() != expected {
490 return Err(RangeVerifyError::OutboardSize {
491 expected,
492 got: outboard.len(),
493 blob_size,
494 });
495 }
496 let ob = PreOrderMemOutboard {
497 root: bao_tree::blake3::Hash::from(root),
498 tree,
499 data: outboard,
500 };
501 let reader = OffsetReadAt {
502 base: aligned.fetch_start,
503 data: range_data,
504 };
505 // bao "combined" encoding: an 8-byte little-endian size header precedes the
506 // interleaved proof+data stream. `iroh-blobs`' `import_bao_reader` reads the
507 // header first to frame the tree, then feeds the rest to its decoder, so the
508 // helper owns producing the full importable byte string. Pre-size the buffer
509 // to the header + fetched range (plus the ~0.4% proof overhead grows once);
510 // `fetch_len` always fits `usize` when `range_data` does, so a failed
511 // conversion just degrades to the default growth.
512 let cap = usize::try_from(aligned.fetch_len())
513 .unwrap_or(0)
514 .saturating_add(8);
515 let mut encoded = Vec::with_capacity(cap);
516 encoded.extend_from_slice(&blob_size.to_le_bytes());
517 encode_ranges_validated(&reader, &ob, aligned.chunk_ranges.as_ref(), &mut encoded)
518 .map_err(|source| RangeVerifyError::Verification { source })?;
519 Ok(Bytes::from(encoded))
520}
521
522#[cfg(test)]
523#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
524mod tests;