1use acdp_primitives::error::AcdpError;
26use acdp_types::log::{
27 encode_sha256_hex, parse_log_id, LogCheckpoint, LogConsistencyProof, LogInclusion, LogLeaf,
28};
29use acdp_types::receipt::ReceiptSigner;
30use chrono::{DateTime, Utc};
31use std::collections::HashSet;
32
33#[derive(Debug, Clone)]
36pub struct MerkleLog {
37 log_id: String,
41 leaves: Vec<LogLeaf>,
44 leaf_hashes: Vec<[u8; 32]>,
46 ctx_ids: HashSet<String>,
49}
50
51impl MerkleLog {
52 pub fn new(log_id: impl Into<String>) -> Result<Self, AcdpError> {
56 let log_id = log_id.into();
57 parse_log_id(&log_id)?;
58 Ok(Self {
59 log_id,
60 leaves: Vec::new(),
61 leaf_hashes: Vec::new(),
62 ctx_ids: HashSet::new(),
63 })
64 }
65
66 pub fn log_id(&self) -> &str {
68 &self.log_id
69 }
70
71 pub fn tree_size(&self) -> u64 {
73 self.leaves.len() as u64
74 }
75
76 pub fn leaf(&self, leaf_index: u64) -> Option<&LogLeaf> {
78 usize::try_from(leaf_index)
79 .ok()
80 .and_then(|i| self.leaves.get(i))
81 }
82
83 pub fn leaf_hash_hex(&self, leaf_index: u64) -> Option<String> {
87 usize::try_from(leaf_index)
88 .ok()
89 .and_then(|i| self.leaf_hashes.get(i))
90 .map(encode_sha256_hex)
91 }
92
93 pub fn append(&mut self, leaf: LogLeaf) -> Result<u64, AcdpError> {
102 let leaf = LogLeaf::from_value(&serde_json::to_value(&leaf)?)?;
104 if self.ctx_ids.contains(leaf.ctx_id.as_str()) {
105 return Err(AcdpError::SchemaViolation(format!(
106 "transparency log already holds a leaf for ctx_id '{}' — exactly one leaf \
107 per publish event (RFC-ACDP-0012 §4)",
108 leaf.ctx_id
109 )));
110 }
111 if leaf.ctx_id.authority() != leaf.origin_registry {
112 return Err(AcdpError::SchemaViolation(format!(
113 "leaf origin_registry '{}' ≠ ctx_id authority '{}' (RFC-ACDP-0012 §4)",
114 leaf.origin_registry,
115 leaf.ctx_id.authority()
116 )));
117 }
118 let (registry_did, _) = parse_log_id(&self.log_id)?;
119 let leaf_did = acdp_did::web::authority_to_did_web(&leaf.origin_registry);
120 if leaf_did != registry_did {
121 return Err(AcdpError::SchemaViolation(format!(
122 "leaf origin_registry '{}' is not this log's registry ('{registry_did}') \
123 (RFC-ACDP-0012 §4)",
124 leaf.origin_registry
125 )));
126 }
127 let hash = leaf.leaf_hash()?;
128 let index = self.leaves.len() as u64;
129 self.ctx_ids.insert(leaf.ctx_id.as_str().to_string());
130 self.leaves.push(leaf);
131 self.leaf_hashes.push(hash);
132 Ok(index)
133 }
134
135 pub fn root(&self) -> [u8; 32] {
138 acdp_crypto::merkle::merkle_tree_hash(&self.leaf_hashes)
139 }
140
141 pub fn root_hash(&self) -> String {
143 encode_sha256_hex(&self.root())
144 }
145
146 pub fn root_at(&self, tree_size: u64) -> Result<[u8; 32], AcdpError> {
149 let n = self.checked_size(tree_size)?;
150 Ok(acdp_crypto::merkle::merkle_tree_hash(
151 &self.leaf_hashes[..n],
152 ))
153 }
154
155 pub fn checkpoint(
163 &self,
164 signer: &ReceiptSigner,
165 timestamp: DateTime<Utc>,
166 ) -> Result<LogCheckpoint, AcdpError> {
167 self.checkpoint_at(signer, self.tree_size(), timestamp)
168 }
169
170 pub fn checkpoint_at(
175 &self,
176 signer: &ReceiptSigner,
177 tree_size: u64,
178 timestamp: DateTime<Utc>,
179 ) -> Result<LogCheckpoint, AcdpError> {
180 let root = self.root_at(tree_size)?;
181 signer.mint_log_checkpoint(
182 &self.log_id,
183 tree_size,
184 &encode_sha256_hex(&root),
185 timestamp,
186 )
187 }
188
189 pub fn inclusion_proof(
199 &self,
200 leaf_index: u64,
201 checkpoint: &LogCheckpoint,
202 ) -> Result<LogInclusion, AcdpError> {
203 if checkpoint.log_id != self.log_id {
204 return Err(AcdpError::SchemaViolation(format!(
205 "checkpoint log_id '{}' is not this log ('{}')",
206 checkpoint.log_id, self.log_id
207 )));
208 }
209 let n = self.checked_size(checkpoint.tree_size)?;
210 let m = usize::try_from(leaf_index)
211 .ok()
212 .filter(|m| *m < n)
213 .ok_or_else(|| {
214 AcdpError::SchemaViolation(format!(
215 "leaf_index {leaf_index} is not < tree_size {} (RFC-ACDP-0012 §8.2)",
216 checkpoint.tree_size
217 ))
218 })?;
219 let path = acdp_crypto::merkle::inclusion_path(m, &self.leaf_hashes[..n])
220 .expect("bounds checked above");
221 Ok(LogInclusion {
222 log_id: self.log_id.clone(),
223 leaf_index,
224 tree_size: checkpoint.tree_size,
225 inclusion_path: path.iter().map(encode_sha256_hex).collect(),
226 log_checkpoint: checkpoint.clone(),
227 leaf: None,
228 })
229 }
230
231 pub fn consistency_proof(&self, first: u64, second: u64) -> Result<Vec<String>, AcdpError> {
236 let n = self.checked_size(second)?;
237 if first == 0 || first > second {
238 return Err(AcdpError::SchemaViolation(format!(
239 "consistency proof requires 0 < first ({first}) ≤ second ({second}) \
240 (RFC-ACDP-0012 §8.2)"
241 )));
242 }
243 let m = usize::try_from(first).expect("first ≤ second ≤ len");
244 let path = acdp_crypto::merkle::consistency_proof(m, &self.leaf_hashes[..n])
245 .expect("bounds checked above");
246 Ok(path.iter().map(encode_sha256_hex).collect())
247 }
248
249 pub fn consistency_proof_response(
253 &self,
254 first: u64,
255 checkpoint: &LogCheckpoint,
256 ) -> Result<LogConsistencyProof, AcdpError> {
257 if checkpoint.log_id != self.log_id {
258 return Err(AcdpError::SchemaViolation(format!(
259 "checkpoint log_id '{}' is not this log ('{}')",
260 checkpoint.log_id, self.log_id
261 )));
262 }
263 Ok(LogConsistencyProof {
264 log_id: self.log_id.clone(),
265 first_tree_size: first,
266 second_tree_size: checkpoint.tree_size,
267 consistency_path: self.consistency_proof(first, checkpoint.tree_size)?,
268 log_checkpoint: checkpoint.clone(),
269 })
270 }
271
272 fn checked_size(&self, tree_size: u64) -> Result<usize, AcdpError> {
274 usize::try_from(tree_size)
275 .ok()
276 .filter(|n| *n <= self.leaves.len())
277 .ok_or_else(|| {
278 AcdpError::SchemaViolation(format!(
279 "tree_size {tree_size} exceeds the current log size {} (RFC-ACDP-0012 §8.2)",
280 self.leaves.len()
281 ))
282 })
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use acdp_crypto::SigningKey;
290 use acdp_types::log::{decode_sha256_hex, LOG_LEAF_VERSION};
291 use acdp_types::primitives::{ContentHash, CtxId};
292
293 const REGISTRY_DID: &str = "did:web:registry.example.com";
294 const LOG_ID: &str = "did:web:registry.example.com/log/1";
295
296 fn signer() -> ReceiptSigner {
297 ReceiptSigner::new(
298 SigningKey::from_bytes(&[0x11u8; 32]),
299 REGISTRY_DID,
300 format!("{REGISTRY_DID}#receipt-key-1"),
301 )
302 .unwrap()
303 }
304
305 fn registry_pub() -> [u8; 32] {
306 SigningKey::from_bytes(&[0x11u8; 32]).verifying_key_bytes()
307 }
308
309 fn leaf(i: u8) -> LogLeaf {
310 let ctx_id =
311 format!("acdp://registry.example.com/00000000-0000-4000-8000-0000000000{i:02}");
312 LogLeaf {
313 leaf_version: LOG_LEAF_VERSION.into(),
314 lineage_id: acdp_crypto::derive_lineage_id(&CtxId(ctx_id.clone())),
315 ctx_id: CtxId(ctx_id),
316 origin_registry: "registry.example.com".into(),
317 created_at: chrono::DateTime::parse_from_rfc3339("2026-07-01T01:00:00.123Z")
318 .unwrap()
319 .with_timezone(&Utc),
320 content_hash: ContentHash(format!("sha256:{}", "b".repeat(64))),
321 key_fingerprint: format!("sha256:{}", "c".repeat(64)),
322 receipt_hash: format!("sha256:{}", "d".repeat(64)),
323 }
324 }
325
326 #[test]
330 fn append_prove_verify_round_trip() {
331 for n in 1..=8u8 {
332 let mut log = MerkleLog::new(LOG_ID).unwrap();
333 let mut roots = vec![log.root()]; for i in 0..n {
335 let idx = log.append(leaf(i)).unwrap();
336 assert_eq!(idx, u64::from(i), "acceptance-order indexing (§5.3)");
337 roots.push(log.root());
338 }
339 assert_eq!(log.tree_size(), u64::from(n));
340
341 let cp = log.checkpoint(&signer(), Utc::now()).unwrap();
342 assert_eq!(cp.tree_size, u64::from(n));
343 assert_eq!(cp.root_hash, log.root_hash());
344 cp.verify_signature_with_key(Some(®istry_pub()), None)
345 .expect("checkpoint signature");
346 cp.cross_check_registry_binding("registry.example.com", REGISTRY_DID)
347 .unwrap();
348
349 for i in 0..u64::from(n) {
350 let proof = log.inclusion_proof(i, &cp).unwrap();
351 proof
354 .verify_reconstructed_leaf(log.leaf(i).unwrap())
355 .expect("inclusion proof verifies");
356 }
357
358 for m in 1..=u64::from(n) {
360 for k in m..=u64::from(n) {
361 let cp_k = log.checkpoint_at(&signer(), k, Utc::now()).unwrap();
362 cp_k.verify_signature_with_key(Some(®istry_pub()), None)
363 .unwrap();
364 let resp = log.consistency_proof_response(m, &cp_k).unwrap();
365 resp.verify_against_first_root(&encode_sha256_hex(
366 &roots[usize::try_from(m).unwrap()],
367 ))
368 .expect("consistency proof verifies");
369 }
370 }
371 }
372 }
373
374 #[test]
378 fn append_invariants_and_bounds() {
379 let mut log = MerkleLog::new(LOG_ID).unwrap();
380 assert_eq!(
381 log.root_hash(),
382 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
383 "empty tree root (RFC-ACDP-0012 §5.2)"
384 );
385 let cp0 = log.checkpoint(&signer(), Utc::now()).unwrap();
387 assert_eq!(cp0.tree_size, 0);
388 cp0.verify_signature_with_key(Some(®istry_pub()), None)
389 .unwrap();
390
391 log.append(leaf(0)).unwrap();
392 let err = log.append(leaf(0)).unwrap_err();
393 assert!(matches!(err, AcdpError::SchemaViolation(_)), "got {err:?}");
394
395 let mut foreign = leaf(1);
397 foreign.origin_registry = "evil.example.com".into();
398 assert!(log.append(foreign).is_err());
399 let mut mismatched = leaf(2);
401 mismatched.ctx_id =
402 CtxId("acdp://other.example.com/00000000-0000-4000-8000-000000000002".into());
403 assert!(log.append(mismatched).is_err());
404
405 let cp = log.checkpoint(&signer(), Utc::now()).unwrap();
407 assert!(log.inclusion_proof(1, &cp).is_err());
408 assert!(log.checkpoint_at(&signer(), 2, Utc::now()).is_err());
409 assert!(log.consistency_proof(0, 1).is_err());
410 assert!(log.consistency_proof(1, 2).is_err());
411 assert!(log.consistency_proof(1, 1).unwrap().is_empty());
412
413 let other = MerkleLog::new("did:web:registry.example.com/log/2").unwrap();
415 let cp_other = other.checkpoint(&signer(), Utc::now()).unwrap();
416 assert!(log.inclusion_proof(0, &cp_other).is_err());
417 assert!(log.consistency_proof_response(1, &cp_other).is_err());
418
419 assert!(MerkleLog::new("did:web:registry.example.com").is_err());
421 assert!(MerkleLog::new("did:web:registry.example.com/log/UPPER").is_err());
422
423 assert_eq!(
425 decode_sha256_hex(&log.leaf_hash_hex(0).unwrap()).unwrap(),
426 log.leaf(0).unwrap().leaf_hash().unwrap()
427 );
428 assert!(log.leaf_hash_hex(1).is_none());
429 }
430}