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;