use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use acdp_types::revocation::{KeyRevocation, RevocationTrustClass};
const MAX_CACHE_ENTRIES: usize = 1000;
const MAX_FACTS_PER_ENTRY: usize = 256;
struct StoredFact {
rev: KeyRevocation,
origin: String,
}
#[derive(Default)]
struct CacheEntry {
facts: Vec<StoredFact>,
markers: HashMap<(String, bool), Instant>,
at_cap: bool,
}
fn class_key(class: RevocationTrustClass) -> bool {
match class {
RevocationTrustClass::ProducerSigned => false,
RevocationTrustClass::RegistryAttested => true,
}
}
#[derive(Clone)]
pub struct RevocationCache {
inner: Arc<Mutex<HashMap<String, CacheEntry>>>,
}
impl RevocationCache {
#[must_use]
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
fn lock(&self) -> MutexGuard<'_, HashMap<String, CacheEntry>> {
self.inner
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) fn facts_for(
&self,
agent_id: &str,
include_registry_attested: bool,
current_vantage: Option<&str>,
) -> Vec<KeyRevocation> {
let map = self.lock();
match map.get(agent_id) {
Some(entry) => entry
.facts
.iter()
.filter(|f| match f.rev.trust_class {
RevocationTrustClass::ProducerSigned => true,
RevocationTrustClass::RegistryAttested => {
include_registry_attested && current_vantage.is_some_and(|v| v == f.origin)
}
})
.map(|f| f.rev.clone())
.collect(),
None => Vec::new(),
}
}
pub(crate) fn marker_fresh(
&self,
authority: &str,
agent_id: &str,
class: RevocationTrustClass,
freshness: Duration,
) -> bool {
if freshness.is_zero() {
return false;
}
let map = match self.inner.lock() {
Ok(guard) => guard,
Err(_poisoned) => return false,
};
match map
.get(agent_id)
.and_then(|e| e.markers.get(&(authority.to_string(), class_key(class))))
{
Some(minted_at) => minted_at.elapsed() < freshness,
None => false,
}
}
pub(crate) fn record_success(
&self,
authority: &str,
agent_id: &str,
class: RevocationTrustClass,
facts: &[KeyRevocation],
) {
let mut map = self.lock();
if !map.contains_key(agent_id) && map.len() >= MAX_CACHE_ENTRIES {
if let Some(victim) = map.keys().next().cloned() {
map.remove(&victim);
}
}
let entry = map.entry(agent_id.to_string()).or_default();
if !entry.at_cap {
for f in facts {
if entry.facts.iter().any(|existing| existing.rev == *f) {
continue;
}
if entry.facts.len() >= MAX_FACTS_PER_ENTRY {
entry.at_cap = true;
break;
}
entry.facts.push(StoredFact {
rev: f.clone(),
origin: authority.to_string(),
});
}
}
if entry.at_cap {
entry.markers.clear();
} else {
entry
.markers
.insert((authority.to_string(), class_key(class)), Instant::now());
}
}
#[doc(hidden)]
#[cfg(feature = "test-transport")]
#[must_use]
pub fn fact_count(&self, agent_id: &str) -> usize {
self.lock().get(agent_id).map_or(0, |e| e.facts.len())
}
}
impl Default for RevocationCache {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for RevocationCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RevocationCache").finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Utc};
fn rev(fp: &str, class: RevocationTrustClass) -> KeyRevocation {
KeyRevocation {
revoked_key_fingerprint: fp.into(),
compromised_since: "2026-05-01T00:00:00.000Z".parse::<DateTime<Utc>>().unwrap(),
reason: None,
revoked_key_id: None,
revoked_key_controller: acdp_types::primitives::AgentDid::new(
"did:web:agents.example.com:p",
),
publisher: acdp_types::primitives::AgentDid::new("did:web:agents.example.com:p"),
trust_class: class,
}
}
#[test]
fn record_success_dedups_identical_facts() {
let cache = RevocationCache::new();
let r = rev("sha256:aaaa", RevocationTrustClass::ProducerSigned);
for _ in 0..5 {
cache.record_success(
"reg.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
std::slice::from_ref(&r),
);
}
assert_eq!(
cache
.facts_for("did:web:p", true, Some("reg.example"))
.len(),
1
);
}
#[test]
fn record_success_caps_facts_and_drops_markers_at_cap() {
let cache = RevocationCache::new();
for i in 0..(MAX_FACTS_PER_ENTRY + 5) {
let fp = format!("sha256:{i:064x}");
let r = rev(&fp, RevocationTrustClass::ProducerSigned);
cache.record_success(
"reg.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
&[r],
);
}
assert_eq!(
cache
.facts_for("did:web:p", true, Some("reg.example"))
.len(),
MAX_FACTS_PER_ENTRY
);
assert!(!cache.marker_fresh(
"reg.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
Duration::from_secs(3600)
));
}
#[test]
fn facts_for_filters_by_trust_class() {
let cache = RevocationCache::new();
let attested = rev("sha256:bbbb", RevocationTrustClass::RegistryAttested);
cache.record_success(
"reg.example",
"did:web:p",
RevocationTrustClass::RegistryAttested,
&[attested],
);
assert!(cache
.facts_for("did:web:p", false, Some("reg.example"))
.is_empty());
assert_eq!(
cache
.facts_for("did:web:p", true, Some("reg.example"))
.len(),
1
);
}
#[test]
fn facts_for_filters_registry_attested_facts_by_origin() {
let cache = RevocationCache::new();
let attested = rev("sha256:cccc", RevocationTrustClass::RegistryAttested);
cache.record_success(
"a.example",
"did:web:p",
RevocationTrustClass::RegistryAttested,
&[attested],
);
assert_eq!(
cache.facts_for("did:web:p", true, Some("a.example")).len(),
1,
"reading from the SAME vantage that minted the fact must include it"
);
assert!(
cache
.facts_for("did:web:p", true, Some("b.example"))
.is_empty(),
"reading from a DIFFERENT vantage must exclude a registry-attested fact"
);
assert!(
cache.facts_for("did:web:p", true, None).is_empty(),
"reading with no resolvable current vantage must exclude a registry-attested \
fact, not include it by default"
);
}
#[test]
fn facts_for_never_filters_producer_signed_facts_by_origin() {
let cache = RevocationCache::new();
let signed = rev("sha256:dddd", RevocationTrustClass::ProducerSigned);
cache.record_success(
"a.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
&[signed],
);
assert_eq!(
cache.facts_for("did:web:p", false, Some("b.example")).len(),
1
);
assert_eq!(cache.facts_for("did:web:p", false, None).len(), 1);
}
#[test]
fn markers_are_per_vantage() {
let cache = RevocationCache::new();
cache.record_success(
"a.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
&[],
);
assert!(cache.marker_fresh(
"a.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
Duration::from_secs(60)
));
assert!(!cache.marker_fresh(
"b.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
Duration::from_secs(60)
));
}
#[test]
fn markers_are_per_trust_class() {
let cache = RevocationCache::new();
cache.record_success(
"a.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
&[],
);
assert!(!cache.marker_fresh(
"a.example",
"did:web:p",
RevocationTrustClass::RegistryAttested,
Duration::from_secs(60)
));
}
#[test]
fn zero_freshness_never_suppresses() {
let cache = RevocationCache::new();
cache.record_success(
"a.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
&[],
);
assert!(!cache.marker_fresh(
"a.example",
"did:web:p",
RevocationTrustClass::ProducerSigned,
Duration::ZERO
));
}
#[test]
fn eviction_at_capacity_removes_facts_and_markers_together() {
let cache = RevocationCache::new();
for i in 0..MAX_CACHE_ENTRIES {
let agent = format!("did:web:p{i}");
let fp = format!("sha256:{i:064x}");
cache.record_success(
"reg.example",
&agent,
RevocationTrustClass::ProducerSigned,
&[rev(&fp, RevocationTrustClass::ProducerSigned)],
);
}
for i in 0..MAX_CACHE_ENTRIES {
let agent = format!("did:web:p{i}");
assert_eq!(
cache.facts_for(&agent, true, Some("reg.example")).len(),
1,
"entry {i} must have its fact before eviction pressure"
);
}
let overflow_agent = "did:web:overflow";
cache.record_success(
"reg.example",
overflow_agent,
RevocationTrustClass::ProducerSigned,
&[rev(
"sha256:overff0000000000000000000000000000000000000000000000000000000",
RevocationTrustClass::ProducerSigned,
)],
);
let mut evicted = 0;
for i in 0..MAX_CACHE_ENTRIES {
let agent = format!("did:web:p{i}");
let has_fact = !cache
.facts_for(&agent, true, Some("reg.example"))
.is_empty();
let has_marker = cache.marker_fresh(
"reg.example",
&agent,
RevocationTrustClass::ProducerSigned,
Duration::from_secs(3600),
);
assert_eq!(
has_fact, has_marker,
"entry {i}: fact presence ({has_fact}) and marker presence \
({has_marker}) must never disagree — eviction must remove both \
together, never a fresh marker over an emptied fact set"
);
if !has_fact {
evicted += 1;
}
}
assert_eq!(
evicted, 1,
"capacity-triggered eviction must remove exactly one existing entry \
to make room for the new one"
);
assert!(
!cache
.facts_for(overflow_agent, true, Some("reg.example"))
.is_empty(),
"the newly-inserted entry must itself survive"
);
}
}