Skip to main content

mkit_core/verify/
span.rs

1//! MKDS v1 multi-chunk range proofs (SPEC-DISCLOSURE §8).
2//!
3//! The decoder borrows its inner bundles. Verification retains only small
4//! summaries and the requested output, so it never keeps decoded chunks
5//! from earlier bundles alive while checking later bundles.
6
7use super::{
8    ChunkHdr, CommitContext, DisclosedPayload, LenProof, PayloadWire, Selector, Step, VerifyError,
9    encode_disclosure, extract_bao_slice, verify_disclosure, verify_disclosure_reusing_context,
10};
11use crate::hash::{Hash, hash};
12use crate::merkle;
13use crate::object::{EntryMode, MkitError, Object};
14use crate::store::{ObjectSource, StoreError};
15use commonware_codec::{EncodeSize, Write};
16
17use super::MAX_BUNDLE_BYTES;
18
19const MAGIC: &[u8; 4] = b"MKDS";
20const VERSION: u8 = 1;
21const MAX_CHUNK_BUNDLES: usize = 1_000_000;
22
23/// An authenticated byte range spanning at least two chunks.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct DisclosedSpan {
27    /// Trusted commit id.
28    pub commit_id: Hash,
29    /// Root tree id from the signed commit.
30    pub tree_hash: Hash,
31    /// Authenticated path and entry modes.
32    pub path: Vec<(Vec<u8>, EntryMode)>,
33    /// `ChunkedBlob` leaf id.
34    pub leaf_id: Hash,
35    /// Public key embedded in the commit.
36    pub signer: [u8; 32],
37    /// Whether the embedded signature verifies; key trust is caller policy.
38    pub signature_valid: bool,
39    /// Absolute content offset.
40    pub offset: u64,
41    /// Verified content bytes in the requested range.
42    pub bytes: Vec<u8>,
43    /// First included chunk index.
44    pub first: u32,
45    /// Last included chunk index.
46    pub last: u32,
47    /// Authenticated absolute beginning of the first chunk.
48    pub span_start: u64,
49    /// Authenticated bare chunk BMT root.
50    pub chunk_inner_root: Hash,
51}
52
53/// First failed MKDS check, in SPEC-DISCLOSURE §8.2 order.
54#[derive(Debug, thiserror::Error)]
55#[non_exhaustive]
56pub enum SpanError {
57    /// Container exceeds 64 MiB.
58    #[error("span_too_large")]
59    TooLarge,
60    /// Wrong magic.
61    #[error("span_magic")]
62    Magic,
63    /// Wrong version.
64    #[error("span_version")]
65    Version,
66    /// Truncated field, invalid varint, or oversized vector.
67    #[error("span_encoding")]
68    Encoding,
69    /// Extra bytes after the declared vectors.
70    #[error("span_trailing_bytes")]
71    TrailingBytes,
72    /// Embedded commit id differs from the trusted id.
73    #[error("span_commit")]
74    Commit,
75    /// Zero length or overflowed endpoint.
76    #[error("span_range_arithmetic")]
77    RangeArithmetic,
78    /// Fewer than two chunk bundles.
79    #[error("span_chunk_count")]
80    ChunkCount,
81    /// Anchor MKDP failed verification.
82    #[error("span_anchor_invalid: {0}")]
83    AnchorInvalid(#[source] VerifyError),
84    /// Anchor is not a one-byte chunked Range at local offset zero.
85    #[error("span_anchor_selector")]
86    AnchorSelector,
87    /// Anchor does not prove its absolute offset.
88    #[error("span_anchor_offset")]
89    AnchorOffset,
90    /// An inner MKDP failed verification.
91    #[error("span_inner_invalid: {0}")]
92    InnerInvalid(#[source] VerifyError),
93    /// An inner payload is not Chunk.
94    #[error("span_chunk_selector")]
95    ChunkSelector,
96    /// Inner path or leaf differs from the anchor.
97    #[error("span_leaf_context")]
98    LeafContext,
99    /// Inner chunk metadata or root differs from the anchor.
100    #[error("span_chunk_context")]
101    ChunkContext,
102    /// Indices do not begin at the anchor and advance consecutively.
103    #[error("span_chunk_order")]
104    ChunkOrder,
105    /// Chunk bytes are not a canonical nonempty Blob.
106    #[error("span_chunk_bytes")]
107    ChunkBytes,
108    /// First chunk is not bound to the anchor byte and id.
109    #[error("span_anchor_binding")]
110    AnchorBinding,
111    /// Range is outside the included, authenticated chunk content.
112    #[error("span_range_outside")]
113    RangeOutside,
114    /// The final included chunk is unnecessary.
115    #[error("span_last_unneeded")]
116    LastUnneeded,
117}
118
119impl SpanError {
120    /// Stable language-independent golden reject label.
121    #[must_use]
122    pub const fn reason(&self) -> &'static str {
123        match self {
124            Self::TooLarge => "span_too_large",
125            Self::Magic => "span_magic",
126            Self::Version => "span_version",
127            Self::Encoding => "span_encoding",
128            Self::TrailingBytes => "span_trailing_bytes",
129            Self::Commit => "span_commit",
130            Self::RangeArithmetic => "span_range_arithmetic",
131            Self::ChunkCount => "span_chunk_count",
132            Self::AnchorInvalid(_) => "span_anchor_invalid",
133            Self::AnchorSelector => "span_anchor_selector",
134            Self::AnchorOffset => "span_anchor_offset",
135            Self::InnerInvalid(_) => "span_inner_invalid",
136            Self::ChunkSelector => "span_chunk_selector",
137            Self::LeafContext => "span_leaf_context",
138            Self::ChunkContext => "span_chunk_context",
139            Self::ChunkOrder => "span_chunk_order",
140            Self::ChunkBytes => "span_chunk_bytes",
141            Self::AnchorBinding => "span_anchor_binding",
142            Self::RangeOutside => "span_range_outside",
143            Self::LastUnneeded => "span_last_unneeded",
144        }
145    }
146}
147
148struct Reader<'a>(&'a [u8]);
149
150impl<'a> Reader<'a> {
151    fn take(&mut self, n: usize) -> Result<&'a [u8], SpanError> {
152        if n > self.0.len() {
153            return Err(SpanError::Encoding);
154        }
155        let (part, rest) = self.0.split_at(n);
156        self.0 = rest;
157        Ok(part)
158    }
159
160    fn u64(&mut self) -> Result<u64, SpanError> {
161        let bytes: [u8; 8] = self.take(8)?.try_into().map_err(|_| SpanError::Encoding)?;
162        Ok(u64::from_be_bytes(bytes))
163    }
164
165    /// Strict minimal LEB128, capped at u32 and at `limit`.
166    fn length(&mut self, limit: usize) -> Result<usize, SpanError> {
167        let mut value = 0u32;
168        for i in 0..5 {
169            let byte = self.take(1)?[0];
170            if (i > 0 && byte == 0) || (i == 4 && byte > 15) {
171                return Err(SpanError::Encoding);
172            }
173            value |= u32::from(byte & 0x7f) << (7 * i);
174            if byte & 0x80 == 0 {
175                let n = value as usize;
176                return (n <= limit).then_some(n).ok_or(SpanError::Encoding);
177            }
178        }
179        Err(SpanError::Encoding)
180    }
181
182    fn vector(&mut self) -> Result<&'a [u8], SpanError> {
183        let n = self.length(self.0.len().min(MAX_BUNDLE_BYTES))?;
184        self.take(n)
185    }
186}
187
188struct Decoded<'a> {
189    commit: Hash,
190    offset: u64,
191    len: u64,
192    anchor: &'a [u8],
193    chunks: Vec<&'a [u8]>,
194}
195
196fn decode(bytes: &[u8]) -> Result<Decoded<'_>, SpanError> {
197    if bytes.len() > MAX_BUNDLE_BYTES {
198        return Err(SpanError::TooLarge);
199    }
200    let mut reader = Reader(bytes);
201    if reader.take(4)? != MAGIC {
202        return Err(SpanError::Magic);
203    }
204    if reader.take(1)? != [VERSION] {
205        return Err(SpanError::Version);
206    }
207    let commit = reader
208        .take(32)?
209        .try_into()
210        .map_err(|_| SpanError::Encoding)?;
211    let offset = reader.u64()?;
212    let len = reader.u64()?;
213    let anchor = reader.vector()?;
214    let count = reader.length(MAX_CHUNK_BUNDLES)?;
215    // Each vector takes at least one length byte. This bound is checked
216    // before even an empty Vec is constructed from an untrusted count.
217    if count > reader.0.len() {
218        return Err(SpanError::Encoding);
219    }
220    let mut chunks = Vec::new();
221    for _ in 0..count {
222        chunks.push(reader.vector()?);
223    }
224    if !reader.0.is_empty() {
225        return Err(SpanError::TrailingBytes);
226    }
227    Ok(Decoded {
228        commit,
229        offset,
230        len,
231        anchor,
232        chunks,
233    })
234}
235
236/// Encode an MKDS v1 container from already encoded MKDP v2 bundles.
237///
238/// Encoding does not authenticate or validate the supplied bundles. Callers
239/// must verify the result against a trusted commit id before using it.
240#[must_use]
241pub fn encode_span(
242    commit: Hash,
243    offset: u64,
244    len: u64,
245    anchor: &[u8],
246    chunks: &[&[u8]],
247) -> Vec<u8> {
248    let mut out = Vec::new();
249    out.extend_from_slice(MAGIC);
250    out.push(VERSION);
251    commit.write(&mut out);
252    offset.write(&mut out);
253    len.write(&mut out);
254    anchor.write(&mut out);
255    let mut count = chunks.len();
256    loop {
257        #[allow(clippy::cast_possible_truncation)] // Mask keeps the value within seven bits.
258        let byte = (count & 0x7f) as u8;
259        count >>= 7;
260        out.push(if count == 0 { byte } else { byte | 0x80 });
261        if count == 0 {
262            break;
263        }
264    }
265    for chunk in chunks {
266        (*chunk).write(&mut out);
267    }
268    out
269}
270
271struct Summary {
272    selector_ok: bool,
273    leaf_ok: bool,
274    context_ok: bool,
275    index: u32,
276    content_len: Option<u64>,
277    first_byte: Option<u8>,
278    canonical_id: Hash,
279}
280
281// This seam lets unit tests exercise inconsistent decoded summaries that
282// genuine authenticated MKDP bundles cannot produce.
283fn check_summaries(
284    summaries: &[Summary],
285    first: u32,
286    anchor_chunk_id: &Hash,
287    anchor_byte: u8,
288) -> Result<(), SpanError> {
289    if summaries.iter().any(|s| !s.selector_ok) {
290        return Err(SpanError::ChunkSelector);
291    }
292    if summaries.iter().any(|s| !s.leaf_ok) {
293        return Err(SpanError::LeafContext);
294    }
295    if summaries.iter().any(|s| !s.context_ok) {
296        return Err(SpanError::ChunkContext);
297    }
298    if summaries
299        .iter()
300        .enumerate()
301        .any(|(i, s)| u32::try_from(i).ok().and_then(|i| first.checked_add(i)) != Some(s.index))
302    {
303        return Err(SpanError::ChunkOrder);
304    }
305    if summaries.iter().any(|s| s.content_len.is_none()) {
306        return Err(SpanError::ChunkBytes);
307    }
308    if summaries[0].canonical_id != *anchor_chunk_id || summaries[0].first_byte != Some(anchor_byte)
309    {
310        return Err(SpanError::AnchorBinding);
311    }
312    Ok(())
313}
314
315/// Verify a multi-chunk range against a trusted commit id.
316///
317/// # Errors
318///
319/// Returns the first failed [`SpanError`] check in SPEC-DISCLOSURE §8.2
320/// table order. No partial content escapes on any error.
321#[allow(clippy::too_many_lines)] // Mirrors the normative ordered check table in one audit path.
322pub fn verify_disclosure_span(trusted: &Hash, bytes: &[u8]) -> Result<DisclosedSpan, SpanError> {
323    let decoded = decode(bytes)?;
324    if &decoded.commit != trusted {
325        return Err(SpanError::Commit);
326    }
327    let end = decoded
328        .offset
329        .checked_add(decoded.len)
330        .filter(|_| decoded.len != 0)
331        .ok_or(SpanError::RangeArithmetic)?;
332    if decoded.chunks.len() < 2 {
333        return Err(SpanError::ChunkCount);
334    }
335    let anchor = verify_disclosure(trusted, decoded.anchor).map_err(SpanError::AnchorInvalid)?;
336    let DisclosedPayload::Range {
337        blob_id,
338        chunk: Some((first, total_size, chunk_size)),
339        offset_in_blob: 0,
340        absolute_offset,
341        bytes: anchor_byte,
342    } = &anchor.payload
343    else {
344        return Err(SpanError::AnchorSelector);
345    };
346    if anchor_byte.len() != 1 {
347        return Err(SpanError::AnchorSelector);
348    }
349    let start = absolute_offset.ok_or(SpanError::AnchorOffset)?;
350    let context = CommitContext::from_disclosed(&anchor);
351    let mut summaries = Vec::new();
352    let mut output = Vec::new();
353    let mut cursor = start;
354    let mut range_overflow = false;
355    let mut last_start = start;
356    for bundle in decoded.chunks {
357        let inner = verify_disclosure_reusing_context(trusted, bundle, &context)
358            .map_err(SpanError::InnerInvalid)?;
359        let leaf_ok = inner.path == anchor.path && inner.leaf_id == anchor.leaf_id;
360        let context_ok = inner.chunk_inner_root == anchor.chunk_inner_root;
361        let mut summary = Summary {
362            selector_ok: false,
363            leaf_ok,
364            context_ok,
365            index: 0,
366            content_len: None,
367            first_byte: None,
368            canonical_id: [0; 32],
369        };
370        if let DisclosedPayload::Chunk {
371            total_size: inner_total,
372            chunk_size: inner_size,
373            index,
374            bytes: chunk_bytes,
375        } = inner.payload
376        {
377            summary.selector_ok = true;
378            summary.context_ok &= inner_total == *total_size && inner_size == *chunk_size;
379            summary.index = index;
380            summary.canonical_id = hash(&chunk_bytes);
381            if let Ok(Object::Blob(blob)) = crate::serialize::deserialize(&chunk_bytes)
382                && !blob.data.is_empty()
383            {
384                summary.content_len = u64::try_from(blob.data.len()).ok();
385                summary.first_byte = blob.data.first().copied();
386                if let Some(length) = summary.content_len {
387                    last_start = cursor;
388                    if let Some(next) = cursor.checked_add(length) {
389                        let from = decoded.offset.max(cursor);
390                        let to = end.min(next);
391                        if from < to {
392                            let a = usize::try_from(from - cursor)
393                                .map_err(|_| SpanError::RangeOutside)?;
394                            let b = usize::try_from(to - cursor)
395                                .map_err(|_| SpanError::RangeOutside)?;
396                            output.extend_from_slice(&blob.data[a..b]);
397                        }
398                        cursor = next;
399                    } else {
400                        range_overflow = true;
401                    }
402                }
403            }
404        }
405        summaries.push(summary);
406    }
407    check_summaries(&summaries, *first, blob_id, anchor_byte[0])?;
408    let first_end = start
409        .checked_add(summaries[0].content_len.ok_or(SpanError::ChunkBytes)?)
410        .ok_or(SpanError::RangeOutside)?;
411    if range_overflow
412        || cursor > *total_size
413        || decoded.offset < start
414        || decoded.offset >= first_end
415        || end > cursor
416    {
417        return Err(SpanError::RangeOutside);
418    }
419    if end <= last_start {
420        return Err(SpanError::LastUnneeded);
421    }
422    if u64::try_from(output.len()) != Ok(decoded.len) {
423        return Err(SpanError::RangeOutside);
424    }
425    let last = first
426        .checked_add(u32::try_from(summaries.len() - 1).map_err(|_| SpanError::ChunkOrder)?)
427        .ok_or(SpanError::ChunkOrder)?;
428    Ok(DisclosedSpan {
429        commit_id: *trusted,
430        tree_hash: anchor.tree_hash,
431        path: anchor.path,
432        leaf_id: anchor.leaf_id,
433        signer: anchor.signer,
434        signature_valid: anchor.signature_valid,
435        offset: decoded.offset,
436        bytes: output,
437        first: *first,
438        last,
439        span_start: start,
440        chunk_inner_root: anchor.chunk_inner_root.ok_or(SpanError::ChunkContext)?,
441    })
442}
443
444/// Encoded representation chosen for a requested range.
445#[derive(Debug, Clone, PartialEq, Eq)]
446#[non_exhaustive]
447pub enum RangeProof {
448    /// One MKDP v2 Range bundle.
449    Mkdp(Vec<u8>),
450    /// One MKDS v1 container.
451    Mkds(Vec<u8>),
452}
453
454/// Proof format, independent of encoded bytes.
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456#[non_exhaustive]
457pub enum RangeProofKind {
458    /// The range lies within one chunk (or a plain Blob): one MKDP v2 bundle.
459    Mkdp,
460    /// The range crosses a chunk boundary: one MKDS v1 container.
461    Mkds,
462}
463
464/// Pure prefetch plan for a chunked file. `needed_chunk_indices` contains
465/// every preceding chunk needed for absolute length proofs and each span
466/// chunk, in ascending order.
467#[derive(Debug, Clone, PartialEq, Eq)]
468#[non_exhaustive]
469pub struct Plan {
470    /// Proof format the range requires.
471    pub kind: RangeProofKind,
472    /// Index of the chunk containing the first requested byte.
473    pub first: usize,
474    /// Index of the chunk containing the last requested byte.
475    pub last: usize,
476    /// Every chunk index that must be read: `0..=last`.
477    pub needed_chunk_indices: Vec<usize>,
478}
479
480/// Typed builder and planner failures.
481#[derive(Debug, thiserror::Error)]
482#[non_exhaustive]
483pub enum RangeProofError {
484    /// The requested length is zero.
485    #[error("range length must be nonzero")]
486    ZeroLength,
487    /// `offset + len` (or a running chunk offset) overflows `u64`.
488    #[error("range endpoint overflow")]
489    OffsetOverflow,
490    /// The range extends past the end of the file.
491    #[error("range is outside the file")]
492    OutOfBounds,
493    /// Boundary hints are malformed, or were supplied for a plain Blob leaf
494    /// (which has no chunk boundaries).
495    #[error("boundary hints must start at zero, strictly increase, and end at total_size")]
496    InvalidBoundaries,
497    /// A boundary hint disagrees with the chunk's canonical Blob length.
498    #[error("boundary hint differs from chunk {index}'s canonical Blob length")]
499    HintMismatch {
500        /// Index of the first chunk whose hint is wrong.
501        index: usize,
502    },
503    /// A manifest chunk is not a canonical nonempty Blob.
504    #[error("chunk {index} is not a canonical nonempty Blob")]
505    InvalidChunk {
506        /// Index of the offending chunk.
507        index: usize,
508    },
509    /// The encoded proof would exceed the 64 MiB cap.
510    #[error("encoded proof exceeds 64 MiB")]
511    ProofTooLarge,
512    /// The object source failed.
513    #[error(transparent)]
514    Store(#[from] StoreError),
515    /// A disclosure-building step failed.
516    #[error(transparent)]
517    Verify(#[from] VerifyError),
518    /// A source object failed to decode.
519    #[error(transparent)]
520    Decode(#[from] MkitError),
521}
522
523/// Plan a range from canonical chunk content lengths. The end is exclusive;
524/// an end exactly on a chunk boundary does not need the next chunk.
525///
526/// # Errors
527///
528/// Zero length, overflow, empty/zero chunks, or an out-of-file range.
529pub fn plan_range_proof(
530    chunk_lengths: &[u64],
531    offset: u64,
532    len: u64,
533) -> Result<Plan, RangeProofError> {
534    if len == 0 {
535        return Err(RangeProofError::ZeroLength);
536    }
537    let end = offset
538        .checked_add(len)
539        .ok_or(RangeProofError::OffsetOverflow)?;
540    let mut cursor = 0u64;
541    let mut first = None;
542    let mut last = None;
543    for (i, &length) in chunk_lengths.iter().enumerate() {
544        if length == 0 {
545            return Err(RangeProofError::InvalidChunk { index: i });
546        }
547        let next = cursor
548            .checked_add(length)
549            .ok_or(RangeProofError::OffsetOverflow)?;
550        if first.is_none() && offset < next {
551            first = Some(i);
552        }
553        if first.is_some() && end <= next {
554            last = Some(i);
555            break;
556        }
557        cursor = next;
558    }
559    let first = first.ok_or(RangeProofError::OutOfBounds)?;
560    let last = last.ok_or(RangeProofError::OutOfBounds)?;
561    Ok(Plan {
562        kind: if first == last {
563            RangeProofKind::Mkdp
564        } else {
565            RangeProofKind::Mkds
566        },
567        first,
568        last,
569        needed_chunk_indices: (0..=last).collect(),
570    })
571}
572
573fn range_payload(
574    cb: &crate::object::ChunkedBlob,
575    index: usize,
576    bytes: &[u8],
577    offset_in_blob: u64,
578    len: u64,
579    proofs: Vec<LenProof>,
580) -> Result<PayloadWire, RangeProofError> {
581    let index = u32::try_from(index).map_err(|_| VerifyError::TooManyChunks)?;
582    let position = index.checked_add(1).ok_or(VerifyError::TooManyChunks)?;
583    let chunk_id = cb.chunks[index as usize];
584    let proof = merkle::build_chunks_multi_proof(cb, [0, position]).map_err(VerifyError::from)?;
585    let bao_offset = offset_in_blob
586        .checked_add(10)
587        .ok_or(RangeProofError::OffsetOverflow)?;
588    let slice = extract_bao_slice(bytes, bao_offset, len)?;
589    Ok(PayloadWire::Range {
590        chunk: Some(ChunkHdr {
591            total_size: cb.total_size,
592            chunk_size: cb.chunk_size,
593            index,
594            inner_root: merkle::chunked_inner_root(cb),
595            chunk_id,
596            proof,
597        }),
598        offset_in_blob,
599        len,
600        slice,
601        chunk_len_proofs: proofs,
602    })
603}
604
605fn chunk_payload(
606    cb: &crate::object::ChunkedBlob,
607    index: usize,
608    bytes: Vec<u8>,
609) -> Result<PayloadWire, RangeProofError> {
610    let index = u32::try_from(index).map_err(|_| VerifyError::TooManyChunks)?;
611    let position = index.checked_add(1).ok_or(VerifyError::TooManyChunks)?;
612    Ok(PayloadWire::Chunk {
613        total_size: cb.total_size,
614        chunk_size: cb.chunk_size,
615        index,
616        inner_root: merkle::chunked_inner_root(cb),
617        proof: merkle::build_chunks_multi_proof(cb, [0, position]).map_err(VerifyError::from)?,
618        bytes,
619    })
620}
621
622// Owning `bytes` makes the preceding chunk's lifetime end before the next
623// source read; only the small Bao slice and Merkle proof escape this call.
624fn preceding_proof(
625    cb: &crate::object::ChunkedBlob,
626    index: usize,
627    id: Hash,
628    bytes: Vec<u8>,
629) -> Result<LenProof, RangeProofError> {
630    #[cfg(test)]
631    let bytes = TrackedPrecedingBytes::new(bytes);
632    let index_u32 = u32::try_from(index).map_err(|_| VerifyError::TooManyChunks)?;
633    let position = index_u32.checked_add(1).ok_or(VerifyError::TooManyChunks)?;
634    #[cfg(test)]
635    let slice = extract_bao_slice(&bytes.0, 0, 10)?;
636    #[cfg(not(test))]
637    let slice = extract_bao_slice(&bytes, 0, 10)?;
638    drop(bytes);
639    Ok(LenProof {
640        index: index_u32,
641        chunk_id: id,
642        proof: merkle::build_chunk_proof(cb, position).map_err(VerifyError::from)?,
643        slice,
644    })
645}
646
647#[cfg(test)]
648std::thread_local! {
649    static PRECEDING_LIVE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
650    static PRECEDING_PEAK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
651}
652
653#[cfg(test)]
654struct TrackedPrecedingBytes(Vec<u8>);
655
656#[cfg(test)]
657impl TrackedPrecedingBytes {
658    fn new(bytes: Vec<u8>) -> Self {
659        PRECEDING_LIVE.with(|live| {
660            let count = live.get() + 1;
661            live.set(count);
662            PRECEDING_PEAK.with(|peak| peak.set(peak.get().max(count)));
663        });
664        Self(bytes)
665    }
666}
667
668#[cfg(test)]
669impl Drop for TrackedPrecedingBytes {
670    fn drop(&mut self) {
671        PRECEDING_LIVE.with(|live| live.set(live.get() - 1));
672    }
673}
674
675fn varint_size(mut n: usize) -> usize {
676    let mut size = 1;
677    while n >= 128 {
678        n >>= 7;
679        size += 1;
680    }
681    size
682}
683
684fn encoded_disclosure_size(
685    commit_bytes: &[u8],
686    steps: &[Step],
687    payload: &PayloadWire,
688) -> Result<usize, RangeProofError> {
689    let payload_size = match payload {
690        PayloadWire::Object { bytes } => bytes.as_slice().encode_size(),
691        PayloadWire::Chunk { proof, bytes, .. } => {
692            8 + 4 + 4 + 32 + proof.encode_size() + bytes.as_slice().encode_size()
693        }
694        PayloadWire::Range {
695            chunk,
696            slice,
697            chunk_len_proofs,
698            ..
699        } => {
700            chunk.encode_size()
701                + 8
702                + 8
703                + slice.as_slice().encode_size()
704                + chunk_len_proofs.encode_size()
705        }
706    };
707    4usize
708        .checked_add(1 + 32 + 1)
709        .and_then(|n| n.checked_add(commit_bytes.encode_size()))
710        .and_then(|n| n.checked_add(steps.encode_size()))
711        .and_then(|n| n.checked_add(payload_size))
712        .filter(|&n| n <= MAX_BUNDLE_BYTES)
713        .ok_or(RangeProofError::ProofTooLarge)
714}
715
716/// Build the smallest representation for a range, reading each preceding
717/// chunk once for its length proof and no chunk after the span. `boundaries`,
718/// when supplied, are the `chunks.len() + 1` prefix content offsets,
719/// beginning at zero and ending at the manifest's total size. Every hint
720/// for a chunk actually read is checked against its canonical Blob length.
721///
722/// # Errors
723///
724/// Returns [`RangeProofError`] for invalid ranges, hints, source objects,
725/// or an encoded result that exceeds the MKDP/MKDS 64 MiB cap.
726#[allow(clippy::too_many_lines)] // The loop must keep each preceding chunk scoped to one iteration.
727pub fn build_range_proof_from<S: ObjectSource + ?Sized>(
728    source: &S,
729    commit_id: &Hash,
730    path: &[&[u8]],
731    offset: u64,
732    len: u64,
733    boundaries: Option<&[u64]>,
734) -> Result<RangeProof, RangeProofError> {
735    if len == 0 {
736        return Err(RangeProofError::ZeroLength);
737    }
738    let end = offset
739        .checked_add(len)
740        .ok_or(RangeProofError::OffsetOverflow)?;
741    let (commit_bytes, steps, leaf_id) = super::build_prefix(source, commit_id, path)?;
742    let leaf = source.read_object(&leaf_id)?;
743    let cb = match leaf {
744        Object::Blob(_) => {
745            if boundaries.is_some() {
746                return Err(RangeProofError::InvalidBoundaries);
747            }
748            let payload = super::build_payload(
749                source,
750                &leaf_id,
751                Selector::Range {
752                    offset,
753                    len,
754                    with_offsets: true,
755                },
756            )?;
757            encoded_disclosure_size(&commit_bytes, &steps, &payload)?;
758            let proof = encode_disclosure(commit_id, &commit_bytes, &steps, &payload);
759            return Ok(RangeProof::Mkdp(proof));
760        }
761        Object::ChunkedBlob(cb) => cb,
762        _ => return Err(VerifyError::SelectorLeafMismatch.into()),
763    };
764    if end > cb.total_size {
765        return Err(RangeProofError::OutOfBounds);
766    }
767    let hinted_plan = if let Some(hints) = boundaries {
768        if hints.len() != cb.chunks.len() + 1
769            || hints.first() != Some(&0)
770            || hints.last() != Some(&cb.total_size)
771            || hints.windows(2).any(|pair| pair[0] >= pair[1])
772        {
773            return Err(RangeProofError::InvalidBoundaries);
774        }
775        let lengths: Vec<u64> = hints.windows(2).map(|pair| pair[1] - pair[0]).collect();
776        Some(plan_range_proof(&lengths, offset, len)?)
777    } else {
778        None
779    };
780    let mut preceding = Vec::new();
781    let mut preceding_size = 0usize;
782    let mut encoded_container_size = 0usize;
783    let mut cursor = 0u64;
784    let mut first = None;
785    let mut anchor = None;
786    let mut chunks = Vec::new();
787    for (i, id) in cb.chunks.iter().enumerate() {
788        if hinted_plan.as_ref().is_some_and(|plan| i > plan.last) {
789            break;
790        }
791        let (chunk_bytes, content_len) = checked_blob_source(source, id, i)?;
792        let next = cursor
793            .checked_add(content_len)
794            .ok_or(RangeProofError::OffsetOverflow)?;
795        if next > cb.total_size {
796            return Err(RangeProofError::OutOfBounds);
797        }
798        if let Some(hints) = boundaries
799            && (hints[i] != cursor || hints[i + 1] != next)
800        {
801            return Err(RangeProofError::HintMismatch { index: i });
802        }
803        if first.is_none() && offset >= next {
804            let proof = preceding_proof(&cb, i, *id, chunk_bytes)?;
805            preceding_size = preceding_size
806                .checked_add(proof.encode_size())
807                .filter(|&size| size <= MAX_BUNDLE_BYTES)
808                .ok_or(RangeProofError::ProofTooLarge)?;
809            preceding.push(proof);
810            cursor = next;
811            continue;
812        }
813        let first_index = *first.get_or_insert(i);
814        if i == first_index {
815            let local_offset = offset
816                .checked_sub(cursor)
817                .ok_or(RangeProofError::OffsetOverflow)?;
818            if end <= next {
819                let payload = range_payload(&cb, i, &chunk_bytes, local_offset, len, preceding)?;
820                encoded_disclosure_size(&commit_bytes, &steps, &payload)?;
821                let proof = encode_disclosure(commit_id, &commit_bytes, &steps, &payload);
822                return Ok(RangeProof::Mkdp(proof));
823            }
824            let payload =
825                range_payload(&cb, i, &chunk_bytes, 0, 1, std::mem::take(&mut preceding))?;
826            let anchor_size = encoded_disclosure_size(&commit_bytes, &steps, &payload)?;
827            encoded_container_size =
828                4 + 1 + 32 + 8 + 8 + varint_size(anchor_size) + anchor_size + 1;
829            if encoded_container_size > MAX_BUNDLE_BYTES {
830                return Err(RangeProofError::ProofTooLarge);
831            }
832            anchor = Some(encode_disclosure(
833                commit_id,
834                &commit_bytes,
835                &steps,
836                &payload,
837            ));
838        }
839        if chunk_bytes.len() > MAX_BUNDLE_BYTES {
840            return Err(RangeProofError::ProofTooLarge);
841        }
842        let payload = chunk_payload(&cb, i, chunk_bytes)?;
843        let bundle_size = encoded_disclosure_size(&commit_bytes, &steps, &payload)?;
844        let new_count = chunks.len() + 1;
845        encoded_container_size = encoded_container_size
846            .checked_add(varint_size(new_count) - varint_size(chunks.len()))
847            .and_then(|n| n.checked_add(varint_size(bundle_size)))
848            .and_then(|n| n.checked_add(bundle_size))
849            .filter(|&n| n <= MAX_BUNDLE_BYTES)
850            .ok_or(RangeProofError::ProofTooLarge)?;
851        let bundle = encode_disclosure(commit_id, &commit_bytes, &steps, &payload);
852        chunks.push(bundle);
853        cursor = next;
854        if end <= next {
855            break;
856        }
857    }
858    let anchor = anchor.ok_or(RangeProofError::OutOfBounds)?;
859    if cursor < end {
860        return Err(RangeProofError::OutOfBounds);
861    }
862    let refs: Vec<&[u8]> = chunks.iter().map(Vec::as_slice).collect();
863    let proof = encode_span(*commit_id, offset, len, &anchor, &refs);
864    if proof.len() > MAX_BUNDLE_BYTES {
865        return Err(RangeProofError::ProofTooLarge);
866    }
867    Ok(RangeProof::Mkds(proof))
868}
869
870fn checked_blob_source<S: ObjectSource + ?Sized>(
871    source: &S,
872    id: &Hash,
873    index: usize,
874) -> Result<(Vec<u8>, u64), RangeProofError> {
875    let bytes = source.read(id)?;
876    let Object::Blob(blob) = crate::serialize::deserialize(&bytes)? else {
877        return Err(RangeProofError::InvalidChunk { index });
878    };
879    let len = u64::try_from(blob.data.len()).map_err(|_| RangeProofError::OffsetOverflow)?;
880    if len == 0 {
881        return Err(RangeProofError::InvalidChunk { index });
882    }
883    Ok((bytes, len))
884}
885
886#[cfg(test)]
887#[allow(clippy::unwrap_used)]
888mod tests {
889    use super::*;
890    use crate::hash::ZERO;
891    use crate::layout::RepoLayout;
892    use crate::object::{Blob, ChunkedBlob, Commit, Identity, Tree, TreeEntry};
893    use crate::sign::{KeyPair, sign_commit};
894    use crate::store::{ObjectStore, StoreResult};
895    use std::cell::RefCell;
896
897    const VALID: &[u8] =
898        include_bytes!("../../../../tests/golden/http-objects/span_two_chunks.bin");
899    const FIRST_ZERO: &[u8] =
900        include_bytes!("../../../../tests/golden/http-objects/span_first_zero.bin");
901
902    fn valid() -> (Hash, Decoded<'static>) {
903        let decoded = decode(VALID).unwrap();
904        (decoded.commit, decoded)
905    }
906
907    fn verify(bytes: &[u8]) -> &'static str {
908        let (trusted, _) = valid();
909        verify_disclosure_span(&trusted, bytes)
910            .unwrap_err()
911            .reason()
912    }
913
914    #[test]
915    fn structural_checks_precede_crypto_and_follow_table_order() {
916        let (trusted, d) = valid();
917        let mut trailing_and_bad_commit = VALID.to_vec();
918        trailing_and_bad_commit[5] ^= 1;
919        trailing_and_bad_commit.push(0);
920        assert_eq!(verify(&trailing_and_bad_commit), "span_trailing_bytes");
921
922        let mut wrong_magic = VALID.to_vec();
923        wrong_magic[0] = b'X';
924        wrong_magic[4] = 2;
925        assert_eq!(verify(&wrong_magic), "span_magic");
926        assert_eq!(verify(&VALID[..4]), "span_encoding");
927        assert_eq!(verify(&[b'M', b'K', b'D', b'S', 2]), "span_version");
928
929        let mut wrong_commit = VALID.to_vec();
930        wrong_commit[5] ^= 1;
931        assert_eq!(verify(&wrong_commit), "span_commit");
932        assert_eq!(
933            verify(&encode_span(trusted, d.offset, 0, d.anchor, &d.chunks)),
934            "span_range_arithmetic"
935        );
936        assert_eq!(
937            verify(&encode_span(trusted, u64::MAX, 2, d.anchor, &d.chunks)),
938            "span_range_arithmetic"
939        );
940        assert_eq!(
941            verify(&encode_span(
942                trusted,
943                d.offset,
944                d.len,
945                d.anchor,
946                &d.chunks[..1]
947            )),
948            "span_chunk_count"
949        );
950        assert_eq!(
951            verify(&encode_span(trusted, d.offset, d.len, &[], &d.chunks)),
952            "span_anchor_invalid"
953        );
954    }
955
956    #[test]
957    fn every_spec_reason_has_its_own_stable_label() {
958        let reasons = [
959            SpanError::TooLarge,
960            SpanError::Magic,
961            SpanError::Version,
962            SpanError::Encoding,
963            SpanError::TrailingBytes,
964            SpanError::Commit,
965            SpanError::RangeArithmetic,
966            SpanError::ChunkCount,
967            SpanError::AnchorInvalid(VerifyError::BadMagic),
968            SpanError::AnchorSelector,
969            SpanError::AnchorOffset,
970            SpanError::InnerInvalid(VerifyError::BadMagic),
971            SpanError::ChunkSelector,
972            SpanError::LeafContext,
973            SpanError::ChunkContext,
974            SpanError::ChunkOrder,
975            SpanError::ChunkBytes,
976            SpanError::AnchorBinding,
977            SpanError::RangeOutside,
978            SpanError::LastUnneeded,
979        ];
980        let labels = [
981            "span_too_large",
982            "span_magic",
983            "span_version",
984            "span_encoding",
985            "span_trailing_bytes",
986            "span_commit",
987            "span_range_arithmetic",
988            "span_chunk_count",
989            "span_anchor_invalid",
990            "span_anchor_selector",
991            "span_anchor_offset",
992            "span_inner_invalid",
993            "span_chunk_selector",
994            "span_leaf_context",
995            "span_chunk_context",
996            "span_chunk_order",
997            "span_chunk_bytes",
998            "span_anchor_binding",
999            "span_range_outside",
1000            "span_last_unneeded",
1001        ];
1002        for (error, label) in reasons.into_iter().zip(labels) {
1003            assert_eq!(error.reason(), label);
1004        }
1005    }
1006
1007    #[test]
1008    fn cross_bundle_reasons_include_defence_in_depth() {
1009        let id = [7; 32];
1010        let good = || Summary {
1011            selector_ok: true,
1012            leaf_ok: true,
1013            context_ok: true,
1014            index: 4,
1015            content_len: Some(5),
1016            first_byte: Some(42),
1017            canonical_id: id,
1018        };
1019        let mut cases = vec![good(), good()];
1020        cases[1].index = 5;
1021        assert!(check_summaries(&cases, 4, &id, 42).is_ok());
1022
1023        cases[1].selector_ok = false;
1024        cases[0].leaf_ok = false;
1025        assert_eq!(
1026            check_summaries(&cases, 4, &id, 42).unwrap_err().reason(),
1027            "span_chunk_selector"
1028        );
1029        cases[1].selector_ok = true;
1030        assert_eq!(
1031            check_summaries(&cases, 4, &id, 42).unwrap_err().reason(),
1032            "span_leaf_context"
1033        );
1034        cases[0].leaf_ok = true;
1035        cases[0].context_ok = false;
1036        assert_eq!(
1037            check_summaries(&cases, 4, &id, 42).unwrap_err().reason(),
1038            "span_chunk_context"
1039        );
1040        cases[0].context_ok = true;
1041        cases[1].index = 6;
1042        assert_eq!(
1043            check_summaries(&cases, 4, &id, 42).unwrap_err().reason(),
1044            "span_chunk_order"
1045        );
1046        cases[1].index = 5;
1047        cases[1].content_len = None;
1048        assert_eq!(
1049            check_summaries(&cases, 4, &id, 42).unwrap_err().reason(),
1050            "span_chunk_bytes"
1051        );
1052        cases[1].content_len = Some(5);
1053        cases[0].canonical_id = [8; 32];
1054        assert_eq!(
1055            check_summaries(&cases, 4, &id, 42).unwrap_err().reason(),
1056            "span_anchor_binding"
1057        );
1058    }
1059
1060    #[test]
1061    fn verified_bundle_failures_follow_selector_and_offset_checks() {
1062        let (trusted, d) = valid();
1063        let wrong_anchor = encode_span(trusted, d.offset, d.len, d.chunks[0], &d.chunks);
1064        assert_eq!(verify(&wrong_anchor), "span_anchor_selector");
1065
1066        let (id, commit_bytes, steps, payload) = super::super::decode_disclosure(d.anchor).unwrap();
1067        let PayloadWire::Range {
1068            chunk,
1069            offset_in_blob,
1070            len,
1071            slice,
1072            ..
1073        } = payload
1074        else {
1075            panic!("anchor Range")
1076        };
1077        let no_offsets = encode_disclosure(
1078            &id,
1079            &commit_bytes,
1080            &steps,
1081            &PayloadWire::Range {
1082                chunk,
1083                offset_in_blob,
1084                len,
1085                slice,
1086                chunk_len_proofs: Vec::new(),
1087            },
1088        );
1089        assert!(verify_disclosure(&trusted, &no_offsets).is_ok());
1090        let missing_offset = encode_span(trusted, d.offset, d.len, &no_offsets, &d.chunks);
1091        assert_eq!(verify(&missing_offset), "span_anchor_offset");
1092
1093        let bad_inner = encode_span(trusted, d.offset, d.len, d.anchor, &[b"MKDS", d.chunks[1]]);
1094        assert_eq!(verify(&bad_inner), "span_inner_invalid");
1095        let wrong_selector =
1096            encode_span(trusted, d.offset, d.len, d.anchor, &[d.anchor, d.chunks[1]]);
1097        assert_eq!(verify(&wrong_selector), "span_chunk_selector");
1098
1099        let start = verify_disclosure_span(&trusted, VALID).unwrap().span_start;
1100        let outside = encode_span(trusted, start - 1, d.len, d.anchor, &d.chunks);
1101        assert_eq!(verify(&outside), "span_range_outside");
1102    }
1103
1104    #[test]
1105    fn strict_varints_and_vector_bounds() {
1106        let (_, d) = valid();
1107        let anchor_len_at = 4 + 1 + 32 + 8 + 8;
1108        let mut nonminimal = VALID[..anchor_len_at].to_vec();
1109        nonminimal.extend_from_slice(&[0x80, 0]);
1110        nonminimal.extend_from_slice(&VALID[anchor_len_at + 2..]);
1111        assert_eq!(verify(&nonminimal), "span_encoding");
1112
1113        let mut over_u32 = VALID[..anchor_len_at].to_vec();
1114        over_u32.extend_from_slice(&[0xff, 0xff, 0xff, 0xff, 0x10]);
1115        assert_eq!(verify(&over_u32), "span_encoding");
1116
1117        let mut length_over_remaining = VALID[..anchor_len_at].to_vec();
1118        length_over_remaining.extend_from_slice(&[0xff, 0xff, 0x03]);
1119        assert_eq!(verify(&length_over_remaining), "span_encoding");
1120
1121        let mut count_over_remaining = encode_span(d.commit, d.offset, d.len, d.anchor, &[]);
1122        *count_over_remaining.last_mut().unwrap() = 2;
1123        assert_eq!(verify(&count_over_remaining), "span_encoding");
1124    }
1125
1126    #[test]
1127    fn exact_boundaries_and_last_chunk_needed() {
1128        let d = decode(FIRST_ZERO).unwrap();
1129        let first = verify_disclosure(&d.commit, d.chunks[0]).unwrap();
1130        let DisclosedPayload::Chunk { bytes, .. } = first.payload else {
1131            panic!("chunk")
1132        };
1133        let Object::Blob(blob) = crate::serialize::deserialize(&bytes).unwrap() else {
1134            panic!("blob")
1135        };
1136        let edge = blob.data.len() as u64;
1137        let prior_only = encode_span(d.commit, edge - 1, 1, d.anchor, &d.chunks);
1138        assert_eq!(verify(&prior_only), "span_last_unneeded");
1139        let at_edge = encode_span(d.commit, edge - 1, 2, d.anchor, &d.chunks);
1140        assert_eq!(
1141            verify_disclosure_span(&d.commit, &at_edge)
1142                .unwrap()
1143                .bytes
1144                .len(),
1145            2
1146        );
1147        let last = verify_disclosure(&d.commit, d.chunks[1]).unwrap();
1148        let DisclosedPayload::Chunk { bytes, .. } = last.payload else {
1149            panic!("chunk")
1150        };
1151        let Object::Blob(blob) = crate::serialize::deserialize(&bytes).unwrap() else {
1152            panic!("blob")
1153        };
1154        let to_span_end = encode_span(
1155            d.commit,
1156            edge - 1,
1157            blob.data.len() as u64 + 1,
1158            d.anchor,
1159            &d.chunks,
1160        );
1161        assert_eq!(
1162            verify_disclosure_span(&d.commit, &to_span_end)
1163                .unwrap()
1164                .bytes
1165                .len() as u64,
1166            blob.data.len() as u64 + 1
1167        );
1168    }
1169
1170    #[test]
1171    fn pure_plan_respects_exact_edges() {
1172        let one = plan_range_proof(&[10, 20, 30], 5, 5).unwrap();
1173        assert_eq!(
1174            (one.kind, one.first, one.last),
1175            (RangeProofKind::Mkdp, 0, 0)
1176        );
1177        let two = plan_range_proof(&[10, 20, 30], 9, 2).unwrap();
1178        assert_eq!(
1179            (two.kind, two.first, two.last),
1180            (RangeProofKind::Mkds, 0, 1)
1181        );
1182        let later = plan_range_proof(&[10, 20, 30], 12, 18).unwrap();
1183        assert_eq!(
1184            (later.kind, later.first, later.last),
1185            (RangeProofKind::Mkdp, 1, 1)
1186        );
1187        assert_eq!(later.needed_chunk_indices, vec![0, 1]);
1188        assert!(matches!(
1189            plan_range_proof(&[10], 0, 0),
1190            Err(RangeProofError::ZeroLength)
1191        ));
1192    }
1193
1194    struct CountingSource<'a> {
1195        store: &'a ObjectStore,
1196        reads: RefCell<Vec<Hash>>,
1197    }
1198
1199    impl ObjectSource for CountingSource<'_> {
1200        fn read(&self, h: &Hash) -> StoreResult<Vec<u8>> {
1201            PRECEDING_LIVE.with(|live| assert_eq!(live.get(), 0));
1202            self.reads.borrow_mut().push(*h);
1203            self.store.read(h)
1204        }
1205    }
1206
1207    #[test]
1208    fn builder_reads_preceding_once_and_never_reads_after_span() {
1209        let temp = tempfile::TempDir::new().unwrap();
1210        let store = ObjectStore::init(&RepoLayout::single(temp.path())).unwrap();
1211        let mut chunk_ids = Vec::new();
1212        for i in 0u8..4 {
1213            let bytes = crate::serialize::serialize(&Object::Blob(Blob {
1214                data: vec![i + 1; 2048],
1215            }))
1216            .unwrap();
1217            chunk_ids.push(store.write(&bytes).unwrap());
1218        }
1219        let manifest = ChunkedBlob {
1220            total_size: 8192,
1221            chunk_size: 2048,
1222            chunks: chunk_ids.clone(),
1223        };
1224        let leaf_id = store
1225            .write(&crate::serialize::serialize(&Object::ChunkedBlob(manifest)).unwrap())
1226            .unwrap();
1227        let tree = Tree {
1228            entries: vec![TreeEntry {
1229                name: b"file".to_vec(),
1230                mode: EntryMode::Blob,
1231                object_hash: leaf_id,
1232            }],
1233        };
1234        let tree_hash = store
1235            .write(&crate::serialize::serialize(&Object::Tree(tree)).unwrap())
1236            .unwrap();
1237        let kp = KeyPair::from_seed([3; 32]);
1238        let mut commit = Commit {
1239            tree_hash,
1240            parents: vec![],
1241            author: Identity::ed25519(kp.public.0),
1242            signer: kp.public.0,
1243            message: b"span unit".to_vec(),
1244            timestamp: 1,
1245            message_hash: ZERO,
1246            content_digest: ZERO,
1247            signature: [0; 64],
1248        };
1249        commit.signature = sign_commit(&commit, &kp).unwrap().0;
1250        let commit_id = store
1251            .write(&crate::serialize::serialize(&Object::Commit(commit)).unwrap())
1252            .unwrap();
1253        let source = CountingSource {
1254            store: &store,
1255            reads: RefCell::new(Vec::new()),
1256        };
1257        PRECEDING_PEAK.with(|peak| peak.set(0));
1258        let RangeProof::Mkds(proof) =
1259            build_range_proof_from(&source, &commit_id, &[b"file"], 2047, 2, None).unwrap()
1260        else {
1261            panic!("expected MKDS")
1262        };
1263        assert_eq!(
1264            verify_disclosure_span(&commit_id, &proof).unwrap().bytes,
1265            [1, 2]
1266        );
1267        let reads = source.reads.borrow();
1268        assert_eq!(reads.iter().filter(|h| **h == chunk_ids[0]).count(), 1);
1269        assert_eq!(reads.iter().filter(|h| **h == chunk_ids[1]).count(), 1);
1270        assert!(!reads.contains(&chunk_ids[2]));
1271        assert!(!reads.contains(&chunk_ids[3]));
1272        drop(reads);
1273        PRECEDING_LIVE.with(|live| assert_eq!(live.get(), 0));
1274        // Reading through chunk 1 needs only chunk 0's length proof, so each
1275        // preceding chunk is live alone: the peak is exactly one, never more.
1276        PRECEDING_PEAK.with(|peak| peak.set(0));
1277        assert!(matches!(
1278            build_range_proof_from(&source, &commit_id, &[b"file"], 6200, 1, None).unwrap(),
1279            RangeProof::Mkdp(_)
1280        ));
1281        PRECEDING_PEAK.with(|peak| assert_eq!(peak.get(), 1));
1282        assert!(matches!(
1283            build_range_proof_from(
1284                &source,
1285                &commit_id,
1286                &[b"file"],
1287                2047,
1288                2,
1289                Some(&[0, 2000, 4048, 6096, 8192])
1290            ),
1291            Err(RangeProofError::HintMismatch { index: 0 })
1292        ));
1293    }
1294    #[test]
1295    fn inner_commit_bytes_must_hash_to_the_trusted_id() {
1296        let (trusted, d) = valid();
1297        let (id, commit_bytes, steps, payload) =
1298            super::super::decode_disclosure(d.chunks[1]).unwrap();
1299        let mut altered = commit_bytes.clone();
1300        altered.push(0);
1301        let forged = encode_disclosure(&id, &altered, &steps, &payload);
1302        let container = encode_span(trusted, d.offset, d.len, d.anchor, &[d.chunks[0], &forged]);
1303        let error = verify_disclosure_span(&trusted, &container).unwrap_err();
1304        assert_eq!(error.reason(), "span_inner_invalid");
1305        assert!(matches!(
1306            error,
1307            SpanError::InnerInvalid(VerifyError::CommitBytesHashMismatch)
1308        ));
1309    }
1310
1311    #[test]
1312    fn invalid_boundary_hints_are_typed() {
1313        let temp = tempfile::TempDir::new().unwrap();
1314        let store = ObjectStore::init(&RepoLayout::single(temp.path())).unwrap();
1315        let chunk = |b: u8| {
1316            store
1317                .write(
1318                    &crate::serialize::serialize(&Object::Blob(Blob { data: vec![b; 10] }))
1319                        .unwrap(),
1320                )
1321                .unwrap()
1322        };
1323        let cb = ChunkedBlob {
1324            total_size: 20,
1325            chunk_size: 10,
1326            chunks: vec![chunk(1), chunk(2)],
1327        };
1328        let leaf = store
1329            .write(&crate::serialize::serialize(&Object::ChunkedBlob(cb)).unwrap())
1330            .unwrap();
1331        let blob = store
1332            .write(&crate::serialize::serialize(&Object::Blob(Blob { data: vec![9; 10] })).unwrap())
1333            .unwrap();
1334        let tree = Tree {
1335            entries: vec![
1336                TreeEntry {
1337                    name: b"blob".to_vec(),
1338                    mode: EntryMode::Blob,
1339                    object_hash: blob,
1340                },
1341                TreeEntry {
1342                    name: b"chunked".to_vec(),
1343                    mode: EntryMode::Blob,
1344                    object_hash: leaf,
1345                },
1346            ],
1347        };
1348        let tree_hash = store
1349            .write(&crate::serialize::serialize(&Object::Tree(tree)).unwrap())
1350            .unwrap();
1351        let kp = KeyPair::from_seed([4; 32]);
1352        let mut commit = Commit {
1353            tree_hash,
1354            parents: vec![],
1355            author: Identity::ed25519(kp.public.0),
1356            signer: kp.public.0,
1357            message: b"hints".to_vec(),
1358            timestamp: 1,
1359            message_hash: ZERO,
1360            content_digest: ZERO,
1361            signature: [0; 64],
1362        };
1363        commit.signature = sign_commit(&commit, &kp).unwrap().0;
1364        let commit_id = store
1365            .write(&crate::serialize::serialize(&Object::Commit(commit)).unwrap())
1366            .unwrap();
1367        for bad in [
1368            &[0u64, 20][..],
1369            &[1, 10, 20],
1370            &[0, 10, 19],
1371            &[0, 10, 10],
1372            &[0, 20, 10],
1373        ] {
1374            assert!(
1375                matches!(
1376                    build_range_proof_from(&store, &commit_id, &[b"chunked"], 0, 1, Some(bad)),
1377                    Err(RangeProofError::InvalidBoundaries)
1378                ),
1379                "{bad:?}"
1380            );
1381        }
1382        // A plain Blob has no boundaries, so any hint is invalid.
1383        assert!(matches!(
1384            build_range_proof_from(&store, &commit_id, &[b"blob"], 0, 1, Some(&[0, 10])),
1385            Err(RangeProofError::InvalidBoundaries)
1386        ));
1387    }
1388    #[test]
1389    fn span_goldens_have_exact_metadata_planned_lengths() {
1390        use super::super::proof_size::{PrefixStep, chunked_range_proof_size};
1391        for bytes in [
1392            include_bytes!("../../../../tests/golden/http-objects/span_two_chunks.bin").as_slice(),
1393            include_bytes!("../../../../tests/golden/http-objects/span_three_chunks.bin")
1394                .as_slice(),
1395            include_bytes!("../../../../tests/golden/http-objects/span_first_zero.bin").as_slice(),
1396        ] {
1397            let span = decode(bytes).unwrap();
1398            let (_, commit, steps, payload) = super::super::decode_disclosure(span.anchor).unwrap();
1399            let PayloadWire::Range {
1400                chunk: Some(chunk),
1401                chunk_len_proofs,
1402                ..
1403            } = payload
1404            else {
1405                panic!("anchor")
1406            };
1407            let mut lengths: Vec<u64> = chunk_len_proofs
1408                .iter()
1409                .map(|proof| u64::from_le_bytes(proof.slice[..8].try_into().unwrap()) - 10)
1410                .collect();
1411            for bundle in span.chunks {
1412                let (_, _, _, PayloadWire::Chunk { bytes, .. }) =
1413                    super::super::decode_disclosure(bundle).unwrap()
1414                else {
1415                    panic!("chunk")
1416                };
1417                lengths.push(bytes.len() as u64 - 10);
1418            }
1419            let metadata: Vec<PrefixStep> = steps
1420                .iter()
1421                .map(|step| PrefixStep {
1422                    name_len: step.name.len(),
1423                    position: step.position,
1424                    leaf_count: step.proof.leaf_count,
1425                })
1426                .collect();
1427            let plan = chunked_range_proof_size(
1428                commit.len() as u64,
1429                &metadata,
1430                chunk.proof.leaf_count - 1,
1431                &lengths,
1432                span.offset,
1433                span.len,
1434            )
1435            .unwrap();
1436            assert_eq!(plan.kind, RangeProofKind::Mkds);
1437            assert_eq!(plan.encoded_size, bytes.len() as u64);
1438        }
1439    }
1440}