Skip to main content

dig_download/
verify.rs

1//! Per-range + whole-resource integrity — L7 §9 "per-range integrity".
2//!
3//! A fetched range must be verifiable so a single peer cannot forge bytes and a multi-source mix
4//! always reassembles correctly. Two checks, at two moments:
5//!
6//! 1. **Per range, immediately** ([`Verifier::verify_range`]) — the returned bytes cover whole
7//!    chunk(s) whose lengths match the commitment's `chunk_lens`, and the range's declared generation
8//!    `root` matches the one being downloaded. This is the cheap check that catches a truncated /
9//!    mis-sized / wrong-generation source the moment its range arrives, so the orchestrator can
10//!    discard it and re-fetch from another provider.
11//! 2. **Whole resource, at completion** ([`Verifier::verify_resource`]) — once every range is
12//!    assembled, `resource_leaf = SHA-256(concatenated chunk ciphertexts)` (L7 §9 / the digstore
13//!    merkle-proofs read path) must be the leaf committed under the **chain-anchored generation
14//!    `root`**. Whichever mix of peers served the ranges, they all verify against the *same* on-chain
15//!    root — so mixing sources never weakens integrity.
16//!
17//! ## The commitment is established once, then trusted
18//!
19//! The first frame of the first successfully-fetched range carries `total_length` + `chunk_lens` +
20//! `root` (+ `inclusion_proof`). That establishes the [`ResourceCommitment`]; every subsequent range
21//! is checked against it (a peer that reports a *different* `chunk_lens` / `root` is serving a
22//! different generation and is rejected). The on-chain binding — that `resource_leaf` really is
23//! committed under `root` — is delegated to an injected [`ProofVerifier`] so this crate does not
24//! re-implement the digstore merkle-proof byte format; dig-node supplies the real one via
25//! [`MerkleVerifier::with_proof_verifier`] (see the implementers' note in the crate docs). There is
26//! no fail-open default constructor: the only structural-only path
27//! ([`MerkleVerifier::insecure_structural_only`]) is explicitly named and `#[doc(hidden)]`, so a
28//! production caller cannot accidentally build a verifier that skips the on-chain binding.
29
30use std::collections::BTreeMap;
31use std::sync::Arc;
32
33use sha2::{Digest, Sha256};
34
35use crate::error::VerifyError;
36use crate::plan::ChunkLayout;
37
38/// Streaming SHA-256 of a resource's ciphertext fed range-by-range in ARBITRARY order, so the
39/// whole-resource `resource_leaf` can be computed WITHOUT retaining every range and then
40/// concatenating a second full-length copy (the old path held ~2N bytes for an N-byte resource —
41/// MEDIUM #179).
42///
43/// Ranges are hashed strictly in ascending offset order. A range whose offset is not yet the
44/// next-needed contiguous offset is buffered in a small out-of-order window and drained the moment
45/// the gap before it fills; a range at (or before, as a verified idempotent re-feed of the exact
46/// same bytes) the next offset is hashed immediately. Because the orchestrator feeds only verified,
47/// chunk-aligned, non-overlapping ranges that tile the resource exactly, the buffer holds at most the
48/// ranges fetched out of order and is emptied as the contiguous frontier advances.
49#[derive(Debug, Default)]
50pub struct ResourceHasher {
51    hasher: Sha256,
52    /// The next contiguous byte offset still to be hashed.
53    next_offset: u64,
54    /// Ranges received ahead of `next_offset`, keyed by their offset, awaiting the gap to fill.
55    pending: BTreeMap<u64, Vec<u8>>,
56}
57
58impl ResourceHasher {
59    /// A fresh hasher positioned at offset 0.
60    pub fn new() -> Self {
61        ResourceHasher::default()
62    }
63
64    /// Feed one verified range's `bytes` at absolute `offset`. Hashes it (and any now-contiguous
65    /// buffered ranges) immediately if `offset` is the contiguous frontier, else buffers it. A range
66    /// strictly before the frontier (already hashed) is ignored — feeding is idempotent for a range
67    /// that was hashed and then re-delivered, which cannot happen for distinct ranges but keeps the
68    /// contract robust.
69    pub fn feed(&mut self, offset: u64, bytes: Vec<u8>) {
70        if offset < self.next_offset {
71            return; // already consumed
72        }
73        self.pending.insert(offset, bytes);
74        while let Some(chunk) = self.pending.remove(&self.next_offset) {
75            self.hasher.update(&chunk);
76            self.next_offset = self.next_offset.saturating_add(chunk.len() as u64);
77        }
78    }
79
80    /// The contiguous byte length hashed so far (the frontier offset).
81    pub fn hashed_len(&self) -> u64 {
82        self.next_offset
83    }
84
85    /// Whether any out-of-order ranges are still buffered (a gap remains before the frontier).
86    pub fn has_gap(&self) -> bool {
87        !self.pending.is_empty()
88    }
89
90    /// Finalize into the `resource_leaf` digest. Valid only once every range has been fed contiguously
91    /// (`has_gap()` is false); a caller checks `hashed_len() == total_length` for completeness.
92    pub fn finalize(self) -> [u8; 32] {
93        self.hasher.finalize().into()
94    }
95}
96
97/// The default ceiling on a peer-DECLARED resource `total_length` — 512 MiB, the resource-side
98/// counterpart of [`DEFAULT_MAX_MODULE_SIZE`](crate::module::DEFAULT_MAX_MODULE_SIZE).
99///
100/// # It is a HOST-MEMORY bound, not a statement about layout capability
101///
102/// Worth separating, because the two were once read as one number and the reading was wrong in a way
103/// that invited "fixing" this constant. This bounds how large a resource this host is willing to size a
104/// plan and a range buffer for. It says nothing about how large a resource the FRAMING can describe —
105/// that is a wire property, bounded by how many `chunk_lens` entries a stream can carry
106/// (dig-nat's `MAX_CHUNK_LENS_PER_FRAME`, paged to `MAX_RESOURCE_CHUNK_COUNT`), and it is owned by
107/// dig-nat rather than by this crate.
108///
109/// The two limits therefore move for different reasons and must not be reconciled by lowering this one.
110/// While the layout ceiling was one frame's worth of entries the framing was the tighter of the two, so
111/// a resource inside this bound could still be unreadable; the paged prologue lifts the layout ceiling
112/// far above 512 MiB, so the constraint that binds is once again this one — host memory — which is
113/// exactly what it was always for. Lowering it would ALSO be a real break: it is the `pub const`
114/// default of a public config field, so any deployment relying on it would silently start refusing
115/// resources it reads today.
116///
117/// The first frame's declared length sizes everything downstream of it (the plan, and the assembler's
118/// per-range buffer), and it arrives from a peer that has proven nothing yet, so it MUST be bounded
119/// before it is believed. Like the module bound, it is deliberately sized to what a modest host can
120/// actually hold rather than to the largest conceivable resource: a ceiling above real host memory
121/// bounds nothing. A deployment that genuinely reads larger resources raises
122/// [`DownloadConfig::max_resource_size`](crate::DownloadConfig::max_resource_size) explicitly, having
123/// sized the host for it.
124pub const DEFAULT_MAX_RESOURCE_SIZE: u64 = 512 * 1024 * 1024;
125
126/// The trusted per-resource metadata a download verifies every range against: the chunk boundaries,
127/// the total length, the chain-anchored generation `root`, and (for a resource, not a capsule) the
128/// whole-resource `inclusion_proof`.
129///
130/// Established from the first frame of the first fetched range (or an availability answer + the first
131/// frame). Immutable for the life of the download: a range whose first-frame metadata disagrees with
132/// this commitment is rejected as a different/forged generation.
133///
134/// # Adoption is NOT verification
135///
136/// Every gate available when a layout is adopted — the root match against the caller's content id, and the
137/// `chunk_lens`-sums-to-`total_length` consistency check — is satisfiable by a holder that lies consistently,
138/// because both compare fields the SAME untrusted holder supplied. Only [`Verifier::verify_resource_leaf`]
139/// binds the layout to the chain, and it cannot run until the whole resource has been fetched against that
140/// layout. So a commitment is a HYPOTHESIS about the resource's shape, and a holder positioned first in the
141/// provider order can therefore deny a read by declaring a short but self-consistent layout for the correct
142/// root (#1670, OPEN).
143///
144/// That denial is not fixable from here. Attributing the later refutation to the holder that supplied the
145/// layout requires distinguishing "the shape was wrong" from "the bytes were wrong", and nothing in the
146/// system can: per-range verification is length and alignment only, with no per-chunk hash. Three successive
147/// attempts to stand a vote over peer DECLARATIONS in for that missing evidence each produced a cheaper
148/// denial than the one they replaced, because those declarations are optional wire fields that cost an
149/// attacker a keypair to forge and that honest holders legitimately omit. #1670 is re-scoped onto per-chunk
150/// attribution, which is the only thing that can name a bad holder.
151///
152/// `#[non_exhaustive]`: the adoption path is expected to gain provenance once that evidence exists. Build one
153/// with [`from_first_frame`](Self::from_first_frame) or
154/// [`from_first_frame_bounded`](Self::from_first_frame_bounded).
155#[derive(Debug, Clone, PartialEq, Eq)]
156#[non_exhaustive]
157pub struct ResourceCommitment {
158    /// The chunk boundaries (`chunk_lens` → offsets).
159    pub layout: ChunkLayout,
160    /// The full resource ciphertext length.
161    pub total_length: u64,
162    /// The chain-anchored generation root (64-hex) every range verifies against. `None` only for a
163    /// self-verifying capsule fetch that carries no per-resource root.
164    pub root: Option<String>,
165    /// The whole-resource merkle inclusion proof (base64), relayed verbatim from the first frame;
166    /// `None` for a `capsule: true` fetch (the capsule self-verifies on install).
167    pub inclusion_proof: Option<String>,
168}
169
170impl ResourceCommitment {
171    /// Build a commitment from first-frame verification metadata, bounded by
172    /// [`DEFAULT_MAX_RESOURCE_SIZE`].
173    ///
174    /// Equivalent to [`from_first_frame_bounded`](Self::from_first_frame_bounded) with the default
175    /// ceiling — see it for what the bound defends against.
176    pub fn from_first_frame(
177        total_length: u64,
178        chunk_lens: Vec<u64>,
179        root: Option<String>,
180        inclusion_proof: Option<String>,
181    ) -> Result<Self, VerifyError> {
182        Self::from_first_frame_bounded(
183            total_length,
184            chunk_lens,
185            root,
186            inclusion_proof,
187            DEFAULT_MAX_RESOURCE_SIZE,
188        )
189    }
190
191    /// Build a commitment from first-frame verification metadata, refusing a declared
192    /// `total_length` above `max_resource_size`.
193    ///
194    /// Validates that `chunk_lens` sums to `total_length` with CHECKED arithmetic (a peer reporting
195    /// inconsistent metadata is rejected up front) — and, BEFORE that, that the declared length is
196    /// within the ceiling.
197    ///
198    /// The ceiling is load-bearing, not hygiene. `total_length` and the individual `chunk_lens` come
199    /// from the first frame of a peer that has not proven anything yet, and they SIZE the download: a
200    /// single chunk becomes at least one whole [`Range`](crate::plan::Range) regardless of the window,
201    /// and the range assembler then buffers up to that length. So an unbounded declared length is a
202    /// one-frame memory-exhaustion primitive: a peer answering the metadata probe with
203    /// `total_length: 2^40, chunk_lens: [2^40]` makes the client try to buffer a terabyte. Bounding it
204    /// here — before any layout or plan exists — is what keeps that a rejection instead of an
205    /// allocation. (The range assembler's own reservation is additionally FALLIBLE, so even a
206    /// within-ceiling length that this host cannot hold is a recoverable error rather than an
207    /// uncatchable abort.)
208    pub fn from_first_frame_bounded(
209        total_length: u64,
210        chunk_lens: Vec<u64>,
211        root: Option<String>,
212        inclusion_proof: Option<String>,
213        max_resource_size: u64,
214    ) -> Result<Self, VerifyError> {
215        if total_length > max_resource_size {
216            return Err(VerifyError::Metadata(format!(
217                "declared total_length {total_length} exceeds the maximum {max_resource_size}"
218            )));
219        }
220        // The lengths came off the wire, so the layout is built with the CHECKED, bounded, fallible
221        // constructor: a saturating sum would let `[1, u64::MAX]` match a declared `u64::MAX` total and
222        // pass the consistency check below as if it were a real resource (#1608).
223        let layout = ChunkLayout::try_new(chunk_lens)?;
224        if layout.total_length() != total_length {
225            return Err(VerifyError::Metadata(format!(
226                "chunk_lens sum {} != total_length {}",
227                layout.total_length(),
228                total_length
229            )));
230        }
231        Ok(ResourceCommitment {
232            layout,
233            total_length,
234            root,
235            inclusion_proof,
236        })
237    }
238
239    /// Check that a range's declared first-frame metadata is consistent with this commitment (same
240    /// `chunk_lens`, `total_length`, and `root`). Used when a later range's first frame arrives to
241    /// reject a source serving a different generation.
242    pub fn check_consistent(
243        &self,
244        total_length: Option<u64>,
245        chunk_lens: Option<&[u64]>,
246        root: Option<&str>,
247    ) -> Result<(), VerifyError> {
248        if let Some(tl) = total_length {
249            if tl != self.total_length {
250                return Err(VerifyError::Metadata(format!(
251                    "total_length {tl} != committed {}",
252                    self.total_length
253                )));
254            }
255        }
256        if let Some(cl) = chunk_lens {
257            if cl != self.layout.chunk_lens() {
258                return Err(VerifyError::Metadata("chunk_lens differ".into()));
259            }
260        }
261        if let (Some(r), Some(committed)) = (root, self.root.as_deref()) {
262            if r != committed {
263                return Err(VerifyError::Metadata(format!(
264                    "root {r} != committed {committed}"
265                )));
266            }
267        }
268        Ok(())
269    }
270}
271
272/// Verifies a reassembled resource's `resource_leaf` is committed under the chain-anchored `root` —
273/// the digstore merkle inclusion check.
274///
275/// This is a **seam**: the digstore merkle-proof byte format lives with the store types, so dig-node
276/// injects the real verifier and this crate ships only the explicitly-opt-in
277/// [`StructuralOnlyProofVerifier`] (which does NOT bind to the chain). See the implementers' note in
278/// the crate docs.
279pub trait ProofVerifier: Send + Sync {
280    /// Return `true` iff `resource_leaf` (SHA-256 of the whole resource ciphertext) is the leaf
281    /// committed under `root` per `inclusion_proof`. For a capsule fetch (`inclusion_proof` / `root`
282    /// = `None`) an implementation returns `true` (the capsule self-verifies on install).
283    fn verify_inclusion(
284        &self,
285        resource_leaf: &[u8; 32],
286        inclusion_proof: Option<&str>,
287        root: Option<&str>,
288    ) -> bool;
289}
290
291/// A **structural-only, fail-OPEN** [`ProofVerifier`] that accepts any `resource_leaf` without
292/// parsing the digstore merkle proof — so a [`MerkleVerifier`] using it enforces length + chunk
293/// alignment + metadata consistency + resource self-consistency, but does **NOT** bind the resource
294/// to the on-chain root.
295///
296/// This provides **no chain-anchored integrity** and MUST NOT be used in production: a
297/// [`Downloader`](crate::Downloader) built with it will accept right-length-but-forged content that a
298/// real proof verifier would reject. It exists only to unit-test the structural checks and to let a
299/// caller opt in EXPLICITLY via [`MerkleVerifier::insecure_structural_only`]. dig-node injects a real
300/// digstore proof verifier via [`MerkleVerifier::with_proof_verifier`] to bind to the chain.
301#[doc(hidden)]
302#[derive(Debug, Clone, Copy, Default)]
303pub struct StructuralOnlyProofVerifier;
304
305impl ProofVerifier for StructuralOnlyProofVerifier {
306    fn verify_inclusion(
307        &self,
308        _resource_leaf: &[u8; 32],
309        _inclusion_proof: Option<&str>,
310        _root: Option<&str>,
311    ) -> bool {
312        true
313    }
314}
315
316/// Per-range + whole-resource integrity verification. The orchestrator holds one and calls
317/// [`verify_range`](Self::verify_range) as each range arrives and
318/// [`verify_resource`](Self::verify_resource) once the resource is fully assembled.
319pub trait Verifier: Send + Sync {
320    /// Fast per-range check: `bytes` (the reassembled range starting at chunk `first_chunk_index`)
321    /// is EXACTLY `expected_len` bytes AND covers whole chunk(s) whose lengths match the commitment.
322    ///
323    /// The `expected_len` check is load-bearing for integrity: a peer can serve fewer whole chunks
324    /// than requested (a boundary-aligned SHORT range) whose bytes still start and end on chunk
325    /// boundaries — structurally aligned yet incomplete. Requiring `bytes.len() == expected_len`
326    /// (the planned [`Range::length`](crate::plan::Range::length)) rejects that short range as
327    /// [`VerifyError::Length`], so the orchestrator re-fetches it from another provider rather than
328    /// silently writing a hole. Returns [`VerifyError::Length`] for a mis-sized range and
329    /// [`VerifyError::Alignment`] for an unaligned one.
330    fn verify_range(
331        &self,
332        commitment: &ResourceCommitment,
333        first_chunk_index: u64,
334        expected_len: u64,
335        bytes: &[u8],
336    ) -> Result<(), VerifyError>;
337
338    /// Whole-resource check once every range is assembled: `full` has the committed `total_length`
339    /// and its `resource_leaf` verifies under the chain-anchored `root`.
340    fn verify_resource(
341        &self,
342        commitment: &ResourceCommitment,
343        full: &[u8],
344    ) -> Result<(), VerifyError>;
345
346    /// Whole-resource check from a PRE-COMPUTED `resource_leaf` + the contiguously-hashed
347    /// `assembled_len`, so the orchestrator can hash ranges incrementally
348    /// ([`ResourceHasher`]) and avoid retaining the whole resource + a concatenated copy in RAM
349    /// (~2N bytes — MEDIUM #179). `assembled_len` must equal the committed `total_length` (else the
350    /// resource is incomplete → [`VerifyError::Length`]); `leaf` is then bound to the chain-anchored
351    /// `root`. The default implementation mirrors [`verify_resource`](Self::verify_resource) minus the
352    /// hashing.
353    fn verify_resource_leaf(
354        &self,
355        commitment: &ResourceCommitment,
356        leaf: &[u8; 32],
357        assembled_len: u64,
358    ) -> Result<(), VerifyError>;
359}
360
361/// The real [`Verifier`]: chunk-length + alignment per range, `resource_leaf = SHA-256(concat)` bound
362/// to the chain-anchored `root` (via a [`ProofVerifier`]) for the whole resource — exactly L7 §9.
363pub struct MerkleVerifier {
364    proof: Arc<dyn ProofVerifier>,
365}
366
367impl MerkleVerifier {
368    /// A verifier that binds `resource_leaf` to the chain-anchored `root` with `proof` — the
369    /// production constructor. dig-node supplies the real digstore proof verifier here so the
370    /// whole-resource check is chain-anchored.
371    ///
372    /// There is deliberately **no** `new()` / `Default` fail-open constructor: a chain-bound
373    /// [`ProofVerifier`] must be supplied explicitly, so a consumer cannot *accidentally* get a
374    /// verifier that skips the on-chain binding. The only structural-only path is the explicitly
375    /// named, `#[doc(hidden)]` [`insecure_structural_only`](Self::insecure_structural_only).
376    pub fn with_proof_verifier(proof: Arc<dyn ProofVerifier>) -> Self {
377        MerkleVerifier { proof }
378    }
379
380    /// A **structural-only, fail-OPEN** verifier (length + alignment + metadata consistency only,
381    /// NO chain binding) — see [`StructuralOnlyProofVerifier`].
382    ///
383    /// This gives no chain-anchored integrity and is for tests / explicit opt-in ONLY; production
384    /// callers MUST use [`with_proof_verifier`](Self::with_proof_verifier) with a real digstore proof
385    /// verifier. The name and `#[doc(hidden)]` are intentional: getting the insecure path requires
386    /// asking for it by name.
387    #[doc(hidden)]
388    pub fn insecure_structural_only() -> Self {
389        MerkleVerifier {
390            proof: Arc::new(StructuralOnlyProofVerifier),
391        }
392    }
393
394    /// The committed `resource_leaf` of `full`: the SHA-256 of the whole resource ciphertext (L7 §9;
395    /// UNTAGGED, matching the digstore merkle-proofs read path `resource_leaf(ciphertext)`).
396    pub fn resource_leaf(full: &[u8]) -> [u8; 32] {
397        let digest = Sha256::digest(full);
398        digest.into()
399    }
400}
401
402impl Verifier for MerkleVerifier {
403    fn verify_range(
404        &self,
405        commitment: &ResourceCommitment,
406        first_chunk_index: u64,
407        expected_len: u64,
408        bytes: &[u8],
409    ) -> Result<(), VerifyError> {
410        // Length first, fail-closed: a boundary-aligned SHORT range (fewer whole chunks than
411        // planned) still passes the alignment check below, so the ONLY thing that catches it is
412        // this exact-length comparison against the planned range length.
413        if bytes.len() as u64 != expected_len {
414            return Err(VerifyError::Length {
415                expected: expected_len,
416                actual: bytes.len() as u64,
417            });
418        }
419        // EXPLICIT conversion, not `as usize`: on a 32-bit target a truncating cast maps an absurd
420        // chunk index onto a VALID one, turning a rejection into a check against the wrong chunk. A
421        // library cannot delegate this to a profile flag (#1608).
422        let start = usize::try_from(first_chunk_index).map_err(|_| {
423            VerifyError::Alignment(format!(
424                "chunk_index {first_chunk_index} does not fit this platform's address space"
425            ))
426        })?;
427        let layout = &commitment.layout;
428        if start >= layout.chunk_count() {
429            return Err(VerifyError::Alignment(format!(
430                "chunk_index {start} out of range (chunk_count {})",
431                layout.chunk_count()
432            )));
433        }
434        let offset = layout
435            .chunk_offset(start)
436            .ok_or_else(|| VerifyError::Alignment("chunk_index has no offset".into()))?;
437        // The bytes must cover whole chunk(s): find the chunk boundary at offset+len.
438        let (cs, ce) = layout.chunks_for_range(offset, bytes.len() as u64)?;
439        debug_assert_eq!(cs, start);
440        let _ = ce;
441        Ok(())
442    }
443
444    fn verify_resource(
445        &self,
446        commitment: &ResourceCommitment,
447        full: &[u8],
448    ) -> Result<(), VerifyError> {
449        let leaf = MerkleVerifier::resource_leaf(full);
450        self.verify_resource_leaf(commitment, &leaf, full.len() as u64)
451    }
452
453    fn verify_resource_leaf(
454        &self,
455        commitment: &ResourceCommitment,
456        leaf: &[u8; 32],
457        assembled_len: u64,
458    ) -> Result<(), VerifyError> {
459        if assembled_len != commitment.total_length {
460            return Err(VerifyError::Length {
461                expected: commitment.total_length,
462                actual: assembled_len,
463            });
464        }
465        if !self.proof.verify_inclusion(
466            leaf,
467            commitment.inclusion_proof.as_deref(),
468            commitment.root.as_deref(),
469        ) {
470            return Err(VerifyError::Root);
471        }
472        Ok(())
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn commitment(chunk_lens: Vec<u64>) -> ResourceCommitment {
481        let total = chunk_lens.iter().sum();
482        ResourceCommitment::from_first_frame(total, chunk_lens, Some("aa".repeat(32)), None)
483            .unwrap()
484    }
485
486    #[test]
487    fn from_first_frame_rejects_inconsistent_total() {
488        let err = ResourceCommitment::from_first_frame(999, vec![10, 20], None, None);
489        assert!(matches!(err, Err(VerifyError::Metadata(_))));
490    }
491
492    #[test]
493    fn resource_hasher_matches_concat_hash_regardless_of_feed_order() {
494        // The incremental hasher must produce EXACTLY the SHA-256 of the concatenated ranges, no
495        // matter what order the ranges are fed in (MEDIUM #179 — replaces retain-all + concat).
496        let full: Vec<u8> = (0..90u16).map(|i| i as u8).collect();
497        let expect = MerkleVerifier::resource_leaf(&full);
498
499        // Feed the three 30-byte ranges out of order: 60, 0, 30.
500        let mut h = ResourceHasher::new();
501        h.feed(60, full[60..90].to_vec());
502        assert!(
503            h.has_gap(),
504            "range at 60 is ahead of the frontier → buffered"
505        );
506        assert_eq!(h.hashed_len(), 0);
507        h.feed(0, full[0..30].to_vec());
508        assert_eq!(h.hashed_len(), 30);
509        assert!(h.has_gap(), "range at 60 still buffered, 30..60 missing");
510        h.feed(30, full[30..60].to_vec());
511        assert!(!h.has_gap(), "the gap filled → everything drained");
512        assert_eq!(h.hashed_len(), 90);
513        assert_eq!(h.finalize(), expect);
514
515        // In-order feed yields the same digest.
516        let mut h2 = ResourceHasher::new();
517        for off in [0u64, 30, 60] {
518            h2.feed(off, full[off as usize..off as usize + 30].to_vec());
519        }
520        assert_eq!(h2.hashed_len(), 90);
521        assert_eq!(h2.finalize(), expect);
522    }
523
524    #[test]
525    fn resource_hasher_ignores_a_range_before_the_frontier() {
526        let mut h = ResourceHasher::new();
527        h.feed(0, vec![1u8; 10]);
528        assert_eq!(h.hashed_len(), 10);
529        // A stale re-feed strictly before the frontier is ignored (does not double-hash).
530        h.feed(0, vec![1u8; 10]);
531        assert_eq!(h.hashed_len(), 10);
532        h.feed(10, vec![2u8; 10]);
533        assert_eq!(h.hashed_len(), 20);
534        let mut concat = vec![1u8; 10];
535        concat.extend_from_slice(&[2u8; 10]);
536        assert_eq!(h.finalize(), MerkleVerifier::resource_leaf(&concat));
537    }
538
539    #[test]
540    fn verify_resource_leaf_length_and_root_binding() {
541        let c = commitment(vec![10, 20]);
542        // Precomputed leaf of the correct 30-byte resource.
543        let correct = vec![3u8; 30];
544        let leaf = MerkleVerifier::resource_leaf(&correct);
545        // Short assembled length → Length error before any root binding.
546        let v = MerkleVerifier::insecure_structural_only();
547        assert!(matches!(
548            v.verify_resource_leaf(&c, &leaf, 20),
549            Err(VerifyError::Length { .. })
550        ));
551        // Correct length passes the structural-only verifier.
552        assert!(v.verify_resource_leaf(&c, &leaf, 30).is_ok());
553
554        // A real proof verifier binds the leaf to the root.
555        struct OnlyLeaf([u8; 32]);
556        impl ProofVerifier for OnlyLeaf {
557            fn verify_inclusion(&self, l: &[u8; 32], _p: Option<&str>, _r: Option<&str>) -> bool {
558                l == &self.0
559            }
560        }
561        let v2 = MerkleVerifier::with_proof_verifier(Arc::new(OnlyLeaf(leaf)));
562        assert!(v2.verify_resource_leaf(&c, &leaf, 30).is_ok());
563        assert!(matches!(
564            v2.verify_resource_leaf(&c, &[0u8; 32], 30),
565            Err(VerifyError::Root)
566        ));
567    }
568
569    #[test]
570    fn verify_range_accepts_whole_chunks() {
571        let c = commitment(vec![10, 20, 5]);
572        let v = MerkleVerifier::insecure_structural_only();
573        // chunk 0 alone (10 bytes)
574        assert!(v.verify_range(&c, 0, 10, &[0u8; 10]).is_ok());
575        // chunks 1..3 (25 bytes) starting at chunk 1
576        assert!(v.verify_range(&c, 1, 25, &[0u8; 25]).is_ok());
577    }
578
579    #[test]
580    fn verify_range_rejects_wrong_length() {
581        let c = commitment(vec![10, 20, 5]);
582        let v = MerkleVerifier::insecure_structural_only();
583        // chunk 0 should be 10 bytes; 9 bytes → length mismatch (also not a chunk boundary).
584        assert!(matches!(
585            v.verify_range(&c, 0, 10, &[0u8; 9]),
586            Err(VerifyError::Length {
587                expected: 10,
588                actual: 9
589            })
590        ));
591    }
592
593    #[test]
594    fn verify_range_rejects_boundary_aligned_short_range() {
595        // CRITICAL #179: a range planned over chunks 0..2 (30 bytes) but served only the first whole
596        // chunk (10 bytes). Those 10 bytes ARE chunk-aligned, so alignment alone would pass — the
597        // exact-length check is what rejects the short range.
598        let c = commitment(vec![10, 20, 5]);
599        let v = MerkleVerifier::insecure_structural_only();
600        assert!(matches!(
601            v.verify_range(&c, 0, 30, &[0u8; 10]),
602            Err(VerifyError::Length {
603                expected: 30,
604                actual: 10
605            })
606        ));
607    }
608
609    /// The #836 class, one layer up: an over-long chunk-granular answer is a holder's LEGITIMATE
610    /// granularity (§2.2), not a protocol violation — asserting otherwise is what defended the read-leg
611    /// defect through six investigations.
612    ///
613    /// [`Verifier::verify_range`] receives bytes that [`assemble_range_stream`] has already CLIPPED to
614    /// the requested window, so its exact-length check is a check on the ASSEMBLED range, not a verdict
615    /// on what the holder streamed. This asserts the property that actually matters, at the layer that
616    /// owns the freedom: a 30-byte answer to a 10-byte window, once clipped, VERIFIES. `RangeTransport`
617    /// is a public trait and dig-node injects its own verifier, so any feeder that clips first must find
618    /// this path open.
619    #[tokio::test]
620    async fn an_over_long_chunk_granular_answer_verifies_once_clipped() {
621        let c = commitment(vec![10, 20, 5]);
622        let v = MerkleVerifier::insecure_structural_only();
623
624        // A chunk-granular holder answers a 10-byte window with a whole 30-byte span.
625        let over_long = dig_nat::RangeFrame::data(0, vec![0u8; 30])
626            .with_complete(true)
627            .with_identity("aa".repeat(32), 35, 3)
628            .with_chunk_lens_page(0, vec![10, 20, 5])
629            .with_chunk_index(0);
630        let wire = over_long
631            .encode()
632            .expect("a 30-byte fixture frame is far inside the framing ceilings");
633        let mut stream = std::io::Cursor::new(wire);
634        let (clipped, _meta) = crate::source::assemble_range_stream(&mut stream, 10)
635            .await
636            .expect("an over-long frame is clipped, never rejected");
637
638        assert_eq!(clipped.len(), 10, "clipped to the requested window");
639        assert!(
640            v.verify_range(&c, 0, 10, &clipped).is_ok(),
641            "a clipped chunk-granular answer verifies — the holder's granularity is not a violation"
642        );
643    }
644
645    #[test]
646    fn verify_range_rejects_out_of_range_chunk_index() {
647        let c = commitment(vec![10]);
648        let v = MerkleVerifier::insecure_structural_only();
649        assert!(matches!(
650            v.verify_range(&c, 5, 10, &[0u8; 10]),
651            Err(VerifyError::Alignment(_))
652        ));
653    }
654
655    #[test]
656    fn verify_resource_length_mismatch() {
657        let c = commitment(vec![10, 20]);
658        let v = MerkleVerifier::insecure_structural_only();
659        assert!(matches!(
660            v.verify_resource(&c, &[0u8; 5]),
661            Err(VerifyError::Length { .. })
662        ));
663    }
664
665    #[test]
666    fn insecure_structural_only_is_fail_open_on_the_root() {
667        // The explicitly-named structural-only verifier does NOT bind to the chain: right-length but
668        // arbitrary bytes pass verify_resource (this is why the constructor is named "insecure" and
669        // #[doc(hidden)] — production callers must use with_proof_verifier). There is deliberately no
670        // MerkleVerifier::new() / Default that could yield this posture by accident (#179 HIGH).
671        let c = commitment(vec![10, 20]);
672        let v = MerkleVerifier::insecure_structural_only();
673        assert!(v.verify_resource(&c, &[0u8; 30]).is_ok());
674        assert!(v.verify_resource(&c, &[0xFFu8; 30]).is_ok());
675    }
676
677    #[test]
678    fn verify_resource_binds_to_root_with_real_proof_verifier() {
679        // A proof verifier that only accepts the leaf of a specific "correct" resource.
680        struct OnlyLeaf([u8; 32]);
681        impl ProofVerifier for OnlyLeaf {
682            fn verify_inclusion(
683                &self,
684                resource_leaf: &[u8; 32],
685                _p: Option<&str>,
686                _r: Option<&str>,
687            ) -> bool {
688                resource_leaf == &self.0
689            }
690        }
691        let correct = vec![7u8; 30];
692        let leaf = MerkleVerifier::resource_leaf(&correct);
693        let v = MerkleVerifier::with_proof_verifier(Arc::new(OnlyLeaf(leaf)));
694        let c = commitment(vec![10, 20]);
695        // Correct bytes verify.
696        assert!(v.verify_resource(&c, &correct).is_ok());
697        // Corrupt-but-right-length bytes fail the root binding.
698        assert!(matches!(
699            v.verify_resource(&c, &[8u8; 30]),
700            Err(VerifyError::Root)
701        ));
702    }
703
704    #[test]
705    fn commitment_consistency_check() {
706        let c = commitment(vec![10, 20, 5]);
707        assert!(c
708            .check_consistent(Some(35), Some(&[10, 20, 5]), Some(&"aa".repeat(32)))
709            .is_ok());
710        assert!(matches!(
711            c.check_consistent(Some(99), None, None),
712            Err(VerifyError::Metadata(_))
713        ));
714        assert!(matches!(
715            c.check_consistent(None, Some(&[1, 2]), None),
716            Err(VerifyError::Metadata(_))
717        ));
718        assert!(matches!(
719            c.check_consistent(None, None, Some(&"bb".repeat(32))),
720            Err(VerifyError::Metadata(_))
721        ));
722    }
723
724    #[test]
725    fn resource_leaf_is_sha256_untagged() {
726        let leaf = MerkleVerifier::resource_leaf(b"hello");
727        let expect: [u8; 32] = Sha256::digest(b"hello").into();
728        assert_eq!(leaf, expect);
729    }
730
731    /// #1608 — a library's own `[profile.release] overflow-checks` protects NOTHING (only the ROOT
732    /// package's profile applies), so a validator that leans on wrapping/saturating arithmetic for
733    /// hostile-input safety is silently unsound in a consumer build. Here the hostile descriptor is
734    /// `{ total_length: u64::MAX, chunk_lens: [1, u64::MAX] }`: a SATURATING sum lands on exactly
735    /// u64::MAX, equals the declared total, and the commitment is ACCEPTED — the plan then covers
736    /// spans no resource can have. The arithmetic must be CHECKED and the overflow a typed rejection.
737    #[test]
738    fn a_saturating_chunk_len_sum_cannot_pass_as_a_consistent_commitment() {
739        // Bounded with a ceiling ABOVE the declared total, so the size bound cannot be what rejects it
740        // and the CHECKED arithmetic is the thing under test.
741        let err = ResourceCommitment::from_first_frame_bounded(
742            u64::MAX,
743            vec![1, u64::MAX],
744            None,
745            None,
746            u64::MAX,
747        )
748        .expect_err("an overflowing chunk layout is refused");
749        assert!(
750            err.to_string().contains("overflow"),
751            "names the arithmetic it broke: {err}"
752        );
753        // And under the DEFAULT ceiling the same descriptor is refused on size, before any arithmetic.
754        let err = ResourceCommitment::from_first_frame(u64::MAX, vec![1, u64::MAX], None, None)
755            .expect_err("an absurd declared total_length is refused");
756        assert!(
757            err.to_string().contains("exceeds the maximum"),
758            "names the bound it broke: {err}"
759        );
760    }
761
762    /// The declared chunk COUNT sizes the layout's own vectors, so it is bounded before allocation —
763    /// the same one-message allocation attack as an absurd declared length.
764    #[test]
765    fn an_absurd_resource_chunk_count_is_refused() {
766        let err = ResourceCommitment::from_first_frame(
767            0,
768            vec![0; crate::plan::MAX_RESOURCE_CHUNK_COUNT + 1],
769            None,
770            None,
771        )
772        .expect_err("an over-cap chunk count is refused");
773        assert!(
774            err.to_string().contains("exceeds the maximum"),
775            "names the bound it broke: {err}"
776        );
777    }
778}