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),
}
}
#[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));
}
}