verify.rs
This Kit feature is not available yet.
In rust/crates/mkit-core/src/
Preview
Code · 104.5 KiB//! Partial-disclosure verification: prove and verify that a single path,
//! chunk, or byte range belongs to a commit id, with proof bytes on the
//! order of a few KiB regardless of repository size.
//!
//! ## Trust model
//!
//! A verified [`Disclosed`] proves that its `payload` bytes are exactly
//! the content the given `commit_id` commits to at `path` — nothing more.
//! It does **not** prove:
//!
//! * that the disclosed path is the *only* thing at that location (no
//! sibling-completeness claim — see the closure profile, issue #1015
//! PR 3, for that);
//! * anything about who the signer *is* — `signer`/`signature_valid`
//! report whether the embedded Ed25519 signature verifies against the
//! embedded public key; binding that key to an identity or trust level
//! is application policy (trust roots), exactly as
//! [`crate::sign::verify_commit`] already documents.
//!
//! Every function in this module verifies against an **object id**, never
//! a bare (pre-domain-wrap) inner root or an unauthenticated byte range —
//! see [`crate::merkle`]'s module docs for why that distinction matters.
//!
//! Wire format, verification algorithm, and bounds are pinned in
//! `docs/specs/SPEC-DISCLOSURE.md`; golden vectors live under
//! `rust/tests/golden/disclosure/`. See issue #1015 (verifier kit) PR 2.
//!
//! ## Layering
//!
//! [`verify_object_id`], [`verify_path`], [`verify_chunk_with_meta`],
//! [`verify_blob_slice`], and [`verify_blob_len_proof`] are the small,
//! independently-useful primitives (each corresponds to one hop of the
//! authentication chain: commit → tree steps → leaf → chunk/slice).
//! [`verify_disclosure`] decodes a self-contained wire bundle (§ below)
//! and composes them into one call. [`build_disclosure_from`] is the
//! producer side, generic over any verifying [`crate::store::ObjectSource`]
//! (the on-disk store, an in-memory overlay, or a server-side index or
//! object CAS); [`build_disclosure`] is its thin wrapper over
//! [`crate::store::ObjectStore`]. The builder's source trait is distinct
//! from this module's [`ObjectSource`], the non-verifying
//! `fetch(&mut self)` trait the closure walker reads through. Full
//! disclosure — every reachable object against a commit id — is
//! [`verify_closure`], [`verify_closure_streaming`],
//! [`verify_closure_packs`], and [`verify_closure_manifest`], with
//! [`verify_closure_store`] and [`export_closure`] as native store-backed
//! operations (issue #1015 PR 3).
//!
//! Everything here, the builder included, compiles with
//! `--no-default-features` and targets `wasm32-unknown-unknown`; no
//! separate feature gate is needed since `mkit-core` itself is a `std`
//! crate (see the crate root docs).
//!
//! ## Wire bundle (`verify_disclosure` / `build_disclosure`)
//!
//! ```text
//! magic = "MKDP" (4 raw bytes)
//! version: u8 = 2
//! commit_id: [u8; 32]
//! commit_bytes: Vec<u8> <= 4 MiB
//! steps: Vec<Step> <= MAX_TREE_DEPTH (128), root first
//! Step { name: Vec<u8> (1..=255), mode: u8, child_id: [u8; 32],
//! inner_root: [u8; 32], position: u32, proof: Proof (max_items = 1) }
//! payload_kind: u8
//! 0 Object { bytes: Vec<u8> <= MAX_RAW_OBJECT_SIZE }
//! 1 Chunk { total_size: u64, chunk_size: u32, index: u32,
//! inner_root: [u8; 32], proof: Proof (max_items = 2),
//! bytes: Vec<u8> }
//! 2 Range { chunk: Option<ChunkHdr>, offset_in_blob: u64, len: u64,
//! slice: Vec<u8>, chunk_len_proofs: Vec<LenProof> }
//! ChunkHdr { total_size: u64, chunk_size: u32, index: u32,
//! inner_root: [u8; 32], chunk_id: [u8; 32],
//! proof: Proof (max_items = 2) }
//! LenProof { index: u32, chunk_id: [u8; 32],
//! proof: Proof (max_items = 1), slice: Vec<u8> }
//! ```
//!
//! All integers are big-endian, every length is an LEB128 varint, arrays
//! are raw — `commonware-codec` conventions (SPEC-CONVENTIONS §3;
//! `crate::transfer`'s packlist node is the existing precedent for this
//! house style). The whole bundle is capped at [`MAX_BUNDLE_BYTES`];
//! trailing bytes are rejected.
//!
//! `ChunkHdr.chunk_id` is not literal to issue #1015's original design
//! note, which omitted it: without an explicit chunk id, a verifier
//! reading a byte-range bundle would have no value to check the Bao slice
//! against before folding it into the enclosing `ChunkedBlob`'s multi-proof
//! (Bao's `SliceDecoder` always requires the expected root up front — it
//! cannot recover an unknown one from the slice, by construction). Adding
//! this field closes that gap; it is exactly the value
//! [`verify_chunk_with_meta`]'s multi-proof authenticates, so a forged
//! `chunk_id` fails to fold to the enclosing `ChunkedBlob`'s id.
use std::collections::BTreeMap;
use std::io::Read as _;
use bytes::{Buf, BufMut};
use commonware_codec::{EncodeSize, Error as CodecError, Read, ReadExt, ReadRangeExt, Write};
use crate::hash::{Hash, hash};
use crate::merkle::{self, MerkleError, ObjectKind, Proof};
use crate::object::{EntryMode, MAGIC, MkitError, Object, ObjectType, SCHEMA_VERSION, TreeEntry};
use crate::sign::{verify_commit, verify_remix};
use crate::store::MAX_TREE_DEPTH;
/// Hard cap on a whole encoded disclosure bundle, checked before any
/// decode work happens. `mkit-wasm` additionally caps decoded objects at
/// 16 MiB; this 64 MiB ceiling is the wasm32-agnostic bound this crate
/// itself enforces.
pub const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024;
/// Fixed 4-byte magic at the start of every disclosure bundle.
const BUNDLE_MAGIC: &[u8; 4] = b"MKDP";
/// Current (and only) bundle version byte. v1 is rejected with
/// [`VerifyError::UnsupportedBundleVersion`]; there is no compatibility
/// decoder (SPEC-DISCLOSURE v2, issue #1024).
const BUNDLE_VERSION: u8 = 2;
/// Cap on `commit_bytes` — comfortably above any real commit (~250 B) or
/// remix (larger, due to `sources`), far below `MAX_RAW_OBJECT_SIZE`.
const MAX_COMMIT_BYTES: usize = 4 * 1024 * 1024;
/// Cap on a single length-proof's Bao slice. Proving 10 bytes needs one
/// 1 KiB Bao leaf chunk plus at most `MAX_LEVELS` (32) 64-byte parent
/// hashes plus an 8-byte header — comfortably under 4 KiB; 8 KiB leaves
/// headroom without opening a real allocation hazard.
const MAX_LEN_PROOF_SLICE_BYTES: usize = 8 * 1024;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Errors verifying (or, natively, building) a disclosure.
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
/// The encoded bundle exceeds [`MAX_BUNDLE_BYTES`].
#[error("disclosure bundle exceeds the {MAX_BUNDLE_BYTES} byte cap")]
BundleTooLarge,
/// The bundle is shorter than the fixed magic+version header, or the
/// magic bytes are not `"MKDP"`.
#[error("disclosure bundle magic is not \"MKDP\"")]
BadMagic,
/// The bundle's version byte is not `2`. A version byte of `1` is
/// this error with payload `1`; there is no v1 compatibility decoder.
#[error("disclosure bundle version {0} is not supported (v2 only)")]
UnsupportedBundleVersion(u8),
/// A step's or chunk header's declared `inner_root` does not wrap to
/// the expected object id (`Tree` domain for steps, `ChunkedBlob` domain
/// for chunk headers). Checked before the field is used for anything.
#[error("declared inner root does not wrap to the expected object id")]
InnerRootMismatch {
/// The parent `Tree` id (steps) or `ChunkedBlob` leaf id (chunk headers).
expected: Hash,
/// The prover-supplied inner root that failed the wrap check.
inner_root: Hash,
},
/// The proof folds to a different root than the bundle declared,
/// even though the declared root wraps to the expected id (or vice
/// versa). Both must agree.
#[error("proof fold does not equal the declared inner root")]
InnerRootFoldMismatch,
/// The codec body is malformed: truncated, an over-cap length, an
/// invalid varint, or trailing bytes after the declared body.
#[error("disclosure bundle body is malformed (bad codec payload or trailing bytes)")]
Malformed,
/// The bundle's embedded `commit_id` field does not match the id the
/// caller asked to verify against.
#[error("disclosure bundle's embedded commit_id does not match the requested commit id")]
CommitIdMismatch,
/// `BLAKE3(commit_bytes)` does not equal the commit id being verified.
#[error("commit_bytes do not hash to the commit id being verified")]
CommitBytesHashMismatch,
/// `commit_bytes` decoded to an object type other than `Commit` or
/// `Remix` — the only two kinds that carry a `tree_hash` a path can
/// be walked from.
#[error("commit_bytes decode to a {0:?}, which is not a Commit or Remix")]
NotACommitOrRemix(ObjectType),
/// More steps than [`crate::store::MAX_TREE_DEPTH`] were supplied.
#[error("{0} steps exceeds MAX_TREE_DEPTH ({MAX_TREE_DEPTH})")]
TooManySteps(usize),
/// The entry name at the given step index fails SPEC-OBJECTS §4.1
/// name validation ([`TreeEntry::validate_name`]).
#[error("step {0}'s entry name fails SPEC-OBJECTS §4.1 validation")]
InvalidEntryName(usize),
/// A non-final step's mode is not [`EntryMode::Tree`] — every step
/// but the last MUST descend into a directory.
#[error("step {0} is not the final step but its mode is not Tree")]
NonFinalStepNotTree(usize),
/// A step's, or the payload's, merkle inclusion proof failed to
/// verify — wraps every [`MerkleError`] variant (out-of-range
/// position, unaligned proof, or a folded value that does not match
/// the expected id).
#[error("merkle proof verification failed: {0}")]
Merkle(#[from] MerkleError),
/// The bundle's `payload_kind` byte is not one of the three defined
/// kinds (`0` Object, `1` Chunk, `2` Range).
#[error("payload_kind byte {0:#04x} is not a recognized disclosure payload kind")]
InvalidPayloadKind(u8),
/// An `Object` payload's `BLAKE3`/merkle id does not equal the
/// authenticated leaf id.
#[error("disclosed Object payload's id does not equal the authenticated leaf id")]
PayloadIdMismatch,
/// A chunk `index` (or a length-proof's `index`) is out of range for
/// the `ChunkedBlob`'s proven `leaf_count`.
#[error("chunk index {index} is out of range for a {leaf_count}-leaf ChunkedBlob proof")]
ChunkIndexOutOfRange {
/// The out-of-range chunk index.
index: u32,
/// The proof's claimed `leaf_count` (chunk count + 1, for the
/// metadata leaf).
leaf_count: u32,
},
/// A proof's implied chunk count (`leaf_count - 1`) exceeds
/// `serialize::MAX_CHUNKS` (crate-private, not linkable from here).
#[error("proof leaf_count implies more chunks than MAX_CHUNKS allows")]
TooManyChunks,
/// A `Range` payload's `len` is zero — there is nothing to disclose
/// or verify.
#[error("byte range length is zero")]
ZeroLengthRange,
/// `chunk_len_proofs` was non-empty but did not cover exactly
/// `0..index` with no gaps or duplicates — an incomplete or malformed
/// set is a typed error, never silently treated as "absent".
#[error("chunk_len_proofs does not cover exactly indices 0..{0} with no gaps or duplicates")]
IncompleteLengthProofSet(u32),
/// `chunk_len_proofs` was non-empty on a `Range` payload that has no
/// chunk before it to describe: either the leaf is a plain `Blob` (no
/// chunk at all), or the disclosed chunk is index 0 (nothing precedes
/// the first chunk).
#[error("chunk_len_proofs is only meaningful for a ChunkedBlob range at chunk index > 0")]
UnexpectedLengthProofs,
/// A Bao slice failed to verify against the expected root/offset/len.
#[error("Bao slice verification failed: {0}")]
Bao(String),
/// A Bao slice decoded to fewer bytes than the claimed `len`.
#[error("Bao slice yielded {got} bytes, expected {expected}")]
ShortSlice {
/// The requested length.
expected: u64,
/// The number of bytes the slice actually decoded to.
got: usize,
},
/// [`verify_blob_len_proof`]'s bytes 0..6 are not a valid v1 Blob
/// prologue (wrong object type, magic, or schema version byte).
#[error("blob length-proof bytes are not a valid v1 Blob prologue")]
InvalidBlobPrologue,
/// An offset/length computation would overflow `u64`.
#[error("offset/length computation overflowed u64")]
OffsetOverflow,
/// A byte range's canonical decode/serialize error.
#[error(transparent)]
Decode(#[from] MkitError),
/// [`Selector::Chunk`]/[`Selector::Range`] was used against a leaf
/// whose object type does not support that selector (e.g. `Chunk` on
/// a plain `Blob`, or either on a `Tree`).
#[error("selector does not match the disclosed leaf's object type")]
SelectorLeafMismatch,
/// [`build_disclosure`]: the requested range crosses a `ChunkedBlob`
/// chunk boundary — unsupported in this (v1) profile.
#[error("byte range crosses a chunk boundary, which this profile does not support")]
RangeCrossesChunkBoundary,
/// [`build_disclosure`]: the requested range is out of bounds for the
/// leaf's actual content length.
#[error("byte range is out of bounds for the leaf's content length")]
RangeOutOfBounds,
/// [`build_disclosure`]: a path component was not found in its
/// parent tree.
#[error("path component {0} was not found in its parent tree")]
PathNotFound(usize),
/// [`build_disclosure`]: the path continues past a non-`Tree` entry,
/// or the requested selector needs a leaf that isn't reached because
/// an intermediate component isn't a directory.
#[error("path continues past a non-Tree entry")]
PathThroughNonTree,
/// [`build_disclosure`]: the underlying object store returned an
/// error.
#[error(transparent)]
Store(#[from] crate::store::StoreError),
/// The supplied object set exceeds [`crate::pack::MAX_ENTRIES`].
#[error("closure object set exceeds the pack MAX_ENTRIES cap")]
TooManyClosureObjects,
/// The closure root deserialized but is not a `Commit`, `Remix`, or `Tag`.
#[error("closure root is a {0:?}, which is not a Commit, Remix, or Tag")]
ClosureRootWrongType(ObjectType),
/// A pack in the closure profile contained a delta or compressed entry.
#[error(
"closure pack {pack_index} entry {entry_index} is not a raw (0x00) entry (closure profile is raw-only)"
)]
ClosureProfileViolation {
/// Index of the offending pack in the caller-supplied list.
pack_index: usize,
/// Index of the offending entry inside that pack.
entry_index: usize,
},
/// The encoded closure manifest is shorter than the magic+version
/// header, or the magic bytes are not `"MKCL"`.
#[error("closure manifest magic is not \"MKCL\"")]
ClosureManifestBadMagic,
/// The manifest's version byte is not `1`.
#[error("closure manifest version {0} is not supported (v1 only)")]
ClosureManifestUnsupportedVersion(u8),
/// The manifest body is malformed: truncated, an over-cap pack
/// list, an unknown mode byte, or trailing bytes.
#[error(
"closure manifest body is malformed (bad codec payload, unknown mode, or trailing bytes)"
)]
ClosureManifestMalformed,
/// The number of packs supplied does not match the manifest.
#[error("closure manifest lists {expected} packs but {got} were supplied")]
ClosurePackCountMismatch {
/// Pack count recorded in the manifest.
expected: usize,
/// Number of pack buffers the caller handed the verifier.
got: usize,
},
/// `pack_key(packs[index])` does not equal the manifest entry.
#[error("pack {index} hash does not match the closure manifest")]
ClosurePackKeyMismatch {
/// Index of the mismatched pack.
index: usize,
},
/// The manifest's `root` does not equal the caller-supplied trusted root.
/// The manifest is a locator, never a trust anchor.
#[error("closure manifest root does not match the caller's trusted root")]
ClosureRootMismatch {
/// Root the caller asked to verify against.
expected: Hash,
/// Root the manifest named.
got: Hash,
},
/// A packfile framing/decode error while iterating a closure pack.
#[error(transparent)]
Pack(#[from] crate::pack::PackError),
}
impl From<CodecError> for VerifyError {
fn from(_: CodecError) -> Self {
// Mirrors `merkle::MerkleError`'s `From<CodecError>`: every codec
// failure (truncation, an over-cap length, a bad varint, trailing
// bytes) collapses to one "malformed" outcome — the caller never
// needs to distinguish which codec primitive tripped, only that
// decode failed rather than that verification failed.
Self::Malformed
}
}
mod closure;
pub use closure::{
ClosureExport, ClosureManifest, ClosureReport, MAX_CLOSURE_PACKS, ObjectSource, export_closure,
verify_closure, verify_closure_manifest, verify_closure_packs, verify_closure_store,
verify_closure_streaming,
};
mod push;
pub use push::{PushReport, verify_push};
/// Exact proof wire-size planning from canonical object metadata.
pub mod proof_size;
pub mod span;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// One authenticated hop of a path: the entry `name`/`mode` proven at
/// `position` under its parent (a commit's `tree_hash`, or the previous
/// step's `child_id`), via a single-leaf [`Proof`].
///
/// This is both the in-memory type [`verify_path`] takes and the wire
/// representation inside a disclosure bundle (§ module docs) — there is
/// no separate internal wire struct to keep in sync.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Step {
/// The entry's name (1..=255 bytes; SPEC-OBJECTS §4.1 rules apply).
pub name: Vec<u8>,
/// The entry's mode.
pub mode: EntryMode,
/// The entry's child object id.
pub child_id: Hash,
/// Bare BMT root of the **parent** Tree this step's proof is verified
/// against (the tree whose id is the commit's `tree_hash` for step 0,
/// or the previous step's `child_id`). Wrap-checked against that id
/// before use; never a second trust anchor.
pub inner_root: Hash,
/// The entry's BMT position (= index) within its parent `Tree`.
pub position: u32,
/// Single-leaf inclusion proof (`max_items = 1`) that `(name, mode,
/// child_id)` is the entry at `position` in the parent tree.
pub proof: Proof,
}
/// The result of walking and verifying a path from a commit's
/// `tree_hash` down to a leaf.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathVerified {
/// The commit's (or remix's) root tree id.
pub tree_hash: Hash,
/// The authenticated path, root first: each entry's `(name, mode)` —
/// never a caller-claimed path string. Callers compare this against
/// what they requested; this module never trusts a claimed path.
pub path: Vec<(Vec<u8>, EntryMode)>,
/// The id of the object at the end of the path (`tree_hash` itself
/// when `path` is empty).
pub leaf_id: Hash,
/// The commit/remix's embedded Ed25519 public key.
pub signer: [u8; 32],
/// Whether the commit/remix's embedded signature verifies against
/// `signer`. Does **not** say anything about who `signer` belongs to
/// — that is application policy (see the module docs).
pub signature_valid: bool,
}
/// A verified partial disclosure: `payload` is exactly the content
/// `commit_id` commits to at `path`, and nothing more. See the module
/// docs for the precise trust model.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Disclosed {
/// The commit id the disclosure was verified against.
pub commit_id: Hash,
/// The commit's (or remix's) root tree id.
pub tree_hash: Hash,
/// The authenticated path, root first (see [`PathVerified::path`]).
pub path: Vec<(Vec<u8>, EntryMode)>,
/// The id of the disclosed leaf object.
pub leaf_id: Hash,
/// The disclosed content.
pub payload: DisclosedPayload,
/// The commit/remix's embedded Ed25519 public key.
pub signer: [u8; 32],
/// Whether the commit/remix's embedded signature verifies. See
/// [`PathVerified::signature_valid`].
pub signature_valid: bool,
/// Authenticated bare BMT inner root of each step's parent Tree,
/// root first. Empty when `steps` is empty (root-tree disclosure).
/// Each value has been wrap-checked against the parent id and
/// cross-checked against the proof fold.
pub step_inner_roots: Vec<Hash>,
/// Authenticated bare BMT inner root of the leaf `ChunkedBlob`, when
/// the payload is `Chunk` or a `Range` over a chunk. `None` for an
/// `Object` payload or a `Range` over a plain `Blob`.
pub chunk_inner_root: Option<Hash>,
}
/// The disclosed content of a [`Disclosed`] result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DisclosedPayload {
/// The leaf's full canonical object bytes; `hash`/`id_from_object` of
/// `bytes` equals `Disclosed::leaf_id`.
Object {
/// Canonical object bytes.
bytes: Vec<u8>,
},
/// One whole chunk of a `ChunkedBlob` leaf, plus its authenticated
/// manifest metadata.
Chunk {
/// The `ChunkedBlob`'s authenticated total content size.
total_size: u64,
/// The `ChunkedBlob`'s authenticated chunk-size marker (`0` =
/// content-defined chunking).
chunk_size: u32,
/// The chunk's index within the manifest.
index: u32,
/// The chunk's canonical `Blob` bytes.
bytes: Vec<u8>,
},
/// A byte range of a `Blob`, or of one chunk of a `ChunkedBlob`.
Range {
/// The id of the object the range was sliced from: the leaf
/// itself when it is a plain `Blob`, or the containing chunk's
/// id when the leaf is a `ChunkedBlob`.
blob_id: Hash,
/// `Some((index, total_size, chunk_size))` when the leaf is a
/// `ChunkedBlob` (the authenticated manifest metadata for the
/// containing chunk); `None` when the leaf is a plain `Blob`.
chunk: Option<(u32, u64, u32)>,
/// The range's offset within `blob_id`'s content (the chunk's
/// content when `chunk` is `Some`, the blob's content otherwise).
offset_in_blob: u64,
/// The range's offset within the *whole* disclosed file, when
/// provable: `Some(offset_in_blob)` for a plain `Blob` (nothing
/// further to prove) or for a `ChunkedBlob` at `index == 0`
/// (nothing precedes the first chunk, so no proof set is needed —
/// and [`VerifyError::UnexpectedLengthProofs`] rejects a
/// non-empty one there rather than silently ignoring it); for a
/// `ChunkedBlob` at `index > 0`, `Some(sum of every preceding
/// chunk's authenticated length + offset_in_blob)` when a complete
/// `0..index` length-proof set was supplied, else `None` (not
/// requested).
absolute_offset: Option<u64>,
/// The disclosed bytes.
bytes: Vec<u8>,
},
}
/// What [`build_disclosure`] discloses about the leaf its `path` reaches.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Selector {
/// Disclose the leaf's full canonical object bytes.
Object,
/// Disclose one whole chunk of a `ChunkedBlob` leaf, by index.
Chunk(u32),
/// Disclose a byte range of a `Blob`/`ChunkedBlob` leaf's content.
Range {
/// Start offset within the leaf's content.
offset: u64,
/// Range length in bytes (MUST be non-zero).
len: u64,
/// When the leaf is a `ChunkedBlob`, also emit length proofs for
/// every chunk before the one containing `offset`, so a verifier
/// can compute an absolute file offset.
with_offsets: bool,
},
}
// ---------------------------------------------------------------------------
// Primitives
// ---------------------------------------------------------------------------
/// Deserialize `bytes` and check that its content-address (BLAKE3 of the
/// bytes, or the merkle BMT root for a `Tree`/`ChunkedBlob`) equals
/// `expected`.
///
/// # Errors
///
/// [`VerifyError::Decode`] if `bytes` does not decode; otherwise
/// [`VerifyError::PayloadIdMismatch`] if the derived id disagrees with
/// `expected`.
/// Wrap-check-first: `domain_digest(TYPE_DOMAIN, inner_root) == expected_id`.
/// The declared field is never a second trust anchor.
fn check_inner_root_wrap(
kind: ObjectKind,
expected_id: &Hash,
inner_root: &Hash,
) -> Result<(), VerifyError> {
if merkle::wrap_id(kind, inner_root) == *expected_id {
Ok(())
} else {
Err(VerifyError::InnerRootMismatch {
expected: *expected_id,
inner_root: *inner_root,
})
}
}
/// Proof fold equals the declared inner root, then wrap of the fold
/// equals `expected_id` (today's id check). Both must hold.
fn check_inner_root_fold(
folded: Hash,
declared: &Hash,
expected_id: &Hash,
kind: ObjectKind,
) -> Result<(), VerifyError> {
if folded != *declared {
return Err(VerifyError::InnerRootFoldMismatch);
}
if merkle::wrap_id(kind, &folded) == *expected_id {
Ok(())
} else {
Err(VerifyError::Merkle(MerkleError::VerificationFailed))
}
}
pub fn verify_object_id(bytes: &[u8], expected: &Hash) -> Result<Object, VerifyError> {
let obj = crate::serialize::deserialize(bytes)?;
let got = crate::object::id_from_object(&obj, bytes);
if &got == expected {
Ok(obj)
} else {
Err(VerifyError::PayloadIdMismatch)
}
}
/// Verify a path of [`Step`]s from a commit/remix's `tree_hash` down to a
/// leaf id, checking each step's proof against the previous step's
/// `child_id` (the first against `tree_hash` itself).
///
/// `commit_bytes` MUST hash to `commit_id` and decode to a `Commit` or
/// `Remix` — every other object kind has no `tree_hash` to walk from.
/// Every non-final step MUST have `mode == Tree`. The returned `path` is
/// the authenticated `(name, mode)` sequence; callers compare it against
/// whatever path string they requested — this function never trusts one.
///
/// # Errors
///
/// See [`VerifyError`]'s variant docs for the specific failure reported.
pub fn verify_path(
commit_id: &Hash,
commit_bytes: &[u8],
steps: &[Step],
) -> Result<PathVerified, VerifyError> {
if hash(commit_bytes) != *commit_id {
return Err(VerifyError::CommitBytesHashMismatch);
}
if steps.len() > MAX_TREE_DEPTH {
return Err(VerifyError::TooManySteps(steps.len()));
}
let obj = crate::serialize::deserialize(commit_bytes)?;
let (tree_hash, signer, signature_valid) = match &obj {
Object::Commit(c) => (c.tree_hash, c.signer, verify_commit(c).is_ok()),
Object::Remix(r) => (r.tree_hash, r.signer, verify_remix(r).is_ok()),
other => return Err(VerifyError::NotACommitOrRemix(other.object_type())),
};
verify_path_with_context(
steps,
&CommitContext {
tree_hash,
signer,
signature_valid,
},
)
}
/// Commit authentication shared by all bundles in one MKDS container.
#[derive(Clone, Copy)]
pub(crate) struct CommitContext {
tree_hash: Hash,
signer: [u8; 32],
signature_valid: bool,
}
impl CommitContext {
pub(crate) fn from_disclosed(d: &Disclosed) -> Self {
Self {
tree_hash: d.tree_hash,
signer: d.signer,
signature_valid: d.signature_valid,
}
}
}
/// Reuse a commit that the MKDS anchor has authenticated, but still bind
/// each inner bundle's own commit bytes to the trusted id.
pub(crate) fn verify_path_reusing_context(
commit_id: &Hash,
commit_bytes: &[u8],
steps: &[Step],
context: &CommitContext,
) -> Result<PathVerified, VerifyError> {
if hash(commit_bytes) != *commit_id {
return Err(VerifyError::CommitBytesHashMismatch);
}
verify_path_with_context(steps, context)
}
fn verify_path_with_context(
steps: &[Step],
context: &CommitContext,
) -> Result<PathVerified, VerifyError> {
if steps.len() > MAX_TREE_DEPTH {
return Err(VerifyError::TooManySteps(steps.len()));
}
let mut expected_parent = context.tree_hash;
let mut leaf_id = context.tree_hash;
let mut path = Vec::with_capacity(steps.len());
let last = steps.len().wrapping_sub(1);
for (i, step) in steps.iter().enumerate() {
if !TreeEntry::validate_name(&step.name) {
return Err(VerifyError::InvalidEntryName(i));
}
if i != last && step.mode != EntryMode::Tree {
return Err(VerifyError::NonFinalStepNotTree(i));
}
let entry = TreeEntry {
name: step.name.clone(),
mode: step.mode,
object_hash: step.child_id,
};
check_inner_root_wrap(ObjectKind::Tree, &expected_parent, &step.inner_root)?;
let folded = step
.proof
.reconstruct_element_root(&merkle::tree_entry_leaf(&entry), step.position)?;
check_inner_root_fold(folded, &step.inner_root, &expected_parent, ObjectKind::Tree)?;
path.push((step.name.clone(), step.mode));
expected_parent = step.child_id;
leaf_id = step.child_id;
}
Ok(PathVerified {
tree_hash: context.tree_hash,
path,
leaf_id,
signer: context.signer,
signature_valid: context.signature_valid,
})
}
/// Verify that a `ChunkedBlob`'s claimed `total_size`/`chunk_size` and
/// the chunk `chunk_hash` at `index` both belong to the `ChunkedBlob`
/// whose id is `chunked_id`, via one multi-proof over BMT positions `{0,
/// index + 1}`.
///
/// The metadata leaf (position 0) is computed here from `total_size`/
/// `chunk_size` — never accepted as an externally supplied leaf digest —
/// so a forged pair fails to fold to `chunked_id`.
///
/// # Errors
///
/// [`VerifyError::ChunkIndexOutOfRange`]/[`VerifyError::TooManyChunks`]
/// for an out-of-bound `index` or an implausible `proof.leaf_count`;
/// otherwise [`VerifyError::Merkle`] if the multi-proof does not verify.
pub fn verify_chunk_with_meta(
chunked_id: &Hash,
total_size: u64,
chunk_size: u32,
chunk_hash: &Hash,
index: u32,
proof: &Proof,
) -> Result<(), VerifyError> {
let chunk_count = proof
.leaf_count
.checked_sub(1)
.ok_or(VerifyError::ChunkIndexOutOfRange {
index,
leaf_count: proof.leaf_count,
})?;
if chunk_count > crate::serialize::MAX_CHUNKS {
return Err(VerifyError::TooManyChunks);
}
let position = index
.checked_add(1)
.filter(|&p| p < proof.leaf_count)
.ok_or(VerifyError::ChunkIndexOutOfRange {
index,
leaf_count: proof.leaf_count,
})?;
merkle::verify_chunk_with_meta_leaf(
chunked_id, total_size, chunk_size, chunk_hash, position, proof,
)
.map_err(VerifyError::from)
}
/// Like [`verify_chunk_with_meta`], plus the two SPEC-DISCLOSURE v2
/// inner-root rules: wrap-check-first against `chunked_id`, then the
/// multi-proof fold equals `inner_root`.
fn verify_chunk_with_declared_root(
chunked_id: &Hash,
inner_root: &Hash,
total_size: u64,
chunk_size: u32,
chunk_hash: &Hash,
index: u32,
proof: &Proof,
) -> Result<(), VerifyError> {
let chunk_count = proof
.leaf_count
.checked_sub(1)
.ok_or(VerifyError::ChunkIndexOutOfRange {
index,
leaf_count: proof.leaf_count,
})?;
if chunk_count > crate::serialize::MAX_CHUNKS {
return Err(VerifyError::TooManyChunks);
}
let position = index
.checked_add(1)
.filter(|&p| p < proof.leaf_count)
.ok_or(VerifyError::ChunkIndexOutOfRange {
index,
leaf_count: proof.leaf_count,
})?;
check_inner_root_wrap(ObjectKind::ChunkedBlob, chunked_id, inner_root)?;
let meta_leaf = merkle::chunked_meta_leaf_raw(total_size, chunk_size);
let folded = proof.reconstruct_multi_root(&[(meta_leaf, 0u32), (*chunk_hash, position)])?;
check_inner_root_fold(folded, inner_root, chunked_id, ObjectKind::ChunkedBlob)
}
/// Verify a Bao slice against `root`/`bao_offset`/`len`, returning the
/// decoded bytes. Shared by [`verify_blob_slice`] (content offset + 10)
/// and [`verify_blob_len_proof`] (raw offset 0, len 10 — the canonical
/// prologue itself).
fn bao_verify_slice(
root: &Hash,
bao_offset: u64,
len: u64,
slice: &[u8],
) -> Result<Vec<u8>, VerifyError> {
let h: bao::Hash = (*root).into();
let mut decoder =
bao::decode::SliceDecoder::new(std::io::Cursor::new(slice), &h, bao_offset, len);
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.map_err(|e| VerifyError::Bao(e.to_string()))?;
if out.len() as u64 != len {
return Err(VerifyError::ShortSlice {
expected: len,
got: out.len(),
});
}
Ok(out)
}
/// Verify a Bao slice proving `len` content bytes at `content_offset`
/// belong to the object (`Blob`, or one `ChunkedBlob` chunk) whose
/// canonical-bytes BLAKE3 is `blob_id`. `slice` is expected in the format
/// `bao::encode::SliceExtractor` produces over the object's **canonical**
/// bytes (prologue ‖ `le32(len)` ‖ data) — so a Bao-authenticated content
/// offset `o` maps to Bao offset `o + 10`, and the returned root equals
/// the object's own id (SPEC-OBJECTS §3).
///
/// # Errors
///
/// [`VerifyError::ZeroLengthRange`] if `len == 0`;
/// [`VerifyError::OffsetOverflow`] if `content_offset + 10` overflows
/// `u64`; [`VerifyError::Bao`]/[`VerifyError::ShortSlice`] if the slice
/// does not verify or yields fewer bytes than `len`.
pub fn verify_blob_slice(
blob_id: &Hash,
content_offset: u64,
len: u64,
slice: &[u8],
) -> Result<Vec<u8>, VerifyError> {
if len == 0 {
return Err(VerifyError::ZeroLengthRange);
}
let bao_offset = content_offset
.checked_add(10)
.ok_or(VerifyError::OffsetOverflow)?;
bao_verify_slice(blob_id, bao_offset, len, slice)
}
/// Verify a Bao slice over canonical bytes `0..10` of the object whose
/// id is `blob_id`, and return the `le32` length field at bytes `6..10`
/// — the object's own declared content length, authenticated by the
/// first Bao leaf chunk (Bao's *encoding header* is not what is trusted
/// here; the canonical prologue bytes themselves are).
///
/// # Errors
///
/// [`VerifyError::InvalidBlobPrologue`] if the authenticated bytes are
/// not a valid v1 Blob prologue; otherwise the same Bao-decode errors as
/// [`verify_blob_slice`].
///
/// # Panics
///
/// Never in practice: `bao_verify_slice` (crate-private) guarantees
/// exactly 10 returned bytes for a requested `len` of 10, so the
/// `bytes[6..10]` slice always converts to `[u8; 4]`.
pub fn verify_blob_len_proof(blob_id: &Hash, slice: &[u8]) -> Result<u32, VerifyError> {
let bytes = bao_verify_slice(blob_id, 0, 10, slice)?;
if bytes[0] != ObjectType::Blob as u8 || bytes[1..5] != MAGIC || bytes[5] != SCHEMA_VERSION {
return Err(VerifyError::InvalidBlobPrologue);
}
let len_bytes: [u8; 4] = bytes[6..10]
.try_into()
.expect("bao_verify_slice guarantees exactly 10 bytes");
Ok(u32::from_le_bytes(len_bytes))
}
// ---------------------------------------------------------------------------
// Bundle: decode + verify
// ---------------------------------------------------------------------------
/// Wire representation of the `ChunkHdr` sub-message (Range payload,
/// chunked case). Kept private: callers see the authenticated
/// [`DisclosedPayload::Range`] fields, never this wire shape directly.
#[derive(Debug, Clone)]
struct ChunkHdr {
total_size: u64,
chunk_size: u32,
index: u32,
inner_root: Hash,
chunk_id: Hash,
proof: Proof,
}
impl Write for ChunkHdr {
fn write(&self, writer: &mut impl BufMut) {
self.total_size.write(writer);
self.chunk_size.write(writer);
self.index.write(writer);
self.inner_root.write(writer);
self.chunk_id.write(writer);
self.proof.write(writer);
}
}
impl EncodeSize for ChunkHdr {
fn encode_size(&self) -> usize {
self.total_size.encode_size()
+ self.chunk_size.encode_size()
+ self.index.encode_size()
+ self.inner_root.encode_size()
+ self.chunk_id.encode_size()
+ self.proof.encode_size()
}
}
impl Read for ChunkHdr {
type Cfg = ();
fn read_cfg(reader: &mut impl Buf, (): &Self::Cfg) -> Result<Self, CodecError> {
let total_size = u64::read(reader)?;
let chunk_size = u32::read(reader)?;
let index = u32::read(reader)?;
let inner_root = Hash::read(reader)?;
let chunk_id = Hash::read(reader)?;
let proof = Proof::read_cfg(reader, &2usize)?;
Ok(Self {
total_size,
chunk_size,
index,
inner_root,
chunk_id,
proof,
})
}
}
/// Wire representation of one `LenProof` entry (Range payload,
/// `with_offsets`). Kept private for the same reason as [`ChunkHdr`].
#[derive(Debug, Clone)]
struct LenProof {
index: u32,
chunk_id: Hash,
proof: Proof,
slice: Vec<u8>,
}
impl Write for LenProof {
fn write(&self, writer: &mut impl BufMut) {
self.index.write(writer);
self.chunk_id.write(writer);
self.proof.write(writer);
self.slice.as_slice().write(writer);
}
}
impl EncodeSize for LenProof {
fn encode_size(&self) -> usize {
self.index.encode_size()
+ self.chunk_id.encode_size()
+ self.proof.encode_size()
+ self.slice.as_slice().encode_size()
}
}
impl Read for LenProof {
type Cfg = ();
fn read_cfg(reader: &mut impl Buf, (): &Self::Cfg) -> Result<Self, CodecError> {
let index = u32::read(reader)?;
let chunk_id = Hash::read(reader)?;
let proof = Proof::read_cfg(reader, &1usize)?;
let slice = Vec::<u8>::read_range(reader, ..=MAX_LEN_PROOF_SLICE_BYTES)?;
Ok(Self {
index,
chunk_id,
proof,
slice,
})
}
}
impl Write for Step {
fn write(&self, writer: &mut impl BufMut) {
self.name.as_slice().write(writer);
(self.mode as u8).write(writer);
self.child_id.write(writer);
self.inner_root.write(writer);
self.position.write(writer);
self.proof.write(writer);
}
}
impl EncodeSize for Step {
fn encode_size(&self) -> usize {
self.name.as_slice().encode_size()
+ 1
+ self.child_id.encode_size()
+ self.inner_root.encode_size()
+ self.position.encode_size()
+ self.proof.encode_size()
}
}
impl Read for Step {
type Cfg = ();
fn read_cfg(reader: &mut impl Buf, (): &Self::Cfg) -> Result<Self, CodecError> {
let name = Vec::<u8>::read_range(reader, 1..=255)?;
let mode_byte = u8::read(reader)?;
let mode = EntryMode::from_u8(mode_byte).map_err(|_| CodecError::InvalidEnum(mode_byte))?;
let child_id = Hash::read(reader)?;
let inner_root = Hash::read(reader)?;
let position = u32::read(reader)?;
let proof = Proof::read_cfg(reader, &1usize)?;
Ok(Self {
name,
mode,
child_id,
inner_root,
position,
proof,
})
}
}
/// Wire payload variants (`payload_kind` byte). Kept private: the public
/// surface is [`DisclosedPayload`] (verified) and [`Selector`] (build
/// request); this is only the on-wire shape between them.
enum PayloadWire {
Object {
bytes: Vec<u8>,
},
Chunk {
total_size: u64,
chunk_size: u32,
index: u32,
inner_root: Hash,
proof: Proof,
bytes: Vec<u8>,
},
Range {
chunk: Option<ChunkHdr>,
offset_in_blob: u64,
len: u64,
slice: Vec<u8>,
chunk_len_proofs: Vec<LenProof>,
},
}
/// Decode a disclosure bundle's fixed header plus codec body. Returns the
/// bundle's own embedded `commit_id` (still to be checked against the
/// caller's expected id by [`verify_disclosure`]), `commit_bytes`,
/// `steps`, and the decoded payload.
fn decode_disclosure(bytes: &[u8]) -> Result<(Hash, Vec<u8>, Vec<Step>, PayloadWire), VerifyError> {
if bytes.len() > MAX_BUNDLE_BYTES {
return Err(VerifyError::BundleTooLarge);
}
if bytes.len() < 5 || bytes[..4] != *BUNDLE_MAGIC {
return Err(VerifyError::BadMagic);
}
let version = bytes[4];
if version != BUNDLE_VERSION {
return Err(VerifyError::UnsupportedBundleVersion(version));
}
let mut r: &[u8] = &bytes[5..];
let commit_id = Hash::read(&mut r)?;
let commit_bytes = Vec::<u8>::read_range(&mut r, ..=MAX_COMMIT_BYTES)?;
let steps = Vec::<Step>::read_range(&mut r, ..=MAX_TREE_DEPTH)?;
let payload_kind = u8::read(&mut r)?;
let payload = match payload_kind {
0 => {
let bytes = Vec::<u8>::read_range(&mut r, ..=crate::store::MAX_RAW_OBJECT_SIZE)?;
PayloadWire::Object { bytes }
}
1 => {
let total_size = u64::read(&mut r)?;
let chunk_size = u32::read(&mut r)?;
let index = u32::read(&mut r)?;
let inner_root = Hash::read(&mut r)?;
let proof = Proof::read_cfg(&mut r, &2usize)?;
let bytes = Vec::<u8>::read_range(&mut r, ..=crate::store::MAX_RAW_OBJECT_SIZE)?;
PayloadWire::Chunk {
total_size,
chunk_size,
index,
inner_root,
proof,
bytes,
}
}
2 => {
let chunk = Option::<ChunkHdr>::read(&mut r)?;
let offset_in_blob = u64::read(&mut r)?;
let len = u64::read(&mut r)?;
let slice = Vec::<u8>::read_range(&mut r, ..=MAX_BUNDLE_BYTES)?;
let chunk_len_proofs =
Vec::<LenProof>::read_range(&mut r, ..=crate::serialize::MAX_CHUNKS as usize)?;
PayloadWire::Range {
chunk,
offset_in_blob,
len,
slice,
chunk_len_proofs,
}
}
other => return Err(VerifyError::InvalidPayloadKind(other)),
};
if r.has_remaining() {
return Err(VerifyError::Malformed);
}
Ok((commit_id, commit_bytes, steps, payload))
}
/// Encode a disclosure bundle. The inverse of [`decode_disclosure`].
fn encode_disclosure(
commit_id: &Hash,
commit_bytes: &[u8],
steps: &[Step],
payload: &PayloadWire,
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(BUNDLE_MAGIC);
out.push(BUNDLE_VERSION);
commit_id.write(&mut out);
commit_bytes.write(&mut out);
steps.write(&mut out);
match payload {
PayloadWire::Object { bytes } => {
out.push(0);
bytes.as_slice().write(&mut out);
}
PayloadWire::Chunk {
total_size,
chunk_size,
index,
inner_root,
proof,
bytes,
} => {
out.push(1);
total_size.write(&mut out);
chunk_size.write(&mut out);
index.write(&mut out);
inner_root.write(&mut out);
proof.write(&mut out);
bytes.as_slice().write(&mut out);
}
PayloadWire::Range {
chunk,
offset_in_blob,
len,
slice,
chunk_len_proofs,
} => {
out.push(2);
chunk.write(&mut out);
offset_in_blob.write(&mut out);
len.write(&mut out);
slice.as_slice().write(&mut out);
chunk_len_proofs.write(&mut out);
}
}
out
}
/// Verify each preceding chunk's authenticated length and, if the set is
/// complete, sum them. See [`DisclosedPayload::Range::absolute_offset`]
/// for the precise semantics (including the `index == 0` special case).
///
/// # Errors
///
/// [`VerifyError::IncompleteLengthProofSet`] if `chunk_len_proofs` is
/// non-empty but does not cover exactly `0..index`; propagates
/// [`VerifyError::Merkle`]/Bao errors from an individual proof.
fn resolve_absolute_offset(
chunked_id: &Hash,
index: u32,
chunk_len_proofs: &[LenProof],
offset_in_blob: u64,
) -> Result<Option<u64>, VerifyError> {
if index == 0 {
// Nothing precedes the first chunk; the offset is already
// absolute regardless of whether the caller bothered to ask. A
// non-empty `chunk_len_proofs` here has no chunk before index 0 to
// describe, so it is rejected the same way the plain-blob path
// rejects one — never silently ignored.
if !chunk_len_proofs.is_empty() {
return Err(VerifyError::UnexpectedLengthProofs);
}
return Ok(Some(offset_in_blob));
}
if chunk_len_proofs.is_empty() {
return Ok(None);
}
let mut by_index: BTreeMap<u32, u32> = BTreeMap::new();
for lp in chunk_len_proofs {
if lp.index >= index || by_index.contains_key(&lp.index) {
return Err(VerifyError::IncompleteLengthProofSet(index));
}
merkle::verify_chunk(chunked_id, &lp.chunk_id, lp.index + 1, &lp.proof)?;
let len_j = verify_blob_len_proof(&lp.chunk_id, &lp.slice)?;
by_index.insert(lp.index, len_j);
}
if u32::try_from(by_index.len()).unwrap_or(u32::MAX) != index {
// Distinct, all < index, but fewer than `index` of them: a gap.
return Err(VerifyError::IncompleteLengthProofSet(index));
}
let sum: u64 = by_index.values().map(|&l| u64::from(l)).sum();
let absolute = sum
.checked_add(offset_in_blob)
.ok_or(VerifyError::OffsetOverflow)?;
Ok(Some(absolute))
}
fn compose_payload(leaf_id: Hash, payload: PayloadWire) -> Result<DisclosedPayload, VerifyError> {
match payload {
PayloadWire::Object { bytes } => {
verify_object_id(&bytes, &leaf_id)?;
Ok(DisclosedPayload::Object { bytes })
}
PayloadWire::Chunk {
total_size,
chunk_size,
index,
inner_root,
proof,
bytes,
} => {
let chunk_hash = hash(&bytes);
verify_chunk_with_declared_root(
&leaf_id,
&inner_root,
total_size,
chunk_size,
&chunk_hash,
index,
&proof,
)?;
Ok(DisclosedPayload::Chunk {
total_size,
chunk_size,
index,
bytes,
})
}
PayloadWire::Range {
chunk: None,
offset_in_blob,
len,
slice,
chunk_len_proofs,
} => {
if !chunk_len_proofs.is_empty() {
return Err(VerifyError::UnexpectedLengthProofs);
}
let bytes = verify_blob_slice(&leaf_id, offset_in_blob, len, &slice)?;
Ok(DisclosedPayload::Range {
blob_id: leaf_id,
chunk: None,
offset_in_blob,
absolute_offset: Some(offset_in_blob),
bytes,
})
}
PayloadWire::Range {
chunk: Some(hdr),
offset_in_blob,
len,
slice,
chunk_len_proofs,
} => {
// SPEC-DISCLOSURE §4: `len == 0` is rejected before the
// wrap/fold checks below, so it wins over e.g. InnerRootMismatch
// when a bundle is malformed both ways.
if len == 0 {
return Err(VerifyError::ZeroLengthRange);
}
verify_chunk_with_declared_root(
&leaf_id,
&hdr.inner_root,
hdr.total_size,
hdr.chunk_size,
&hdr.chunk_id,
hdr.index,
&hdr.proof,
)?;
let bytes = verify_blob_slice(&hdr.chunk_id, offset_in_blob, len, &slice)?;
let absolute_offset =
resolve_absolute_offset(&leaf_id, hdr.index, &chunk_len_proofs, offset_in_blob)?;
Ok(DisclosedPayload::Range {
blob_id: hdr.chunk_id,
chunk: Some((hdr.index, hdr.total_size, hdr.chunk_size)),
offset_in_blob,
absolute_offset,
bytes,
})
}
}
}
/// Decode and fully verify a disclosure bundle against `commit_id`.
///
/// # Errors
///
/// See [`VerifyError`]'s variant docs. In particular: a bundle whose
/// embedded `commit_id` disagrees with the argument, whose `commit_bytes`
/// does not hash to it, or whose payload fails its merkle/Bao check, is
/// rejected with a specific typed error — never a bare "invalid".
pub fn verify_disclosure(commit_id: &Hash, bundle: &[u8]) -> Result<Disclosed, VerifyError> {
let (wire_commit_id, commit_bytes, steps, payload) = decode_disclosure(bundle)?;
if wire_commit_id != *commit_id {
return Err(VerifyError::CommitIdMismatch);
}
let verified = verify_path(commit_id, &commit_bytes, &steps)?;
let step_inner_roots: Vec<Hash> = steps.iter().map(|s| s.inner_root).collect();
let chunk_inner_root = match &payload {
PayloadWire::Chunk { inner_root, .. } => Some(*inner_root),
PayloadWire::Range {
chunk: Some(hdr), ..
} => Some(hdr.inner_root),
_ => None,
};
let payload = compose_payload(verified.leaf_id, payload)?;
Ok(Disclosed {
commit_id: *commit_id,
tree_hash: verified.tree_hash,
path: verified.path,
leaf_id: verified.leaf_id,
payload,
signer: verified.signer,
signature_valid: verified.signature_valid,
step_inner_roots,
chunk_inner_root,
})
}
/// MKDS-only verification path. The anchor authenticated `context`; the
/// inner bundle still has to carry commit bytes hashing to `commit_id`.
pub(crate) fn verify_disclosure_reusing_context(
commit_id: &Hash,
bundle: &[u8],
context: &CommitContext,
) -> Result<Disclosed, VerifyError> {
let (wire_commit_id, commit_bytes, steps, payload) = decode_disclosure(bundle)?;
if wire_commit_id != *commit_id {
return Err(VerifyError::CommitIdMismatch);
}
let verified = verify_path_reusing_context(commit_id, &commit_bytes, &steps, context)?;
let step_inner_roots = steps.iter().map(|s| s.inner_root).collect();
let chunk_inner_root = match &payload {
PayloadWire::Chunk { inner_root, .. } => Some(*inner_root),
PayloadWire::Range {
chunk: Some(hdr), ..
} => Some(hdr.inner_root),
_ => None,
};
let payload = compose_payload(verified.leaf_id, payload)?;
Ok(Disclosed {
commit_id: *commit_id,
tree_hash: verified.tree_hash,
path: verified.path,
leaf_id: verified.leaf_id,
payload,
signer: verified.signer,
signature_valid: verified.signature_valid,
step_inner_roots,
chunk_inner_root,
})
}
// ---------------------------------------------------------------------------
// Builder (generic over `crate::store::ObjectSource`)
// ---------------------------------------------------------------------------
/// Build a Bao outboard encoding of `bytes` and extract a slice proving
/// `len` bytes at `bao_offset`.
fn extract_bao_slice(bytes: &[u8], bao_offset: u64, len: u64) -> Result<Vec<u8>, VerifyError> {
let (outboard, _root) = bao::encode::outboard(bytes);
let mut extractor = bao::encode::SliceExtractor::new_outboard(
std::io::Cursor::new(bytes),
std::io::Cursor::new(outboard),
bao_offset,
len,
);
let mut out = Vec::new();
extractor
.read_to_end(&mut out)
.map_err(|e| VerifyError::Bao(e.to_string()))?;
Ok(out)
}
/// Build a disclosure bundle proving `selector`'s content at `path` under
/// `commit_id`, reading only from `store`. `path` empty with
/// [`Selector::Object`] discloses the root tree.
///
/// A thin wrapper over [`build_disclosure_from`] for the on-disk
/// [`crate::store::ObjectStore`]; the bundle bytes are identical.
///
/// # Errors
///
/// [`VerifyError::Store`] for any missing/corrupt object; typed errors
/// (see [`VerifyError`]) for a path that does not resolve, a selector
/// that does not match the leaf's object type, an out-of-bounds range, or
/// a range that crosses a `ChunkedBlob` chunk boundary (unsupported in
/// this profile).
pub fn build_disclosure(
store: &crate::store::ObjectStore,
commit_id: &Hash,
path: &[&[u8]],
selector: Selector,
) -> Result<Vec<u8>, VerifyError> {
build_disclosure_from(store, commit_id, path, selector)
}
/// Build a disclosure bundle proving `selector`'s content at `path` under
/// `commit_id`, reading only through `source`: any
/// [`crate::store::ObjectSource`] (the on-disk store, an
/// [`crate::store::EphemeralSink`], or a server-side repository index or
/// object CAS). Not to be confused with [`ObjectSource`], this module's
/// non-verifying closure-walker trait (`fetch(&mut self)`).
///
/// `source` MUST return verified bytes from `read`/`read_object` (the
/// [`crate::store::ObjectSource`] contract). The builder never calls
/// `read_unverified` and adds no verification of its own, so the bundle
/// bytes are identical to [`build_disclosure`]'s for the same objects.
/// [`crate::store::DisplaySource`] is therefore not a valid source: its
/// `read` skips verification.
///
/// A source that breaks the contract (returns bytes that do not hash to
/// the requested id) still cannot make the builder panic, and cannot make
/// it emit a bundle that verifies for content `commit_id` does not commit
/// to, because the bundle is self-authenticating against `commit_id`. The
/// build then either fails with a typed [`VerifyError`] or yields a bundle
/// that [`verify_disclosure`] rejects. It can still waste work: the
/// builder bounds nothing beyond what the objects themselves declare.
///
/// A source reports an absent object as
/// [`crate::store::StoreError::ObjectNotFound`], which surfaces as
/// [`VerifyError::Store`] exactly as on the store path.
///
/// # Errors
///
/// As [`build_disclosure`].
pub fn build_disclosure_from<S: crate::store::ObjectSource + ?Sized>(
source: &S,
commit_id: &Hash,
path: &[&[u8]],
selector: Selector,
) -> Result<Vec<u8>, VerifyError> {
let (commit_bytes, steps, leaf_id) = build_prefix(source, commit_id, path)?;
let payload = build_payload(source, &leaf_id, selector)?;
Ok(encode_disclosure(
commit_id,
&commit_bytes,
&steps,
&payload,
))
}
fn build_prefix<S: crate::store::ObjectSource + ?Sized>(
source: &S,
commit_id: &Hash,
path: &[&[u8]],
) -> Result<(Vec<u8>, Vec<Step>, Hash), VerifyError> {
if path.len() > MAX_TREE_DEPTH {
return Err(VerifyError::TooManySteps(path.len()));
}
let commit_bytes = source.read(commit_id)?;
let commit_obj = crate::serialize::deserialize(&commit_bytes)?;
let tree_hash = match &commit_obj {
Object::Commit(c) => c.tree_hash,
Object::Remix(r) => r.tree_hash,
other => return Err(VerifyError::NotACommitOrRemix(other.object_type())),
};
let mut steps = Vec::with_capacity(path.len());
let mut current_tree_id = tree_hash;
let mut leaf_id = tree_hash;
for (i, &name) in path.iter().enumerate() {
let Object::Tree(tree) = source.read_object(¤t_tree_id)? else {
return Err(VerifyError::PathThroughNonTree);
};
let position =
merkle::tree_entry_position(&tree, name).ok_or(VerifyError::PathNotFound(i))?;
let entry = tree.entries[position as usize].clone();
let proof = merkle::build_tree_entry_proof(&tree, position)?;
steps.push(Step {
name: name.to_vec(),
mode: entry.mode,
child_id: entry.object_hash,
inner_root: merkle::tree_inner_root(&tree),
position,
proof,
});
leaf_id = entry.object_hash;
let is_last = i + 1 == path.len();
if entry.mode == EntryMode::Tree {
current_tree_id = entry.object_hash;
} else if !is_last {
return Err(VerifyError::PathThroughNonTree);
}
}
Ok((commit_bytes, steps, leaf_id))
}
fn build_payload<S: crate::store::ObjectSource + ?Sized>(
source: &S,
leaf_id: &Hash,
selector: Selector,
) -> Result<PayloadWire, VerifyError> {
match selector {
Selector::Object => {
let bytes = source.read(leaf_id)?;
Ok(PayloadWire::Object { bytes })
}
Selector::Chunk(index) => {
let Object::ChunkedBlob(cb) = source.read_object(leaf_id)? else {
return Err(VerifyError::SelectorLeafMismatch);
};
let leaf_count = u32::try_from(cb.chunks.len())
.ok()
.and_then(|n| n.checked_add(1))
.ok_or(VerifyError::TooManyChunks)?;
let chunk_id = *cb
.chunks
.get(index as usize)
.ok_or(VerifyError::ChunkIndexOutOfRange { index, leaf_count })?;
let position = index
.checked_add(1)
.ok_or(VerifyError::ChunkIndexOutOfRange { index, leaf_count })?;
let proof = merkle::build_chunks_multi_proof(&cb, [0, position])?;
let bytes = source.read(&chunk_id)?;
Ok(PayloadWire::Chunk {
total_size: cb.total_size,
chunk_size: cb.chunk_size,
index,
inner_root: merkle::chunked_inner_root(&cb),
proof,
bytes,
})
}
Selector::Range {
offset,
len,
with_offsets,
} => {
if len == 0 {
return Err(VerifyError::ZeroLengthRange);
}
match source.read_object(leaf_id)? {
Object::Blob(b) => {
let end = offset.checked_add(len).ok_or(VerifyError::OffsetOverflow)?;
if end > b.data.len() as u64 {
return Err(VerifyError::RangeOutOfBounds);
}
let canonical = source.read(leaf_id)?;
let bao_offset = offset.checked_add(10).ok_or(VerifyError::OffsetOverflow)?;
let slice = extract_bao_slice(&canonical, bao_offset, len)?;
Ok(PayloadWire::Range {
chunk: None,
offset_in_blob: offset,
len,
slice,
chunk_len_proofs: Vec::new(),
})
}
Object::ChunkedBlob(cb) => {
build_chunked_range_payload(source, &cb, offset, len, with_offsets)
}
_ => Err(VerifyError::SelectorLeafMismatch),
}
}
}
}
fn build_chunked_range_payload<S: crate::store::ObjectSource + ?Sized>(
source: &S,
cb: &crate::object::ChunkedBlob,
offset: u64,
len: u64,
with_offsets: bool,
) -> Result<PayloadWire, VerifyError> {
// Read every chunk's canonical bytes once, up front: needed both to
// locate the containing chunk (chunk lengths aren't in the manifest)
// and, when `with_offsets` is set, to build each preceding chunk's
// length proof.
let chunk_bytes: Vec<Vec<u8>> = cb
.chunks
.iter()
.map(|id| source.read(id))
.collect::<Result<_, _>>()?;
// Every length and offset below is checked: `source` bytes and the
// caller's `offset`/`len` are both untrusted here, and a panic (the
// release profile has `overflow-checks = true`) is never acceptable.
let mut cumulative: u64 = 0;
let mut located = None;
for (idx, bytes) in chunk_bytes.iter().enumerate() {
// A chunk is a `Blob`: 10-byte prologue (6-byte header + u32
// length) then content. Anything shorter is a truncated object.
let content_len = bytes
.len()
.checked_sub(10)
.ok_or(VerifyError::Decode(MkitError::UnexpectedEof))? as u64;
let chunk_end = cumulative
.checked_add(content_len)
.ok_or(VerifyError::OffsetOverflow)?;
if offset < chunk_end {
located = Some((idx, cumulative, content_len));
break;
}
cumulative = chunk_end;
}
let (index, chunk_start, content_len) = located.ok_or(VerifyError::RangeOutOfBounds)?;
let offset_in_chunk = offset
.checked_sub(chunk_start)
.ok_or(VerifyError::OffsetOverflow)?;
let range_end = offset_in_chunk
.checked_add(len)
.ok_or(VerifyError::OffsetOverflow)?;
if range_end > content_len {
return Err(VerifyError::RangeCrossesChunkBoundary);
}
let index_u32 = u32::try_from(index).map_err(|_| VerifyError::TooManyChunks)?;
let position = index_u32.checked_add(1).ok_or(VerifyError::TooManyChunks)?;
let proof = merkle::build_chunks_multi_proof(cb, [0, position])?;
let bao_offset = offset_in_chunk
.checked_add(10)
.ok_or(VerifyError::OffsetOverflow)?;
let slice = extract_bao_slice(&chunk_bytes[index], bao_offset, len)?;
let mut chunk_len_proofs = Vec::new();
if with_offsets {
for (j, preceding_bytes) in chunk_bytes.iter().enumerate().take(index) {
let j_u32 = u32::try_from(j).map_err(|_| VerifyError::TooManyChunks)?;
let position_j = j_u32.checked_add(1).ok_or(VerifyError::TooManyChunks)?;
let proof_j = merkle::build_chunk_proof(cb, position_j)?;
let slice_j = extract_bao_slice(preceding_bytes, 0, 10)?;
chunk_len_proofs.push(LenProof {
index: j_u32,
chunk_id: cb.chunks[j],
proof: proof_j,
slice: slice_j,
});
}
}
Ok(PayloadWire::Range {
chunk: Some(ChunkHdr {
total_size: cb.total_size,
chunk_size: cb.chunk_size,
index: index_u32,
inner_root: merkle::chunked_inner_root(cb),
chunk_id: cb.chunks[index],
proof,
}),
offset_in_blob: offset_in_chunk,
len,
slice,
chunk_len_proofs,
})
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
#[allow(clippy::unwrap_used)] // unwrap is the assertion in test helpers
mod tests {
use super::*;
use crate::hash::ZERO;
use crate::layout::RepoLayout;
use crate::object::{Commit, Identity, Tree};
use crate::sign::{KeyPair, sign_commit};
use crate::store::ObjectStore;
use crate::worktree::store_file_object;
/// A small deterministic repo: root tree with a shallow file, a
/// 3-level-nested file, an executable file, and a chunked (> 1 MiB)
/// file, committed under a fixed signer. Built fresh (native
/// `ObjectStore`, no golden files) for this module's own unit tests;
/// `tests/golden_disclosure.rs` carries the pinned wire-byte fixture.
struct Fixture {
_dir: tempfile::TempDir,
store: ObjectStore,
commit_id: Hash,
chunked_bytes: Vec<u8>,
}
fn build_fixture() -> Fixture {
let dir = tempfile::TempDir::new().expect("tempdir");
let store = ObjectStore::init(&RepoLayout::single(dir.path())).expect("store init");
let shallow = store_file_object(&store, b"shallow file content").unwrap();
let deep_file = store_file_object(&store, b"three levels deep").unwrap();
let exec = store_file_object(&store, b"#!/bin/sh\necho hi\n").unwrap();
// Deterministic pseudo-random 2 MiB stream so FastCDC yields
// several chunks.
let mut chunked_bytes = vec![0u8; 2 * 1024 * 1024];
let mut x: u64 = 0x1234_5678_9abc_def0;
for b in &mut chunked_bytes {
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
*b = (x >> 56) as u8;
}
let chunked_id = store_file_object(&store, &chunked_bytes).unwrap();
let deep_tree = Tree {
entries: vec![TreeEntry {
name: b"deep.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: deep_file,
}],
};
let deep_tree_id = store
.write(&crate::serialize::serialize(&Object::Tree(deep_tree)).unwrap())
.unwrap();
let mid_tree = Tree {
entries: vec![TreeEntry {
name: b"deep".to_vec(),
mode: EntryMode::Tree,
object_hash: deep_tree_id,
}],
};
let mid_tree_id = store
.write(&crate::serialize::serialize(&Object::Tree(mid_tree)).unwrap())
.unwrap();
let root_tree = Tree {
entries: vec![
TreeEntry {
name: b"chunked.bin".to_vec(),
mode: EntryMode::Blob,
object_hash: chunked_id,
},
TreeEntry {
name: b"exec.sh".to_vec(),
mode: EntryMode::Executable,
object_hash: exec,
},
TreeEntry {
name: b"shallow.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: shallow,
},
TreeEntry {
name: b"sub".to_vec(),
mode: EntryMode::Tree,
object_hash: mid_tree_id,
},
],
};
let tree_hash = store
.write(&crate::serialize::serialize(&Object::Tree(root_tree)).unwrap())
.unwrap();
let kp = KeyPair::from_seed([0x07; 32]);
let mut commit = Commit {
tree_hash,
parents: vec![],
author: Identity::ed25519(kp.public.0),
signer: kp.public.0,
message: b"disclosure fixture".to_vec(),
timestamp: 1_726_300_000,
message_hash: ZERO,
content_digest: ZERO,
signature: [0u8; 64],
};
commit.signature = sign_commit(&commit, &kp).unwrap().0;
let commit_bytes = crate::serialize::serialize(&Object::Commit(commit)).unwrap();
let commit_id = store.write(&commit_bytes).unwrap();
Fixture {
_dir: dir,
store,
commit_id,
chunked_bytes,
}
}
#[test]
#[allow(clippy::too_many_lines)] // exercises every Selector against one fixture, kept together for auditability
fn round_trips_every_selector() {
let f = build_fixture();
// Root tree, via Object.
let bundle = build_disclosure(&f.store, &f.commit_id, &[], Selector::Object).unwrap();
let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
assert!(d.path.is_empty());
assert_eq!(d.leaf_id, d.tree_hash);
assert!(d.signature_valid);
// Shallow file, via Object.
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
assert_eq!(d.path, vec![(b"shallow.txt".to_vec(), EntryMode::Blob)]);
let DisclosedPayload::Object { bytes } = d.payload else {
panic!("expected Object payload");
};
assert_eq!(
bytes,
crate::serialize::serialize(&Object::Blob(crate::object::Blob {
data: b"shallow file content".to_vec()
}))
.unwrap()
);
// Nested (3-level) file.
let bundle = build_disclosure(
&f.store,
&f.commit_id,
&[b"sub", b"deep", b"deep.txt"],
Selector::Object,
)
.unwrap();
let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
assert_eq!(
d.path,
vec![
(b"sub".to_vec(), EntryMode::Tree),
(b"deep".to_vec(), EntryMode::Tree),
(b"deep.txt".to_vec(), EntryMode::Blob),
]
);
// Executable-mode file.
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"exec.sh"], Selector::Object).unwrap();
let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
assert_eq!(d.path, vec![(b"exec.sh".to_vec(), EntryMode::Executable)]);
// One chunk of the chunked file.
let bundle = build_disclosure(
&f.store,
&f.commit_id,
&[b"chunked.bin"],
Selector::Chunk(1),
)
.unwrap();
let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
let DisclosedPayload::Chunk { index, .. } = d.payload else {
panic!("expected Chunk payload");
};
assert_eq!(index, 1);
// Range inside a chunk, without offsets.
let bundle = build_disclosure(
&f.store,
&f.commit_id,
&[b"chunked.bin"],
Selector::Range {
offset: 200_000,
len: 64,
with_offsets: false,
},
)
.unwrap();
let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
let DisclosedPayload::Range {
chunk,
absolute_offset,
bytes,
..
} = d.payload
else {
panic!("expected Range payload");
};
assert!(chunk.is_some());
assert_eq!(bytes.len(), 64);
// Same range, with offsets: absolute_offset must be provable
// (this fixture's chosen offset lands past chunk 0).
let bundle_off = build_disclosure(
&f.store,
&f.commit_id,
&[b"chunked.bin"],
Selector::Range {
offset: 200_000,
len: 64,
with_offsets: true,
},
)
.unwrap();
let d_off = verify_disclosure(&f.commit_id, &bundle_off).unwrap();
let DisclosedPayload::Range {
absolute_offset: abs_off,
bytes: bytes_off,
chunk: chunk_off,
..
} = d_off.payload
else {
panic!("expected Range payload");
};
assert_ne!(chunk_off.map(|(idx, _, _)| idx), Some(0));
assert_eq!(abs_off, Some(200_000));
assert_eq!(bytes_off, f.chunked_bytes[200_000..200_064]);
// The no-offsets bundle for the same range discloses the same
// bytes either way.
assert_eq!(bytes, bytes_off);
assert_eq!(absolute_offset, None);
// Range covering a whole small blob.
let bundle = build_disclosure(
&f.store,
&f.commit_id,
&[b"shallow.txt"],
Selector::Range {
offset: 0,
len: "shallow file content".len() as u64,
with_offsets: false,
},
)
.unwrap();
let d = verify_disclosure(&f.commit_id, &bundle).unwrap();
let DisclosedPayload::Range { bytes, chunk, .. } = d.payload else {
panic!("expected Range payload");
};
assert!(chunk.is_none());
assert_eq!(bytes, b"shallow file content");
}
#[test]
fn verify_object_id_rejects_mismatch() {
let f = build_fixture();
let bytes = f
.store
.read(&f.commit_id)
.expect("commit_id was just written");
assert!(matches!(
verify_object_id(&bytes, &ZERO),
Err(VerifyError::PayloadIdMismatch)
));
verify_object_id(&bytes, &f.commit_id).expect("matches itself");
}
#[test]
fn verify_path_rejects_wrong_commit_bytes_hash() {
let f = build_fixture();
let commit_bytes = f.store.read(&f.commit_id).unwrap();
assert!(matches!(
verify_path(&ZERO, &commit_bytes, &[]),
Err(VerifyError::CommitBytesHashMismatch)
));
}
#[test]
fn verify_path_rejects_non_final_non_tree_mode() {
let f = build_fixture();
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
// A second, non-final step whose mode is Blob (not Tree) must be
// rejected — the wire-level decode bound (steps <= 128) doesn't
// catch this; only the semantic check does.
let (_id, commit_bytes, mut steps, _payload) = decode_disclosure(&bundle).unwrap();
assert_eq!(steps.len(), 1);
let dup = steps[0].clone();
steps.push(dup);
assert!(matches!(
verify_path(&f.commit_id, &commit_bytes, &steps),
Err(VerifyError::NonFinalStepNotTree(0))
));
}
#[test]
fn verify_path_rejects_too_many_steps() {
let f = build_fixture();
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
let (_id, commit_bytes, steps, _payload) = decode_disclosure(&bundle).unwrap();
let too_many: Vec<Step> =
std::iter::repeat_n(steps[0].clone(), MAX_TREE_DEPTH + 1).collect();
assert!(matches!(
verify_path(&f.commit_id, &commit_bytes, &too_many),
Err(VerifyError::TooManySteps(_))
));
}
#[test]
fn verify_path_rejects_invalid_entry_name() {
let f = build_fixture();
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
let (_id, commit_bytes, mut steps, _payload) = decode_disclosure(&bundle).unwrap();
steps[0].name = b"trailing space ".to_vec();
assert!(matches!(
verify_path(&f.commit_id, &commit_bytes, &steps),
Err(VerifyError::InvalidEntryName(0))
));
}
#[test]
fn verify_path_rejects_swapped_step_proof() {
let f = build_fixture();
let bundle = build_disclosure(
&f.store,
&f.commit_id,
&[b"sub", b"deep", b"deep.txt"],
Selector::Object,
)
.unwrap();
let (_id, commit_bytes, mut steps, _payload) = decode_disclosure(&bundle).unwrap();
assert_eq!(steps.len(), 3);
steps.swap(0, 1);
assert!(matches!(
verify_path(&f.commit_id, &commit_bytes, &steps),
Err(VerifyError::InnerRootMismatch { .. } | VerifyError::Merkle(_))
));
}
#[test]
fn verify_path_rejects_non_commit_non_remix() {
let f = build_fixture();
// A Tag targeting the commit: valid bytes, wrong object kind.
let tag = crate::object::Tag {
target: f.commit_id,
target_type: ObjectType::Commit,
name: b"v1".to_vec(),
tagger: Identity::ed25519([9u8; 32]),
signer: [9u8; 32],
message: Vec::new(),
timestamp: 0,
signature: [0u8; 64],
};
let tag_bytes = crate::serialize::serialize(&Object::Tag(tag)).unwrap();
let tag_id = crate::hash::hash(&tag_bytes);
assert!(matches!(
verify_path(&tag_id, &tag_bytes, &[]),
Err(VerifyError::NotACommitOrRemix(ObjectType::Tag))
));
}
#[test]
fn verify_chunk_with_meta_bounds() {
let f = build_fixture();
let bundle = build_disclosure(
&f.store,
&f.commit_id,
&[b"chunked.bin"],
Selector::Chunk(1),
)
.unwrap();
let (_id, _commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
let leaf_id = steps[0].child_id;
let PayloadWire::Chunk {
total_size,
chunk_size,
index,
proof,
bytes,
..
} = payload
else {
panic!("expected Chunk payload");
};
let chunk_hash = hash(&bytes);
verify_chunk_with_meta(&leaf_id, total_size, chunk_size, &chunk_hash, index, &proof)
.expect("freshly built chunk proof must verify");
// Forged total_size.
assert!(matches!(
verify_chunk_with_meta(
&leaf_id,
total_size + 1,
chunk_size,
&chunk_hash,
index,
&proof
),
Err(VerifyError::Merkle(_))
));
// Out-of-range index (one past the last valid position).
assert!(matches!(
verify_chunk_with_meta(
&leaf_id,
total_size,
chunk_size,
&chunk_hash,
proof.leaf_count,
&proof
),
Err(VerifyError::ChunkIndexOutOfRange { .. })
));
}
#[test]
fn verify_blob_slice_and_len_proof_round_trip() {
let content = b"the quick brown fox jumps over the lazy dog";
let canonical = {
let prologue = crate::serialize::blob_prologue(content.len()).unwrap();
let mut v = prologue.to_vec();
v.extend_from_slice(content);
v
};
let blob_id = crate::hash::hash(&canonical);
let (outboard, _root) = bao::encode::outboard(&canonical);
let mut extractor = bao::encode::SliceExtractor::new_outboard(
std::io::Cursor::new(&canonical),
std::io::Cursor::new(&outboard),
10, // content offset 0 -> bao offset 10
content.len() as u64,
);
let mut slice = Vec::new();
std::io::Read::read_to_end(&mut extractor, &mut slice).unwrap();
let got = verify_blob_slice(&blob_id, 0, content.len() as u64, &slice).unwrap();
assert_eq!(got, content);
// Zero length is always rejected, regardless of slice bytes.
assert!(matches!(
verify_blob_slice(&blob_id, 0, 0, &slice),
Err(VerifyError::ZeroLengthRange)
));
// Length proof over bytes 0..10.
let mut len_extractor = bao::encode::SliceExtractor::new_outboard(
std::io::Cursor::new(&canonical),
std::io::Cursor::new(&outboard),
0,
10,
);
let mut len_slice = Vec::new();
std::io::Read::read_to_end(&mut len_extractor, &mut len_slice).unwrap();
let len = verify_blob_len_proof(&blob_id, &len_slice).unwrap();
assert_eq!(len as usize, content.len());
}
#[test]
fn zero_length_range_rejected_end_to_end() {
let f = build_fixture();
assert!(matches!(
build_disclosure(
&f.store,
&f.commit_id,
&[b"shallow.txt"],
Selector::Range {
offset: 0,
len: 0,
with_offsets: false
}
),
Err(VerifyError::ZeroLengthRange)
));
}
#[test]
fn range_crossing_chunk_boundary_rejected() {
let f = build_fixture();
// The full 2 MiB span certainly crosses at least one chunk
// boundary (chunks average well under 1 MiB).
assert!(matches!(
build_disclosure(
&f.store,
&f.commit_id,
&[b"chunked.bin"],
Selector::Range {
offset: 0,
len: f.chunked_bytes.len() as u64,
with_offsets: false,
}
),
Err(VerifyError::RangeCrossesChunkBoundary)
));
}
#[test]
fn incomplete_length_proof_set_is_rejected() {
let f = build_fixture();
let bundle = build_disclosure(
&f.store,
&f.commit_id,
&[b"chunked.bin"],
Selector::Range {
offset: 700_000,
len: 16,
with_offsets: true,
},
)
.unwrap();
let (commit_id, commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
let PayloadWire::Range {
chunk,
offset_in_blob,
len,
slice,
mut chunk_len_proofs,
} = payload
else {
panic!("expected Range payload");
};
let hdr = chunk.clone().expect("chunked leaf");
assert!(hdr.index > 0, "test fixture assumption: chunk index > 0");
// Drop one proof: incomplete coverage of 0..index.
chunk_len_proofs.remove(0);
let tampered = encode_disclosure(
&commit_id,
&commit_bytes,
&steps,
&PayloadWire::Range {
chunk,
offset_in_blob,
len,
slice,
chunk_len_proofs,
},
);
assert!(matches!(
verify_disclosure(&f.commit_id, &tampered),
Err(VerifyError::IncompleteLengthProofSet(_))
));
}
#[test]
fn resolve_absolute_offset_rejects_overflow() {
// `resolve_absolute_offset` sums verified preceding-chunk lengths
// and adds `offset_in_blob`; that final addition must be checked
// like every neighbouring offset computation, not silently wrap.
let f = build_fixture();
let bundle = build_disclosure(
&f.store,
&f.commit_id,
&[b"chunked.bin"],
Selector::Range {
offset: 700_000,
len: 16,
with_offsets: true,
},
)
.unwrap();
let (_id, _commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
let leaf_id = steps[0].child_id;
let PayloadWire::Range {
chunk,
offset_in_blob,
chunk_len_proofs,
..
} = payload
else {
panic!("expected Range payload");
};
let hdr = chunk.expect("chunked leaf");
assert!(hdr.index > 0, "test fixture assumption: chunk index > 0");
assert!(matches!(
resolve_absolute_offset(&leaf_id, hdr.index, &chunk_len_proofs, u64::MAX),
Err(VerifyError::OffsetOverflow)
));
// Sanity: the same, real proof set still resolves with a
// non-overflowing offset.
resolve_absolute_offset(&leaf_id, hdr.index, &chunk_len_proofs, offset_in_blob)
.expect("freshly built length proofs must resolve");
}
#[test]
fn len_proofs_on_chunk0_are_rejected() {
// Chunk 0 has nothing preceding it, so `chunk_len_proofs` MUST be
// empty there too — the same rule the plain-`Blob` path already
// enforces (`UnexpectedLengthProofs`). `resolve_absolute_offset`'s
// `index == 0` early return must not silently ignore a non-empty
// set (SPEC-DISCLOSURE §4).
let f = build_fixture();
let bundle = build_disclosure(
&f.store,
&f.commit_id,
&[b"chunked.bin"],
Selector::Range {
offset: 0,
len: 8,
with_offsets: false,
},
)
.unwrap();
let (commit_id, commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
let PayloadWire::Range {
chunk,
offset_in_blob,
len,
slice,
chunk_len_proofs,
} = payload
else {
panic!("expected Range payload");
};
let hdr = chunk.clone().expect("chunked leaf");
assert_eq!(hdr.index, 0, "test fixture assumption: offset 0 is chunk 0");
assert!(
chunk_len_proofs.is_empty(),
"builder never emits proofs for chunk 0"
);
// Forge a bogus (but structurally valid) length-proof entry — its
// content doesn't matter, because it must be rejected before any
// proof inside it is even checked.
let forged = vec![LenProof {
index: 0,
chunk_id: [0u8; 32],
proof: Proof::default(),
slice: Vec::new(),
}];
let tampered = encode_disclosure(
&commit_id,
&commit_bytes,
&steps,
&PayloadWire::Range {
chunk,
offset_in_blob,
len,
slice,
chunk_len_proofs: forged,
},
);
assert!(matches!(
verify_disclosure(&f.commit_id, &tampered),
Err(VerifyError::UnexpectedLengthProofs)
));
}
#[test]
fn decode_rejects_bad_magic_version_and_trailing_bytes() {
let f = build_fixture();
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
let mut bad_magic = bundle.clone();
bad_magic[0] = b'X';
assert!(matches!(
verify_disclosure(&f.commit_id, &bad_magic),
Err(VerifyError::BadMagic)
));
let mut bad_version = bundle.clone();
bad_version[4] = 1;
assert!(matches!(
verify_disclosure(&f.commit_id, &bad_version),
Err(VerifyError::UnsupportedBundleVersion(1))
));
let mut trailing = bundle.clone();
trailing.push(0);
assert!(matches!(
verify_disclosure(&f.commit_id, &trailing),
Err(VerifyError::Malformed)
));
let oversize = vec![0u8; MAX_BUNDLE_BYTES + 1];
assert!(matches!(
verify_disclosure(&f.commit_id, &oversize),
Err(VerifyError::BundleTooLarge)
));
}
#[test]
fn decode_rejects_unknown_payload_kind() {
let f = build_fixture();
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
let (commit_id, commit_bytes, steps, _payload) = decode_disclosure(&bundle).unwrap();
// Recompute the prefix (magic + version + commit_id +
// commit_bytes + steps) to find the payload_kind byte's offset
// in the real bundle, rather than hand-computing it.
let mut prefix = Vec::new();
prefix.extend_from_slice(BUNDLE_MAGIC);
prefix.push(BUNDLE_VERSION);
commit_id.write(&mut prefix);
commit_bytes.as_slice().write(&mut prefix);
steps.as_slice().write(&mut prefix);
let kind_offset = prefix.len();
assert_eq!(bundle[kind_offset], 0, "Object payload_kind must be 0");
let mut tampered = bundle.clone();
tampered[kind_offset] = 0xFF;
assert!(matches!(
verify_disclosure(&f.commit_id, &tampered),
Err(VerifyError::InvalidPayloadKind(0xFF))
));
}
#[test]
fn payload_id_mismatch_is_rejected() {
let f = build_fixture();
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
let (commit_id, commit_bytes, steps, payload) = decode_disclosure(&bundle).unwrap();
let PayloadWire::Object { mut bytes } = payload else {
panic!("expected Object payload");
};
*bytes.last_mut().unwrap() ^= 0xFF;
let tampered = encode_disclosure(
&commit_id,
&commit_bytes,
&steps,
&PayloadWire::Object { bytes },
);
assert!(matches!(
verify_disclosure(&f.commit_id, &tampered),
Err(VerifyError::PayloadIdMismatch)
));
}
#[test]
fn commit_id_mismatch_is_rejected() {
let f = build_fixture();
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
assert!(matches!(
verify_disclosure(&ZERO, &bundle),
Err(VerifyError::CommitIdMismatch)
));
}
#[test]
fn inner_root_forged_is_rejected() {
let f = build_fixture();
let bundle =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
let (commit_id, commit_bytes, mut steps, payload) = decode_disclosure(&bundle).unwrap();
steps[0].inner_root[0] ^= 0xFF;
let tampered = encode_disclosure(&commit_id, &commit_bytes, &steps, &payload);
assert!(matches!(
verify_disclosure(&f.commit_id, &tampered),
Err(VerifyError::InnerRootMismatch { .. })
));
}
#[test]
fn inner_root_fold_mismatch_is_rejected() {
let f = build_fixture();
let nested = build_disclosure(
&f.store,
&f.commit_id,
&[b"sub", b"deep", b"deep.txt"],
Selector::Object,
)
.unwrap();
let shallow =
build_disclosure(&f.store, &f.commit_id, &[b"shallow.txt"], Selector::Object).unwrap();
let (commit_id, commit_bytes, mut steps, payload) = decode_disclosure(&shallow).unwrap();
let (_, _, nested_steps, _) = decode_disclosure(&nested).unwrap();
// Keep the wrap-correct inner_root of the root tree, but swap in
// a proof built against a different tree so the fold disagrees.
steps[0].proof = nested_steps[1].proof.clone();
steps[0].position = nested_steps[1].position;
let tampered = encode_disclosure(&commit_id, &commit_bytes, &steps, &payload);
assert!(matches!(
verify_disclosure(&f.commit_id, &tampered),
Err(VerifyError::InnerRootFoldMismatch)
));
}
// --- `build_disclosure_from` over a generic `store::ObjectSource` ---
use crate::store::StoreError;
/// A verifying in-memory [`crate::store::ObjectSource`]: every `read`
/// re-derives the object id (the trait contract), so it stands in for
/// a server-side per-repository index or global CAS.
struct MapSource(BTreeMap<Hash, Vec<u8>>);
impl MapSource {
/// Every object currently in `store`.
fn from_store(store: &ObjectStore) -> Self {
let map = store
.iter_object_hashes()
.unwrap()
.into_iter()
.map(|h| (h, store.read(&h).unwrap()))
.collect();
Self(map)
}
}
impl crate::store::ObjectSource for MapSource {
fn read(&self, h: &Hash) -> crate::store::StoreResult<Vec<u8>> {
let bytes = self
.0
.get(h)
.ok_or_else(|| StoreError::ObjectNotFound(crate::hash::to_hex(h)))?;
verify_object_id(bytes, h).map_err(|_| StoreError::HashMismatch {
expected: crate::hash::to_hex(h),
actual: String::from("<mismatch>"),
})?;
Ok(bytes.clone())
}
}
/// A deliberately NON-verifying source (violates the
/// `store::ObjectSource` contract): serves `lie`'s bytes for `target`.
struct LyingSource<'a> {
inner: &'a MapSource,
target: Hash,
lie: Vec<u8>,
}
impl crate::store::ObjectSource for LyingSource<'_> {
fn read(&self, h: &Hash) -> crate::store::StoreResult<Vec<u8>> {
if *h == self.target {
return Ok(self.lie.clone());
}
crate::store::ObjectSource::read(self.inner, h)
}
}
/// Another contract-violating source: `read_object(target)` decodes
/// `object_lie`, while `read(target)` still returns the honest bytes,
/// so the two methods disagree about the same id.
struct SplitSource<'a> {
inner: &'a MapSource,
target: Hash,
object_lie: Object,
}
impl crate::store::ObjectSource for SplitSource<'_> {
fn read(&self, h: &Hash) -> crate::store::StoreResult<Vec<u8>> {
crate::store::ObjectSource::read(self.inner, h)
}
fn read_object(&self, h: &Hash) -> crate::store::StoreResult<Object> {
if *h == self.target {
return Ok(self.object_lie.clone());
}
crate::store::ObjectSource::read_object(self.inner, h)
}
}
/// Id of the entry `name` in the tree `tree_id`.
fn entry_id(store: &ObjectStore, tree_id: &Hash, name: &[u8]) -> Hash {
let Object::Tree(tree) = store.read_object(tree_id).unwrap() else {
panic!("expected a tree");
};
tree.entries
.iter()
.find(|e| e.name == name)
.unwrap()
.object_hash
}
fn root_tree_id(store: &ObjectStore, commit_id: &Hash) -> Hash {
let Object::Commit(c) = store.read_object(commit_id).unwrap() else {
panic!("expected a commit");
};
c.tree_hash
}
/// A second commit (in the fixture store) whose root holds one plain,
/// multi-Bao-block `Blob`, so small-blob ranges span block boundaries.
fn medium_blob_commit(f: &Fixture) -> Hash {
let data: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
let blob = store_file_object(&f.store, &data).unwrap();
assert!(matches!(
f.store.read_object(&blob).unwrap(),
Object::Blob(_)
));
let tree = Tree {
entries: vec![TreeEntry {
name: b"medium.bin".to_vec(),
mode: EntryMode::Blob,
object_hash: blob,
}],
};
let tree_hash = f
.store
.write(&crate::serialize::serialize(&Object::Tree(tree)).unwrap())
.unwrap();
let kp = KeyPair::from_seed([0x07; 32]);
let mut commit = Commit {
tree_hash,
parents: vec![],
author: Identity::ed25519(kp.public.0),
signer: kp.public.0,
message: b"medium blob".to_vec(),
timestamp: 1_726_300_001,
message_hash: ZERO,
content_digest: ZERO,
signature: [0u8; 64],
};
commit.signature = sign_commit(&commit, &kp).unwrap().0;
f.store
.write(&crate::serialize::serialize(&Object::Commit(commit)).unwrap())
.unwrap()
}
type Case = (Hash, Vec<&'static [u8]>, Selector);
/// Every selector kind the builder supports, as `(commit, path,
/// selector)` cases over the fixture plus [`medium_blob_commit`].
fn equivalence_cases(f: &Fixture, medium: Hash) -> Vec<Case> {
let range = |offset, len, with_offsets| Selector::Range {
offset,
len,
with_offsets,
};
let c = f.commit_id;
let mut cases: Vec<Case> = vec![
(c, vec![], Selector::Object),
(c, vec![b"shallow.txt"], Selector::Object),
(c, vec![b"sub", b"deep", b"deep.txt"], Selector::Object),
(c, vec![b"sub"], Selector::Object),
(c, vec![b"exec.sh"], Selector::Object),
(c, vec![b"chunked.bin"], Selector::Object),
(c, vec![b"shallow.txt"], range(0, 20, false)),
(c, vec![b"shallow.txt"], range(8, 4, false)),
// Plain blob: first block, last partial block, a range across a
// block boundary, and the whole content.
(medium, vec![b"medium.bin"], range(0, 1024, false)),
(medium, vec![b"medium.bin"], range(4096, 904, false)),
(medium, vec![b"medium.bin"], range(1000, 100, false)),
(medium, vec![b"medium.bin"], range(0, 5000, false)),
// Chunked blob: a range inside chunk 0 and one past it, each
// with and without offset proofs.
(c, vec![b"chunked.bin"], range(10, 64, false)),
(c, vec![b"chunked.bin"], range(10, 64, true)),
(c, vec![b"chunked.bin"], range(200_000, 64, false)),
(c, vec![b"chunked.bin"], range(200_000, 64, true)),
];
let chunked_id = entry_id(&f.store, &root_tree_id(&f.store, &c), b"chunked.bin");
let Object::ChunkedBlob(cb) = f.store.read_object(&chunked_id).unwrap() else {
panic!("expected a chunked blob");
};
assert!(cb.chunks.len() > 1, "fixture must yield several chunks");
for i in 0..cb.chunks.len() {
let index = u32::try_from(i).unwrap();
cases.push((c, vec![b"chunked.bin"], Selector::Chunk(index)));
}
cases
}
fn assert_source_matches_store<S: crate::store::ObjectSource + ?Sized>(
f: &Fixture,
source: &S,
medium: Hash,
) {
for (commit, path, selector) in equivalence_cases(f, medium) {
let expected = build_disclosure(&f.store, &commit, &path, selector).unwrap();
let got = build_disclosure_from(source, &commit, &path, selector).unwrap();
assert_eq!(
got, expected,
"bundle bytes differ for {path:?} / {selector:?}"
);
verify_disclosure(&commit, &got).unwrap();
}
}
#[test]
fn disclosure_from_map_source_equals_store_builder() {
let f = build_fixture();
let medium = medium_blob_commit(&f);
let map = MapSource::from_store(&f.store);
assert_source_matches_store(&f, &map, medium);
// Also through a trait object (`?Sized`).
let dyn_source: &dyn crate::store::ObjectSource = ↦
assert_source_matches_store(&f, dyn_source, medium);
}
#[test]
fn disclosure_from_ephemeral_sink_equals_store_builder() {
let f = build_fixture();
let medium = medium_blob_commit(&f);
let sink = crate::store::EphemeralSink::new(&f.store);
assert_source_matches_store(&f, &sink, medium);
}
#[test]
fn disclosure_from_missing_object_is_store_error() {
let f = build_fixture();
let sub_id = entry_id(&f.store, &root_tree_id(&f.store, &f.commit_id), b"sub");
let path: [&[u8]; 3] = [b"sub", b"deep", b"deep.txt"];
let mut map = MapSource::from_store(&f.store);
assert!(map.0.remove(&sub_id).is_some());
let from_map =
build_disclosure_from(&map, &f.commit_id, &path, Selector::Object).unwrap_err();
f.store.remove_object(&sub_id).unwrap();
let from_store =
build_disclosure(&f.store, &f.commit_id, &path, Selector::Object).unwrap_err();
let VerifyError::Store(StoreError::ObjectNotFound(map_hex)) = &from_map else {
panic!("expected Store(ObjectNotFound), got {from_map:?}");
};
let VerifyError::Store(StoreError::ObjectNotFound(store_hex)) = &from_store else {
panic!("expected Store(ObjectNotFound), got {from_store:?}");
};
assert_eq!(map_hex, store_hex);
assert_eq!(map_hex, &crate::hash::to_hex(&sub_id));
assert_eq!(from_map.to_string(), from_store.to_string());
}
#[test]
fn disclosure_from_lying_leaf_fails_verification() {
let f = build_fixture();
let root = root_tree_id(&f.store, &f.commit_id);
let shallow_id = entry_id(&f.store, &root, b"shallow.txt");
let exec_id = entry_id(&f.store, &root, b"exec.sh");
let map = MapSource::from_store(&f.store);
let liar = LyingSource {
inner: &map,
target: shallow_id,
// A different, well-formed blob.
lie: f.store.read(&exec_id).unwrap(),
};
let bundle =
build_disclosure_from(&liar, &f.commit_id, &[b"shallow.txt"], Selector::Object)
.unwrap();
assert!(matches!(
verify_disclosure(&f.commit_id, &bundle),
Err(VerifyError::PayloadIdMismatch)
));
}
#[test]
fn disclosure_from_lying_tree_fails_at_that_path_step() {
let f = build_fixture();
let root = root_tree_id(&f.store, &f.commit_id);
let sub_id = entry_id(&f.store, &root, b"sub");
let deep_tree_id = entry_id(&f.store, &sub_id, b"deep");
let shallow_id = entry_id(&f.store, &root, b"shallow.txt");
// A well-formed forgery of `sub`: it still has the `deep` entry the
// path needs, plus an extra one, so the builder walks straight
// through it.
let forged = Tree {
entries: vec![
TreeEntry {
name: b"aaa.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: shallow_id,
},
TreeEntry {
name: b"deep".to_vec(),
mode: EntryMode::Tree,
object_hash: deep_tree_id,
},
],
};
let map = MapSource::from_store(&f.store);
let liar = LyingSource {
inner: &map,
target: sub_id,
lie: crate::serialize::serialize(&Object::Tree(forged)).unwrap(),
};
let path: [&[u8]; 3] = [b"sub", b"deep", b"deep.txt"];
let bundle = build_disclosure_from(&liar, &f.commit_id, &path, Selector::Object).unwrap();
// Step 0 (root → sub) is honest; step 1 carries the forged tree's
// inner root, which does not wrap to `sub`'s real id.
let err = verify_disclosure(&f.commit_id, &bundle).unwrap_err();
let VerifyError::InnerRootMismatch { expected, .. } = err else {
panic!("expected InnerRootMismatch at the `sub` step, got {err:?}");
};
assert_eq!(expected, sub_id);
}
#[test]
fn disclosure_from_short_chunk_is_error_not_panic() {
let f = build_fixture();
let root = root_tree_id(&f.store, &f.commit_id);
let chunked_id = entry_id(&f.store, &root, b"chunked.bin");
let Object::ChunkedBlob(cb) = f.store.read_object(&chunked_id).unwrap() else {
panic!("expected a chunked blob");
};
let map = MapSource::from_store(&f.store);
for short_len in [0usize, 1, 9] {
let liar = LyingSource {
inner: &map,
target: cb.chunks[0],
lie: vec![0u8; short_len],
};
for with_offsets in [false, true] {
let selector = Selector::Range {
offset: 200_000,
len: 64,
with_offsets,
};
let err = build_disclosure_from(&liar, &f.commit_id, &[b"chunked.bin"], selector)
.unwrap_err();
assert!(
matches!(err, VerifyError::Decode(MkitError::UnexpectedEof)),
"{short_len}-byte chunk: got {err:?}"
);
}
}
}
#[test]
fn disclosure_range_len_u64_max_is_error_not_panic() {
let f = build_fixture();
let map = MapSource::from_store(&f.store);
let huge = |offset| Selector::Range {
offset,
len: u64::MAX,
with_offsets: false,
};
for (path, offset) in [
(b"chunked.bin".as_slice(), 10),
(b"chunked.bin".as_slice(), 200_000),
(b"shallow.txt".as_slice(), 1),
] {
for result in [
build_disclosure(&f.store, &f.commit_id, &[path], huge(offset)),
build_disclosure_from(&map, &f.commit_id, &[path], huge(offset)),
] {
let err = result.unwrap_err();
assert!(
matches!(err, VerifyError::OffsetOverflow),
"{path:?} @ {offset}: got {err:?}"
);
}
}
// An offset at the very top of u64 is simply out of bounds.
let top = Selector::Range {
offset: u64::MAX,
len: 1,
with_offsets: true,
};
let err = build_disclosure(&f.store, &f.commit_id, &[b"chunked.bin"], top).unwrap_err();
assert!(matches!(err, VerifyError::RangeOutOfBounds), "got {err:?}");
}
#[test]
fn disclosure_from_split_blob_source_is_error_not_panic() {
let f = build_fixture();
let root = root_tree_id(&f.store, &f.commit_id);
let shallow_id = entry_id(&f.store, &root, b"shallow.txt");
let map = MapSource::from_store(&f.store);
// `read_object` claims a 4 KiB blob; `read` returns the real
// 20-byte one, so the range is past the canonical bytes.
let liar = SplitSource {
inner: &map,
target: shallow_id,
object_lie: Object::Blob(crate::object::Blob {
data: vec![0u8; 4096],
}),
};
let selector = Selector::Range {
offset: 2048,
len: 512,
with_offsets: false,
};
// Either a typed error or a bundle the verifier rejects; never a
// panic, never a verifying bundle.
if let Ok(bundle) = build_disclosure_from(&liar, &f.commit_id, &[b"shallow.txt"], selector)
{
assert!(verify_disclosure(&f.commit_id, &bundle).is_err());
}
}
}