Skip to main content

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}