1use 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#[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#[derive(Copy, Clone, Debug, PartialEq, Eq)]
42pub enum CrlFetchFailure {
43 Tolerate,
45 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
59const MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
64
65const 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_failures: u32,
83}
84
85fn 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#[async_trait]
104pub trait CrlFetcher: Send + Sync {
105 async fn fetch(&self, src: &CrlSourceId) -> Result<Vec<u8>, String>;
110}
111
112pub 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 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 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 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 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 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 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 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 {
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 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 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 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 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
414pub 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#[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 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 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 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 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 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 assert_eq!(
739 expo_backoff(5, MIN_REFRESH_INTERVAL, MAX_REFRESH_BACKOFF),
740 MIN_REFRESH_INTERVAL * 16
741 );
742 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}