use sha2::{Sha256, Digest};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MerkleError {
UnknownVersion(u8),
}
impl std::fmt::Display for MerkleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownVersion(v) => write!(
f,
"unknown merkle_version {} (expected {} or {})",
v, MERKLE_VERSION_V1, MERKLE_VERSION_V2,
),
}
}
}
impl std::error::Error for MerkleError {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Direction {
Left,
Right,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProofStep {
pub direction: Direction,
pub hash: String,
}
pub const MERKLE_ALGORITHM_V1: &str = "sha256-duplicate-last";
pub const MERKLE_ALGORITHM_V2: &str = "sha256-rfc9162";
pub const MERKLE_VERSION_V1: u8 = 1;
pub const MERKLE_VERSION_V2: u8 = 2;
pub fn default_merkle_version_v1() -> u8 {
MERKLE_VERSION_V1
}
pub(crate) fn hash_leaf_v1(artifact_id: &str) -> [u8; 32] {
Sha256::digest(artifact_id.as_bytes()).into()
}
pub(crate) fn hash_internal_v1(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(left);
h.update(right);
h.finalize().into()
}
pub(crate) fn hash_leaf_v2(artifact_id: &str) -> [u8; 32] {
let mut h = Sha256::new();
h.update([0x00u8]);
h.update(artifact_id.as_bytes());
h.finalize().into()
}
pub(crate) fn hash_internal_v2(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
let mut h = Sha256::new();
h.update([0x01u8]);
h.update(left);
h.update(right);
h.finalize().into()
}
pub(crate) fn hash_leaf(version: u8, artifact_id: &str) -> Result<[u8; 32], MerkleError> {
match version {
MERKLE_VERSION_V1 => Ok(hash_leaf_v1(artifact_id)),
MERKLE_VERSION_V2 => Ok(hash_leaf_v2(artifact_id)),
other => Err(MerkleError::UnknownVersion(other)),
}
}
pub(crate) fn hash_internal(
version: u8,
left: &[u8; 32],
right: &[u8; 32],
) -> Result<[u8; 32], MerkleError> {
match version {
MERKLE_VERSION_V1 => Ok(hash_internal_v1(left, right)),
MERKLE_VERSION_V2 => Ok(hash_internal_v2(left, right)),
other => Err(MerkleError::UnknownVersion(other)),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InclusionProof {
pub leaf_index: usize,
pub leaf_hash: String,
pub path: Vec<ProofStep>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub algorithm: Option<String>,
#[serde(default = "default_merkle_version_v1")]
pub merkle_version: u8,
}
pub struct MerkleTree {
leaves: Vec<[u8; 32]>,
version: u8,
}
impl Default for MerkleTree {
fn default() -> Self {
Self::new()
}
}
impl MerkleTree {
pub fn new() -> Self {
Self::with_version(MERKLE_VERSION_V2)
.expect("MERKLE_VERSION_V2 is always a valid version")
}
pub fn with_version(version: u8) -> Result<Self, MerkleError> {
if version != MERKLE_VERSION_V1 && version != MERKLE_VERSION_V2 {
return Err(MerkleError::UnknownVersion(version));
}
Ok(Self { leaves: Vec::new(), version })
}
pub fn version(&self) -> u8 {
self.version
}
pub fn append(&mut self, artifact_id: &str) -> usize {
let hash = hash_leaf(self.version, artifact_id)
.expect("tree version validated at construction");
self.leaves.push(hash);
self.leaves.len() - 1
}
pub fn len(&self) -> usize {
self.leaves.len()
}
pub fn is_empty(&self) -> bool {
self.leaves.is_empty()
}
pub fn root(&self) -> Option<[u8; 32]> {
if self.leaves.is_empty() {
return None;
}
Some(self.compute_root(&self.leaves))
}
pub fn height(&self) -> usize {
if self.leaves.len() <= 1 {
return 0;
}
(self.leaves.len() as f64).log2().ceil() as usize
}
pub fn inclusion_proof(&self, leaf_index: usize) -> Option<InclusionProof> {
if leaf_index >= self.leaves.len() {
return None;
}
let mut path = Vec::new();
let mut idx = leaf_index;
let mut level: Vec<[u8; 32]> = self.leaves.clone();
while level.len() > 1 {
if idx + 1 < level.len() && idx % 2 == 0 {
path.push(ProofStep {
direction: Direction::Right,
hash: hex::encode(level[idx + 1]),
});
} else if idx % 2 == 1 {
path.push(ProofStep {
direction: Direction::Left,
hash: hex::encode(level[idx - 1]),
});
}
let mut next_level = Vec::with_capacity((level.len() + 1) / 2);
let mut i = 0;
while i + 1 < level.len() {
next_level.push(
hash_internal(self.version, &level[i], &level[i + 1])
.expect("tree version validated at construction"),
);
i += 2;
}
if i < level.len() {
next_level.push(level[i]);
}
level = next_level;
idx /= 2;
}
Some(InclusionProof {
leaf_index,
leaf_hash: hex::encode(self.leaves[leaf_index]),
path,
algorithm: Some(match self.version {
MERKLE_VERSION_V1 => MERKLE_ALGORITHM_V1.to_string(),
_ => MERKLE_ALGORITHM_V2.to_string(),
}),
merkle_version: self.version,
})
}
pub fn verify_proof(
expected_version: u8,
root_hex: &str,
artifact_id: &str,
proof: &InclusionProof,
) -> bool {
if expected_version != MERKLE_VERSION_V1 && expected_version != MERKLE_VERSION_V2 {
return false;
}
if proof.merkle_version != expected_version {
return false;
}
if let Some(algo) = proof.algorithm.as_deref() {
if algo != MERKLE_ALGORITHM_V1 && algo != MERKLE_ALGORITHM_V2 {
return false;
}
}
if expected_version == MERKLE_VERSION_V2 && proof.path.is_empty() && proof.leaf_index != 0 {
return false;
}
let current: [u8; 32] = match hash_leaf(expected_version, artifact_id) {
Ok(h) => h,
Err(_) => return false,
};
if hex::encode(current) != proof.leaf_hash {
return false;
}
let mut current = current;
for step in &proof.path {
let sibling = match hex::decode(&step.hash) {
Ok(b) if b.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&b);
arr
}
_ => return false,
};
current = match step.direction {
Direction::Right => match hash_internal(expected_version, ¤t, &sibling) {
Ok(h) => h,
Err(_) => return false,
},
Direction::Left => match hash_internal(expected_version, &sibling, ¤t) {
Ok(h) => h,
Err(_) => return false,
},
};
}
hex::encode(current) == root_hex
}
fn compute_root(&self, leaves: &[[u8; 32]]) -> [u8; 32] {
if leaves.len() == 1 {
return leaves[0];
}
let mut level = leaves.to_vec();
while level.len() > 1 {
let mut next_level = Vec::with_capacity((level.len() + 1) / 2);
let mut i = 0;
while i + 1 < level.len() {
next_level.push(
hash_internal(self.version, &level[i], &level[i + 1])
.expect("tree version validated at construction"),
);
i += 2;
}
if i < level.len() {
next_level.push(level[i]);
}
level = next_level;
}
level[0]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_leaf_root_is_leaf_hash() {
let mut tree = MerkleTree::new();
tree.append("art_abc123");
let root = tree.root().unwrap();
let expected = hash_leaf_v2("art_abc123");
assert_eq!(root, expected);
}
#[test]
fn single_leaf_root_is_leaf_hash_v1_legacy() {
let mut tree = MerkleTree::with_version(MERKLE_VERSION_V1).unwrap();
tree.append("art_abc123");
let root = tree.root().unwrap();
let expected = hash_leaf_v1("art_abc123");
assert_eq!(root, expected);
}
#[test]
fn inclusion_proof_verifies() {
let mut tree = MerkleTree::new();
let ids = ["art_a", "art_b", "art_c", "art_d"];
for id in &ids {
tree.append(id);
}
let root = hex::encode(tree.root().unwrap());
let proof = tree.inclusion_proof(1).unwrap();
assert!(MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_b", &proof));
}
#[test]
fn wrong_artifact_fails_verification() {
let mut tree = MerkleTree::new();
tree.append("art_a");
tree.append("art_b");
let root = hex::encode(tree.root().unwrap());
let proof = tree.inclusion_proof(0).unwrap();
assert!(!MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_WRONG", &proof));
}
#[test]
fn tampered_sibling_fails() {
let mut tree = MerkleTree::new();
tree.append("art_a");
tree.append("art_b");
let root = hex::encode(tree.root().unwrap());
let mut proof = tree.inclusion_proof(0).unwrap();
proof.path[0].hash = "0".repeat(64);
assert!(!MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_a", &proof));
}
#[test]
fn v2_leaf_uses_0x00_prefix() {
let got = hash_leaf_v2("art_test");
let mut h = Sha256::new();
h.update([0x00u8]);
h.update(b"art_test");
let expected: [u8; 32] = h.finalize().into();
assert_eq!(got, expected);
}
#[test]
fn v2_internal_uses_0x01_prefix() {
let left = [0x11u8; 32];
let right = [0x22u8; 32];
let got = hash_internal_v2(&left, &right);
let mut h = Sha256::new();
h.update([0x01u8]);
h.update(left);
h.update(right);
let expected: [u8; 32] = h.finalize().into();
assert_eq!(got, expected);
}
#[test]
fn v1_legacy_root_unchanged() {
let ids = ["art_a", "art_b", "art_c", "art_d"];
let mut leaves: Vec<[u8; 32]> = ids.iter().map(|id| hash_leaf_v1(id)).collect();
while leaves.len() > 1 {
let mut next = Vec::with_capacity((leaves.len() + 1) / 2);
let mut i = 0;
while i + 1 < leaves.len() {
next.push(hash_internal_v1(&leaves[i], &leaves[i + 1]));
i += 2;
}
if i < leaves.len() {
next.push(leaves[i]);
}
leaves = next;
}
let got = hex::encode(leaves[0]);
let expected = "cb4c9e4a9374ea3917b9ba75554ce8908a593db1183f1af48edf41fa3eb67b0d";
assert_eq!(
got, expected,
"v1 root drifted; v0.10.2 receipts will fail to verify",
);
}
#[test]
fn v2_differs_from_v1_for_same_input() {
assert_ne!(hash_leaf_v1("x"), hash_leaf_v2("x"));
let l = [0x33u8; 32];
let r = [0x44u8; 32];
assert_ne!(hash_internal_v1(&l, &r), hash_internal_v2(&l, &r));
}
#[test]
fn odd_number_of_leaves() {
let mut tree = MerkleTree::new();
for i in 0..5 {
tree.append(&format!("art_{}", i));
}
let root = hex::encode(tree.root().unwrap());
let proof = tree.inclusion_proof(4).unwrap();
assert!(MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_4", &proof));
}
#[test]
fn v2_verify_round_trip() {
let mut tree = MerkleTree::new();
for id in &["art_a", "art_b", "art_c", "art_d"] {
tree.append(id);
}
assert_eq!(tree.version(), MERKLE_VERSION_V2);
let root = hex::encode(tree.root().unwrap());
let proof = tree.inclusion_proof(1).unwrap();
assert_eq!(proof.merkle_version, MERKLE_VERSION_V2);
assert!(MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_b", &proof));
}
#[test]
fn v2_rejects_v1_proof() {
let mut tree = MerkleTree::new();
for id in &["art_a", "art_b", "art_c", "art_d"] {
tree.append(id);
}
let root = hex::encode(tree.root().unwrap());
let mut proof = tree.inclusion_proof(1).unwrap();
proof.merkle_version = MERKLE_VERSION_V1;
assert!(
!MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_b", &proof),
"v2 verifier must reject a proof that downgrades itself to v1",
);
}
#[test]
fn v1_rejects_v2_proof() {
let mut tree = MerkleTree::with_version(MERKLE_VERSION_V1).unwrap();
for id in &["art_a", "art_b", "art_c", "art_d"] {
tree.append(id);
}
let root = hex::encode(tree.root().unwrap());
let mut proof = tree.inclusion_proof(1).unwrap();
proof.merkle_version = MERKLE_VERSION_V2;
assert!(
!MerkleTree::verify_proof(MERKLE_VERSION_V1, &root, "art_b", &proof),
"v1 verifier must reject a proof that upgrades itself to v2",
);
}
#[test]
fn v2_rejects_internal_node_as_leaf() {
let left = [0x11u8; 32];
let right = [0x22u8; 32];
let internal = hash_internal_v2(&left, &right);
let internal_hex = hex::encode(internal);
let fake_proof = InclusionProof {
leaf_index: 0,
leaf_hash: internal_hex.clone(),
path: vec![],
algorithm: Some(MERKLE_ALGORITHM_V2.to_string()),
merkle_version: MERKLE_VERSION_V2,
};
assert!(
!MerkleTree::verify_proof(MERKLE_VERSION_V2, &internal_hex, "art_attacker", &fake_proof),
"v2 verifier must reject an internal-node hash impersonating a single-leaf tree",
);
}
#[test]
fn v2_rejects_empty_path_with_nonzero_leaf_index() {
let mut tree = MerkleTree::new();
tree.append("art_a");
let root = hex::encode(tree.root().unwrap());
let bad = InclusionProof {
leaf_index: 1,
leaf_hash: hex::encode(hash_leaf_v2("art_a")),
path: vec![],
algorithm: Some(MERKLE_ALGORITHM_V2.to_string()),
merkle_version: MERKLE_VERSION_V2,
};
assert!(!MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_a", &bad));
}
#[test]
fn unknown_merkle_version_rejected() {
let mut tree = MerkleTree::new();
tree.append("art_a");
tree.append("art_b");
let root = hex::encode(tree.root().unwrap());
let mut proof = tree.inclusion_proof(0).unwrap();
proof.merkle_version = 7; assert!(!MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_a", &proof));
let mut proof2 = tree.inclusion_proof(0).unwrap();
proof2.merkle_version = 7;
assert!(!MerkleTree::verify_proof(7, &root, "art_a", &proof2));
}
#[test]
fn default_merkle_version_function_returns_one() {
assert_eq!(default_merkle_version_v1(), MERKLE_VERSION_V1);
}
#[test]
fn missing_merkle_version_field_defaults_to_v1() {
let json = serde_json::json!({
"leaf_index": 0,
"leaf_hash": "00".repeat(32),
"path": [],
"algorithm": "sha256-duplicate-last",
});
let proof: InclusionProof = serde_json::from_value(json).unwrap();
assert_eq!(proof.merkle_version, MERKLE_VERSION_V1);
}
#[test]
fn mixed_version_in_receipt_explicit() {
let mut tree = MerkleTree::new();
for id in &["art_a", "art_b"] {
tree.append(id);
}
let root = hex::encode(tree.root().unwrap());
let proof = tree.inclusion_proof(0).unwrap();
let json = serde_json::to_string(&proof).unwrap();
assert!(json.contains("\"merkle_version\":2"), "wire shape must include merkle_version=2");
let parsed: InclusionProof = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.merkle_version, MERKLE_VERSION_V2);
assert!(MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_a", &parsed));
}
#[test]
fn cross_version_downgrade_rejected() {
let mut tree = MerkleTree::new();
for id in &["art_a", "art_b"] {
tree.append(id);
}
let root = hex::encode(tree.root().unwrap());
let downgrade = InclusionProof {
leaf_index: 0,
leaf_hash: root.clone(),
path: vec![],
algorithm: Some(MERKLE_ALGORITHM_V1.to_string()),
merkle_version: MERKLE_VERSION_V1,
};
assert!(
!MerkleTree::verify_proof(MERKLE_VERSION_V2, &root, "art_attacker", &downgrade),
"v1 proof must NOT verify when the trusted version is v2 (downgrade vector)",
);
}
#[test]
fn unknown_merkle_version_rejected_at_construct() {
assert!(matches!(
MerkleTree::with_version(99),
Err(MerkleError::UnknownVersion(99)),
));
assert!(matches!(
MerkleTree::with_version(0),
Err(MerkleError::UnknownVersion(0)),
));
assert!(MerkleTree::with_version(MERKLE_VERSION_V1).is_ok());
assert!(MerkleTree::with_version(MERKLE_VERSION_V2).is_ok());
}
#[test]
fn unknown_merkle_version_rejected_at_primitive() {
assert!(matches!(
hash_leaf(42, "x"),
Err(MerkleError::UnknownVersion(42)),
));
let l = [0u8; 32];
let r = [0u8; 32];
assert!(matches!(
hash_internal(42, &l, &r),
Err(MerkleError::UnknownVersion(42)),
));
}
#[test]
fn checkpoint_canonical_includes_version() {
use crate::merkle::checkpoint::{
Checkpoint, CANONICAL_VERSION_V1, CANONICAL_VERSION_V2, CANONICAL_VERSION_V3,
};
let canonical_v1 = Checkpoint::canonical_for_signing(
CANONICAL_VERSION_V1,
MERKLE_VERSION_V1,
None,
None,
7,
"sha256:abcd",
42,
6,
"key_test",
"2026-05-17T00:00:00Z",
);
let canonical_v2 = Checkpoint::canonical_for_signing(
CANONICAL_VERSION_V2,
MERKLE_VERSION_V2,
None,
None,
7,
"sha256:abcd",
42,
6,
"key_test",
"2026-05-17T00:00:00Z",
);
let canonical_v3 = Checkpoint::canonical_for_signing(
CANONICAL_VERSION_V3,
MERKLE_VERSION_V2,
Some(MERKLE_ALGORITHM_V2),
None,
7,
"sha256:abcd",
42,
6,
"key_test",
"2026-05-17T00:00:00Z",
);
assert_ne!(
canonical_v1, canonical_v2,
"canonical strings for v1 vs v2 must differ — merkle_version must be bound into signing",
);
assert_ne!(
canonical_v2, canonical_v3,
"canonical strings for v2 vs v3 must differ — canonical_version must be bound into signing",
);
assert!(
canonical_v2.starts_with("v2|"),
"v2 canonical must be prefixed with v2| tag, got: {canonical_v2}",
);
assert!(
canonical_v3.starts_with("v3|"),
"v3 canonical must be prefixed with v3| tag, got: {canonical_v3}",
);
assert_eq!(
canonical_v1,
"7|sha256:abcd|42|6|key_test|2026-05-17T00:00:00Z",
"v1 canonical must remain byte-identical to legacy",
);
}
}