Skip to main content

rustls_crl_refresh/
cache.rs

1//! Process-wide CRL cache keyed by source identity. CRL bytes mutate
2//! in place across refresh cycles, so any surrounding `Arc<ClientConfig>`
3//! / `Arc<ServerConfig>` identity stays stable.
4//!
5//! Wrapper verifiers in [`crate::verifier`] pull the latest snapshot
6//! per handshake.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::Duration;
12
13use async_trait::async_trait;
14use parking_lot::RwLock;
15use rustls_pki_types::CertificateRevocationListDer;
16use time::OffsetDateTime;
17use tokio_util::sync::CancellationToken;
18
19/// Source identity used as the cache key. The fingerprint hashes the
20/// path / URL string, **not** the fetched bytes — so refresh cycles
21/// never invalidate downstream caches keyed off this identity.
22#[derive(Clone, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)]
23pub enum CrlSourceId {
24	File(PathBuf),
25	Url(String),
26}
27
28impl CrlSourceId {
29	#[must_use]
30	pub fn from_file<P: Into<PathBuf>>(path: P) -> Self {
31		Self::File(path.into())
32	}
33
34	#[must_use]
35	pub fn from_url<S: Into<String>>(url: S) -> Self {
36		Self::Url(url.into())
37	}
38}
39
40/// Per-source policy on what to do when a CRL becomes unavailable.
41#[derive(Copy, Clone, Debug, PartialEq, Eq)]
42pub enum CrlFetchFailure {
43	/// Keep using last-known bytes; if never loaded, silently drop.
44	Tolerate,
45	/// Surface as a hard error from `snapshot` so handshakes fail.
46	Reject,
47}
48
49#[derive(Copy, Clone, Debug, PartialEq, Eq)]
50enum HealthState {
51	Healthy,
52	Unavailable,
53}
54
55const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
56const FALLBACK_INTERVAL: Duration = Duration::from_hours(4);
57const REFRESH_LEAD: Duration = Duration::from_hours(1);
58
59/// Floor on the refresh-loop sleep. A CRL whose `nextUpdate` is in
60/// the past would otherwise yield a `Duration::ZERO` sleep and busy-
61/// loop the fetcher against the CDN. 30s gives the CDN a chance to
62/// publish a fresh CRL while preventing per-task hot spins.
63const MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
64
65/// Upper bound on the failure-backoff exponential. With base 30s and
66/// max 10 min the schedule is roughly `30s, 1m, 2m, 4m, 8m, 10m, ...`
67/// — long enough to avoid hammering an unhealthy upstream, short
68/// enough that recovery is sub-half-hour even after a long outage.
69const MAX_REFRESH_BACKOFF: Duration = Duration::from_mins(10);
70
71struct CrlEntry {
72	bytes: Option<Arc<CertificateRevocationListDer<'static>>>,
73	next_update: Option<OffsetDateTime>,
74	last_success: Option<OffsetDateTime>,
75	last_failure: Option<OffsetDateTime>,
76	fetch_failure: CrlFetchFailure,
77	last_logged_state: HealthState,
78	/// Consecutive failed fetches since the last success. Drives
79	/// [`expo_backoff`] for the next refresh sleep, regardless of
80	/// whether the entry's policy is `Tolerate` or `Reject` — both
81	/// classes count.
82	consecutive_failures: u32,
83}
84
85/// Exponential backoff helper. `failures == 0` returns `min`;
86/// `failures == 1` returns `min` (we treat the first failure as
87/// "wait one base interval"); each additional failure doubles, capped
88/// at `max`. Independent of any wall-clock reference.
89fn expo_backoff(failures: u32, min: Duration, max: Duration) -> Duration {
90	if failures <= 1 {
91		return min;
92	}
93	let exp = failures.saturating_sub(1).min(20);
94	let multiplier: u64 = 1u64 << exp;
95	let secs = min.as_secs().saturating_mul(multiplier);
96	let candidate = Duration::from_secs(secs);
97	if candidate > max { max } else { candidate }
98}
99
100/// Pluggable transport. Production wires up an HTTP / `tokio::fs`
101/// fetcher; tests substitute in-memory mocks to drive failure paths
102/// and rotation.
103#[async_trait]
104pub trait CrlFetcher: Send + Sync {
105	/// Fetch the raw bytes for one source. File source: typically read
106	/// from disk. URL source: typically HTTP GET. Returns DER bytes on
107	/// success; PEM input is decoded by the cache via `rustls-pemfile`
108	/// before parsing. Caller's `await` is timed out at 30 s.
109	async fn fetch(&self, src: &CrlSourceId) -> Result<Vec<u8>, String>;
110}
111
112/// Process-wide CRL cache.
113pub struct CrlCache {
114	inner: RwLock<HashMap<CrlSourceId, CrlEntry>>,
115	fetcher: Arc<dyn CrlFetcher>,
116}
117
118impl std::fmt::Debug for CrlCache {
119	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120		let guard = self.inner.read();
121		f.debug_struct("CrlCache").field("entries", &guard.len()).finish_non_exhaustive()
122	}
123}
124
125impl CrlCache {
126	#[must_use]
127	pub fn new(fetcher: Arc<dyn CrlFetcher>) -> Arc<Self> {
128		Arc::new(Self { inner: RwLock::new(HashMap::new()), fetcher })
129	}
130
131	/// Synchronous link-time loader. Each source is fetched with a
132	/// 30-second timeout. On success, parses `nextUpdate` and stores
133	/// the bytes. On failure, behavior depends on `policy`:
134	///
135	/// * [`CrlFetchFailure::Tolerate`] — record the failure and
136	///   continue. Subsequent [`Self::snapshot`] calls for this source
137	///   silently drop it until a refresh succeeds.
138	/// * [`CrlFetchFailure::Reject`] — propagate the error so the
139	///   caller can fail link.
140	///
141	/// # Panics
142	///
143	/// Must be called from within a multi-thread tokio runtime — uses
144	/// `block_in_place` + `Handle::current().block_on`. Single-thread
145	/// runtimes panic.
146	///
147	/// # Errors
148	///
149	/// String description of the first reject-policy source that
150	/// failed to load. Tolerate-policy failures are kept silent at
151	/// link time (logged as transitions, but `Ok` returned).
152	pub fn ensure_loaded(&self, sources: &[(CrlSourceId, CrlFetchFailure)]) -> Result<(), String> {
153		tokio::task::block_in_place(|| {
154			tokio::runtime::Handle::current().block_on(async {
155				for (src, policy) in sources {
156					self.fetch_source(src, *policy).await?;
157				}
158				Ok(())
159			})
160		})
161	}
162
163	/// Read-only handshake-time accessor. Returns the latest CRL bytes
164	/// for each requested source. Sources whose policy is `tolerate`
165	/// and whose entry has never successfully loaded are silently
166	/// dropped from the result. Sources whose policy is `reject` and
167	/// whose entry is currently `unavailable` cause this function to
168	/// return `Err` — wrappers turn that into a handshake failure.
169	///
170	/// # Errors
171	///
172	/// Returns the first reject-policy source whose state is
173	/// `Unavailable`.
174	pub fn snapshot(
175		&self,
176		sources: &[CrlSourceId],
177	) -> Result<Vec<Arc<CertificateRevocationListDer<'static>>>, String> {
178		let now = OffsetDateTime::now_utc();
179		let guard = self.inner.read();
180		let mut out = Vec::with_capacity(sources.len());
181		for src in sources {
182			let Some(entry) = guard.get(src) else {
183				return Err(format!("crl source not registered: {src:?}"));
184			};
185			let state = entry_state(entry, now);
186			match (state, entry.fetch_failure) {
187				(HealthState::Healthy, _) => {
188					if let Some(bytes) = &entry.bytes {
189						out.push(Arc::clone(bytes));
190					}
191				}
192				(HealthState::Unavailable, CrlFetchFailure::Tolerate) => {
193					// `tolerate` + cached but stale: keep using the
194					// last-known bytes. `tolerate` + never-loaded:
195					// silently drop.
196					if let Some(bytes) = &entry.bytes {
197						out.push(Arc::clone(bytes));
198					}
199				}
200				(HealthState::Unavailable, CrlFetchFailure::Reject) => {
201					return Err(format!("crl source unavailable (reject policy): {src:?}"));
202				}
203			}
204		}
205		Ok(out)
206	}
207
208	/// Reload-friendly variant of [`Self::ensure_loaded`]: only fetches
209	/// sources whose entry is not already registered. Useful from the
210	/// reload path so an unchanged URL source doesn't re-block on a
211	/// cold fetch every time the watcher fires.
212	///
213	/// File sources are always re-fetched (their bytes are local).
214	///
215	/// # Panics
216	///
217	/// Same multi-thread runtime requirement as [`Self::ensure_loaded`].
218	///
219	/// # Errors
220	///
221	/// As [`Self::ensure_loaded`].
222	pub fn ensure_loaded_new(
223		&self,
224		sources: &[(CrlSourceId, CrlFetchFailure)],
225	) -> Result<(), String> {
226		let to_fetch: Vec<(CrlSourceId, CrlFetchFailure)> = {
227			let guard = self.inner.read();
228			sources
229				.iter()
230				.filter(|(id, _)| match id {
231					CrlSourceId::File(_) => true,
232					CrlSourceId::Url(_) => !guard.contains_key(id),
233				})
234				.cloned()
235				.collect()
236		};
237		if to_fetch.is_empty() {
238			return Ok(());
239		}
240		self.ensure_loaded(&to_fetch)
241	}
242
243	/// Spawn the background refresh loop. One tokio task per URL
244	/// source — file sources don't refresh here (callers re-read them
245	/// via [`Self::ensure_loaded`] on reload). Cancellation token lets
246	/// the host stop the workers at shutdown.
247	pub fn spawn_refresher(self: &Arc<Self>, shutdown: &CancellationToken) {
248		let urls: Vec<CrlSourceId> = {
249			let guard = self.inner.read();
250			guard.keys().filter(|k| matches!(k, CrlSourceId::Url(_))).cloned().collect()
251		};
252		for src in urls {
253			let cache = Arc::clone(self);
254			let shutdown = shutdown.clone();
255			tokio::spawn(async move {
256				cache.refresh_loop(src, shutdown).await;
257			});
258		}
259	}
260
261	async fn refresh_loop(self: Arc<Self>, src: CrlSourceId, shutdown: CancellationToken) {
262		loop {
263			let policy = {
264				let guard = self.inner.read();
265				match guard.get(&src) {
266					Some(e) => e.fetch_failure,
267					None => return,
268				}
269			};
270			let next_in = self.next_refresh_delay(&src);
271			tokio::select! {
272				() = shutdown.cancelled() => return,
273				() = tokio::time::sleep(next_in) => {}
274			}
275			let _ = self.fetch_source(&src, policy).await;
276		}
277	}
278
279	fn next_refresh_delay(&self, src: &CrlSourceId) -> Duration {
280		let guard = self.inner.read();
281		let Some(entry) = guard.get(src) else {
282			return FALLBACK_INTERVAL;
283		};
284		// While the source is failing, ignore `nextUpdate` — the CDN
285		// is unhealthy and crowding more requests under a stale
286		// nextUpdate window won't help. Walk the exponential schedule
287		// up to MAX_REFRESH_BACKOFF; a single successful fetch resets
288		// the counter and we fall back into the nextUpdate-driven
289		// schedule below.
290		if entry.consecutive_failures > 0 {
291			return expo_backoff(entry.consecutive_failures, MIN_REFRESH_INTERVAL, MAX_REFRESH_BACKOFF);
292		}
293		let Some(nu) = entry.next_update else {
294			return FALLBACK_INTERVAL;
295		};
296		let now = OffsetDateTime::now_utc();
297		let target = nu - REFRESH_LEAD;
298		let raw = if target <= now {
299			Duration::ZERO
300		} else {
301			let delta = target - now;
302			delta.try_into().unwrap_or(FALLBACK_INTERVAL)
303		};
304		// Floor the sleep so a CRL whose `nextUpdate` is already in
305		// the past doesn't busy-spin the refresh loop against the CDN.
306		if raw < MIN_REFRESH_INTERVAL { MIN_REFRESH_INTERVAL } else { raw }
307	}
308
309	async fn fetch_source(&self, src: &CrlSourceId, policy: CrlFetchFailure) -> Result<(), String> {
310		// Insert / refresh policy on the entry up front so concurrent
311		// snapshot() readers see a consistent state machine.
312		{
313			let mut guard = self.inner.write();
314			let entry = guard.entry(src.clone()).or_insert_with(|| CrlEntry {
315				bytes: None,
316				next_update: None,
317				last_success: None,
318				last_failure: None,
319				fetch_failure: policy,
320				last_logged_state: HealthState::Unavailable,
321				consecutive_failures: 0,
322			});
323			entry.fetch_failure = policy;
324		}
325
326		let outcome = tokio::time::timeout(FETCH_TIMEOUT, self.fetcher.fetch(src)).await;
327		let result: Result<Vec<u8>, String> = match outcome {
328			Ok(r) => r,
329			Err(_) => Err(format!("crl fetch timeout after {}s", FETCH_TIMEOUT.as_secs())),
330		};
331
332		// Pre-decode any PEM-armoured CRL into raw DER before parsing
333		// `nextUpdate`. Callers can hand back either form.
334		let result = result.map(|bytes| decode_pem_crl(&bytes).unwrap_or(bytes));
335
336		match result {
337			Ok(bytes) => {
338				let next_update = parse_next_update(&bytes);
339				let der: CertificateRevocationListDer<'static> = CertificateRevocationListDer::from(bytes);
340				let prev_state = {
341					let mut guard = self.inner.write();
342					let entry = guard.get_mut(src).expect("entry inserted above");
343					let prev = entry.last_logged_state;
344					entry.bytes = Some(Arc::new(der));
345					entry.next_update = next_update;
346					entry.last_success = Some(OffsetDateTime::now_utc());
347					entry.last_logged_state = HealthState::Healthy;
348					// Reset the failure counter so the refresh loop
349					// falls back to the nextUpdate-driven schedule.
350					entry.consecutive_failures = 0;
351					prev
352				};
353				if prev_state == HealthState::Unavailable {
354					tracing::info!(?src, "crl source recovered");
355				}
356				Ok(())
357			}
358			Err(err) => {
359				let (prev_state, policy) = {
360					let mut guard = self.inner.write();
361					let entry = guard.get_mut(src).expect("entry inserted above");
362					entry.last_failure = Some(OffsetDateTime::now_utc());
363					let prev = entry.last_logged_state;
364					entry.last_logged_state = HealthState::Unavailable;
365					// Tracked independent of policy: a tolerated
366					// failure still counts toward the backoff so
367					// the loop doesn't hammer a sick CDN.
368					entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
369					(prev, entry.fetch_failure)
370				};
371				if prev_state == HealthState::Healthy {
372					match policy {
373						CrlFetchFailure::Tolerate => {
374							tracing::warn!(?src, error = %err, "crl source became unavailable; using last-known bytes");
375						}
376						CrlFetchFailure::Reject => {
377							tracing::error!(?src, error = %err, "crl source became unavailable; reject policy will fail handshakes");
378						}
379					}
380				}
381				match policy {
382					CrlFetchFailure::Tolerate => Ok(()),
383					CrlFetchFailure::Reject => Err(format!("crl source {src:?}: {err}")),
384				}
385			}
386		}
387	}
388}
389
390fn entry_state(entry: &CrlEntry, now: OffsetDateTime) -> HealthState {
391	let Some(_bytes) = entry.bytes.as_ref() else {
392		return HealthState::Unavailable;
393	};
394	let Some(nu) = entry.next_update else {
395		return HealthState::Healthy;
396	};
397	if now <= nu {
398		return HealthState::Healthy;
399	}
400	// Stale. Unavailable iff the most recent refetch attempt failed.
401	match (entry.last_success, entry.last_failure) {
402		(Some(s), Some(f)) if f > s => HealthState::Unavailable,
403		_ => HealthState::Healthy,
404	}
405}
406
407fn parse_next_update(der: &[u8]) -> Option<OffsetDateTime> {
408	use x509_parser::prelude::FromDer as _;
409	let (_rest, crl) = x509_parser::revocation_list::CertificateRevocationList::from_der(der).ok()?;
410	let nu = crl.tbs_cert_list.next_update?;
411	nu.to_datetime().into()
412}
413
414/// Read a CRL file from disk and return raw DER bytes. PEM-armoured
415/// inputs are decoded; non-PEM inputs pass through unchanged. Useful
416/// for [`CrlFetcher`] implementations that back `CrlSourceId::File`.
417///
418/// # Errors
419///
420/// Wraps the underlying `tokio::fs::read` error.
421pub async fn read_crl_file(path: &Path) -> Result<Vec<u8>, String> {
422	let bytes =
423		tokio::fs::read(path).await.map_err(|e| format!("read crl file {}: {e}", path.display()))?;
424	if let Some(der) = decode_pem_crl(&bytes) {
425		return Ok(der);
426	}
427	Ok(bytes)
428}
429
430fn decode_pem_crl(bytes: &[u8]) -> Option<Vec<u8>> {
431	let mut reader = std::io::BufReader::new(bytes);
432	if let Some(der) = rustls_pemfile::crls(&mut reader).flatten().next() {
433		return Some(der.as_ref().to_vec());
434	}
435	None
436}
437
438/// Dedupe a CRL source list by [`CrlSourceId`], keeping the strictest
439/// policy ([`CrlFetchFailure::Reject`] wins over
440/// [`CrlFetchFailure::Tolerate`]) when the same source appears
441/// multiple times. Order in the result is the first-seen order.
442#[must_use]
443pub fn dedupe_crl_sources(
444	iter: impl IntoIterator<Item = (CrlSourceId, CrlFetchFailure)>,
445) -> Vec<(CrlSourceId, CrlFetchFailure)> {
446	use std::collections::HashMap;
447	let mut by_id: HashMap<CrlSourceId, CrlFetchFailure> = HashMap::new();
448	let mut order: Vec<CrlSourceId> = Vec::new();
449	for (id, policy) in iter {
450		match by_id.entry(id.clone()) {
451			std::collections::hash_map::Entry::Vacant(slot) => {
452				slot.insert(policy);
453				order.push(id);
454			}
455			std::collections::hash_map::Entry::Occupied(mut slot) => {
456				if matches!(policy, CrlFetchFailure::Reject) {
457					slot.insert(CrlFetchFailure::Reject);
458				}
459			}
460		}
461	}
462	order
463		.into_iter()
464		.map(|id| {
465			let policy = by_id[&id];
466			(id, policy)
467		})
468		.collect()
469}
470
471#[cfg(test)]
472mod tests {
473	use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
474
475	use super::*;
476
477	struct StaticFetcher {
478		bytes: Vec<u8>,
479		count: AtomicUsize,
480	}
481
482	#[async_trait]
483	impl CrlFetcher for StaticFetcher {
484		async fn fetch(&self, _src: &CrlSourceId) -> Result<Vec<u8>, String> {
485			self.count.fetch_add(1, Ordering::SeqCst);
486			Ok(self.bytes.clone())
487		}
488	}
489
490	struct AlwaysFailFetcher {
491		count: AtomicUsize,
492	}
493
494	#[async_trait]
495	impl CrlFetcher for AlwaysFailFetcher {
496		async fn fetch(&self, _src: &CrlSourceId) -> Result<Vec<u8>, String> {
497			self.count.fetch_add(1, Ordering::SeqCst);
498			Err("fixture failure".into())
499		}
500	}
501
502	struct FlippingFetcher {
503		ok_bytes: Vec<u8>,
504		succeed: AtomicBool,
505	}
506
507	#[async_trait]
508	impl CrlFetcher for FlippingFetcher {
509		async fn fetch(&self, _src: &CrlSourceId) -> Result<Vec<u8>, String> {
510			if self.succeed.load(Ordering::SeqCst) {
511				Ok(self.ok_bytes.clone())
512			} else {
513				Err("flip failure".into())
514			}
515		}
516	}
517
518	// Minimal CRL DER built once via rcgen. Cheap enough at test time.
519	fn fixture_crl_bytes() -> Vec<u8> {
520		use rcgen::{
521			CertificateParams, CertificateRevocationListParams, Issuer, KeyIdMethod, KeyPair,
522			KeyUsagePurpose, RevocationReason, RevokedCertParams, SerialNumber,
523		};
524		let mut ca_params = CertificateParams::new(vec!["fixture ca".into()]).expect("ca params");
525		ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
526		ca_params.key_usages = vec![
527			KeyUsagePurpose::KeyCertSign,
528			KeyUsagePurpose::DigitalSignature,
529			KeyUsagePurpose::CrlSign,
530		];
531		let ca_key = KeyPair::generate().expect("ca key");
532		let issuer = Issuer::new(ca_params, ca_key);
533
534		let now = time::OffsetDateTime::now_utc();
535		let params = CertificateRevocationListParams {
536			this_update: now,
537			next_update: now + time::Duration::hours(24),
538			crl_number: SerialNumber::from(1u64),
539			issuing_distribution_point: None,
540			revoked_certs: vec![RevokedCertParams {
541				serial_number: SerialNumber::from(42u64),
542				revocation_time: now,
543				reason_code: Some(RevocationReason::KeyCompromise),
544				invalidity_date: None,
545			}],
546			key_identifier_method: KeyIdMethod::Sha256,
547		};
548		let crl = params.signed_by(&issuer).expect("sign crl");
549		crl.der().as_ref().to_vec()
550	}
551
552	#[tokio::test(flavor = "multi_thread")]
553	async fn snapshot_serves_same_arc_for_same_source() {
554		let bytes = fixture_crl_bytes();
555		let fetcher = Arc::new(StaticFetcher { bytes, count: AtomicUsize::new(0) });
556		let cache = CrlCache::new(fetcher.clone());
557		let src = CrlSourceId::Url("https://crl.example/fixture".into());
558		cache.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)]).expect("load");
559		let s1 = cache.snapshot(std::slice::from_ref(&src)).expect("snap");
560		let s2 = cache.snapshot(std::slice::from_ref(&src)).expect("snap");
561		assert_eq!(s1.len(), 1);
562		assert!(Arc::ptr_eq(&s1[0], &s2[0]), "snapshot must clone same Arc");
563		assert_eq!(fetcher.count.load(Ordering::SeqCst), 1, "no extra fetches");
564	}
565
566	#[tokio::test(flavor = "multi_thread")]
567	async fn tolerate_unavailable_silently_drops_source() {
568		let fetcher = Arc::new(AlwaysFailFetcher { count: AtomicUsize::new(0) });
569		let cache = CrlCache::new(fetcher);
570		let src = CrlSourceId::Url("https://crl.example/down".into());
571		cache
572			.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)])
573			.expect("tolerate must not propagate");
574		let snap = cache.snapshot(&[src]).expect("snapshot ok");
575		assert!(snap.is_empty(), "tolerate + never-loaded => silently dropped");
576	}
577
578	#[tokio::test(flavor = "multi_thread")]
579	async fn reject_unavailable_returns_err_at_link() {
580		let fetcher = Arc::new(AlwaysFailFetcher { count: AtomicUsize::new(0) });
581		let cache = CrlCache::new(fetcher);
582		let src = CrlSourceId::Url("https://crl.example/down".into());
583		let err =
584			cache.ensure_loaded(&[(src, CrlFetchFailure::Reject)]).expect_err("reject must fail-closed");
585		assert!(err.contains("fixture failure"), "{err}");
586	}
587
588	#[tokio::test(flavor = "multi_thread")]
589	async fn reject_unavailable_returns_err_at_snapshot() {
590		let fetcher = Arc::new(AlwaysFailFetcher { count: AtomicUsize::new(0) });
591		let cache = CrlCache::new(fetcher);
592		let src = CrlSourceId::Url("https://crl.example/down".into());
593		// Tolerate at link time so ensure_loaded returns Ok, then ask
594		// for a reject snapshot — same entry, harder policy. The
595		// snapshot path independently checks reject + unavailable.
596		cache.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)]).expect("tolerate at link");
597		assert!(cache.ensure_loaded(&[(src.clone(), CrlFetchFailure::Reject)]).is_err());
598		let snap_err = cache.snapshot(&[src]).expect_err("reject snapshot must fail-closed");
599		assert!(snap_err.contains("unavailable"), "{snap_err}");
600	}
601
602	#[tokio::test(flavor = "multi_thread")]
603	async fn next_update_parsed_from_fixture_crl() {
604		let bytes = fixture_crl_bytes();
605		let nu = parse_next_update(&bytes).expect("nextUpdate present");
606		assert!(nu > time::OffsetDateTime::now_utc(), "fixture nextUpdate is in future");
607	}
608
609	#[tokio::test(flavor = "multi_thread")]
610	async fn refresh_loop_updates_bytes_in_place() {
611		let bytes = fixture_crl_bytes();
612		let fetcher =
613			Arc::new(FlippingFetcher { ok_bytes: bytes.clone(), succeed: AtomicBool::new(true) });
614		let cache = CrlCache::new(fetcher.clone());
615		let src = CrlSourceId::Url("https://crl.example/flipping".into());
616		cache.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)]).expect("initial load");
617		let first = cache.snapshot(std::slice::from_ref(&src)).expect("snap");
618		assert_eq!(first.len(), 1);
619
620		fetcher.succeed.store(false, Ordering::SeqCst);
621		cache
622			.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)])
623			.expect("tolerate keeps last-known bytes");
624
625		let after = cache.snapshot(&[src]).expect("snap");
626		assert_eq!(after.len(), 1);
627		assert!(Arc::ptr_eq(&first[0], &after[0]), "Arc identity preserved across failed refresh");
628	}
629
630	#[tokio::test(flavor = "multi_thread")]
631	async fn snapshot_unknown_source_errors() {
632		let fetcher = Arc::new(StaticFetcher { bytes: vec![], count: AtomicUsize::new(0) });
633		let cache = CrlCache::new(fetcher);
634		let src = CrlSourceId::Url("https://crl.example/never-loaded".into());
635		assert!(cache.snapshot(&[src]).is_err());
636	}
637
638	#[tokio::test(flavor = "multi_thread")]
639	async fn next_refresh_delay_clamps_to_min_when_next_update_is_past() {
640		// A CRL whose `nextUpdate` is already in the past must NOT
641		// produce a Duration::ZERO sleep; the loop would busy-spin.
642		// Inject an entry directly so we don't fight test wall-clock.
643		let fetcher = Arc::new(AlwaysFailFetcher { count: AtomicUsize::new(0) });
644		let cache = CrlCache::new(fetcher);
645		let src = CrlSourceId::Url("https://crl.example/past".into());
646		{
647			let mut guard = cache.inner.write();
648			guard.insert(
649				src.clone(),
650				CrlEntry {
651					bytes: None,
652					next_update: Some(OffsetDateTime::now_utc() - time::Duration::hours(1)),
653					last_success: None,
654					last_failure: None,
655					fetch_failure: CrlFetchFailure::Tolerate,
656					last_logged_state: HealthState::Healthy,
657					consecutive_failures: 0,
658				},
659			);
660		}
661		let d = cache.next_refresh_delay(&src);
662		assert!(d >= MIN_REFRESH_INTERVAL, "got {d:?}, want >= {MIN_REFRESH_INTERVAL:?}");
663	}
664
665	#[tokio::test(flavor = "multi_thread")]
666	async fn next_refresh_delay_uses_expo_backoff_after_failures() {
667		// Inject a synthetic failure history and confirm the delay
668		// follows the expo schedule rather than the nextUpdate timer.
669		let fetcher = Arc::new(AlwaysFailFetcher { count: AtomicUsize::new(0) });
670		let cache = CrlCache::new(fetcher);
671		let src = CrlSourceId::Url("https://crl.example/sick".into());
672		{
673			let mut guard = cache.inner.write();
674			guard.insert(
675				src.clone(),
676				CrlEntry {
677					bytes: None,
678					next_update: Some(OffsetDateTime::now_utc() + time::Duration::days(7)),
679					last_success: None,
680					last_failure: Some(OffsetDateTime::now_utc()),
681					fetch_failure: CrlFetchFailure::Tolerate,
682					last_logged_state: HealthState::Unavailable,
683					consecutive_failures: 4,
684				},
685			);
686		}
687		// failures=4 → expo: min * 2^3 = 30s * 8 = 4 min, still below the 10 min cap.
688		let d = cache.next_refresh_delay(&src);
689		assert_eq!(d, Duration::from_mins(4));
690	}
691
692	#[tokio::test(flavor = "multi_thread")]
693	async fn tolerated_failure_increments_consecutive_counter() {
694		let fetcher = Arc::new(AlwaysFailFetcher { count: AtomicUsize::new(0) });
695		let cache = CrlCache::new(fetcher);
696		let src = CrlSourceId::Url("https://crl.example/inc".into());
697		cache.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)]).expect("tolerate ok");
698		cache.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)]).expect("tolerate ok");
699		cache.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)]).expect("tolerate ok");
700		let guard = cache.inner.read();
701		assert_eq!(guard.get(&src).expect("entry").consecutive_failures, 3);
702	}
703
704	#[tokio::test(flavor = "multi_thread")]
705	async fn successful_fetch_resets_failure_counter() {
706		let bytes = fixture_crl_bytes();
707		let fetcher =
708			Arc::new(FlippingFetcher { ok_bytes: bytes.clone(), succeed: AtomicBool::new(false) });
709		let cache = CrlCache::new(fetcher.clone());
710		let src = CrlSourceId::Url("https://crl.example/recover".into());
711		cache.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)]).expect("first fail tolerated");
712		cache
713			.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)])
714			.expect("second fail tolerated");
715		{
716			let guard = cache.inner.read();
717			assert_eq!(guard.get(&src).expect("entry").consecutive_failures, 2);
718		}
719		fetcher.succeed.store(true, Ordering::SeqCst);
720		cache.ensure_loaded(&[(src.clone(), CrlFetchFailure::Tolerate)]).expect("success");
721		let guard = cache.inner.read();
722		assert_eq!(guard.get(&src).expect("entry").consecutive_failures, 0);
723	}
724
725	#[test]
726	fn expo_backoff_doubles_until_cap() {
727		assert_eq!(expo_backoff(0, MIN_REFRESH_INTERVAL, MAX_REFRESH_BACKOFF), MIN_REFRESH_INTERVAL);
728		assert_eq!(expo_backoff(1, MIN_REFRESH_INTERVAL, MAX_REFRESH_BACKOFF), MIN_REFRESH_INTERVAL);
729		assert_eq!(
730			expo_backoff(2, MIN_REFRESH_INTERVAL, MAX_REFRESH_BACKOFF),
731			MIN_REFRESH_INTERVAL * 2
732		);
733		assert_eq!(
734			expo_backoff(3, MIN_REFRESH_INTERVAL, MAX_REFRESH_BACKOFF),
735			MIN_REFRESH_INTERVAL * 4
736		);
737		// 30s * 2^4 = 480s ≤ 600s cap.
738		assert_eq!(
739			expo_backoff(5, MIN_REFRESH_INTERVAL, MAX_REFRESH_BACKOFF),
740			MIN_REFRESH_INTERVAL * 16
741		);
742		// 30s * 2^5 = 960s > 600s cap.
743		assert_eq!(expo_backoff(6, MIN_REFRESH_INTERVAL, MAX_REFRESH_BACKOFF), MAX_REFRESH_BACKOFF);
744		assert_eq!(
745			expo_backoff(u32::MAX, MIN_REFRESH_INTERVAL, MAX_REFRESH_BACKOFF),
746			MAX_REFRESH_BACKOFF
747		);
748	}
749
750	#[test]
751	fn dedupe_picks_strictest_policy() {
752		let src = CrlSourceId::from_url("https://crl.example/x");
753		let out = dedupe_crl_sources([
754			(src.clone(), CrlFetchFailure::Tolerate),
755			(src.clone(), CrlFetchFailure::Reject),
756			(src.clone(), CrlFetchFailure::Tolerate),
757		]);
758		assert_eq!(out.len(), 1);
759		assert!(matches!(out[0].1, CrlFetchFailure::Reject));
760	}
761}