Skip to main content

auths_sdk/workflows/
transparency.rs

1//! SDK transparency verification workflows.
2
3use std::path::Path;
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, LogOrigin, OfflineBundle, SignedCheckpoint,
12    TrustRoot, TrustRootWitness,
13};
14use auths_verifier::Ed25519PublicKey;
15use chrono::{DateTime, Utc};
16use thiserror::Error;
17
18/// Errors from transparency verification workflows.
19#[derive(Debug, Error)]
20pub enum TransparencyWorkflowError {
21    /// Bundle verification found issues.
22    #[error("bundle verification found issues")]
23    VerificationFailed(Box<BundleVerificationReport>),
24
25    /// Checkpoint consistency check failed.
26    #[error("checkpoint inconsistent: {0}")]
27    CheckpointInconsistent(String),
28
29    /// Cache I/O error.
30    #[error("cache I/O error: {0}")]
31    CacheError(#[from] ConfigStoreError),
32
33    /// JSON deserialization error.
34    #[error("deserialization error: {0}")]
35    DeserializationError(String),
36
37    /// Network error fetching trust root or other remote data.
38    #[error("network error: {0}")]
39    NetworkError(#[source] NetworkError),
40}
41
42/// Wire-format response from the registry trust-root endpoint.
43#[derive(Debug, serde::Deserialize)]
44struct TrustRootResponse {
45    log_origin: String,
46    log_public_key: String,
47    witnesses: Vec<TrustRootWitnessResponse>,
48    #[allow(dead_code)]
49    version: u32,
50}
51
52/// Wire-format witness entry from the trust-root response.
53#[derive(Debug, serde::Deserialize)]
54struct TrustRootWitnessResponse {
55    name: String,
56    public_key: String,
57    #[allow(dead_code)]
58    url: String,
59    /// Operator-independence attributes. Carried through to the trust root so the
60    /// diversity gate can evaluate the actual cosigners; absent ⇒ this witness
61    /// cannot contribute to independence.
62    #[serde(default)]
63    organization: Option<String>,
64    #[serde(default)]
65    jurisdiction: Option<String>,
66    #[serde(default)]
67    infrastructure: Option<String>,
68}
69
70/// Build the operator-independence attributes for a wire witness entry, if all
71/// three axes are present and valid. Returns `None` (not an error) when any axis
72/// is missing — an untagged witness simply cannot prove independence.
73fn operator_info_from_wire(w: &TrustRootWitnessResponse) -> Option<WitnessOperatorInfo> {
74    Some(WitnessOperatorInfo {
75        operator: OperatorId::new(w.name.clone()).ok()?,
76        organization: Organization::new(w.organization.clone()?).ok()?,
77        jurisdiction: Jurisdiction::new(w.jurisdiction.clone()?).ok()?,
78        infrastructure: Infrastructure::new(w.infrastructure.clone()?).ok()?,
79    })
80}
81
82/// Fetch the trust root from a registry URL.
83///
84/// Issues a GET to `{registry_url}/v1/trust-root`, parses the JSON
85/// response, and converts it into a domain [`TrustRoot`].
86///
87/// Args:
88/// * `registry_url` — Base URL of the auths registry.
89/// * `client` — Network client for HTTP communication.
90///
91/// Usage:
92/// ```ignore
93/// let trust_root = fetch_trust_root("https://registry.auths.dev", &http_client).await?;
94/// ```
95pub async fn fetch_trust_root(
96    registry_url: &str,
97    client: &impl RegistryClient,
98) -> Result<TrustRoot, TransparencyWorkflowError> {
99    let bytes = client
100        .fetch_registry_data(registry_url, "v1/trust-root")
101        .await
102        .map_err(TransparencyWorkflowError::NetworkError)?;
103
104    let resp: TrustRootResponse = serde_json::from_slice(&bytes)
105        .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
106
107    let log_public_key_bytes: [u8; 32] = hex::decode(&resp.log_public_key)
108        .map_err(|e| {
109            TransparencyWorkflowError::DeserializationError(format!(
110                "invalid hex in log_public_key: {e}"
111            ))
112        })?
113        .try_into()
114        .map_err(|_| {
115            TransparencyWorkflowError::DeserializationError(
116                "log_public_key must be exactly 32 bytes".into(),
117            )
118        })?;
119
120    let log_origin = LogOrigin::new(&resp.log_origin).map_err(|e| {
121        TransparencyWorkflowError::DeserializationError(format!("invalid log origin: {e}"))
122    })?;
123
124    let witnesses = resp
125        .witnesses
126        .into_iter()
127        .filter(|w| !w.public_key.is_empty())
128        .filter_map(|w| {
129            let pk_bytes: [u8; 32] = hex::decode(&w.public_key).ok()?.try_into().ok()?;
130            let public_key = Ed25519PublicKey::from_bytes(pk_bytes);
131            let witness_did = auths_verifier::CanonicalDid::from_public_key_did_key(
132                public_key.as_bytes(),
133                auths_crypto::CurveType::Ed25519,
134            );
135            let operator_info = operator_info_from_wire(&w);
136            Some(TrustRootWitness {
137                witness_did,
138                name: w.name,
139                public_key,
140                operator_info,
141            })
142        })
143        .collect();
144
145    Ok(TrustRoot {
146        log_public_key: Ed25519PublicKey::from_bytes(log_public_key_bytes),
147        log_origin,
148        witnesses,
149        signature_algorithm: Default::default(),
150        ecdsa_log_public_key_der: None,
151        // The registry trust-root wire format does not yet carry diversity
152        // thresholds; the pinned `witness_policy.json` is the enforcement source.
153        independence_policy: IndependencePolicy::unconstrained(),
154    })
155}
156
157/// Configuration for bundle verification.
158pub struct BundleVerifyConfig {
159    /// The offline bundle serialized as JSON.
160    pub bundle_json: String,
161    /// The trust root serialized as JSON.
162    pub trust_root_json: String,
163}
164
165/// Result of a consistency check between cached and new checkpoints.
166#[derive(Debug)]
167pub struct ConsistencyReport {
168    /// Tree size of the previously cached checkpoint (0 if none).
169    pub old_size: u64,
170    /// Tree size of the newly cached checkpoint.
171    pub new_size: u64,
172    /// Whether consistency was verified.
173    pub consistent: bool,
174}
175
176/// Verify an offline transparency bundle.
177///
178/// Deserializes the bundle and trust root from JSON, delegates to
179/// `auths_transparency::verify_bundle`, and returns the report.
180/// Returns `Err(VerificationFailed)` when the bundle does not pass
181/// all verification checks.
182///
183/// Args:
184/// * `config` — Bundle and trust root JSON strings.
185/// * `now` — Injected wall-clock time.
186///
187/// Usage:
188/// ```ignore
189/// let report = verify_artifact_bundle(&config, now)?;
190/// ```
191pub fn verify_artifact_bundle(
192    config: &BundleVerifyConfig,
193    now: DateTime<Utc>,
194) -> Result<BundleVerificationReport, TransparencyWorkflowError> {
195    let bundle: OfflineBundle = serde_json::from_str(&config.bundle_json)
196        .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
197    let trust_root: TrustRoot = serde_json::from_str(&config.trust_root_json)
198        .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
199
200    let report = auths_transparency::verify_bundle(&bundle, &trust_root, now);
201
202    if !report.is_valid() {
203        return Err(TransparencyWorkflowError::VerificationFailed(Box::new(
204            report,
205        )));
206    }
207
208    Ok(report)
209}
210
211/// Update the local checkpoint cache after verifying consistency.
212///
213/// Loads the cached checkpoint from disk, verifies that the new checkpoint
214/// is a consistent append-only extension of the cached one, and writes the
215/// new checkpoint to disk.
216///
217/// Args:
218/// * `store` — File-access port for the checkpoint cache file.
219/// * `cache_path` — Path to the cached checkpoint JSON file.
220/// * `new_checkpoint` — The newly received signed checkpoint.
221/// * `consistency_proof` — Proof that old tree is a prefix of the new tree.
222/// * `_trust_root` — Trust root for checkpoint signature verification (reserved for future use).
223/// * `_now` — Injected wall-clock time (reserved for future use).
224///
225/// Usage:
226/// ```ignore
227/// let report = update_checkpoint_cache(
228///     &store,
229///     &cache_path,
230///     &new_checkpoint,
231///     &consistency_proof,
232///     &trust_root,
233///     now,
234/// )?;
235/// ```
236pub fn update_checkpoint_cache(
237    store: &dyn ConfigStore,
238    cache_path: &Path,
239    new_checkpoint: &SignedCheckpoint,
240    consistency_proof: &ConsistencyProof,
241    _trust_root: &TrustRoot,
242    _now: DateTime<Utc>,
243) -> Result<ConsistencyReport, TransparencyWorkflowError> {
244    let old_checkpoint = load_cached_checkpoint(store, cache_path)?;
245
246    if let Some(ref old) = old_checkpoint {
247        auths_transparency::verify_consistency(
248            old.checkpoint.size,
249            new_checkpoint.checkpoint.size,
250            &consistency_proof.hashes,
251            &old.checkpoint.root,
252            &new_checkpoint.checkpoint.root,
253        )
254        .map_err(|e| TransparencyWorkflowError::CheckpointInconsistent(e.to_string()))?;
255    }
256
257    write_cached_checkpoint(store, cache_path, new_checkpoint)?;
258
259    let old_size = old_checkpoint.map(|c| c.checkpoint.size).unwrap_or(0);
260
261    Ok(ConsistencyReport {
262        old_size,
263        new_size: new_checkpoint.checkpoint.size,
264        consistent: true,
265    })
266}
267
268/// Read and parse the cached checkpoint via the store, `None` when absent.
269fn load_cached_checkpoint(
270    store: &dyn ConfigStore,
271    cache_path: &Path,
272) -> Result<Option<SignedCheckpoint>, TransparencyWorkflowError> {
273    match store.read(cache_path)? {
274        Some(json) => serde_json::from_str(&json)
275            .map(Some)
276            .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string())),
277        None => Ok(None),
278    }
279}
280
281/// Serialize and write the checkpoint via the store (parent dirs created by the store).
282fn write_cached_checkpoint(
283    store: &dyn ConfigStore,
284    cache_path: &Path,
285    checkpoint: &SignedCheckpoint,
286) -> Result<(), TransparencyWorkflowError> {
287    let json = serde_json::to_string_pretty(checkpoint)
288        .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
289    store.write(cache_path, &json)?;
290    Ok(())
291}
292
293/// Cache a checkpoint using trust-on-first-use (TOFU) semantics.
294///
295/// If no cached checkpoint exists, the new checkpoint is accepted and written.
296/// If a cached checkpoint exists with the same or smaller tree size and matching
297/// root, the cache is left unchanged. If the cached checkpoint has the same size
298/// but a different root, this is equivocation — returns a hard error.
299/// If a consistency proof is provided, full Merkle consistency is verified.
300///
301/// Args:
302/// * `store` — File-access port for the checkpoint cache file.
303/// * `cache_path` — Path to the cached checkpoint JSON file (`~/.auths/log_checkpoint.json`).
304/// * `new_checkpoint` — The checkpoint to cache.
305/// * `consistency_proof` — Optional consistency proof for cache-hit cases.
306///
307/// Usage:
308/// ```ignore
309/// try_cache_checkpoint(
310///     &store,
311///     &Path::new("~/.auths/log_checkpoint.json"),
312///     &bundle.signed_checkpoint,
313///     None,
314/// )?;
315/// ```
316pub fn try_cache_checkpoint(
317    store: &dyn ConfigStore,
318    cache_path: &Path,
319    new_checkpoint: &SignedCheckpoint,
320    consistency_proof: Option<&ConsistencyProof>,
321) -> Result<ConsistencyReport, TransparencyWorkflowError> {
322    let old_checkpoint = load_cached_checkpoint(store, cache_path)?;
323
324    if let Some(ref old) = old_checkpoint {
325        // Equivocation: same size, different root
326        if old.checkpoint.size == new_checkpoint.checkpoint.size
327            && old.checkpoint.root != new_checkpoint.checkpoint.root
328        {
329            return Err(TransparencyWorkflowError::CheckpointInconsistent(format!(
330                "equivocation detected: same tree size {} but different roots",
331                old.checkpoint.size
332            )));
333        }
334
335        // New checkpoint must not be smaller
336        if new_checkpoint.checkpoint.size < old.checkpoint.size {
337            return Err(TransparencyWorkflowError::CheckpointInconsistent(format!(
338                "new checkpoint size {} is smaller than cached size {}",
339                new_checkpoint.checkpoint.size, old.checkpoint.size
340            )));
341        }
342
343        // Same checkpoint — no update needed
344        if old.checkpoint.size == new_checkpoint.checkpoint.size {
345            return Ok(ConsistencyReport {
346                old_size: old.checkpoint.size,
347                new_size: new_checkpoint.checkpoint.size,
348                consistent: true,
349            });
350        }
351
352        // If we have a consistency proof, verify it
353        if let Some(proof) = consistency_proof {
354            auths_transparency::verify_consistency(
355                old.checkpoint.size,
356                new_checkpoint.checkpoint.size,
357                &proof.hashes,
358                &old.checkpoint.root,
359                &new_checkpoint.checkpoint.root,
360            )
361            .map_err(|e| TransparencyWorkflowError::CheckpointInconsistent(e.to_string()))?;
362        }
363    }
364
365    write_cached_checkpoint(store, cache_path, new_checkpoint)?;
366
367    let old_size = old_checkpoint.map(|c| c.checkpoint.size).unwrap_or(0);
368
369    Ok(ConsistencyReport {
370        old_size,
371        new_size: new_checkpoint.checkpoint.size,
372        consistent: true,
373    })
374}
375
376#[cfg(test)]
377#[allow(clippy::unwrap_used, clippy::expect_used, clippy::disallowed_methods)]
378mod tests {
379    use super::*;
380    use auths_transparency::checkpoint::{Checkpoint, SignedCheckpoint};
381
382    struct FsStore;
383
384    impl ConfigStore for FsStore {
385        fn read(&self, path: &Path) -> Result<Option<String>, ConfigStoreError> {
386            match std::fs::read_to_string(path) {
387                Ok(content) => Ok(Some(content)),
388                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
389                Err(e) => Err(ConfigStoreError::Read {
390                    path: path.to_path_buf(),
391                    source: e,
392                }),
393            }
394        }
395
396        fn write(&self, path: &Path, content: &str) -> Result<(), ConfigStoreError> {
397            if let Some(parent) = path.parent() {
398                std::fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write {
399                    path: path.to_path_buf(),
400                    source: e,
401                })?;
402            }
403            std::fs::write(path, content).map_err(|e| ConfigStoreError::Write {
404                path: path.to_path_buf(),
405                source: e,
406            })
407        }
408    }
409    use auths_transparency::entry::{Entry, EntryBody, EntryContent, EntryType};
410    use auths_transparency::proof::InclusionProof;
411    use auths_transparency::types::{LogOrigin, MerkleHash};
412    use auths_verifier::{CanonicalDid, Ed25519PublicKey, Ed25519Signature};
413
414    fn dummy_signed_checkpoint(size: u64, root: MerkleHash) -> SignedCheckpoint {
415        SignedCheckpoint {
416            checkpoint: Checkpoint {
417                origin: LogOrigin::new("test.dev/log").unwrap(),
418                size,
419                root,
420                timestamp: chrono::DateTime::parse_from_rfc3339("2025-06-15T00:00:00Z")
421                    .unwrap()
422                    .with_timezone(&Utc),
423            },
424            log_signature: Ed25519Signature::from_bytes([0u8; 64]),
425            log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
426            witnesses: vec![],
427            ecdsa_checkpoint_signature: None,
428            ecdsa_checkpoint_key: None,
429        }
430    }
431
432    fn dummy_trust_root() -> TrustRoot {
433        TrustRoot {
434            log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
435            log_origin: LogOrigin::new("test.dev/log").unwrap(),
436            witnesses: vec![],
437            signature_algorithm: Default::default(),
438            ecdsa_log_public_key_der: None,
439            independence_policy: IndependencePolicy::unconstrained(),
440        }
441    }
442
443    /// A registry client that returns a fixed trust-root document.
444    struct CannedRegistry {
445        trust_root_json: Vec<u8>,
446    }
447
448    impl RegistryClient for CannedRegistry {
449        async fn fetch_registry_data(
450            &self,
451            _registry_url: &str,
452            _path: &str,
453        ) -> Result<Vec<u8>, NetworkError> {
454            Ok(self.trust_root_json.clone())
455        }
456
457        async fn push_registry_data(
458            &self,
459            _registry_url: &str,
460            _path: &str,
461            _data: &[u8],
462        ) -> Result<(), NetworkError> {
463            Ok(())
464        }
465
466        async fn post_json(
467            &self,
468            _registry_url: &str,
469            _path: &str,
470            _json_body: &[u8],
471        ) -> Result<auths_core::ports::network::RegistryResponse, NetworkError> {
472            Ok(auths_core::ports::network::RegistryResponse {
473                status: 200,
474                body: vec![],
475                rate_limit: None,
476            })
477        }
478    }
479
480    #[tokio::test]
481    async fn fetch_trust_root_round_trips_independence_attributes() {
482        let log_pk = hex::encode([0u8; 32]);
483        let w_pk = hex::encode([7u8; 32]);
484        let json = format!(
485            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"}}]}}"#
486        );
487        let client = CannedRegistry {
488            trust_root_json: json.into_bytes(),
489        };
490
491        let trust_root = fetch_trust_root("https://registry.test", &client)
492            .await
493            .unwrap();
494
495        assert_eq!(trust_root.witnesses.len(), 1);
496        let attrs = trust_root.witnesses[0]
497            .operator_attributes()
498            .expect("operator attributes survive ingestion");
499        assert_eq!(attrs.organization.as_str(), "org-a");
500        assert_eq!(attrs.jurisdiction.as_str(), "US");
501        assert_eq!(attrs.infrastructure.as_str(), "aws/us-east-1");
502    }
503
504    #[test]
505    fn verify_artifact_bundle_invalid_bundle_json() {
506        let config = BundleVerifyConfig {
507            bundle_json: "not valid json".into(),
508            trust_root_json: "{}".into(),
509        };
510        let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
511            .unwrap()
512            .with_timezone(&Utc);
513        let err = verify_artifact_bundle(&config, now).unwrap_err();
514        assert!(matches!(
515            err,
516            TransparencyWorkflowError::DeserializationError(_)
517        ));
518    }
519
520    fn dummy_bundle() -> OfflineBundle {
521        let ts = chrono::DateTime::parse_from_rfc3339("2025-06-15T00:00:00Z")
522            .unwrap()
523            .with_timezone(&Utc);
524        let entry = Entry {
525            sequence: 0,
526            timestamp: ts,
527            content: EntryContent {
528                entry_type: EntryType::DeviceBind,
529                body: EntryBody::DeviceBind {
530                    device_did: CanonicalDid::new_unchecked("did:key:z6MkTest"),
531                    public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
532                },
533                actor_did: CanonicalDid::new_unchecked("did:key:z6MkTest"),
534            },
535            actor_sig: Ed25519Signature::empty(),
536        };
537        let root = MerkleHash::from_bytes([0u8; 32]);
538        OfflineBundle {
539            entry,
540            inclusion_proof: InclusionProof {
541                index: 0,
542                size: 1,
543                root,
544                hashes: vec![],
545            },
546            signed_checkpoint: dummy_signed_checkpoint(1, root),
547            delegation_chain: vec![],
548        }
549    }
550
551    #[test]
552    fn verify_artifact_bundle_invalid_trust_root_json() {
553        let bundle = dummy_bundle();
554        let bundle_json = serde_json::to_string(&bundle).unwrap();
555
556        let config = BundleVerifyConfig {
557            bundle_json,
558            trust_root_json: "not valid json".into(),
559        };
560        let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
561            .unwrap()
562            .with_timezone(&Utc);
563        let err = verify_artifact_bundle(&config, now).unwrap_err();
564        assert!(matches!(
565            err,
566            TransparencyWorkflowError::DeserializationError(_)
567        ));
568    }
569
570    #[test]
571    fn update_checkpoint_cache_writes_new_file() {
572        let dir = tempfile::tempdir().unwrap();
573        let cache_path = dir.path().join("checkpoint.json");
574
575        let root = MerkleHash::from_bytes([0xaa; 32]);
576        let new_cp = dummy_signed_checkpoint(10, root);
577        let proof = ConsistencyProof {
578            old_size: 0,
579            new_size: 10,
580            old_root: MerkleHash::from_bytes([0u8; 32]),
581            new_root: root,
582            hashes: vec![],
583        };
584        let trust_root = dummy_trust_root();
585        let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
586            .unwrap()
587            .with_timezone(&Utc);
588
589        let report =
590            update_checkpoint_cache(&FsStore, &cache_path, &new_cp, &proof, &trust_root, now)
591                .unwrap();
592
593        assert_eq!(report.old_size, 0);
594        assert_eq!(report.new_size, 10);
595        assert!(report.consistent);
596        assert!(cache_path.exists());
597
598        let written: SignedCheckpoint =
599            serde_json::from_str(&std::fs::read_to_string(&cache_path).unwrap()).unwrap();
600        assert_eq!(written.checkpoint.size, 10);
601    }
602
603    #[test]
604    fn update_checkpoint_cache_creates_parent_dirs() {
605        let dir = tempfile::tempdir().unwrap();
606        let cache_path = dir
607            .path()
608            .join("nested")
609            .join("dir")
610            .join("checkpoint.json");
611
612        let root = MerkleHash::from_bytes([0xbb; 32]);
613        let new_cp = dummy_signed_checkpoint(5, root);
614        let proof = ConsistencyProof {
615            old_size: 0,
616            new_size: 5,
617            old_root: MerkleHash::from_bytes([0u8; 32]),
618            new_root: root,
619            hashes: vec![],
620        };
621        let trust_root = dummy_trust_root();
622        let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
623            .unwrap()
624            .with_timezone(&Utc);
625
626        let report =
627            update_checkpoint_cache(&FsStore, &cache_path, &new_cp, &proof, &trust_root, now)
628                .unwrap();
629
630        assert!(report.consistent);
631        assert!(cache_path.exists());
632    }
633
634    #[test]
635    fn try_cache_checkpoint_tofu_writes_new_file() {
636        let dir = tempfile::tempdir().unwrap();
637        let cache_path = dir.path().join("log_checkpoint.json");
638
639        let root = MerkleHash::from_bytes([0xaa; 32]);
640        let cp = dummy_signed_checkpoint(10, root);
641
642        let report = try_cache_checkpoint(&FsStore, &cache_path, &cp, None).unwrap();
643        assert_eq!(report.old_size, 0);
644        assert_eq!(report.new_size, 10);
645        assert!(report.consistent);
646        assert!(cache_path.exists());
647    }
648
649    #[test]
650    fn try_cache_checkpoint_same_checkpoint_is_noop() {
651        let dir = tempfile::tempdir().unwrap();
652        let cache_path = dir.path().join("log_checkpoint.json");
653
654        let root = MerkleHash::from_bytes([0xaa; 32]);
655        let cp = dummy_signed_checkpoint(10, root);
656
657        try_cache_checkpoint(&FsStore, &cache_path, &cp, None).unwrap();
658        let report = try_cache_checkpoint(&FsStore, &cache_path, &cp, None).unwrap();
659        assert_eq!(report.old_size, 10);
660        assert_eq!(report.new_size, 10);
661        assert!(report.consistent);
662    }
663
664    #[test]
665    fn try_cache_checkpoint_detects_equivocation() {
666        let dir = tempfile::tempdir().unwrap();
667        let cache_path = dir.path().join("log_checkpoint.json");
668
669        let root1 = MerkleHash::from_bytes([0xaa; 32]);
670        let cp1 = dummy_signed_checkpoint(10, root1);
671        try_cache_checkpoint(&FsStore, &cache_path, &cp1, None).unwrap();
672
673        let root2 = MerkleHash::from_bytes([0xbb; 32]);
674        let cp2 = dummy_signed_checkpoint(10, root2);
675        let err = try_cache_checkpoint(&FsStore, &cache_path, &cp2, None).unwrap_err();
676        assert!(matches!(
677            err,
678            TransparencyWorkflowError::CheckpointInconsistent(_)
679        ));
680    }
681
682    #[test]
683    fn try_cache_checkpoint_rejects_smaller_size() {
684        let dir = tempfile::tempdir().unwrap();
685        let cache_path = dir.path().join("log_checkpoint.json");
686
687        let cp1 = dummy_signed_checkpoint(10, MerkleHash::from_bytes([0xaa; 32]));
688        try_cache_checkpoint(&FsStore, &cache_path, &cp1, None).unwrap();
689
690        let cp2 = dummy_signed_checkpoint(5, MerkleHash::from_bytes([0xbb; 32]));
691        let err = try_cache_checkpoint(&FsStore, &cache_path, &cp2, None).unwrap_err();
692        assert!(matches!(
693            err,
694            TransparencyWorkflowError::CheckpointInconsistent(_)
695        ));
696    }
697}