Skip to main content

auths_sdk/workflows/
transparency.rs

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