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;
pub const MAX_BUNDLE_BYTES: usize = 64 * 1024 * 1024;
const BUNDLE_MAGIC: &[u8; 4] = b"MKDP";
const BUNDLE_VERSION: u8 = 2;
const MAX_COMMIT_BYTES: usize = 4 * 1024 * 1024;
const MAX_LEN_PROOF_SLICE_BYTES: usize = 8 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("disclosure bundle exceeds the {MAX_BUNDLE_BYTES} byte cap")]
BundleTooLarge,
#[error("disclosure bundle magic is not \"MKDP\"")]
BadMagic,
#[error("disclosure bundle version {0} is not supported (v2 only)")]
UnsupportedBundleVersion(u8),
#[error("declared inner root does not wrap to the expected object id")]
InnerRootMismatch {
expected: Hash,
inner_root: Hash,
},
#[error("proof fold does not equal the declared inner root")]
InnerRootFoldMismatch,
#[error("disclosure bundle body is malformed (bad codec payload or trailing bytes)")]
Malformed,
#[error("disclosure bundle's embedded commit_id does not match the requested commit id")]
CommitIdMismatch,
#[error("commit_bytes do not hash to the commit id being verified")]
CommitBytesHashMismatch,
#[error("commit_bytes decode to a {0:?}, which is not a Commit or Remix")]
NotACommitOrRemix(ObjectType),
#[error("{0} steps exceeds MAX_TREE_DEPTH ({MAX_TREE_DEPTH})")]
TooManySteps(usize),
#[error("step {0}'s entry name fails SPEC-OBJECTS §4.1 validation")]
InvalidEntryName(usize),
#[error("step {0} is not the final step but its mode is not Tree")]
NonFinalStepNotTree(usize),
#[error("merkle proof verification failed: {0}")]
Merkle(#[from] MerkleError),
#[error("payload_kind byte {0:#04x} is not a recognized disclosure payload kind")]
InvalidPayloadKind(u8),
#[error("disclosed Object payload's id does not equal the authenticated leaf id")]
PayloadIdMismatch,
#[error("chunk index {index} is out of range for a {leaf_count}-leaf ChunkedBlob proof")]
ChunkIndexOutOfRange {
index: u32,
leaf_count: u32,
},
#[error("proof leaf_count implies more chunks than MAX_CHUNKS allows")]
TooManyChunks,
#[error("byte range length is zero")]
ZeroLengthRange,
#[error("chunk_len_proofs does not cover exactly indices 0..{0} with no gaps or duplicates")]
IncompleteLengthProofSet(u32),
#[error("chunk_len_proofs is only meaningful for a ChunkedBlob range at chunk index > 0")]
UnexpectedLengthProofs,
#[error("Bao slice verification failed: {0}")]
Bao(String),
#[error("Bao slice yielded {got} bytes, expected {expected}")]
ShortSlice {
expected: u64,
got: usize,
},
#[error("blob length-proof bytes are not a valid v1 Blob prologue")]
InvalidBlobPrologue,
#[error("offset/length computation overflowed u64")]
OffsetOverflow,
#[error(transparent)]
Decode(#[from] MkitError),
#[error("selector does not match the disclosed leaf's object type")]
SelectorLeafMismatch,
#[error("byte range crosses a chunk boundary, which this profile does not support")]
RangeCrossesChunkBoundary,
#[error("byte range is out of bounds for the leaf's content length")]
RangeOutOfBounds,
#[error("path component {0} was not found in its parent tree")]
PathNotFound(usize),
#[error("path continues past a non-Tree entry")]
PathThroughNonTree,
#[error(transparent)]
Store(#[from] crate::store::StoreError),
#[error("closure object set exceeds the pack MAX_ENTRIES cap")]
TooManyClosureObjects,
#[error("closure root is a {0:?}, which is not a Commit, Remix, or Tag")]
ClosureRootWrongType(ObjectType),
#[error(
"closure pack {pack_index} entry {entry_index} is not a raw (0x00) entry (closure profile is raw-only)"
)]
ClosureProfileViolation {
pack_index: usize,
entry_index: usize,
},
#[error("closure manifest magic is not \"MKCL\"")]
ClosureManifestBadMagic,
#[error("closure manifest version {0} is not supported (v1 only)")]
ClosureManifestUnsupportedVersion(u8),
#[error(
"closure manifest body is malformed (bad codec payload, unknown mode, or trailing bytes)"
)]
ClosureManifestMalformed,
#[error("closure manifest lists {expected} packs but {got} were supplied")]
ClosurePackCountMismatch {
expected: usize,
got: usize,
},
#[error("pack {index} hash does not match the closure manifest")]
ClosurePackKeyMismatch {
index: usize,
},
#[error("closure manifest root does not match the caller's trusted root")]
ClosureRootMismatch {
expected: Hash,
got: Hash,
},
#[error(transparent)]
Pack(#[from] crate::pack::PackError),
}
impl From<CodecError> for VerifyError {
fn from(_: CodecError) -> Self {
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};
pub mod proof_size;
pub mod span;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Step {
pub name: Vec<u8>,
pub mode: EntryMode,
pub child_id: Hash,
pub inner_root: Hash,
pub position: u32,
pub proof: Proof,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathVerified {
pub tree_hash: Hash,
pub path: Vec<(Vec<u8>, EntryMode)>,
pub leaf_id: Hash,
pub signer: [u8; 32],
pub signature_valid: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Disclosed {
pub commit_id: Hash,
pub tree_hash: Hash,
pub path: Vec<(Vec<u8>, EntryMode)>,
pub leaf_id: Hash,
pub payload: DisclosedPayload,
pub signer: [u8; 32],
pub signature_valid: bool,
pub step_inner_roots: Vec<Hash>,
pub chunk_inner_root: Option<Hash>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DisclosedPayload {
Object {
bytes: Vec<u8>,
},
Chunk {
total_size: u64,
chunk_size: u32,
index: u32,
bytes: Vec<u8>,
},
Range {
blob_id: Hash,
chunk: Option<(u32, u64, u32)>,
offset_in_blob: u64,
absolute_offset: Option<u64>,
bytes: Vec<u8>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Selector {
Object,
Chunk(u32),
Range {
offset: u64,
len: u64,
with_offsets: bool,
},
}
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,
})
}
}
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)
}
}
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,
},
)
}
#[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,
}
}
}
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,
})
}
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)
}
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)
}
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)
}
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)
}
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))
}
#[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,
})
}
}
#[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,
})
}
}
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>,
},
}
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))
}
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
}
fn resolve_absolute_offset(
chunked_id: &Hash,
index: u32,
chunk_len_proofs: &[LenProof],
offset_in_blob: u64,
) -> Result<Option<u64>, VerifyError> {
if index == 0 {
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 {
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,
} => {
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,
})
}
}
}
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,
})
}
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,
})
}
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)
}
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)
}
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> {
let chunk_bytes: Vec<Vec<u8>> = cb
.chunks
.iter()
.map(|id| source.read(id))
.collect::<Result<_, _>>()?;
let mut cumulative: u64 = 0;
let mut located = None;
for (idx, bytes) in chunk_bytes.iter().enumerate() {
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,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] 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;
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();
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)] fn round_trips_every_selector() {
let f = build_fixture();
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);
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()
);
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),
]
);
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)]);
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);
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);
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]);
assert_eq!(bytes, bytes_off);
assert_eq!(absolute_offset, None);
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();
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();
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");
assert!(matches!(
verify_chunk_with_meta(
&leaf_id,
total_size + 1,
chunk_size,
&chunk_hash,
index,
&proof
),
Err(VerifyError::Merkle(_))
));
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.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);
assert!(matches!(
verify_blob_slice(&blob_id, 0, 0, &slice),
Err(VerifyError::ZeroLengthRange)
));
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();
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");
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() {
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)
));
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() {
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"
);
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();
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();
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)
));
}
use crate::store::StoreError;
struct MapSource(BTreeMap<Hash, Vec<u8>>);
impl MapSource {
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())
}
}
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)
}
}
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)
}
}
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
}
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);
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)),
(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)),
(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);
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,
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");
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();
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:?}"
);
}
}
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);
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,
};
if let Ok(bundle) = build_disclosure_from(&liar, &f.commit_id, &[b"shallow.txt"], selector)
{
assert!(verify_disclosure(&f.commit_id, &bundle).is_err());
}
}
}