acdp_client/revocation_cache.rs
1//! Issue #257: a cache for RFC-ACDP-0014 §7/§8 revocation discovery.
2//!
3//! This is deliberately **two objects sharing one lock**, not one:
4//!
5//! - **Facts** — verified [`KeyRevocation`]s. RFC-ACDP-0014 §7:114 licenses
6//! caching these *indefinitely* ("the statement is permanent"). They are
7//! **always unioned** into classification by
8//! [`crate::verified::VerifiedContext`]'s internal `verify_retrieved` —
9//! never used to replace or subset a discovery result. A revocation is
10//! monotone (more revocations ⇒ an earlier effective boundary ⇒ strictly
11//! more fail-closed verdicts), so seeding from the fact store can only
12//! *tighten* a verdict, never loosen one. That makes the fact store an
13//! **anti-rollback security control**: without it, a registry that serves
14//! a revocation on one call and hides it on the next causes the client to
15//! forget it ever saw it. See `RevocationCache::facts_for` (read) and
16//! `RevocationCache::record_success` (write) — both crate-private.
17//!
18//! **Every stored fact carries the vantage that minted it** (its
19//! `origin`, set from `record_success`'s `authority` parameter — see
20//! `StoredFact`). RFC-ACDP-0014 §6 draws a real distinction here: a
21//! *producer-signed* fact is self-contained and "verifies identically
22//! wherever it came from" (§8), so it is never filtered by origin — this
23//! is what makes it correct for it to cross vantages (see
24//! `crate::verified`'s anti-rollback tests). A *registry-attested* fact is
25//! one specific registry's claim, and §6 licenses applying it only "for
26//! contexts served by or receipted by that same registry" — so a read
27//! filters registry-attested facts to `origin == current vantage`.
28//! Storing only `trust_class` (as an earlier revision of this cache did)
29//! answers a different question than "was this claim made by the
30//! registry now serving this context" — collapsing the two let a fact
31//! minted at a hostile or deceived registry A fail-close a producer's
32//! contexts at every OTHER authority, for the cache's lifetime: a
33//! targeted cross-registry DoS exactly bounded by §6's scoping default.
34//! - **Freshness markers** — "vantage V was asked about producer/controller
35//! P for trust class C, and a full, untruncated discovery completed at
36//! time T." This is a cached *absence*, which §7:114 does **not** license
37//! and which §8 explicitly warns about: "a malicious registry can hide a
38//! revocation … absence of search results is not evidence of absence." So
39//! a marker is TTL-bounded ([`crate::verified::RevocationDiscovery::freshness`],
40//! default 300 s — RFC-ACDP-0014 §8's RECOMMENDED bound), per-vantage,
41//! per-trust-class, and read via `RevocationCache::fresh_marker`
42//! (crate-private) — the only
43//! method in this module that can cause a lookup to be skipped rather
44//! than merely supplemented.
45//!
46//! **The two objects are stored separately and have different lifetimes**
47//! (0.15.0, RFC-ACDP-0014 §8 "Caching", 0.5.0):
48//!
49//! - Facts are **never evicted**. A fact can only leave the cache with the
50//! cache itself. Admission is capped per producer
51//! ([`MAX_FACTS_PER_CLASS`] per trust class, and per origin for the
52//! registry-attested class); a fact refused at the cap still applies to
53//! the call that found it, and the capped slot stops minting markers so
54//! that lookup runs in full on every later call rather than claiming a
55//! completeness the cache can no longer track.
56//! - Markers live in a bounded map ([`MAX_MARKERS`]). Losing a marker only
57//! costs a re-discovery, so the oldest is evicted at capacity. Each one
58//! records the wall-clock time the absence was observed, which the
59//! verdict reports (`RevocationVerdict::NotRevoked::observed_at`).
60//!
61//! Only a complete, untruncated lookup mints a marker. Every `unknown`
62//! outcome (truncated, unanswerable, transient, unverifiable candidate)
63//! returns `Err` before `record_success` is reached, so `unknown` is never
64//! cached as not-revoked.
65//!
66//! **Never call anything here holding the lock across an `.await`** — every
67//! method in this module is synchronous and returns before its caller does
68//! any network I/O.
69
70use std::collections::{HashMap, HashSet};
71use std::sync::{Arc, Mutex, MutexGuard};
72use std::time::{Duration, Instant, SystemTime};
73
74use acdp_types::revocation::{KeyRevocation, RevocationTrustClass};
75
76/// Bound on the number of freshness markers held. Markers are a pure
77/// optimization, so at capacity the oldest one is evicted.
78pub const MAX_MARKERS: usize = 4096;
79
80/// Admission cap on stored facts per producer for the producer-signed
81/// class, and per `(producer, origin)` for the registry-attested class.
82/// Reached only by a producer with a genuinely large revocation history, or
83/// a registry padding results. Stored facts are never removed; a fact
84/// refused here still applies to the call that found it, and the slot stops
85/// minting markers.
86pub const MAX_FACTS_PER_CLASS: usize = 256;
87
88/// A cached, verified [`KeyRevocation`] plus the vantage that minted it.
89///
90/// The origin is consulted only for
91/// [`RevocationTrustClass::RegistryAttested`] facts — `facts_for` filters
92/// those to `origin == current vantage`, per RFC-ACDP-0014 §6 ("apply it
93/// ... for contexts served by or receipted by that same registry"). A
94/// [`RevocationTrustClass::ProducerSigned`] fact is self-contained (§8) and
95/// is never filtered by origin.
96#[derive(Clone)]
97struct StoredFact {
98 rev: KeyRevocation,
99 /// The [`crate::RegistryClient::authority`] whose complete discovery
100 /// stored this fact.
101 origin: String,
102}
103
104/// One producer's verified facts. Never evicted.
105#[derive(Default)]
106struct FactSet {
107 facts: Vec<StoredFact>,
108 /// The producer-signed slot reached [`MAX_FACTS_PER_CLASS`].
109 producer_signed_full: bool,
110 /// Origins whose registry-attested slot reached [`MAX_FACTS_PER_CLASS`].
111 attested_full: HashSet<String>,
112}
113
114impl FactSet {
115 fn count(&self, class: RevocationTrustClass, origin: &str) -> usize {
116 self.facts
117 .iter()
118 .filter(|f| {
119 f.rev.trust_class == class
120 && (class == RevocationTrustClass::ProducerSigned || f.origin == origin)
121 })
122 .count()
123 }
124
125 fn full(&self, class: RevocationTrustClass, origin: &str) -> bool {
126 match class {
127 RevocationTrustClass::ProducerSigned => self.producer_signed_full,
128 RevocationTrustClass::RegistryAttested => self.attested_full.contains(origin),
129 }
130 }
131
132 fn mark_full(&mut self, class: RevocationTrustClass, origin: &str) {
133 match class {
134 RevocationTrustClass::ProducerSigned => self.producer_signed_full = true,
135 RevocationTrustClass::RegistryAttested => {
136 self.attested_full.insert(origin.to_string());
137 }
138 }
139 }
140}
141
142/// `(agent_id, authority, is_registry_attested)`. Never keyed by the search
143/// identity the registry-attested lookup uses internally (that would let
144/// one marker suppress discovery for every producer at that registry).
145type MarkerKey = (String, String, bool);
146
147/// A cached complete absence: when it was observed, on both clocks.
148#[derive(Clone, Copy)]
149struct Marker {
150 /// Monotonic — drives the freshness comparison.
151 minted: Instant,
152 /// Wall clock — reported as the verdict's `observed_at`.
153 observed: SystemTime,
154}
155
156#[derive(Default)]
157struct Inner {
158 facts: HashMap<String, FactSet>,
159 markers: HashMap<MarkerKey, Marker>,
160}
161
162/// N5: an exhaustive `match`, not `matches!`, so a future third
163/// `RevocationTrustClass` variant fails to compile here instead of
164/// silently aliasing onto the `ProducerSigned` marker bucket.
165fn class_key(class: RevocationTrustClass) -> bool {
166 match class {
167 RevocationTrustClass::ProducerSigned => false,
168 RevocationTrustClass::RegistryAttested => true,
169 }
170}
171
172/// A cache of RFC-ACDP-0014 revocation facts and discovery-freshness
173/// markers, injectable into a [`crate::RegistryClient`] via
174/// [`crate::RegistryClient::with_revocation_cache`].
175///
176/// `Clone`, cheap (an `Arc` handle). Sharing one clone across multiple
177/// `RegistryClient`s (or a `CrossRegistryResolver`'s per-authority clients)
178/// is the point: it is how a caller amortizes discovery across many
179/// verifications of the same producer.
180///
181/// Facts (verified revocations) are held for the cache's lifetime and are
182/// always unioned into classification. Markers (complete absences) suppress
183/// a lookup only while younger than
184/// [`RevocationDiscovery::freshness`](crate::RevocationDiscovery::freshness)
185/// — 300 s from both named constructors
186/// ([`crate::RECOMMENDED_NEGATIVE_FRESHNESS`]), `Duration::ZERO` to never
187/// suppress.
188#[derive(Clone)]
189pub struct RevocationCache {
190 inner: Arc<Mutex<Inner>>,
191}
192
193impl RevocationCache {
194 /// A new, empty cache.
195 #[must_use]
196 pub fn new() -> Self {
197 Self {
198 inner: Arc::new(Mutex::new(Inner::default())),
199 }
200 }
201
202 /// Recover from a poisoned lock rather than propagate the panic. Used
203 /// only by the fact read/write paths, where recovering can at worst
204 /// tighten a verdict or cost an extra write — never skip a lookup.
205 fn lock(&self) -> MutexGuard<'_, Inner> {
206 self.inner
207 .lock()
208 .unwrap_or_else(|poisoned| poisoned.into_inner())
209 }
210
211 /// Read cached facts for `agent_id`, filtered to the trust classes the
212 /// CURRENT discovery configuration opted into, with the vantage each
213 /// was first verified at. A registry-attested fact is additionally
214 /// filtered to `origin == current_vantage` (§6); `current_vantage:
215 /// None` excludes every registry-attested fact. A producer-signed fact
216 /// is never filtered by origin (§8).
217 pub(crate) fn facts_for(
218 &self,
219 agent_id: &str,
220 include_registry_attested: bool,
221 current_vantage: Option<&str>,
222 ) -> Vec<(KeyRevocation, String)> {
223 let inner = self.lock();
224 let Some(set) = inner.facts.get(agent_id) else {
225 return Vec::new();
226 };
227 set.facts
228 .iter()
229 .filter(|f| match f.rev.trust_class {
230 RevocationTrustClass::ProducerSigned => true,
231 RevocationTrustClass::RegistryAttested => {
232 include_registry_attested && current_vantage.is_some_and(|v| v == f.origin)
233 }
234 })
235 .map(|f| (f.rev.clone(), f.origin.clone()))
236 .collect()
237 }
238
239 /// When a marker for `(authority, agent_id, class)` is younger than
240 /// `freshness`, the wall-clock time its absence was observed; else
241 /// `None`. The only method that can cause a lookup to be skipped.
242 /// `freshness == Duration::ZERO` never touches the lock.
243 ///
244 /// A poisoned lock returns `None` (run discovery) rather than recover
245 /// a marker present at poison time.
246 pub(crate) fn fresh_marker(
247 &self,
248 authority: &str,
249 agent_id: &str,
250 class: RevocationTrustClass,
251 freshness: Duration,
252 ) -> Option<SystemTime> {
253 if freshness.is_zero() {
254 return None;
255 }
256 let inner = self.inner.lock().ok()?;
257 let key = (
258 agent_id.to_string(),
259 authority.to_string(),
260 class_key(class),
261 );
262 inner
263 .markers
264 .get(&key)
265 .filter(|m| m.minted.elapsed() < freshness)
266 .map(|m| m.observed)
267 }
268
269 /// Whether a fresh marker exists — `fresh_marker(..).is_some()`.
270 #[cfg(test)]
271 fn marker_fresh(
272 &self,
273 authority: &str,
274 agent_id: &str,
275 class: RevocationTrustClass,
276 freshness: Duration,
277 ) -> bool {
278 self.fresh_marker(authority, agent_id, class, freshness)
279 .is_some()
280 }
281
282 /// Record a complete, untruncated discovery lookup: admit new `facts`
283 /// (dedup via `KeyRevocation`'s `Eq`) and, unless this slot is at its
284 /// admission cap, mint a marker for `(authority, agent_id, class)`.
285 ///
286 /// Called ONLY from the tail of `find_revocations` /
287 /// `find_registry_attested_revocations`, immediately before each
288 /// returns `Ok` — a truncated, failed, or otherwise `unknown` lookup
289 /// returns early and never reaches here, so it can neither mint a
290 /// marker nor lose a fact.
291 pub(crate) fn record_success(
292 &self,
293 authority: &str,
294 agent_id: &str,
295 class: RevocationTrustClass,
296 facts: &[KeyRevocation],
297 ) {
298 let mut inner = self.lock();
299 let set = inner.facts.entry(agent_id.to_string()).or_default();
300 for f in facts {
301 // Dedup BEFORE the cap: re-finding a known fact must never trip
302 // the cap.
303 if set.facts.iter().any(|existing| existing.rev == *f) {
304 continue;
305 }
306 let fact_origin = authority;
307 if set.count(f.trust_class, fact_origin) >= MAX_FACTS_PER_CLASS {
308 set.mark_full(f.trust_class, fact_origin);
309 continue;
310 }
311 set.facts.push(StoredFact {
312 rev: f.clone(),
313 origin: fact_origin.to_string(),
314 });
315 }
316 let full = set.full(class, authority);
317 let key = (
318 agent_id.to_string(),
319 authority.to_string(),
320 class_key(class),
321 );
322 if full {
323 inner.markers.remove(&key);
324 return;
325 }
326 if !inner.markers.contains_key(&key) && inner.markers.len() >= MAX_MARKERS {
327 if let Some(oldest) = inner
328 .markers
329 .iter()
330 .min_by_key(|(_, m)| m.minted)
331 .map(|(k, _)| k.clone())
332 {
333 inner.markers.remove(&oldest);
334 }
335 }
336 inner.markers.insert(
337 key,
338 Marker {
339 minted: Instant::now(),
340 observed: SystemTime::now(),
341 },
342 );
343 }
344
345 /// Test-only introspection: the number of facts stored for `agent_id`,
346 /// unfiltered by trust class or origin.
347 #[doc(hidden)]
348 #[cfg(feature = "test-transport")]
349 #[must_use]
350 pub fn fact_count(&self, agent_id: &str) -> usize {
351 self.lock().facts.get(agent_id).map_or(0, |s| s.facts.len())
352 }
353}
354
355impl Default for RevocationCache {
356 fn default() -> Self {
357 Self::new()
358 }
359}
360
361impl std::fmt::Debug for RevocationCache {
362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363 f.debug_struct("RevocationCache").finish_non_exhaustive()
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use chrono::{DateTime, Utc};
371
372 fn rev(fp: &str, class: RevocationTrustClass) -> KeyRevocation {
373 KeyRevocation {
374 revoked_key_fingerprint: fp.into(),
375 compromised_since: "2026-05-01T00:00:00.000Z".parse::<DateTime<Utc>>().unwrap(),
376 reason: None,
377 revoked_key_id: None,
378 revoked_key_controller: acdp_types::primitives::AgentDid::new(
379 "did:web:agents.example.com:p",
380 ),
381 publisher: acdp_types::primitives::AgentDid::new("did:web:agents.example.com:p"),
382 trust_class: class,
383 ctx_id: None,
384 }
385 }
386
387 /// AC4 (dedup): recording the same fact repeatedly does not grow the
388 /// entry.
389 #[test]
390 fn record_success_dedups_identical_facts() {
391 let cache = RevocationCache::new();
392 let r = rev("sha256:aaaa", RevocationTrustClass::ProducerSigned);
393 for _ in 0..5 {
394 cache.record_success(
395 "reg.example",
396 "did:web:p",
397 RevocationTrustClass::ProducerSigned,
398 std::slice::from_ref(&r),
399 );
400 }
401 assert_eq!(
402 cache
403 .facts_for("did:web:p", true, Some("reg.example"))
404 .len(),
405 1
406 );
407 }
408
409 /// Admission cap: once a producer-signed slot is full, new distinct
410 /// facts are not admitted, already-stored ones stay, and the slot
411 /// stops minting markers (never a fresh marker over a set that stopped
412 /// growing).
413 #[test]
414 fn record_success_caps_admission_and_drops_markers_at_cap() {
415 let cache = RevocationCache::new();
416 for i in 0..(MAX_FACTS_PER_CLASS + 5) {
417 let fp = format!("sha256:{i:064x}");
418 let r = rev(&fp, RevocationTrustClass::ProducerSigned);
419 cache.record_success(
420 "reg.example",
421 "did:web:p",
422 RevocationTrustClass::ProducerSigned,
423 &[r],
424 );
425 }
426 assert_eq!(
427 cache
428 .facts_for("did:web:p", true, Some("reg.example"))
429 .len(),
430 MAX_FACTS_PER_CLASS
431 );
432 assert!(!cache.marker_fresh(
433 "reg.example",
434 "did:web:p",
435 RevocationTrustClass::ProducerSigned,
436 Duration::from_secs(3600)
437 ));
438 }
439
440 /// A full registry-attested slot at one origin does not stop markers
441 /// for the producer-signed class or for another origin.
442 #[test]
443 fn attested_cap_is_per_origin() {
444 let cache = RevocationCache::new();
445 for i in 0..=MAX_FACTS_PER_CLASS {
446 let fp = format!("sha256:{i:064x}");
447 cache.record_success(
448 "a.example",
449 "did:web:p",
450 RevocationTrustClass::RegistryAttested,
451 &[rev(&fp, RevocationTrustClass::RegistryAttested)],
452 );
453 }
454 let fresh = |auth: &str, class| {
455 cache.marker_fresh(auth, "did:web:p", class, Duration::from_secs(3600))
456 };
457 assert!(!fresh("a.example", RevocationTrustClass::RegistryAttested));
458 cache.record_success(
459 "b.example",
460 "did:web:p",
461 RevocationTrustClass::RegistryAttested,
462 &[],
463 );
464 cache.record_success(
465 "a.example",
466 "did:web:p",
467 RevocationTrustClass::ProducerSigned,
468 &[],
469 );
470 assert!(fresh("b.example", RevocationTrustClass::RegistryAttested));
471 assert!(fresh("a.example", RevocationTrustClass::ProducerSigned));
472 }
473
474 /// `facts_for` filters registry-attested facts out for a
475 /// producer-signed-only caller, and in for an all-trust-classes one
476 /// reading from the SAME vantage that minted the fact.
477 #[test]
478 fn facts_for_filters_by_trust_class() {
479 let cache = RevocationCache::new();
480 let attested = rev("sha256:bbbb", RevocationTrustClass::RegistryAttested);
481 cache.record_success(
482 "reg.example",
483 "did:web:p",
484 RevocationTrustClass::RegistryAttested,
485 &[attested],
486 );
487 assert!(cache
488 .facts_for("did:web:p", false, Some("reg.example"))
489 .is_empty());
490 assert_eq!(
491 cache
492 .facts_for("did:web:p", true, Some("reg.example"))
493 .len(),
494 1
495 );
496 }
497
498 /// BLOCKER-1: a registry-attested fact minted at vantage A must NOT be
499 /// returned when reading at a DIFFERENT vantage B, even under
500 /// `include_registry_attested: true` — RFC-ACDP-0014 §6 scopes a
501 /// registry-attested claim to the registry that made it. `None` (no
502 /// resolvable vantage on the reading call) must behave the same as a
503 /// mismatched vantage: exclude the fact, never include it.
504 #[test]
505 fn facts_for_filters_registry_attested_facts_by_origin() {
506 let cache = RevocationCache::new();
507 let attested = rev("sha256:cccc", RevocationTrustClass::RegistryAttested);
508 cache.record_success(
509 "a.example",
510 "did:web:p",
511 RevocationTrustClass::RegistryAttested,
512 &[attested],
513 );
514 assert_eq!(
515 cache.facts_for("did:web:p", true, Some("a.example")).len(),
516 1,
517 "reading from the SAME vantage that minted the fact must include it"
518 );
519 assert!(
520 cache
521 .facts_for("did:web:p", true, Some("b.example"))
522 .is_empty(),
523 "reading from a DIFFERENT vantage must exclude a registry-attested fact"
524 );
525 assert!(
526 cache.facts_for("did:web:p", true, None).is_empty(),
527 "reading with no resolvable current vantage must exclude a registry-attested \
528 fact, not include it by default"
529 );
530 }
531
532 /// BLOCKER-1's other direction: a producer-signed fact is
533 /// self-contained (RFC-ACDP-0014 §8) and is NEVER filtered by origin —
534 /// it must be returned when read from a vantage other than the one
535 /// that minted it.
536 #[test]
537 fn facts_for_never_filters_producer_signed_facts_by_origin() {
538 let cache = RevocationCache::new();
539 let signed = rev("sha256:dddd", RevocationTrustClass::ProducerSigned);
540 cache.record_success(
541 "a.example",
542 "did:web:p",
543 RevocationTrustClass::ProducerSigned,
544 &[signed],
545 );
546 assert_eq!(
547 cache.facts_for("did:web:p", false, Some("b.example")).len(),
548 1
549 );
550 assert_eq!(cache.facts_for("did:web:p", false, None).len(), 1);
551 }
552
553 /// Markers are per-vantage: minting one for authority A does not make
554 /// `marker_fresh` true for authority B.
555 #[test]
556 fn markers_are_per_vantage() {
557 let cache = RevocationCache::new();
558 cache.record_success(
559 "a.example",
560 "did:web:p",
561 RevocationTrustClass::ProducerSigned,
562 &[],
563 );
564 assert!(cache.marker_fresh(
565 "a.example",
566 "did:web:p",
567 RevocationTrustClass::ProducerSigned,
568 Duration::from_secs(60)
569 ));
570 assert!(!cache.marker_fresh(
571 "b.example",
572 "did:web:p",
573 RevocationTrustClass::ProducerSigned,
574 Duration::from_secs(60)
575 ));
576 }
577
578 /// Markers are per trust class too.
579 #[test]
580 fn markers_are_per_trust_class() {
581 let cache = RevocationCache::new();
582 cache.record_success(
583 "a.example",
584 "did:web:p",
585 RevocationTrustClass::ProducerSigned,
586 &[],
587 );
588 assert!(!cache.marker_fresh(
589 "a.example",
590 "did:web:p",
591 RevocationTrustClass::RegistryAttested,
592 Duration::from_secs(60)
593 ));
594 }
595
596 /// `freshness: ZERO` never reports a marker as fresh, regardless of how
597 /// recently it was minted.
598 #[test]
599 fn zero_freshness_never_suppresses() {
600 let cache = RevocationCache::new();
601 cache.record_success(
602 "a.example",
603 "did:web:p",
604 RevocationTrustClass::ProducerSigned,
605 &[],
606 );
607 assert!(!cache.marker_fresh(
608 "a.example",
609 "did:web:p",
610 RevocationTrustClass::ProducerSigned,
611 Duration::ZERO
612 ));
613 }
614
615 /// RFC-ACDP-0014 §8 "Caching" (0.5.0): verified revocations are
616 /// retained for the cache's lifetime — marker pressure evicts markers
617 /// only, never a fact, and facts are stored separately from markers.
618 #[test]
619 fn facts_are_never_evicted_by_marker_pressure() {
620 let cache = RevocationCache::new();
621 let revoked = rev(
622 "sha256:aaaa000000000000000000000000000000000000000000000000000000000000",
623 RevocationTrustClass::ProducerSigned,
624 );
625 cache.record_success(
626 "reg.example",
627 "did:web:revoked",
628 RevocationTrustClass::ProducerSigned,
629 std::slice::from_ref(&revoked),
630 );
631 // Far more distinct producers than the marker bound.
632 for i in 0..(MAX_MARKERS * 2) {
633 cache.record_success(
634 "reg.example",
635 &format!("did:web:p{i}"),
636 RevocationTrustClass::ProducerSigned,
637 &[],
638 );
639 }
640 let facts = cache.facts_for("did:web:revoked", false, None);
641 assert_eq!(facts.len(), 1, "the verified revocation must survive");
642 assert_eq!(facts[0].0, revoked);
643 assert_eq!(facts[0].1, "reg.example");
644 // Its marker was the oldest, so it went — the absence is
645 // re-discovered, the fact is not forgotten.
646 assert!(!cache.marker_fresh(
647 "reg.example",
648 "did:web:revoked",
649 RevocationTrustClass::ProducerSigned,
650 Duration::from_secs(3600)
651 ));
652 assert!(cache.lock().markers.len() <= MAX_MARKERS);
653 }
654
655 /// Marker eviction removes the oldest marker.
656 #[test]
657 fn marker_eviction_removes_oldest() {
658 let cache = RevocationCache::new();
659 for i in 0..=MAX_MARKERS {
660 cache.record_success(
661 "reg.example",
662 &format!("did:web:p{i}"),
663 RevocationTrustClass::ProducerSigned,
664 &[],
665 );
666 }
667 let fresh = |agent: &str| {
668 cache.marker_fresh(
669 "reg.example",
670 agent,
671 RevocationTrustClass::ProducerSigned,
672 Duration::from_secs(3600),
673 )
674 };
675 assert!(!fresh("did:web:p0"));
676 assert!(fresh("did:web:p1"));
677 assert!(fresh(&format!("did:web:p{MAX_MARKERS}")));
678 }
679
680 /// The observed wall-clock time is reported with a fresh marker.
681 #[test]
682 fn fresh_marker_reports_observed_time() {
683 let cache = RevocationCache::new();
684 let before = SystemTime::now();
685 cache.record_success(
686 "a.example",
687 "did:web:p",
688 RevocationTrustClass::ProducerSigned,
689 &[],
690 );
691 let after = SystemTime::now();
692 let observed = cache
693 .fresh_marker(
694 "a.example",
695 "did:web:p",
696 RevocationTrustClass::ProducerSigned,
697 Duration::from_secs(60),
698 )
699 .expect("fresh");
700 assert!(observed >= before && observed <= after);
701 }
702}