Skip to main content

mkit_core/
verify.rs

1//! Partial-disclosure verification: prove and verify that a single path,
2//! chunk, or byte range belongs to a commit id, with proof bytes on the
3//! order of a few KiB regardless of repository size.
4//!
5//! ## Trust model
6//!
7//! A verified [`Disclosed`] proves that its `payload` bytes are exactly
8//! the content the given `commit_id` commits to at `path` — nothing more.
9//! It does **not** prove:
10//!
11//! * that the disclosed path is the *only* thing at that location (no
12//!   sibling-completeness claim — see the closure profile, issue #1015
13//!   PR 3, for that);
14//! * anything about who the signer *is* — `signer`/`signature_valid`
15//!   report whether the embedded Ed25519 signature verifies against the
16//!   embedded public key; binding that key to an identity or trust level
17//!   is application policy (trust roots), exactly as
18//!   [`crate::sign::verify_commit`] already documents.
19//!
20//! Every function in this module verifies against an **object id**, never
21//! a bare (pre-domain-wrap) inner root or an unauthenticated byte range —
22//! see [`crate::merkle`]'s module docs for why that distinction matters.
23//!
24//! Wire format, verification algorithm, and bounds are pinned in
25//! `docs/specs/SPEC-DISCLOSURE.md`; golden vectors live under
26//! `rust/tests/golden/disclosure/`. See issue #1015 (verifier kit) PR 2.
27//!
28//! ## Layering
29//!
30//! [`verify_object_id`], [`verify_path`], [`verify_chunk_with_meta`],
31//! [`verify_blob_slice`], and [`verify_blob_len_proof`] are the small,
32//! independently-useful primitives (each corresponds to one hop of the
33//! authentication chain: commit → tree steps → leaf → chunk/slice).
34//! [`verify_disclosure`] decodes a self-contained wire bundle (§ below)
35//! and composes them into one call. [`build_disclosure_from`] is the
36//! producer side, generic over any verifying [`crate::store::ObjectSource`]
37//! (the on-disk store, an in-memory overlay, or a server-side index or
38//! object CAS); [`build_disclosure`] is its thin wrapper over
39//! [`crate::store::ObjectStore`]. The builder's source trait is distinct
40//! from this module's [`ObjectSource`], the non-verifying
41//! `fetch(&mut self)` trait the closure walker reads through. Full
42//! disclosure — every reachable object against a commit id — is
43//! [`verify_closure`], [`verify_closure_streaming`],
44//! [`verify_closure_packs`], and [`verify_closure_manifest`], with
45//! [`verify_closure_store`] and [`export_closure`] as native store-backed
46//! operations (issue #1015 PR 3).
47//!
48//! Everything here, the builder included, compiles with
49//! `--no-default-features` and targets `wasm32-unknown-unknown`; no
50//! separate feature gate is needed since `mkit-core` itself is a `std`
51//! crate (see the crate root docs).
52//!
53//! ## Wire bundle (`verify_disclosure` / `build_disclosure`)
54//!
55//! ```text
56//! magic   = "MKDP"            (4 raw bytes)
57//! version: u8 = 2
58//! commit_id: [u8; 32]
59//! commit_bytes: Vec<u8>        <= 4 MiB
60//! steps: Vec<Step>             <= MAX_TREE_DEPTH (128), root first
61//!   Step { name: Vec<u8> (1..=255), mode: u8, child_id: [u8; 32],
62//!          inner_root: [u8; 32], position: u32, proof: Proof (max_items = 1) }
63//! payload_kind: u8
64//!   0 Object { bytes: Vec<u8> <= MAX_RAW_OBJECT_SIZE }
65//!   1 Chunk  { total_size: u64, chunk_size: u32, index: u32,
66//!              inner_root: [u8; 32], proof: Proof (max_items = 2),
67//!              bytes: Vec<u8> }
68//!   2 Range  { chunk: Option<ChunkHdr>, offset_in_blob: u64, len: u64,
69//!              slice: Vec<u8>, chunk_len_proofs: Vec<LenProof> }
70//!     ChunkHdr { total_size: u64, chunk_size: u32, index: u32,
71//!                inner_root: [u8; 32], chunk_id: [u8; 32],
72//!                proof: Proof (max_items = 2) }
73//!     LenProof { index: u32, chunk_id: [u8; 32],
74//!                proof: Proof (max_items = 1), slice: Vec<u8> }
75//! ```
76//!
77//! All integers are big-endian, every length is an LEB128 varint, arrays
78//! are raw — `commonware-codec` conventions (SPEC-CONVENTIONS §3;
79//! `crate::transfer`'s packlist node is the existing precedent for this
80//! house style). The whole bundle is capped at [`MAX_BUNDLE_BYTES`];
81//! trailing bytes are rejected.
82//!
83//! `ChunkHdr.chunk_id` is not literal to issue #1015's original design
84//! note, which omitted it: without an explicit chunk id, a verifier
85//! reading a byte-range bundle would have no value to check the Bao slice
86//! against before folding it into the enclosing `ChunkedBlob`'s multi-proof
87//! (Bao's `SliceDecoder` always requires the expected root up front — it
88//! cannot recover an unknown one from the slice, by construction). Adding
89//! this field closes that gap; it is exactly the value
90//! [`verify_chunk_with_meta`]'s multi-proof authenticates, so a forged
91//! `chunk_id` fails to fold to the enclosing `ChunkedBlob`'s id.
92
93use std::collections::BTreeMap;
94use std::io::Read as _;
95
96use bytes::{Buf, BufMut};
97use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadExt, ReadRangeExt, Write};
98
99use crate::hash::{Hash, hash};
100use crate::merkle::{self, MerkleError, ObjectKind, Proof};
101use crate::object::{EntryMode, MAGIC, MkitError, Object, ObjectType, SCHEMA_VERSION, TreeEntry};
102use crate::sign::{verify_commit, verify_remix};
103use crate::store::MAX_TREE_DEPTH;
104
105/// Hard cap on a whole encoded disclosure bundle, checked before any
106/// decode work happens. `mkit-wasm` additionally caps decoded objects at
107/// 16 MiB; this 64 MiB ceiling is the wasm32-agnostic bound this crate
108/// itself enforces.
109pub const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024;
110
111/// Fixed 4-byte magic at the start of every disclosure bundle.
112const BUNDLE_MAGIC: &[u8; 4] = b"MKDP";
113/// Current (and only) bundle version byte. v1 is rejected with
114/// [`VerifyError::UnsupportedBundleVersion`]; there is no compatibility
115/// decoder (SPEC-DISCLOSURE v2, issue #1024).
116const BUNDLE_VERSION: u8 = 2;
117/// Cap on `commit_bytes` — comfortably above any real commit (~250 B) or
118/// remix (larger, due to `sources`), far below `MAX_RAW_OBJECT_SIZE`.
119const MAX_COMMIT_BYTES: usize = 4 * 1024 * 1024;
120/// Cap on a single length-proof's Bao slice. Proving 10 bytes needs one
121/// 1 KiB Bao leaf chunk plus at most `MAX_LEVELS` (32) 64-byte parent
122/// hashes plus an 8-byte header — comfortably under 4 KiB; 8 KiB leaves
123/// headroom without opening a real allocation hazard.
124const MAX_LEN_PROOF_SLICE_BYTES: usize = 8 * 1024;
125
126// ---------------------------------------------------------------------------
127// Errors
128// ---------------------------------------------------------------------------
129
130/// Errors verifying (or, natively, building) a disclosure.
131#[derive(Debug, thiserror::Error)]
132pub enum VerifyError {
133    /// The encoded bundle exceeds [`MAX_BUNDLE_BYTES`].
134    #[error("disclosure bundle exceeds the {MAX_BUNDLE_BYTES} byte cap")]
135    BundleTooLarge,
136    /// The bundle is shorter than the fixed magic+version header, or the
137    /// magic bytes are not `"MKDP"`.
138    #[error("disclosure bundle magic is not \"MKDP\"")]
139    BadMagic,
140    /// The bundle's version byte is not `2`. A version byte of `1` is
141    /// this error with payload `1`; there is no v1 compatibility decoder.
142    #[error("disclosure bundle version {0} is not supported (v2 only)")]
143    UnsupportedBundleVersion(u8),
144    /// A step's or chunk header's declared `inner_root` does not wrap to
145    /// the expected object id (`Tree` domain for steps, `ChunkedBlob` domain
146    /// for chunk headers). Checked before the field is used for anything.
147    #[error("declared inner root does not wrap to the expected object id")]
148    InnerRootMismatch {
149        /// The parent `Tree` id (steps) or `ChunkedBlob` leaf id (chunk headers).
150        expected: Hash,
151        /// The prover-supplied inner root that failed the wrap check.
152        inner_root: Hash,
153    },
154    /// The proof folds to a different root than the bundle declared,
155    /// even though the declared root wraps to the expected id (or vice
156    /// versa). Both must agree.
157    #[error("proof fold does not equal the declared inner root")]
158    InnerRootFoldMismatch,
159    /// The codec body is malformed: truncated, an over-cap length, an
160    /// invalid varint, or trailing bytes after the declared body.
161    #[error("disclosure bundle body is malformed (bad codec payload or trailing bytes)")]
162    Malformed,
163    /// The bundle's embedded `commit_id` field does not match the id the
164    /// caller asked to verify against.
165    #[error("disclosure bundle's embedded commit_id does not match the requested commit id")]
166    CommitIdMismatch,
167    /// `BLAKE3(commit_bytes)` does not equal the commit id being verified.
168    #[error("commit_bytes do not hash to the commit id being verified")]
169    CommitBytesHashMismatch,
170    /// `commit_bytes` decoded to an object type other than `Commit` or
171    /// `Remix` — the only two kinds that carry a `tree_hash` a path can
172    /// be walked from.
173    #[error("commit_bytes decode to a {0:?}, which is not a Commit or Remix")]
174    NotACommitOrRemix(ObjectType),
175    /// More steps than [`crate::store::MAX_TREE_DEPTH`] were supplied.
176    #[error("{0} steps exceeds MAX_TREE_DEPTH ({MAX_TREE_DEPTH})")]
177    TooManySteps(usize),
178    /// The entry name at the given step index fails SPEC-OBJECTS §4.1
179    /// name validation ([`TreeEntry::validate_name`]).
180    #[error("step {0}'s entry name fails SPEC-OBJECTS §4.1 validation")]
181    InvalidEntryName(usize),
182    /// A non-final step's mode is not [`EntryMode::Tree`] — every step
183    /// but the last MUST descend into a directory.
184    #[error("step {0} is not the final step but its mode is not Tree")]
185    NonFinalStepNotTree(usize),
186    /// A step's, or the payload's, merkle inclusion proof failed to
187    /// verify — wraps every [`MerkleError`] variant (out-of-range
188    /// position, unaligned proof, or a folded value that does not match
189    /// the expected id).
190    #[error("merkle proof verification failed: {0}")]
191    Merkle(#[from] MerkleError),
192    /// The bundle's `payload_kind` byte is not one of the three defined
193    /// kinds (`0` Object, `1` Chunk, `2` Range).
194    #[error("payload_kind byte {0:#04x} is not a recognized disclosure payload kind")]
195    InvalidPayloadKind(u8),
196    /// An `Object` payload's `BLAKE3`/merkle id does not equal the
197    /// authenticated leaf id.
198    #[error("disclosed Object payload's id does not equal the authenticated leaf id")]
199    PayloadIdMismatch,
200    /// A chunk `index` (or a length-proof's `index`) is out of range for
201    /// the `ChunkedBlob`'s proven `leaf_count`.
202    #[error("chunk index {index} is out of range for a {leaf_count}-leaf ChunkedBlob proof")]
203    ChunkIndexOutOfRange {
204        /// The out-of-range chunk index.
205        index: u32,
206        /// The proof's claimed `leaf_count` (chunk count + 1, for the
207        /// metadata leaf).
208        leaf_count: u32,
209    },
210    /// A proof's implied chunk count (`leaf_count - 1`) exceeds
211    /// `serialize::MAX_CHUNKS` (crate-private, not linkable from here).
212    #[error("proof leaf_count implies more chunks than MAX_CHUNKS allows")]
213    TooManyChunks,
214    /// A `Range` payload's `len` is zero — there is nothing to disclose
215    /// or verify.
216    #[error("byte range length is zero")]
217    ZeroLengthRange,
218    /// `chunk_len_proofs` was non-empty but did not cover exactly
219    /// `0..index` with no gaps or duplicates — an incomplete or malformed
220    /// set is a typed error, never silently treated as "absent".
221    #[error("chunk_len_proofs does not cover exactly indices 0..{0} with no gaps or duplicates")]
222    IncompleteLengthProofSet(u32),
223    /// `chunk_len_proofs` was non-empty on a `Range` payload that has no
224    /// chunk before it to describe: either the leaf is a plain `Blob` (no
225    /// chunk at all), or the disclosed chunk is index 0 (nothing precedes
226    /// the first chunk).
227    #[error("chunk_len_proofs is only meaningful for a ChunkedBlob range at chunk index > 0")]
228    UnexpectedLengthProofs,
229    /// A Bao slice failed to verify against the expected root/offset/len.
230    #[error("Bao slice verification failed: {0}")]
231    Bao(String),
232    /// A Bao slice decoded to fewer bytes than the claimed `len`.
233    #[error("Bao slice yielded {got} bytes, expected {expected}")]
234    ShortSlice {
235        /// The requested length.
236        expected: u64,
237        /// The number of bytes the slice actually decoded to.
238        got: usize,
239    },
240    /// [`verify_blob_len_proof`]'s bytes 0..6 are not a valid v1 Blob
241    /// prologue (wrong object type, magic, or schema version byte).
242    #[error("blob length-proof bytes are not a valid v1 Blob prologue")]
243    InvalidBlobPrologue,
244    /// An offset/length computation would overflow `u64`.
245    #[error("offset/length computation overflowed u64")]
246    OffsetOverflow,
247    /// A byte range's canonical decode/serialize error.
248    #[error(transparent)]
249    Decode(#[from] MkitError),
250    /// [`Selector::Chunk`]/[`Selector::Range`] was used against a leaf
251    /// whose object type does not support that selector (e.g. `Chunk` on
252    /// a plain `Blob`, or either on a `Tree`).
253    #[error("selector does not match the disclosed leaf's object type")]
254    SelectorLeafMismatch,
255    /// [`build_disclosure`]: the requested range crosses a `ChunkedBlob`
256    /// chunk boundary — unsupported in this (v1) profile.
257    #[error("byte range crosses a chunk boundary, which this profile does not support")]
258    RangeCrossesChunkBoundary,
259    /// [`build_disclosure`]: the requested range is out of bounds for the
260    /// leaf's actual content length.
261    #[error("byte range is out of bounds for the leaf's content length")]
262    RangeOutOfBounds,
263    /// [`build_disclosure`]: a path component was not found in its
264    /// parent tree.
265    #[error("path component {0} was not found in its parent tree")]
266    PathNotFound(usize),
267    /// [`build_disclosure`]: the path continues past a non-`Tree` entry,
268    /// or the requested selector needs a leaf that isn't reached because
269    /// an intermediate component isn't a directory.
270    #[error("path continues past a non-Tree entry")]
271    PathThroughNonTree,
272    /// [`build_disclosure`]: the underlying object store returned an
273    /// error.
274    #[error(transparent)]
275    Store(#[from] crate::store::StoreError),
276    /// The supplied object set exceeds [`crate::pack::MAX_ENTRIES`].
277    #[error("closure object set exceeds the pack MAX_ENTRIES cap")]
278    TooManyClosureObjects,
279    /// The closure root deserialized but is not a `Commit`, `Remix`, or `Tag`.
280    #[error("closure root is a {0:?}, which is not a Commit, Remix, or Tag")]
281    ClosureRootWrongType(ObjectType),
282    /// A pack in the closure profile contained a delta or compressed entry.
283    #[error(
284        "closure pack {pack_index} entry {entry_index} is not a raw (0x00) entry (closure profile is raw-only)"
285    )]
286    ClosureProfileViolation {
287        /// Index of the offending pack in the caller-supplied list.
288        pack_index: usize,
289        /// Index of the offending entry inside that pack.
290        entry_index: usize,
291    },
292    /// The encoded closure manifest is shorter than the magic+version
293    /// header, or the magic bytes are not `"MKCL"`.
294    #[error("closure manifest magic is not \"MKCL\"")]
295    ClosureManifestBadMagic,
296    /// The manifest's version byte is not `1`.
297    #[error("closure manifest version {0} is not supported (v1 only)")]
298    ClosureManifestUnsupportedVersion(u8),
299    /// The manifest body is malformed: truncated, an over-cap pack
300    /// list, an unknown mode byte, or trailing bytes.
301    #[error(
302        "closure manifest body is malformed (bad codec payload, unknown mode, or trailing bytes)"
303    )]
304    ClosureManifestMalformed,
305    /// The number of packs supplied does not match the manifest.
306    #[error("closure manifest lists {expected} packs but {got} were supplied")]
307    ClosurePackCountMismatch {
308        /// Pack count recorded in the manifest.
309        expected: usize,
310        /// Number of pack buffers the caller handed the verifier.
311        got: usize,
312    },
313    /// `pack_key(packs[index])` does not equal the manifest entry.
314    #[error("pack {index} hash does not match the closure manifest")]
315    ClosurePackKeyMismatch {
316        /// Index of the mismatched pack.
317        index: usize,
318    },
319    /// The manifest's `root` does not equal the caller-supplied trusted root.
320    /// The manifest is a locator, never a trust anchor.
321    #[error("closure manifest root does not match the caller's trusted root")]
322    ClosureRootMismatch {
323        /// Root the caller asked to verify against.
324        expected: Hash,
325        /// Root the manifest named.
326        got: Hash,
327    },
328    /// A packfile framing/decode error while iterating a closure pack.
329    #[error(transparent)]
330    Pack(#[from] crate::pack::PackError),
331}
332
333impl From<CodecError> for VerifyError {
334    fn from(_: CodecError) -> Self {
335        // Mirrors `merkle::MerkleError`'s `From<CodecError>`: every codec
336        // failure (truncation, an over-cap length, a bad varint, trailing
337        // bytes) collapses to one "malformed" outcome — the caller never
338        // needs to distinguish which codec primitive tripped, only that
339        // decode failed rather than that verification failed.
340        Self::Malformed
341    }
342}
343
344mod closure;
345pub use closure::{
346    ClosureExport, ClosureManifest, ClosureReport, MAX_CLOSURE_PACKS, ObjectSource, export_closure,
347    verify_closure, verify_closure_manifest, verify_closure_packs, verify_closure_store,
348    verify_closure_streaming,
349};
350mod push;
351pub use push::{PushReport, verify_push};
352/// Exact proof wire-size planning from canonical object metadata.
353pub mod proof_size;
354pub mod span;
355
356// ---------------------------------------------------------------------------
357// Public types
358// ---------------------------------------------------------------------------
359
360/// One authenticated hop of a path: the entry `name`/`mode` proven at
361/// `position` under its parent (a commit's `tree_hash`, or the previous
362/// step's `child_id`), via a single-leaf [`Proof`].
363///
364/// This is both the in-memory type [`verify_path`] takes and the wire
365/// representation inside a disclosure bundle (§ module docs) — there is
366/// no separate internal wire struct to keep in sync.
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct Step {
369    /// The entry's name (1..=255 bytes; SPEC-OBJECTS §4.1 rules apply).
370    pub name: Vec<u8>,
371    /// The entry's mode.
372    pub mode: EntryMode,
373    /// The entry's child object id.
374    pub child_id: Hash,
375    /// Bare BMT root of the **parent** Tree this step's proof is verified
376    /// against (the tree whose id is the commit's `tree_hash` for step 0,
377    /// or the previous step's `child_id`). Wrap-checked against that id
378    /// before use; never a second trust anchor.
379    pub inner_root: Hash,
380    /// The entry's BMT position (= index) within its parent `Tree`.
381    pub position: u32,
382    /// Single-leaf inclusion proof (`max_items = 1`) that `(name, mode,
383    /// child_id)` is the entry at `position` in the parent tree.
384    pub proof: Proof,
385}
386
387/// The result of walking and verifying a path from a commit's
388/// `tree_hash` down to a leaf.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct PathVerified {
391    /// The commit's (or remix's) root tree id.
392    pub tree_hash: Hash,
393    /// The authenticated path, root first: each entry's `(name, mode)` —
394    /// never a caller-claimed path string. Callers compare this against
395    /// what they requested; this module never trusts a claimed path.
396    pub path: Vec<(Vec<u8>, EntryMode)>,
397    /// The id of the object at the end of the path (`tree_hash` itself
398    /// when `path` is empty).
399    pub leaf_id: Hash,
400    /// The commit/remix's embedded Ed25519 public key.
401    pub signer: [u8; 32],
402    /// Whether the commit/remix's embedded signature verifies against
403    /// `signer`. Does **not** say anything about who `signer` belongs to
404    /// — that is application policy (see the module docs).
405    pub signature_valid: bool,
406}
407
408/// A verified partial disclosure: `payload` is exactly the content
409/// `commit_id` commits to at `path`, and nothing more. See the module
410/// docs for the precise trust model.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct Disclosed {
413    /// The commit id the disclosure was verified against.
414    pub commit_id: Hash,
415    /// The commit's (or remix's) root tree id.
416    pub tree_hash: Hash,
417    /// The authenticated path, root first (see [`PathVerified::path`]).
418    pub path: Vec<(Vec<u8>, EntryMode)>,
419    /// The id of the disclosed leaf object.
420    pub leaf_id: Hash,
421    /// The disclosed content.
422    pub payload: DisclosedPayload,
423    /// The commit/remix's embedded Ed25519 public key.
424    pub signer: [u8; 32],
425    /// Whether the commit/remix's embedded signature verifies. See
426    /// [`PathVerified::signature_valid`].
427    pub signature_valid: bool,
428    /// Authenticated bare BMT inner root of each step's parent Tree,
429    /// root first. Empty when `steps` is empty (root-tree disclosure).
430    /// Each value has been wrap-checked against the parent id and
431    /// cross-checked against the proof fold.
432    pub step_inner_roots: Vec<Hash>,
433    /// Authenticated bare BMT inner root of the leaf `ChunkedBlob`, when
434    /// the payload is `Chunk` or a `Range` over a chunk. `None` for an
435    /// `Object` payload or a `Range` over a plain `Blob`.
436    pub chunk_inner_root: Option<Hash>,
437}
438
439/// The disclosed content of a [`Disclosed`] result.
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub enum DisclosedPayload {
442    /// The leaf's full canonical object bytes; `hash`/`id_from_object` of
443    /// `bytes` equals `Disclosed::leaf_id`.
444    Object {
445        /// Canonical object bytes.
446        bytes: Vec<u8>,
447    },
448    /// One whole chunk of a `ChunkedBlob` leaf, plus its authenticated
449    /// manifest metadata.
450    Chunk {
451        /// The `ChunkedBlob`'s authenticated total content size.
452        total_size: u64,
453        /// The `ChunkedBlob`'s authenticated chunk-size marker (`0` =
454        /// content-defined chunking).
455        chunk_size: u32,
456        /// The chunk's index within the manifest.
457        index: u32,
458        /// The chunk's canonical `Blob` bytes.
459        bytes: Vec<u8>,
460    },
461    /// A byte range of a `Blob`, or of one chunk of a `ChunkedBlob`.
462    Range {
463        /// The id of the object the range was sliced from: the leaf
464        /// itself when it is a plain `Blob`, or the containing chunk's
465        /// id when the leaf is a `ChunkedBlob`.
466        blob_id: Hash,
467        /// `Some((index, total_size, chunk_size))` when the leaf is a
468        /// `ChunkedBlob` (the authenticated manifest metadata for the
469        /// containing chunk); `None` when the leaf is a plain `Blob`.
470        chunk: Option<(u32, u64, u32)>,
471        /// The range's offset within `blob_id`'s content (the chunk's
472        /// content when `chunk` is `Some`, the blob's content otherwise).
473        offset_in_blob: u64,
474        /// The range's offset within the *whole* disclosed file, when
475        /// provable: `Some(offset_in_blob)` for a plain `Blob` (nothing
476        /// further to prove) or for a `ChunkedBlob` at `index == 0`
477        /// (nothing precedes the first chunk, so no proof set is needed —
478        /// and [`VerifyError::UnexpectedLengthProofs`] rejects a
479        /// non-empty one there rather than silently ignoring it); for a
480        /// `ChunkedBlob` at `index > 0`, `Some(sum of every preceding
481        /// chunk's authenticated length + offset_in_blob)` when a complete
482        /// `0..index` length-proof set was supplied, else `None` (not
483        /// requested).
484        absolute_offset: Option<u64>,
485        /// The disclosed bytes.
486        bytes: Vec<u8>,
487    },
488}
489
490/// What [`build_disclosure`] discloses about the leaf its `path` reaches.
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub enum Selector {
493    /// Disclose the leaf's full canonical object bytes.
494    Object,
495    /// Disclose one whole chunk of a `ChunkedBlob` leaf, by index.
496    Chunk(u32),
497    /// Disclose a byte range of a `Blob`/`ChunkedBlob` leaf's content.
498    Range {
499        /// Start offset within the leaf's content.
500        offset: u64,
501        /// Range length in bytes (MUST be non-zero).
502        len: u64,
503        /// When the leaf is a `ChunkedBlob`, also emit length proofs for
504        /// every chunk before the one containing `offset`, so a verifier
505        /// can compute an absolute file offset.
506        with_offsets: bool,
507    },
508}
509
510// ---------------------------------------------------------------------------
511// Primitives
512// ---------------------------------------------------------------------------
513
514/// Deserialize `bytes` and check that its content-address (BLAKE3 of the
515/// bytes, or the merkle BMT root for a `Tree`/`ChunkedBlob`) equals
516/// `expected`.
517///
518/// # Errors
519///
520/// [`VerifyError::Decode`] if `bytes` does not decode; otherwise
521/// [`VerifyError::PayloadIdMismatch`] if the derived id disagrees with
522/// `expected`.
523/// Wrap-check-first: `domain_digest(TYPE_DOMAIN, inner_root) == expected_id`.
524/// The declared field is never a second trust anchor.
525fn check_inner_root_wrap(
526    kind: ObjectKind,
527    expected_id: &Hash,
528    inner_root: &Hash,
529) -> Result<(), VerifyError> {
530    if merkle::wrap_id(kind, inner_root) == *expected_id {
531        Ok(())
532    } else {
533        Err(VerifyError::InnerRootMismatch {
534            expected: *expected_id,
535            inner_root: *inner_root,
536        })
537    }
538}
539
540/// Proof fold equals the declared inner root, then wrap of the fold
541/// equals `expected_id` (today's id check). Both must hold.
542fn check_inner_root_fold(
543    folded: Hash,
544    declared: &Hash,
545    expected_id: &Hash,
546    kind: ObjectKind,
547) -> Result<(), VerifyError> {
548    if folded != *declared {
549        return Err(VerifyError::InnerRootFoldMismatch);
550    }
551    if merkle::wrap_id(kind, &folded) == *expected_id {
552        Ok(())
553    } else {
554        Err(VerifyError::Merkle(MerkleError::VerificationFailed))
555    }
556}
557
558pub fn verify_object_id(bytes: &[u8], expected: &Hash) -> Result<Object, VerifyError> {
559    let obj = crate::serialize::deserialize(bytes)?;
560    let got = crate::object::id_from_object(&obj, bytes);
561    if &got == expected {
562        Ok(obj)
563    } else {
564        Err(VerifyError::PayloadIdMismatch)
565    }
566}
567
568/// Verify a path of [`Step`]s from a commit/remix's `tree_hash` down to a
569/// leaf id, checking each step's proof against the previous step's
570/// `child_id` (the first against `tree_hash` itself).
571///
572/// `commit_bytes` MUST hash to `commit_id` and decode to a `Commit` or
573/// `Remix` — every other object kind has no `tree_hash` to walk from.
574/// Every non-final step MUST have `mode == Tree`. The returned `path` is
575/// the authenticated `(name, mode)` sequence; callers compare it against
576/// whatever path string they requested — this function never trusts one.
577///
578/// # Errors
579///
580/// See [`VerifyError`]'s variant docs for the specific failure reported.
581pub fn verify_path(
582    commit_id: &Hash,
583    commit_bytes: &[u8],
584    steps: &[Step],
585) -> Result<PathVerified, VerifyError> {
586    if hash(commit_bytes) != *commit_id {
587        return Err(VerifyError::CommitBytesHashMismatch);
588    }
589    if steps.len() > MAX_TREE_DEPTH {
590        return Err(VerifyError::TooManySteps(steps.len()));
591    }
592    let obj = crate::serialize::deserialize(commit_bytes)?;
593    let (tree_hash, signer, signature_valid) = match &obj {
594        Object::Commit(c) => (c.tree_hash, c.signer, verify_commit(c).is_ok()),
595        Object::Remix(r) => (r.tree_hash, r.signer, verify_remix(r).is_ok()),
596        other => return Err(VerifyError::NotACommitOrRemix(other.object_type())),
597    };
598    verify_path_with_context(
599        steps,
600        &CommitContext {
601            tree_hash,
602            signer,
603            signature_valid,
604        },
605    )
606}
607
608/// Commit authentication shared by all bundles in one MKDS container.
609#[derive(Clone, Copy)]
610pub(crate) struct CommitContext {
611    tree_hash: Hash,
612    signer: [u8; 32],
613    signature_valid: bool,
614}
615
616impl CommitContext {
617    pub(crate) fn from_disclosed(d: &Disclosed) -> Self {
618        Self {
619            tree_hash: d.tree_hash,
620            signer: d.signer,
621            signature_valid: d.signature_valid,
622        }
623    }
624}
625
626/// Reuse a commit that the MKDS anchor has authenticated, but still bind
627/// each inner bundle's own commit bytes to the trusted id.
628pub(crate) fn verify_path_reusing_context(
629    commit_id: &Hash,
630    commit_bytes: &[u8],
631    steps: &[Step],
632    context: &CommitContext,
633) -> Result<PathVerified, VerifyError> {
634    if hash(commit_bytes) != *commit_id {
635        return Err(VerifyError::CommitBytesHashMismatch);
636    }
637    verify_path_with_context(steps, context)
638}
639
640fn verify_path_with_context(
641    steps: &[Step],
642    context: &CommitContext,
643) -> Result<PathVerified, VerifyError> {
644    if steps.len() > MAX_TREE_DEPTH {
645        return Err(VerifyError::TooManySteps(steps.len()));
646    }
647
648    let mut expected_parent = context.tree_hash;
649    let mut leaf_id = context.tree_hash;
650    let mut path = Vec::with_capacity(steps.len());
651    let last = steps.len().wrapping_sub(1);
652    for (i, step) in steps.iter().enumerate() {
653        if !TreeEntry::validate_name(&step.name) {
654            return Err(VerifyError::InvalidEntryName(i));
655        }
656        if i != last && step.mode != EntryMode::Tree {
657            return Err(VerifyError::NonFinalStepNotTree(i));
658        }
659        let entry = TreeEntry {
660            name: step.name.clone(),
661            mode: step.mode,
662            object_hash: step.child_id,
663        };
664        check_inner_root_wrap(ObjectKind::Tree, &expected_parent, &step.inner_root)?;
665        let folded = step
666            .proof
667            .reconstruct_element_root(&merkle::tree_entry_leaf(&entry), step.position)?;
668        check_inner_root_fold(folded, &step.inner_root, &expected_parent, ObjectKind::Tree)?;
669        path.push((step.name.clone(), step.mode));
670        expected_parent = step.child_id;
671        leaf_id = step.child_id;
672    }
673
674    Ok(PathVerified {
675        tree_hash: context.tree_hash,
676        path,
677        leaf_id,
678        signer: context.signer,
679        signature_valid: context.signature_valid,
680    })
681}
682
683/// Verify that a `ChunkedBlob`'s claimed `total_size`/`chunk_size` and
684/// the chunk `chunk_hash` at `index` both belong to the `ChunkedBlob`
685/// whose id is `chunked_id`, via one multi-proof over BMT positions `{0,
686/// index + 1}`.
687///
688/// The metadata leaf (position 0) is computed here from `total_size`/
689/// `chunk_size` — never accepted as an externally supplied leaf digest —
690/// so a forged pair fails to fold to `chunked_id`.
691///
692/// # Errors
693///
694/// [`VerifyError::ChunkIndexOutOfRange`]/[`VerifyError::TooManyChunks`]
695/// for an out-of-bound `index` or an implausible `proof.leaf_count`;
696/// otherwise [`VerifyError::Merkle`] if the multi-proof does not verify.
697pub fn verify_chunk_with_meta(
698    chunked_id: &Hash,
699    total_size: u64,
700    chunk_size: u32,
701    chunk_hash: &Hash,
702    index: u32,
703    proof: &Proof,
704) -> Result<(), VerifyError> {
705    let chunk_count = proof
706        .leaf_count
707        .checked_sub(1)
708        .ok_or(VerifyError::ChunkIndexOutOfRange {
709            index,
710            leaf_count: proof.leaf_count,
711        })?;
712    if chunk_count > crate::serialize::MAX_CHUNKS {
713        return Err(VerifyError::TooManyChunks);
714    }
715    let position = index
716        .checked_add(1)
717        .filter(|&p| p < proof.leaf_count)
718        .ok_or(VerifyError::ChunkIndexOutOfRange {
719            index,
720            leaf_count: proof.leaf_count,
721        })?;
722    merkle::verify_chunk_with_meta_leaf(
723        chunked_id, total_size, chunk_size, chunk_hash, position, proof,
724    )
725    .map_err(VerifyError::from)
726}
727
728/// Like [`verify_chunk_with_meta`], plus the two SPEC-DISCLOSURE v2
729/// inner-root rules: wrap-check-first against `chunked_id`, then the
730/// multi-proof fold equals `inner_root`.
731fn verify_chunk_with_declared_root(
732    chunked_id: &Hash,
733    inner_root: &Hash,
734    total_size: u64,
735    chunk_size: u32,
736    chunk_hash: &Hash,
737    index: u32,
738    proof: &Proof,
739) -> Result<(), VerifyError> {
740    let chunk_count = proof
741        .leaf_count
742        .checked_sub(1)
743        .ok_or(VerifyError::ChunkIndexOutOfRange {
744            index,
745            leaf_count: proof.leaf_count,
746        })?;
747    if chunk_count > crate::serialize::MAX_CHUNKS {
748        return Err(VerifyError::TooManyChunks);
749    }
750    let position = index
751        .checked_add(1)
752        .filter(|&p| p < proof.leaf_count)
753        .ok_or(VerifyError::ChunkIndexOutOfRange {
754            index,
755            leaf_count: proof.leaf_count,
756        })?;
757    check_inner_root_wrap(ObjectKind::ChunkedBlob, chunked_id, inner_root)?;
758    let meta_leaf = merkle::chunked_meta_leaf_raw(total_size, chunk_size);
759    let folded = proof.reconstruct_multi_root(&[(meta_leaf, 0u32), (*chunk_hash, position)])?;
760    check_inner_root_fold(folded, inner_root, chunked_id, ObjectKind::ChunkedBlob)
761}
762
763/// Verify a Bao slice against `root`/`bao_offset`/`len`, returning the
764/// decoded bytes. Shared by [`verify_blob_slice`] (content offset + 10)
765/// and [`verify_blob_len_proof`] (raw offset 0, len 10 — the canonical
766/// prologue itself).
767fn bao_verify_slice(
768    root: &Hash,
769    bao_offset: u64,
770    len: u64,
771    slice: &[u8],
772) -> Result<Vec<u8>, VerifyError> {
773    let h: bao::Hash = (*root).into();
774    let mut decoder =
775        bao::decode::SliceDecoder::new(std::io::Cursor::new(slice), &h, bao_offset, len);
776    let mut out = Vec::new();
777    decoder
778        .read_to_end(&mut out)
779        .map_err(|e| VerifyError::Bao(e.to_string()))?;
780    if out.len() as u64 != len {
781        return Err(VerifyError::ShortSlice {
782            expected: len,
783            got: out.len(),
784        });
785    }
786    Ok(out)
787}
788
789/// Verify a Bao slice proving `len` content bytes at `content_offset`
790/// belong to the object (`Blob`, or one `ChunkedBlob` chunk) whose
791/// canonical-bytes BLAKE3 is `blob_id`. `slice` is expected in the format
792/// `bao::encode::SliceExtractor` produces over the object's **canonical**
793/// bytes (prologue ‖ `le32(len)` ‖ data) — so a Bao-authenticated content
794/// offset `o` maps to Bao offset `o + 10`, and the returned root equals
795/// the object's own id (SPEC-OBJECTS §3).
796///
797/// # Errors
798///
799/// [`VerifyError::ZeroLengthRange`] if `len == 0`;
800/// [`VerifyError::OffsetOverflow`] if `content_offset + 10` overflows
801/// `u64`; [`VerifyError::Bao`]/[`VerifyError::ShortSlice`] if the slice
802/// does not verify or yields fewer bytes than `len`.
803pub fn verify_blob_slice(
804    blob_id: &Hash,
805    content_offset: u64,
806    len: u64,
807    slice: &[u8],
808) -> Result<Vec<u8>, VerifyError> {
809    if len == 0 {
810        return Err(VerifyError::ZeroLengthRange);
811    }
812    let bao_offset = content_offset
813        .checked_add(10)
814        .ok_or(VerifyError::OffsetOverflow)?;
815    bao_verify_slice(blob_id, bao_offset, len, slice)
816}
817
818/// Verify a Bao slice over canonical bytes `0..10` of the object whose
819/// id is `blob_id`, and return the `le32` length field at bytes `6..10`
820/// — the object's own declared content length, authenticated by the
821/// first Bao leaf chunk (Bao's *encoding header* is not what is trusted
822/// here; the canonical prologue bytes themselves are).
823///
824/// # Errors
825///
826/// [`VerifyError::InvalidBlobPrologue`] if the authenticated bytes are
827/// not a valid v1 Blob prologue; otherwise the same Bao-decode errors as
828/// [`verify_blob_slice`].
829///
830/// # Panics
831///
832/// Never in practice: `bao_verify_slice` (crate-private) guarantees
833/// exactly 10 returned bytes for a requested `len` of 10, so the
834/// `bytes[6..10]` slice always converts to `[u8; 4]`.
835pub fn verify_blob_len_proof(blob_id: &Hash, slice: &[u8]) -> Result<u32, VerifyError> {
836    let bytes = bao_verify_slice(blob_id, 0, 10, slice)?;
837    if bytes[0] != ObjectType::Blob as u8 || bytes[1..5] != MAGIC || bytes[5] != SCHEMA_VERSION {
838        return Err(VerifyError::InvalidBlobPrologue);
839    }
840    let len_bytes: [u8; 4] = bytes[6..10]
841        .try_into()
842        .expect("bao_verify_slice guarantees exactly 10 bytes");
843    Ok(u32::from_le_bytes(len_bytes))
844}
845
846// ---------------------------------------------------------------------------
847// Bundle: decode + verify
848// ---------------------------------------------------------------------------
849
850/// Wire representation of the `ChunkHdr` sub-message (Range payload,
851/// chunked case). Kept private: callers see the authenticated
852/// [`DisclosedPayload::Range`] fields, never this wire shape directly.
853#[derive(Debug, Clone)]
854struct ChunkHdr {
855    total_size: u64,
856    chunk_size: u32,
857    index: u32,
858    inner_root: Hash,
859    chunk_id: Hash,
860    proof: Proof,
861}
862
863impl Write for ChunkHdr {
864    fn write(&self, writer: &mut impl BufMut) {
865        self.total_size.write(writer);
866        self.chunk_size.write(writer);
867        self.index.write(writer);
868        self.inner_root.write(writer);
869        self.chunk_id.write(writer);
870        self.proof.write(writer);
871    }
872}
873
874impl EncodeSize for ChunkHdr {
875    fn encode_size(&self) -> usize {
876        self.total_size.encode_size()
877            + self.chunk_size.encode_size()
878            + self.index.encode_size()
879            + self.inner_root.encode_size()
880            + self.chunk_id.encode_size()
881            + self.proof.encode_size()
882    }
883}
884
885impl Read for ChunkHdr {
886    type Cfg = ();
887
888    fn read_cfg(reader: &mut impl Buf, (): &Self::Cfg) -> Result<Self, CodecError> {
889        let total_size = u64::read(reader)?;
890        let chunk_size = u32::read(reader)?;
891        let index = u32::read(reader)?;
892        let inner_root = Hash::read(reader)?;
893        let chunk_id = Hash::read(reader)?;
894        let proof = Proof::read_cfg(reader, &2usize)?;
895        Ok(Self {
896            total_size,
897            chunk_size,
898            index,
899            inner_root,
900            chunk_id,
901            proof,
902        })
903    }
904}
905
906/// Wire representation of one `LenProof` entry (Range payload,
907/// `with_offsets`). Kept private for the same reason as [`ChunkHdr`].
908#[derive(Debug, Clone)]
909struct LenProof {
910    index: u32,
911    chunk_id: Hash,
912    proof: Proof,
913    slice: Vec<u8>,
914}
915
916impl Write for LenProof {
917    fn write(&self, writer: &mut impl BufMut) {
918        self.index.write(writer);
919        self.chunk_id.write(writer);
920        self.proof.write(writer);
921        self.slice.as_slice().write(writer);
922    }
923}
924
925impl EncodeSize for LenProof {
926    fn encode_size(&self) -> usize {
927        self.index.encode_size()
928            + self.chunk_id.encode_size()
929            + self.proof.encode_size()
930            + self.slice.as_slice().encode_size()
931    }
932}
933
934impl Read for LenProof {
935    type Cfg = ();
936
937    fn read_cfg(reader: &mut impl Buf, (): &Self::Cfg) -> Result<Self, CodecError> {
938        let index = u32::read(reader)?;
939        let chunk_id = Hash::read(reader)?;
940        let proof = Proof::read_cfg(reader, &1usize)?;
941        let slice = Vec::<u8>::read_range(reader, ..=MAX_LEN_PROOF_SLICE_BYTES)?;
942        Ok(Self {
943            index,
944            chunk_id,
945            proof,
946            slice,
947        })
948    }
949}
950
951impl Write for Step {
952    fn write(&self, writer: &mut impl BufMut) {
953        self.name.as_slice().write(writer);
954        (self.mode as u8).write(writer);
955        self.child_id.write(writer);
956        self.inner_root.write(writer);
957        self.position.write(writer);
958        self.proof.write(writer);
959    }
960}
961
962impl EncodeSize for Step {
963    fn encode_size(&self) -> usize {
964        self.name.as_slice().encode_size()
965            + 1
966            + self.child_id.encode_size()
967            + self.inner_root.encode_size()
968            + self.position.encode_size()
969            + self.proof.encode_size()
970    }
971}
972
973impl Read for Step {
974    type Cfg = ();
975
976    fn read_cfg(reader: &mut impl Buf, (): &Self::Cfg) -> Result<Self, CodecError> {
977        let name = Vec::<u8>::read_range(reader, 1..=255)?;
978        let mode_byte = u8::read(reader)?;
979        let mode = EntryMode::from_u8(mode_byte).map_err(|_| CodecError::InvalidEnum(mode_byte))?;
980        let child_id = Hash::read(reader)?;
981        let inner_root = Hash::read(reader)?;
982        let position = u32::read(reader)?;
983        let proof = Proof::read_cfg(reader, &1usize)?;
984        Ok(Self {
985            name,
986            mode,
987            child_id,
988            inner_root,
989            position,
990            proof,
991        })
992    }
993}
994
995/// Wire payload variants (`payload_kind` byte). Kept private: the public
996/// surface is [`DisclosedPayload`] (verified) and [`Selector`] (build
997/// request); this is only the on-wire shape between them.
998enum PayloadWire {
999    Object {
1000        bytes: Vec<u8>,
1001    },
1002    Chunk {
1003        total_size: u64,
1004        chunk_size: u32,
1005        index: u32,
1006        inner_root: Hash,
1007        proof: Proof,
1008        bytes: Vec<u8>,
1009    },
1010    Range {
1011        chunk: Option<ChunkHdr>,
1012        offset_in_blob: u64,
1013        len: u64,
1014        slice: Vec<u8>,
1015        chunk_len_proofs: Vec<LenProof>,
1016    },
1017}
1018
1019/// Decode a disclosure bundle's fixed header plus codec body. Returns the
1020/// bundle's own embedded `commit_id` (still to be checked against the
1021/// caller's expected id by [`verify_disclosure`]), `commit_bytes`,
1022/// `steps`, and the decoded payload.
1023fn decode_disclosure(bytes: &[u8]) -> Result<(Hash, Vec<u8>, Vec<Step>, PayloadWire), VerifyError> {
1024    if bytes.len() > MAX_BUNDLE_BYTES {
1025        return Err(VerifyError::BundleTooLarge);
1026    }
1027    if bytes.len() < 5 || bytes[..4] != *BUNDLE_MAGIC {
1028        return Err(VerifyError::BadMagic);
1029    }
1030    let version = bytes[4];
1031    if version != BUNDLE_VERSION {
1032        return Err(VerifyError::UnsupportedBundleVersion(version));
1033    }
1034    let mut r: &[u8] = &bytes[5..];
1035    let commit_id = Hash::read(&mut r)?;
1036    let commit_bytes = Vec::<u8>::read_range(&mut r, ..=MAX_COMMIT_BYTES)?;
1037    let steps = Vec::<Step>::read_range(&mut r, ..=MAX_TREE_DEPTH)?;
1038    let payload_kind = u8::read(&mut r)?;
1039    let payload = match payload_kind {
1040        0 => {
1041            let bytes = Vec::<u8>::read_range(&mut r, ..=crate::store::MAX_RAW_OBJECT_SIZE)?;
1042            PayloadWire::Object { bytes }
1043        }
1044        1 => {
1045            let total_size = u64::read(&mut r)?;
1046            let chunk_size = u32::read(&mut r)?;
1047            let index = u32::read(&mut r)?;
1048            let inner_root = Hash::read(&mut r)?;
1049            let proof = Proof::read_cfg(&mut r, &2usize)?;
1050            let bytes = Vec::<u8>::read_range(&mut r, ..=crate::store::MAX_RAW_OBJECT_SIZE)?;
1051            PayloadWire::Chunk {
1052                total_size,
1053                chunk_size,
1054                index,
1055                inner_root,
1056                proof,
1057                bytes,
1058            }
1059        }
1060        2 => {
1061            let chunk = Option::<ChunkHdr>::read(&mut r)?;
1062            let offset_in_blob = u64::read(&mut r)?;
1063            let len = u64::read(&mut r)?;
1064            let slice = Vec::<u8>::read_range(&mut r, ..=MAX_BUNDLE_BYTES)?;
1065            let chunk_len_proofs =
1066                Vec::<LenProof>::read_range(&mut r, ..=crate::serialize::MAX_CHUNKS as usize)?;
1067            PayloadWire::Range {
1068                chunk,
1069                offset_in_blob,
1070                len,
1071                slice,
1072                chunk_len_proofs,
1073            }
1074        }
1075        other => return Err(VerifyError::InvalidPayloadKind(other)),
1076    };
1077    if r.has_remaining() {
1078        return Err(VerifyError::Malformed);
1079    }
1080    Ok((commit_id, commit_bytes, steps, payload))
1081}
1082
1083/// Encode a disclosure bundle. The inverse of [`decode_disclosure`].
1084fn encode_disclosure(
1085    commit_id: &Hash,
1086    commit_bytes: &[u8],
1087    steps: &[Step],
1088    payload: &PayloadWire,
1089) -> Vec<u8> {
1090    let mut out = Vec::new();
1091    out.extend_from_slice(BUNDLE_MAGIC);
1092    out.push(BUNDLE_VERSION);
1093    commit_id.write(&mut out);
1094    commit_bytes.write(&mut out);
1095    steps.write(&mut out);
1096    match payload {
1097        PayloadWire::Object { bytes } => {
1098            out.push(0);
1099            bytes.as_slice().write(&mut out);
1100        }
1101        PayloadWire::Chunk {
1102            total_size,
1103            chunk_size,
1104            index,
1105            inner_root,
1106            proof,
1107            bytes,
1108        } => {
1109            out.push(1);
1110            total_size.write(&mut out);
1111            chunk_size.write(&mut out);
1112            index.write(&mut out);
1113            inner_root.write(&mut out);
1114            proof.write(&mut out);
1115            bytes.as_slice().write(&mut out);
1116        }
1117        PayloadWire::Range {
1118            chunk,
1119            offset_in_blob,
1120            len,
1121            slice,
1122            chunk_len_proofs,
1123        } => {
1124            out.push(2);
1125            chunk.write(&mut out);
1126            offset_in_blob.write(&mut out);
1127            len.write(&mut out);
1128            slice.as_slice().write(&mut out);
1129            chunk_len_proofs.write(&mut out);
1130        }
1131    }
1132    out
1133}
1134
1135/// Verify each preceding chunk's authenticated length and, if the set is
1136/// complete, sum them. See [`DisclosedPayload::Range::absolute_offset`]
1137/// for the precise semantics (including the `index == 0` special case).
1138///
1139/// # Errors
1140///
1141/// [`VerifyError::IncompleteLengthProofSet`] if `chunk_len_proofs` is
1142/// non-empty but does not cover exactly `0..index`; propagates
1143/// [`VerifyError::Merkle`]/Bao errors from an individual proof.
1144fn resolve_absolute_offset(
1145    chunked_id: &Hash,
1146    index: u32,
1147    chunk_len_proofs: &[LenProof],
1148    offset_in_blob: u64,
1149) -> Result<Option<u64>, VerifyError> {
1150    if index == 0 {
1151        // Nothing precedes the first chunk; the offset is already
1152        // absolute regardless of whether the caller bothered to ask. A
1153        // non-empty `chunk_len_proofs` here has no chunk before index 0 to
1154        // describe, so it is rejected the same way the plain-blob path
1155        // rejects one — never silently ignored.
1156        if !chunk_len_proofs.is_empty() {
1157            return Err(VerifyError::UnexpectedLengthProofs);
1158        }
1159        return Ok(Some(offset_in_blob));
1160    }
1161    if chunk_len_proofs.is_empty() {
1162        return Ok(None);
1163    }
1164    let mut by_index: BTreeMap<u32, u32> = BTreeMap::new();
1165    for lp in chunk_len_proofs {
1166        if lp.index >= index || by_index.contains_key(&lp.index) {
1167            return Err(VerifyError::IncompleteLengthProofSet(index));
1168        }
1169        merkle::verify_chunk(chunked_id, &lp.chunk_id, lp.index + 1, &lp.proof)?;
1170        let len_j = verify_blob_len_proof(&lp.chunk_id, &lp.slice)?;
1171        by_index.insert(lp.index, len_j);
1172    }
1173    if u32::try_from(by_index.len()).unwrap_or(u32::MAX) != index {
1174        // Distinct, all < index, but fewer than `index` of them: a gap.
1175        return Err(VerifyError::IncompleteLengthProofSet(index));
1176    }
1177    let sum: u64 = by_index.values().map(|&l| u64::from(l)).sum();
1178    let absolute = sum
1179        .checked_add(offset_in_blob)
1180        .ok_or(VerifyError::OffsetOverflow)?;
1181    Ok(Some(absolute))
1182}
1183
1184fn compose_payload(leaf_id: Hash, payload: PayloadWire) -> Result<DisclosedPayload, VerifyError> {
1185    match payload {
1186        PayloadWire::Object { bytes } => {
1187            verify_object_id(&bytes, &leaf_id)?;
1188            Ok(DisclosedPayload::Object { bytes })
1189        }
1190        PayloadWire::Chunk {
1191            total_size,
1192            chunk_size,
1193            index,
1194            inner_root,
1195            proof,
1196            bytes,
1197        } => {
1198            let chunk_hash = hash(&bytes);
1199            verify_chunk_with_declared_root(
1200                &leaf_id,
1201                &inner_root,
1202                total_size,
1203                chunk_size,
1204                &chunk_hash,
1205                index,
1206                &proof,
1207            )?;
1208            Ok(DisclosedPayload::Chunk {
1209                total_size,
1210                chunk_size,
1211                index,
1212                bytes,
1213            })
1214        }
1215        PayloadWire::Range {
1216            chunk: None,
1217            offset_in_blob,
1218            len,
1219            slice,
1220            chunk_len_proofs,
1221        } => {
1222            if !chunk_len_proofs.is_empty() {
1223                return Err(VerifyError::UnexpectedLengthProofs);
1224            }
1225            let bytes = verify_blob_slice(&leaf_id, offset_in_blob, len, &slice)?;
1226            Ok(DisclosedPayload::Range {
1227                blob_id: leaf_id,
1228                chunk: None,
1229                offset_in_blob,
1230                absolute_offset: Some(offset_in_blob),
1231                bytes,
1232            })
1233        }
1234        PayloadWire::Range {
1235            chunk: Some(hdr),
1236            offset_in_blob,
1237            len,
1238            slice,
1239            chunk_len_proofs,
1240        } => {
1241            // SPEC-DISCLOSURE §4: `len == 0` is rejected before the
1242            // wrap/fold checks below, so it wins over e.g. InnerRootMismatch
1243            // when a bundle is malformed both ways.
1244            if len == 0 {
1245                return Err(VerifyError::ZeroLengthRange);
1246            }
1247            verify_chunk_with_declared_root(
1248                &leaf_id,
1249                &hdr.inner_root,
1250                hdr.total_size,
1251                hdr.chunk_size,
1252                &hdr.chunk_id,
1253                hdr.index,
1254                &hdr.proof,
1255            )?;
1256            let bytes = verify_blob_slice(&hdr.chunk_id, offset_in_blob, len, &slice)?;
1257            let absolute_offset =
1258                resolve_absolute_offset(&leaf_id, hdr.index, &chunk_len_proofs, offset_in_blob)?;
1259            Ok(DisclosedPayload::Range {
1260                blob_id: hdr.chunk_id,
1261                chunk: Some((hdr.index, hdr.total_size, hdr.chunk_size)),
1262                offset_in_blob,
1263                absolute_offset,
1264                bytes,
1265            })
1266        }
1267    }
1268}
1269
1270/// Decode and fully verify a disclosure bundle against `commit_id`.
1271///
1272/// # Errors
1273///
1274/// See [`VerifyError`]'s variant docs. In particular: a bundle whose
1275/// embedded `commit_id` disagrees with the argument, whose `commit_bytes`
1276/// does not hash to it, or whose payload fails its merkle/Bao check, is
1277/// rejected with a specific typed error — never a bare "invalid".
1278pub fn verify_disclosure(commit_id: &Hash, bundle: &[u8]) -> Result<Disclosed, VerifyError> {
1279    let (wire_commit_id, commit_bytes, steps, payload) = decode_disclosure(bundle)?;
1280    if wire_commit_id != *commit_id {
1281        return Err(VerifyError::CommitIdMismatch);
1282    }
1283    let verified = verify_path(commit_id, &commit_bytes, &steps)?;
1284    let step_inner_roots: Vec<Hash> = steps.iter().map(|s| s.inner_root).collect();
1285    let chunk_inner_root = match &payload {
1286        PayloadWire::Chunk { inner_root, .. } => Some(*inner_root),
1287        PayloadWire::Range {
1288            chunk: Some(hdr), ..
1289        } => Some(hdr.inner_root),
1290        _ => None,
1291    };
1292    let payload = compose_payload(verified.leaf_id, payload)?;
1293    Ok(Disclosed {
1294        commit_id: *commit_id,
1295        tree_hash: verified.tree_hash,
1296        path: verified.path,
1297        leaf_id: verified.leaf_id,
1298        payload,
1299        signer: verified.signer,
1300        signature_valid: verified.signature_valid,
1301        step_inner_roots,
1302        chunk_inner_root,
1303    })
1304}
1305
1306/// MKDS-only verification path. The anchor authenticated `context`; the
1307/// inner bundle still has to carry commit bytes hashing to `commit_id`.
1308pub(crate) fn verify_disclosure_reusing_context(
1309    commit_id: &Hash,
1310    bundle: &[u8],
1311    context: &CommitContext,
1312) -> Result<Disclosed, VerifyError> {
1313    let (wire_commit_id, commit_bytes, steps, payload) = decode_disclosure(bundle)?;
1314    if wire_commit_id != *commit_id {
1315        return Err(VerifyError::CommitIdMismatch);
1316    }
1317    let verified = verify_path_reusing_context(commit_id, &commit_bytes, &steps, context)?;
1318    let step_inner_roots = steps.iter().map(|s| s.inner_root).collect();
1319    let chunk_inner_root = match &payload {
1320        PayloadWire::Chunk { inner_root, .. } => Some(*inner_root),
1321        PayloadWire::Range {
1322            chunk: Some(hdr), ..
1323        } => Some(hdr.inner_root),
1324        _ => None,
1325    };
1326    let payload = compose_payload(verified.leaf_id, payload)?;
1327    Ok(Disclosed {
1328        commit_id: *commit_id,
1329        tree_hash: verified.tree_hash,
1330        path: verified.path,
1331        leaf_id: verified.leaf_id,
1332        payload,
1333        signer: verified.signer,
1334        signature_valid: verified.signature_valid,
1335        step_inner_roots,
1336        chunk_inner_root,
1337    })
1338}
1339
1340// ---------------------------------------------------------------------------
1341// Builder (generic over `crate::store::ObjectSource`)
1342// ---------------------------------------------------------------------------
1343
1344/// Build a Bao outboard encoding of `bytes` and extract a slice proving
1345/// `len` bytes at `bao_offset`.
1346fn extract_bao_slice(bytes: &[u8], bao_offset: u64, len: u64) -> Result<Vec<u8>, VerifyError> {
1347    let (outboard, _root) = bao::encode::outboard(bytes);
1348    let mut extractor = bao::encode::SliceExtractor::new_outboard(
1349        std::io::Cursor::new(bytes),
1350        std::io::Cursor::new(outboard),
1351        bao_offset,
1352        len,
1353    );
1354    let mut out = Vec::new();
1355    extractor
1356        .read_to_end(&mut out)
1357        .map_err(|e| VerifyError::Bao(e.to_string()))?;
1358    Ok(out)
1359}
1360
1361/// Build a disclosure bundle proving `selector`'s content at `path` under
1362/// `commit_id`, reading only from `store`. `path` empty with
1363/// [`Selector::Object`] discloses the root tree.
1364///
1365/// A thin wrapper over [`build_disclosure_from`] for the on-disk
1366/// [`crate::store::ObjectStore`]; the bundle bytes are identical.
1367///
1368/// # Errors
1369///
1370/// [`VerifyError::Store`] for any missing/corrupt object; typed errors
1371/// (see [`VerifyError`]) for a path that does not resolve, a selector
1372/// that does not match the leaf's object type, an out-of-bounds range, or
1373/// a range that crosses a `ChunkedBlob` chunk boundary (unsupported in
1374/// this profile).
1375pub fn build_disclosure(
1376    store: &crate::store::ObjectStore,
1377    commit_id: &Hash,
1378    path: &[&[u8]],
1379    selector: Selector,
1380) -> Result<Vec<u8>, VerifyError> {
1381    build_disclosure_from(store, commit_id, path, selector)
1382}
1383
1384/// Build a disclosure bundle proving `selector`'s content at `path` under
1385/// `commit_id`, reading only through `source`: any
1386/// [`crate::store::ObjectSource`] (the on-disk store, an
1387/// [`crate::store::EphemeralSink`], or a server-side repository index or
1388/// object CAS). Not to be confused with [`ObjectSource`], this module's
1389/// non-verifying closure-walker trait (`fetch(&mut self)`).
1390///
1391/// `source` MUST return verified bytes from `read`/`read_object` (the
1392/// [`crate::store::ObjectSource`] contract). The builder never calls
1393/// `read_unverified` and adds no verification of its own, so the bundle
1394/// bytes are identical to [`build_disclosure`]'s for the same objects.
1395/// [`crate::store::DisplaySource`] is therefore not a valid source: its
1396/// `read` skips verification.
1397///
1398/// A source that breaks the contract (returns bytes that do not hash to
1399/// the requested id) still cannot make the builder panic, and cannot make
1400/// it emit a bundle that verifies for content `commit_id` does not commit
1401/// to, because the bundle is self-authenticating against `commit_id`. The
1402/// build then either fails with a typed [`VerifyError`] or yields a bundle
1403/// that [`verify_disclosure`] rejects. It can still waste work: the
1404/// builder bounds nothing beyond what the objects themselves declare.
1405///
1406/// A source reports an absent object as
1407/// [`crate::store::StoreError::ObjectNotFound`], which surfaces as
1408/// [`VerifyError::Store`] exactly as on the store path.
1409///
1410/// # Errors
1411///
1412/// As [`build_disclosure`].
1413pub fn build_disclosure_from<S: crate::store::ObjectSource + ?Sized>(
1414    source: &S,
1415    commit_id: &Hash,
1416    path: &[&[u8]],
1417    selector: Selector,
1418) -> Result<Vec<u8>, VerifyError> {
1419    let (commit_bytes, steps, leaf_id) = build_prefix(source, commit_id, path)?;
1420    let payload = build_payload(source, &leaf_id, selector)?;
1421    Ok(encode_disclosure(
1422        commit_id,
1423        &commit_bytes,
1424        &steps,
1425        &payload,
1426    ))
1427}
1428
1429fn build_prefix<S: crate::store::ObjectSource + ?Sized>(
1430    source: &S,
1431    commit_id: &Hash,
1432    path: &[&[u8]],
1433) -> Result<(Vec<u8>, Vec<Step>, Hash), VerifyError> {
1434    if path.len() > MAX_TREE_DEPTH {
1435        return Err(VerifyError::TooManySteps(path.len()));
1436    }
1437    let commit_bytes = source.read(commit_id)?;
1438    let commit_obj = crate::serialize::deserialize(&commit_bytes)?;
1439    let tree_hash = match &commit_obj {
1440        Object::Commit(c) => c.tree_hash,
1441        Object::Remix(r) => r.tree_hash,
1442        other => return Err(VerifyError::NotACommitOrRemix(other.object_type())),
1443    };
1444
1445    let mut steps = Vec::with_capacity(path.len());
1446    let mut current_tree_id = tree_hash;
1447    let mut leaf_id = tree_hash;
1448    for (i, &name) in path.iter().enumerate() {
1449        let Object::Tree(tree) = source.read_object(&current_tree_id)? else {
1450            return Err(VerifyError::PathThroughNonTree);
1451        };
1452        let position =
1453            merkle::tree_entry_position(&tree, name).ok_or(VerifyError::PathNotFound(i))?;
1454        let entry = tree.entries[position as usize].clone();
1455        let proof = merkle::build_tree_entry_proof(&tree, position)?;
1456        steps.push(Step {
1457            name: name.to_vec(),
1458            mode: entry.mode,
1459            child_id: entry.object_hash,
1460            inner_root: merkle::tree_inner_root(&tree),
1461            position,
1462            proof,
1463        });
1464        leaf_id = entry.object_hash;
1465        let is_last = i + 1 == path.len();
1466        if entry.mode == EntryMode::Tree {
1467            current_tree_id = entry.object_hash;
1468        } else if !is_last {
1469            return Err(VerifyError::PathThroughNonTree);
1470        }
1471    }
1472
1473    Ok((commit_bytes, steps, leaf_id))
1474}
1475
1476fn build_payload<S: crate::store::ObjectSource + ?Sized>(
1477    source: &S,
1478    leaf_id: &Hash,
1479    selector: Selector,
1480) -> Result<PayloadWire, VerifyError> {
1481    match selector {
1482        Selector::Object => {
1483            let bytes = source.read(leaf_id)?;
1484            Ok(PayloadWire::Object { bytes })
1485        }
1486        Selector::Chunk(index) => {
1487            let Object::ChunkedBlob(cb) = source.read_object(leaf_id)? else {
1488                return Err(VerifyError::SelectorLeafMismatch);
1489            };
1490            let leaf_count = u32::try_from(cb.chunks.len())
1491                .ok()
1492                .and_then(|n| n.checked_add(1))
1493                .ok_or(VerifyError::TooManyChunks)?;
1494            let chunk_id = *cb
1495                .chunks
1496                .get(index as usize)
1497                .ok_or(VerifyError::ChunkIndexOutOfRange { index, leaf_count })?;
1498            let position = index
1499                .checked_add(1)
1500                .ok_or(VerifyError::ChunkIndexOutOfRange { index, leaf_count })?;
1501            let proof = merkle::build_chunks_multi_proof(&cb, [0, position])?;
1502            let bytes = source.read(&chunk_id)?;
1503            Ok(PayloadWire::Chunk {
1504                total_size: cb.total_size,
1505                chunk_size: cb.chunk_size,
1506                index,
1507                inner_root: merkle::chunked_inner_root(&cb),
1508                proof,
1509                bytes,
1510            })
1511        }
1512        Selector::Range {
1513            offset,
1514            len,
1515            with_offsets,
1516        } => {
1517            if len == 0 {
1518                return Err(VerifyError::ZeroLengthRange);
1519            }
1520            match source.read_object(leaf_id)? {
1521                Object::Blob(b) => {
1522                    let end = offset.checked_add(len).ok_or(VerifyError::OffsetOverflow)?;
1523                    if end > b.data.len() as u64 {
1524                        return Err(VerifyError::RangeOutOfBounds);
1525                    }
1526                    let canonical = source.read(leaf_id)?;
1527                    let bao_offset = offset.checked_add(10).ok_or(VerifyError::OffsetOverflow)?;
1528                    let slice = extract_bao_slice(&canonical, bao_offset, len)?;
1529                    Ok(PayloadWire::Range {
1530                        chunk: None,
1531                        offset_in_blob: offset,
1532                        len,
1533                        slice,
1534                        chunk_len_proofs: Vec::new(),
1535                    })
1536                }
1537                Object::ChunkedBlob(cb) => {
1538                    build_chunked_range_payload(source, &cb, offset, len, with_offsets)
1539                }
1540                _ => Err(VerifyError::SelectorLeafMismatch),
1541            }
1542        }
1543    }
1544}
1545
1546fn build_chunked_range_payload<S: crate::store::ObjectSource + ?Sized>(
1547    source: &S,
1548    cb: &crate::object::ChunkedBlob,
1549    offset: u64,
1550    len: u64,
1551    with_offsets: bool,
1552) -> Result<PayloadWire, VerifyError> {
1553    // Read every chunk's canonical bytes once, up front: needed both to
1554    // locate the containing chunk (chunk lengths aren't in the manifest)
1555    // and, when `with_offsets` is set, to build each preceding chunk's
1556    // length proof.
1557    let chunk_bytes: Vec<Vec<u8>> = cb
1558        .chunks
1559        .iter()
1560        .map(|id| source.read(id))
1561        .collect::<Result<_, _>>()?;
1562
1563    // Every length and offset below is checked: `source` bytes and the
1564    // caller's `offset`/`len` are both untrusted here, and a panic (the
1565    // release profile has `overflow-checks = true`) is never acceptable.
1566    let mut cumulative: u64 = 0;
1567    let mut located = None;
1568    for (idx, bytes) in chunk_bytes.iter().enumerate() {
1569        // A chunk is a `Blob`: 10-byte prologue (6-byte header + u32
1570        // length) then content. Anything shorter is a truncated object.
1571        let content_len = bytes
1572            .len()
1573            .checked_sub(10)
1574            .ok_or(VerifyError::Decode(MkitError::UnexpectedEof))? as u64;
1575        let chunk_end = cumulative
1576            .checked_add(content_len)
1577            .ok_or(VerifyError::OffsetOverflow)?;
1578        if offset < chunk_end {
1579            located = Some((idx, cumulative, content_len));
1580            break;
1581        }
1582        cumulative = chunk_end;
1583    }
1584    let (index, chunk_start, content_len) = located.ok_or(VerifyError::RangeOutOfBounds)?;
1585    let offset_in_chunk = offset
1586        .checked_sub(chunk_start)
1587        .ok_or(VerifyError::OffsetOverflow)?;
1588    let range_end = offset_in_chunk
1589        .checked_add(len)
1590        .ok_or(VerifyError::OffsetOverflow)?;
1591    if range_end > content_len {
1592        return Err(VerifyError::RangeCrossesChunkBoundary);
1593    }
1594
1595    let index_u32 = u32::try_from(index).map_err(|_| VerifyError::TooManyChunks)?;
1596    let position = index_u32.checked_add(1).ok_or(VerifyError::TooManyChunks)?;
1597    let proof = merkle::build_chunks_multi_proof(cb, [0, position])?;
1598    let bao_offset = offset_in_chunk
1599        .checked_add(10)
1600        .ok_or(VerifyError::OffsetOverflow)?;
1601    let slice = extract_bao_slice(&chunk_bytes[index], bao_offset, len)?;
1602
1603    let mut chunk_len_proofs = Vec::new();
1604    if with_offsets {
1605        for (j, preceding_bytes) in chunk_bytes.iter().enumerate().take(index) {
1606            let j_u32 = u32::try_from(j).map_err(|_| VerifyError::TooManyChunks)?;
1607            let position_j = j_u32.checked_add(1).ok_or(VerifyError::TooManyChunks)?;
1608            let proof_j = merkle::build_chunk_proof(cb, position_j)?;
1609            let slice_j = extract_bao_slice(preceding_bytes, 0, 10)?;
1610            chunk_len_proofs.push(LenProof {
1611                index: j_u32,
1612                chunk_id: cb.chunks[j],
1613                proof: proof_j,
1614                slice: slice_j,
1615            });
1616        }
1617    }
1618
1619    Ok(PayloadWire::Range {
1620        chunk: Some(ChunkHdr {
1621            total_size: cb.total_size,
1622            chunk_size: cb.chunk_size,
1623            index: index_u32,
1624            inner_root: merkle::chunked_inner_root(cb),
1625            chunk_id: cb.chunks[index],
1626            proof,
1627        }),
1628        offset_in_blob: offset_in_chunk,
1629        len,
1630        slice,
1631        chunk_len_proofs,
1632    })
1633}
1634
1635// =========================================================================
1636// Tests
1637// =========================================================================
1638
1639#[cfg(test)]
1640#[allow(clippy::unwrap_used)] // unwrap is the assertion in test helpers
1641mod tests {
1642    use super::*;
1643    use crate::hash::ZERO;
1644    use crate::layout::RepoLayout;
1645    use crate::object::{Commit, Identity, Tree};
1646    use crate::sign::{KeyPair, sign_commit};
1647    use crate::store::ObjectStore;
1648    use crate::worktree::store_file_object;
1649
1650    /// A small deterministic repo: root tree with a shallow file, a
1651    /// 3-level-nested file, an executable file, and a chunked (> 1 MiB)
1652    /// file, committed under a fixed signer. Built fresh (native
1653    /// `ObjectStore`, no golden files) for this module's own unit tests;
1654    /// `tests/golden_disclosure.rs` carries the pinned wire-byte fixture.
1655    struct Fixture {
1656        _dir: tempfile::TempDir,
1657        store: ObjectStore,
1658        commit_id: Hash,
1659        chunked_bytes: Vec<u8>,
1660    }
1661
1662    fn build_fixture() -> Fixture {
1663        let dir = tempfile::TempDir::new().expect("tempdir");
1664        let store = ObjectStore::init(&RepoLayout::single(dir.path())).expect("store init");
1665
1666        let shallow = store_file_object(&store, b"shallow file content").unwrap();
1667        let deep_file = store_file_object(&store, b"three levels deep").unwrap();
1668        let exec = store_file_object(&store, b"#!/bin/sh\necho hi\n").unwrap();
1669
1670        // Deterministic pseudo-random 2 MiB stream so FastCDC yields
1671        // several chunks.
1672        let mut chunked_bytes = vec![0u8; 2 * 1024 * 1024];
1673        let mut x: u64 = 0x1234_5678_9abc_def0;
1674        for b in &mut chunked_bytes {
1675            x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1676            *b = (x >> 56) as u8;
1677        }
1678        let chunked_id = store_file_object(&store, &chunked_bytes).unwrap();
1679
1680        let deep_tree = Tree {
1681            entries: vec![TreeEntry {
1682                name: b"deep.txt".to_vec(),
1683                mode: EntryMode::Blob,
1684                object_hash: deep_file,
1685            }],
1686        };
1687        let deep_tree_id = store
1688            .write(&crate::serialize::serialize(&Object::Tree(deep_tree)).unwrap())
1689            .unwrap();
1690
1691        let mid_tree = Tree {
1692            entries: vec![TreeEntry {
1693                name: b"deep".to_vec(),
1694                mode: EntryMode::Tree,
1695                object_hash: deep_tree_id,
1696            }],
1697        };
1698        let mid_tree_id = store
1699            .write(&crate::serialize::serialize(&Object::Tree(mid_tree)).unwrap())
1700            .unwrap();
1701
1702        let root_tree = Tree {
1703            entries: vec![
1704                TreeEntry {
1705                    name: b"chunked.bin".to_vec(),
1706                    mode: EntryMode::Blob,
1707                    object_hash: chunked_id,
1708                },
1709                TreeEntry {
1710                    name: b"exec.sh".to_vec(),
1711                    mode: EntryMode::Executable,
1712                    object_hash: exec,
1713                },
1714                TreeEntry {
1715                    name: b"shallow.txt".to_vec(),
1716                    mode: EntryMode::Blob,
1717                    object_hash: shallow,
1718                },
1719                TreeEntry {
1720                    name: b"sub".to_vec(),
1721                    mode: EntryMode::Tree,
1722                    object_hash: mid_tree_id,
1723                },
1724            ],
1725        };
1726        let tree_hash = store
1727            .write(&crate::serialize::serialize(&Object::Tree(root_tree)).unwrap())
1728            .unwrap();
1729
1730        let kp = KeyPair::from_seed([0x07; 32]);
1731        let mut commit = Commit {
1732            tree_hash,
1733            parents: vec![],
1734            author: Identity::ed25519(kp.public.0),
1735            signer: kp.public.0,
1736            message: b"disclosure fixture".to_vec(),
1737            timestamp: 1_726_300_000,
1738            message_hash: ZERO,
1739            content_digest: ZERO,
1740            signature: [0u8; 64],
1741        };
1742        commit.signature = sign_commit(&commit, &kp).unwrap().0;
1743        let commit_bytes = crate::serialize::serialize(&Object::Commit(commit)).unwrap();
1744        let commit_id = store.write(&commit_bytes).unwrap();
1745
1746        Fixture {
1747            _dir: dir,
1748            store,
1749            commit_id,
1750            chunked_bytes,
1751        }
1752    }
1753
1754    #[test]
1755    #[allow(clippy::too_many_lines)] // exercises every Selector against one fixture, kept together for auditability
1756    fn round_trips_every_selector() {
1757        let f = build_fixture();
1758
1759        // Root tree, via Object.
1760        let bundle = build_disclosure(&f.store, &f.commit_id, &[], Selector::Object).unwrap();
1761        let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
1762        assert!(d.path.is_empty());
1763        assert_eq!(d.leaf_id, d.tree_hash);
1764        assert!(d.signature_valid);
1765
1766        // Shallow file, via Object.
1767        let bundle =
1768            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
1769        let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
1770        assert_eq!(d.path, vec![(b"shallow.txt".to_vec(), EntryMode::Blob)]);
1771        let DisclosedPayload::Object { bytes } = d.payload else {
1772            panic!("expected Object payload");
1773        };
1774        assert_eq!(
1775            bytes,
1776            crate::serialize::serialize(&Object::Blob(crate::object::Blob {
1777                data: b"shallow file content".to_vec()
1778            }))
1779            .unwrap()
1780        );
1781
1782        // Nested (3-level) file.
1783        let bundle = build_disclosure(
1784            &f.store,
1785            &f.commit_id,
1786            &[b"sub", b"deep", b"deep.txt"],
1787            Selector::Object,
1788        )
1789        .unwrap();
1790        let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
1791        assert_eq!(
1792            d.path,
1793            vec![
1794                (b"sub".to_vec(), EntryMode::Tree),
1795                (b"deep".to_vec(), EntryMode::Tree),
1796                (b"deep.txt".to_vec(), EntryMode::Blob),
1797            ]
1798        );
1799
1800        // Executable-mode file.
1801        let bundle =
1802            build_disclosure(&f.store, &f.commit_id, &[b"exec.sh"], Selector::Object).unwrap();
1803        let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
1804        assert_eq!(d.path, vec![(b"exec.sh".to_vec(), EntryMode::Executable)]);
1805
1806        // One chunk of the chunked file.
1807        let bundle = build_disclosure(
1808            &f.store,
1809            &f.commit_id,
1810            &[b"chunked.bin"],
1811            Selector::Chunk(1),
1812        )
1813        .unwrap();
1814        let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
1815        let DisclosedPayload::Chunk { index, .. } = d.payload else {
1816            panic!("expected Chunk payload");
1817        };
1818        assert_eq!(index, 1);
1819
1820        // Range inside a chunk, without offsets.
1821        let bundle = build_disclosure(
1822            &f.store,
1823            &f.commit_id,
1824            &[b"chunked.bin"],
1825            Selector::Range {
1826                offset: 200_000,
1827                len: 64,
1828                with_offsets: false,
1829            },
1830        )
1831        .unwrap();
1832        let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
1833        let DisclosedPayload::Range {
1834            chunk,
1835            absolute_offset,
1836            bytes,
1837            ..
1838        } = d.payload
1839        else {
1840            panic!("expected Range payload");
1841        };
1842        assert!(chunk.is_some());
1843        assert_eq!(bytes.len(), 64);
1844
1845        // Same range, with offsets: absolute_offset must be provable
1846        // (this fixture's chosen offset lands past chunk 0).
1847        let bundle_off = build_disclosure(
1848            &f.store,
1849            &f.commit_id,
1850            &[b"chunked.bin"],
1851            Selector::Range {
1852                offset: 200_000,
1853                len: 64,
1854                with_offsets: true,
1855            },
1856        )
1857        .unwrap();
1858        let d_off = verify_disclosure(&f.commit_id, &bundle_off).unwrap();
1859        let DisclosedPayload::Range {
1860            absolute_offset: abs_off,
1861            bytes: bytes_off,
1862            chunk: chunk_off,
1863            ..
1864        } = d_off.payload
1865        else {
1866            panic!("expected Range payload");
1867        };
1868        assert_ne!(chunk_off.map(|(idx, _, _)| idx), Some(0));
1869        assert_eq!(abs_off, Some(200_000));
1870        assert_eq!(bytes_off, f.chunked_bytes[200_000..200_064]);
1871        // The no-offsets bundle for the same range discloses the same
1872        // bytes either way.
1873        assert_eq!(bytes, bytes_off);
1874        assert_eq!(absolute_offset, None);
1875
1876        // Range covering a whole small blob.
1877        let bundle = build_disclosure(
1878            &f.store,
1879            &f.commit_id,
1880            &[b"shallow.txt"],
1881            Selector::Range {
1882                offset: 0,
1883                len: "shallow file content".len() as u64,
1884                with_offsets: false,
1885            },
1886        )
1887        .unwrap();
1888        let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
1889        let DisclosedPayload::Range { bytes, chunk, .. } = d.payload else {
1890            panic!("expected Range payload");
1891        };
1892        assert!(chunk.is_none());
1893        assert_eq!(bytes, b"shallow file content");
1894    }
1895
1896    #[test]
1897    fn verify_object_id_rejects_mismatch() {
1898        let f = build_fixture();
1899        let bytes = f
1900            .store
1901            .read(&f.commit_id)
1902            .expect("commit_id was just written");
1903        assert!(matches!(
1904            verify_object_id(&bytes, &ZERO),
1905            Err(VerifyError::PayloadIdMismatch)
1906        ));
1907        verify_object_id(&bytes, &f.commit_id).expect("matches itself");
1908    }
1909
1910    #[test]
1911    fn verify_path_rejects_wrong_commit_bytes_hash() {
1912        let f = build_fixture();
1913        let commit_bytes = f.store.read(&f.commit_id).unwrap();
1914        assert!(matches!(
1915            verify_path(&ZERO, &commit_bytes, &[]),
1916            Err(VerifyError::CommitBytesHashMismatch)
1917        ));
1918    }
1919
1920    #[test]
1921    fn verify_path_rejects_non_final_non_tree_mode() {
1922        let f = build_fixture();
1923        let bundle =
1924            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
1925        // A second, non-final step whose mode is Blob (not Tree) must be
1926        // rejected — the wire-level decode bound (steps <= 128) doesn't
1927        // catch this; only the semantic check does.
1928        let (_id, commit_bytes, mut steps, _payload) = decode_disclosure(&bundle).unwrap();
1929        assert_eq!(steps.len(), 1);
1930        let dup = steps[0].clone();
1931        steps.push(dup);
1932        assert!(matches!(
1933            verify_path(&f.commit_id, &commit_bytes, &steps),
1934            Err(VerifyError::NonFinalStepNotTree(0))
1935        ));
1936    }
1937
1938    #[test]
1939    fn verify_path_rejects_too_many_steps() {
1940        let f = build_fixture();
1941        let bundle =
1942            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
1943        let (_id, commit_bytes, steps, _payload) = decode_disclosure(&bundle).unwrap();
1944        let too_many: Vec<Step> =
1945            std::iter::repeat_n(steps[0].clone(), MAX_TREE_DEPTH + 1).collect();
1946        assert!(matches!(
1947            verify_path(&f.commit_id, &commit_bytes, &too_many),
1948            Err(VerifyError::TooManySteps(_))
1949        ));
1950    }
1951
1952    #[test]
1953    fn verify_path_rejects_invalid_entry_name() {
1954        let f = build_fixture();
1955        let bundle =
1956            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
1957        let (_id, commit_bytes, mut steps, _payload) = decode_disclosure(&bundle).unwrap();
1958        steps[0].name = b"trailing space ".to_vec();
1959        assert!(matches!(
1960            verify_path(&f.commit_id, &commit_bytes, &steps),
1961            Err(VerifyError::InvalidEntryName(0))
1962        ));
1963    }
1964
1965    #[test]
1966    fn verify_path_rejects_swapped_step_proof() {
1967        let f = build_fixture();
1968        let bundle = build_disclosure(
1969            &f.store,
1970            &f.commit_id,
1971            &[b"sub", b"deep", b"deep.txt"],
1972            Selector::Object,
1973        )
1974        .unwrap();
1975        let (_id, commit_bytes, mut steps, _payload) = decode_disclosure(&bundle).unwrap();
1976        assert_eq!(steps.len(), 3);
1977        steps.swap(0, 1);
1978        assert!(matches!(
1979            verify_path(&f.commit_id, &commit_bytes, &steps),
1980            Err(VerifyError::InnerRootMismatch { .. } | VerifyError::Merkle(_))
1981        ));
1982    }
1983
1984    #[test]
1985    fn verify_path_rejects_non_commit_non_remix() {
1986        let f = build_fixture();
1987        // A Tag targeting the commit: valid bytes, wrong object kind.
1988        let tag = crate::object::Tag {
1989            target: f.commit_id,
1990            target_type: ObjectType::Commit,
1991            name: b"v1".to_vec(),
1992            tagger: Identity::ed25519([9u8; 32]),
1993            signer: [9u8; 32],
1994            message: Vec::new(),
1995            timestamp: 0,
1996            signature: [0u8; 64],
1997        };
1998        let tag_bytes = crate::serialize::serialize(&Object::Tag(tag)).unwrap();
1999        let tag_id = crate::hash::hash(&tag_bytes);
2000        assert!(matches!(
2001            verify_path(&tag_id, &tag_bytes, &[]),
2002            Err(VerifyError::NotACommitOrRemix(ObjectType::Tag))
2003        ));
2004    }
2005
2006    #[test]
2007    fn verify_chunk_with_meta_bounds() {
2008        let f = build_fixture();
2009        let bundle = build_disclosure(
2010            &f.store,
2011            &f.commit_id,
2012            &[b"chunked.bin"],
2013            Selector::Chunk(1),
2014        )
2015        .unwrap();
2016        let (_id, _commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
2017        let leaf_id = steps[0].child_id;
2018        let PayloadWire::Chunk {
2019            total_size,
2020            chunk_size,
2021            index,
2022            proof,
2023            bytes,
2024            ..
2025        } = payload
2026        else {
2027            panic!("expected Chunk payload");
2028        };
2029        let chunk_hash = hash(&bytes);
2030
2031        verify_chunk_with_meta(&leaf_id, total_size, chunk_size, &chunk_hash, index, &proof)
2032            .expect("freshly built chunk proof must verify");
2033        // Forged total_size.
2034        assert!(matches!(
2035            verify_chunk_with_meta(
2036                &leaf_id,
2037                total_size + 1,
2038                chunk_size,
2039                &chunk_hash,
2040                index,
2041                &proof
2042            ),
2043            Err(VerifyError::Merkle(_))
2044        ));
2045        // Out-of-range index (one past the last valid position).
2046        assert!(matches!(
2047            verify_chunk_with_meta(
2048                &leaf_id,
2049                total_size,
2050                chunk_size,
2051                &chunk_hash,
2052                proof.leaf_count,
2053                &proof
2054            ),
2055            Err(VerifyError::ChunkIndexOutOfRange { .. })
2056        ));
2057    }
2058
2059    #[test]
2060    fn verify_blob_slice_and_len_proof_round_trip() {
2061        let content = b"the quick brown fox jumps over the lazy dog";
2062        let canonical = {
2063            let prologue = crate::serialize::blob_prologue(content.len()).unwrap();
2064            let mut v = prologue.to_vec();
2065            v.extend_from_slice(content);
2066            v
2067        };
2068        let blob_id = crate::hash::hash(&canonical);
2069        let (outboard, _root) = bao::encode::outboard(&canonical);
2070
2071        let mut extractor = bao::encode::SliceExtractor::new_outboard(
2072            std::io::Cursor::new(&canonical),
2073            std::io::Cursor::new(&outboard),
2074            10, // content offset 0 -> bao offset 10
2075            content.len() as u64,
2076        );
2077        let mut slice = Vec::new();
2078        std::io::Read::read_to_end(&mut extractor, &mut slice).unwrap();
2079
2080        let got = verify_blob_slice(&blob_id, 0, content.len() as u64, &slice).unwrap();
2081        assert_eq!(got, content);
2082
2083        // Zero length is always rejected, regardless of slice bytes.
2084        assert!(matches!(
2085            verify_blob_slice(&blob_id, 0, 0, &slice),
2086            Err(VerifyError::ZeroLengthRange)
2087        ));
2088
2089        // Length proof over bytes 0..10.
2090        let mut len_extractor = bao::encode::SliceExtractor::new_outboard(
2091            std::io::Cursor::new(&canonical),
2092            std::io::Cursor::new(&outboard),
2093            0,
2094            10,
2095        );
2096        let mut len_slice = Vec::new();
2097        std::io::Read::read_to_end(&mut len_extractor, &mut len_slice).unwrap();
2098        let len = verify_blob_len_proof(&blob_id, &len_slice).unwrap();
2099        assert_eq!(len as usize, content.len());
2100    }
2101
2102    #[test]
2103    fn zero_length_range_rejected_end_to_end() {
2104        let f = build_fixture();
2105        assert!(matches!(
2106            build_disclosure(
2107                &f.store,
2108                &f.commit_id,
2109                &[b"shallow.txt"],
2110                Selector::Range {
2111                    offset: 0,
2112                    len: 0,
2113                    with_offsets: false
2114                }
2115            ),
2116            Err(VerifyError::ZeroLengthRange)
2117        ));
2118    }
2119
2120    #[test]
2121    fn range_crossing_chunk_boundary_rejected() {
2122        let f = build_fixture();
2123        // The full 2 MiB span certainly crosses at least one chunk
2124        // boundary (chunks average well under 1 MiB).
2125        assert!(matches!(
2126            build_disclosure(
2127                &f.store,
2128                &f.commit_id,
2129                &[b"chunked.bin"],
2130                Selector::Range {
2131                    offset: 0,
2132                    len: f.chunked_bytes.len() as u64,
2133                    with_offsets: false,
2134                }
2135            ),
2136            Err(VerifyError::RangeCrossesChunkBoundary)
2137        ));
2138    }
2139
2140    #[test]
2141    fn incomplete_length_proof_set_is_rejected() {
2142        let f = build_fixture();
2143        let bundle = build_disclosure(
2144            &f.store,
2145            &f.commit_id,
2146            &[b"chunked.bin"],
2147            Selector::Range {
2148                offset: 700_000,
2149                len: 16,
2150                with_offsets: true,
2151            },
2152        )
2153        .unwrap();
2154        let (commit_id, commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
2155        let PayloadWire::Range {
2156            chunk,
2157            offset_in_blob,
2158            len,
2159            slice,
2160            mut chunk_len_proofs,
2161        } = payload
2162        else {
2163            panic!("expected Range payload");
2164        };
2165        let hdr = chunk.clone().expect("chunked leaf");
2166        assert!(hdr.index > 0, "test fixture assumption: chunk index > 0");
2167        // Drop one proof: incomplete coverage of 0..index.
2168        chunk_len_proofs.remove(0);
2169        let tampered = encode_disclosure(
2170            &commit_id,
2171            &commit_bytes,
2172            &steps,
2173            &PayloadWire::Range {
2174                chunk,
2175                offset_in_blob,
2176                len,
2177                slice,
2178                chunk_len_proofs,
2179            },
2180        );
2181        assert!(matches!(
2182            verify_disclosure(&f.commit_id, &tampered),
2183            Err(VerifyError::IncompleteLengthProofSet(_))
2184        ));
2185    }
2186
2187    #[test]
2188    fn resolve_absolute_offset_rejects_overflow() {
2189        // `resolve_absolute_offset` sums verified preceding-chunk lengths
2190        // and adds `offset_in_blob`; that final addition must be checked
2191        // like every neighbouring offset computation, not silently wrap.
2192        let f = build_fixture();
2193        let bundle = build_disclosure(
2194            &f.store,
2195            &f.commit_id,
2196            &[b"chunked.bin"],
2197            Selector::Range {
2198                offset: 700_000,
2199                len: 16,
2200                with_offsets: true,
2201            },
2202        )
2203        .unwrap();
2204        let (_id, _commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
2205        let leaf_id = steps[0].child_id;
2206        let PayloadWire::Range {
2207            chunk,
2208            offset_in_blob,
2209            chunk_len_proofs,
2210            ..
2211        } = payload
2212        else {
2213            panic!("expected Range payload");
2214        };
2215        let hdr = chunk.expect("chunked leaf");
2216        assert!(hdr.index > 0, "test fixture assumption: chunk index > 0");
2217
2218        assert!(matches!(
2219            resolve_absolute_offset(&leaf_id, hdr.index, &chunk_len_proofs, u64::MAX),
2220            Err(VerifyError::OffsetOverflow)
2221        ));
2222
2223        // Sanity: the same, real proof set still resolves with a
2224        // non-overflowing offset.
2225        resolve_absolute_offset(&leaf_id, hdr.index, &chunk_len_proofs, offset_in_blob)
2226            .expect("freshly built length proofs must resolve");
2227    }
2228
2229    #[test]
2230    fn len_proofs_on_chunk0_are_rejected() {
2231        // Chunk 0 has nothing preceding it, so `chunk_len_proofs` MUST be
2232        // empty there too — the same rule the plain-`Blob` path already
2233        // enforces (`UnexpectedLengthProofs`). `resolve_absolute_offset`'s
2234        // `index == 0` early return must not silently ignore a non-empty
2235        // set (SPEC-DISCLOSURE §4).
2236        let f = build_fixture();
2237        let bundle = build_disclosure(
2238            &f.store,
2239            &f.commit_id,
2240            &[b"chunked.bin"],
2241            Selector::Range {
2242                offset: 0,
2243                len: 8,
2244                with_offsets: false,
2245            },
2246        )
2247        .unwrap();
2248        let (commit_id, commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
2249        let PayloadWire::Range {
2250            chunk,
2251            offset_in_blob,
2252            len,
2253            slice,
2254            chunk_len_proofs,
2255        } = payload
2256        else {
2257            panic!("expected Range payload");
2258        };
2259        let hdr = chunk.clone().expect("chunked leaf");
2260        assert_eq!(hdr.index, 0, "test fixture assumption: offset 0 is chunk 0");
2261        assert!(
2262            chunk_len_proofs.is_empty(),
2263            "builder never emits proofs for chunk 0"
2264        );
2265
2266        // Forge a bogus (but structurally valid) length-proof entry — its
2267        // content doesn't matter, because it must be rejected before any
2268        // proof inside it is even checked.
2269        let forged = vec![LenProof {
2270            index: 0,
2271            chunk_id: [0u8; 32],
2272            proof: Proof::default(),
2273            slice: Vec::new(),
2274        }];
2275        let tampered = encode_disclosure(
2276            &commit_id,
2277            &commit_bytes,
2278            &steps,
2279            &PayloadWire::Range {
2280                chunk,
2281                offset_in_blob,
2282                len,
2283                slice,
2284                chunk_len_proofs: forged,
2285            },
2286        );
2287        assert!(matches!(
2288            verify_disclosure(&f.commit_id, &tampered),
2289            Err(VerifyError::UnexpectedLengthProofs)
2290        ));
2291    }
2292
2293    #[test]
2294    fn decode_rejects_bad_magic_version_and_trailing_bytes() {
2295        let f = build_fixture();
2296        let bundle =
2297            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
2298
2299        let mut bad_magic = bundle.clone();
2300        bad_magic[0] = b'X';
2301        assert!(matches!(
2302            verify_disclosure(&f.commit_id, &bad_magic),
2303            Err(VerifyError::BadMagic)
2304        ));
2305
2306        let mut bad_version = bundle.clone();
2307        bad_version[4] = 1;
2308        assert!(matches!(
2309            verify_disclosure(&f.commit_id, &bad_version),
2310            Err(VerifyError::UnsupportedBundleVersion(1))
2311        ));
2312
2313        let mut trailing = bundle.clone();
2314        trailing.push(0);
2315        assert!(matches!(
2316            verify_disclosure(&f.commit_id, &trailing),
2317            Err(VerifyError::Malformed)
2318        ));
2319
2320        let oversize = vec![0u8; MAX_BUNDLE_BYTES + 1];
2321        assert!(matches!(
2322            verify_disclosure(&f.commit_id, &oversize),
2323            Err(VerifyError::BundleTooLarge)
2324        ));
2325    }
2326
2327    #[test]
2328    fn decode_rejects_unknown_payload_kind() {
2329        let f = build_fixture();
2330        let bundle =
2331            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
2332        let (commit_id, commit_bytes, steps, _payload) = decode_disclosure(&bundle).unwrap();
2333
2334        // Recompute the prefix (magic + version + commit_id +
2335        // commit_bytes + steps) to find the payload_kind byte's offset
2336        // in the real bundle, rather than hand-computing it.
2337        let mut prefix = Vec::new();
2338        prefix.extend_from_slice(BUNDLE_MAGIC);
2339        prefix.push(BUNDLE_VERSION);
2340        commit_id.write(&mut prefix);
2341        commit_bytes.as_slice().write(&mut prefix);
2342        steps.as_slice().write(&mut prefix);
2343        let kind_offset = prefix.len();
2344        assert_eq!(bundle[kind_offset], 0, "Object payload_kind must be 0");
2345
2346        let mut tampered = bundle.clone();
2347        tampered[kind_offset] = 0xFF;
2348        assert!(matches!(
2349            verify_disclosure(&f.commit_id, &tampered),
2350            Err(VerifyError::InvalidPayloadKind(0xFF))
2351        ));
2352    }
2353
2354    #[test]
2355    fn payload_id_mismatch_is_rejected() {
2356        let f = build_fixture();
2357        let bundle =
2358            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
2359        let (commit_id, commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
2360        let PayloadWire::Object { mut bytes } = payload else {
2361            panic!("expected Object payload");
2362        };
2363        *bytes.last_mut().unwrap() ^= 0xFF;
2364        let tampered = encode_disclosure(
2365            &commit_id,
2366            &commit_bytes,
2367            &steps,
2368            &PayloadWire::Object { bytes },
2369        );
2370        assert!(matches!(
2371            verify_disclosure(&f.commit_id, &tampered),
2372            Err(VerifyError::PayloadIdMismatch)
2373        ));
2374    }
2375
2376    #[test]
2377    fn commit_id_mismatch_is_rejected() {
2378        let f = build_fixture();
2379        let bundle =
2380            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
2381        assert!(matches!(
2382            verify_disclosure(&ZERO, &bundle),
2383            Err(VerifyError::CommitIdMismatch)
2384        ));
2385    }
2386
2387    #[test]
2388    fn inner_root_forged_is_rejected() {
2389        let f = build_fixture();
2390        let bundle =
2391            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
2392        let (commit_id, commit_bytes, mut steps, payload) = decode_disclosure(&bundle).unwrap();
2393        steps[0].inner_root[0] ^= 0xFF;
2394        let tampered = encode_disclosure(&commit_id, &commit_bytes, &steps, &payload);
2395        assert!(matches!(
2396            verify_disclosure(&f.commit_id, &tampered),
2397            Err(VerifyError::InnerRootMismatch { .. })
2398        ));
2399    }
2400
2401    #[test]
2402    fn inner_root_fold_mismatch_is_rejected() {
2403        let f = build_fixture();
2404        let nested = build_disclosure(
2405            &f.store,
2406            &f.commit_id,
2407            &[b"sub", b"deep", b"deep.txt"],
2408            Selector::Object,
2409        )
2410        .unwrap();
2411        let shallow =
2412            build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
2413        let (commit_id, commit_bytes, mut steps, payload) = decode_disclosure(&shallow).unwrap();
2414        let (_, _, nested_steps, _) = decode_disclosure(&nested).unwrap();
2415        // Keep the wrap-correct inner_root of the root tree, but swap in
2416        // a proof built against a different tree so the fold disagrees.
2417        steps[0].proof = nested_steps[1].proof.clone();
2418        steps[0].position = nested_steps[1].position;
2419        let tampered = encode_disclosure(&commit_id, &commit_bytes, &steps, &payload);
2420        assert!(matches!(
2421            verify_disclosure(&f.commit_id, &tampered),
2422            Err(VerifyError::InnerRootFoldMismatch)
2423        ));
2424    }
2425
2426    // --- `build_disclosure_from` over a generic `store::ObjectSource` ---
2427
2428    use crate::store::StoreError;
2429
2430    /// A verifying in-memory [`crate::store::ObjectSource`]: every `read`
2431    /// re-derives the object id (the trait contract), so it stands in for
2432    /// a server-side per-repository index or global CAS.
2433    struct MapSource(BTreeMap<Hash, Vec<u8>>);
2434
2435    impl MapSource {
2436        /// Every object currently in `store`.
2437        fn from_store(store: &ObjectStore) -> Self {
2438            let map = store
2439                .iter_object_hashes()
2440                .unwrap()
2441                .into_iter()
2442                .map(|h| (h, store.read(&h).unwrap()))
2443                .collect();
2444            Self(map)
2445        }
2446    }
2447
2448    impl crate::store::ObjectSource for MapSource {
2449        fn read(&self, h: &Hash) -> crate::store::StoreResult<Vec<u8>> {
2450            let bytes = self
2451                .0
2452                .get(h)
2453                .ok_or_else(|| StoreError::ObjectNotFound(crate::hash::to_hex(h)))?;
2454            verify_object_id(bytes, h).map_err(|_| StoreError::HashMismatch {
2455                expected: crate::hash::to_hex(h),
2456                actual: String::from("<mismatch>"),
2457            })?;
2458            Ok(bytes.clone())
2459        }
2460    }
2461
2462    /// A deliberately NON-verifying source (violates the
2463    /// `store::ObjectSource` contract): serves `lie`'s bytes for `target`.
2464    struct LyingSource<'a> {
2465        inner: &'a MapSource,
2466        target: Hash,
2467        lie: Vec<u8>,
2468    }
2469
2470    impl crate::store::ObjectSource for LyingSource<'_> {
2471        fn read(&self, h: &Hash) -> crate::store::StoreResult<Vec<u8>> {
2472            if *h == self.target {
2473                return Ok(self.lie.clone());
2474            }
2475            crate::store::ObjectSource::read(self.inner, h)
2476        }
2477    }
2478
2479    /// Another contract-violating source: `read_object(target)` decodes
2480    /// `object_lie`, while `read(target)` still returns the honest bytes,
2481    /// so the two methods disagree about the same id.
2482    struct SplitSource<'a> {
2483        inner: &'a MapSource,
2484        target: Hash,
2485        object_lie: Object,
2486    }
2487
2488    impl crate::store::ObjectSource for SplitSource<'_> {
2489        fn read(&self, h: &Hash) -> crate::store::StoreResult<Vec<u8>> {
2490            crate::store::ObjectSource::read(self.inner, h)
2491        }
2492
2493        fn read_object(&self, h: &Hash) -> crate::store::StoreResult<Object> {
2494            if *h == self.target {
2495                return Ok(self.object_lie.clone());
2496            }
2497            crate::store::ObjectSource::read_object(self.inner, h)
2498        }
2499    }
2500
2501    /// Id of the entry `name` in the tree `tree_id`.
2502    fn entry_id(store: &ObjectStore, tree_id: &Hash, name: &[u8]) -> Hash {
2503        let Object::Tree(tree) = store.read_object(tree_id).unwrap() else {
2504            panic!("expected a tree");
2505        };
2506        tree.entries
2507            .iter()
2508            .find(|e| e.name == name)
2509            .unwrap()
2510            .object_hash
2511    }
2512
2513    fn root_tree_id(store: &ObjectStore, commit_id: &Hash) -> Hash {
2514        let Object::Commit(c) = store.read_object(commit_id).unwrap() else {
2515            panic!("expected a commit");
2516        };
2517        c.tree_hash
2518    }
2519
2520    /// A second commit (in the fixture store) whose root holds one plain,
2521    /// multi-Bao-block `Blob`, so small-blob ranges span block boundaries.
2522    fn medium_blob_commit(f: &Fixture) -> Hash {
2523        let data: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
2524        let blob = store_file_object(&f.store, &data).unwrap();
2525        assert!(matches!(
2526            f.store.read_object(&blob).unwrap(),
2527            Object::Blob(_)
2528        ));
2529        let tree = Tree {
2530            entries: vec![TreeEntry {
2531                name: b"medium.bin".to_vec(),
2532                mode: EntryMode::Blob,
2533                object_hash: blob,
2534            }],
2535        };
2536        let tree_hash = f
2537            .store
2538            .write(&crate::serialize::serialize(&Object::Tree(tree)).unwrap())
2539            .unwrap();
2540        let kp = KeyPair::from_seed([0x07; 32]);
2541        let mut commit = Commit {
2542            tree_hash,
2543            parents: vec![],
2544            author: Identity::ed25519(kp.public.0),
2545            signer: kp.public.0,
2546            message: b"medium blob".to_vec(),
2547            timestamp: 1_726_300_001,
2548            message_hash: ZERO,
2549            content_digest: ZERO,
2550            signature: [0u8; 64],
2551        };
2552        commit.signature = sign_commit(&commit, &kp).unwrap().0;
2553        f.store
2554            .write(&crate::serialize::serialize(&Object::Commit(commit)).unwrap())
2555            .unwrap()
2556    }
2557
2558    type Case = (Hash, Vec<&'static [u8]>, Selector);
2559
2560    /// Every selector kind the builder supports, as `(commit, path,
2561    /// selector)` cases over the fixture plus [`medium_blob_commit`].
2562    fn equivalence_cases(f: &Fixture, medium: Hash) -> Vec<Case> {
2563        let range = |offset, len, with_offsets| Selector::Range {
2564            offset,
2565            len,
2566            with_offsets,
2567        };
2568        let c = f.commit_id;
2569        let mut cases: Vec<Case> = vec![
2570            (c, vec![], Selector::Object),
2571            (c, vec![b"shallow.txt"], Selector::Object),
2572            (c, vec![b"sub", b"deep", b"deep.txt"], Selector::Object),
2573            (c, vec![b"sub"], Selector::Object),
2574            (c, vec![b"exec.sh"], Selector::Object),
2575            (c, vec![b"chunked.bin"], Selector::Object),
2576            (c, vec![b"shallow.txt"], range(0, 20, false)),
2577            (c, vec![b"shallow.txt"], range(8, 4, false)),
2578            // Plain blob: first block, last partial block, a range across a
2579            // block boundary, and the whole content.
2580            (medium, vec![b"medium.bin"], range(0, 1024, false)),
2581            (medium, vec![b"medium.bin"], range(4096, 904, false)),
2582            (medium, vec![b"medium.bin"], range(1000, 100, false)),
2583            (medium, vec![b"medium.bin"], range(0, 5000, false)),
2584            // Chunked blob: a range inside chunk 0 and one past it, each
2585            // with and without offset proofs.
2586            (c, vec![b"chunked.bin"], range(10, 64, false)),
2587            (c, vec![b"chunked.bin"], range(10, 64, true)),
2588            (c, vec![b"chunked.bin"], range(200_000, 64, false)),
2589            (c, vec![b"chunked.bin"], range(200_000, 64, true)),
2590        ];
2591        let chunked_id = entry_id(&f.store, &root_tree_id(&f.store, &c), b"chunked.bin");
2592        let Object::ChunkedBlob(cb) = f.store.read_object(&chunked_id).unwrap() else {
2593            panic!("expected a chunked blob");
2594        };
2595        assert!(cb.chunks.len() > 1, "fixture must yield several chunks");
2596        for i in 0..cb.chunks.len() {
2597            let index = u32::try_from(i).unwrap();
2598            cases.push((c, vec![b"chunked.bin"], Selector::Chunk(index)));
2599        }
2600        cases
2601    }
2602
2603    fn assert_source_matches_store<S: crate::store::ObjectSource + ?Sized>(
2604        f: &Fixture,
2605        source: &S,
2606        medium: Hash,
2607    ) {
2608        for (commit, path, selector) in equivalence_cases(f, medium) {
2609            let expected = build_disclosure(&f.store, &commit, &path, selector).unwrap();
2610            let got = build_disclosure_from(source, &commit, &path, selector).unwrap();
2611            assert_eq!(
2612                got, expected,
2613                "bundle bytes differ for {path:?} / {selector:?}"
2614            );
2615            verify_disclosure(&commit, &got).unwrap();
2616        }
2617    }
2618
2619    #[test]
2620    fn disclosure_from_map_source_equals_store_builder() {
2621        let f = build_fixture();
2622        let medium = medium_blob_commit(&f);
2623        let map = MapSource::from_store(&f.store);
2624        assert_source_matches_store(&f, &map, medium);
2625        // Also through a trait object (`?Sized`).
2626        let dyn_source: &dyn crate::store::ObjectSource = &map;
2627        assert_source_matches_store(&f, dyn_source, medium);
2628    }
2629
2630    #[test]
2631    fn disclosure_from_ephemeral_sink_equals_store_builder() {
2632        let f = build_fixture();
2633        let medium = medium_blob_commit(&f);
2634        let sink = crate::store::EphemeralSink::new(&f.store);
2635        assert_source_matches_store(&f, &sink, medium);
2636    }
2637
2638    #[test]
2639    fn disclosure_from_missing_object_is_store_error() {
2640        let f = build_fixture();
2641        let sub_id = entry_id(&f.store, &root_tree_id(&f.store, &f.commit_id), b"sub");
2642        let path: [&[u8]; 3] = [b"sub", b"deep", b"deep.txt"];
2643
2644        let mut map = MapSource::from_store(&f.store);
2645        assert!(map.0.remove(&sub_id).is_some());
2646        let from_map =
2647            build_disclosure_from(&map, &f.commit_id, &path, Selector::Object).unwrap_err();
2648
2649        f.store.remove_object(&sub_id).unwrap();
2650        let from_store =
2651            build_disclosure(&f.store, &f.commit_id, &path, Selector::Object).unwrap_err();
2652
2653        let VerifyError::Store(StoreError::ObjectNotFound(map_hex)) = &from_map else {
2654            panic!("expected Store(ObjectNotFound), got {from_map:?}");
2655        };
2656        let VerifyError::Store(StoreError::ObjectNotFound(store_hex)) = &from_store else {
2657            panic!("expected Store(ObjectNotFound), got {from_store:?}");
2658        };
2659        assert_eq!(map_hex, store_hex);
2660        assert_eq!(map_hex, &crate::hash::to_hex(&sub_id));
2661        assert_eq!(from_map.to_string(), from_store.to_string());
2662    }
2663
2664    #[test]
2665    fn disclosure_from_lying_leaf_fails_verification() {
2666        let f = build_fixture();
2667        let root = root_tree_id(&f.store, &f.commit_id);
2668        let shallow_id = entry_id(&f.store, &root, b"shallow.txt");
2669        let exec_id = entry_id(&f.store, &root, b"exec.sh");
2670        let map = MapSource::from_store(&f.store);
2671        let liar = LyingSource {
2672            inner: &map,
2673            target: shallow_id,
2674            // A different, well-formed blob.
2675            lie: f.store.read(&exec_id).unwrap(),
2676        };
2677        let bundle =
2678            build_disclosure_from(&liar, &f.commit_id, &[b"shallow.txt"], Selector::Object)
2679                .unwrap();
2680        assert!(matches!(
2681            verify_disclosure(&f.commit_id, &bundle),
2682            Err(VerifyError::PayloadIdMismatch)
2683        ));
2684    }
2685
2686    #[test]
2687    fn disclosure_from_lying_tree_fails_at_that_path_step() {
2688        let f = build_fixture();
2689        let root = root_tree_id(&f.store, &f.commit_id);
2690        let sub_id = entry_id(&f.store, &root, b"sub");
2691        let deep_tree_id = entry_id(&f.store, &sub_id, b"deep");
2692        let shallow_id = entry_id(&f.store, &root, b"shallow.txt");
2693        // A well-formed forgery of `sub`: it still has the `deep` entry the
2694        // path needs, plus an extra one, so the builder walks straight
2695        // through it.
2696        let forged = Tree {
2697            entries: vec![
2698                TreeEntry {
2699                    name: b"aaa.txt".to_vec(),
2700                    mode: EntryMode::Blob,
2701                    object_hash: shallow_id,
2702                },
2703                TreeEntry {
2704                    name: b"deep".to_vec(),
2705                    mode: EntryMode::Tree,
2706                    object_hash: deep_tree_id,
2707                },
2708            ],
2709        };
2710        let map = MapSource::from_store(&f.store);
2711        let liar = LyingSource {
2712            inner: &map,
2713            target: sub_id,
2714            lie: crate::serialize::serialize(&Object::Tree(forged)).unwrap(),
2715        };
2716        let path: [&[u8]; 3] = [b"sub", b"deep", b"deep.txt"];
2717        let bundle = build_disclosure_from(&liar, &f.commit_id, &path, Selector::Object).unwrap();
2718        // Step 0 (root → sub) is honest; step 1 carries the forged tree's
2719        // inner root, which does not wrap to `sub`'s real id.
2720        let err = verify_disclosure(&f.commit_id, &bundle).unwrap_err();
2721        let VerifyError::InnerRootMismatch { expected, .. } = err else {
2722            panic!("expected InnerRootMismatch at the `sub` step, got {err:?}");
2723        };
2724        assert_eq!(expected, sub_id);
2725    }
2726
2727    #[test]
2728    fn disclosure_from_short_chunk_is_error_not_panic() {
2729        let f = build_fixture();
2730        let root = root_tree_id(&f.store, &f.commit_id);
2731        let chunked_id = entry_id(&f.store, &root, b"chunked.bin");
2732        let Object::ChunkedBlob(cb) = f.store.read_object(&chunked_id).unwrap() else {
2733            panic!("expected a chunked blob");
2734        };
2735        let map = MapSource::from_store(&f.store);
2736        for short_len in [0usize, 1, 9] {
2737            let liar = LyingSource {
2738                inner: &map,
2739                target: cb.chunks[0],
2740                lie: vec![0u8; short_len],
2741            };
2742            for with_offsets in [false, true] {
2743                let selector = Selector::Range {
2744                    offset: 200_000,
2745                    len: 64,
2746                    with_offsets,
2747                };
2748                let err = build_disclosure_from(&liar, &f.commit_id, &[b"chunked.bin"], selector)
2749                    .unwrap_err();
2750                assert!(
2751                    matches!(err, VerifyError::Decode(MkitError::UnexpectedEof)),
2752                    "{short_len}-byte chunk: got {err:?}"
2753                );
2754            }
2755        }
2756    }
2757
2758    #[test]
2759    fn disclosure_range_len_u64_max_is_error_not_panic() {
2760        let f = build_fixture();
2761        let map = MapSource::from_store(&f.store);
2762        let huge = |offset| Selector::Range {
2763            offset,
2764            len: u64::MAX,
2765            with_offsets: false,
2766        };
2767        for (path, offset) in [
2768            (b"chunked.bin".as_slice(), 10),
2769            (b"chunked.bin".as_slice(), 200_000),
2770            (b"shallow.txt".as_slice(), 1),
2771        ] {
2772            for result in [
2773                build_disclosure(&f.store, &f.commit_id, &[path], huge(offset)),
2774                build_disclosure_from(&map, &f.commit_id, &[path], huge(offset)),
2775            ] {
2776                let err = result.unwrap_err();
2777                assert!(
2778                    matches!(err, VerifyError::OffsetOverflow),
2779                    "{path:?} @ {offset}: got {err:?}"
2780                );
2781            }
2782        }
2783        // An offset at the very top of u64 is simply out of bounds.
2784        let top = Selector::Range {
2785            offset: u64::MAX,
2786            len: 1,
2787            with_offsets: true,
2788        };
2789        let err = build_disclosure(&f.store, &f.commit_id, &[b"chunked.bin"], top).unwrap_err();
2790        assert!(matches!(err, VerifyError::RangeOutOfBounds), "got {err:?}");
2791    }
2792
2793    #[test]
2794    fn disclosure_from_split_blob_source_is_error_not_panic() {
2795        let f = build_fixture();
2796        let root = root_tree_id(&f.store, &f.commit_id);
2797        let shallow_id = entry_id(&f.store, &root, b"shallow.txt");
2798        let map = MapSource::from_store(&f.store);
2799        // `read_object` claims a 4 KiB blob; `read` returns the real
2800        // 20-byte one, so the range is past the canonical bytes.
2801        let liar = SplitSource {
2802            inner: &map,
2803            target: shallow_id,
2804            object_lie: Object::Blob(crate::object::Blob {
2805                data: vec![0u8; 4096],
2806            }),
2807        };
2808        let selector = Selector::Range {
2809            offset: 2048,
2810            len: 512,
2811            with_offsets: false,
2812        };
2813        // Either a typed error or a bundle the verifier rejects; never a
2814        // panic, never a verifying bundle.
2815        if let Ok(bundle) = build_disclosure_from(&liar, &f.commit_id, &[b"shallow.txt"], selector)
2816        {
2817            assert!(verify_disclosure(&f.commit_id, &bundle).is_err());
2818        }
2819    }
2820}