Skip to main content

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](../../../adr/037-regional-proxy-warming.md),
7//! [ADR 038 §Wire format](../../../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](../../../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;