Skip to main content

cortex_ledger/external_sink/
mod.rs

1//! External anchor receipt primitive (ADR 0013 Mechanism C foundation).
2//!
3//! This module defines the **parser-only** v1 surface for the external
4//! anchor authority work: the typed [`ExternalSink`] selector, the
5//! [`ExternalReceipt`] struct, the canonical header
6//! [`EXTERNAL_RECEIPT_FORMAT_HEADER_V1`], and a fail-closed parser for
7//! single records and append-only receipt histories.
8//!
9//! **Out of scope (intentional):** live submission to Rekor or
10//! OpenTimestamps, Rekor `SignedEntryTimestamp` cryptographic verification,
11//! and OTS `.ots` binary-proof verification. Those wait on operator
12//! decisions enumerated in `docs/design/DESIGN_external_anchor_authority.md`
13//! §Residual risk — specifically the Rekor public key pin, the
14//! ECDSA-P-256 vs Ed25519 choice for `SignedEntryTimestamp`, and the OTS
15//! binary proof format. The parser here is sufficient to round-trip
16//! receipts that an operator obtained out-of-band and to refuse anything
17//! that is not a fully-specified v1 sidecar.
18//!
19//! The text format mirrors the existing position-bound `LedgerAnchor`
20//! format: a one-line `# cortex-external-anchor-receipt-format: 1` header
21//! followed by exactly one JSON body line. Receipt histories are the same
22//! record repeated back-to-back, with monotonicity on `anchor_event_count`
23//! enforced by [`parse_external_receipt_history`].
24//!
25//! ```text
26//! # cortex-external-anchor-receipt-format: 1
27//! {"sink":"rekor", "anchor_text_sha256":"<hex>", ...}
28//! # cortex-external-anchor-receipt-format: 1
29//! {"sink":"opentimestamps", "anchor_text_sha256":"<hex>", ...}
30//! ```
31
32pub mod ots;
33pub mod rekor;
34pub mod trusted_root;
35
36use std::fmt;
37use std::path::{Path, PathBuf};
38use std::time::Duration;
39
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42use thiserror::Error;
43
44use crate::anchor::{verify_anchor, AnchorParseError, AnchorVerifyError, LedgerAnchor};
45use crate::sha256::sha256_hex;
46
47pub use trusted_root::{
48    active_trusted_root, ActiveTrustedRoot, TransparencyLogInstance, TransparencyLogPublicKey,
49    TrustRootStalenessAnchor, TrustRootStalenessError, TrustedRoot, TrustedRootIoError,
50    TrustedRootKeyError, TrustedRootParseError, ValidityPeriod, CACHED_ROOT_STATUS,
51    DEFAULT_MAX_TRUST_ROOT_AGE, EMBEDDED_ROOT_STATUS, EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE,
52    REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT, TRUSTED_ROOT_CACHE_STALE_INVARIANT,
53    TRUSTED_ROOT_JSON, TRUSTED_ROOT_PARSE_INVARIANT, TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT,
54    TRUSTED_ROOT_STALE_INVARIANT,
55};
56
57/// Header required at the top of every v1 external anchor receipt record.
58pub const EXTERNAL_RECEIPT_FORMAT_HEADER_V1: &str = "# cortex-external-anchor-receipt-format: 1";
59
60/// SHA-256 hex digest length, in lowercase ASCII characters.
61const SHA256_HEX_LEN: usize = 64;
62
63/// BLAKE3 hex digest length used elsewhere in the ledger; reused for
64/// `anchor_chain_head_hash` validation since the receipt mirrors a
65/// [`crate::LedgerAnchor`] chain head.
66const BLAKE3_HEX_LEN: usize = 64;
67
68/// Typed external anchor sink selector.
69///
70/// `None` is the doctrine default: no external sink is configured and no
71/// receipt can be emitted. The two adapter-bound variants are currently
72/// parser-only — submitting and verifying with a live network adapter is
73/// deferred per the design doc.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum ExternalSink {
76    /// No external sink configured. Fail-closed default.
77    None,
78    /// Sigstore Rekor public transparency log.
79    Rekor,
80    /// OpenTimestamps Bitcoin-rooted calendar proof.
81    OpenTimestamps,
82}
83
84impl ExternalSink {
85    /// Stable wire token for the `sink` field of an [`ExternalReceipt`].
86    #[must_use]
87    pub const fn as_wire_str(self) -> &'static str {
88        match self {
89            Self::None => "none",
90            Self::Rekor => "rekor",
91            Self::OpenTimestamps => "opentimestamps",
92        }
93    }
94
95    /// Parse a wire-form sink token. Unknown tokens fail closed.
96    pub fn from_wire_str(value: &str) -> Result<Self, ExternalReceiptParseError> {
97        match value {
98            "none" => Ok(Self::None),
99            "rekor" => Ok(Self::Rekor),
100            "opentimestamps" => Ok(Self::OpenTimestamps),
101            other => Err(ExternalReceiptParseError::UnknownSink {
102                observed: other.to_string(),
103            }),
104        }
105    }
106}
107
108impl fmt::Display for ExternalSink {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        f.write_str(self.as_wire_str())
111    }
112}
113
114/// v1 external anchor receipt sidecar.
115///
116/// Mirrors the JSON shape in `DESIGN_external_anchor_authority.md` §Receipt
117/// shape. `receipt` carries the sink-specific payload and is intentionally
118/// left as `serde_json::Value`: Rekor and OpenTimestamps each pin their own
119/// shape inside this field, but the live adapters that consume them are
120/// deferred. Unknown fields inside `receipt` are not validated here.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct ExternalReceipt {
123    /// Sink that produced this receipt.
124    pub sink: ExternalSink,
125    /// Lowercase hex SHA-256 over the canonical [`crate::LedgerAnchor`] text
126    /// at the witnessed position. The verifier recomputes this against the
127    /// local ledger; mismatch is `external_anchor_receipts.anchor_text_hash.mismatch`.
128    pub anchor_text_sha256: String,
129    /// Event count at the witnessed position.
130    pub anchor_event_count: u64,
131    /// Lowercase hex BLAKE3 ledger event hash at `anchor_event_count`.
132    pub anchor_chain_head_hash: String,
133    /// RFC 3339 submission timestamp recorded by the operator client.
134    pub submitted_at: DateTime<Utc>,
135    /// Sink endpoint actually contacted (e.g. `https://rekor.sigstore.dev`).
136    pub sink_endpoint: String,
137    /// Sink-specific payload (Rekor entry envelope or OTS proof envelope).
138    pub receipt: serde_json::Value,
139}
140
141impl ExternalReceipt {
142    /// Render this receipt as a canonical v1 record: header line, then the
143    /// JSON body on one line, then a trailing newline.
144    pub fn to_record_text(&self) -> Result<String, ExternalReceiptParseError> {
145        let body = serde_json::to_string(self).map_err(|source| {
146            ExternalReceiptParseError::MalformedBody {
147                reason: format!("failed to serialize receipt body: {source}"),
148            }
149        })?;
150        Ok(format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n"))
151    }
152}
153
154impl ExternalSink {
155    /// Custom serde representation: emit `as_wire_str`, parse via
156    /// `from_wire_str`. This keeps the wire token stable even if the enum
157    /// reorders.
158    fn serialize_sink<S>(sink: &Self, ser: S) -> Result<S::Ok, S::Error>
159    where
160        S: serde::Serializer,
161    {
162        ser.serialize_str(sink.as_wire_str())
163    }
164
165    fn deserialize_sink<'de, D>(de: D) -> Result<Self, D::Error>
166    where
167        D: serde::Deserializer<'de>,
168    {
169        let raw = String::deserialize(de)?;
170        Self::from_wire_str(&raw).map_err(serde::de::Error::custom)
171    }
172}
173
174// The ExternalSink serde adapter has to flow through ExternalReceipt's serde
175// derive without losing the wire-token contract. We achieve that by wiring
176// the field via #[serde(serialize_with / deserialize_with)] below.
177impl Serialize for ExternalSink {
178    fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
179    where
180        S: serde::Serializer,
181    {
182        Self::serialize_sink(self, ser)
183    }
184}
185
186impl<'de> Deserialize<'de> for ExternalSink {
187    fn deserialize<D>(de: D) -> Result<Self, D::Error>
188    where
189        D: serde::Deserializer<'de>,
190    {
191        Self::deserialize_sink(de)
192    }
193}
194
195/// Parse a single v1 external anchor receipt record from text.
196///
197/// Expects exactly two structural lines: the [`EXTERNAL_RECEIPT_FORMAT_HEADER_V1`]
198/// header and a one-line JSON body. Trailing content, missing header, or
199/// missing body all fail closed.
200pub fn parse_external_receipt(input: &str) -> Result<ExternalReceipt, ExternalReceiptParseError> {
201    let mut lines = input.lines();
202    let Some(header) = lines.next() else {
203        return Err(ExternalReceiptParseError::MissingHeader);
204    };
205    if header != EXTERNAL_RECEIPT_FORMAT_HEADER_V1 {
206        return Err(ExternalReceiptParseError::UnknownFormatHeader {
207            observed: header.to_string(),
208        });
209    }
210
211    let Some(body) = lines.next() else {
212        return Err(ExternalReceiptParseError::MissingBody);
213    };
214    if body.trim() != body {
215        return Err(ExternalReceiptParseError::MalformedBody {
216            reason: "body line must not have leading or trailing whitespace".to_string(),
217        });
218    }
219    if body.is_empty() {
220        return Err(ExternalReceiptParseError::MalformedBody {
221            reason: "body line must not be empty".to_string(),
222        });
223    }
224    if lines.next().is_some() {
225        return Err(ExternalReceiptParseError::TrailingContent);
226    }
227
228    let receipt: ExternalReceipt =
229        serde_json::from_str(body).map_err(|source| ExternalReceiptParseError::MalformedBody {
230            reason: format!("invalid receipt JSON: {source}"),
231        })?;
232    validate_external_receipt_fields(&receipt)?;
233    Ok(receipt)
234}
235
236/// Parse a v1 external anchor receipt history from repeated canonical records.
237///
238/// Mirrors [`crate::anchor::parse_anchor_history`]: receipts are the
239/// `header\nbody\n` record repeated back-to-back. Monotonicity on
240/// `anchor_event_count` is enforced — a later record may equal but must not
241/// be less than the previous record's count. An empty history is a parse
242/// error.
243pub fn parse_external_receipt_history(
244    input: &str,
245) -> Result<Vec<ExternalReceipt>, ExternalReceiptParseError> {
246    let mut lines = input.lines();
247    let mut receipts = Vec::new();
248
249    loop {
250        let Some(header) = lines.next() else {
251            break;
252        };
253        let Some(body) = lines.next() else {
254            return Err(ExternalReceiptParseError::MissingBody);
255        };
256        receipts.push(parse_external_receipt(&format!("{header}\n{body}\n"))?);
257    }
258
259    if receipts.is_empty() {
260        return Err(ExternalReceiptParseError::MissingHeader);
261    }
262
263    let mut previous_event_count: Option<u64> = None;
264    for (index, receipt) in receipts.iter().enumerate() {
265        if let Some(previous) = previous_event_count {
266            if receipt.anchor_event_count < previous {
267                return Err(ExternalReceiptParseError::NonMonotonic {
268                    receipt_index: index + 1,
269                    previous_event_count: previous,
270                    event_count: receipt.anchor_event_count,
271                });
272            }
273        }
274        previous_event_count = Some(receipt.anchor_event_count);
275    }
276
277    Ok(receipts)
278}
279
280fn validate_external_receipt_fields(
281    receipt: &ExternalReceipt,
282) -> Result<(), ExternalReceiptParseError> {
283    if receipt.sink == ExternalSink::None {
284        return Err(ExternalReceiptParseError::UnknownSink {
285            observed: ExternalSink::None.as_wire_str().to_string(),
286        });
287    }
288    validate_lower_hex(
289        &receipt.anchor_text_sha256,
290        SHA256_HEX_LEN,
291        "anchor_text_sha256",
292    )?;
293    validate_lower_hex(
294        &receipt.anchor_chain_head_hash,
295        BLAKE3_HEX_LEN,
296        "anchor_chain_head_hash",
297    )?;
298    if receipt.sink_endpoint.is_empty() {
299        return Err(ExternalReceiptParseError::MalformedBody {
300            reason: "sink_endpoint must not be empty".to_string(),
301        });
302    }
303    if receipt.anchor_event_count == 0 {
304        return Err(ExternalReceiptParseError::MalformedBody {
305            reason: "anchor_event_count must be positive".to_string(),
306        });
307    }
308    if !receipt.receipt.is_object() {
309        return Err(ExternalReceiptParseError::MalformedBody {
310            reason: "receipt body field must be a JSON object".to_string(),
311        });
312    }
313    Ok(())
314}
315
316fn validate_lower_hex(
317    value: &str,
318    expected_len: usize,
319    field: &'static str,
320) -> Result<(), ExternalReceiptParseError> {
321    if value.len() != expected_len
322        || !value
323            .bytes()
324            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
325    {
326        return Err(ExternalReceiptParseError::InvalidHexField {
327            field,
328            value: value.to_string(),
329            expected_len,
330        });
331    }
332    Ok(())
333}
334
335/// Parse errors for the v1 external anchor receipt text format.
336#[derive(Debug, Clone, PartialEq, Eq, Error)]
337pub enum ExternalReceiptParseError {
338    /// No first line was present.
339    #[error("missing external anchor receipt format header")]
340    MissingHeader,
341    /// Header was present but was not the supported v1 header.
342    #[error("unknown external anchor receipt format header: {observed}")]
343    UnknownFormatHeader {
344        /// Header line found in the input.
345        observed: String,
346    },
347    /// Header was present but the body line was absent.
348    #[error("missing external anchor receipt body")]
349    MissingBody,
350    /// Body did not parse as a valid v1 receipt JSON document.
351    #[error("malformed external anchor receipt body: {reason}")]
352    MalformedBody {
353        /// Human-readable parse failure.
354        reason: String,
355    },
356    /// Extra non-format structure followed the body line.
357    #[error("external anchor receipt has trailing content")]
358    TrailingContent,
359    /// Receipt referenced an unknown sink token.
360    #[error("unknown external anchor receipt sink: {observed}")]
361    UnknownSink {
362        /// Sink token as parsed from the input.
363        observed: String,
364    },
365    /// A hex field did not have the expected length or lowercase ASCII shape.
366    #[error("invalid external anchor receipt {field}: expected {expected_len} lowercase hex chars, got `{value}`")]
367    InvalidHexField {
368        /// Field name in the receipt envelope.
369        field: &'static str,
370        /// Field value as parsed from the input.
371        value: String,
372        /// Expected number of hex characters.
373        expected_len: usize,
374    },
375    /// Receipt history moved backwards in logical event position.
376    #[error(
377        "external anchor receipt history is non-monotonic at record {receipt_index}: event_count {event_count} follows {previous_event_count}"
378    )]
379    NonMonotonic {
380        /// 1-based receipt record index.
381        receipt_index: usize,
382        /// Previous receipt event count.
383        previous_event_count: u64,
384        /// Current receipt event count.
385        event_count: u64,
386    },
387}
388
389/// I/O errors when reading a receipt history file off disk.
390#[derive(Debug, Error)]
391pub enum ExternalReceiptHistoryIoError {
392    /// The history file could not be opened or read.
393    #[error("failed to read external anchor receipt history {path:?}: {source}")]
394    ReadHistory {
395        /// Receipt history path that was being read.
396        path: PathBuf,
397        /// I/O failure.
398        source: std::io::Error,
399    },
400    /// The receipt history text did not parse as repeated v1 receipt records.
401    #[error("invalid external anchor receipt history {path:?}: {source}")]
402    Parse {
403        /// Receipt history path that was being parsed.
404        path: PathBuf,
405        /// Parse failure.
406        source: ExternalReceiptParseError,
407    },
408}
409
410/// Read a receipt history file and return the parsed monotonic record list.
411pub fn read_external_receipt_history(
412    path: impl Into<PathBuf>,
413) -> Result<Vec<ExternalReceipt>, ExternalReceiptHistoryIoError> {
414    let path = path.into();
415    let text = std::fs::read_to_string(&path).map_err(|source| {
416        ExternalReceiptHistoryIoError::ReadHistory {
417            path: path.clone(),
418            source,
419        }
420    })?;
421    parse_external_receipt_history(&text)
422        .map_err(|source| ExternalReceiptHistoryIoError::Parse { path, source })
423}
424
425/// Stable invariant identifier emitted when the recomputed
426/// `anchor_text_sha256` for an external receipt does not match the local
427/// ledger. Surfaced both in CLI diagnostics and in any future export
428/// artifact so wrappers can grep for the exact token.
429pub const ANCHOR_TEXT_HASH_MISMATCH_INVARIANT: &str =
430    "external_anchor_receipts.anchor_text_hash.mismatch";
431
432/// Stable status emitted by [`verify_external_receipts`] today: the parser
433/// has confirmed the receipt envelope is well-formed and the local
434/// position-bound anchor it claims matches the ledger, but the live Rekor
435/// signature / OTS proof verification adapters are deferred. Operators and
436/// dashboards key off this token to distinguish "parsed only" from a
437/// future "fully cryptographically verified" status.
438pub const PARSED_ONLY_VERIFICATION_STATUS: &str = "parsed_only_signature_verification_pending";
439
440/// Successful external-receipt verification summary.
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct ExternalReceiptVerification {
443    /// Ledger path that was verified against.
444    pub path: PathBuf,
445    /// Receipt history path that was verified.
446    pub receipts_path: PathBuf,
447    /// Number of rows observed in the current ledger after scanning the
448    /// last receipt position.
449    pub db_count: u64,
450    /// Number of receipts parsed and parser-verified.
451    pub receipts_verified: usize,
452    /// The latest receipt that was verified — has the largest
453    /// `anchor_event_count`.
454    pub latest_receipt: ExternalReceipt,
455    /// Stable status string. See [`PARSED_ONLY_VERIFICATION_STATUS`].
456    pub status: &'static str,
457    /// Provenance token for the trusted root that was in force during
458    /// verification. Either [`EMBEDDED_ROOT_STATUS`] when no operator
459    /// cache was supplied or could be loaded, or [`CACHED_ROOT_STATUS`]
460    /// when a refreshed cache was active.
461    pub trust_root_status: &'static str,
462    /// Activation timestamp of the trusted root that was in force.
463    pub trust_root_signed_at: Option<DateTime<Utc>>,
464}
465
466/// Verification errors for an external receipt history checked against a
467/// JSONL ledger.
468#[derive(Debug, Error)]
469pub enum ExternalReceiptVerifyError {
470    /// The receipt history file could not be read.
471    #[error("failed to read external anchor receipt history {path:?}: {source}")]
472    ReadHistory {
473        /// Receipt history path that was being read.
474        path: PathBuf,
475        /// I/O failure.
476        source: std::io::Error,
477    },
478    /// The receipt history did not parse as repeated v1 records.
479    #[error("invalid external anchor receipt history {path:?}: {source}")]
480    Parse {
481        /// Receipt history path that was being parsed.
482        path: PathBuf,
483        /// Parse failure.
484        source: ExternalReceiptParseError,
485    },
486    /// One receipt referenced a chain-head hash that does not validate as a
487    /// canonical [`LedgerAnchor`].
488    #[error(
489        "external anchor receipt {receipt_index} in {path:?} carries a malformed anchor: {source}"
490    )]
491    Anchor {
492        /// Receipt history path that was being verified.
493        path: PathBuf,
494        /// 1-based receipt record index.
495        receipt_index: usize,
496        /// Anchor field validation failure.
497        source: AnchorParseError,
498    },
499    /// The receipt's position-bound anchor failed to verify against the local ledger.
500    #[error(
501        "external anchor receipt {receipt_index} in {path:?} failed local anchor verification: {source}"
502    )]
503    AnchorVerify {
504        /// Receipt history path that was being verified.
505        path: PathBuf,
506        /// 1-based receipt record index.
507        receipt_index: usize,
508        /// Local-ledger anchor verification failure.
509        source: Box<AnchorVerifyError>,
510    },
511    /// The recomputed `anchor_text_sha256` did not match the receipt-declared value.
512    #[error(
513        "{invariant}: external anchor receipt {receipt_index} in {path:?} declared anchor_text_sha256 {declared} but local ledger recomputes {observed}"
514    )]
515    AnchorTextHashMismatch {
516        /// Stable invariant token, equal to [`ANCHOR_TEXT_HASH_MISMATCH_INVARIANT`].
517        invariant: &'static str,
518        /// Receipt history path that was being verified.
519        path: PathBuf,
520        /// 1-based receipt record index.
521        receipt_index: usize,
522        /// Anchor-text hash declared by the receipt.
523        declared: String,
524        /// Anchor-text hash recomputed from the local ledger.
525        observed: String,
526    },
527    /// The cached or embedded `trusted_root.json` is older than the
528    /// operator-configured staleness window. Emits the stable
529    /// [`TRUSTED_ROOT_STALE_INVARIANT`].
530    #[error(
531        "{invariant}: trusted_root.json (status={trust_root_status}) signed_at {signed_at:?} is stale beyond max_age {max_age:?} at now {now}"
532    )]
533    TrustedRootStale {
534        /// Stable invariant token, equal to [`TRUSTED_ROOT_STALE_INVARIANT`].
535        invariant: &'static str,
536        /// Provenance token: [`EMBEDDED_ROOT_STATUS`] or [`CACHED_ROOT_STATUS`].
537        trust_root_status: &'static str,
538        /// Cache path that was inspected, if any.
539        cache_path: Option<PathBuf>,
540        /// Latest `validFor.start` observed in the active trust root.
541        signed_at: Option<DateTime<Utc>>,
542        /// Wall-clock used to compute staleness.
543        now: DateTime<Utc>,
544        /// Maximum age permitted by the operator policy.
545        max_age: Duration,
546    },
547    /// The cached `trusted_root.json` could not be loaded — I/O or parse
548    /// failure on the operator-supplied path. The embedded fallback path
549    /// is selected by passing `None` for the cache; this variant only
550    /// fires when a cache path was supplied AND the file existed.
551    #[error("{invariant}: failed to load trusted_root.json from {path:?}: {source}")]
552    TrustedRootIo {
553        /// Stable invariant token, equal to [`TRUSTED_ROOT_PARSE_INVARIANT`].
554        invariant: &'static str,
555        /// Cache path that was being inspected.
556        path: PathBuf,
557        /// Underlying I/O / parse failure.
558        source: Box<TrustedRootIoError>,
559    },
560}
561
562/// Verify a v1 external anchor receipt history against the local JSONL
563/// ledger using the **parser-only** rules, with the embedded trust root
564/// and `Utc::now()` for the staleness gate.
565///
566/// For each receipt this enforces:
567///
568/// 1. The receipt envelope parses and the receipt is well-formed.
569/// 2. The receipt's `(anchor_event_count, anchor_chain_head_hash)` pair
570///    parses as a canonical [`LedgerAnchor`] and verifies against the
571///    local ledger via [`verify_anchor`].
572/// 3. SHA-256 over the canonical anchor text at that position matches
573///    `anchor_text_sha256`. Mismatch returns
574///    [`ExternalReceiptVerifyError::AnchorTextHashMismatch`] with the
575///    stable [`ANCHOR_TEXT_HASH_MISMATCH_INVARIANT`] token.
576/// 4. Monotonicity across the receipt history is enforced by
577///    [`parse_external_receipt_history`] before any per-receipt check
578///    runs.
579/// 5. The active trusted root (embedded snapshot here, see
580///    [`verify_external_receipts_with_options`] for cached refresh) is
581///    not stale beyond [`DEFAULT_MAX_TRUST_ROOT_AGE`]. The staleness
582///    gate fails closed with [`ExternalReceiptVerifyError::TrustedRootStale`].
583///
584/// **Out of scope today:** Rekor `SignedEntryTimestamp` verification and
585/// OpenTimestamps `.ots` proof verification. A successful return from this
586/// function carries `status = `[`PARSED_ONLY_VERIFICATION_STATUS`] to make
587/// the deferred-authority surface explicit to operators.
588pub fn verify_external_receipts(
589    ledger_path: impl AsRef<Path>,
590    receipts_path: impl Into<PathBuf>,
591) -> Result<ExternalReceiptVerification, ExternalReceiptVerifyError> {
592    verify_external_receipts_with_options(
593        ledger_path,
594        receipts_path,
595        None,
596        Utc::now(),
597        DEFAULT_MAX_TRUST_ROOT_AGE,
598    )
599}
600
601/// Same as [`verify_external_receipts`] but with the trust-root cache
602/// path, wall clock, and staleness window injected by the caller.
603///
604/// `trust_root_cache` is `Some(path)` when the operator has refreshed
605/// `trusted_root.json` via `cortex audit anchor refresh-trust`. When the
606/// file is missing or unparseable, the embedded snapshot is the
607/// fail-closed floor. `now` and `max_age` follow the ADR 0041 pattern of
608/// caller-injected freshness inputs (no system clock buried in the trust
609/// path) so test fixtures can pin both axes.
610pub fn verify_external_receipts_with_options(
611    ledger_path: impl AsRef<Path>,
612    receipts_path: impl Into<PathBuf>,
613    trust_root_cache: Option<&Path>,
614    now: DateTime<Utc>,
615    max_age: Duration,
616) -> Result<ExternalReceiptVerification, ExternalReceiptVerifyError> {
617    let receipts_path = receipts_path.into();
618    let active = match active_trusted_root(trust_root_cache) {
619        Ok(active) => active,
620        Err(source) => {
621            let path = trust_root_cache
622                .map(Path::to_path_buf)
623                .unwrap_or_else(|| PathBuf::from("<embedded>"));
624            return Err(ExternalReceiptVerifyError::TrustedRootIo {
625                invariant: TRUSTED_ROOT_PARSE_INVARIANT,
626                path,
627                source: Box::new(source),
628            });
629        }
630    };
631    // ADR 0013 staleness policy (council Decision #1; 2026-05-15
632    // portfolio-extension fix on the 2026-05-12 footnote):
633    //
634    //   embedded + stale  -> warn-only, allow operation (operator may not
635    //                        have refreshed yet; refusing here would brick
636    //                        every first-time install)
637    //   cached   + stale  -> fail closed (operator HAS refreshed once, the
638    //                        refresh cadence has lapsed beyond max_age, so
639    //                        external anchor authority cannot stand)
640    //
641    // Anchor field-choice (Bug J + portfolio extension): cached-path
642    // staleness is anchored to the cache file's mtime, NOT the Sigstore
643    // tlog signing-key activation date inside the JSON (the latter
644    // rotates rarely so would make every release immediately stale).
645    // The warn-only branch returns the active status verbatim so callers
646    // still see `embedded_snapshot` and can surface the warning.
647    let trust_root_status = active.status;
648    let trust_root_signed_at = active.root.metadata_signed_at();
649    if active.status == CACHED_ROOT_STATUS {
650        let cache_path = active
651            .cache_path
652            .as_deref()
653            .expect("CACHED_ROOT_STATUS implies a cache path was inspected");
654        let anchor = TrustRootStalenessAnchor::cache_file_mtime(cache_path);
655        match active.root.is_stale_at(now, max_age, anchor) {
656            Ok(true) => {
657                return Err(ExternalReceiptVerifyError::TrustedRootStale {
658                    invariant: TRUSTED_ROOT_CACHE_STALE_INVARIANT,
659                    trust_root_status,
660                    cache_path: active.cache_path,
661                    signed_at: trust_root_signed_at,
662                    now,
663                    max_age,
664                });
665            }
666            Ok(false) => {}
667            Err(TrustRootStalenessError::CacheFutureDated { .. }) => {
668                // Prior F3 closure: future-dated cache mtime is a
669                // dedicated bypass guard, not a parse / I/O failure.
670                // Surface it as the `cache_future_dated` invariant so
671                // operators and dashboards can pivot on a stable token.
672                return Err(ExternalReceiptVerifyError::TrustedRootStale {
673                    invariant: trusted_root::STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED,
674                    trust_root_status,
675                    cache_path: active.cache_path,
676                    signed_at: trust_root_signed_at,
677                    now,
678                    max_age,
679                });
680            }
681            Err(source) => {
682                return Err(ExternalReceiptVerifyError::TrustedRootIo {
683                    invariant: TRUSTED_ROOT_PARSE_INVARIANT,
684                    path: cache_path.to_path_buf(),
685                    source: Box::new(TrustedRootIoError::Read {
686                        path: cache_path.to_path_buf(),
687                        source: std::io::Error::other(source.to_string()),
688                    }),
689                });
690            }
691        }
692    }
693
694    let text = std::fs::read_to_string(&receipts_path).map_err(|source| {
695        ExternalReceiptVerifyError::ReadHistory {
696            path: receipts_path.clone(),
697            source,
698        }
699    })?;
700    let receipts = parse_external_receipt_history(&text).map_err(|source| {
701        ExternalReceiptVerifyError::Parse {
702            path: receipts_path.clone(),
703            source,
704        }
705    })?;
706
707    // Note: `parse_external_receipt_history` already verified monotonicity
708    // and that there is at least one receipt; this expect is structural.
709    let mut latest_db_count = 0u64;
710    let mut latest_receipt: Option<ExternalReceipt> = None;
711
712    for (index, receipt) in receipts.iter().enumerate() {
713        let record_index = index + 1;
714        // Build a canonical anchor for the witnessed position. We do not
715        // accept the receipt's chain head verbatim — we ask the local
716        // ledger to confirm the position-hash pair, just like
717        // `verify_anchor` does.
718        let anchor = LedgerAnchor::new(
719            receipt.submitted_at,
720            receipt.anchor_event_count,
721            receipt.anchor_chain_head_hash.clone(),
722        )
723        .map_err(|source| ExternalReceiptVerifyError::Anchor {
724            path: receipts_path.clone(),
725            receipt_index: record_index,
726            source,
727        })?;
728        let verified = verify_anchor(ledger_path.as_ref(), &anchor).map_err(|source| {
729            ExternalReceiptVerifyError::AnchorVerify {
730                path: receipts_path.clone(),
731                receipt_index: record_index,
732                source: Box::new(source),
733            }
734        })?;
735        let recomputed = sha256_hex(anchor.to_anchor_text().as_bytes());
736        if recomputed != receipt.anchor_text_sha256 {
737            return Err(ExternalReceiptVerifyError::AnchorTextHashMismatch {
738                invariant: ANCHOR_TEXT_HASH_MISMATCH_INVARIANT,
739                path: receipts_path,
740                receipt_index: record_index,
741                declared: receipt.anchor_text_sha256.clone(),
742                observed: recomputed,
743            });
744        }
745        latest_db_count = verified.db_count;
746        latest_receipt = Some(receipt.clone());
747    }
748
749    let latest_receipt = latest_receipt.expect("parse_external_receipt_history returns non-empty");
750    Ok(ExternalReceiptVerification {
751        path: ledger_path.as_ref().to_path_buf(),
752        receipts_path,
753        db_count: latest_db_count,
754        receipts_verified: receipts.len(),
755        latest_receipt,
756        status: PARSED_ONLY_VERIFICATION_STATUS,
757        trust_root_status,
758        trust_root_signed_at,
759    })
760}
761
762/// Helper for callers (CLI, drill scripts, etc.) that need to construct a
763/// stable, canonical `anchor_text_sha256` value for a given
764/// [`LedgerAnchor`]. Equivalent to `sha256_hex(anchor.to_anchor_text())`.
765#[must_use]
766pub fn anchor_text_sha256(anchor: &LedgerAnchor) -> String {
767    sha256_hex(anchor.to_anchor_text().as_bytes())
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use chrono::TimeZone;
774
775    fn sample_receipt(event_count: u64, sink: ExternalSink) -> ExternalReceipt {
776        ExternalReceipt {
777            sink,
778            anchor_text_sha256: "0".repeat(SHA256_HEX_LEN),
779            anchor_event_count: event_count,
780            anchor_chain_head_hash: "a".repeat(BLAKE3_HEX_LEN),
781            submitted_at: Utc.with_ymd_and_hms(2026, 5, 12, 18, 0, 0).unwrap(),
782            sink_endpoint: "https://rekor.sigstore.dev".to_string(),
783            receipt: serde_json::json!({"logIndex": 1, "uuid": "abc"}),
784        }
785    }
786
787    #[test]
788    fn parses_clean_record_round_trip() {
789        let receipt = sample_receipt(7, ExternalSink::Rekor);
790        let text = receipt.to_record_text().unwrap();
791        assert!(text.starts_with(EXTERNAL_RECEIPT_FORMAT_HEADER_V1));
792        let parsed = parse_external_receipt(&text).unwrap();
793        assert_eq!(parsed, receipt);
794    }
795
796    #[test]
797    fn rejects_unknown_sink_token() {
798        let body = serde_json::json!({
799            "sink": "unknown-sink",
800            "anchor_text_sha256": "0".repeat(SHA256_HEX_LEN),
801            "anchor_event_count": 1,
802            "anchor_chain_head_hash": "a".repeat(BLAKE3_HEX_LEN),
803            "submitted_at": "2026-05-12T18:00:00Z",
804            "sink_endpoint": "https://example.invalid",
805            "receipt": {},
806        });
807        let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
808        let err = parse_external_receipt(&text).unwrap_err();
809        match err {
810            ExternalReceiptParseError::MalformedBody { reason } => {
811                assert!(
812                    reason.contains("unknown external anchor receipt sink"),
813                    "{reason}"
814                );
815            }
816            other => panic!("expected MalformedBody, got {other:?}"),
817        }
818    }
819
820    #[test]
821    fn rejects_missing_header() {
822        let body = serde_json::to_string(&sample_receipt(1, ExternalSink::Rekor)).unwrap();
823        let err = parse_external_receipt(&body).unwrap_err();
824        assert!(matches!(
825            err,
826            ExternalReceiptParseError::UnknownFormatHeader { .. }
827        ));
828    }
829
830    #[test]
831    fn rejects_unknown_format_header() {
832        let body = serde_json::to_string(&sample_receipt(1, ExternalSink::Rekor)).unwrap();
833        let text = format!("# cortex-external-anchor-receipt-format: 2\n{body}\n");
834        let err = parse_external_receipt(&text).unwrap_err();
835        assert!(matches!(
836            err,
837            ExternalReceiptParseError::UnknownFormatHeader { .. }
838        ));
839    }
840
841    #[test]
842    fn rejects_missing_required_field() {
843        let body = serde_json::json!({
844            "sink": "rekor",
845            "anchor_event_count": 1,
846            "anchor_chain_head_hash": "a".repeat(BLAKE3_HEX_LEN),
847            "submitted_at": "2026-05-12T18:00:00Z",
848            "sink_endpoint": "https://example.invalid",
849            "receipt": {},
850        });
851        let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
852        let err = parse_external_receipt(&text).unwrap_err();
853        match err {
854            ExternalReceiptParseError::MalformedBody { reason } => {
855                assert!(reason.contains("anchor_text_sha256"), "{reason}");
856            }
857            other => panic!("expected MalformedBody, got {other:?}"),
858        }
859    }
860
861    #[test]
862    fn rejects_invalid_hex_lengths() {
863        let mut receipt = sample_receipt(1, ExternalSink::Rekor);
864        receipt.anchor_text_sha256 = "abc".to_string();
865        let body = serde_json::to_string(&receipt).unwrap();
866        let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
867        let err = parse_external_receipt(&text).unwrap_err();
868        assert!(matches!(
869            err,
870            ExternalReceiptParseError::InvalidHexField {
871                field: "anchor_text_sha256",
872                ..
873            }
874        ));
875    }
876
877    #[test]
878    fn rejects_trailing_content() {
879        let body = serde_json::to_string(&sample_receipt(1, ExternalSink::Rekor)).unwrap();
880        let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\nextra\n");
881        let err = parse_external_receipt(&text).unwrap_err();
882        assert_eq!(err, ExternalReceiptParseError::TrailingContent);
883    }
884
885    #[test]
886    fn rejects_none_sink_in_payload() {
887        let body = serde_json::json!({
888            "sink": "none",
889            "anchor_text_sha256": "0".repeat(SHA256_HEX_LEN),
890            "anchor_event_count": 1,
891            "anchor_chain_head_hash": "a".repeat(BLAKE3_HEX_LEN),
892            "submitted_at": "2026-05-12T18:00:00Z",
893            "sink_endpoint": "https://example.invalid",
894            "receipt": {},
895        });
896        let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
897        let err = parse_external_receipt(&text).unwrap_err();
898        assert!(matches!(err, ExternalReceiptParseError::UnknownSink { .. }));
899    }
900
901    #[test]
902    fn parses_history_round_trip_with_monotonic_records() {
903        let r1 = sample_receipt(1, ExternalSink::Rekor);
904        let r2 = sample_receipt(3, ExternalSink::OpenTimestamps);
905        let text = format!(
906            "{}{}",
907            r1.to_record_text().unwrap(),
908            r2.to_record_text().unwrap()
909        );
910        let parsed = parse_external_receipt_history(&text).unwrap();
911        assert_eq!(parsed, vec![r1, r2]);
912    }
913
914    #[test]
915    fn history_rejects_non_monotonic_event_count() {
916        let r1 = sample_receipt(5, ExternalSink::Rekor);
917        let r2 = sample_receipt(2, ExternalSink::Rekor);
918        let text = format!(
919            "{}{}",
920            r1.to_record_text().unwrap(),
921            r2.to_record_text().unwrap()
922        );
923        let err = parse_external_receipt_history(&text).unwrap_err();
924        assert!(matches!(
925            err,
926            ExternalReceiptParseError::NonMonotonic {
927                receipt_index: 2,
928                previous_event_count: 5,
929                event_count: 2,
930            }
931        ));
932    }
933
934    #[test]
935    fn history_rejects_truncated_record() {
936        let err = parse_external_receipt_history(EXTERNAL_RECEIPT_FORMAT_HEADER_V1).unwrap_err();
937        assert_eq!(err, ExternalReceiptParseError::MissingBody);
938    }
939
940    #[test]
941    fn history_rejects_empty_input() {
942        let err = parse_external_receipt_history("").unwrap_err();
943        assert_eq!(err, ExternalReceiptParseError::MissingHeader);
944    }
945
946    #[test]
947    fn sink_wire_tokens_are_stable() {
948        assert_eq!(ExternalSink::None.as_wire_str(), "none");
949        assert_eq!(ExternalSink::Rekor.as_wire_str(), "rekor");
950        assert_eq!(ExternalSink::OpenTimestamps.as_wire_str(), "opentimestamps");
951    }
952
953    #[test]
954    fn sink_from_wire_str_rejects_garbage() {
955        let err = ExternalSink::from_wire_str("garbage").unwrap_err();
956        assert!(matches!(err, ExternalReceiptParseError::UnknownSink { .. }));
957    }
958
959    // ---------- Slice 3: verify_external_receipts ----------
960
961    use cortex_core::{Event, EventId, EventSource, EventType, SCHEMA_VERSION};
962    use tempfile::tempdir;
963
964    use crate::JsonlLog;
965
966    fn ledger_event(seq: u64) -> Event {
967        Event {
968            id: EventId::new(),
969            schema_version: SCHEMA_VERSION,
970            observed_at: Utc.with_ymd_and_hms(2026, 5, 12, 18, 0, 0).unwrap(),
971            recorded_at: Utc.with_ymd_and_hms(2026, 5, 12, 18, 0, 1).unwrap(),
972            source: EventSource::User,
973            event_type: EventType::UserMessage,
974            trace_id: None,
975            session_id: Some("ext-receipt".into()),
976            domain_tags: vec![],
977            payload: serde_json::json!({"seq": seq}),
978            payload_hash: String::new(),
979            prev_event_hash: None,
980            event_hash: String::new(),
981        }
982    }
983
984    fn build_ledger(count: u64) -> (tempfile::TempDir, std::path::PathBuf, Vec<String>) {
985        let dir = tempdir().unwrap();
986        let path = dir.path().join("events.jsonl");
987        let mut log = JsonlLog::open(&path).unwrap();
988        let mut heads = Vec::new();
989        let policy = crate::append_policy_decision_test_allow();
990        for seq in 0..count {
991            heads.push(log.append(ledger_event(seq), &policy).unwrap());
992        }
993        (dir, path, heads)
994    }
995
996    fn make_canonical_receipt(
997        timestamp: DateTime<Utc>,
998        event_count: u64,
999        chain_head: &str,
1000        sink: ExternalSink,
1001    ) -> ExternalReceipt {
1002        let anchor = LedgerAnchor::new(timestamp, event_count, chain_head.to_string()).unwrap();
1003        ExternalReceipt {
1004            sink,
1005            anchor_text_sha256: anchor_text_sha256(&anchor),
1006            anchor_event_count: event_count,
1007            anchor_chain_head_hash: chain_head.to_string(),
1008            submitted_at: timestamp,
1009            sink_endpoint: "https://rekor.sigstore.dev".to_string(),
1010            receipt: serde_json::json!({"logIndex": event_count, "uuid": "fixture"}),
1011        }
1012    }
1013
1014    fn write_receipt_history(path: &Path, receipts: &[ExternalReceipt]) {
1015        let mut text = String::new();
1016        for receipt in receipts {
1017            text.push_str(&receipt.to_record_text().unwrap());
1018        }
1019        std::fs::write(path, text).unwrap();
1020    }
1021
1022    #[test]
1023    fn clean_receipt_history_parses_and_verifies() {
1024        let (_dir, ledger, heads) = build_ledger(3);
1025        let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1026        let r1 = make_canonical_receipt(
1027            Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1028            1,
1029            &heads[0],
1030            ExternalSink::Rekor,
1031        );
1032        let r2 = make_canonical_receipt(
1033            Utc.with_ymd_and_hms(2026, 5, 12, 18, 10, 0).unwrap(),
1034            3,
1035            &heads[2],
1036            ExternalSink::Rekor,
1037        );
1038        write_receipt_history(&receipts_path, &[r1, r2.clone()]);
1039
1040        let verification = verify_external_receipts(&ledger, &receipts_path).unwrap();
1041        assert_eq!(verification.receipts_verified, 2);
1042        assert_eq!(verification.latest_receipt, r2);
1043        assert_eq!(verification.db_count, 3);
1044        assert_eq!(verification.status, PARSED_ONLY_VERIFICATION_STATUS);
1045    }
1046
1047    #[test]
1048    fn tampered_anchor_text_hash_fails_closed_with_stable_invariant() {
1049        let (_dir, ledger, heads) = build_ledger(3);
1050        let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1051        let mut r1 = make_canonical_receipt(
1052            Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1053            3,
1054            &heads[2],
1055            ExternalSink::Rekor,
1056        );
1057        r1.anchor_text_sha256 = "f".repeat(SHA256_HEX_LEN);
1058        write_receipt_history(&receipts_path, &[r1]);
1059
1060        let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
1061        match err {
1062            ExternalReceiptVerifyError::AnchorTextHashMismatch {
1063                invariant,
1064                receipt_index,
1065                ..
1066            } => {
1067                assert_eq!(invariant, ANCHOR_TEXT_HASH_MISMATCH_INVARIANT);
1068                assert_eq!(receipt_index, 1);
1069            }
1070            other => panic!("expected AnchorTextHashMismatch, got {other:?}"),
1071        }
1072    }
1073
1074    #[test]
1075    fn tampered_anchor_chain_head_hash_fails_closed() {
1076        let (_dir, ledger, heads) = build_ledger(3);
1077        let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1078        let mut r1 = make_canonical_receipt(
1079            Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1080            3,
1081            &heads[2],
1082            ExternalSink::Rekor,
1083        );
1084        // Replace with a valid-shape hash that does not match the ledger.
1085        r1.anchor_chain_head_hash = "0".repeat(BLAKE3_HEX_LEN);
1086        // Also resynth anchor_text_sha256 so the AnchorVerify branch
1087        // fires before the hash mismatch branch — verifies that the
1088        // local-anchor verification is the load-bearing check.
1089        let bogus_anchor = LedgerAnchor::new(
1090            r1.submitted_at,
1091            r1.anchor_event_count,
1092            r1.anchor_chain_head_hash.clone(),
1093        )
1094        .unwrap();
1095        r1.anchor_text_sha256 = anchor_text_sha256(&bogus_anchor);
1096        write_receipt_history(&receipts_path, &[r1]);
1097
1098        let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
1099        assert!(
1100            matches!(err, ExternalReceiptVerifyError::AnchorVerify { .. }),
1101            "got {err:?}"
1102        );
1103    }
1104
1105    #[test]
1106    fn non_monotonic_receipt_history_fails_closed_before_anchor_check() {
1107        let (_dir, ledger, heads) = build_ledger(3);
1108        let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1109        let r1 = make_canonical_receipt(
1110            Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1111            3,
1112            &heads[2],
1113            ExternalSink::Rekor,
1114        );
1115        let r2 = make_canonical_receipt(
1116            Utc.with_ymd_and_hms(2026, 5, 12, 18, 10, 0).unwrap(),
1117            1,
1118            &heads[0],
1119            ExternalSink::Rekor,
1120        );
1121        write_receipt_history(&receipts_path, &[r1, r2]);
1122
1123        let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
1124        match err {
1125            ExternalReceiptVerifyError::Parse { source, .. } => {
1126                assert!(matches!(
1127                    source,
1128                    ExternalReceiptParseError::NonMonotonic {
1129                        receipt_index: 2,
1130                        previous_event_count: 3,
1131                        event_count: 1,
1132                    }
1133                ));
1134            }
1135            other => panic!("expected Parse(NonMonotonic), got {other:?}"),
1136        }
1137    }
1138
1139    #[test]
1140    fn missing_receipt_history_file_fails_closed() {
1141        let dir = tempdir().unwrap();
1142        let ledger = dir.path().join("events.jsonl");
1143        std::fs::write(&ledger, "").unwrap();
1144        let receipts_path = dir.path().join("missing-receipts");
1145        let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
1146        assert!(matches!(
1147            err,
1148            ExternalReceiptVerifyError::ReadHistory { .. }
1149        ));
1150    }
1151
1152    // ---------- Slice 4: trusted-root staleness gate ----------
1153
1154    fn near_root_now() -> DateTime<Utc> {
1155        // Pin `now` close to the embedded trust root's latest tlog
1156        // activation so the staleness gate does not falsely fire when
1157        // the test wall clock drifts.
1158        let root = TrustedRoot::embedded().unwrap();
1159        let signed_at = root.metadata_signed_at().unwrap();
1160        signed_at + chrono::Duration::days(1)
1161    }
1162
1163    fn build_receipts_fixture() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
1164        let (dir, ledger, heads) = build_ledger(3);
1165        let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
1166        let r1 = make_canonical_receipt(
1167            Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1168            3,
1169            &heads[2],
1170            ExternalSink::Rekor,
1171        );
1172        write_receipt_history(&receipts_path, &[r1]);
1173        (dir, ledger, receipts_path)
1174    }
1175
1176    #[test]
1177    fn fresh_cached_root_allows_verification() {
1178        let (dir, ledger, receipts_path) = build_receipts_fixture();
1179        let trust_root_path = dir.path().join("trusted_root.json");
1180        TrustedRoot::embedded()
1181            .unwrap()
1182            .write_atomic(&trust_root_path)
1183            .unwrap();
1184
1185        let now = near_root_now();
1186        // Prior F3 closure (2026-05-13): pin the cache file's mtime
1187        // back to the injected `now`. Without this, the cache mtime
1188        // (real wall-clock at write time, ~2026-05-13) is ahead of
1189        // the injected `now` (`signed_at + 1 day` ~2025-09-24) by
1190        // months, and the future-dated bypass guard correctly fires.
1191        // We are NOT trying to exercise that guard here; we are
1192        // pinning the standard "fresh cache mtime, fresh now" path.
1193        let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
1194            + std::time::Duration::from_secs(now.timestamp() as u64);
1195        std::fs::File::options()
1196            .write(true)
1197            .open(&trust_root_path)
1198            .unwrap()
1199            .set_modified(mtime_systemtime)
1200            .expect("set mtime");
1201        let verification = verify_external_receipts_with_options(
1202            &ledger,
1203            &receipts_path,
1204            Some(&trust_root_path),
1205            now,
1206            DEFAULT_MAX_TRUST_ROOT_AGE,
1207        )
1208        .expect("fresh cached root verifies");
1209        assert_eq!(verification.trust_root_status, CACHED_ROOT_STATUS);
1210        assert!(verification.trust_root_signed_at.is_some());
1211        assert_eq!(verification.status, PARSED_ONLY_VERIFICATION_STATUS);
1212    }
1213
1214    #[test]
1215    fn cached_root_older_than_31_days_fails_closed_with_cache_stale_invariant() {
1216        let (dir, ledger, receipts_path) = build_receipts_fixture();
1217        let trust_root_path = dir.path().join("trusted_root.json");
1218        TrustedRoot::embedded()
1219            .unwrap()
1220            .write_atomic(&trust_root_path)
1221            .unwrap();
1222
1223        // 2026-05-15 portfolio extension of Bug J: cached-path staleness
1224        // is anchored to file mtime, NOT the Sigstore tlog activation
1225        // inside the JSON. Set the cache file's mtime 31 days in the
1226        // past so the gate fires on the operator-meaningful datum.
1227        let now = Utc::now();
1228        let old_mtime = now - chrono::Duration::days(31);
1229        let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
1230            + std::time::Duration::from_secs(old_mtime.timestamp() as u64);
1231        std::fs::File::options()
1232            .write(true)
1233            .open(&trust_root_path)
1234            .unwrap()
1235            .set_modified(mtime_systemtime)
1236            .expect("set mtime");
1237
1238        let err = verify_external_receipts_with_options(
1239            &ledger,
1240            &receipts_path,
1241            Some(&trust_root_path),
1242            now,
1243            DEFAULT_MAX_TRUST_ROOT_AGE,
1244        )
1245        .unwrap_err();
1246        match err {
1247            ExternalReceiptVerifyError::TrustedRootStale {
1248                invariant,
1249                trust_root_status,
1250                ..
1251            } => {
1252                assert_eq!(invariant, TRUSTED_ROOT_CACHE_STALE_INVARIANT);
1253                assert_eq!(trust_root_status, CACHED_ROOT_STATUS);
1254            }
1255            other => panic!("expected TrustedRootStale, got {other:?}"),
1256        }
1257    }
1258
1259    #[test]
1260    fn cached_root_with_fresh_mtime_passes_even_when_metadata_old() {
1261        // 2026-05-15 portfolio extension of Bug J: a freshly-written
1262        // cache MUST pass the staleness gate even when the embedded
1263        // JSON's tlog-activation date is months old. This is the
1264        // structural defense against the metadata_signed_at-derived
1265        // staleness bug.
1266        let (dir, ledger, receipts_path) = build_receipts_fixture();
1267        let trust_root_path = dir.path().join("trusted_root.json");
1268        TrustedRoot::embedded()
1269            .unwrap()
1270            .write_atomic(&trust_root_path)
1271            .unwrap();
1272        // Wall clock 1 year past the embedded snapshot's tlog
1273        // activation. Under the buggy metadata_signed_at anchor this
1274        // would have fired stale; under the fixed mtime anchor it does
1275        // not provided the cache file's mtime is also pinned forward.
1276        let now = TrustedRoot::embedded()
1277            .unwrap()
1278            .metadata_signed_at()
1279            .unwrap()
1280            + chrono::Duration::days(365);
1281        // Pin cache mtime to the simulated `now` so the staleness gate
1282        // sees age=0 instead of negative drift between wall-clock
1283        // injection and real fs mtime.
1284        let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
1285            + std::time::Duration::from_secs(now.timestamp() as u64);
1286        std::fs::File::options()
1287            .write(true)
1288            .open(&trust_root_path)
1289            .unwrap()
1290            .set_modified(mtime_systemtime)
1291            .expect("set mtime");
1292        let verification = verify_external_receipts_with_options(
1293            &ledger,
1294            &receipts_path,
1295            Some(&trust_root_path),
1296            now,
1297            DEFAULT_MAX_TRUST_ROOT_AGE,
1298        )
1299        .expect("fresh cache mtime must pass the staleness gate");
1300        assert_eq!(verification.trust_root_status, CACHED_ROOT_STATUS);
1301    }
1302
1303    #[test]
1304    fn missing_cache_falls_back_to_embedded_and_allows_even_when_stale() {
1305        let (dir, ledger, receipts_path) = build_receipts_fixture();
1306        let missing_cache = dir.path().join("nonexistent-trusted_root.json");
1307        // Step the clock far past any plausible embedded activation.
1308        let now = Utc.with_ymd_and_hms(2099, 1, 1, 0, 0, 0).unwrap();
1309
1310        let verification = verify_external_receipts_with_options(
1311            &ledger,
1312            &receipts_path,
1313            Some(&missing_cache),
1314            now,
1315            DEFAULT_MAX_TRUST_ROOT_AGE,
1316        )
1317        .expect("missing cache + stale embedded still allows (warn-only)");
1318        assert_eq!(verification.trust_root_status, EMBEDDED_ROOT_STATUS);
1319    }
1320
1321    #[test]
1322    fn unparseable_cache_fails_closed_with_parse_invariant() {
1323        let (dir, ledger, receipts_path) = build_receipts_fixture();
1324        let trust_root_path = dir.path().join("trusted_root.json");
1325        std::fs::write(&trust_root_path, b"this is not valid json").unwrap();
1326
1327        let now = near_root_now();
1328        let err = verify_external_receipts_with_options(
1329            &ledger,
1330            &receipts_path,
1331            Some(&trust_root_path),
1332            now,
1333            DEFAULT_MAX_TRUST_ROOT_AGE,
1334        )
1335        .unwrap_err();
1336        match err {
1337            ExternalReceiptVerifyError::TrustedRootIo { invariant, .. } => {
1338                assert_eq!(invariant, TRUSTED_ROOT_PARSE_INVARIANT);
1339            }
1340            other => panic!("expected TrustedRootIo, got {other:?}"),
1341        }
1342    }
1343
1344    /// Prior F3 closure
1345    /// (`docs/reviews/CODE_REVIEW_2026-05-12_post_fd779d7.md`): the
1346    /// audit-verify path used to silently pass when the cached
1347    /// `trusted_root.json` had a future-dated mtime. The freshness
1348    /// gate now refuses with the cache-future-dated invariant.
1349    #[test]
1350    fn cached_root_with_future_dated_mtime_fails_closed_with_future_dated_invariant() {
1351        let (dir, ledger, receipts_path) = build_receipts_fixture();
1352        let trust_root_path = dir.path().join("trusted_root.json");
1353        TrustedRoot::embedded()
1354            .unwrap()
1355            .write_atomic(&trust_root_path)
1356            .unwrap();
1357
1358        let now = near_root_now();
1359        // Touch the cache 70 years into the future — the original
1360        // `touch -d 2099-01-01` attack shape.
1361        let future = now + chrono::Duration::days(365 * 70);
1362        let future_systemtime = std::time::SystemTime::UNIX_EPOCH
1363            + std::time::Duration::from_secs(future.timestamp() as u64);
1364        std::fs::File::options()
1365            .write(true)
1366            .open(&trust_root_path)
1367            .unwrap()
1368            .set_modified(future_systemtime)
1369            .expect("set mtime");
1370
1371        let err = verify_external_receipts_with_options(
1372            &ledger,
1373            &receipts_path,
1374            Some(&trust_root_path),
1375            now,
1376            DEFAULT_MAX_TRUST_ROOT_AGE,
1377        )
1378        .unwrap_err();
1379        match err {
1380            ExternalReceiptVerifyError::TrustedRootStale {
1381                invariant,
1382                trust_root_status,
1383                ..
1384            } => {
1385                assert_eq!(
1386                    invariant,
1387                    trusted_root::STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED
1388                );
1389                assert_eq!(trust_root_status, CACHED_ROOT_STATUS);
1390            }
1391            other => panic!("expected TrustedRootStale with cache_future_dated, got {other:?}"),
1392        }
1393    }
1394}