Skip to main content

mkit_core/
merkle.rs

1//! Merkle (BMT) content-addressing for `ChunkedBlob` and `Tree`.
2//!
3//! A merkelized object's content-address **is** its Binary Merkle Tree
4//! root: the object id is `wrap_id(kind, bmt_root(leaves))` where the BMT
5//! is built over the object's child stream. This makes inclusion of any
6//! chunk/entry provable, and makes a reconstructed object's read-time id
7//! check a free completeness proof for its whole child set.
8//!
9//! ## Primitive — a vendored BMT, byte-identical to `commonware_storage::bmt`
10//!
11//! The house idiom (makechain `transactions_root.rs`) uses
12//! `commonware_storage::bmt`. We do **not** depend on it for object identity:
13//! `merkle.rs` lives in `mkit-core` and `Object::id` calls it, so this module
14//! must compile to `wasm32` for `mkit-core` *itself* to — independent of any
15//! wasm caller. Instead this module vendors the *identical* BMT construction
16//! over the `blake3` crate (already a mkit dependency, wasm-clean). A
17//! native-only test (`tests::vendored_root_matches_commonware`) cross-verifies
18//! the vendored root byte-for-byte against `commonware_storage::bmt`, and
19//! `tests::proofs_match_commonware` extends that guarantee to inclusion
20//! proofs (bytes and cross-verifier acceptance) — so the two never drift.
21//!
22//! RESOLVED (commonware#4089 / commonwarexyz/monorepo#4090, shipped in
23//! `commonware =2026.7.0`): `bmt` itself is now genuinely `no_std` — that
24//! part of the original TODO held. It is *not* enough to drop the vendored
25//! copy, though: `commonware_storage::bmt::Builder<H: Hasher>` is generic
26//! over `commonware_cryptography::Hasher`, so reaching a concrete hasher
27//! (e.g. `Blake3`) means depending on the `commonware-cryptography` crate —
28//! and that crate's `blst` dependency (BLS12-381) is **not** feature-gated;
29//! it compiles unconditionally for every consumer, wasm or not. Confirmed
30//! empirically: `blst`'s C sources fail to build for
31//! `wasm32-unknown-unknown` wherever the local `clang` has no WASM LLVM
32//! backend registered (e.g. stock Xcode clang on macOS — `clang
33//! --print-targets` lists no `wasm32` entry). Even on a toolchain where it
34//! *does* build, making `commonware-storage` (and therefore
35//! `commonware-cryptography`/`blst`) a mandatory dependency of `mkit-core`'s
36//! object-identity path would impose that C library's build/binary-size
37//! cost on every consumer, not just wasm callers — the same reason
38//! `mkit-attest` keeps `blst` behind an opt-in `bls-threshold` feature
39//! instead of pulling it in by default. So the vendored construction stays;
40//! the cross-check test is what keeps it honest against upstream.
41//!
42//! The construction (matching commonware):
43//! * leaf at index `i` is hashed with its position: `H(i_be32 ‖ leaf)`;
44//! * each level pairs nodes `H(left ‖ right)`, duplicating the last node
45//!   `H(left ‖ left)` when a level has an odd count;
46//! * an empty tree is a single node `H("")`;
47//! * the finalized root is `H(leaf_count_be32 ‖ tree_root)`, which binds
48//!   the leaf count and defeats the odd-node-duplication malleability.
49//!
50//! Normative crypto: `docs/specs/SPEC-MERKLE-OBJECTS.md`.
51//!
52//! ## Identity formulas
53//!
54//! ```text
55//! id            = wrap_id(kind, bmt_root(leaves))
56//!               = domain_digest(TYPE_DOMAIN, bmt_root(leaves))
57//!
58//! ChunkedBlob   leaves = [meta_leaf] ++ chunks
59//!   meta_leaf   = domain_digest("mkit-cblob-meta-v1", total_size_le ‖ chunk_size_le)
60//!   chunk i     -> BMT position i+1   (meta is position 0)
61//!
62//! Tree          leaves = entries (existing lex order)
63//!   entry leaf  = domain_digest("mkit-tree-entry-v1", name_len_le ‖ name ‖ mode ‖ object_hash)
64//! ```
65//!
66//! The outer `wrap_id` wrap makes the id type-distinct: a bare BMT root
67//! over identical leaf streams would collide across types (the prologue
68//! type byte is not in the root), so an empty `Tree` and an empty
69//! `ChunkedBlob`, or a 1-entry `Tree` and a 1-chunk `ChunkedBlob` with the
70//! same child hash, would otherwise share an id.
71//!
72//! ## Inclusion proofs — stable, commonware-aligned
73//!
74//! Object identity (`compute_tree_id` / `compute_chunked_id`) and the
75//! inclusion-proof construction below are both stable
76//! (`docs/specs/SPEC-MERKLE-OBJECTS.md` §5). [`Proof`]'s bytes and sibling
77//! selection are byte-identical to `commonware_storage::bmt::Proof` at the
78//! pinned `2026.9.0` train: a commonware-based verifier can decode a
79//! mkit-produced proof with the upstream type and run its own
80//! `verify_element_inclusion` / `verify_range_inclusion` /
81//! `verify_multi_inclusion` against the **inner root** (see
82//! [`chunked_inner_root`] / [`tree_inner_root`]), then apply [`wrap_id`] to
83//! compare against the object id. `tests::proofs_match_commonware` pins
84//! this claim; `rust/tests/golden/proofs/` carries fixed vectors.
85//!
86//! Verification in this crate is always stated against the **object id**
87//! (`verify_tree_entry`, `verify_chunk`, and their range/multi
88//! counterparts), never the bare inner root — passing a caller the inner
89//! root invited skipping the type-domain wrap and accepting, say, a
90//! `Tree` proof against a `ChunkedBlob` id (issue #1015 §Security). The
91//! inner-root-comparing primitives (`Proof::verify_element_inclusion` and
92//! friends) stay `pub(crate)`, kept only so the cross-check test can
93//! compare directly against upstream's verifiers.
94
95use std::collections::BTreeSet;
96
97use bytes::{Buf, BufMut};
98use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadExt, ReadRangeExt, Write};
99
100use crate::hash::{HASH_LEN, Hash, Hasher, domain_digest, hash};
101use crate::object::{ChunkedBlob, Tree, TreeEntry};
102
103/// Type domain for the outer identity wrap of a `ChunkedBlob`.
104const CHUNKED_TYPE_DOMAIN: &[u8] = b"mkit.chunked\x00";
105/// Type domain for the outer identity wrap of a `Tree`.
106const TREE_TYPE_DOMAIN: &[u8] = b"mkit.tree\x00";
107/// Leaf domain binding a `ChunkedBlob`'s `total_size`/`chunk_size`.
108const CBLOB_META_DOMAIN: &[u8] = b"mkit-cblob-meta-v1";
109/// Leaf domain for a `Tree` entry's `(name, mode, object_hash)` triple.
110const TREE_ENTRY_DOMAIN: &[u8] = b"mkit-tree-entry-v1";
111
112/// Upper bound on sibling levels in a [`Proof`]: `u32::BITS`, matching
113/// `commonware_storage::bmt::MAX_LEVELS`. Because [`Proof::leaf_count`] is a
114/// `u32`, a tree can have at most `u32::MAX` leaves, which requires at most
115/// `u32::BITS` sibling hashes per proven item.
116pub const MAX_LEVELS: usize = u32::BITS as usize;
117
118/// The two object kinds addressed by a BMT root (`crate::merkle`), keying
119/// [`wrap_id`]'s type-domain wrap. A versioned, supported accessor for the
120/// outer identity wrap — the alternative (exposing the raw `TYPE_DOMAIN`
121/// byte strings) would make them de facto ABI.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum ObjectKind {
124    /// A [`Tree`]'s entry list.
125    Tree,
126    /// A [`ChunkedBlob`]'s `[meta, chunks…]` list.
127    ChunkedBlob,
128}
129
130impl ObjectKind {
131    fn type_domain(self) -> &'static [u8] {
132        match self {
133            Self::Tree => TREE_TYPE_DOMAIN,
134            Self::ChunkedBlob => CHUNKED_TYPE_DOMAIN,
135        }
136    }
137}
138
139/// Apply the outer type-domain wrap to a bare BMT inner root, producing the
140/// object id. The inverse does not exist — `wrap_id` is a one-way hash —
141/// so a verifier holding `(inner_root, proof)` reconstructs the inner root
142/// from the proof and wraps it with the *expected* kind to compare against
143/// a claimed id, rather than trying to "unwrap" the id.
144#[must_use]
145pub fn wrap_id(kind: ObjectKind, inner_root: &Hash) -> Hash {
146    domain_digest(kind.type_domain(), inner_root)
147}
148
149/// Errors building or verifying a merkle inclusion proof.
150#[derive(Debug, thiserror::Error, PartialEq, Eq)]
151pub enum MerkleError {
152    /// The requested leaf position is outside the object's leaf range.
153    #[error("merkle position {0} is out of range")]
154    PositionOutOfRange(u32),
155    /// The same position was requested more than once in a multi-proof.
156    #[error("merkle position {0} is duplicated")]
157    DuplicatePosition(u32),
158    /// No positions were given to prove/verify.
159    #[error("no merkle positions given")]
160    NoPositions,
161    /// A range's `start` is greater than its `end`.
162    #[error("merkle range start {start} is greater than end {end}")]
163    InvalidRange {
164        /// The range's start position.
165        start: u32,
166        /// The range's end position.
167        end: u32,
168    },
169    /// The proof bytes were malformed (bad codec payload or trailing bytes).
170    #[error("merkle proof is malformed")]
171    MalformedProof,
172    /// The proof's sibling count does not match what the position(s)
173    /// require — too few, too many, or misordered.
174    #[error("merkle proof is unaligned with the requested position(s)")]
175    UnalignedProof,
176    /// The proof did not verify against the given root/id.
177    #[error("merkle proof verification failed")]
178    VerificationFailed,
179}
180
181impl From<CodecError> for MerkleError {
182    fn from(_: CodecError) -> Self {
183        Self::MalformedProof
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Vendored BMT primitive (over `blake3`)
189// ---------------------------------------------------------------------------
190
191/// Leaf/index count as `u32`. Objects are decode-capped at 1M
192/// entries/chunks (see `serialize.rs`), far below `u32::MAX`, so this
193/// only panics on a programmer error that bypassed those caps.
194fn u32_of(n: usize) -> u32 {
195    u32::try_from(n).expect("merkle leaf/index count fits u32 (objects capped at 1M)")
196}
197
198/// `BLAKE3(a ‖ b)`.
199fn h2(a: &[u8], b: &[u8]) -> Hash {
200    let mut h = Hasher::new();
201    h.update(a).update(b);
202    h.finalize()
203}
204
205/// Position-hash a leaf: `H(index_be32 ‖ leaf)` (matches commonware
206/// `Builder::add`).
207fn position_leaf(index: u32, leaf: &Hash) -> Hash {
208    h2(&index.to_be_bytes(), leaf)
209}
210
211/// Returns the number of levels in a tree with `leaf_count` leaves
212/// (level 0 = leaves, last level = the pre-finalize root). A tree with 1
213/// leaf has 1 level, a tree with 2 leaves has 2 levels, etc. Ported from
214/// `commonware_storage::bmt::levels_in_tree`.
215fn levels_in_tree(leaf_count: u32) -> usize {
216    (u32::BITS - leaf_count.saturating_sub(1).leading_zeros() + 1) as usize
217}
218
219/// A built BMT: every level from the position-hashed leaves up to the
220/// single pre-finalize root node, plus the finalized root. Kept around so
221/// one build serves many proofs cheaply — mirrors commonware's
222/// `Builder`/`Tree` split.
223struct BmtTree {
224    /// The real leaf count (0 for an empty tree, even though `levels[0]`
225    /// then holds a single placeholder node).
226    leaf_count: u32,
227    /// `true` when built from zero leaves — no position is ever provable.
228    empty: bool,
229    /// `levels[0]` = position-hashed leaves (or the single `H("")`
230    /// placeholder); `levels[levels.len() - 1]` = the single pre-finalize
231    /// tree root node.
232    levels: Vec<Vec<Hash>>,
233    /// The finalized root: `H(leaf_count_be32 ‖ levels.last()[0])`.
234    root: Hash,
235}
236
237fn build_bmt(leaves: &[Hash]) -> BmtTree {
238    let leaf_count = u32_of(leaves.len());
239    let empty = leaves.is_empty();
240    let position_hashed: Vec<Hash> = if empty {
241        vec![hash(b"")]
242    } else {
243        leaves
244            .iter()
245            .enumerate()
246            .map(|(i, l)| position_leaf(u32_of(i), l))
247            .collect()
248    };
249    let mut levels = vec![position_hashed];
250    while levels.last().expect("levels is never empty").len() > 1 {
251        let cur = levels.last().expect("levels is never empty");
252        let mut next = Vec::with_capacity(cur.len().div_ceil(2));
253        for pair in cur.chunks(2) {
254            let right = if pair.len() == 2 { &pair[1] } else { &pair[0] };
255            next.push(h2(&pair[0], right));
256        }
257        levels.push(next);
258    }
259    let tree_root = levels.last().expect("levels is never empty")[0];
260    let root = h2(&leaf_count.to_be_bytes(), &tree_root);
261    BmtTree {
262        leaf_count,
263        empty,
264        levels,
265        root,
266    }
267}
268
269/// Returns the sorted, deduplicated `(level, index)` positions of siblings
270/// required to prove inclusion of leaves at the given `positions`. A
271/// sibling is omitted when it would be the node's own duplicate (an odd
272/// trailing node) or is already covered by another proven position —
273/// verifiers reconstruct `H(computed, computed)` / skip re-deriving it
274/// without consuming a proof entry. Ported from
275/// `commonware_storage::bmt::siblings_required_for_multi_proof`.
276fn siblings_required_for_multi_proof(
277    leaf_count: u32,
278    positions: impl IntoIterator<Item = u32>,
279) -> Result<BTreeSet<(usize, usize)>, MerkleError> {
280    let mut current = BTreeSet::new();
281    for pos in positions {
282        if pos >= leaf_count {
283            return Err(MerkleError::PositionOutOfRange(pos));
284        }
285        if !current.insert(pos as usize) {
286            return Err(MerkleError::DuplicatePosition(pos));
287        }
288    }
289    if current.is_empty() {
290        return Err(MerkleError::NoPositions);
291    }
292
293    let mut sibling_positions = BTreeSet::new();
294    let levels_count = levels_in_tree(leaf_count);
295    let mut level_size = leaf_count as usize;
296    for level in 0..levels_count.saturating_sub(1) {
297        for &index in &current {
298            let sibling_index = if index.is_multiple_of(2) {
299                if index + 1 < level_size {
300                    index + 1
301                } else {
302                    index
303                }
304            } else {
305                index - 1
306            };
307            if sibling_index != index && !current.contains(&sibling_index) {
308                sibling_positions.insert((level, sibling_index));
309            }
310        }
311        current = current.iter().map(|idx| idx / 2).collect();
312        level_size = level_size.div_ceil(2);
313    }
314    Ok(sibling_positions)
315}
316
317/// Returns the sorted, deduplicated `(level, index)` positions of siblings
318/// required to prove inclusion of a contiguous range of leaves from
319/// `start` to `end` (inclusive). Ported from
320/// `commonware_storage::bmt::siblings_required_for_range_proof`.
321fn siblings_required_for_range_proof(
322    leaf_count: u32,
323    start: u32,
324    end: u32,
325) -> Result<BTreeSet<(usize, usize)>, MerkleError> {
326    if leaf_count == 0 {
327        return Err(MerkleError::NoPositions);
328    }
329    if start > end {
330        return Err(MerkleError::InvalidRange { start, end });
331    }
332    if start >= leaf_count {
333        return Err(MerkleError::PositionOutOfRange(start));
334    }
335    if end >= leaf_count {
336        return Err(MerkleError::PositionOutOfRange(end));
337    }
338
339    let mut sibling_positions = BTreeSet::new();
340    let levels_count = levels_in_tree(leaf_count);
341    let mut level_start = start as usize;
342    let mut level_end = end as usize;
343    let mut level_size = leaf_count as usize;
344    for level in 0..levels_count.saturating_sub(1) {
345        if !level_start.is_multiple_of(2) {
346            sibling_positions.insert((level, level_start - 1));
347        }
348        if level_end.is_multiple_of(2) {
349            let right = level_end + 1;
350            if right < level_size {
351                sibling_positions.insert((level, right));
352            }
353        }
354        level_start /= 2;
355        level_end /= 2;
356        level_size = level_size.div_ceil(2);
357    }
358    Ok(sibling_positions)
359}
360
361impl BmtTree {
362    /// Generates a proof for the leaf at `position`. A single-element
363    /// multi-proof.
364    fn proof(&self, position: u32) -> Result<Proof, MerkleError> {
365        self.multi_proof(core::iter::once(position))
366    }
367
368    /// Generates a range proof for the contiguous leaves `start..=end`.
369    fn range_proof(&self, start: u32, end: u32) -> Result<Proof, MerkleError> {
370        // Intentional divergence from `commonware_storage::bmt`, whose
371        // `range_proof(0, 0)` on an empty tree returns `Proof::default()`:
372        // SPEC-MERKLE-OBJECTS §5.4 requires a builder to refuse every
373        // position of the empty `Tree`, including the range `0..=0`.
374        // `tests::proofs_match_commonware` only draws `n >= 1`, so the
375        // cross-check never compares this case.
376        if self.empty {
377            return Err(MerkleError::PositionOutOfRange(start));
378        }
379        if start > end {
380            return Err(MerkleError::InvalidRange { start, end });
381        }
382        let leaf_count = self.leaf_count;
383        if start >= leaf_count {
384            return Err(MerkleError::PositionOutOfRange(start));
385        }
386        if end >= leaf_count {
387            return Err(MerkleError::PositionOutOfRange(end));
388        }
389        let sibling_positions = siblings_required_for_range_proof(leaf_count, start, end)?;
390        let siblings = sibling_positions
391            .iter()
392            .map(|&(level, index)| self.levels[level][index])
393            .collect();
394        Ok(Proof {
395            leaf_count,
396            siblings,
397        })
398    }
399
400    /// Generates a proof for the non-contiguous leaves at `positions`.
401    /// Positions may be given in any order; duplicates are rejected.
402    fn multi_proof(&self, positions: impl IntoIterator<Item = u32>) -> Result<Proof, MerkleError> {
403        let mut positions = positions.into_iter().peekable();
404        let first = *positions.peek().ok_or(MerkleError::NoPositions)?;
405        if self.empty {
406            return Err(MerkleError::PositionOutOfRange(first));
407        }
408        let leaf_count = self.leaf_count;
409        let sibling_positions = siblings_required_for_multi_proof(leaf_count, positions)?;
410        let siblings = sibling_positions
411            .iter()
412            .map(|&(level, index)| self.levels[level][index])
413            .collect();
414        Ok(Proof {
415            leaf_count,
416            siblings,
417        })
418    }
419}
420
421// ---------------------------------------------------------------------------
422// Proof: wire format + verification
423// ---------------------------------------------------------------------------
424
425/// A BMT inclusion proof for one or more leaves: the tree's leaf count
426/// plus the deduplicated sibling digests needed to reconstruct the root,
427/// ordered level-major (bottom-up) then index-ascending.
428///
429/// Bytes are `u32 BE leaf_count ‖ LEB128-varint(n) ‖ n × 32-byte digest` —
430/// byte-identical to `commonware_storage::bmt::Proof<D>` at the pinned
431/// `2026.9.0` train (`tests::proofs_match_commonware` pins this; see also
432/// `rust/tests/golden/proofs/`). A commonware-based verifier can decode
433/// these bytes with the upstream type directly.
434#[derive(Debug, Clone, Default, PartialEq, Eq)]
435pub struct Proof {
436    /// The number of leaves in the tree. Incorporated into the finalized
437    /// root, so a mismatched `leaf_count` fails verification rather than
438    /// being silently accepted (malleability guard).
439    pub leaf_count: u32,
440    /// The deduplicated sibling digests, level-major bottom-up then
441    /// index-ascending, with self-duplicate and already-proven siblings
442    /// omitted (see `docs/specs/SPEC-MERKLE-OBJECTS.md` §5.3).
443    pub siblings: Vec<Hash>,
444}
445
446impl Write for Proof {
447    fn write(&self, writer: &mut impl BufMut) {
448        self.leaf_count.write(writer);
449        self.siblings.write(writer);
450    }
451}
452
453impl EncodeSize for Proof {
454    fn encode_size(&self) -> usize {
455        self.leaf_count.encode_size() + self.siblings.encode_size()
456    }
457}
458
459impl Read for Proof {
460    /// The maximum number of items being proven. The upper bound on
461    /// sibling hashes is derived as `max_items * MAX_LEVELS`, bounding
462    /// allocation before any hashing happens.
463    type Cfg = usize;
464
465    fn read_cfg(reader: &mut impl Buf, max_items: &Self::Cfg) -> Result<Self, CodecError> {
466        let leaf_count = u32::read(reader)?;
467        let max_siblings = max_items.saturating_mul(MAX_LEVELS);
468        let siblings = Vec::<Hash>::read_range(reader, ..=max_siblings)?;
469        Ok(Self {
470            leaf_count,
471            siblings,
472        })
473    }
474}
475
476impl Proof {
477    /// Encode to the commonware-identical wire bytes.
478    #[must_use]
479    pub fn encode(&self) -> Vec<u8> {
480        let mut out = Vec::with_capacity(self.encode_size());
481        self.write(&mut out);
482        out
483    }
484
485    /// Decode from the commonware-identical wire bytes, bounding the
486    /// sibling count to `max_items * MAX_LEVELS`. `max_items` should be the
487    /// number of positions the caller is about to verify (1 for a
488    /// single-leaf proof, the range length for a range proof, the position
489    /// count for a multi-proof). Rejects trailing bytes.
490    ///
491    /// # Errors
492    ///
493    /// [`MerkleError::MalformedProof`] on a truncated buffer, an
494    /// over-`max_items` sibling count, or trailing bytes.
495    pub fn decode(bytes: &[u8], max_items: usize) -> Result<Self, MerkleError> {
496        let mut buf = bytes;
497        let proof = Self::read_cfg(&mut buf, &max_items)?;
498        if buf.has_remaining() {
499            return Err(MerkleError::MalformedProof);
500        }
501        Ok(proof)
502    }
503
504    /// Reconstructs the tree's inner (pre-wrap) root implied by this proof
505    /// for `leaf` at `position`, without comparing it to anything. The
506    /// shared building block for both the inner-root-comparing
507    /// (commonware-parity) verifiers below and the public id-based
508    /// verifiers. `pub(crate)` so `verify` can compare the fold to a
509    /// bundle-declared inner root without a second pass.
510    pub(crate) fn reconstruct_element_root(
511        &self,
512        leaf: &Hash,
513        mut position: u32,
514    ) -> Result<Hash, MerkleError> {
515        if position >= self.leaf_count {
516            return Err(MerkleError::PositionOutOfRange(position));
517        }
518        let mut computed = position_leaf(position, leaf);
519        let mut level_size = self.leaf_count as usize;
520        let mut sibling_iter = self.siblings.iter();
521
522        while level_size > 1 {
523            let is_last_odd = position.is_multiple_of(2) && position as usize + 1 >= level_size;
524            let (left, right) = if is_last_odd {
525                (computed, computed)
526            } else if position.is_multiple_of(2) {
527                let sib = *sibling_iter.next().ok_or(MerkleError::UnalignedProof)?;
528                (computed, sib)
529            } else {
530                let sib = *sibling_iter.next().ok_or(MerkleError::UnalignedProof)?;
531                (sib, computed)
532            };
533            computed = h2(&left, &right);
534            position /= 2;
535            level_size = level_size.div_ceil(2);
536        }
537
538        if sibling_iter.next().is_some() {
539            return Err(MerkleError::UnalignedProof);
540        }
541        Ok(h2(&self.leaf_count.to_be_bytes(), &computed))
542    }
543
544    /// Reconstructs the tree's inner (pre-wrap) root implied by this proof
545    /// for the non-contiguous `elements` (leaf, position pairs). Elements
546    /// may be given in any order; duplicate positions are rejected.
547    pub(crate) fn reconstruct_multi_root(
548        &self,
549        elements: &[(Hash, u32)],
550    ) -> Result<Hash, MerkleError> {
551        // A proof over zero positions is rejected unconditionally, even
552        // when `leaf_count == 0` and `siblings` is empty (upstream's
553        // `Default` proof, which would otherwise trivially "reconstruct"
554        // the empty tree's root). Accepting it here would let a caller
555        // verify zero proven entries/chunks against any id whose inner
556        // root happens to equal the empty tree's — most notably the
557        // real `TREE_EMPTY_ID` — without ever having proven anything.
558        // Builders already refuse to construct such a proof
559        // (`BmtTree::multi_proof`'s `NoPositions` on an empty position
560        // iterator); this is the matching verification-side rule (see
561        // `tests::verify_rejects_empty_element_set` and
562        // SPEC-MERKLE-OBJECTS §5.4).
563        if elements.is_empty() {
564            return Err(MerkleError::NoPositions);
565        }
566        for (_, position) in elements {
567            if *position >= self.leaf_count {
568                return Err(MerkleError::PositionOutOfRange(*position));
569            }
570        }
571
572        let mut sorted: Vec<(u32, Hash)> = elements
573            .iter()
574            .map(|(leaf, pos)| (*pos, position_leaf(*pos, leaf)))
575            .collect();
576        sorted.sort_unstable_by_key(|(pos, _)| *pos);
577        for i in 1..sorted.len() {
578            if sorted[i - 1].0 == sorted[i].0 {
579                return Err(MerkleError::DuplicatePosition(sorted[i].0));
580            }
581        }
582
583        let levels = levels_in_tree(self.leaf_count);
584        let mut level_size = self.leaf_count;
585        let mut sibling_iter = self.siblings.iter();
586        let mut current = sorted;
587
588        for _ in 0..levels.saturating_sub(1) {
589            let mut next_level: Vec<(u32, Hash)> = Vec::with_capacity(current.len().div_ceil(2));
590            let mut idx = 0;
591            while idx < current.len() {
592                let (pos, digest) = current[idx];
593                let parent_pos = pos / 2;
594                let (left, right) = if pos.is_multiple_of(2) {
595                    let left = digest;
596                    let right = if idx + 1 < current.len() && current[idx + 1].0 == pos + 1 {
597                        idx += 1;
598                        current[idx].1
599                    } else if pos + 1 >= level_size {
600                        left
601                    } else {
602                        *sibling_iter.next().ok_or(MerkleError::UnalignedProof)?
603                    };
604                    (left, right)
605                } else {
606                    // The left child was missing from `current`, so it must
607                    // be a sibling.
608                    let right = digest;
609                    let left = *sibling_iter.next().ok_or(MerkleError::UnalignedProof)?;
610                    (left, right)
611                };
612                next_level.push((parent_pos, h2(&left, &right)));
613                idx += 1;
614            }
615            current = next_level;
616            level_size = level_size.div_ceil(2);
617        }
618
619        if sibling_iter.next().is_some() {
620            return Err(MerkleError::UnalignedProof);
621        }
622        if current.len() != 1 {
623            return Err(MerkleError::UnalignedProof);
624        }
625        Ok(h2(&self.leaf_count.to_be_bytes(), &current[0].1))
626    }
627
628    /// Reconstructs the inner root for a contiguous range of `leaves`
629    /// starting at `position`. A convenience wrapper over
630    /// [`Self::reconstruct_multi_root`].
631    fn reconstruct_range_root(&self, position: u32, leaves: &[Hash]) -> Result<Hash, MerkleError> {
632        if leaves.is_empty() && position != 0 {
633            return Err(MerkleError::PositionOutOfRange(position));
634        }
635        if !leaves.is_empty() {
636            let leaves_len = u32_of(leaves.len());
637            let end = position
638                .checked_add(leaves_len - 1)
639                .ok_or(MerkleError::PositionOutOfRange(position))?;
640            if end >= self.leaf_count {
641                return Err(MerkleError::PositionOutOfRange(end));
642            }
643        }
644        let elements: Vec<(Hash, u32)> = leaves
645            .iter()
646            .enumerate()
647            .map(|(i, l)| (*l, position + u32_of(i)))
648            .collect();
649        self.reconstruct_multi_root(&elements)
650    }
651
652    /// Verify against the bare (pre-wrap) inner root — matches
653    /// `commonware_storage::bmt::Proof::verify_element_inclusion` exactly.
654    /// `cfg(test)`: kept only for the commonware cross-check test
655    /// (`tests::proofs_match_commonware`) and `tests::verify_chunk_rejects_meta_leaf`,
656    /// which need to show the *bare* BMT proof accepts a position/leaf the
657    /// id-based verifier must still reject. Every real caller MUST use the
658    /// id-based [`verify_tree_entry`] / [`verify_chunk`], which close the
659    /// inner-root-vs-id confusion footgun (issue #1015 §Security) — so this
660    /// is deliberately not reachable outside tests, not just `pub(crate)`.
661    #[cfg(test)]
662    pub(crate) fn verify_element_inclusion(
663        &self,
664        leaf: &Hash,
665        position: u32,
666        inner_root: &Hash,
667    ) -> Result<(), MerkleError> {
668        let got = self.reconstruct_element_root(leaf, position)?;
669        if &got == inner_root {
670            Ok(())
671        } else {
672            Err(MerkleError::VerificationFailed)
673        }
674    }
675
676    /// Verify a multi-leaf proof against the bare inner root. `cfg(test)`
677    /// for the same reason as [`Self::verify_element_inclusion`].
678    #[cfg(test)]
679    pub(crate) fn verify_multi_inclusion(
680        &self,
681        elements: &[(Hash, u32)],
682        inner_root: &Hash,
683    ) -> Result<(), MerkleError> {
684        let got = self.reconstruct_multi_root(elements)?;
685        if &got == inner_root {
686            Ok(())
687        } else {
688            Err(MerkleError::VerificationFailed)
689        }
690    }
691
692    /// Verify a range proof against the bare inner root. `cfg(test)` for
693    /// the same reason as [`Self::verify_element_inclusion`].
694    #[cfg(test)]
695    pub(crate) fn verify_range_inclusion(
696        &self,
697        position: u32,
698        leaves: &[Hash],
699        inner_root: &Hash,
700    ) -> Result<(), MerkleError> {
701        let got = self.reconstruct_range_root(position, leaves)?;
702        if &got == inner_root {
703            Ok(())
704        } else {
705            Err(MerkleError::VerificationFailed)
706        }
707    }
708}
709
710// ---------------------------------------------------------------------------
711// Leaf digests
712// ---------------------------------------------------------------------------
713
714/// The position-0 metadata leaf digest for a `ChunkedBlob`'s
715/// `total_size`/`chunk_size`, without requiring a materialized
716/// [`ChunkedBlob`] value. Shared by [`chunked_meta_leaf`] (which has one)
717/// and [`verify_chunk_with_meta_leaf`] (which — by design — never does;
718/// see that function's docs).
719pub(crate) fn chunked_meta_leaf_raw(total_size: u64, chunk_size: u32) -> Hash {
720    let mut body = [0u8; 12];
721    body[..8].copy_from_slice(&total_size.to_le_bytes());
722    body[8..].copy_from_slice(&chunk_size.to_le_bytes());
723    domain_digest(CBLOB_META_DOMAIN, &body)
724}
725
726/// The position-0 metadata leaf for a `ChunkedBlob`, binding its
727/// `total_size` and `chunk_size` (neither is derivable from the chunk
728/// list, so without this they could be forged — a second-preimage hole).
729fn chunked_meta_leaf(cb: &ChunkedBlob) -> Hash {
730    chunked_meta_leaf_raw(cb.total_size, cb.chunk_size)
731}
732
733/// The leaf digest for one `Tree` entry. The `name_len` u32-LE prefix is
734/// the anti-ambiguity guard so `("ab", m, h)` and `("a", m, "b"‖…)` cannot
735/// alias. Feeding this triple (not the raw `object_hash`) means a Tree
736/// inclusion proof attests the full `(name, mode, object_hash)`.
737pub(crate) fn tree_entry_leaf(e: &TreeEntry) -> Hash {
738    let mut body = Vec::with_capacity(4 + e.name.len() + 1 + HASH_LEN);
739    body.extend_from_slice(&u32_of(e.name.len()).to_le_bytes());
740    body.extend_from_slice(&e.name);
741    body.push(e.mode as u8);
742    body.extend_from_slice(&e.object_hash);
743    domain_digest(TREE_ENTRY_DOMAIN, &body)
744}
745
746fn chunked_leaves(cb: &ChunkedBlob) -> Vec<Hash> {
747    let mut leaves = Vec::with_capacity(1 + cb.chunks.len());
748    leaves.push(chunked_meta_leaf(cb));
749    leaves.extend_from_slice(&cb.chunks);
750    leaves
751}
752
753fn tree_leaves(tree: &Tree) -> Vec<Hash> {
754    tree.entries.iter().map(tree_entry_leaf).collect()
755}
756
757// ---------------------------------------------------------------------------
758// Inner roots + identity
759// ---------------------------------------------------------------------------
760
761/// Bare (pre-wrap) BMT root over a `ChunkedBlob`'s leaves (`[meta] ++
762/// chunks`). A commonware-based verifier's `verify_*_inclusion` checks
763/// against this; mkit callers use the id-based [`verify_chunk`] instead.
764#[must_use]
765pub fn chunked_inner_root(cb: &ChunkedBlob) -> Hash {
766    build_bmt(&chunked_leaves(cb)).root
767}
768
769/// Bare (pre-wrap) BMT root over a `Tree`'s entry leaves. A
770/// commonware-based verifier's `verify_*_inclusion` checks against this;
771/// mkit callers use the id-based [`verify_tree_entry`] instead.
772#[must_use]
773pub fn tree_inner_root(tree: &Tree) -> Hash {
774    build_bmt(&tree_leaves(tree)).root
775}
776
777/// The content-address (object id) of a `ChunkedBlob`.
778#[must_use]
779pub fn compute_chunked_id(cb: &ChunkedBlob) -> Hash {
780    wrap_id(ObjectKind::ChunkedBlob, &chunked_inner_root(cb))
781}
782
783/// The content-address (object id) of a `Tree`.
784#[must_use]
785pub fn compute_tree_id(tree: &Tree) -> Hash {
786    wrap_id(ObjectKind::Tree, &tree_inner_root(tree))
787}
788
789/// The id of the empty `Tree` (`entries = []`) — a real, common object.
790/// Pinned from a test run (see `empty_tree_id_matches_constant`); the
791/// empty BMT root is `H(leaf_count ‖ H(""))`, NOT `H(0 ‖ 0)`.
792pub const TREE_EMPTY_ID: Hash = [
793    0x1a, 0xb8, 0xd0, 0x78, 0x8b, 0x29, 0xfe, 0x59, 0x92, 0x01, 0x1e, 0x64, 0xd6, 0xc9, 0x22, 0xec,
794    0x93, 0xf4, 0x24, 0x8b, 0x37, 0x55, 0xb9, 0x2b, 0x15, 0xb0, 0x7e, 0x66, 0x4c, 0xb1, 0x56, 0x52,
795];
796
797// ---------------------------------------------------------------------------
798// Position lookup (mirror makechain `message_index`)
799// ---------------------------------------------------------------------------
800
801/// BMT position of `chunk_hash` within `cb`, or `None` if absent. The
802/// returned position is the chunk index **+ 1** (the metadata leaf
803/// occupies position 0).
804#[must_use]
805pub fn chunk_position(cb: &ChunkedBlob, chunk_hash: &Hash) -> Option<u32> {
806    cb.chunks
807        .iter()
808        .position(|c| c == chunk_hash)
809        .map(|i| u32_of(i + 1))
810}
811
812/// BMT position (= entry index) of the entry named `name` within `tree`,
813/// or `None` if absent.
814#[must_use]
815pub fn tree_entry_position(tree: &Tree, name: &[u8]) -> Option<u32> {
816    tree.entries.iter().position(|e| e.name == name).map(u32_of)
817}
818
819/// Wire size of a proof, calculated from tree shape without hashing leaves.
820pub(crate) fn proof_encoded_size(
821    leaf_count: u32,
822    positions: impl IntoIterator<Item = u32>,
823) -> Result<usize, MerkleError> {
824    let siblings = siblings_required_for_multi_proof(leaf_count, positions)?.len();
825    let mut count = siblings;
826    let mut prefix = 1;
827    while count >= 128 {
828        prefix += 1;
829        count >>= 7;
830    }
831    Ok(4 + prefix + siblings * 32)
832}
833
834// ---------------------------------------------------------------------------
835// Proof construction
836// ---------------------------------------------------------------------------
837
838/// Build an inclusion proof that the chunk at `position` (= chunk index +
839/// 1, per [`chunk_position`]) belongs to `cb`.
840pub fn build_chunk_proof(cb: &ChunkedBlob, position: u32) -> Result<Proof, MerkleError> {
841    build_bmt(&chunked_leaves(cb)).proof(position)
842}
843
844/// Build a range proof that the chunks at `start..=end` belong to `cb`.
845pub fn build_chunks_range_proof(
846    cb: &ChunkedBlob,
847    start: u32,
848    end: u32,
849) -> Result<Proof, MerkleError> {
850    build_bmt(&chunked_leaves(cb)).range_proof(start, end)
851}
852
853/// Build a multi-leaf proof that the chunks at `positions` belong to `cb`.
854pub fn build_chunks_multi_proof(
855    cb: &ChunkedBlob,
856    positions: impl IntoIterator<Item = u32>,
857) -> Result<Proof, MerkleError> {
858    build_bmt(&chunked_leaves(cb)).multi_proof(positions)
859}
860
861/// Build an inclusion proof that the entry at `position` (= entry index)
862/// belongs to `tree`.
863pub fn build_tree_entry_proof(tree: &Tree, position: u32) -> Result<Proof, MerkleError> {
864    build_bmt(&tree_leaves(tree)).proof(position)
865}
866
867/// Build a range proof that the entries at `start..=end` belong to `tree`.
868pub fn build_tree_entries_range_proof(
869    tree: &Tree,
870    start: u32,
871    end: u32,
872) -> Result<Proof, MerkleError> {
873    build_bmt(&tree_leaves(tree)).range_proof(start, end)
874}
875
876/// Build a multi-leaf proof that the entries at `positions` belong to
877/// `tree`.
878pub fn build_tree_entries_multi_proof(
879    tree: &Tree,
880    positions: impl IntoIterator<Item = u32>,
881) -> Result<Proof, MerkleError> {
882    build_bmt(&tree_leaves(tree)).multi_proof(positions)
883}
884
885// ---------------------------------------------------------------------------
886// Proof verification — against the object id
887// ---------------------------------------------------------------------------
888
889/// Verify that `entry` at `position` belongs to the `Tree` whose id is
890/// `tree_id`.
891pub fn verify_tree_entry(
892    tree_id: &Hash,
893    entry: &TreeEntry,
894    position: u32,
895    proof: &Proof,
896) -> Result<(), MerkleError> {
897    let root = proof.reconstruct_element_root(&tree_entry_leaf(entry), position)?;
898    check_wrapped(ObjectKind::Tree, &root, tree_id)
899}
900
901/// Verify that `entries` (in tree order, starting at `start`) belong to
902/// the `Tree` whose id is `tree_id`.
903pub fn verify_tree_entries_range(
904    tree_id: &Hash,
905    start: u32,
906    entries: &[TreeEntry],
907    proof: &Proof,
908) -> Result<(), MerkleError> {
909    let leaves: Vec<Hash> = entries.iter().map(tree_entry_leaf).collect();
910    let root = proof.reconstruct_range_root(start, &leaves)?;
911    check_wrapped(ObjectKind::Tree, &root, tree_id)
912}
913
914/// Verify that `entries` (each with its own position, any order) belong to
915/// the `Tree` whose id is `tree_id`.
916pub fn verify_tree_entries_multi(
917    tree_id: &Hash,
918    entries: &[(TreeEntry, u32)],
919    proof: &Proof,
920) -> Result<(), MerkleError> {
921    let elements: Vec<(Hash, u32)> = entries
922        .iter()
923        .map(|(e, pos)| (tree_entry_leaf(e), *pos))
924        .collect();
925    let root = proof.reconstruct_multi_root(&elements)?;
926    check_wrapped(ObjectKind::Tree, &root, tree_id)
927}
928
929/// Verify that the chunk `chunk_hash` at `position` belongs to the
930/// `ChunkedBlob` whose id is `chunked_id`. Rejects `position == 0`: that
931/// position is the metadata leaf, not a chunk, so a valid BMT proof of it
932/// must never be accepted as a chunk proof (see `verify_chunk_rejects_meta_leaf`).
933pub fn verify_chunk(
934    chunked_id: &Hash,
935    chunk_hash: &Hash,
936    position: u32,
937    proof: &Proof,
938) -> Result<(), MerkleError> {
939    if position == 0 {
940        return Err(MerkleError::PositionOutOfRange(0));
941    }
942    let root = proof.reconstruct_element_root(chunk_hash, position)?;
943    check_wrapped(ObjectKind::ChunkedBlob, &root, chunked_id)
944}
945
946/// Verify that `chunk_hashes` (in chunk order, starting at `start`) belong
947/// to the `ChunkedBlob` whose id is `chunked_id`. Rejects a range whose
948/// `start` is 0 (the metadata leaf).
949pub fn verify_chunks_range(
950    chunked_id: &Hash,
951    start: u32,
952    chunk_hashes: &[Hash],
953    proof: &Proof,
954) -> Result<(), MerkleError> {
955    if start == 0 {
956        return Err(MerkleError::PositionOutOfRange(0));
957    }
958    let root = proof.reconstruct_range_root(start, chunk_hashes)?;
959    check_wrapped(ObjectKind::ChunkedBlob, &root, chunked_id)
960}
961
962/// Verify that `chunks` (each with its own position, any order) belong to
963/// the `ChunkedBlob` whose id is `chunked_id`. Rejects any position `== 0`
964/// (the metadata leaf).
965pub fn verify_chunks_multi(
966    chunked_id: &Hash,
967    chunks: &[(Hash, u32)],
968    proof: &Proof,
969) -> Result<(), MerkleError> {
970    if chunks.iter().any(|(_, pos)| *pos == 0) {
971        return Err(MerkleError::PositionOutOfRange(0));
972    }
973    let elements: Vec<(Hash, u32)> = chunks.to_vec();
974    let root = proof.reconstruct_multi_root(&elements)?;
975    check_wrapped(ObjectKind::ChunkedBlob, &root, chunked_id)
976}
977
978/// Verify, in one multi-proof, that (a) a `ChunkedBlob`'s metadata leaf —
979/// computed HERE from the caller's *claimed* `total_size`/`chunk_size`,
980/// never accepted as an externally supplied leaf digest — and (b) the
981/// chunk `chunk_hash` at `chunk_position` (= chunk index + 1) both belong
982/// to the `ChunkedBlob` whose id is `chunked_id`.
983///
984/// This is the one sanctioned way a proof over position 0 is ever
985/// accepted from outside this module: the verifier derives the meta leaf
986/// itself from the very values it is simultaneously authenticating,
987/// rather than trusting a caller-supplied leaf digest at that position.
988/// [`verify_chunk`]'s "reject a caller-supplied position 0" rule is
989/// unaffected — that guards a different, single-leaf proof shape; this
990/// one always proves exactly `{0, chunk_position}` together. Used by
991/// `crate::verify` (issue #1015 verifier kit PR 2, SPEC-DISCLOSURE) to
992/// authenticate a disclosed chunk's `total_size`/`chunk_size`
993/// announcement alongside its content id, in a single proof.
994pub(crate) fn verify_chunk_with_meta_leaf(
995    chunked_id: &Hash,
996    total_size: u64,
997    chunk_size: u32,
998    chunk_hash: &Hash,
999    chunk_position: u32,
1000    proof: &Proof,
1001) -> Result<(), MerkleError> {
1002    if chunk_position == 0 {
1003        return Err(MerkleError::PositionOutOfRange(0));
1004    }
1005    let meta_leaf = chunked_meta_leaf_raw(total_size, chunk_size);
1006    let elements = [(meta_leaf, 0u32), (*chunk_hash, chunk_position)];
1007    let root = proof.reconstruct_multi_root(&elements)?;
1008    check_wrapped(ObjectKind::ChunkedBlob, &root, chunked_id)
1009}
1010
1011/// Wrap `root` with `kind`'s type domain and compare to `expected_id`.
1012fn check_wrapped(kind: ObjectKind, root: &Hash, expected_id: &Hash) -> Result<(), MerkleError> {
1013    if &wrap_id(kind, root) == expected_id {
1014        Ok(())
1015    } else {
1016        Err(MerkleError::VerificationFailed)
1017    }
1018}
1019
1020// =========================================================================
1021// Tests
1022// =========================================================================
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027    use crate::object::EntryMode;
1028
1029    fn cb(total: u64, chunk_size: u32, chunks: &[u8]) -> ChunkedBlob {
1030        ChunkedBlob {
1031            total_size: total,
1032            chunk_size,
1033            chunks: chunks.iter().map(|b| [*b; 32]).collect(),
1034        }
1035    }
1036
1037    fn entry(name: &[u8], mode: EntryMode, h: u8) -> TreeEntry {
1038        TreeEntry {
1039            name: name.to_vec(),
1040            mode,
1041            object_hash: [h; 32],
1042        }
1043    }
1044
1045    fn tree(entries: Vec<TreeEntry>) -> Tree {
1046        Tree { entries }
1047    }
1048
1049    #[test]
1050    fn id_changes_when_a_leaf_changes() {
1051        let a = cb(100, 0, &[1, 2, 3]);
1052        let b = cb(100, 0, &[1, 2, 4]);
1053        assert_ne!(compute_chunked_id(&a), compute_chunked_id(&b));
1054    }
1055
1056    #[test]
1057    fn id_changes_when_leaf_count_changes() {
1058        let a = cb(100, 0, &[1, 2, 3]);
1059        let b = cb(100, 0, &[1, 2, 3, 3]); // duplicated last — must not collide
1060        assert_ne!(compute_chunked_id(&a), compute_chunked_id(&b));
1061    }
1062
1063    #[test]
1064    fn chunked_id_changes_when_metadata_changes() {
1065        let a = cb(100, 0, &[1, 2, 3]);
1066        let b = cb(101, 0, &[1, 2, 3]);
1067        let c = cb(100, 64, &[1, 2, 3]);
1068        assert_ne!(compute_chunked_id(&a), compute_chunked_id(&b));
1069        assert_ne!(compute_chunked_id(&a), compute_chunked_id(&c));
1070    }
1071
1072    #[test]
1073    fn tree_ordering_matters() {
1074        let a = tree(vec![
1075            entry(b"a", EntryMode::Blob, 1),
1076            entry(b"b", EntryMode::Blob, 2),
1077        ]);
1078        let b = tree(vec![
1079            entry(b"b", EntryMode::Blob, 2),
1080            entry(b"a", EntryMode::Blob, 1),
1081        ]);
1082        assert_ne!(compute_tree_id(&a), compute_tree_id(&b));
1083    }
1084
1085    #[test]
1086    fn empty_tree_id_matches_constant() {
1087        let got = compute_tree_id(&tree(vec![]));
1088        assert_eq!(got, TREE_EMPTY_ID, "update TREE_EMPTY_ID to {got:02x?}");
1089    }
1090
1091    #[test]
1092    fn type_binding_no_cross_collisions() {
1093        let empty_tree = compute_tree_id(&tree(vec![]));
1094        let empty_cblob = compute_chunked_id(&cb(0, 0, &[]));
1095        assert_ne!(empty_tree, empty_cblob);
1096
1097        let t = tree(vec![entry(b"x", EntryMode::Blob, 9)]);
1098        let c = cb(10, 0, &[9]);
1099        assert_ne!(compute_tree_id(&t), compute_chunked_id(&c));
1100    }
1101
1102    #[test]
1103    fn id_ne_flat_blake3_of_serialized_bytes() {
1104        let c = cb(100, 0, &[1, 2, 3]);
1105        let serialized =
1106            crate::serialize::serialize(&crate::object::Object::ChunkedBlob(c.clone())).unwrap();
1107        assert_ne!(compute_chunked_id(&c), crate::hash::hash(&serialized));
1108    }
1109
1110    #[test]
1111    fn chunk_position_offsets_by_one() {
1112        let c = cb(100, 0, &[7, 8, 9]);
1113        assert_eq!(chunk_position(&c, &[7; 32]), Some(1));
1114        assert_eq!(chunk_position(&c, &[9; 32]), Some(3));
1115        assert_eq!(chunk_position(&c, &[0; 32]), None);
1116    }
1117
1118    #[test]
1119    fn chunk_inclusion_proof_round_trips() {
1120        let c = cb(100, 0, &[10, 20, 30, 40]);
1121        let id = compute_chunked_id(&c);
1122        for (idx, byte) in [(0usize, 10u8), (2, 30), (3, 40)] {
1123            let pos = chunk_position(&c, &[byte; 32]).unwrap();
1124            assert_eq!(pos, u32_of(idx) + 1);
1125            let proof = build_chunk_proof(&c, pos).unwrap();
1126            verify_chunk(&id, &[byte; 32], pos, &proof).unwrap();
1127            assert!(verify_chunk(&id, &[0xFF; 32], pos, &proof).is_err());
1128        }
1129    }
1130
1131    #[test]
1132    fn verify_chunk_rejects_meta_leaf() {
1133        let c = cb(100, 0, &[10, 20, 30]);
1134        let id = compute_chunked_id(&c);
1135        let meta_leaf = chunked_meta_leaf(&c);
1136        // Position 0 is a *valid* raw BMT proof of the meta leaf...
1137        let proof = build_chunk_proof(&c, 0).unwrap();
1138        proof
1139            .verify_element_inclusion(&meta_leaf, 0, &chunked_inner_root(&c))
1140            .expect("meta leaf is a valid BMT element at position 0");
1141        // ...but `verify_chunk` MUST reject it: position 0 is never a chunk.
1142        assert_eq!(
1143            verify_chunk(&id, &meta_leaf, 0, &proof),
1144            Err(MerkleError::PositionOutOfRange(0))
1145        );
1146    }
1147
1148    #[test]
1149    fn verify_chunk_with_meta_leaf_round_trips_and_rejects_forgery() {
1150        let c = cb(100, 0, &[10, 20, 30]);
1151        let id = compute_chunked_id(&c);
1152        let pos = chunk_position(&c, &[20; 32]).unwrap(); // index 1 -> position 2
1153        let proof = build_chunks_multi_proof(&c, [0, pos]).unwrap();
1154
1155        verify_chunk_with_meta_leaf(&id, 100, 0, &[20; 32], pos, &proof).unwrap();
1156
1157        // Forged total_size must be rejected — the meta leaf the verifier
1158        // computes no longer folds to the real root.
1159        assert!(verify_chunk_with_meta_leaf(&id, 101, 0, &[20; 32], pos, &proof).is_err());
1160        // Forged chunk_size, likewise.
1161        assert!(verify_chunk_with_meta_leaf(&id, 100, 64, &[20; 32], pos, &proof).is_err());
1162        // Wrong chunk hash at the right position.
1163        assert!(verify_chunk_with_meta_leaf(&id, 100, 0, &[0xFF; 32], pos, &proof).is_err());
1164        // Position 0 is never accepted as `chunk_position`, even though
1165        // it is a structurally valid multi-proof position.
1166        assert_eq!(
1167            verify_chunk_with_meta_leaf(&id, 100, 0, &[10; 32], 0, &proof),
1168            Err(MerkleError::PositionOutOfRange(0))
1169        );
1170    }
1171
1172    #[test]
1173    fn tree_inclusion_proof_round_trips() {
1174        let t = tree(vec![
1175            entry(b"a", EntryMode::Blob, 1),
1176            entry(b"b", EntryMode::Tree, 2),
1177            entry(b"c", EntryMode::Executable, 3),
1178        ]);
1179        let id = compute_tree_id(&t);
1180        let pos = tree_entry_position(&t, b"b").unwrap();
1181        assert_eq!(pos, 1);
1182        let proof = build_tree_entry_proof(&t, pos).unwrap();
1183        verify_tree_entry(&id, &t.entries[1], pos, &proof).unwrap();
1184        let wrong = entry(b"b", EntryMode::Blob, 2);
1185        assert!(verify_tree_entry(&id, &wrong, pos, &proof).is_err());
1186    }
1187
1188    #[test]
1189    fn range_and_multi_proofs_round_trip() {
1190        let t = tree(
1191            (0..9)
1192                .map(|i| entry(&[b'a' + i], EntryMode::Blob, i))
1193                .collect(),
1194        );
1195        let id = compute_tree_id(&t);
1196
1197        let range_proof = build_tree_entries_range_proof(&t, 2, 4).unwrap();
1198        verify_tree_entries_range(&id, 2, &t.entries[2..=4], &range_proof).unwrap();
1199        assert!(verify_tree_entries_range(&id, 2, &t.entries[2..4], &range_proof).is_err());
1200
1201        let multi_proof = build_tree_entries_multi_proof(&t, [0, 4, 8]).unwrap();
1202        let elements = [
1203            (t.entries[0].clone(), 0),
1204            (t.entries[4].clone(), 4),
1205            (t.entries[8].clone(), 8),
1206        ];
1207        verify_tree_entries_multi(&id, &elements, &multi_proof).unwrap();
1208        let wrong_elements = [
1209            (t.entries[0].clone(), 0),
1210            (t.entries[4].clone(), 5), // wrong position
1211            (t.entries[8].clone(), 8),
1212        ];
1213        assert!(verify_tree_entries_multi(&id, &wrong_elements, &multi_proof).is_err());
1214    }
1215
1216    #[test]
1217    fn chunk_range_and_multi_proofs_reject_position_zero() {
1218        let c = cb(100, 0, &[1, 2, 3]);
1219        let id = compute_chunked_id(&c);
1220        let proof = build_chunks_range_proof(&c, 0, 1).unwrap();
1221        assert_eq!(
1222            verify_chunks_range(&id, 0, &c.chunks[..2], &proof),
1223            Err(MerkleError::PositionOutOfRange(0))
1224        );
1225        let multi = build_chunks_multi_proof(&c, [0, 2]).unwrap();
1226        assert_eq!(
1227            verify_chunks_multi(&id, &[(c.chunks[0], 0), (c.chunks[1], 2)], &multi),
1228            Err(MerkleError::PositionOutOfRange(0))
1229        );
1230    }
1231
1232    #[test]
1233    fn single_leaf_tree_proof_has_no_siblings() {
1234        // Odd trailing node at every level up to the root: the proof must
1235        // omit the self-duplicate sibling entirely (issue #1015: the old
1236        // format wrongly emitted it).
1237        let t = tree(vec![entry(b"only", EntryMode::Blob, 1)]);
1238        let id = compute_tree_id(&t);
1239        let proof = build_tree_entry_proof(&t, 0).unwrap();
1240        assert!(
1241            proof.siblings.is_empty(),
1242            "single-leaf tree proof must have zero siblings"
1243        );
1244        verify_tree_entry(&id, &t.entries[0], 0, &proof).unwrap();
1245    }
1246
1247    #[test]
1248    fn verify_rejects_empty_element_set() {
1249        // A "proof" over zero positions must never verify — not even
1250        // against the real empty tree id with the trivial
1251        // `Proof::default()` (`leaf_count: 0, siblings: []`). Upstream's
1252        // own `verify_multi_inclusion` treats exactly this input as a
1253        // valid proof that a tree is empty; mkit's id-based verifiers
1254        // reject it unconditionally instead, so a caller can never
1255        // "verify" zero proven entries/chunks against an id merely
1256        // because that id's inner root happens to fold the same way
1257        // (SPEC-MERKLE-OBJECTS §5.4).
1258        assert_eq!(
1259            verify_tree_entries_range(&TREE_EMPTY_ID, 0, &[], &Proof::default()),
1260            Err(MerkleError::NoPositions)
1261        );
1262        assert_eq!(
1263            verify_tree_entries_multi(&TREE_EMPTY_ID, &[], &Proof::default()),
1264            Err(MerkleError::NoPositions)
1265        );
1266
1267        // The rule holds regardless of `leaf_count`: an empty
1268        // entries/positions slice is rejected even against a genuinely
1269        // non-empty tree with an otherwise-valid proof.
1270        let t = tree(vec![entry(b"a", EntryMode::Blob, 1)]);
1271        let id = compute_tree_id(&t);
1272        let real_proof = build_tree_entry_proof(&t, 0).unwrap();
1273        assert_eq!(
1274            verify_tree_entries_range(&id, 0, &[], &real_proof),
1275            Err(MerkleError::NoPositions)
1276        );
1277        assert_eq!(
1278            verify_tree_entries_multi(&id, &[], &real_proof),
1279            Err(MerkleError::NoPositions)
1280        );
1281    }
1282
1283    #[test]
1284    fn empty_tree_builders_refuse_position_zero() {
1285        // SPEC-MERKLE-OBJECTS §5.4: position 0 of the empty `Tree`
1286        // (leaf_count 0), including the range `0..=0`, MUST be refused
1287        // rather than answered with the all-default proof (MKIT-56).
1288        let empty = tree(vec![]);
1289        assert_eq!(
1290            build_tree_entries_range_proof(&empty, 0, 0),
1291            Err(MerkleError::PositionOutOfRange(0))
1292        );
1293        assert_eq!(
1294            build_tree_entry_proof(&empty, 0),
1295            Err(MerkleError::PositionOutOfRange(0))
1296        );
1297        assert_eq!(
1298            build_tree_entries_multi_proof(&empty, [0]),
1299            Err(MerkleError::PositionOutOfRange(0))
1300        );
1301    }
1302
1303    #[test]
1304    fn out_of_range_position_rejected() {
1305        let c = cb(10, 0, &[1]);
1306        assert_eq!(
1307            build_chunk_proof(&c, 2),
1308            Err(MerkleError::PositionOutOfRange(2))
1309        );
1310    }
1311
1312    #[test]
1313    fn proof_round_trips_through_encode_decode() {
1314        let t = tree(
1315            (0..7)
1316                .map(|i| entry(&[b'a' + i], EntryMode::Blob, i))
1317                .collect(),
1318        );
1319        let proof = build_tree_entry_proof(&t, 6).unwrap();
1320        let bytes = proof.encode();
1321        let decoded = Proof::decode(&bytes, 1).unwrap();
1322        assert_eq!(proof, decoded);
1323
1324        // Trailing byte must be rejected.
1325        let mut truncated_extra = bytes.clone();
1326        truncated_extra.push(0);
1327        assert_eq!(
1328            Proof::decode(&truncated_extra, 1),
1329            Err(MerkleError::MalformedProof)
1330        );
1331
1332        // A truncated buffer must be rejected.
1333        assert_eq!(
1334            Proof::decode(&bytes[..bytes.len() - 1], 1),
1335            Err(MerkleError::MalformedProof)
1336        );
1337
1338        // An over-tight `max_items` bound must be rejected.
1339        assert_eq!(Proof::decode(&bytes, 0), Err(MerkleError::MalformedProof));
1340    }
1341
1342    /// Cross-verify the vendored BMT against `commonware_storage::bmt`
1343    /// (native-only dev-dep) for several leaf counts incl. odd ones — the
1344    /// guard that the wasm-path vendored construction never drifts from the
1345    /// house primitive.
1346    #[test]
1347    fn vendored_root_matches_commonware() {
1348        use commonware_cryptography::blake3::{Blake3, Digest};
1349        use commonware_storage::bmt::Builder;
1350
1351        for n in [1usize, 2, 3, 4, 5, 7, 8, 9, 16, 33] {
1352            let leaves: Vec<Hash> = (0..n)
1353                .map(|i| hash(&[u8::try_from(i % 256).unwrap(); 4]))
1354                .collect();
1355
1356            let mut builder = Builder::<Blake3>::new(n);
1357            for l in &leaves {
1358                builder.add(&Digest(*l));
1359            }
1360            let cw_root = builder.build().root().0;
1361            let ours = build_bmt(&leaves).root;
1362            assert_eq!(ours, cw_root, "vendored BMT root diverged at n={n}");
1363        }
1364    }
1365
1366    /// Strategy for `(leaf_count, single_pos, (range_start, range_end),
1367    /// multi_positions)`, weighted towards odd counts and powers-of-two ±1
1368    /// (where the odd-trailing-node dedup logic is most exercised) on top
1369    /// of a broad uniform range.
1370    fn tree_and_positions()
1371    -> impl proptest::strategy::Strategy<Value = (usize, u32, (u32, u32), Vec<u32>)> {
1372        use proptest::prelude::*;
1373
1374        let boundary_counts: Vec<usize> = [1usize, 2, 4, 8, 16, 32, 64, 128, 256]
1375            .into_iter()
1376            .flat_map(|p| [p.saturating_sub(1).max(1), p, p + 1])
1377            .chain([
1378                3usize, 5, 7, 9, 15, 17, 31, 33, 63, 65, 127, 129, 255, 257, 300,
1379            ])
1380            .collect();
1381
1382        prop_oneof![
1383            3 => 1usize..=300,
1384            2 => proptest::sample::select(boundary_counts),
1385        ]
1386        .prop_flat_map(|n| {
1387            let n_u32 =
1388                u32::try_from(n).expect("n is bounded well under u32::MAX by the strategy above");
1389            (
1390                Just(n),
1391                0..n_u32,
1392                (0..n_u32).prop_flat_map(move |s| (Just(s), s..n_u32)),
1393                proptest::collection::vec(0..n_u32, 1..=n.min(6)).prop_map(|mut v| {
1394                    v.sort_unstable();
1395                    v.dedup();
1396                    v
1397                }),
1398            )
1399        })
1400    }
1401
1402    proptest::proptest! {
1403        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(400))]
1404
1405        /// Cross-verify [`Proof`] against `commonware_storage::bmt::Proof`
1406        /// (native-only dev-dep): for many randomised trees and randomised
1407        /// single/range/multi positions, mkit's encoded bytes must equal
1408        /// upstream's, and each side's verifier must accept the other's
1409        /// bytes/positions against the (matching) inner root. A mutated
1410        /// proof must be rejected by both. Pins issue #1015's
1411        /// "commonware-aligned bytes" claim beyond the root-only check
1412        /// above.
1413        #[test]
1414        fn proofs_match_commonware((n, pos, (start, end), positions) in tree_and_positions()) {
1415            use commonware_cryptography::blake3::{Blake3, Digest};
1416            use commonware_storage::bmt::Builder;
1417
1418            let leaves: Vec<Hash> = (0..n).map(|i| hash(&(i as u64).to_le_bytes())).collect();
1419
1420            let ours = build_bmt(&leaves);
1421            let mut cw_builder = Builder::<Blake3>::new(n);
1422            for l in &leaves {
1423                cw_builder.add(&Digest(*l));
1424            }
1425            let cw_tree = cw_builder.build();
1426            let cw_root = cw_tree.root();
1427            assert_eq!(ours.root, cw_root.0, "root mismatch at n={n}");
1428
1429            // --- single position ---
1430            let our_proof = ours.proof(pos).unwrap();
1431            let cw_proof = cw_tree.proof(pos).unwrap();
1432            assert_eq!(
1433                our_proof.encode(),
1434                commonware_codec::Encode::encode(&cw_proof).to_vec(),
1435                "single-proof bytes diverged at n={n} pos={pos}"
1436            );
1437            cw_proof
1438                .verify_element_inclusion::<Blake3>(&Digest(leaves[pos as usize]), pos, &cw_root)
1439                .expect("upstream must accept its own proof");
1440            our_proof
1441                .verify_element_inclusion(&leaves[pos as usize], pos, &ours.root)
1442                .expect("ours must accept its own proof");
1443            // Cross-accept: decode the other side's bytes with our type.
1444            let cw_bytes = commonware_codec::Encode::encode(&cw_proof).to_vec();
1445            let our_decoded = Proof::decode(&cw_bytes, 1).unwrap();
1446            our_decoded
1447                .verify_element_inclusion(&leaves[pos as usize], pos, &ours.root)
1448                .expect("ours must accept upstream's proof bytes");
1449            // Cross-accept the other way: upstream decodes our bytes.
1450            let our_bytes = our_proof.encode();
1451            let mut our_bytes_buf: &[u8] = &our_bytes;
1452            let cw_decoded =
1453                <commonware_storage::bmt::Proof<Digest> as commonware_codec::Read>::read_cfg(
1454                    &mut our_bytes_buf,
1455                    &1usize,
1456                )
1457                .unwrap();
1458            cw_decoded
1459                .verify_element_inclusion::<Blake3>(&Digest(leaves[pos as usize]), pos, &cw_root)
1460                .expect("upstream must accept our proof bytes");
1461
1462            // --- mutation must be rejected by both ---
1463            if !our_proof.siblings.is_empty() {
1464                let mut mutated = our_proof.clone();
1465                mutated.siblings[0][0] ^= 0x01;
1466                assert!(
1467                    mutated
1468                        .verify_element_inclusion(&leaves[pos as usize], pos, &ours.root)
1469                        .is_err()
1470                );
1471
1472                let mut dropped = our_proof.clone();
1473                dropped.siblings.pop();
1474                assert!(
1475                    dropped
1476                        .verify_element_inclusion(&leaves[pos as usize], pos, &ours.root)
1477                        .is_err()
1478                );
1479
1480                let mut extra = our_proof.clone();
1481                extra.siblings.push(hash(b"extra"));
1482                assert!(
1483                    extra
1484                        .verify_element_inclusion(&leaves[pos as usize], pos, &ours.root)
1485                        .is_err()
1486                );
1487            }
1488            let mut wrong_count = our_proof.clone();
1489            wrong_count.leaf_count = wrong_count.leaf_count.wrapping_add(1);
1490            assert!(
1491                wrong_count
1492                    .verify_element_inclusion(&leaves[pos as usize], pos, &ours.root)
1493                    .is_err()
1494            );
1495
1496            if n >= 2 {
1497                // --- range ---
1498                let our_range = ours.range_proof(start, end).unwrap();
1499                let cw_range = cw_tree.range_proof(start, end).unwrap();
1500                assert_eq!(
1501                    our_range.encode(),
1502                    commonware_codec::Encode::encode(&cw_range).to_vec(),
1503                    "range-proof bytes diverged at n={n} start={start} end={end}"
1504                );
1505                let range_leaves: Vec<Hash> = leaves[start as usize..=end as usize].to_vec();
1506                let cw_range_leaves: Vec<Digest> =
1507                    range_leaves.iter().map(|h| Digest(*h)).collect();
1508                cw_range
1509                    .verify_range_inclusion::<Blake3>(start, &cw_range_leaves, &cw_root)
1510                    .expect("upstream must accept its own range proof");
1511                our_range
1512                    .verify_range_inclusion(start, &range_leaves, &ours.root)
1513                    .expect("ours must accept its own range proof");
1514
1515                // --- multi ---
1516                if !positions.is_empty() {
1517                    let our_multi = ours.multi_proof(positions.iter().copied()).unwrap();
1518                    let cw_multi = cw_tree.multi_proof(positions.iter().copied()).unwrap();
1519                    assert_eq!(
1520                        our_multi.encode(),
1521                        commonware_codec::Encode::encode(&cw_multi).to_vec(),
1522                        "multi-proof bytes diverged at n={n} positions={positions:?}"
1523                    );
1524                    let elements: Vec<(Hash, u32)> =
1525                        positions.iter().map(|&p| (leaves[p as usize], p)).collect();
1526                    let cw_elements: Vec<(Digest, u32)> = positions
1527                        .iter()
1528                        .map(|&p| (Digest(leaves[p as usize]), p))
1529                        .collect();
1530                    cw_multi
1531                        .verify_multi_inclusion::<Blake3>(&cw_elements, &cw_root)
1532                        .expect("upstream must accept its own multi proof");
1533                    our_multi
1534                        .verify_multi_inclusion(&elements, &ours.root)
1535                        .expect("ours must accept its own multi proof");
1536                }
1537            }
1538        }
1539    }
1540}
1541
1542/// Kani proof harnesses (`cargo kani -p mkit-core --no-default-features
1543/// -Z stubbing --harness merkle_`, see `delta.rs`), the model-checked counterpart of the `merkle_proof` fuzz
1544/// target.
1545///
1546/// BLAKE3 (`h2`, `domain_digest`, `hash`) is stubbed with a cheap
1547/// deterministic `toy` mixer: the index/level arithmetic, sibling
1548/// consumption and wire decoding are what is verified here; collision
1549/// resistance is BLAKE3's job and out of scope for a model checker.
1550#[cfg(kani)]
1551mod kani_proofs {
1552    use super::*;
1553
1554    /// Loop-free deterministic mixer over the first 16 bytes of `a` and
1555    /// of `b` (zero-padded) and both lengths. Each output half depends on
1556    /// both inputs, so a change in either input's first 16 bytes
1557    /// propagates up the tree (a mixer that merely copied inputs into
1558    /// disjoint halves would drop leaf bytes one level up and make the
1559    /// tampered-leaf canary unfalsifiable). Loop-free so it does not
1560    /// interact with the global unwind bound.
1561    fn toy(a: &[u8], b: &[u8]) -> Hash {
1562        fn first16(x: &[u8]) -> u128 {
1563            let mut w = [0u8; 16];
1564            let n = x.len().min(16);
1565            w[..n].copy_from_slice(&x[..n]);
1566            u128::from_le_bytes(w)
1567        }
1568        let (ha, hb) = (first16(a), first16(b));
1569        let lo = ha ^ hb.rotate_left(8) ^ a.len() as u128;
1570        let hi = hb ^ ha.rotate_left(16) ^ ((b.len() as u128) << 64);
1571        let mut out = [0u8; HASH_LEN];
1572        out[..16].copy_from_slice(&lo.to_le_bytes());
1573        out[16..].copy_from_slice(&hi.to_le_bytes());
1574        out
1575    }
1576    fn toy_h2(a: &[u8], b: &[u8]) -> Hash {
1577        toy(a, b)
1578    }
1579    fn toy_domain_digest(domain: &[u8], body: &[u8]) -> Hash {
1580        toy(body, domain)
1581    }
1582    fn toy_hash(data: &[u8]) -> Hash {
1583        toy(data, &[])
1584    }
1585
1586    /// SPEC-MERKLE-OBJECTS §5.3/§5.4: number of wire siblings a
1587    /// single-leaf proof of `position` in a `leaf_count`-leaf tree
1588    /// consumes (odd trailing nodes fold with themselves, no sibling).
1589    fn spec_sibling_count(leaf_count: u32, mut position: u32) -> usize {
1590        let mut level_size = u64::from(leaf_count);
1591        let mut need = 0;
1592        while level_size > 1 {
1593            if !(position % 2 == 0 && u64::from(position) + 1 >= level_size) {
1594                need += 1;
1595            }
1596            position /= 2;
1597            level_size = level_size.div_ceil(2);
1598        }
1599        need
1600    }
1601
1602    fn short_at<const N: usize>() {
1603        let buf: [u8; N] = kani::any();
1604        let b: &[u8] = &buf;
1605        if let Ok(p) = Proof::decode(b, 1) {
1606            assert!(N == 5 && b[4] == 0 && p.siblings.is_empty());
1607            let lc: [u8; 4] = b[..4].try_into().expect("4 bytes");
1608            assert_eq!(p.leaf_count, u32::from_be_bytes(lc));
1609            kani::cover!(true, "ok_no_siblings");
1610        }
1611    }
1612
1613    /// `Proof::decode(_, 1)` (the single-leaf bound the fuzz target and
1614    /// `verify_chunk` callers use) never panics on any input of 0..=4
1615    /// bytes (truncated before the sibling-count varint; each length
1616    /// concrete, every byte symbolic) and rejects all of them.
1617    #[kani::proof]
1618    #[kani::unwind(4)]
1619    fn merkle_proof_decode_no_panic() {
1620        short_at::<0>();
1621        short_at::<1>();
1622        short_at::<2>();
1623        short_at::<3>();
1624        short_at::<4>();
1625    }
1626
1627    /// As above at 5 bytes (be32 leaf count + a symbolic varint): accepts
1628    /// exactly the §5.2 empty proof `be32(leaf_count) ‖ varint(0)`.
1629    #[kani::proof]
1630    // Varint <= 1 byte here; no sibling fits.
1631    #[kani::unwind(4)]
1632    fn merkle_proof_decode_empty_proof() {
1633        short_at::<5>();
1634    }
1635
1636    /// At exactly 37 bytes with the sibling-count varint pinned to 1
1637    /// (be32 leaf count + `0x01` + one digest, the other 36 bytes
1638    /// symbolic): no panic, `Ok`, and the proof carries exactly the wire
1639    /// leaf count and digest (§5.2). (With the varint symbolic, the
1640    /// symbolic-size sibling `Vec` allocation ran out of memory.)
1641    #[kani::proof]
1642    // Largest loop: the 4-word digest comparison.
1643    #[kani::unwind(6)]
1644    fn merkle_proof_decode_one_sibling() {
1645        let mut buf: [u8; 37] = kani::any();
1646        buf[4] = 1;
1647        let p = Proof::decode(&buf, 1).expect("one-sibling proof decodes");
1648        assert_eq!(
1649            p.leaf_count,
1650            u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]])
1651        );
1652        assert_eq!(p.siblings.len(), 1);
1653        let d = &p.siblings[0];
1654        let w = |x: &[u8], i: usize| u64::from_le_bytes(x[i..i + 8].try_into().expect("8"));
1655        assert!((0..4).all(|k| w(d, 8 * k) == w(&buf[5..], 8 * k)));
1656    }
1657
1658    /// One adversarial verification with `S` sibling digests and a
1659    /// symbolic `leaf_count <= max_leaves`.
1660    fn verify_with<const S: usize>(max_leaves: u32) -> bool {
1661        let proof = Proof {
1662            leaf_count: kani::any_where(|&n| n <= max_leaves),
1663            siblings: kani::any::<[Hash; S]>().to_vec(),
1664        };
1665        let position: u32 = kani::any();
1666        let leaf: Hash = kani::any();
1667        let id: Hash = kani::any();
1668
1669        let folded = proof.reconstruct_element_root(&leaf, position);
1670        let shape_ok = position < proof.leaf_count
1671            && proof.siblings.len() == spec_sibling_count(proof.leaf_count, position);
1672        assert_eq!(folded.is_ok(), shape_ok);
1673
1674        let chunk = verify_chunk(&id, &leaf, position, &proof);
1675        if position == 0 {
1676            assert_eq!(chunk, Err(MerkleError::PositionOutOfRange(0)));
1677        }
1678        if chunk.is_ok() {
1679            assert!(shape_ok);
1680        }
1681        let entry = TreeEntry {
1682            name: vec![b'a'],
1683            mode: crate::object::EntryMode::Blob,
1684            object_hash: leaf,
1685        };
1686        let _ = verify_tree_entry(&id, &entry, position, &proof);
1687        chunk.is_ok()
1688    }
1689
1690    /// Adversarial single-leaf verification: any `leaf_count`, any
1691    /// `position`, no sibling digests, any claimed id. `verify_chunk` /
1692    /// `verify_tree_entry` never panic/overflow, and the fold succeeds
1693    /// iff `position < leaf_count` and the proof carries exactly the §5.3
1694    /// sibling count (§5.4 "consumed exactly once"). Chunk position 0 is
1695    /// always rejected (§5.5). One harness per sibling count: 0..=2 in
1696    /// one harness did not finish within 15 min.
1697    #[kani::proof]
1698    #[kani::stub(h2, toy_h2)]
1699    #[kani::stub(crate::hash::domain_digest, toy_domain_digest)]
1700    #[kani::stub(crate::hash::hash, toy_hash)]
1701    // <= 32 fold levels (any u32 leaf count).
1702    #[kani::unwind(34)]
1703    fn merkle_verify_s0() {
1704        verify_with::<0>(u32::MAX);
1705    }
1706
1707    /// As above with one arbitrary sibling digest and `leaf_count <= 8`
1708    /// (<= 3 fold levels; any `u32` leaf count ran out of memory). Run
1709    /// with `-Z unstable-options --cbmc-args --unwindset memcmp.0:33`.
1710    #[kani::proof]
1711    #[kani::stub(h2, toy_h2)]
1712    #[kani::stub(crate::hash::domain_digest, toy_domain_digest)]
1713    #[kani::stub(crate::hash::hash, toy_hash)]
1714    #[kani::unwind(5)]
1715    fn merkle_verify_s1() {
1716        kani::cover!(verify_with::<1>(8), "accepts_one_sibling_proof");
1717    }
1718
1719    /// As above with two arbitrary sibling digests and `leaf_count <= 8`.
1720    #[kani::proof]
1721    #[kani::stub(h2, toy_h2)]
1722    #[kani::stub(crate::hash::domain_digest, toy_domain_digest)]
1723    #[kani::stub(crate::hash::hash, toy_hash)]
1724    #[kani::unwind(5)]
1725    fn merkle_verify_s2() {
1726        kani::cover!(verify_with::<2>(8), "accepts_two_sibling_proof");
1727    }
1728
1729    /// Independent single-leaf proof builder following SPEC-MERKLE-
1730    /// OBJECTS §1.1 (position-hashed leaves, odd trailing node paired
1731    /// with itself, `H(be32(leaf_count) ‖ top)` finalization) and §5.3
1732    /// (level-major siblings, self-duplicates omitted). Returns the
1733    /// finalized inner root and the sibling list. `leaves.len() <= 4`.
1734    fn spec_proof(leaves: &[Hash], pos: usize) -> (Hash, Vec<Hash>) {
1735        #[allow(clippy::cast_possible_truncation)]
1736        let mut level: Vec<Hash> = leaves
1737            .iter()
1738            .enumerate()
1739            .map(|(i, l)| toy_h2(&(i as u32).to_be_bytes(), l))
1740            .collect();
1741        let mut p = pos;
1742        let mut siblings = Vec::new();
1743        while level.len() > 1 {
1744            if p % 2 == 1 {
1745                siblings.push(level[p - 1]);
1746            } else if p + 1 < level.len() {
1747                siblings.push(level[p + 1]);
1748            }
1749            let mut next = Vec::new();
1750            let mut i = 0;
1751            while i < level.len() {
1752                let right = if i + 1 < level.len() {
1753                    level[i + 1]
1754                } else {
1755                    level[i]
1756                };
1757                next.push(toy_h2(&level[i], &right));
1758                i += 2;
1759            }
1760            level = next;
1761            p /= 2;
1762        }
1763        #[allow(clippy::cast_possible_truncation)]
1764        let root = toy_h2(&(leaves.len() as u32).to_be_bytes(), &level[0]);
1765        (root, siblings)
1766    }
1767
1768    /// A `ChunkedBlob` with `N` symbolic chunks and symbolic sizes, its
1769    /// §3.1 leaf list `[meta, chunks…]`, and its id computed through the
1770    /// §2 domain wrap of the spec-built root.
1771    fn chunked_fixture<const N: usize>() -> (ChunkedBlob, Vec<Hash>) {
1772        let cb = ChunkedBlob {
1773            total_size: kani::any(),
1774            chunk_size: kani::any(),
1775            chunks: kani::any::<[Hash; N]>().to_vec(),
1776        };
1777        let mut leaves = vec![chunked_meta_leaf_raw(cb.total_size, cb.chunk_size)];
1778        leaves.extend_from_slice(&cb.chunks);
1779        (cb, leaves)
1780    }
1781
1782    /// Spec-built proof for chunk `pos` (1-based, concrete) of an
1783    /// `N`-chunk symbolic blob is accepted.
1784    fn chunk_rt<const N: usize>(pos: u32) {
1785        let (cb, leaves) = chunked_fixture::<N>();
1786        let (root, siblings) = spec_proof(&leaves, pos as usize);
1787        let id = wrap_id(ObjectKind::ChunkedBlob, &root);
1788        #[allow(clippy::cast_possible_truncation)]
1789        let proof = Proof {
1790            leaf_count: leaves.len() as u32,
1791            siblings,
1792        };
1793        assert_eq!(
1794            proof.siblings.len(),
1795            spec_sibling_count(proof.leaf_count, pos)
1796        );
1797        assert_eq!(
1798            verify_chunk(&id, &cb.chunks[(pos - 1) as usize], pos, &proof),
1799            Ok(())
1800        );
1801    }
1802
1803    /// Spec conformance (§1.1, §5.3–§5.5): for every `ChunkedBlob` of 1
1804    /// symbolic chunk (any sizes) and every chunk position, the proof an
1805    /// independent §5.3 builder produces for the independently built §1.1
1806    /// root (wrapped per §2) has the model sibling count and is accepted
1807    /// by `verify_chunk`. (`build_chunk_proof` goes through a `BTreeSet`
1808    /// and `compute_chunked_id` through `build_bmt`'s `Vec<Vec<Hash>>`
1809    /// levels; both ran out of memory even for one chunk, so the builder
1810    /// side is left to the unit tests and the `merkle_proof` fuzz target.)
1811    #[kani::proof]
1812    #[kani::stub(h2, toy_h2)]
1813    #[kani::stub(crate::hash::domain_digest, toy_domain_digest)]
1814    #[kani::stub(crate::hash::hash, toy_hash)]
1815    // <= 2 levels for <= 3 leaves; `spec_proof` pairs <= 3 nodes. Run
1816    // with `-Z unstable-options --cbmc-args --unwindset memcmp.0:33` for
1817    // the 32-byte digest comparisons.
1818    #[kani::unwind(5)]
1819    fn merkle_roundtrip_one_chunk() {
1820        chunk_rt::<1>(1);
1821    }
1822
1823    /// As above for a 2-chunk blob (3 leaves, odd trailing node), both
1824    /// chunk positions, each concrete (a symbolic position, i.e. a
1825    /// symbolic index into the digest vectors, ran out of memory).
1826    #[kani::proof]
1827    #[kani::stub(h2, toy_h2)]
1828    #[kani::stub(crate::hash::domain_digest, toy_domain_digest)]
1829    #[kani::stub(crate::hash::hash, toy_hash)]
1830    // As above: `--cbmc-args --unwindset memcmp.0:33`.
1831    #[kani::unwind(5)]
1832    fn merkle_roundtrip_two_chunks() {
1833        chunk_rt::<2>(1);
1834        chunk_rt::<2>(2);
1835    }
1836
1837    /// SPEC-MERKLE-OBJECTS §5.4 builder rule: "A builder MUST refuse any
1838    /// position `>= leaf_count` ... A builder asked for position 0 of an
1839    /// empty `Tree`, including the range `0..=0`, MUST refuse rather than
1840    /// return the all-default proof." For the empty `Tree` every
1841    /// single-leaf, range and (one-position) multi-leaf request, over
1842    /// every `u32` position, is refused. The `cover` shows the range
1843    /// assertion is on a reachable path.
1844    #[kani::proof]
1845    #[kani::stub(h2, toy_h2)]
1846    #[kani::stub(crate::hash::domain_digest, toy_domain_digest)]
1847    #[kani::stub(crate::hash::hash, toy_hash)]
1848    #[kani::unwind(3)]
1849    fn merkle_builder_empty_tree_refuses() {
1850        let empty = Tree {
1851            entries: Vec::new(),
1852        };
1853        let (p, start, end): (u32, u32, u32) = (kani::any(), kani::any(), kani::any());
1854        assert!(build_tree_entry_proof(&empty, p).is_err());
1855        assert!(build_tree_entries_multi_proof(&empty, [p]).is_err());
1856        let range = build_tree_entries_range_proof(&empty, start, end);
1857        kani::cover!(range.is_err(), "range_refused");
1858        assert!(
1859            range.is_err(),
1860            "empty-Tree range proof must be refused (SPEC-MERKLE-OBJECTS §5.4)"
1861        );
1862    }
1863
1864    /// Canary (§6 "leaf tampered"): the checker must find a forged chunk
1865    /// hash that the verifier rejects under a genuine 2-chunk proof, i.e.
1866    /// falsify "any leaf verifies under a genuine proof".
1867    #[kani::proof]
1868    #[kani::stub(h2, toy_h2)]
1869    #[kani::stub(crate::hash::domain_digest, toy_domain_digest)]
1870    #[kani::stub(crate::hash::hash, toy_hash)]
1871    // As above: `--cbmc-args --unwindset memcmp.0:33`.
1872    #[kani::unwind(6)]
1873    #[kani::should_panic]
1874    fn merkle_canary_tampered_leaf_verifies() {
1875        let (_cb, leaves) = chunked_fixture::<2>();
1876        let (root, siblings) = spec_proof(&leaves, 1);
1877        let id = wrap_id(ObjectKind::ChunkedBlob, &root);
1878        let proof = Proof {
1879            leaf_count: 3,
1880            siblings,
1881        };
1882        let forged: Hash = kani::any();
1883        assert!(verify_chunk(&id, &forged, 1, &proof).is_ok());
1884    }
1885}