Skip to main content

cortex_ledger/external_sink/
trusted_root.rs

1//! TUF-rooted Rekor trust bundle (ADR 0013 Mechanism C trust channel).
2//!
3//! This module pins the **trusted_root.json** Sigstore TUF artifact used to
4//! verify Rekor `SignedEntryTimestamp` payloads. Council Decision #1 (TUF
5//! trust root channel, fail-closed when stale > 30 days) is the operator
6//! contract enforced here:
7//!
8//! 1. A known-good `trusted_root.json` snapshot is embedded into the binary
9//!    via [`include_bytes!`] so that the verifier can decide what counts as
10//!    valid Rekor public keys even when the operator has never run
11//!    `cortex audit anchor refresh-trust`. The snapshot date is recorded
12//!    in [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`].
13//! 2. Operators may stage a fresher cached `trusted_root.json` on disk via
14//!    [`TrustedRoot::load_cached`]. The CLI exposes this through the
15//!    `cortex audit anchor refresh-trust` subcommand.
16//! 3. [`TrustedRoot::is_stale_at`] returns `true` when an
17//!    operator-supplied freshness anchor (build-time snapshot date for
18//!    the embedded path, file mtime for the cached refresh path) is
19//!    older than `max_age` at the supplied `now`. Default operator
20//!    policy is 30 days. Anchor selection is the explicit
21//!    [`TrustRootStalenessAnchor`] caller argument — the
22//!    [`Self::metadata_signed_at`]-derived field is NOT a freshness
23//!    proxy (Bug J + 2026-05-15 portfolio-extension footnote on ADR
24//!    0013).
25//!
26//! ## What this module does **not** do
27//!
28//! - It does not verify TUF metadata signatures. The embedded snapshot is
29//!   trusted on the first build; subsequent refresh is structurally
30//!   validated (parse + currency) but the cryptographic chain-of-trust
31//!   over the TUF root → snapshot → targets path is an operator action
32//!   gated by the live HTTP refresh flow.
33//! - It does not perform any I/O outside [`TrustedRoot::load_cached`]
34//!   (read) and [`TrustedRoot::write_atomic`] (write). Both are isolated
35//!   to file paths the CLI passes in.
36//! - It does not consume the Rekor public key bytes. Rekor signature
37//!   verification is deferred per
38//!   `docs/design/DESIGN_external_anchor_authority.md` §Residual risk.
39//!   This slice unlocks the *channel* through which keys will be
40//!   distributed, not the verification of an individual entry.
41//!
42//! ## Cosign GHSA-whqx-f9j3-ch6m mitigation
43//!
44//! The trust root channel is the load-bearing primitive for the Cosign
45//! advisory class: a verifier that hard-codes Rekor public keys cannot
46//! follow key rotation, and a verifier that fetches keys without TUF
47//! semantics can be tricked into accepting attacker-controlled keys.
48//! Cortex's mitigation is the same as Sigstore's: ship a TUF-validated
49//! `trusted_root.json` with explicit operator-driven refresh, and refuse
50//! to mark anchor evidence authoritative when the trust root has aged
51//! past the operator's risk-acceptance window. Subject binding stays
52//! enforced at the verifier path (see
53//! [`crate::external_sink::verify_external_receipts`]).
54
55use std::fs::OpenOptions;
56use std::io::Write;
57use std::path::{Path, PathBuf};
58use std::time::Duration;
59
60use chrono::{DateTime, NaiveDate, Utc};
61use serde::{Deserialize, Serialize};
62use thiserror::Error;
63
64/// Raw bytes for the embedded `trusted_root.json` snapshot.
65///
66/// Fetched from
67/// `https://tuf-repo-cdn.sigstore.dev/targets/<hash>.trusted_root.json` on
68/// the snapshot date below. SHA-256 of the embedded bytes:
69/// `6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66`.
70///
71/// The embed targets the binary at build time; serde validation happens at
72/// the [`TrustedRoot::embedded`] boundary so a corrupted embed cannot
73/// silently produce a usable `TrustedRoot`.
74pub const TRUSTED_ROOT_JSON: &[u8] =
75    include_bytes!("embedded/sigstore_trusted_root_2026-05-12.json");
76
77/// Operator-visible date the embedded `trusted_root.json` snapshot was
78/// pulled. Used by diagnostics so an operator can correlate a stale
79/// embedded root with the public Sigstore TUF history.
80///
81/// **F2 closure (Finding 3 in
82/// `docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md`).** This constant
83/// USED to be a hand-written sibling literal next to
84/// [`TRUSTED_ROOT_JSON`]'s `include_bytes!("embedded/sigstore_trusted_root_YYYY-MM-DD.json")`
85/// argument. A snapshot refresh that bumped the filename but forgot to
86/// bump the constant would silently anchor the 30-day freshness gate to
87/// the stale capture date. The value is now derived at build time by
88/// `crates/cortex-ledger/build.rs` from the embedded snapshot's
89/// filename, so the two literals cannot drift independently. Mutating
90/// the filename to something that does not match
91/// `sigstore_trusted_root_YYYY-MM-DD.json`, or dropping the snapshot
92/// altogether, fails the build with a hard error.
93pub const EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE: &str = env!("EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE");
94
95/// Default operator policy: a trusted root older than this is fail-closed
96/// for the external-anchor authority path. Operators may override the
97/// policy through the verify command's `--max-trust-root-age-days` flag
98/// (slice 4) when their risk-acceptance window differs.
99pub const DEFAULT_MAX_TRUST_ROOT_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
100
101/// Tolerance window for future-dated freshness anchors before they are
102/// treated as a bypass attempt.
103///
104/// Prior F3 closure (CODE_REVIEW_2026-05-12_post_fd779d7.md): a
105/// future-dated cache `mtime` (e.g. via `touch -d 2099-01-01`) used to
106/// silently pass the freshness gate because `now - mtime` went negative
107/// and the `> max_age` comparison stayed false indefinitely. Refusing on
108/// any future mtime would be too strict (filesystem clock drift between
109/// hosts is real); instead we permit a small skew and refuse anything
110/// beyond it as
111/// [`TrustRootStalenessError::CacheFutureDated`] /
112/// [`STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED`].
113pub const TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE: Duration = Duration::from_secs(60);
114
115/// Stable invariant emitted when a trusted-root cache file's `mtime` is
116/// dated more than [`TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE`] ahead of
117/// wall-clock at the freshness check. See
118/// [`TrustRootStalenessError::CacheFutureDated`].
119pub const STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED: &str =
120    "audit.verify.trusted_root.cache_future_dated";
121
122/// Stable invariant emitted when an external-anchor receipt verifier
123/// refuses to mark anchor authority authoritative because the cached
124/// trusted_root.json has aged past
125/// [`DEFAULT_MAX_TRUST_ROOT_AGE`]. CLI diagnostics and JSON envelopes
126/// surface this exact token so dashboards and grep-based wrappers can
127/// distinguish "trust root stale" from "receipt parse failure".
128///
129/// Retained as a back-compat catch-all that is emitted ALONGSIDE the
130/// branch-specific token (see [`TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT`]
131/// and [`TRUSTED_ROOT_CACHE_STALE_INVARIANT`]) so existing dashboards do
132/// not break.
133pub const TRUSTED_ROOT_STALE_INVARIANT: &str = "audit.verify.trusted_root.stale_beyond_max_age";
134
135/// Stable invariant emitted when the embedded trusted_root.json snapshot
136/// is older than [`DEFAULT_MAX_TRUST_ROOT_AGE`]. Anchor is the explicit
137/// [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`] constant — NOT the Sigstore
138/// tlog signing-key activation date inside the JSON.
139///
140/// ADR 0013 footnote (2026-05-12 clarification on Council Decision #1)
141/// codifies the anchor field-choice that closes Bug J. The 2026-05-15
142/// portfolio-extension footnote extends that fix to ALL trust-root
143/// freshness call sites, not just the production-restore drill path.
144pub const TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT: &str = "audit.verify.trusted_root.snapshot_stale";
145
146/// Stable invariant emitted when a cached `trusted_root.json` written by
147/// `cortex audit refresh-trust` is older (by file mtime) than
148/// [`DEFAULT_MAX_TRUST_ROOT_AGE`]. Anchor is the cache file's
149/// modification time — NOT the Sigstore tlog signing-key activation
150/// date.
151pub const TRUSTED_ROOT_CACHE_STALE_INVARIANT: &str = "audit.verify.trusted_root.cache_stale";
152
153/// Stable invariant emitted by [`crate::external_sink::verify_external_receipts`] when the
154/// operator-supplied `trusted_root.json` (cache or embedded) cannot be
155/// parsed at all. Distinguished from `audit.verify.trusted_root.stale_beyond_max_age`
156/// so operators see whether to refresh or to debug the file shape.
157pub const TRUSTED_ROOT_PARSE_INVARIANT: &str = "audit.verify.trusted_root.parse_error";
158
159/// Stable status emitted by the trusted-root channel when the active root
160/// is the embedded snapshot rather than a cached operator refresh.
161pub const EMBEDDED_ROOT_STATUS: &str = "embedded_snapshot";
162/// Stable status emitted by the trusted-root channel when the active root
163/// is a cached `trusted_root.json` written by `cortex audit anchor refresh-trust`.
164pub const CACHED_ROOT_STATUS: &str = "operator_cached";
165
166/// Stable invariant emitted when [`TrustedRoot::rekor_verifying_key`]
167/// refuses a Rekor receipt because no transparency-log instance in the
168/// trusted root declares a `logId.keyId` matching the receipt's
169/// `body.logID`.
170///
171/// Closes BH-3 (Finding 3 in
172/// `docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md`): the previous
173/// "latest activation wins" selector verified entries against the wrong
174/// key when `trusted_root.json` carried multiple historical tlogs
175/// (key-rotation case) or a newly-added attacker-mirror tlog (Cosign
176/// GHSA-whqx-f9j3-ch6m class). The verifier now requires log-bound
177/// selection — no silent fall-back to "latest" is permitted.
178pub const REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT: &str =
179    "rekor.trusted_root.tlog_logid_no_match";
180
181/// Parsed Sigstore `trusted_root.json` (v0.1 `protobuf-go` JSON shape).
182///
183/// Only the fields cortex needs are deserialised. Unknown fields are
184/// preserved as `serde_json::Value` so a refresh from a slightly newer
185/// schema still round-trips.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct TrustedRoot {
188    /// Media type from the Sigstore protobuf descriptor.
189    /// Example: `application/vnd.dev.sigstore.trustedroot+json;version=0.1`.
190    #[serde(rename = "mediaType")]
191    pub media_type: String,
192    /// Rekor transparency log declarations. Cortex enforces a non-empty
193    /// list at parse time; an empty array fails closed.
194    #[serde(default)]
195    pub tlogs: Vec<TransparencyLogInstance>,
196    /// Fulcio certificate authority declarations. Cortex does not consume
197    /// these on the trust path today; they are kept so the cached file is
198    /// a faithful copy and so a future signature verifier can use them.
199    #[serde(rename = "certificateAuthorities", default)]
200    pub certificate_authorities: serde_json::Value,
201    /// Certificate transparency log declarations. Same posture as
202    /// `certificateAuthorities` above.
203    #[serde(default)]
204    pub ctlogs: serde_json::Value,
205    /// RFC 3161 timestamp authority declarations.
206    #[serde(rename = "timestampAuthorities", default)]
207    pub timestamp_authorities: serde_json::Value,
208}
209
210/// Single Rekor transparency log entry inside `trusted_root.json`.
211///
212/// The full Sigstore protobuf has additional metadata; cortex's slice 1
213/// trust-channel path only consumes the activation date and the key
214/// bytes, so the rest is captured as `serde_json::Value` and round-tripped
215/// without interpretation.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct TransparencyLogInstance {
218    /// Rekor API base URL.
219    #[serde(rename = "baseUrl")]
220    pub base_url: String,
221    /// `logId.keyId` base64; opaque to cortex today.
222    #[serde(rename = "logId", default)]
223    pub log_id: serde_json::Value,
224    /// Hash algorithm declaration; opaque to cortex today.
225    #[serde(rename = "hashAlgorithm", default)]
226    pub hash_algorithm: serde_json::Value,
227    /// Public key envelope; opaque except for `validFor.start`.
228    #[serde(rename = "publicKey")]
229    pub public_key: TransparencyLogPublicKey,
230    /// Rekor log shard identity (`log_index`, `tree_size`, etc.); opaque
231    /// to cortex today.
232    #[serde(default)]
233    pub log_index: serde_json::Value,
234    /// Tree-id metadata; opaque to cortex today.
235    #[serde(rename = "treeId", default)]
236    pub tree_id: serde_json::Value,
237    /// Catch-all so a refresh from a newer schema does not drop fields.
238    #[serde(flatten)]
239    pub extra: serde_json::Map<String, serde_json::Value>,
240}
241
242/// Sigstore public-key envelope inside [`TransparencyLogInstance`].
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct TransparencyLogPublicKey {
245    /// Base64 DER public key bytes; opaque on the trust-channel path
246    /// today.
247    #[serde(rename = "rawBytes", default)]
248    pub raw_bytes: Option<String>,
249    /// Sigstore protobuf `KeyDetails` enum string (e.g.
250    /// `PKIX_ECDSA_P256_SHA_256`, `PKIX_ED25519`). Cortex emits this in
251    /// diagnostics so operators can correlate the active key shape.
252    #[serde(rename = "keyDetails", default)]
253    pub key_details: Option<String>,
254    /// Activation window for the key. `start` is the canonical "trust
255    /// root signed at" proxy cortex uses for staleness checks.
256    #[serde(rename = "validFor", default)]
257    pub valid_for: ValidityPeriod,
258    /// Catch-all for unmodelled fields.
259    #[serde(flatten)]
260    pub extra: serde_json::Map<String, serde_json::Value>,
261}
262
263/// Activation window emitted by Sigstore protobufs.
264#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
265pub struct ValidityPeriod {
266    /// Inclusive start of the window. RFC 3339; required by Sigstore.
267    #[serde(default)]
268    pub start: Option<DateTime<Utc>>,
269    /// Inclusive end of the window. Absent means "still active".
270    #[serde(default)]
271    pub end: Option<DateTime<Utc>>,
272}
273
274/// Caller-selected freshness anchor used by [`TrustedRoot::is_stale_at`].
275///
276/// Bug J fix + 2026-05-15 portfolio extension on ADR 0013 footnote:
277/// every trust-root freshness gate in Cortex MUST anchor against an
278/// operator-meaningful datum, not the Sigstore tlog signing-key
279/// activation date inside the JSON. The two operator-meaningful
280/// anchors are:
281///
282/// - **Embedded snapshot path** ([`Self::EmbeddedSnapshotDate`]) — the
283///   build-time date Cortex captured the embedded snapshot, parsed
284///   from [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`]. Fresh until 30 days
285///   past that release date.
286/// - **Cached refresh path** ([`Self::CacheFileMtime`]) — the
287///   filesystem modification time of the cache file written by
288///   `cortex audit refresh-trust`. Fresh until 30 days past the last
289///   operator-driven refresh.
290///
291/// Callers select the variant by inspecting whether they have a
292/// readable cache file at the configured cache path. The verifier
293/// helpers in [`crate::external_sink::verify_external_receipts_with_options`]
294/// and the production-restore drill follow the same convention.
295#[derive(Debug)]
296pub enum TrustRootStalenessAnchor<'a> {
297    /// Anchor against the build-time embedded snapshot date.
298    /// `snapshot_iso_date` defaults to [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`]
299    /// at construction via [`Self::embedded_snapshot()`].
300    EmbeddedSnapshotDate {
301        /// `YYYY-MM-DD` string for the embedded snapshot date. Parsed
302        /// at anchor-resolution time so a malformed constant fails
303        /// loud rather than silently treating the root as stale.
304        snapshot_iso_date: &'a str,
305    },
306    /// Anchor against a cache file's modification time.
307    CacheFileMtime {
308        /// Path to the cache file written by
309        /// `cortex audit refresh-trust`. The file MUST exist and be
310        /// readable; missing/unreadable files surface as
311        /// [`TrustRootStalenessError`] so callers can distinguish a
312        /// missing cache (fall back to embedded) from an unreadable
313        /// cache (operator-actionable I/O error).
314        cache_path: &'a Path,
315    },
316}
317
318impl<'a> TrustRootStalenessAnchor<'a> {
319    /// Build the embedded-snapshot anchor anchored to the
320    /// build-time [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`] constant.
321    #[must_use]
322    pub const fn embedded_snapshot() -> Self {
323        Self::EmbeddedSnapshotDate {
324            snapshot_iso_date: EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE,
325        }
326    }
327
328    /// Build the cache-file-mtime anchor at the supplied path.
329    #[must_use]
330    pub const fn cache_file_mtime(cache_path: &'a Path) -> Self {
331        Self::CacheFileMtime { cache_path }
332    }
333
334    /// Short, stable label used in [`TrustRootStalenessError::CacheFutureDated`]
335    /// diagnostics so the operator transcript names which anchor source
336    /// resolved to a future timestamp.
337    fn diagnostic_label(&self) -> String {
338        match self {
339            Self::EmbeddedSnapshotDate { snapshot_iso_date } => {
340                format!("embedded_snapshot_date={snapshot_iso_date}")
341            }
342            Self::CacheFileMtime { cache_path } => {
343                format!("cache_file_mtime path={}", cache_path.display())
344            }
345        }
346    }
347
348    /// Resolve the anchor into a wall-clock timestamp. `_now` is
349    /// reserved for potential future anchors (e.g. fixture-injected
350    /// clocks) but not used today.
351    fn resolve(&self, _now: DateTime<Utc>) -> Result<DateTime<Utc>, TrustRootStalenessError> {
352        match self {
353            Self::EmbeddedSnapshotDate { snapshot_iso_date } => {
354                let date =
355                    NaiveDate::parse_from_str(snapshot_iso_date, "%Y-%m-%d").map_err(|err| {
356                        TrustRootStalenessError::MalformedEmbeddedSnapshotDate {
357                            observed: (*snapshot_iso_date).to_string(),
358                            reason: err.to_string(),
359                        }
360                    })?;
361                let utc = date
362                    .and_hms_opt(0, 0, 0)
363                    .ok_or(TrustRootStalenessError::EmbeddedSnapshotMidnightConstruction)?
364                    .and_utc();
365                Ok(utc)
366            }
367            Self::CacheFileMtime { cache_path } => {
368                let meta = std::fs::metadata(cache_path).map_err(|source| {
369                    TrustRootStalenessError::CacheMetadata {
370                        path: cache_path.to_path_buf(),
371                        source,
372                    }
373                })?;
374                let mtime =
375                    meta.modified()
376                        .map_err(|source| TrustRootStalenessError::CacheMtime {
377                            path: cache_path.to_path_buf(),
378                            source,
379                        })?;
380                Ok(DateTime::<Utc>::from(mtime))
381            }
382        }
383    }
384}
385
386/// Errors produced while resolving a [`TrustRootStalenessAnchor`].
387///
388/// These surface to the CLI when the caller's selected anchor source
389/// is structurally unavailable (e.g. unreadable cache file). They are
390/// **not** the "trust root is stale" branch — that is the `Ok(true)`
391/// return of [`TrustedRoot::is_stale_at`].
392#[derive(Debug, Error)]
393pub enum TrustRootStalenessError {
394    /// `EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE` constant did not parse as
395    /// `YYYY-MM-DD`. Build-time bug — surfaces to operators only when
396    /// a fresh build is mis-tagged.
397    #[error(
398        "EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE `{observed}` is not RFC 3339 YYYY-MM-DD: {reason}"
399    )]
400    MalformedEmbeddedSnapshotDate {
401        /// The observed snapshot-date string that failed to parse.
402        observed: String,
403        /// Underlying chrono parse error message.
404        reason: String,
405    },
406    /// `NaiveDate::and_hms_opt(0,0,0)` failed; not actually reachable
407    /// for any well-formed date but retained so the anchor resolver
408    /// can fail closed rather than panic.
409    #[error("EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE midnight construction failed")]
410    EmbeddedSnapshotMidnightConstruction,
411    /// `std::fs::metadata` on the cache file failed (file missing,
412    /// unreadable permissions, etc.).
413    #[error("cannot stat trusted_root.json cache `{path}`: {source}", path = path.display())]
414    CacheMetadata {
415        /// Cache path the caller supplied.
416        path: PathBuf,
417        /// Underlying I/O failure.
418        source: std::io::Error,
419    },
420    /// File metadata was read but `modified()` was unsupported / failed.
421    #[error("cannot read mtime on trusted_root.json cache `{path}`: {source}", path = path.display())]
422    CacheMtime {
423        /// Cache path the caller supplied.
424        path: PathBuf,
425        /// Underlying I/O failure.
426        source: std::io::Error,
427    },
428    /// The resolved freshness anchor is dated more than the operator
429    /// tolerance ahead of wall-clock.
430    ///
431    /// Prior F3 closure: a future-dated cache `mtime` (e.g. set via
432    /// `touch -d 2099-01-01`) used to silently pass the freshness gate
433    /// because the `now - mtime > max_age` comparison goes false for a
434    /// negative duration. The check is now fail-closed: any anchor more
435    /// than [`TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE`] in the future
436    /// surfaces this error and the caller MUST refuse the operation with
437    /// [`STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED`].
438    #[error(
439        "trusted_root anchor `{anchor}` is dated {anchor_ts} which is more than {tolerance_seconds}s ahead of now={now}: \
440         refusing freshness gate (future-dated bypass guard)"
441    )]
442    CacheFutureDated {
443        /// Diagnostic label naming the anchor source (`embedded_snapshot_date=…`
444        /// or `cache_file_mtime path=…`).
445        anchor: String,
446        /// Resolved anchor timestamp (the future-dated value).
447        anchor_ts: DateTime<Utc>,
448        /// Wall-clock at the check.
449        now: DateTime<Utc>,
450        /// Tolerance window expressed in seconds (matches
451        /// [`TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE`]).
452        tolerance_seconds: u64,
453    },
454}
455
456impl TrustedRoot {
457    /// Parse the embedded snapshot. Panics only when the embedded JSON is
458    /// structurally invalid — which is a build-time bug, not a runtime
459    /// failure mode.
460    ///
461    /// Operators MUST NOT rely on the embedded root staying fresh; it is
462    /// the fail-closed floor that exists so verifying with a brand-new
463    /// data directory does not silently degrade. Refresh is an operator
464    /// action.
465    pub fn embedded() -> Result<Self, TrustedRootParseError> {
466        Self::parse_bytes(TRUSTED_ROOT_JSON)
467    }
468
469    /// Parse a `trusted_root.json` payload supplied as raw bytes.
470    pub fn parse_bytes(bytes: &[u8]) -> Result<Self, TrustedRootParseError> {
471        let root: Self =
472            serde_json::from_slice(bytes).map_err(|source| TrustedRootParseError::Malformed {
473                reason: source.to_string(),
474            })?;
475        root.validate()?;
476        Ok(root)
477    }
478
479    /// Read a cached `trusted_root.json` off disk. Missing files return
480    /// `Ok(None)` so callers can fall back to [`Self::embedded`]; parse
481    /// failures fail closed.
482    pub fn load_cached(path: impl AsRef<Path>) -> Result<Option<Self>, TrustedRootIoError> {
483        let path = path.as_ref();
484        let bytes = match std::fs::read(path) {
485            Ok(bytes) => bytes,
486            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
487            Err(source) => {
488                return Err(TrustedRootIoError::Read {
489                    path: path.to_path_buf(),
490                    source,
491                });
492            }
493        };
494        Self::parse_bytes(&bytes)
495            .map(Some)
496            .map_err(|source| TrustedRootIoError::Parse {
497                path: path.to_path_buf(),
498                source,
499            })
500    }
501
502    /// Validate the structural invariants cortex enforces on every
503    /// `trusted_root.json`:
504    ///
505    /// - `mediaType` is non-empty and starts with the Sigstore
506    ///   `application/vnd.dev.sigstore.trustedroot+json` prefix so a
507    ///   misrouted file (e.g. `targets.json`, `timestamp.json`) cannot be
508    ///   silently accepted.
509    /// - At least one tlog is declared; an empty list cannot witness
510    ///   anything and is treated as a hard parse failure rather than as
511    ///   "trust nothing".
512    /// - Every tlog has a parseable activation start.
513    pub fn validate(&self) -> Result<(), TrustedRootParseError> {
514        if !self
515            .media_type
516            .starts_with("application/vnd.dev.sigstore.trustedroot+json")
517        {
518            return Err(TrustedRootParseError::WrongMediaType {
519                observed: self.media_type.clone(),
520            });
521        }
522        if self.tlogs.is_empty() {
523            return Err(TrustedRootParseError::EmptyTlogs);
524        }
525        for (index, tlog) in self.tlogs.iter().enumerate() {
526            if tlog.base_url.is_empty() {
527                return Err(TrustedRootParseError::MissingBaseUrl { tlog_index: index });
528            }
529            if tlog.public_key.valid_for.start.is_none() {
530                return Err(TrustedRootParseError::MissingValidityStart { tlog_index: index });
531            }
532        }
533        Ok(())
534    }
535
536    /// Latest tlog activation timestamp across every transparency log in
537    /// the root. Used as the proxy for "this trust root was signed at"
538    /// — a fresher activation entry means the trust root saw at least
539    /// one signing-key rotation event after that date.
540    ///
541    /// Returns `None` only when validation was skipped or every tlog
542    /// lacked a start (which `validate` would have rejected).
543    #[must_use]
544    pub fn metadata_signed_at(&self) -> Option<DateTime<Utc>> {
545        self.tlogs
546            .iter()
547            .filter_map(|tlog| tlog.public_key.valid_for.start)
548            .max()
549    }
550
551    /// Return `true` when `now - anchor` exceeds `max_age`, where the
552    /// `anchor` is the explicit freshness datum the caller selected via
553    /// [`TrustRootStalenessAnchor`].
554    ///
555    /// Bug J + 2026-05-15 portfolio-extension fix: callers MUST pass an
556    /// explicit anchor source rather than letting the implementation
557    /// fall through to [`Self::metadata_signed_at`] — the latter is the
558    /// Sigstore tlog signing-key activation date, which rotates rarely
559    /// (months/years) and would make every release immediately stale
560    /// under a 30-day policy. The correct anchors are documented on
561    /// [`TrustRootStalenessAnchor`].
562    ///
563    /// Returns `Err(TrustRootStalenessError)` only when the anchor
564    /// source cannot be resolved (e.g. cache file unreadable,
565    /// embedded snapshot date constant is malformed). I/O failures on
566    /// the cache file are reported verbatim so callers can map them to
567    /// the correct CLI exit code; they are NOT silently treated as
568    /// stale.
569    pub fn is_stale_at(
570        &self,
571        now: DateTime<Utc>,
572        max_age: Duration,
573        anchor: TrustRootStalenessAnchor<'_>,
574    ) -> Result<bool, TrustRootStalenessError> {
575        let anchor_ts = anchor.resolve(now)?;
576        // Prior F3 closure: refuse a future-dated freshness anchor before
577        // any direction-sensitive comparison. The bug used to be
578        //   let age = now.signed_duration_since(anchor_ts);
579        //   age > max_age
580        // returning false when `anchor_ts > now` (the `Duration` goes
581        // negative). An operator who runs `touch -d 2099-01-01` on the
582        // cached `trusted_root.json` would then bypass the staleness
583        // window indefinitely. We allow a small tolerance for legitimate
584        // wall-clock skew (e.g. between an NFS server and the host) and
585        // refuse anything beyond it as a hard error so callers can map
586        // the case to a stable invariant.
587        let tolerance = chrono::Duration::from_std(TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE)
588            .unwrap_or_default();
589        if anchor_ts > now + tolerance {
590            return Err(TrustRootStalenessError::CacheFutureDated {
591                anchor: anchor.diagnostic_label(),
592                anchor_ts,
593                now,
594                tolerance_seconds: TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE.as_secs(),
595            });
596        }
597        // F3 closure: use saturating millisecond arithmetic instead of
598        // chrono's `now - anchor_ts` subtraction, which can produce
599        // unexpected values near i64::MIN/MAX (e.g. an adversarially
600        // crafted anchor near DateTime::MIN could underflow the i64
601        // millisecond counter and produce a large positive duration that
602        // falsely reports "not stale"). `saturating_sub` clamps to i64::MAX
603        // (always-stale) on underflow and to 0 (not-stale) on overflow,
604        // both of which are safe failure modes for this gate.
605        let age_ms = now
606            .timestamp_millis()
607            .saturating_sub(anchor_ts.timestamp_millis());
608        let max_age_ms = chrono::Duration::from_std(max_age)
609            .unwrap_or(chrono::Duration::MAX)
610            .num_milliseconds();
611        Ok(age_ms > max_age_ms)
612    }
613
614    /// Convenience alias for [`Self::is_stale_at`] with the default
615    /// operator policy ([`DEFAULT_MAX_TRUST_ROOT_AGE`]). Used by the
616    /// Rekor live adapter (council Decision #2) to keep the call sites
617    /// readable.
618    pub fn is_stale(
619        &self,
620        now: DateTime<Utc>,
621        anchor: TrustRootStalenessAnchor<'_>,
622    ) -> Result<bool, TrustRootStalenessError> {
623        self.is_stale_at(now, DEFAULT_MAX_TRUST_ROOT_AGE, anchor)
624    }
625
626    /// Convenience alias for [`Self::embedded`]. Used by the Rekor live
627    /// adapter (council Decision #2) which was authored against the
628    /// pre-merge shim API before Decision #1 landed.
629    pub fn from_embedded() -> Result<Self, TrustedRootParseError> {
630        Self::embedded()
631    }
632
633    /// Construct a single-tlog `TrustedRoot` from a fixture
634    /// ECDSA P-256 SubjectPublicKeyInfo PEM. Test-only path used by
635    /// adapter unit tests that synthesise their own Rekor receipts with
636    /// a deterministic signing seed and need a matching verifying key
637    /// out of the trust root. Activation is pinned to `signed_at` so
638    /// the staleness gate can be exercised deterministically.
639    ///
640    /// `log_id` is the `logId.keyId` string the constructed tlog will
641    /// declare. BH-3 fix
642    /// (`docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md` Finding 3)
643    /// requires log-bound key selection; pass the same string the
644    /// fixture's Rekor receipt declares as `body.logID` so
645    /// [`Self::rekor_verifying_key`] resolves the fixture key.
646    #[cfg(any(test, feature = "test-support"))]
647    pub fn from_fixture_rekor_pem(
648        pem: &str,
649        signed_at: DateTime<Utc>,
650        log_id: &str,
651    ) -> Result<Self, TrustedRootKeyError> {
652        use base64::Engine;
653        use p256::pkcs8::{DecodePublicKey, EncodePublicKey};
654
655        let key = p256::ecdsa::VerifyingKey::from_public_key_pem(pem).map_err(|source| {
656            TrustedRootKeyError::DecodeKey {
657                reason: source.to_string(),
658            }
659        })?;
660        let encoded = key
661            .to_public_key_der()
662            .map_err(|source| TrustedRootKeyError::DecodeKey {
663                reason: source.to_string(),
664            })?;
665        let raw_b64 = base64::engine::general_purpose::STANDARD.encode(encoded.as_bytes());
666        Ok(Self {
667            media_type: "application/vnd.dev.sigstore.trustedroot+json;version=0.1".to_string(),
668            tlogs: vec![TransparencyLogInstance {
669                base_url: "https://rekor.test.fixture".to_string(),
670                log_id: serde_json::json!({ "keyId": log_id }),
671                hash_algorithm: serde_json::Value::String("SHA2_256".to_string()),
672                public_key: TransparencyLogPublicKey {
673                    raw_bytes: Some(raw_b64),
674                    key_details: Some("PKIX_ECDSA_P256_SHA_256".to_string()),
675                    valid_for: ValidityPeriod {
676                        start: Some(signed_at),
677                        end: None,
678                    },
679                    extra: serde_json::Map::new(),
680                },
681                log_index: serde_json::Value::Null,
682                tree_id: serde_json::Value::Null,
683                extra: serde_json::Map::new(),
684            }],
685            ctlogs: serde_json::Value::Null,
686            certificate_authorities: serde_json::Value::Null,
687            timestamp_authorities: serde_json::Value::Null,
688        })
689    }
690
691    /// Best-effort signed-at accessor for the staleness gate.
692    ///
693    /// Falls back to the Unix epoch when no tlog activation is parseable —
694    /// trust-root validation refuses that case earlier, so the fallback is
695    /// only reachable when the operator deliberately constructs a trust
696    /// root with no tlog. Callers that need to distinguish "no activation"
697    /// from a real timestamp should use [`Self::metadata_signed_at`].
698    #[must_use]
699    pub fn signed_at(&self) -> DateTime<Utc> {
700        self.metadata_signed_at()
701            .unwrap_or_else(|| DateTime::<Utc>::from_timestamp(0, 0).expect("epoch is valid"))
702    }
703
704    /// Return the parsed ECDSA P-256 verifying key for the Rekor
705    /// `SignedEntryTimestamp` signature **bound to the receipt's
706    /// `body.logID`**.
707    ///
708    /// BH-3 fix (Finding 3 in
709    /// `docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md`,
710    /// Cosign GHSA-whqx-f9j3-ch6m class). The selector finds the tlog
711    /// whose `logId.keyId` byte-decodes to the same value as the
712    /// receipt's `body.logID`, then returns that tlog's ECDSA P-256
713    /// public key. It does **not** silently fall back to a "latest
714    /// activation" tlog when the logId is unknown — Sigstore's
715    /// `trusted_root.json` declares multiple historical tlogs during
716    /// rotation, each with a distinct `logId` and key; an entry signed
717    /// by an older tlog (or a newly-added attacker-controlled tlog
718    /// that wins the activation-date race) would otherwise be verified
719    /// against the wrong key.
720    ///
721    /// Encoding: Sigstore's `logId.keyId` is base64 of the raw bytes
722    /// (typically SHA-256(public_key_der)); Rekor's REST API returns
723    /// `body.logID` as the same bytes in lowercase hex. The match
724    /// function decodes both sides to bytes (preferring hex then
725    /// base64 for the receipt, base64 then raw for the tlog id) and
726    /// compares any pair for equality. Fixture log-ids that are
727    /// neither hex nor base64 (e.g. the literal `"fixture-log-id"`
728    /// used in unit tests) are matched on raw byte equality so the
729    /// fixture surface keeps working without inflating the production
730    /// matching rules.
731    ///
732    /// Fails closed with [`TrustedRootKeyError::TlogLogIdNoMatch`]
733    /// (carrying the stable invariant
734    /// [`REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT`]) when no
735    /// declared tlog matches the receipt's logId, and with the
736    /// existing [`TrustedRootKeyError`] variants when the matched
737    /// tlog's `rawBytes` is missing or does not decode as DER
738    /// SubjectPublicKeyInfo.
739    pub fn rekor_verifying_key(
740        &self,
741        receipt_log_id: &str,
742    ) -> Result<p256::ecdsa::VerifyingKey, TrustedRootKeyError> {
743        use p256::pkcs8::DecodePublicKey;
744
745        // 1. Filter to tlogs declaring ECDSA P-256 — Cortex does not
746        //    yet accept Ed25519 Rekor signatures, so we must NOT pick
747        //    an Ed25519 tlog even if its logId happened to match.
748        let ecdsa_tlogs = self
749            .tlogs
750            .iter()
751            .filter(|tlog| {
752                tlog.public_key
753                    .key_details
754                    .as_deref()
755                    .is_some_and(|details| details.contains("ECDSA_P256"))
756            })
757            .collect::<Vec<_>>();
758        if ecdsa_tlogs.is_empty() {
759            return Err(TrustedRootKeyError::NoEcdsaP256Tlog);
760        }
761
762        // 2. Of those, find the one whose `logId.keyId` matches the
763        //    receipt's `body.logID`. No silent fall-back to "latest
764        //    activation" — refusal is the explicit Cosign mitigation.
765        let tlog = ecdsa_tlogs
766            .into_iter()
767            .find(|tlog| {
768                tlog_log_id_key_id(&tlog.log_id)
769                    .as_deref()
770                    .is_some_and(|tlog_key_id| log_id_matches(tlog_key_id, receipt_log_id))
771            })
772            .ok_or_else(|| TrustedRootKeyError::TlogLogIdNoMatch {
773                invariant: REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT,
774                receipt_log_id: receipt_log_id.to_string(),
775                tlog_log_ids: self
776                    .tlogs
777                    .iter()
778                    .filter_map(|tlog| tlog_log_id_key_id(&tlog.log_id))
779                    .collect(),
780            })?;
781
782        // 3. Decode the matched tlog's DER SPKI to a typed P-256
783        //    VerifyingKey. Same fail-closed semantics as the previous
784        //    implementation for the remaining branches.
785        use base64::Engine;
786        let raw_b64 = tlog
787            .public_key
788            .raw_bytes
789            .as_deref()
790            .ok_or(TrustedRootKeyError::MissingRawBytes)?;
791        let der = base64::engine::general_purpose::STANDARD
792            .decode(raw_b64)
793            .map_err(|source| TrustedRootKeyError::Base64 {
794                reason: source.to_string(),
795            })?;
796        p256::ecdsa::VerifyingKey::from_public_key_der(&der).map_err(|source| {
797            TrustedRootKeyError::DecodeKey {
798                reason: source.to_string(),
799            }
800        })
801    }
802
803    /// Atomically install this trusted root at `path`.
804    ///
805    /// Writes to a sibling temp file then renames into place so a partial
806    /// write cannot leave a half-written trust root on disk for the next
807    /// verify call to load.
808    pub fn write_atomic(&self, path: impl AsRef<Path>) -> Result<(), TrustedRootIoError> {
809        let path = path.as_ref();
810        let parent = path.parent().unwrap_or_else(|| Path::new("."));
811        std::fs::create_dir_all(parent).map_err(|source| TrustedRootIoError::Write {
812            path: path.to_path_buf(),
813            source,
814        })?;
815        let temp = path.with_extension("json.tmp");
816        let body = serde_json::to_vec(self).map_err(|source| TrustedRootIoError::Serialize {
817            path: path.to_path_buf(),
818            source,
819        })?;
820        {
821            let mut file = OpenOptions::new()
822                .write(true)
823                .create(true)
824                .truncate(true)
825                .open(&temp)
826                .map_err(|source| TrustedRootIoError::Write {
827                    path: temp.clone(),
828                    source,
829                })?;
830            file.write_all(&body)
831                .map_err(|source| TrustedRootIoError::Write {
832                    path: temp.clone(),
833                    source,
834                })?;
835            file.sync_all()
836                .map_err(|source| TrustedRootIoError::Write {
837                    path: temp.clone(),
838                    source,
839                })?;
840        }
841        std::fs::rename(&temp, path).map_err(|source| TrustedRootIoError::Write {
842            path: path.to_path_buf(),
843            source,
844        })
845    }
846}
847
848/// Bundle returned by [`active_trusted_root`] describing which root is in
849/// force for the current verification call.
850#[derive(Debug, Clone, PartialEq, Eq)]
851pub struct ActiveTrustedRoot {
852    /// Parsed trust root currently in force.
853    pub root: TrustedRoot,
854    /// Stable status token: [`EMBEDDED_ROOT_STATUS`] when the embedded
855    /// snapshot is in force, [`CACHED_ROOT_STATUS`] when a cached file
856    /// was loaded.
857    pub status: &'static str,
858    /// Cache path that was inspected. `None` when the caller did not
859    /// supply one.
860    pub cache_path: Option<PathBuf>,
861}
862
863/// Resolve the active trusted root for verification: cached file if
864/// present and parseable, embedded snapshot otherwise. The embedded
865/// snapshot is the fail-closed floor.
866pub fn active_trusted_root(
867    cache_path: Option<&Path>,
868) -> Result<ActiveTrustedRoot, TrustedRootIoError> {
869    if let Some(path) = cache_path {
870        if let Some(root) = TrustedRoot::load_cached(path)? {
871            return Ok(ActiveTrustedRoot {
872                root,
873                status: CACHED_ROOT_STATUS,
874                cache_path: Some(path.to_path_buf()),
875            });
876        }
877    }
878    let root = TrustedRoot::embedded().map_err(|source| TrustedRootIoError::Parse {
879        path: PathBuf::from("<embedded>"),
880        source,
881    })?;
882    Ok(ActiveTrustedRoot {
883        root,
884        status: EMBEDDED_ROOT_STATUS,
885        cache_path: cache_path.map(Path::to_path_buf),
886    })
887}
888
889/// Errors produced while parsing a `trusted_root.json` payload.
890#[derive(Debug, Clone, PartialEq, Eq, Error)]
891pub enum TrustedRootParseError {
892    /// Bytes did not parse as JSON in the Sigstore trust-root shape.
893    #[error("malformed trusted_root.json: {reason}")]
894    Malformed {
895        /// Underlying serde decode message.
896        reason: String,
897    },
898    /// `mediaType` did not start with the Sigstore trust-root prefix.
899    #[error("trusted_root.json mediaType `{observed}` is not application/vnd.dev.sigstore.trustedroot+json")]
900    WrongMediaType {
901        /// `mediaType` value observed in the payload.
902        observed: String,
903    },
904    /// Trust root declared zero Rekor tlogs.
905    #[error("trusted_root.json has no Rekor tlogs declared")]
906    EmptyTlogs,
907    /// Trust root declared a tlog with no Rekor `baseUrl`.
908    #[error("trusted_root.json tlog at index {tlog_index} is missing baseUrl")]
909    MissingBaseUrl {
910        /// Zero-based tlog index in the payload.
911        tlog_index: usize,
912    },
913    /// Trust root declared a tlog with no `validFor.start` timestamp.
914    #[error("trusted_root.json tlog at index {tlog_index} is missing publicKey.validFor.start")]
915    MissingValidityStart {
916        /// Zero-based tlog index in the payload.
917        tlog_index: usize,
918    },
919}
920
921/// Errors produced when reading or writing a cached trust root.
922#[derive(Debug, Error)]
923pub enum TrustedRootIoError {
924    /// Cache file could not be read off disk.
925    #[error("failed to read trusted_root.json {path:?}: {source}")]
926    Read {
927        /// Cache path that was being read.
928        path: PathBuf,
929        /// Underlying I/O failure.
930        source: std::io::Error,
931    },
932    /// Cache file existed but did not parse.
933    #[error("invalid trusted_root.json {path:?}: {source}")]
934    Parse {
935        /// Cache path that was being parsed.
936        path: PathBuf,
937        /// Underlying parse failure.
938        source: TrustedRootParseError,
939    },
940    /// Cache file could not be serialised before write.
941    #[error("failed to serialise trusted_root.json for {path:?}: {source}")]
942    Serialize {
943        /// Cache path that was being written.
944        path: PathBuf,
945        /// Underlying serde encode failure.
946        source: serde_json::Error,
947    },
948    /// Cache file could not be written to disk.
949    #[error("failed to write trusted_root.json {path:?}: {source}")]
950    Write {
951        /// Cache path that was being written.
952        path: PathBuf,
953        /// Underlying I/O failure.
954        source: std::io::Error,
955    },
956}
957
958/// Errors when extracting a typed Rekor verifying key from the active
959/// trusted root.
960#[derive(Debug, Error)]
961pub enum TrustedRootKeyError {
962    /// No transparency-log instance in the trusted root declared an
963    /// ECDSA P-256 key. Cortex does not (yet) accept Ed25519 Rekor
964    /// signatures.
965    #[error("trusted_root.json has no ECDSA P-256 tlog entry")]
966    NoEcdsaP256Tlog,
967    /// No transparency-log instance declared a `logId.keyId` that
968    /// matches the Rekor receipt's `body.logID`. Closes BH-3
969    /// (`docs/reviews/BUG_HUNT_2026-05-12_post_8f43450.md` Finding 3,
970    /// Cosign GHSA-whqx-f9j3-ch6m class). Refusal is structural — there
971    /// is no silent fall-back to the latest-activated tlog.
972    #[error(
973        "{invariant}: trusted_root.json has no tlog whose logId.keyId matches Rekor receipt logID `{receipt_log_id}` (declared tlogs: {})",
974        tlog_log_ids.join(", ")
975    )]
976    TlogLogIdNoMatch {
977        /// Stable invariant token, equal to
978        /// [`REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT`].
979        invariant: &'static str,
980        /// `body.logID` from the Rekor receipt that failed to bind.
981        receipt_log_id: String,
982        /// The tlog `logId.keyId` values declared in the trusted root,
983        /// preserved verbatim from the JSON so operators can correlate
984        /// with the Sigstore TUF history. Empty when every tlog
985        /// omitted `logId.keyId`.
986        tlog_log_ids: Vec<String>,
987    },
988    /// The selected tlog declared no `rawBytes` field for its
989    /// `publicKey`. The verifier refuses to fall back to inferring keys
990    /// from other channels.
991    #[error("trusted_root.json tlog publicKey is missing rawBytes")]
992    MissingRawBytes,
993    /// `rawBytes` was present but did not decode as base64.
994    #[error("trusted_root.json tlog publicKey rawBytes is not base64: {reason}")]
995    Base64 {
996        /// Underlying base64 decode failure.
997        reason: String,
998    },
999    /// The decoded DER bytes did not parse as a P-256 SubjectPublicKeyInfo.
1000    #[error("trusted_root.json tlog publicKey did not decode as P-256 SPKI: {reason}")]
1001    DecodeKey {
1002        /// Underlying p256/pkcs8 failure.
1003        reason: String,
1004    },
1005}
1006
1007/// Project a [`TransparencyLogInstance::log_id`] JSON value to its
1008/// `keyId` string, or `None` if the tlog declared no `logId.keyId`.
1009///
1010/// Sigstore's protobuf-go JSON encodes `LogId` as
1011/// `{"keyId": "<base64>"}`; we round-trip via `serde_json::Value` to
1012/// avoid binding the field shape on the trust-channel path. Returns
1013/// `None` for missing / non-string `keyId` rather than failing closed
1014/// here — the no-match branch in [`TrustedRoot::rekor_verifying_key`]
1015/// surfaces the verifier-visible refusal.
1016fn tlog_log_id_key_id(log_id: &serde_json::Value) -> Option<String> {
1017    log_id
1018        .as_object()?
1019        .get("keyId")
1020        .and_then(serde_json::Value::as_str)
1021        .map(str::to_string)
1022}
1023
1024/// Match a trusted-root tlog `logId.keyId` against a Rekor receipt
1025/// `body.logID`. Returns `true` when the two encode the same byte
1026/// sequence.
1027///
1028/// Decoding rules (BH-3 fix + post-fd779d7 base64-url widening):
1029/// - The tlog side is canonically base64 per Sigstore's protobuf-JSON
1030///   shape. Both STANDARD (`+/`, padded) and URL-safe (`-_`, padded
1031///   or unpadded) alphabets are accepted, because proto3 JSON encodes
1032///   binary fields as URL-safe base64 (RFC 7515 §2) — the doctrine
1033///   reference cited by the previous docstring. Raw bytes equality
1034///   is also accepted for fixtures that pre-date the BH-3 fix and
1035///   stash a human-readable string.
1036/// - The receipt side is canonically lowercase hex per Rekor's REST
1037///   API; we also accept STANDARD and URL-safe base64 for receipt
1038///   sources that emit the protobuf-JSON shape, and raw bytes
1039///   equality for fixtures.
1040///
1041/// Match holds when ANY candidate byte vector on the tlog side equals
1042/// ANY candidate byte vector on the receipt side.
1043fn log_id_matches(tlog_key_id: &str, receipt_log_id: &str) -> bool {
1044    use base64::Engine;
1045    let b64_std = base64::engine::general_purpose::STANDARD;
1046    let b64_url = base64::engine::general_purpose::URL_SAFE;
1047    let b64_url_nopad = base64::engine::general_purpose::URL_SAFE_NO_PAD;
1048
1049    let mut tlog_candidates: Vec<Vec<u8>> = Vec::with_capacity(4);
1050    if let Ok(bytes) = b64_std.decode(tlog_key_id) {
1051        tlog_candidates.push(bytes);
1052    }
1053    if let Ok(bytes) = b64_url.decode(tlog_key_id) {
1054        tlog_candidates.push(bytes);
1055    }
1056    if let Ok(bytes) = b64_url_nopad.decode(tlog_key_id) {
1057        tlog_candidates.push(bytes);
1058    }
1059    tlog_candidates.push(tlog_key_id.as_bytes().to_vec());
1060
1061    let mut receipt_candidates: Vec<Vec<u8>> = Vec::with_capacity(5);
1062    if let Ok(bytes) = decode_lowercase_hex(receipt_log_id) {
1063        receipt_candidates.push(bytes);
1064    }
1065    if let Ok(bytes) = b64_std.decode(receipt_log_id) {
1066        receipt_candidates.push(bytes);
1067    }
1068    if let Ok(bytes) = b64_url.decode(receipt_log_id) {
1069        receipt_candidates.push(bytes);
1070    }
1071    if let Ok(bytes) = b64_url_nopad.decode(receipt_log_id) {
1072        receipt_candidates.push(bytes);
1073    }
1074    receipt_candidates.push(receipt_log_id.as_bytes().to_vec());
1075
1076    for tlog_bytes in &tlog_candidates {
1077        for receipt_bytes in &receipt_candidates {
1078            if tlog_bytes == receipt_bytes {
1079                return true;
1080            }
1081        }
1082    }
1083    false
1084}
1085
1086/// Decode a lowercase ASCII hex string to bytes. Returns `Err(())` on
1087/// any non-hex character or odd length. Kept local to this module so
1088/// the BH-3 matcher does not pull a hex decoder transitively into the
1089/// trust-channel path.
1090fn decode_lowercase_hex(s: &str) -> Result<Vec<u8>, ()> {
1091    if s.is_empty() || s.len() % 2 != 0 {
1092        return Err(());
1093    }
1094    let bytes = s.as_bytes();
1095    let mut out = Vec::with_capacity(s.len() / 2);
1096    for chunk in bytes.chunks_exact(2) {
1097        let hi = hex_nibble(chunk[0])?;
1098        let lo = hex_nibble(chunk[1])?;
1099        out.push((hi << 4) | lo);
1100    }
1101    Ok(out)
1102}
1103
1104fn hex_nibble(b: u8) -> Result<u8, ()> {
1105    match b {
1106        b'0'..=b'9' => Ok(b - b'0'),
1107        b'a'..=b'f' => Ok(10 + b - b'a'),
1108        _ => Err(()),
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use chrono::TimeZone;
1116    use tempfile::tempdir;
1117
1118    #[test]
1119    fn embedded_root_parses_and_has_rekor_tlogs() {
1120        let root = TrustedRoot::embedded().expect("embedded root parses");
1121        assert!(root
1122            .media_type
1123            .starts_with("application/vnd.dev.sigstore.trustedroot+json"));
1124        assert!(!root.tlogs.is_empty(), "embedded root must declare tlogs");
1125        let signed_at = root
1126            .metadata_signed_at()
1127            .expect("embedded root has a validity start");
1128        // Sanity floor: the embedded snapshot must carry a tlog
1129        // activation after the Sigstore v1 launch.
1130        let v1_launch = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap();
1131        assert!(
1132            signed_at >= v1_launch,
1133            "tlog start {signed_at} predates v1 launch"
1134        );
1135    }
1136
1137    fn embedded_snapshot_anchor_date() -> DateTime<Utc> {
1138        NaiveDate::parse_from_str(EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE, "%Y-%m-%d")
1139            .expect("EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE parses")
1140            .and_hms_opt(0, 0, 0)
1141            .expect("midnight is valid")
1142            .and_utc()
1143    }
1144
1145    #[test]
1146    fn fresh_root_is_not_stale_within_30_days_against_embedded_snapshot_anchor() {
1147        let root = TrustedRoot::embedded().unwrap();
1148        let anchor = embedded_snapshot_anchor_date();
1149        let now = anchor + chrono::Duration::days(29);
1150        let stale = root
1151            .is_stale_at(
1152                now,
1153                DEFAULT_MAX_TRUST_ROOT_AGE,
1154                TrustRootStalenessAnchor::embedded_snapshot(),
1155            )
1156            .expect("anchor resolves");
1157        assert!(!stale);
1158    }
1159
1160    #[test]
1161    fn root_is_stale_after_31_days_against_embedded_snapshot_anchor() {
1162        let root = TrustedRoot::embedded().unwrap();
1163        let anchor = embedded_snapshot_anchor_date();
1164        let now = anchor + chrono::Duration::days(31);
1165        let stale = root
1166            .is_stale_at(
1167                now,
1168                DEFAULT_MAX_TRUST_ROOT_AGE,
1169                TrustRootStalenessAnchor::embedded_snapshot(),
1170            )
1171            .expect("anchor resolves");
1172        assert!(stale);
1173    }
1174
1175    #[test]
1176    fn cache_mtime_anchor_resolves_to_file_mtime() {
1177        let tmp = tempdir().unwrap();
1178        let path = tmp.path().join("trusted_root.json");
1179        let root = TrustedRoot::embedded().unwrap();
1180        root.write_atomic(&path).unwrap();
1181        let now = Utc::now();
1182        let old_mtime = now - chrono::Duration::days(40);
1183        let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
1184            + std::time::Duration::from_secs(old_mtime.timestamp() as u64);
1185        std::fs::File::options()
1186            .write(true)
1187            .open(&path)
1188            .unwrap()
1189            .set_modified(mtime_systemtime)
1190            .expect("set mtime");
1191        let stale = root
1192            .is_stale_at(
1193                now,
1194                DEFAULT_MAX_TRUST_ROOT_AGE,
1195                TrustRootStalenessAnchor::cache_file_mtime(&path),
1196            )
1197            .expect("anchor resolves");
1198        assert!(stale, "40-day-old cache must be stale");
1199    }
1200
1201    #[test]
1202    fn cache_mtime_anchor_fails_loud_when_cache_missing() {
1203        let tmp = tempdir().unwrap();
1204        let path = tmp.path().join("does-not-exist.json");
1205        let root = TrustedRoot::embedded().unwrap();
1206        let err = root
1207            .is_stale_at(
1208                Utc::now(),
1209                DEFAULT_MAX_TRUST_ROOT_AGE,
1210                TrustRootStalenessAnchor::cache_file_mtime(&path),
1211            )
1212            .unwrap_err();
1213        assert!(matches!(err, TrustRootStalenessError::CacheMetadata { .. }));
1214    }
1215
1216    // -----------------------------------------------------------------
1217    // Prior F3 closure
1218    // (`docs/reviews/CODE_REVIEW_2026-05-12_post_fd779d7.md`):
1219    // a future-dated cache `mtime` (e.g. via `touch -d 2099-01-01`)
1220    // used to bypass the freshness gate because `now - mtime` went
1221    // negative and the `> max_age` comparison stayed false
1222    // indefinitely. `is_stale_at` now refuses with
1223    // `TrustRootStalenessError::CacheFutureDated` when the anchor
1224    // is more than [`TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE`]
1225    // ahead of `now`.
1226    // -----------------------------------------------------------------
1227
1228    fn write_cache_with_systemtime(path: &Path, mtime: std::time::SystemTime) {
1229        let root = TrustedRoot::embedded().unwrap();
1230        root.write_atomic(path).unwrap();
1231        std::fs::File::options()
1232            .write(true)
1233            .open(path)
1234            .unwrap()
1235            .set_modified(mtime)
1236            .expect("set mtime");
1237    }
1238
1239    #[test]
1240    fn cache_mtime_anchor_refuses_far_future_dated_cache() {
1241        let tmp = tempdir().unwrap();
1242        let path = tmp.path().join("trusted_root.json");
1243        let now = Utc::now();
1244        // 70 years ahead — the original Bug 3 attack shape
1245        // (`touch -d 2099-01-01`).
1246        let future = now + chrono::Duration::days(365 * 70);
1247        let future_systemtime = std::time::SystemTime::UNIX_EPOCH
1248            + std::time::Duration::from_secs(future.timestamp() as u64);
1249        write_cache_with_systemtime(&path, future_systemtime);
1250        let root = TrustedRoot::embedded().unwrap();
1251        let err = root
1252            .is_stale_at(
1253                now,
1254                DEFAULT_MAX_TRUST_ROOT_AGE,
1255                TrustRootStalenessAnchor::cache_file_mtime(&path),
1256            )
1257            .unwrap_err();
1258        match err {
1259            TrustRootStalenessError::CacheFutureDated {
1260                anchor,
1261                tolerance_seconds,
1262                ..
1263            } => {
1264                assert!(
1265                    anchor.contains("cache_file_mtime"),
1266                    "diagnostic label must name the cache_file_mtime anchor: got {anchor}"
1267                );
1268                assert_eq!(
1269                    tolerance_seconds,
1270                    TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE.as_secs(),
1271                );
1272            }
1273            other => panic!("expected CacheFutureDated, got {other:?}"),
1274        }
1275    }
1276
1277    #[test]
1278    fn cache_mtime_anchor_tolerates_small_clock_skew() {
1279        // The future-dated guard allows a small tolerance for legitimate
1280        // wall-clock skew between an NFS server and the host. An mtime
1281        // that is `TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE / 2` ahead
1282        // of `now` must still pass.
1283        let tmp = tempdir().unwrap();
1284        let path = tmp.path().join("trusted_root.json");
1285        let now = Utc::now();
1286        let half_tolerance = chrono::Duration::seconds(
1287            (TRUSTED_ROOT_CACHE_FUTURE_MTIME_TOLERANCE.as_secs() / 2) as i64,
1288        );
1289        let skewed = now + half_tolerance;
1290        let skewed_systemtime = std::time::SystemTime::UNIX_EPOCH
1291            + std::time::Duration::from_secs(skewed.timestamp() as u64);
1292        write_cache_with_systemtime(&path, skewed_systemtime);
1293        let root = TrustedRoot::embedded().unwrap();
1294        let stale = root
1295            .is_stale_at(
1296                now,
1297                DEFAULT_MAX_TRUST_ROOT_AGE,
1298                TrustRootStalenessAnchor::cache_file_mtime(&path),
1299            )
1300            .expect("small skew must not trigger the future-dated guard");
1301        // A fresh-mtime cache must NOT be reported as stale.
1302        assert!(!stale);
1303    }
1304
1305    // ---------------------------------------------------------------
1306    // F3 closure (new finding, adversarial review post-fd779d7):
1307    // chrono Duration arithmetic near i64 overflow can produce
1308    // unexpected values. `is_stale_at` now uses saturating millisecond
1309    // arithmetic instead of `now - anchor_ts`. Drive the boundary by
1310    // constructing an anchor_ts that is exactly at `now`; the saturated
1311    // age_ms must be 0 and the root must NOT be reported as stale.
1312    // ---------------------------------------------------------------
1313    #[test]
1314    fn is_stale_at_saturates_instead_of_panic_on_backward_clock() {
1315        // Scenario: anchor_ts == now (age_ms == 0 after saturating_sub).
1316        // Stale must return false regardless of max_age.
1317        let root = TrustedRoot::embedded().unwrap();
1318        let anchor_date = embedded_snapshot_anchor_date();
1319        // Set now == anchor so the saturated age is 0 ms.
1320        let now = anchor_date;
1321        let stale = root
1322            .is_stale_at(
1323                now,
1324                DEFAULT_MAX_TRUST_ROOT_AGE,
1325                TrustRootStalenessAnchor::embedded_snapshot(),
1326            )
1327            .expect("anchor resolves");
1328        assert!(
1329            !stale,
1330            "age_ms == 0 must never be stale; saturating_sub must clamp at 0 not wrap"
1331        );
1332
1333        // Edge: anchor very slightly in the past (1 ms) — within the
1334        // max-age window — must also be not-stale.
1335        let just_before = anchor_date - chrono::Duration::milliseconds(1);
1336        let stale_edge = root
1337            .is_stale_at(
1338                anchor_date,
1339                DEFAULT_MAX_TRUST_ROOT_AGE,
1340                TrustRootStalenessAnchor::EmbeddedSnapshotDate {
1341                    snapshot_iso_date: EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE,
1342                },
1343            )
1344            .expect("anchor resolves");
1345        // `anchor_date - anchor_date` == 0 ms < 30d → not stale
1346        assert!(!stale_edge);
1347        let _ = just_before; // suppress unused-variable warning
1348    }
1349
1350    #[test]
1351    fn trusted_root_cache_future_dated_invariant_token_is_stable() {
1352        assert_eq!(
1353            STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED,
1354            "audit.verify.trusted_root.cache_future_dated"
1355        );
1356    }
1357
1358    // 2026-05-15 portfolio-extension wave 2 invariant-coverage audit: pin the
1359    // stable invariant strings emitted by the three trusted-root staleness
1360    // branches so a string-level dashboards-and-grep regression is caught at
1361    // unit-test time, not from a flaky end-to-end consumer. The actual emit
1362    // sites live in `cortex-cli`'s audit verify path
1363    // (`crates/cortex-cli/src/cmd/audit.rs`), but the invariant constants are
1364    // the carrier identity that downstream wrappers key on.
1365    #[test]
1366    fn trusted_root_staleness_invariant_tokens_are_stable() {
1367        assert_eq!(
1368            TRUSTED_ROOT_STALE_INVARIANT,
1369            "audit.verify.trusted_root.stale_beyond_max_age"
1370        );
1371        assert_eq!(
1372            TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT,
1373            "audit.verify.trusted_root.snapshot_stale"
1374        );
1375        assert_eq!(
1376            TRUSTED_ROOT_CACHE_STALE_INVARIANT,
1377            "audit.verify.trusted_root.cache_stale"
1378        );
1379        // The branch-specific tokens MUST NOT collide with the generic
1380        // back-compat token; consumers route on the branch-specific token
1381        // FIRST and fall back to the generic one for legacy dashboards.
1382        assert_ne!(
1383            TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT, TRUSTED_ROOT_STALE_INVARIANT,
1384            "snapshot_stale token must differ from the generic back-compat token"
1385        );
1386        assert_ne!(
1387            TRUSTED_ROOT_CACHE_STALE_INVARIANT, TRUSTED_ROOT_STALE_INVARIANT,
1388            "cache_stale token must differ from the generic back-compat token"
1389        );
1390        assert_ne!(
1391            TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT, TRUSTED_ROOT_CACHE_STALE_INVARIANT,
1392            "snapshot_stale and cache_stale tokens must be distinct branches"
1393        );
1394    }
1395
1396    // 2026-05-15 wave-2 invariant-coverage audit: drive the embedded-snapshot
1397    // staleness predicate to fire, then assert the invariant constant the
1398    // audit-verify CLI emit site keys on
1399    // (`crates/cortex-cli/src/cmd/audit.rs::1138`) carries the stable token.
1400    // CLI-level integration emit requires `Utc::now() > snapshot+30d`, which
1401    // is not driveable from a deterministic test; this unit test pins the
1402    // emit precondition (is_stale returns true) AND the surface string the
1403    // CLI feeds to `eprintln!` so a string-level regression cannot land
1404    // silently.
1405    #[test]
1406    fn embedded_snapshot_stale_emit_precondition_pins_invariant_token() {
1407        let root = TrustedRoot::embedded().expect("embedded root parses");
1408        let anchor = embedded_snapshot_anchor_date();
1409        // Drive the same condition the CLI audit-verify emit site checks:
1410        // `TrustedRoot::embedded().is_stale(now, embedded_snapshot())`. Use
1411        // a far-future `now` so the staleness predicate must fire even when
1412        // the embedded snapshot has just been refreshed.
1413        let now = anchor + chrono::Duration::days(31);
1414        let stale = root
1415            .is_stale(now, TrustRootStalenessAnchor::embedded_snapshot())
1416            .expect("anchor resolves");
1417        assert!(
1418            stale,
1419            "embedded snapshot must be stale once now > snapshot+30d"
1420        );
1421        // Pin the surface string the CLI emit site feeds to `eprintln!` so
1422        // a refactor that renames the constant breaks at unit-test time.
1423        assert_eq!(
1424            TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT, "audit.verify.trusted_root.snapshot_stale",
1425            "CLI audit-verify emit site keys on this exact stable token"
1426        );
1427        // The CLI emit site surfaces BOTH the branch-specific token and the
1428        // legacy generic token so existing dashboards keep working. Pin
1429        // that companion token here as well.
1430        assert_eq!(
1431            TRUSTED_ROOT_STALE_INVARIANT,
1432            "audit.verify.trusted_root.stale_beyond_max_age"
1433        );
1434    }
1435
1436    // 2026-05-15 wave-2 invariant-coverage audit, negative companion: when
1437    // the embedded snapshot is fresh (now within snapshot+30d), the
1438    // staleness predicate MUST return false so the CLI emit site does NOT
1439    // surface the `snapshot_stale` invariant. Pairs with
1440    // `embedded_snapshot_stale_emit_precondition_pins_invariant_token`.
1441    #[test]
1442    fn embedded_snapshot_not_stale_emit_precondition_holds_within_30d() {
1443        let root = TrustedRoot::embedded().expect("embedded root parses");
1444        let anchor = embedded_snapshot_anchor_date();
1445        let now = anchor + chrono::Duration::days(29);
1446        let stale = root
1447            .is_stale(now, TrustRootStalenessAnchor::embedded_snapshot())
1448            .expect("anchor resolves");
1449        assert!(
1450            !stale,
1451            "embedded snapshot must NOT be stale within snapshot+30d; \
1452             CLI emit site for `{TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT}` must stay silent"
1453        );
1454    }
1455
1456    #[test]
1457    fn parse_rejects_wrong_media_type() {
1458        let body = serde_json::json!({
1459            "mediaType": "application/json",
1460            "tlogs": [],
1461        });
1462        let err = TrustedRoot::parse_bytes(body.to_string().as_bytes()).unwrap_err();
1463        assert!(matches!(err, TrustedRootParseError::WrongMediaType { .. }));
1464    }
1465
1466    #[test]
1467    fn parse_rejects_empty_tlogs() {
1468        let body = serde_json::json!({
1469            "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1",
1470            "tlogs": [],
1471        });
1472        let err = TrustedRoot::parse_bytes(body.to_string().as_bytes()).unwrap_err();
1473        assert_eq!(err, TrustedRootParseError::EmptyTlogs);
1474    }
1475
1476    #[test]
1477    fn parse_rejects_tlog_without_validity_start() {
1478        let body = serde_json::json!({
1479            "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1",
1480            "tlogs": [{
1481                "baseUrl": "https://rekor.sigstore.dev",
1482                "publicKey": {
1483                    "rawBytes": "AAAA",
1484                    "validFor": {}
1485                }
1486            }]
1487        });
1488        let err = TrustedRoot::parse_bytes(body.to_string().as_bytes()).unwrap_err();
1489        assert!(matches!(
1490            err,
1491            TrustedRootParseError::MissingValidityStart { tlog_index: 0 }
1492        ));
1493    }
1494
1495    #[test]
1496    fn load_cached_returns_none_for_missing_path() {
1497        let tmp = tempdir().unwrap();
1498        let path = tmp.path().join("does-not-exist.json");
1499        let result = TrustedRoot::load_cached(&path).expect("missing path returns Ok(None)");
1500        assert!(result.is_none());
1501    }
1502
1503    #[test]
1504    fn load_cached_returns_some_for_valid_file() {
1505        let tmp = tempdir().unwrap();
1506        let path = tmp.path().join("trusted_root.json");
1507        let root = TrustedRoot::embedded().unwrap();
1508        root.write_atomic(&path).unwrap();
1509        let loaded = TrustedRoot::load_cached(&path).unwrap().unwrap();
1510        assert_eq!(loaded.media_type, root.media_type);
1511    }
1512
1513    #[test]
1514    fn write_atomic_replaces_existing_file() {
1515        let tmp = tempdir().unwrap();
1516        let path = tmp.path().join("trusted_root.json");
1517        std::fs::write(&path, b"old garbage").unwrap();
1518        let root = TrustedRoot::embedded().unwrap();
1519        root.write_atomic(&path).unwrap();
1520        let loaded = TrustedRoot::load_cached(&path).unwrap().unwrap();
1521        assert_eq!(loaded.media_type, root.media_type);
1522    }
1523
1524    #[test]
1525    fn active_trusted_root_prefers_cache_then_embedded() {
1526        let tmp = tempdir().unwrap();
1527        let path = tmp.path().join("trusted_root.json");
1528
1529        let active = active_trusted_root(Some(&path)).expect("missing cache falls back");
1530        assert_eq!(active.status, EMBEDDED_ROOT_STATUS);
1531        assert_eq!(active.cache_path.as_deref(), Some(path.as_path()));
1532
1533        let root = TrustedRoot::embedded().unwrap();
1534        root.write_atomic(&path).unwrap();
1535        let active = active_trusted_root(Some(&path)).unwrap();
1536        assert_eq!(active.status, CACHED_ROOT_STATUS);
1537    }
1538
1539    #[test]
1540    fn missing_embedded_snapshot_date_constant_fails_loud() {
1541        // The anchor-resolution path surfaces a typed error rather than
1542        // silently treating the root as stale when the constant is
1543        // malformed. Build-time guard.
1544        let root = TrustedRoot::embedded().unwrap();
1545        let anchor = TrustRootStalenessAnchor::EmbeddedSnapshotDate {
1546            snapshot_iso_date: "not-a-date",
1547        };
1548        let err = root
1549            .is_stale_at(Utc::now(), DEFAULT_MAX_TRUST_ROOT_AGE, anchor)
1550            .unwrap_err();
1551        assert!(matches!(
1552            err,
1553            TrustRootStalenessError::MalformedEmbeddedSnapshotDate { .. }
1554        ));
1555    }
1556
1557    // ---------------------------------------------------------------
1558    // F2 pin: embedded snapshot date constant must match the embedded
1559    // `include_bytes!` filename.
1560    //
1561    // The constant is now derived at build time by `build.rs` from the
1562    // filename. These tests are the runtime restatement of the
1563    // invariant — they enumerate `src/external_sink/embedded/*.json` at
1564    // test time and assert exactly one matches
1565    // `sigstore_trusted_root_YYYY-MM-DD.json` AND the embedded date
1566    // string equals [`EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`].
1567    //
1568    // `cargo test` runs with CWD = the crate root, so the relative path
1569    // resolves identically to the build script's `read_dir`.
1570    // ---------------------------------------------------------------
1571
1572    fn discover_embedded_snapshot_filename() -> (String, String) {
1573        // Returns (filename, date).
1574        let dir = std::path::Path::new("src/external_sink/embedded");
1575        let mut matches: Vec<(String, String)> = Vec::new();
1576        for entry in std::fs::read_dir(dir)
1577            .expect("embedded directory readable from crate-root CWD during cargo test")
1578        {
1579            let entry = entry.expect("read_dir entry");
1580            let name_os = entry.file_name();
1581            let name = match name_os.to_str() {
1582                Some(s) => s.to_string(),
1583                None => continue,
1584            };
1585            if let Some(date_and_suffix) = name.strip_prefix("sigstore_trusted_root_") {
1586                if let Some(date) = date_and_suffix.strip_suffix(".json") {
1587                    matches.push((name.clone(), date.to_string()));
1588                }
1589            }
1590        }
1591        assert_eq!(
1592            matches.len(),
1593            1,
1594            "embedded directory must contain exactly one sigstore_trusted_root_YYYY-MM-DD.json, found: {matches:?}"
1595        );
1596        matches.into_iter().next().unwrap()
1597    }
1598
1599    #[test]
1600    fn embedded_snapshot_date_constant_matches_embedded_filename() {
1601        // F2 pin: the build-derived constant equals the date encoded in
1602        // the on-disk filename. If the filename is bumped without the
1603        // build rerunning (or vice versa), this assert fails.
1604        let (filename, date) = discover_embedded_snapshot_filename();
1605        assert_eq!(
1606            EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE, date,
1607            "EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE = `{EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE}` but embedded filename `{filename}` encodes date `{date}`"
1608        );
1609    }
1610
1611    #[test]
1612    fn embedded_filename_pin_is_documented() {
1613        // Inert pin: documents that the constant is derived from the
1614        // filename via `build.rs`. If `EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE`
1615        // ever stops compiling, this test name is the breadcrumb to the
1616        // build script.
1617        assert!(
1618            EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE.len() == 10
1619                && EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE.as_bytes()[4] == b'-'
1620                && EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE.as_bytes()[7] == b'-',
1621            "EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE must be YYYY-MM-DD; got `{EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE}`"
1622        );
1623    }
1624
1625    // ---------------------------------------------------------------
1626    // BH-3: rekor_verifying_key MUST be log-bound (Cosign
1627    // GHSA-whqx-f9j3-ch6m mitigation). Test the matcher and the
1628    // selector contract directly.
1629    // ---------------------------------------------------------------
1630
1631    /// Synthesize a trust root with a chosen log_id keyId for testing
1632    /// the log-bound selector. Uses a deterministic ECDSA P-256 PEM so
1633    /// the test does not need a fixture file.
1634    fn synth_trust_root_with_log_id(log_id_key_id: &str, signed_at: DateTime<Utc>) -> TrustedRoot {
1635        // A throwaway ECDSA P-256 public key in PEM form. Generated
1636        // offline and pinned here so the test has no live key-gen
1637        // dependency. The bytes do not need to verify any signature —
1638        // we only exercise the selector logic.
1639        const PEM: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE38RLcWzg03IUsJCRqVqTfrIsrZ16\n9knDnAIDtWs7oSxtQ7vlFDJwBMsFiyufFcxRRXotLozXlslNcujXkKAdzQ==\n-----END PUBLIC KEY-----\n";
1640        TrustedRoot::from_fixture_rekor_pem(PEM, signed_at, log_id_key_id)
1641            .expect("fixture key parses")
1642    }
1643
1644    #[test]
1645    fn rekor_verifying_key_returns_key_for_matching_logid() {
1646        let signed_at = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
1647        let root = synth_trust_root_with_log_id("my-fixture-log-id", signed_at);
1648        // Same raw string on both sides: matches via the raw-bytes
1649        // fallback branch.
1650        root.rekor_verifying_key("my-fixture-log-id")
1651            .expect("matching log_id resolves to a verifying key");
1652    }
1653
1654    #[test]
1655    fn rekor_verifying_key_refuses_when_no_tlog_matches_receipt_logid() {
1656        let signed_at = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
1657        let root = synth_trust_root_with_log_id("trust-root-log-id", signed_at);
1658        let err = root
1659            .rekor_verifying_key("a-different-receipt-log-id")
1660            .unwrap_err();
1661        match err {
1662            TrustedRootKeyError::TlogLogIdNoMatch {
1663                invariant,
1664                receipt_log_id,
1665                tlog_log_ids,
1666            } => {
1667                assert_eq!(invariant, REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT);
1668                assert_eq!(receipt_log_id, "a-different-receipt-log-id");
1669                assert_eq!(tlog_log_ids, vec!["trust-root-log-id".to_string()]);
1670            }
1671            other => panic!("expected TlogLogIdNoMatch, got {other:?}"),
1672        }
1673    }
1674
1675    #[test]
1676    fn rekor_verifying_key_does_not_silently_fall_back_to_latest_when_logid_unknown() {
1677        // Construct a multi-tlog trust root: a recent (would-win the
1678        // "latest activation" race) one with a non-matching log_id and
1679        // an older one with the matching log_id. The selector MUST
1680        // pick the matching one — and if the matching one is removed,
1681        // it MUST refuse, NOT silently fall back to "latest".
1682        let old = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap();
1683        let recent = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
1684        let matching_log_id = "matching-log-id";
1685        let _decoy_log_id = "decoy-but-newer-log-id";
1686
1687        // First, baseline: matching log_id present + decoy → matches,
1688        // verifying key returned.
1689        let mut root = synth_trust_root_with_log_id(matching_log_id, old);
1690        // Append the decoy tlog (recent activation, different log_id).
1691        const PEM2: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE38RLcWzg03IUsJCRqVqTfrIsrZ16\n9knDnAIDtWs7oSxtQ7vlFDJwBMsFiyufFcxRRXotLozXlslNcujXkKAdzQ==\n-----END PUBLIC KEY-----\n";
1692        let decoy_root =
1693            TrustedRoot::from_fixture_rekor_pem(PEM2, recent, "decoy-but-newer-log-id")
1694                .expect("decoy tlog parses");
1695        root.tlogs.extend(decoy_root.tlogs);
1696        // Matching path: still resolves.
1697        root.rekor_verifying_key(matching_log_id)
1698            .expect("log-bound selector picks the matching (older) tlog despite a newer decoy");
1699
1700        // Now: drop the matching tlog, leave only the decoy. Receipt
1701        // logId still references the now-absent log_id. The selector
1702        // MUST refuse, NOT fall back to the decoy on activation date.
1703        let only_decoy = TrustedRoot {
1704            media_type: root.media_type.clone(),
1705            tlogs: root
1706                .tlogs
1707                .iter()
1708                .filter(|tlog| tlog_log_id_key_id(&tlog.log_id).as_deref() != Some(matching_log_id))
1709                .cloned()
1710                .collect(),
1711            certificate_authorities: root.certificate_authorities.clone(),
1712            ctlogs: root.ctlogs.clone(),
1713            timestamp_authorities: root.timestamp_authorities.clone(),
1714        };
1715        assert_eq!(
1716            only_decoy.tlogs.len(),
1717            1,
1718            "decoy-only root must have exactly one tlog"
1719        );
1720        let err = only_decoy.rekor_verifying_key(matching_log_id).unwrap_err();
1721        assert!(
1722            matches!(
1723                err,
1724                TrustedRootKeyError::TlogLogIdNoMatch {
1725                    invariant: REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT,
1726                    ..
1727                }
1728            ),
1729            "selector silently fell back to a non-matching tlog instead of refusing; got {err:?}"
1730        );
1731    }
1732
1733    #[test]
1734    fn log_id_matches_decodes_hex_against_base64() {
1735        // Realistic Sigstore case: the trust root's logId.keyId is
1736        // base64-encoded raw bytes; the Rekor receipt's logID is the
1737        // same bytes in lowercase hex. The matcher must equate them.
1738        use base64::Engine;
1739        let key_id_bytes: [u8; 32] = [
1740            0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55,
1741            0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x10, 0x20, 0x30, 0x40,
1742            0x50, 0x60, 0x70, 0x80,
1743        ];
1744        let b64 = base64::engine::general_purpose::STANDARD.encode(key_id_bytes);
1745        let hex: String = key_id_bytes.iter().map(|b| format!("{b:02x}")).collect();
1746        assert!(log_id_matches(&b64, &hex));
1747        // And a deliberate mismatch must not match.
1748        let mut tampered = key_id_bytes;
1749        tampered[0] ^= 0x01;
1750        let tampered_hex: String = tampered.iter().map(|b| format!("{b:02x}")).collect();
1751        assert!(!log_id_matches(&b64, &tampered_hex));
1752    }
1753
1754    /// post-fd779d7 LOW closure: proto3-JSON receipts encode binary
1755    /// fields with URL-safe base64 (RFC 7515 §2 — `-_` alphabet),
1756    /// not STANDARD base64 (`+/`). The matcher must accept both
1757    /// alphabets so a tlog `keyId` carried over a protobuf-JSON wire
1758    /// path matches a receipt `logID` carried over either wire path.
1759    /// Bytes containing `0x3e` (`>` in raw) encode to a `+` (STANDARD)
1760    /// or a `-` (URL-safe), which makes them a faithful regression
1761    /// fixture for the difference between the two alphabets.
1762    #[test]
1763    fn log_id_matches_accepts_url_safe_base64_both_sides() {
1764        use base64::Engine;
1765        // Bytes chosen so STANDARD and URL-safe encodings differ
1766        // (the `0x3e`/`0x3f` nibble combos exercise both `+/` and
1767        // `-_` alphabets distinctly).
1768        let key_id_bytes: [u8; 32] = [
1769            0xfb, 0xff, 0xbf, 0xfe, 0x3e, 0x3f, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
1770            0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x10, 0x20, 0x30, 0x40, 0x50,
1771            0x60, 0x70, 0x80, 0x90,
1772        ];
1773        let b64_std = base64::engine::general_purpose::STANDARD.encode(key_id_bytes);
1774        let b64_url = base64::engine::general_purpose::URL_SAFE.encode(key_id_bytes);
1775        let b64_url_nopad = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key_id_bytes);
1776        let hex: String = key_id_bytes.iter().map(|b| format!("{b:02x}")).collect();
1777
1778        // Sanity: STANDARD and URL-safe encodings must actually differ
1779        // for these bytes (otherwise this test would be vacuous).
1780        assert_ne!(
1781            b64_std, b64_url,
1782            "fixture bytes were chosen so the two alphabets differ; if they match the test is vacuous",
1783        );
1784
1785        // tlog side STANDARD vs receipt side URL-safe.
1786        assert!(log_id_matches(&b64_std, &b64_url));
1787        // tlog side URL-safe vs receipt side STANDARD.
1788        assert!(log_id_matches(&b64_url, &b64_std));
1789        // tlog side URL-safe (no padding) vs receipt side hex.
1790        assert!(log_id_matches(&b64_url_nopad, &hex));
1791        // tlog side URL-safe vs receipt side URL-safe (no padding).
1792        assert!(log_id_matches(&b64_url, &b64_url_nopad));
1793
1794        // Tampered URL-safe receipt still refuses.
1795        let mut tampered = key_id_bytes;
1796        tampered[0] ^= 0x01;
1797        let tampered_url = base64::engine::general_purpose::URL_SAFE.encode(tampered);
1798        assert!(!log_id_matches(&b64_std, &tampered_url));
1799    }
1800}