use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Coverage {
None,
EndpointOnly,
Periodic,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Anchor {
pub at: i64,
pub mechanism: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnchorCoverage {
pub coverage: Coverage,
pub unwitnessed_span_seconds: i64,
pub claimed_span_seconds: i64,
pub anchor_count: usize,
pub mechanisms: Vec<String>,
}
impl AnchorCoverage {
pub fn compute(event_times: &[i64], anchors: &[Anchor]) -> Self {
let mut mechanisms: Vec<String> = anchors.iter().map(|a| a.mechanism.clone()).collect();
mechanisms.sort();
mechanisms.dedup();
let (Some(&start), Some(&end)) = (event_times.iter().min(), event_times.iter().max())
else {
return Self {
coverage: Coverage::None,
unwitnessed_span_seconds: 0,
claimed_span_seconds: 0,
anchor_count: anchors.len(),
mechanisms,
};
};
let claimed = (end - start).max(0);
if anchors.is_empty() {
return Self {
coverage: Coverage::None,
unwitnessed_span_seconds: claimed,
claimed_span_seconds: claimed,
anchor_count: 0,
mechanisms,
};
}
let mut interior: Vec<i64> = anchors
.iter()
.map(|a| a.at)
.filter(|&t| t > start && t < end)
.collect();
interior.sort_unstable();
let coverage = if interior.is_empty() {
Coverage::EndpointOnly
} else {
Coverage::Periodic
};
let mut widest = 0i64;
let mut prev = start;
for t in &interior {
widest = widest.max(t - prev);
prev = *t;
}
widest = widest.max(end - prev);
Self {
coverage,
unwitnessed_span_seconds: widest,
claimed_span_seconds: claimed,
anchor_count: anchors.len(),
mechanisms,
}
}
pub fn within(&self, max_unwitnessed_seconds: i64) -> bool {
self.claimed_span_seconds == 0 || self.unwitnessed_span_seconds <= max_unwitnessed_seconds
}
pub fn summary(&self) -> String {
match self.coverage {
Coverage::None if self.claimed_span_seconds == 0 => "no timeline to anchor".to_string(),
Coverage::None => format!(
"UNWITNESSED — {} of claimed work with no external anchor; \
the timeline rests on the signer's own clock",
human(self.claimed_span_seconds)
),
Coverage::EndpointOnly => format!(
"endpoint-only — {} anchor(s), none during the work; \
{} unwitnessed",
self.anchor_count,
human(self.unwitnessed_span_seconds)
),
Coverage::Periodic => format!(
"anchored — {} anchor(s) via {}; longest unwitnessed span {}",
self.anchor_count,
self.mechanisms.join("+"),
human(self.unwitnessed_span_seconds)
),
}
}
}
fn human(secs: i64) -> String {
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m", s / 60),
s => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnchorTally {
pub verified: usize,
pub claimed_unverified: usize,
pub failed_attempts: usize,
pub rejected: Vec<String>,
}
pub fn witnessed_anchors(
recorded: &[crate::storage::RecordAnchor],
envelope: &crate::attestation::Envelope,
logs: &[crate::verify::rekor::RekorLogKey],
tally: &mut AnchorTally,
) -> Vec<Anchor> {
let mut out = Vec::new();
for a in recorded {
if matches!(a.status.as_deref(), Some("failed") | Some("skipped")) {
tally.failed_attempts += 1;
continue;
}
match (a.mechanism.as_str(), &a.proof) {
("rekor", Some(proof)) => {
match crate::verify::rekor::verify_rekor_entry(proof, envelope, logs) {
Ok(v) => {
tally.verified += 1;
out.push(Anchor {
at: v.integrated_time,
mechanism: "rekor".into(),
});
}
Err(e) => tally.rejected.push(e.to_string()),
}
}
_ => tally.claimed_unverified += 1,
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn anchor(at: i64) -> Anchor {
Anchor {
at,
mechanism: "hub".into(),
}
}
const H: i64 = 3600;
#[test]
fn fabricated_session_reports_the_whole_span_unwitnessed() {
let events: Vec<i64> = (0..=11).map(|h| h * H).collect();
let c = AnchorCoverage::compute(&events, &[anchor(11 * H)]);
assert_eq!(c.coverage, Coverage::EndpointOnly);
assert_eq!(c.unwitnessed_span_seconds, 11 * H);
assert!(!c.within(H), "an 11h unwitnessed span must fail a 1h bound");
}
#[test]
fn honest_session_anchored_throughout_reports_the_interval() {
let events: Vec<i64> = (0..=11).map(|h| h * H).collect();
let anchors: Vec<Anchor> = (1..=11).map(|h| anchor(h * H)).collect();
let c = AnchorCoverage::compute(&events, &anchors);
assert_eq!(c.coverage, Coverage::Periodic);
assert_eq!(c.unwitnessed_span_seconds, H);
assert!(c.within(2 * H));
assert!(!c.within(H / 2));
}
#[test]
fn closing_anchor_does_not_shrink_the_interior_gap() {
let events = vec![0, 4 * H];
let with = AnchorCoverage::compute(&events, &[anchor(4 * H)]);
let without = AnchorCoverage::compute(&events, &[]);
assert_eq!(
with.unwitnessed_span_seconds, without.unwitnessed_span_seconds,
"an anchor at close constrains nothing inside the session"
);
assert_eq!(with.anchor_count, 1);
assert_eq!(with.coverage, Coverage::EndpointOnly);
assert_eq!(without.coverage, Coverage::None);
}
#[test]
fn no_anchors_reports_the_entire_claimed_span() {
let c = AnchorCoverage::compute(&[0, 4 * H], &[]);
assert_eq!(c.coverage, Coverage::None);
assert_eq!(c.unwitnessed_span_seconds, 4 * H);
assert!(c.summary().contains("UNWITNESSED"));
}
#[test]
fn empty_timeline_is_neither_a_pass_nor_a_violation() {
let c = AnchorCoverage::compute(&[], &[]);
assert_eq!(c.claimed_span_seconds, 0);
assert!(c.within(0));
assert_eq!(c.coverage, Coverage::None);
assert!(!c.summary().contains("UNWITNESSED"));
}
#[test]
fn unsorted_events_are_handled() {
let jumbled = vec![3 * H, 0, 2 * H, H];
let ordered = vec![0, H, 2 * H, 3 * H];
assert_eq!(
AnchorCoverage::compute(&jumbled, &[anchor(90 * 60)]),
AnchorCoverage::compute(&ordered, &[anchor(90 * 60)]),
);
}
#[test]
fn mechanisms_are_deduped_and_sorted() {
let events = vec![0, 4 * H];
let anchors = vec![
Anchor {
at: H,
mechanism: "rekor".into(),
},
Anchor {
at: 2 * H,
mechanism: "hub".into(),
},
Anchor {
at: 3 * H,
mechanism: "rekor".into(),
},
];
let c = AnchorCoverage::compute(&events, &anchors);
assert_eq!(c.mechanisms, vec!["hub", "rekor"]);
assert_eq!(c.anchor_count, 3);
}
#[test]
fn single_event_has_no_span_to_fabricate() {
let c = AnchorCoverage::compute(&[1000], &[]);
assert_eq!(c.claimed_span_seconds, 0);
assert_eq!(c.unwitnessed_span_seconds, 0);
assert!(c.within(0));
}
#[test]
fn locally_written_anchor_time_is_never_witnessed() {
use crate::storage::RecordAnchor;
let env: crate::attestation::Envelope = serde_json::from_value(serde_json::json!({
"payload": "e30", "payloadType": "t", "signatures": []
}))
.unwrap();
let forged = RecordAnchor {
mechanism: "rekor".into(),
observed_at: "2026-09-01T10:30:00Z".into(),
reference: Some("123".into()),
status: None,
reason: None,
proof: None,
};
let hub = RecordAnchor {
mechanism: "hub".into(),
..forged.clone()
};
let mut tally = AnchorTally::default();
let got = witnessed_anchors(
&[forged, hub],
&env,
&[crate::verify::rekor::RekorLogKey::sigstore_public_good()],
&mut tally,
);
assert!(got.is_empty());
assert_eq!(tally.claimed_unverified, 2);
assert_eq!(tally.verified, 0);
}
#[test]
fn invalid_proof_is_rejected_with_a_reason() {
use crate::storage::RecordAnchor;
let env: crate::attestation::Envelope = serde_json::from_value(serde_json::json!({
"payload": "e30", "payloadType": "t", "signatures": []
}))
.unwrap();
let a = RecordAnchor {
mechanism: "rekor".into(),
observed_at: String::new(),
reference: None,
status: Some("anchored".into()),
reason: None,
proof: Some(serde_json::json!({"body": "x"})),
};
let mut tally = AnchorTally::default();
assert!(witnessed_anchors(&[a], &env, &[], &mut tally).is_empty());
assert_eq!(tally.rejected.len(), 1);
}
#[test]
fn failed_attempts_are_counted_separately() {
use crate::storage::RecordAnchor;
let env: crate::attestation::Envelope = serde_json::from_value(serde_json::json!({
"payload": "e30", "payloadType": "t", "signatures": []
}))
.unwrap();
let a = RecordAnchor {
mechanism: "rekor".into(),
observed_at: String::new(),
reference: None,
status: Some("failed".into()),
reason: Some("rekor rejected the entry".into()),
proof: None,
};
let mut tally = AnchorTally::default();
assert!(witnessed_anchors(&[a], &env, &[], &mut tally).is_empty());
assert_eq!(tally.failed_attempts, 1);
assert_eq!(tally.claimed_unverified, 0);
}
#[test]
fn verified_proof_counts_at_rekor_time_not_local_time() {
use crate::storage::RecordAnchor;
let (env, entry, log) = crate::verify::rekor::staging_fixture_for_tests();
let a = RecordAnchor {
mechanism: "rekor".into(),
observed_at: "2025-01-01T00:00:00Z".into(),
reference: None,
status: Some("anchored".into()),
reason: None,
proof: Some(entry.clone()),
};
let mut tally = AnchorTally::default();
let got = witnessed_anchors(&[a], &env, &[log], &mut tally);
assert_eq!(tally.verified, 1);
assert_eq!(got.len(), 1);
assert_eq!(got[0].at, entry["integratedTime"].as_i64().unwrap());
}
}