Skip to main content

auths_sdk/workflows/
transparency.rs

1//! SDK transparency verification workflows.
2
3use 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/// Errors from transparency verification workflows.
22#[derive(Debug, Error)]
23pub enum TransparencyWorkflowError {
24    /// Bundle verification found issues.
25    #[error("bundle verification found issues")]
26    VerificationFailed(Box<BundleVerificationReport>),
27
28    /// Checkpoint consistency check failed.
29    #[error("checkpoint inconsistent: {0}")]
30    CheckpointInconsistent(String),
31
32    /// Cache I/O error.
33    #[error("cache I/O error: {0}")]
34    CacheError(#[from] ConfigStoreError),
35
36    /// JSON deserialization error.
37    #[error("deserialization error: {0}")]
38    DeserializationError(String),
39
40    /// Network error fetching trust root or other remote data.
41    #[error("network error: {0}")]
42    NetworkError(#[source] NetworkError),
43
44    /// Invalid caller input — a malformed artifact digest or log origin.
45    #[error("invalid input: {0}")]
46    InvalidInput(String),
47
48    /// No local transparency log exists at the given directory yet.
49    #[error("no transparency log at {0} — append an artifact first")]
50    LogNotFound(PathBuf),
51
52    /// An underlying transparency-log operation (append, prove, key) failed.
53    #[error("transparency log error: {0}")]
54    Transparency(#[from] TransparencyError),
55}
56
57/// Wire-format response from the registry trust-root endpoint.
58#[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/// Wire-format witness entry from the trust-root response.
68#[derive(Debug, serde::Deserialize)]
69struct TrustRootWitnessResponse {
70    name: String,
71    public_key: String,
72    #[allow(dead_code)]
73    url: String,
74    /// Operator-independence attributes. Carried through to the trust root so the
75    /// diversity gate can evaluate the actual cosigners; absent ⇒ this witness
76    /// cannot contribute to independence.
77    #[serde(default)]
78    organization: Option<String>,
79    #[serde(default)]
80    jurisdiction: Option<String>,
81    #[serde(default)]
82    infrastructure: Option<String>,
83}
84
85/// Build the operator-independence attributes for a wire witness entry, if all
86/// three axes are present and valid. Returns `None` (not an error) when any axis
87/// is missing — an untagged witness simply cannot prove independence.
88fn 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
97/// Fetch the trust root from a registry URL.
98///
99/// Issues a GET to `{registry_url}/v1/trust-root`, parses the JSON
100/// response, and converts it into a domain [`TrustRoot`].
101///
102/// Args:
103/// * `registry_url` — Base URL of the auths registry.
104/// * `client` — Network client for HTTP communication.
105///
106/// Usage:
107/// ```ignore
108/// let trust_root = fetch_trust_root("https://registry.auths.dev", &http_client).await?;
109/// ```
110pub 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        // The registry trust-root wire format does not yet carry diversity
167        // thresholds; the pinned `witness_policy.json` is the enforcement source.
168        independence_policy: IndependencePolicy::unconstrained(),
169    })
170}
171
172/// Configuration for bundle verification.
173pub struct BundleVerifyConfig {
174    /// The offline bundle serialized as JSON.
175    pub bundle_json: String,
176    /// The trust root serialized as JSON.
177    pub trust_root_json: String,
178}
179
180/// Result of a consistency check between cached and new checkpoints.
181#[derive(Debug)]
182pub struct ConsistencyReport {
183    /// Tree size of the previously cached checkpoint (0 if none).
184    pub old_size: u64,
185    /// Tree size of the newly cached checkpoint.
186    pub new_size: u64,
187    /// Whether consistency was verified.
188    pub consistent: bool,
189}
190
191/// Verify an offline transparency bundle.
192///
193/// Deserializes the bundle and trust root from JSON, delegates to
194/// `auths_transparency::verify_bundle`, and returns the report.
195/// Returns `Err(VerificationFailed)` when the bundle does not pass
196/// all verification checks.
197///
198/// Args:
199/// * `config` — Bundle and trust root JSON strings.
200/// * `now` — Injected wall-clock time.
201///
202/// Usage:
203/// ```ignore
204/// let report = verify_artifact_bundle(&config, now)?;
205/// ```
206pub 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
226/// Update the local checkpoint cache after verifying consistency.
227///
228/// Loads the cached checkpoint from disk, verifies that the new checkpoint
229/// is a consistent append-only extension of the cached one, and writes the
230/// new checkpoint to disk.
231///
232/// Args:
233/// * `store` — File-access port for the checkpoint cache file.
234/// * `cache_path` — Path to the cached checkpoint JSON file.
235/// * `new_checkpoint` — The newly received signed checkpoint.
236/// * `consistency_proof` — Proof that old tree is a prefix of the new tree.
237/// * `_trust_root` — Trust root for checkpoint signature verification (reserved for future use).
238/// * `_now` — Injected wall-clock time (reserved for future use).
239///
240/// Usage:
241/// ```ignore
242/// let report = update_checkpoint_cache(
243///     &store,
244///     &cache_path,
245///     &new_checkpoint,
246///     &consistency_proof,
247///     &trust_root,
248///     now,
249/// )?;
250/// ```
251pub 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
283/// Read and parse the cached checkpoint via the store, `None` when absent.
284fn 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
296/// Serialize and write the checkpoint via the store (parent dirs created by the store).
297fn 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
308/// Cache a checkpoint using trust-on-first-use (TOFU) semantics.
309///
310/// If no cached checkpoint exists, the new checkpoint is accepted and written.
311/// If a cached checkpoint exists with the same or smaller tree size and matching
312/// root, the cache is left unchanged. If the cached checkpoint has the same size
313/// but a different root, this is equivocation — returns a hard error.
314/// If a consistency proof is provided, full Merkle consistency is verified.
315///
316/// Args:
317/// * `store` — File-access port for the checkpoint cache file.
318/// * `cache_path` — Path to the cached checkpoint JSON file (`~/.auths/log_checkpoint.json`).
319/// * `new_checkpoint` — The checkpoint to cache.
320/// * `consistency_proof` — Optional consistency proof for cache-hit cases.
321///
322/// Usage:
323/// ```ignore
324/// try_cache_checkpoint(
325///     &store,
326///     &Path::new("~/.auths/log_checkpoint.json"),
327///     &bundle.signed_checkpoint,
328///     None,
329/// )?;
330/// ```
331pub 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        // Equivocation: same size, different root
341        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        // New checkpoint must not be smaller
351        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        // Same checkpoint — no update needed
359        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 we have a consistency proof, verify it
368        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/// Outcome of appending an artifact digest to a local transparency log.
392///
393/// Carries the sequenced position and the fresh signed checkpoint so a
394/// presentation layer can render or persist them; the raw leaf hash is
395/// retained for callers that re-derive it (`hash_leaf(artifact_digest)`).
396#[derive(Debug, Clone)]
397pub struct AppendedArtifact {
398    /// Canonical `sha256:<hex>` digest that was logged.
399    pub artifact_digest: String,
400    /// Merkle leaf hash the digest was stored under.
401    pub leaf_hash: MerkleHash,
402    /// Zero-based index the leaf was sequenced at.
403    pub index: u64,
404    /// The checkpoint signed over the tree that now includes the leaf.
405    pub signed_checkpoint: SignedCheckpoint,
406}
407
408/// Open a writer over the local tile-backed log, validating the origin and
409/// (when `create`) ensuring the log directory and signing key exist. The
410/// filesystem work — creating the directory and loading or minting the signing
411/// key — lives on [`FsTileStore`] so the workflow stays I/O-free.
412fn 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
429/// Append an artifact digest to a local tile-backed transparency log.
430///
431/// Validates the digest, creates the log directory and signing key on first
432/// use, hashes the canonical digest string into a leaf, appends it, and
433/// returns the sequenced position plus the newly signed checkpoint. The log
434/// is append-only: repeated calls grow the tree.
435///
436/// Args:
437/// * `log_dir` — Directory holding the tile store and `log.key`.
438/// * `origin` — Log origin string (non-empty ASCII) written into checkpoints.
439/// * `artifact_digest` — Artifact digest to log (`sha256:<64 hex>`).
440/// * `now` — Injected wall-clock time stamped into the checkpoint.
441///
442/// Usage:
443/// ```ignore
444/// let appended =
445///     append_artifact_digest(&log_dir, "acme.dev/releases", "sha256:ab..cd", now).await?;
446/// ```
447pub 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
469/// Emit offline inclusion evidence for an artifact digest already appended to
470/// a local transparency log.
471///
472/// Validates the digest, re-derives its leaf, and proves the leaf against the
473/// current signed checkpoint. Errors when no log exists yet ([`TransparencyWorkflowError::LogNotFound`])
474/// or the leaf was never appended (an [`TransparencyWorkflowError::Transparency`]
475/// invalid-proof error). The returned evidence is re-verifiable offline.
476///
477/// Args:
478/// * `log_dir` — Directory holding the tile store and `log.key`.
479/// * `origin` — Log origin string (must match the one used to append).
480/// * `artifact_digest` — Artifact digest to prove (`sha256:<64 hex>`).
481///
482/// Usage:
483/// ```ignore
484/// let evidence = prove_artifact_digest(&log_dir, "acme.dev/releases", "sha256:ab..cd").await?;
485/// ```
486pub 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    /// A registry client that returns a fixed trust-root document.
569    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    // ---- local-log append / prove workflow ----
824
825    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}