pub mod ots;
pub mod rekor;
pub mod trusted_root;
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::anchor::{verify_anchor, AnchorParseError, AnchorVerifyError, LedgerAnchor};
use crate::sha256::sha256_hex;
pub use trusted_root::{
active_trusted_root, ActiveTrustedRoot, TransparencyLogInstance, TransparencyLogPublicKey,
TrustRootStalenessAnchor, TrustRootStalenessError, TrustedRoot, TrustedRootIoError,
TrustedRootKeyError, TrustedRootParseError, ValidityPeriod, CACHED_ROOT_STATUS,
DEFAULT_MAX_TRUST_ROOT_AGE, EMBEDDED_ROOT_STATUS, EMBEDDED_TRUSTED_ROOT_SNAPSHOT_DATE,
REKOR_TRUSTED_ROOT_TLOG_LOGID_NO_MATCH_INVARIANT, TRUSTED_ROOT_CACHE_STALE_INVARIANT,
TRUSTED_ROOT_JSON, TRUSTED_ROOT_PARSE_INVARIANT, TRUSTED_ROOT_SNAPSHOT_STALE_INVARIANT,
TRUSTED_ROOT_STALE_INVARIANT,
};
pub const EXTERNAL_RECEIPT_FORMAT_HEADER_V1: &str = "# cortex-external-anchor-receipt-format: 1";
const SHA256_HEX_LEN: usize = 64;
const BLAKE3_HEX_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExternalSink {
None,
Rekor,
OpenTimestamps,
}
impl ExternalSink {
#[must_use]
pub const fn as_wire_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Rekor => "rekor",
Self::OpenTimestamps => "opentimestamps",
}
}
pub fn from_wire_str(value: &str) -> Result<Self, ExternalReceiptParseError> {
match value {
"none" => Ok(Self::None),
"rekor" => Ok(Self::Rekor),
"opentimestamps" => Ok(Self::OpenTimestamps),
other => Err(ExternalReceiptParseError::UnknownSink {
observed: other.to_string(),
}),
}
}
}
impl fmt::Display for ExternalSink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_wire_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalReceipt {
pub sink: ExternalSink,
pub anchor_text_sha256: String,
pub anchor_event_count: u64,
pub anchor_chain_head_hash: String,
pub submitted_at: DateTime<Utc>,
pub sink_endpoint: String,
pub receipt: serde_json::Value,
}
impl ExternalReceipt {
pub fn to_record_text(&self) -> Result<String, ExternalReceiptParseError> {
let body = serde_json::to_string(self).map_err(|source| {
ExternalReceiptParseError::MalformedBody {
reason: format!("failed to serialize receipt body: {source}"),
}
})?;
Ok(format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n"))
}
}
impl ExternalSink {
fn serialize_sink<S>(sink: &Self, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
ser.serialize_str(sink.as_wire_str())
}
fn deserialize_sink<'de, D>(de: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(de)?;
Self::from_wire_str(&raw).map_err(serde::de::Error::custom)
}
}
impl Serialize for ExternalSink {
fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
Self::serialize_sink(self, ser)
}
}
impl<'de> Deserialize<'de> for ExternalSink {
fn deserialize<D>(de: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Self::deserialize_sink(de)
}
}
pub fn parse_external_receipt(input: &str) -> Result<ExternalReceipt, ExternalReceiptParseError> {
let mut lines = input.lines();
let Some(header) = lines.next() else {
return Err(ExternalReceiptParseError::MissingHeader);
};
if header != EXTERNAL_RECEIPT_FORMAT_HEADER_V1 {
return Err(ExternalReceiptParseError::UnknownFormatHeader {
observed: header.to_string(),
});
}
let Some(body) = lines.next() else {
return Err(ExternalReceiptParseError::MissingBody);
};
if body.trim() != body {
return Err(ExternalReceiptParseError::MalformedBody {
reason: "body line must not have leading or trailing whitespace".to_string(),
});
}
if body.is_empty() {
return Err(ExternalReceiptParseError::MalformedBody {
reason: "body line must not be empty".to_string(),
});
}
if lines.next().is_some() {
return Err(ExternalReceiptParseError::TrailingContent);
}
let receipt: ExternalReceipt =
serde_json::from_str(body).map_err(|source| ExternalReceiptParseError::MalformedBody {
reason: format!("invalid receipt JSON: {source}"),
})?;
validate_external_receipt_fields(&receipt)?;
Ok(receipt)
}
pub fn parse_external_receipt_history(
input: &str,
) -> Result<Vec<ExternalReceipt>, ExternalReceiptParseError> {
let mut lines = input.lines();
let mut receipts = Vec::new();
loop {
let Some(header) = lines.next() else {
break;
};
let Some(body) = lines.next() else {
return Err(ExternalReceiptParseError::MissingBody);
};
receipts.push(parse_external_receipt(&format!("{header}\n{body}\n"))?);
}
if receipts.is_empty() {
return Err(ExternalReceiptParseError::MissingHeader);
}
let mut previous_event_count: Option<u64> = None;
for (index, receipt) in receipts.iter().enumerate() {
if let Some(previous) = previous_event_count {
if receipt.anchor_event_count < previous {
return Err(ExternalReceiptParseError::NonMonotonic {
receipt_index: index + 1,
previous_event_count: previous,
event_count: receipt.anchor_event_count,
});
}
}
previous_event_count = Some(receipt.anchor_event_count);
}
Ok(receipts)
}
fn validate_external_receipt_fields(
receipt: &ExternalReceipt,
) -> Result<(), ExternalReceiptParseError> {
if receipt.sink == ExternalSink::None {
return Err(ExternalReceiptParseError::UnknownSink {
observed: ExternalSink::None.as_wire_str().to_string(),
});
}
validate_lower_hex(
&receipt.anchor_text_sha256,
SHA256_HEX_LEN,
"anchor_text_sha256",
)?;
validate_lower_hex(
&receipt.anchor_chain_head_hash,
BLAKE3_HEX_LEN,
"anchor_chain_head_hash",
)?;
if receipt.sink_endpoint.is_empty() {
return Err(ExternalReceiptParseError::MalformedBody {
reason: "sink_endpoint must not be empty".to_string(),
});
}
if receipt.anchor_event_count == 0 {
return Err(ExternalReceiptParseError::MalformedBody {
reason: "anchor_event_count must be positive".to_string(),
});
}
if !receipt.receipt.is_object() {
return Err(ExternalReceiptParseError::MalformedBody {
reason: "receipt body field must be a JSON object".to_string(),
});
}
Ok(())
}
fn validate_lower_hex(
value: &str,
expected_len: usize,
field: &'static str,
) -> Result<(), ExternalReceiptParseError> {
if value.len() != expected_len
|| !value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err(ExternalReceiptParseError::InvalidHexField {
field,
value: value.to_string(),
expected_len,
});
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ExternalReceiptParseError {
#[error("missing external anchor receipt format header")]
MissingHeader,
#[error("unknown external anchor receipt format header: {observed}")]
UnknownFormatHeader {
observed: String,
},
#[error("missing external anchor receipt body")]
MissingBody,
#[error("malformed external anchor receipt body: {reason}")]
MalformedBody {
reason: String,
},
#[error("external anchor receipt has trailing content")]
TrailingContent,
#[error("unknown external anchor receipt sink: {observed}")]
UnknownSink {
observed: String,
},
#[error("invalid external anchor receipt {field}: expected {expected_len} lowercase hex chars, got `{value}`")]
InvalidHexField {
field: &'static str,
value: String,
expected_len: usize,
},
#[error(
"external anchor receipt history is non-monotonic at record {receipt_index}: event_count {event_count} follows {previous_event_count}"
)]
NonMonotonic {
receipt_index: usize,
previous_event_count: u64,
event_count: u64,
},
}
#[derive(Debug, Error)]
pub enum ExternalReceiptHistoryIoError {
#[error("failed to read external anchor receipt history {path:?}: {source}")]
ReadHistory {
path: PathBuf,
source: std::io::Error,
},
#[error("invalid external anchor receipt history {path:?}: {source}")]
Parse {
path: PathBuf,
source: ExternalReceiptParseError,
},
}
pub fn read_external_receipt_history(
path: impl Into<PathBuf>,
) -> Result<Vec<ExternalReceipt>, ExternalReceiptHistoryIoError> {
let path = path.into();
let text = std::fs::read_to_string(&path).map_err(|source| {
ExternalReceiptHistoryIoError::ReadHistory {
path: path.clone(),
source,
}
})?;
parse_external_receipt_history(&text)
.map_err(|source| ExternalReceiptHistoryIoError::Parse { path, source })
}
pub const ANCHOR_TEXT_HASH_MISMATCH_INVARIANT: &str =
"external_anchor_receipts.anchor_text_hash.mismatch";
pub const PARSED_ONLY_VERIFICATION_STATUS: &str = "parsed_only_signature_verification_pending";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalReceiptVerification {
pub path: PathBuf,
pub receipts_path: PathBuf,
pub db_count: u64,
pub receipts_verified: usize,
pub latest_receipt: ExternalReceipt,
pub status: &'static str,
pub trust_root_status: &'static str,
pub trust_root_signed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Error)]
pub enum ExternalReceiptVerifyError {
#[error("failed to read external anchor receipt history {path:?}: {source}")]
ReadHistory {
path: PathBuf,
source: std::io::Error,
},
#[error("invalid external anchor receipt history {path:?}: {source}")]
Parse {
path: PathBuf,
source: ExternalReceiptParseError,
},
#[error(
"external anchor receipt {receipt_index} in {path:?} carries a malformed anchor: {source}"
)]
Anchor {
path: PathBuf,
receipt_index: usize,
source: AnchorParseError,
},
#[error(
"external anchor receipt {receipt_index} in {path:?} failed local anchor verification: {source}"
)]
AnchorVerify {
path: PathBuf,
receipt_index: usize,
source: Box<AnchorVerifyError>,
},
#[error(
"{invariant}: external anchor receipt {receipt_index} in {path:?} declared anchor_text_sha256 {declared} but local ledger recomputes {observed}"
)]
AnchorTextHashMismatch {
invariant: &'static str,
path: PathBuf,
receipt_index: usize,
declared: String,
observed: String,
},
#[error(
"{invariant}: trusted_root.json (status={trust_root_status}) signed_at {signed_at:?} is stale beyond max_age {max_age:?} at now {now}"
)]
TrustedRootStale {
invariant: &'static str,
trust_root_status: &'static str,
cache_path: Option<PathBuf>,
signed_at: Option<DateTime<Utc>>,
now: DateTime<Utc>,
max_age: Duration,
},
#[error("{invariant}: failed to load trusted_root.json from {path:?}: {source}")]
TrustedRootIo {
invariant: &'static str,
path: PathBuf,
source: Box<TrustedRootIoError>,
},
}
pub fn verify_external_receipts(
ledger_path: impl AsRef<Path>,
receipts_path: impl Into<PathBuf>,
) -> Result<ExternalReceiptVerification, ExternalReceiptVerifyError> {
verify_external_receipts_with_options(
ledger_path,
receipts_path,
None,
Utc::now(),
DEFAULT_MAX_TRUST_ROOT_AGE,
)
}
pub fn verify_external_receipts_with_options(
ledger_path: impl AsRef<Path>,
receipts_path: impl Into<PathBuf>,
trust_root_cache: Option<&Path>,
now: DateTime<Utc>,
max_age: Duration,
) -> Result<ExternalReceiptVerification, ExternalReceiptVerifyError> {
let receipts_path = receipts_path.into();
let active = match active_trusted_root(trust_root_cache) {
Ok(active) => active,
Err(source) => {
let path = trust_root_cache
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("<embedded>"));
return Err(ExternalReceiptVerifyError::TrustedRootIo {
invariant: TRUSTED_ROOT_PARSE_INVARIANT,
path,
source: Box::new(source),
});
}
};
let trust_root_status = active.status;
let trust_root_signed_at = active.root.metadata_signed_at();
if active.status == CACHED_ROOT_STATUS {
let cache_path = active
.cache_path
.as_deref()
.expect("CACHED_ROOT_STATUS implies a cache path was inspected");
let anchor = TrustRootStalenessAnchor::cache_file_mtime(cache_path);
match active.root.is_stale_at(now, max_age, anchor) {
Ok(true) => {
return Err(ExternalReceiptVerifyError::TrustedRootStale {
invariant: TRUSTED_ROOT_CACHE_STALE_INVARIANT,
trust_root_status,
cache_path: active.cache_path,
signed_at: trust_root_signed_at,
now,
max_age,
});
}
Ok(false) => {}
Err(TrustRootStalenessError::CacheFutureDated { .. }) => {
return Err(ExternalReceiptVerifyError::TrustedRootStale {
invariant: trusted_root::STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED,
trust_root_status,
cache_path: active.cache_path,
signed_at: trust_root_signed_at,
now,
max_age,
});
}
Err(source) => {
return Err(ExternalReceiptVerifyError::TrustedRootIo {
invariant: TRUSTED_ROOT_PARSE_INVARIANT,
path: cache_path.to_path_buf(),
source: Box::new(TrustedRootIoError::Read {
path: cache_path.to_path_buf(),
source: std::io::Error::other(source.to_string()),
}),
});
}
}
}
let text = std::fs::read_to_string(&receipts_path).map_err(|source| {
ExternalReceiptVerifyError::ReadHistory {
path: receipts_path.clone(),
source,
}
})?;
let receipts = parse_external_receipt_history(&text).map_err(|source| {
ExternalReceiptVerifyError::Parse {
path: receipts_path.clone(),
source,
}
})?;
let mut latest_db_count = 0u64;
let mut latest_receipt: Option<ExternalReceipt> = None;
for (index, receipt) in receipts.iter().enumerate() {
let record_index = index + 1;
let anchor = LedgerAnchor::new(
receipt.submitted_at,
receipt.anchor_event_count,
receipt.anchor_chain_head_hash.clone(),
)
.map_err(|source| ExternalReceiptVerifyError::Anchor {
path: receipts_path.clone(),
receipt_index: record_index,
source,
})?;
let verified = verify_anchor(ledger_path.as_ref(), &anchor).map_err(|source| {
ExternalReceiptVerifyError::AnchorVerify {
path: receipts_path.clone(),
receipt_index: record_index,
source: Box::new(source),
}
})?;
let recomputed = sha256_hex(anchor.to_anchor_text().as_bytes());
if recomputed != receipt.anchor_text_sha256 {
return Err(ExternalReceiptVerifyError::AnchorTextHashMismatch {
invariant: ANCHOR_TEXT_HASH_MISMATCH_INVARIANT,
path: receipts_path,
receipt_index: record_index,
declared: receipt.anchor_text_sha256.clone(),
observed: recomputed,
});
}
latest_db_count = verified.db_count;
latest_receipt = Some(receipt.clone());
}
let latest_receipt = latest_receipt.expect("parse_external_receipt_history returns non-empty");
Ok(ExternalReceiptVerification {
path: ledger_path.as_ref().to_path_buf(),
receipts_path,
db_count: latest_db_count,
receipts_verified: receipts.len(),
latest_receipt,
status: PARSED_ONLY_VERIFICATION_STATUS,
trust_root_status,
trust_root_signed_at,
})
}
#[must_use]
pub fn anchor_text_sha256(anchor: &LedgerAnchor) -> String {
sha256_hex(anchor.to_anchor_text().as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn sample_receipt(event_count: u64, sink: ExternalSink) -> ExternalReceipt {
ExternalReceipt {
sink,
anchor_text_sha256: "0".repeat(SHA256_HEX_LEN),
anchor_event_count: event_count,
anchor_chain_head_hash: "a".repeat(BLAKE3_HEX_LEN),
submitted_at: Utc.with_ymd_and_hms(2026, 5, 12, 18, 0, 0).unwrap(),
sink_endpoint: "https://rekor.sigstore.dev".to_string(),
receipt: serde_json::json!({"logIndex": 1, "uuid": "abc"}),
}
}
#[test]
fn parses_clean_record_round_trip() {
let receipt = sample_receipt(7, ExternalSink::Rekor);
let text = receipt.to_record_text().unwrap();
assert!(text.starts_with(EXTERNAL_RECEIPT_FORMAT_HEADER_V1));
let parsed = parse_external_receipt(&text).unwrap();
assert_eq!(parsed, receipt);
}
#[test]
fn rejects_unknown_sink_token() {
let body = serde_json::json!({
"sink": "unknown-sink",
"anchor_text_sha256": "0".repeat(SHA256_HEX_LEN),
"anchor_event_count": 1,
"anchor_chain_head_hash": "a".repeat(BLAKE3_HEX_LEN),
"submitted_at": "2026-05-12T18:00:00Z",
"sink_endpoint": "https://example.invalid",
"receipt": {},
});
let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
let err = parse_external_receipt(&text).unwrap_err();
match err {
ExternalReceiptParseError::MalformedBody { reason } => {
assert!(
reason.contains("unknown external anchor receipt sink"),
"{reason}"
);
}
other => panic!("expected MalformedBody, got {other:?}"),
}
}
#[test]
fn rejects_missing_header() {
let body = serde_json::to_string(&sample_receipt(1, ExternalSink::Rekor)).unwrap();
let err = parse_external_receipt(&body).unwrap_err();
assert!(matches!(
err,
ExternalReceiptParseError::UnknownFormatHeader { .. }
));
}
#[test]
fn rejects_unknown_format_header() {
let body = serde_json::to_string(&sample_receipt(1, ExternalSink::Rekor)).unwrap();
let text = format!("# cortex-external-anchor-receipt-format: 2\n{body}\n");
let err = parse_external_receipt(&text).unwrap_err();
assert!(matches!(
err,
ExternalReceiptParseError::UnknownFormatHeader { .. }
));
}
#[test]
fn rejects_missing_required_field() {
let body = serde_json::json!({
"sink": "rekor",
"anchor_event_count": 1,
"anchor_chain_head_hash": "a".repeat(BLAKE3_HEX_LEN),
"submitted_at": "2026-05-12T18:00:00Z",
"sink_endpoint": "https://example.invalid",
"receipt": {},
});
let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
let err = parse_external_receipt(&text).unwrap_err();
match err {
ExternalReceiptParseError::MalformedBody { reason } => {
assert!(reason.contains("anchor_text_sha256"), "{reason}");
}
other => panic!("expected MalformedBody, got {other:?}"),
}
}
#[test]
fn rejects_invalid_hex_lengths() {
let mut receipt = sample_receipt(1, ExternalSink::Rekor);
receipt.anchor_text_sha256 = "abc".to_string();
let body = serde_json::to_string(&receipt).unwrap();
let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
let err = parse_external_receipt(&text).unwrap_err();
assert!(matches!(
err,
ExternalReceiptParseError::InvalidHexField {
field: "anchor_text_sha256",
..
}
));
}
#[test]
fn rejects_trailing_content() {
let body = serde_json::to_string(&sample_receipt(1, ExternalSink::Rekor)).unwrap();
let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\nextra\n");
let err = parse_external_receipt(&text).unwrap_err();
assert_eq!(err, ExternalReceiptParseError::TrailingContent);
}
#[test]
fn rejects_none_sink_in_payload() {
let body = serde_json::json!({
"sink": "none",
"anchor_text_sha256": "0".repeat(SHA256_HEX_LEN),
"anchor_event_count": 1,
"anchor_chain_head_hash": "a".repeat(BLAKE3_HEX_LEN),
"submitted_at": "2026-05-12T18:00:00Z",
"sink_endpoint": "https://example.invalid",
"receipt": {},
});
let text = format!("{EXTERNAL_RECEIPT_FORMAT_HEADER_V1}\n{body}\n");
let err = parse_external_receipt(&text).unwrap_err();
assert!(matches!(err, ExternalReceiptParseError::UnknownSink { .. }));
}
#[test]
fn parses_history_round_trip_with_monotonic_records() {
let r1 = sample_receipt(1, ExternalSink::Rekor);
let r2 = sample_receipt(3, ExternalSink::OpenTimestamps);
let text = format!(
"{}{}",
r1.to_record_text().unwrap(),
r2.to_record_text().unwrap()
);
let parsed = parse_external_receipt_history(&text).unwrap();
assert_eq!(parsed, vec![r1, r2]);
}
#[test]
fn history_rejects_non_monotonic_event_count() {
let r1 = sample_receipt(5, ExternalSink::Rekor);
let r2 = sample_receipt(2, ExternalSink::Rekor);
let text = format!(
"{}{}",
r1.to_record_text().unwrap(),
r2.to_record_text().unwrap()
);
let err = parse_external_receipt_history(&text).unwrap_err();
assert!(matches!(
err,
ExternalReceiptParseError::NonMonotonic {
receipt_index: 2,
previous_event_count: 5,
event_count: 2,
}
));
}
#[test]
fn history_rejects_truncated_record() {
let err = parse_external_receipt_history(EXTERNAL_RECEIPT_FORMAT_HEADER_V1).unwrap_err();
assert_eq!(err, ExternalReceiptParseError::MissingBody);
}
#[test]
fn history_rejects_empty_input() {
let err = parse_external_receipt_history("").unwrap_err();
assert_eq!(err, ExternalReceiptParseError::MissingHeader);
}
#[test]
fn sink_wire_tokens_are_stable() {
assert_eq!(ExternalSink::None.as_wire_str(), "none");
assert_eq!(ExternalSink::Rekor.as_wire_str(), "rekor");
assert_eq!(ExternalSink::OpenTimestamps.as_wire_str(), "opentimestamps");
}
#[test]
fn sink_from_wire_str_rejects_garbage() {
let err = ExternalSink::from_wire_str("garbage").unwrap_err();
assert!(matches!(err, ExternalReceiptParseError::UnknownSink { .. }));
}
use cortex_core::{Event, EventId, EventSource, EventType, SCHEMA_VERSION};
use tempfile::tempdir;
use crate::JsonlLog;
fn ledger_event(seq: u64) -> Event {
Event {
id: EventId::new(),
schema_version: SCHEMA_VERSION,
observed_at: Utc.with_ymd_and_hms(2026, 5, 12, 18, 0, 0).unwrap(),
recorded_at: Utc.with_ymd_and_hms(2026, 5, 12, 18, 0, 1).unwrap(),
source: EventSource::User,
event_type: EventType::UserMessage,
trace_id: None,
session_id: Some("ext-receipt".into()),
domain_tags: vec![],
payload: serde_json::json!({"seq": seq}),
payload_hash: String::new(),
prev_event_hash: None,
event_hash: String::new(),
}
}
fn build_ledger(count: u64) -> (tempfile::TempDir, std::path::PathBuf, Vec<String>) {
let dir = tempdir().unwrap();
let path = dir.path().join("events.jsonl");
let mut log = JsonlLog::open(&path).unwrap();
let mut heads = Vec::new();
let policy = crate::append_policy_decision_test_allow();
for seq in 0..count {
heads.push(log.append(ledger_event(seq), &policy).unwrap());
}
(dir, path, heads)
}
fn make_canonical_receipt(
timestamp: DateTime<Utc>,
event_count: u64,
chain_head: &str,
sink: ExternalSink,
) -> ExternalReceipt {
let anchor = LedgerAnchor::new(timestamp, event_count, chain_head.to_string()).unwrap();
ExternalReceipt {
sink,
anchor_text_sha256: anchor_text_sha256(&anchor),
anchor_event_count: event_count,
anchor_chain_head_hash: chain_head.to_string(),
submitted_at: timestamp,
sink_endpoint: "https://rekor.sigstore.dev".to_string(),
receipt: serde_json::json!({"logIndex": event_count, "uuid": "fixture"}),
}
}
fn write_receipt_history(path: &Path, receipts: &[ExternalReceipt]) {
let mut text = String::new();
for receipt in receipts {
text.push_str(&receipt.to_record_text().unwrap());
}
std::fs::write(path, text).unwrap();
}
#[test]
fn clean_receipt_history_parses_and_verifies() {
let (_dir, ledger, heads) = build_ledger(3);
let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
let r1 = make_canonical_receipt(
Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
1,
&heads[0],
ExternalSink::Rekor,
);
let r2 = make_canonical_receipt(
Utc.with_ymd_and_hms(2026, 5, 12, 18, 10, 0).unwrap(),
3,
&heads[2],
ExternalSink::Rekor,
);
write_receipt_history(&receipts_path, &[r1, r2.clone()]);
let verification = verify_external_receipts(&ledger, &receipts_path).unwrap();
assert_eq!(verification.receipts_verified, 2);
assert_eq!(verification.latest_receipt, r2);
assert_eq!(verification.db_count, 3);
assert_eq!(verification.status, PARSED_ONLY_VERIFICATION_STATUS);
}
#[test]
fn tampered_anchor_text_hash_fails_closed_with_stable_invariant() {
let (_dir, ledger, heads) = build_ledger(3);
let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
let mut r1 = make_canonical_receipt(
Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
3,
&heads[2],
ExternalSink::Rekor,
);
r1.anchor_text_sha256 = "f".repeat(SHA256_HEX_LEN);
write_receipt_history(&receipts_path, &[r1]);
let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
match err {
ExternalReceiptVerifyError::AnchorTextHashMismatch {
invariant,
receipt_index,
..
} => {
assert_eq!(invariant, ANCHOR_TEXT_HASH_MISMATCH_INVARIANT);
assert_eq!(receipt_index, 1);
}
other => panic!("expected AnchorTextHashMismatch, got {other:?}"),
}
}
#[test]
fn tampered_anchor_chain_head_hash_fails_closed() {
let (_dir, ledger, heads) = build_ledger(3);
let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
let mut r1 = make_canonical_receipt(
Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
3,
&heads[2],
ExternalSink::Rekor,
);
r1.anchor_chain_head_hash = "0".repeat(BLAKE3_HEX_LEN);
let bogus_anchor = LedgerAnchor::new(
r1.submitted_at,
r1.anchor_event_count,
r1.anchor_chain_head_hash.clone(),
)
.unwrap();
r1.anchor_text_sha256 = anchor_text_sha256(&bogus_anchor);
write_receipt_history(&receipts_path, &[r1]);
let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
assert!(
matches!(err, ExternalReceiptVerifyError::AnchorVerify { .. }),
"got {err:?}"
);
}
#[test]
fn non_monotonic_receipt_history_fails_closed_before_anchor_check() {
let (_dir, ledger, heads) = build_ledger(3);
let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
let r1 = make_canonical_receipt(
Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
3,
&heads[2],
ExternalSink::Rekor,
);
let r2 = make_canonical_receipt(
Utc.with_ymd_and_hms(2026, 5, 12, 18, 10, 0).unwrap(),
1,
&heads[0],
ExternalSink::Rekor,
);
write_receipt_history(&receipts_path, &[r1, r2]);
let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
match err {
ExternalReceiptVerifyError::Parse { source, .. } => {
assert!(matches!(
source,
ExternalReceiptParseError::NonMonotonic {
receipt_index: 2,
previous_event_count: 3,
event_count: 1,
}
));
}
other => panic!("expected Parse(NonMonotonic), got {other:?}"),
}
}
#[test]
fn missing_receipt_history_file_fails_closed() {
let dir = tempdir().unwrap();
let ledger = dir.path().join("events.jsonl");
std::fs::write(&ledger, "").unwrap();
let receipts_path = dir.path().join("missing-receipts");
let err = verify_external_receipts(&ledger, &receipts_path).unwrap_err();
assert!(matches!(
err,
ExternalReceiptVerifyError::ReadHistory { .. }
));
}
fn near_root_now() -> DateTime<Utc> {
let root = TrustedRoot::embedded().unwrap();
let signed_at = root.metadata_signed_at().unwrap();
signed_at + chrono::Duration::days(1)
}
fn build_receipts_fixture() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
let (dir, ledger, heads) = build_ledger(3);
let receipts_path = ledger.parent().unwrap().join("EXTERNAL_ANCHOR_RECEIPTS");
let r1 = make_canonical_receipt(
Utc.with_ymd_and_hms(2026, 5, 12, 18, 5, 0).unwrap(),
3,
&heads[2],
ExternalSink::Rekor,
);
write_receipt_history(&receipts_path, &[r1]);
(dir, ledger, receipts_path)
}
#[test]
fn fresh_cached_root_allows_verification() {
let (dir, ledger, receipts_path) = build_receipts_fixture();
let trust_root_path = dir.path().join("trusted_root.json");
TrustedRoot::embedded()
.unwrap()
.write_atomic(&trust_root_path)
.unwrap();
let now = near_root_now();
let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
+ std::time::Duration::from_secs(now.timestamp() as u64);
std::fs::File::options()
.write(true)
.open(&trust_root_path)
.unwrap()
.set_modified(mtime_systemtime)
.expect("set mtime");
let verification = verify_external_receipts_with_options(
&ledger,
&receipts_path,
Some(&trust_root_path),
now,
DEFAULT_MAX_TRUST_ROOT_AGE,
)
.expect("fresh cached root verifies");
assert_eq!(verification.trust_root_status, CACHED_ROOT_STATUS);
assert!(verification.trust_root_signed_at.is_some());
assert_eq!(verification.status, PARSED_ONLY_VERIFICATION_STATUS);
}
#[test]
fn cached_root_older_than_31_days_fails_closed_with_cache_stale_invariant() {
let (dir, ledger, receipts_path) = build_receipts_fixture();
let trust_root_path = dir.path().join("trusted_root.json");
TrustedRoot::embedded()
.unwrap()
.write_atomic(&trust_root_path)
.unwrap();
let now = Utc::now();
let old_mtime = now - chrono::Duration::days(31);
let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
+ std::time::Duration::from_secs(old_mtime.timestamp() as u64);
std::fs::File::options()
.write(true)
.open(&trust_root_path)
.unwrap()
.set_modified(mtime_systemtime)
.expect("set mtime");
let err = verify_external_receipts_with_options(
&ledger,
&receipts_path,
Some(&trust_root_path),
now,
DEFAULT_MAX_TRUST_ROOT_AGE,
)
.unwrap_err();
match err {
ExternalReceiptVerifyError::TrustedRootStale {
invariant,
trust_root_status,
..
} => {
assert_eq!(invariant, TRUSTED_ROOT_CACHE_STALE_INVARIANT);
assert_eq!(trust_root_status, CACHED_ROOT_STATUS);
}
other => panic!("expected TrustedRootStale, got {other:?}"),
}
}
#[test]
fn cached_root_with_fresh_mtime_passes_even_when_metadata_old() {
let (dir, ledger, receipts_path) = build_receipts_fixture();
let trust_root_path = dir.path().join("trusted_root.json");
TrustedRoot::embedded()
.unwrap()
.write_atomic(&trust_root_path)
.unwrap();
let now = TrustedRoot::embedded()
.unwrap()
.metadata_signed_at()
.unwrap()
+ chrono::Duration::days(365);
let mtime_systemtime = std::time::SystemTime::UNIX_EPOCH
+ std::time::Duration::from_secs(now.timestamp() as u64);
std::fs::File::options()
.write(true)
.open(&trust_root_path)
.unwrap()
.set_modified(mtime_systemtime)
.expect("set mtime");
let verification = verify_external_receipts_with_options(
&ledger,
&receipts_path,
Some(&trust_root_path),
now,
DEFAULT_MAX_TRUST_ROOT_AGE,
)
.expect("fresh cache mtime must pass the staleness gate");
assert_eq!(verification.trust_root_status, CACHED_ROOT_STATUS);
}
#[test]
fn missing_cache_falls_back_to_embedded_and_allows_even_when_stale() {
let (dir, ledger, receipts_path) = build_receipts_fixture();
let missing_cache = dir.path().join("nonexistent-trusted_root.json");
let now = Utc.with_ymd_and_hms(2099, 1, 1, 0, 0, 0).unwrap();
let verification = verify_external_receipts_with_options(
&ledger,
&receipts_path,
Some(&missing_cache),
now,
DEFAULT_MAX_TRUST_ROOT_AGE,
)
.expect("missing cache + stale embedded still allows (warn-only)");
assert_eq!(verification.trust_root_status, EMBEDDED_ROOT_STATUS);
}
#[test]
fn unparseable_cache_fails_closed_with_parse_invariant() {
let (dir, ledger, receipts_path) = build_receipts_fixture();
let trust_root_path = dir.path().join("trusted_root.json");
std::fs::write(&trust_root_path, b"this is not valid json").unwrap();
let now = near_root_now();
let err = verify_external_receipts_with_options(
&ledger,
&receipts_path,
Some(&trust_root_path),
now,
DEFAULT_MAX_TRUST_ROOT_AGE,
)
.unwrap_err();
match err {
ExternalReceiptVerifyError::TrustedRootIo { invariant, .. } => {
assert_eq!(invariant, TRUSTED_ROOT_PARSE_INVARIANT);
}
other => panic!("expected TrustedRootIo, got {other:?}"),
}
}
#[test]
fn cached_root_with_future_dated_mtime_fails_closed_with_future_dated_invariant() {
let (dir, ledger, receipts_path) = build_receipts_fixture();
let trust_root_path = dir.path().join("trusted_root.json");
TrustedRoot::embedded()
.unwrap()
.write_atomic(&trust_root_path)
.unwrap();
let now = near_root_now();
let future = now + chrono::Duration::days(365 * 70);
let future_systemtime = std::time::SystemTime::UNIX_EPOCH
+ std::time::Duration::from_secs(future.timestamp() as u64);
std::fs::File::options()
.write(true)
.open(&trust_root_path)
.unwrap()
.set_modified(future_systemtime)
.expect("set mtime");
let err = verify_external_receipts_with_options(
&ledger,
&receipts_path,
Some(&trust_root_path),
now,
DEFAULT_MAX_TRUST_ROOT_AGE,
)
.unwrap_err();
match err {
ExternalReceiptVerifyError::TrustedRootStale {
invariant,
trust_root_status,
..
} => {
assert_eq!(
invariant,
trusted_root::STABLE_INVARIANT_TRUSTED_ROOT_CACHE_FUTURE_DATED
);
assert_eq!(trust_root_status, CACHED_ROOT_STATUS);
}
other => panic!("expected TrustedRootStale with cache_future_dated, got {other:?}"),
}
}
}