Skip to main content

decdn_bao_range/
lib.rs

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