1use std::path::{Path, PathBuf};
4
5use auths_core::ports::config_store::{ConfigStore, ConfigStoreError};
6use auths_core::ports::network::{NetworkError, RegistryClient};
7use auths_keri::witness::independence::{
8 IndependencePolicy, Infrastructure, Jurisdiction, OperatorId, Organization, WitnessOperatorInfo,
9};
10use auths_transparency::{
11 BundleVerificationReport, ConsistencyProof, FsTileStore, LogOrigin, LogWriter, MerkleHash,
12 OfflineBundle, SignedCheckpoint, TransparencyError, TrustRoot, TrustRootWitness, hash_leaf,
13};
14use auths_verifier::Ed25519PublicKey;
15use auths_verifier::evidence_pack::TransparencyInclusion;
16use chrono::{DateTime, Utc};
17use thiserror::Error;
18
19use crate::domains::compliance::releases::ArtifactDigest;
20
21#[derive(Debug, Error)]
23pub enum TransparencyWorkflowError {
24 #[error("bundle verification found issues")]
26 VerificationFailed(Box<BundleVerificationReport>),
27
28 #[error("checkpoint inconsistent: {0}")]
30 CheckpointInconsistent(String),
31
32 #[error("cache I/O error: {0}")]
34 CacheError(#[from] ConfigStoreError),
35
36 #[error("deserialization error: {0}")]
38 DeserializationError(String),
39
40 #[error("network error: {0}")]
42 NetworkError(#[source] NetworkError),
43
44 #[error("invalid input: {0}")]
46 InvalidInput(String),
47
48 #[error("no transparency log at {0} — append an artifact first")]
50 LogNotFound(PathBuf),
51
52 #[error("transparency log error: {0}")]
54 Transparency(#[from] TransparencyError),
55}
56
57#[derive(Debug, serde::Deserialize)]
59struct TrustRootResponse {
60 log_origin: String,
61 log_public_key: String,
62 witnesses: Vec<TrustRootWitnessResponse>,
63 #[allow(dead_code)]
64 version: u32,
65}
66
67#[derive(Debug, serde::Deserialize)]
69struct TrustRootWitnessResponse {
70 name: String,
71 public_key: String,
72 #[allow(dead_code)]
73 url: String,
74 #[serde(default)]
78 organization: Option<String>,
79 #[serde(default)]
80 jurisdiction: Option<String>,
81 #[serde(default)]
82 infrastructure: Option<String>,
83}
84
85fn operator_info_from_wire(w: &TrustRootWitnessResponse) -> Option<WitnessOperatorInfo> {
89 Some(WitnessOperatorInfo {
90 operator: OperatorId::new(w.name.clone()).ok()?,
91 organization: Organization::new(w.organization.clone()?).ok()?,
92 jurisdiction: Jurisdiction::new(w.jurisdiction.clone()?).ok()?,
93 infrastructure: Infrastructure::new(w.infrastructure.clone()?).ok()?,
94 })
95}
96
97pub async fn fetch_trust_root(
111 registry_url: &str,
112 client: &impl RegistryClient,
113) -> Result<TrustRoot, TransparencyWorkflowError> {
114 let bytes = client
115 .fetch_registry_data(registry_url, "v1/trust-root")
116 .await
117 .map_err(TransparencyWorkflowError::NetworkError)?;
118
119 let resp: TrustRootResponse = serde_json::from_slice(&bytes)
120 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
121
122 let log_public_key_bytes: [u8; 32] = hex::decode(&resp.log_public_key)
123 .map_err(|e| {
124 TransparencyWorkflowError::DeserializationError(format!(
125 "invalid hex in log_public_key: {e}"
126 ))
127 })?
128 .try_into()
129 .map_err(|_| {
130 TransparencyWorkflowError::DeserializationError(
131 "log_public_key must be exactly 32 bytes".into(),
132 )
133 })?;
134
135 let log_origin = LogOrigin::new(&resp.log_origin).map_err(|e| {
136 TransparencyWorkflowError::DeserializationError(format!("invalid log origin: {e}"))
137 })?;
138
139 let witnesses = resp
140 .witnesses
141 .into_iter()
142 .filter(|w| !w.public_key.is_empty())
143 .filter_map(|w| {
144 let pk_bytes: [u8; 32] = hex::decode(&w.public_key).ok()?.try_into().ok()?;
145 let public_key = Ed25519PublicKey::from_bytes(pk_bytes);
146 let witness_did = auths_verifier::CanonicalDid::from_public_key_did_key(
147 public_key.as_bytes(),
148 auths_crypto::CurveType::Ed25519,
149 );
150 let operator_info = operator_info_from_wire(&w);
151 Some(TrustRootWitness {
152 witness_did,
153 name: w.name,
154 public_key,
155 operator_info,
156 })
157 })
158 .collect();
159
160 Ok(TrustRoot {
161 log_public_key: Ed25519PublicKey::from_bytes(log_public_key_bytes),
162 log_origin,
163 witnesses,
164 signature_algorithm: Default::default(),
165 ecdsa_log_public_key_der: None,
166 independence_policy: IndependencePolicy::unconstrained(),
169 })
170}
171
172pub struct BundleVerifyConfig {
174 pub bundle_json: String,
176 pub trust_root_json: String,
178}
179
180#[derive(Debug)]
182pub struct ConsistencyReport {
183 pub old_size: u64,
185 pub new_size: u64,
187 pub consistent: bool,
189}
190
191pub fn verify_artifact_bundle(
207 config: &BundleVerifyConfig,
208 now: DateTime<Utc>,
209) -> Result<BundleVerificationReport, TransparencyWorkflowError> {
210 let bundle: OfflineBundle = serde_json::from_str(&config.bundle_json)
211 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
212 let trust_root: TrustRoot = serde_json::from_str(&config.trust_root_json)
213 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
214
215 let report = auths_transparency::verify_bundle(&bundle, &trust_root, now);
216
217 if !report.is_valid() {
218 return Err(TransparencyWorkflowError::VerificationFailed(Box::new(
219 report,
220 )));
221 }
222
223 Ok(report)
224}
225
226pub fn update_checkpoint_cache(
252 store: &dyn ConfigStore,
253 cache_path: &Path,
254 new_checkpoint: &SignedCheckpoint,
255 consistency_proof: &ConsistencyProof,
256 _trust_root: &TrustRoot,
257 _now: DateTime<Utc>,
258) -> Result<ConsistencyReport, TransparencyWorkflowError> {
259 let old_checkpoint = load_cached_checkpoint(store, cache_path)?;
260
261 if let Some(ref old) = old_checkpoint {
262 auths_transparency::verify_consistency(
263 old.checkpoint.size,
264 new_checkpoint.checkpoint.size,
265 &consistency_proof.hashes,
266 &old.checkpoint.root,
267 &new_checkpoint.checkpoint.root,
268 )
269 .map_err(|e| TransparencyWorkflowError::CheckpointInconsistent(e.to_string()))?;
270 }
271
272 write_cached_checkpoint(store, cache_path, new_checkpoint)?;
273
274 let old_size = old_checkpoint.map(|c| c.checkpoint.size).unwrap_or(0);
275
276 Ok(ConsistencyReport {
277 old_size,
278 new_size: new_checkpoint.checkpoint.size,
279 consistent: true,
280 })
281}
282
283fn load_cached_checkpoint(
285 store: &dyn ConfigStore,
286 cache_path: &Path,
287) -> Result<Option<SignedCheckpoint>, TransparencyWorkflowError> {
288 match store.read(cache_path)? {
289 Some(json) => serde_json::from_str(&json)
290 .map(Some)
291 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string())),
292 None => Ok(None),
293 }
294}
295
296fn write_cached_checkpoint(
298 store: &dyn ConfigStore,
299 cache_path: &Path,
300 checkpoint: &SignedCheckpoint,
301) -> Result<(), TransparencyWorkflowError> {
302 let json = serde_json::to_string_pretty(checkpoint)
303 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
304 store.write(cache_path, &json)?;
305 Ok(())
306}
307
308pub fn try_cache_checkpoint(
332 store: &dyn ConfigStore,
333 cache_path: &Path,
334 new_checkpoint: &SignedCheckpoint,
335 consistency_proof: Option<&ConsistencyProof>,
336) -> Result<ConsistencyReport, TransparencyWorkflowError> {
337 let old_checkpoint = load_cached_checkpoint(store, cache_path)?;
338
339 if let Some(ref old) = old_checkpoint {
340 if old.checkpoint.size == new_checkpoint.checkpoint.size
342 && old.checkpoint.root != new_checkpoint.checkpoint.root
343 {
344 return Err(TransparencyWorkflowError::CheckpointInconsistent(format!(
345 "equivocation detected: same tree size {} but different roots",
346 old.checkpoint.size
347 )));
348 }
349
350 if new_checkpoint.checkpoint.size < old.checkpoint.size {
352 return Err(TransparencyWorkflowError::CheckpointInconsistent(format!(
353 "new checkpoint size {} is smaller than cached size {}",
354 new_checkpoint.checkpoint.size, old.checkpoint.size
355 )));
356 }
357
358 if old.checkpoint.size == new_checkpoint.checkpoint.size {
360 return Ok(ConsistencyReport {
361 old_size: old.checkpoint.size,
362 new_size: new_checkpoint.checkpoint.size,
363 consistent: true,
364 });
365 }
366
367 if let Some(proof) = consistency_proof {
369 auths_transparency::verify_consistency(
370 old.checkpoint.size,
371 new_checkpoint.checkpoint.size,
372 &proof.hashes,
373 &old.checkpoint.root,
374 &new_checkpoint.checkpoint.root,
375 )
376 .map_err(|e| TransparencyWorkflowError::CheckpointInconsistent(e.to_string()))?;
377 }
378 }
379
380 write_cached_checkpoint(store, cache_path, new_checkpoint)?;
381
382 let old_size = old_checkpoint.map(|c| c.checkpoint.size).unwrap_or(0);
383
384 Ok(ConsistencyReport {
385 old_size,
386 new_size: new_checkpoint.checkpoint.size,
387 consistent: true,
388 })
389}
390
391#[derive(Debug, Clone)]
397pub struct AppendedArtifact {
398 pub artifact_digest: String,
400 pub leaf_hash: MerkleHash,
402 pub index: u64,
404 pub signed_checkpoint: SignedCheckpoint,
406}
407
408fn open_log_writer(
413 log_dir: &Path,
414 origin: &str,
415 create: bool,
416) -> Result<LogWriter<FsTileStore>, TransparencyWorkflowError> {
417 let origin = LogOrigin::new(origin)
418 .map_err(|e| TransparencyWorkflowError::InvalidInput(format!("invalid log origin: {e}")))?;
419 let store = FsTileStore::new(log_dir.to_path_buf());
420 if create {
421 store.ensure_base_dir()?;
422 }
423 let key = store
424 .load_or_create_key(create)?
425 .ok_or_else(|| TransparencyWorkflowError::LogNotFound(log_dir.to_path_buf()))?;
426 Ok(LogWriter::new(store, key, origin))
427}
428
429pub async fn append_artifact_digest(
448 log_dir: &Path,
449 origin: &str,
450 artifact_digest: &str,
451 now: DateTime<Utc>,
452) -> Result<AppendedArtifact, TransparencyWorkflowError> {
453 let digest = ArtifactDigest::parse(artifact_digest).map_err(|e| {
454 TransparencyWorkflowError::InvalidInput(format!("invalid artifact digest: {e}"))
455 })?;
456 let writer = open_log_writer(log_dir, origin, true)?;
457
458 let leaf_hash = hash_leaf(digest.as_str().as_bytes());
459 let appended = writer.append(leaf_hash, now).await?;
460
461 Ok(AppendedArtifact {
462 artifact_digest: digest.into_inner(),
463 leaf_hash,
464 index: appended.index,
465 signed_checkpoint: appended.signed_checkpoint,
466 })
467}
468
469pub async fn prove_artifact_digest(
487 log_dir: &Path,
488 origin: &str,
489 artifact_digest: &str,
490) -> Result<TransparencyInclusion, TransparencyWorkflowError> {
491 let digest = ArtifactDigest::parse(artifact_digest).map_err(|e| {
492 TransparencyWorkflowError::InvalidInput(format!("invalid artifact digest: {e}"))
493 })?;
494 let writer = open_log_writer(log_dir, origin, false)?;
495
496 let leaf_hash = hash_leaf(digest.as_str().as_bytes());
497 let inclusion = writer.prove(&leaf_hash).await?;
498 Ok(inclusion)
499}
500
501#[cfg(test)]
502#[allow(clippy::unwrap_used, clippy::expect_used, clippy::disallowed_methods)]
503mod tests {
504 use super::*;
505 use auths_transparency::checkpoint::{Checkpoint, SignedCheckpoint};
506
507 struct FsStore;
508
509 impl ConfigStore for FsStore {
510 fn read(&self, path: &Path) -> Result<Option<String>, ConfigStoreError> {
511 match std::fs::read_to_string(path) {
512 Ok(content) => Ok(Some(content)),
513 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
514 Err(e) => Err(ConfigStoreError::Read {
515 path: path.to_path_buf(),
516 source: e,
517 }),
518 }
519 }
520
521 fn write(&self, path: &Path, content: &str) -> Result<(), ConfigStoreError> {
522 if let Some(parent) = path.parent() {
523 std::fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write {
524 path: path.to_path_buf(),
525 source: e,
526 })?;
527 }
528 std::fs::write(path, content).map_err(|e| ConfigStoreError::Write {
529 path: path.to_path_buf(),
530 source: e,
531 })
532 }
533 }
534 use auths_transparency::entry::{Entry, EntryBody, EntryContent, EntryType};
535 use auths_transparency::proof::InclusionProof;
536 use auths_transparency::types::{LogOrigin, MerkleHash};
537 use auths_verifier::{CanonicalDid, Ed25519PublicKey, Ed25519Signature};
538
539 fn dummy_signed_checkpoint(size: u64, root: MerkleHash) -> SignedCheckpoint {
540 SignedCheckpoint {
541 checkpoint: Checkpoint {
542 origin: LogOrigin::new("test.dev/log").unwrap(),
543 size,
544 root,
545 timestamp: chrono::DateTime::parse_from_rfc3339("2025-06-15T00:00:00Z")
546 .unwrap()
547 .with_timezone(&Utc),
548 },
549 log_signature: Ed25519Signature::from_bytes([0u8; 64]),
550 log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
551 witnesses: vec![],
552 ecdsa_checkpoint_signature: None,
553 ecdsa_checkpoint_key: None,
554 }
555 }
556
557 fn dummy_trust_root() -> TrustRoot {
558 TrustRoot {
559 log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
560 log_origin: LogOrigin::new("test.dev/log").unwrap(),
561 witnesses: vec![],
562 signature_algorithm: Default::default(),
563 ecdsa_log_public_key_der: None,
564 independence_policy: IndependencePolicy::unconstrained(),
565 }
566 }
567
568 struct CannedRegistry {
570 trust_root_json: Vec<u8>,
571 }
572
573 impl RegistryClient for CannedRegistry {
574 async fn fetch_registry_data(
575 &self,
576 _registry_url: &str,
577 _path: &str,
578 ) -> Result<Vec<u8>, NetworkError> {
579 Ok(self.trust_root_json.clone())
580 }
581
582 async fn push_registry_data(
583 &self,
584 _registry_url: &str,
585 _path: &str,
586 _data: &[u8],
587 ) -> Result<(), NetworkError> {
588 Ok(())
589 }
590
591 async fn post_json(
592 &self,
593 _registry_url: &str,
594 _path: &str,
595 _json_body: &[u8],
596 ) -> Result<auths_core::ports::network::RegistryResponse, NetworkError> {
597 Ok(auths_core::ports::network::RegistryResponse {
598 status: 200,
599 body: vec![],
600 rate_limit: None,
601 })
602 }
603 }
604
605 #[tokio::test]
606 async fn fetch_trust_root_round_trips_independence_attributes() {
607 let log_pk = hex::encode([0u8; 32]);
608 let w_pk = hex::encode([7u8; 32]);
609 let json = format!(
610 r#"{{"version":1,"log_origin":"test.dev/log","log_public_key":"{log_pk}","witnesses":[{{"name":"w1","public_key":"{w_pk}","url":"http://w1","organization":"org-a","jurisdiction":"US","infrastructure":"aws/us-east-1"}}]}}"#
611 );
612 let client = CannedRegistry {
613 trust_root_json: json.into_bytes(),
614 };
615
616 let trust_root = fetch_trust_root("https://registry.test", &client)
617 .await
618 .unwrap();
619
620 assert_eq!(trust_root.witnesses.len(), 1);
621 let attrs = trust_root.witnesses[0]
622 .operator_attributes()
623 .expect("operator attributes survive ingestion");
624 assert_eq!(attrs.organization.as_str(), "org-a");
625 assert_eq!(attrs.jurisdiction.as_str(), "US");
626 assert_eq!(attrs.infrastructure.as_str(), "aws/us-east-1");
627 }
628
629 #[test]
630 fn verify_artifact_bundle_invalid_bundle_json() {
631 let config = BundleVerifyConfig {
632 bundle_json: "not valid json".into(),
633 trust_root_json: "{}".into(),
634 };
635 let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
636 .unwrap()
637 .with_timezone(&Utc);
638 let err = verify_artifact_bundle(&config, now).unwrap_err();
639 assert!(matches!(
640 err,
641 TransparencyWorkflowError::DeserializationError(_)
642 ));
643 }
644
645 fn dummy_bundle() -> OfflineBundle {
646 let ts = chrono::DateTime::parse_from_rfc3339("2025-06-15T00:00:00Z")
647 .unwrap()
648 .with_timezone(&Utc);
649 let entry = Entry {
650 sequence: 0,
651 timestamp: ts,
652 content: EntryContent {
653 entry_type: EntryType::DeviceBind,
654 body: EntryBody::DeviceBind {
655 device_did: CanonicalDid::new_unchecked("did:key:z6MkTest"),
656 public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
657 },
658 actor_did: CanonicalDid::new_unchecked("did:key:z6MkTest"),
659 },
660 actor_sig: Ed25519Signature::empty(),
661 };
662 let root = MerkleHash::from_bytes([0u8; 32]);
663 OfflineBundle {
664 entry,
665 inclusion_proof: InclusionProof {
666 index: 0,
667 size: 1,
668 root,
669 hashes: vec![],
670 },
671 signed_checkpoint: dummy_signed_checkpoint(1, root),
672 delegation_chain: vec![],
673 }
674 }
675
676 #[test]
677 fn verify_artifact_bundle_invalid_trust_root_json() {
678 let bundle = dummy_bundle();
679 let bundle_json = serde_json::to_string(&bundle).unwrap();
680
681 let config = BundleVerifyConfig {
682 bundle_json,
683 trust_root_json: "not valid json".into(),
684 };
685 let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
686 .unwrap()
687 .with_timezone(&Utc);
688 let err = verify_artifact_bundle(&config, now).unwrap_err();
689 assert!(matches!(
690 err,
691 TransparencyWorkflowError::DeserializationError(_)
692 ));
693 }
694
695 #[test]
696 fn update_checkpoint_cache_writes_new_file() {
697 let dir = tempfile::tempdir().unwrap();
698 let cache_path = dir.path().join("checkpoint.json");
699
700 let root = MerkleHash::from_bytes([0xaa; 32]);
701 let new_cp = dummy_signed_checkpoint(10, root);
702 let proof = ConsistencyProof {
703 old_size: 0,
704 new_size: 10,
705 old_root: MerkleHash::from_bytes([0u8; 32]),
706 new_root: root,
707 hashes: vec![],
708 };
709 let trust_root = dummy_trust_root();
710 let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
711 .unwrap()
712 .with_timezone(&Utc);
713
714 let report =
715 update_checkpoint_cache(&FsStore, &cache_path, &new_cp, &proof, &trust_root, now)
716 .unwrap();
717
718 assert_eq!(report.old_size, 0);
719 assert_eq!(report.new_size, 10);
720 assert!(report.consistent);
721 assert!(cache_path.exists());
722
723 let written: SignedCheckpoint =
724 serde_json::from_str(&std::fs::read_to_string(&cache_path).unwrap()).unwrap();
725 assert_eq!(written.checkpoint.size, 10);
726 }
727
728 #[test]
729 fn update_checkpoint_cache_creates_parent_dirs() {
730 let dir = tempfile::tempdir().unwrap();
731 let cache_path = dir
732 .path()
733 .join("nested")
734 .join("dir")
735 .join("checkpoint.json");
736
737 let root = MerkleHash::from_bytes([0xbb; 32]);
738 let new_cp = dummy_signed_checkpoint(5, root);
739 let proof = ConsistencyProof {
740 old_size: 0,
741 new_size: 5,
742 old_root: MerkleHash::from_bytes([0u8; 32]),
743 new_root: root,
744 hashes: vec![],
745 };
746 let trust_root = dummy_trust_root();
747 let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
748 .unwrap()
749 .with_timezone(&Utc);
750
751 let report =
752 update_checkpoint_cache(&FsStore, &cache_path, &new_cp, &proof, &trust_root, now)
753 .unwrap();
754
755 assert!(report.consistent);
756 assert!(cache_path.exists());
757 }
758
759 #[test]
760 fn try_cache_checkpoint_tofu_writes_new_file() {
761 let dir = tempfile::tempdir().unwrap();
762 let cache_path = dir.path().join("log_checkpoint.json");
763
764 let root = MerkleHash::from_bytes([0xaa; 32]);
765 let cp = dummy_signed_checkpoint(10, root);
766
767 let report = try_cache_checkpoint(&FsStore, &cache_path, &cp, None).unwrap();
768 assert_eq!(report.old_size, 0);
769 assert_eq!(report.new_size, 10);
770 assert!(report.consistent);
771 assert!(cache_path.exists());
772 }
773
774 #[test]
775 fn try_cache_checkpoint_same_checkpoint_is_noop() {
776 let dir = tempfile::tempdir().unwrap();
777 let cache_path = dir.path().join("log_checkpoint.json");
778
779 let root = MerkleHash::from_bytes([0xaa; 32]);
780 let cp = dummy_signed_checkpoint(10, root);
781
782 try_cache_checkpoint(&FsStore, &cache_path, &cp, None).unwrap();
783 let report = try_cache_checkpoint(&FsStore, &cache_path, &cp, None).unwrap();
784 assert_eq!(report.old_size, 10);
785 assert_eq!(report.new_size, 10);
786 assert!(report.consistent);
787 }
788
789 #[test]
790 fn try_cache_checkpoint_detects_equivocation() {
791 let dir = tempfile::tempdir().unwrap();
792 let cache_path = dir.path().join("log_checkpoint.json");
793
794 let root1 = MerkleHash::from_bytes([0xaa; 32]);
795 let cp1 = dummy_signed_checkpoint(10, root1);
796 try_cache_checkpoint(&FsStore, &cache_path, &cp1, None).unwrap();
797
798 let root2 = MerkleHash::from_bytes([0xbb; 32]);
799 let cp2 = dummy_signed_checkpoint(10, root2);
800 let err = try_cache_checkpoint(&FsStore, &cache_path, &cp2, None).unwrap_err();
801 assert!(matches!(
802 err,
803 TransparencyWorkflowError::CheckpointInconsistent(_)
804 ));
805 }
806
807 #[test]
808 fn try_cache_checkpoint_rejects_smaller_size() {
809 let dir = tempfile::tempdir().unwrap();
810 let cache_path = dir.path().join("log_checkpoint.json");
811
812 let cp1 = dummy_signed_checkpoint(10, MerkleHash::from_bytes([0xaa; 32]));
813 try_cache_checkpoint(&FsStore, &cache_path, &cp1, None).unwrap();
814
815 let cp2 = dummy_signed_checkpoint(5, MerkleHash::from_bytes([0xbb; 32]));
816 let err = try_cache_checkpoint(&FsStore, &cache_path, &cp2, None).unwrap_err();
817 assert!(matches!(
818 err,
819 TransparencyWorkflowError::CheckpointInconsistent(_)
820 ));
821 }
822
823 const TEST_DIGEST: &str =
826 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
827 const TEST_DIGEST_2: &str =
828 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
829
830 fn fixed_now() -> DateTime<Utc> {
831 chrono::DateTime::parse_from_rfc3339("2026-07-01T00:00:00Z")
832 .unwrap()
833 .with_timezone(&Utc)
834 }
835
836 #[tokio::test]
837 async fn append_creates_log_and_returns_sequenced_leaf() {
838 let dir = tempfile::tempdir().unwrap();
839
840 let appended = append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST, fixed_now())
841 .await
842 .unwrap();
843
844 assert_eq!(appended.index, 0);
845 assert_eq!(appended.artifact_digest, TEST_DIGEST);
846 assert_eq!(appended.signed_checkpoint.checkpoint.size, 1);
847 assert_eq!(appended.leaf_hash, hash_leaf(TEST_DIGEST.as_bytes()));
848 assert!(dir.path().join("log.key").exists());
849 }
850
851 #[tokio::test]
852 async fn append_normalizes_uppercase_hex() {
853 let dir = tempfile::tempdir().unwrap();
854 let upper = TEST_DIGEST
855 .to_ascii_uppercase()
856 .replace("SHA256:", "sha256:");
857
858 let appended = append_artifact_digest(dir.path(), "test.dev/log", &upper, fixed_now())
859 .await
860 .unwrap();
861
862 assert_eq!(appended.artifact_digest, TEST_DIGEST);
863 assert_eq!(appended.leaf_hash, hash_leaf(TEST_DIGEST.as_bytes()));
864 }
865
866 #[tokio::test]
867 async fn append_is_append_only_and_grows_the_tree() {
868 let dir = tempfile::tempdir().unwrap();
869
870 let a = append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST, fixed_now())
871 .await
872 .unwrap();
873 let b = append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST_2, fixed_now())
874 .await
875 .unwrap();
876
877 assert_eq!(a.index, 0);
878 assert_eq!(b.index, 1);
879 assert_eq!(b.signed_checkpoint.checkpoint.size, 2);
880 }
881
882 #[tokio::test]
883 async fn append_then_prove_round_trips_and_proof_verifies() {
884 let dir = tempfile::tempdir().unwrap();
885 append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST, fixed_now())
886 .await
887 .unwrap();
888
889 let inclusion = prove_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST)
890 .await
891 .unwrap();
892
893 assert_eq!(inclusion.inclusion_proof.index, 0);
894 assert_eq!(inclusion.inclusion_proof.size, 1);
895 assert_eq!(inclusion.leaf_hash, hash_leaf(TEST_DIGEST.as_bytes()));
896 inclusion
897 .inclusion_proof
898 .verify(&inclusion.leaf_hash)
899 .unwrap();
900 }
901
902 #[tokio::test]
903 async fn prove_without_any_log_errors() {
904 let dir = tempfile::tempdir().unwrap();
905 let err = prove_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST)
906 .await
907 .unwrap_err();
908 assert!(matches!(err, TransparencyWorkflowError::LogNotFound(_)));
909 }
910
911 #[tokio::test]
912 async fn prove_absent_leaf_errors() {
913 let dir = tempfile::tempdir().unwrap();
914 append_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST, fixed_now())
915 .await
916 .unwrap();
917
918 let err = prove_artifact_digest(dir.path(), "test.dev/log", TEST_DIGEST_2)
919 .await
920 .unwrap_err();
921 assert!(matches!(err, TransparencyWorkflowError::Transparency(_)));
922 }
923
924 #[tokio::test]
925 async fn append_rejects_malformed_digest() {
926 let dir = tempfile::tempdir().unwrap();
927 let err = append_artifact_digest(dir.path(), "test.dev/log", "not-a-digest", fixed_now())
928 .await
929 .unwrap_err();
930 assert!(matches!(err, TransparencyWorkflowError::InvalidInput(_)));
931 }
932
933 #[tokio::test]
934 async fn append_rejects_empty_origin() {
935 let dir = tempfile::tempdir().unwrap();
936 let err = append_artifact_digest(dir.path(), "", TEST_DIGEST, fixed_now())
937 .await
938 .unwrap_err();
939 assert!(matches!(err, TransparencyWorkflowError::InvalidInput(_)));
940 }
941}