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}