1use std::{
25 collections::{HashMap, HashSet},
26 num::NonZeroU32,
27 pin::Pin,
28 sync::{Arc, Mutex},
29 time::{Duration, SystemTime, UNIX_EPOCH},
30};
31
32use arc_swap::ArcSwap;
33use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
34use rustls::{
35 DigitallySignedStruct, DistinguishedName, Error as TlsError, RootCertStore, SignatureScheme,
36 client::danger::HandshakeSignatureValid,
37 pki_types::{CertificateDer, CertificateRevocationListDer, UnixTime},
38 server::{
39 WebPkiClientVerifier,
40 danger::{ClientCertVerified, ClientCertVerifier},
41 },
42};
43use tokio::{
44 net::lookup_host,
45 sync::{RwLock, Semaphore, mpsc},
46 task::JoinSet,
47 time::{Instant, Sleep},
48};
49use tokio_util::sync::CancellationToken;
50use url::Url;
51use x509_parser::{
52 extensions::{DistributionPointName, GeneralName, ParsedExtension},
53 prelude::{FromDer, X509Certificate},
54 revocation_list::CertificateRevocationList,
55};
56
57use crate::{
58 auth::MtlsConfig,
59 error::RmcpServerKitError,
60 ssrf::{check_scheme, ip_block_reason, sanitized_url_for_log},
61};
62
63const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(10);
64const MIN_AUTO_REFRESH: Duration = Duration::from_mins(10);
65const MAX_AUTO_REFRESH: Duration = Duration::from_hours(24);
66const CRL_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
69
70#[derive(Clone, Debug)]
72#[non_exhaustive]
73pub struct CachedCrl {
74 pub der: CertificateRevocationListDer<'static>,
76 pub this_update: SystemTime,
78 pub next_update: Option<SystemTime>,
80 pub fetched_at: SystemTime,
82 pub source_url: String,
84}
85
86pub(crate) struct VerifierHandle(pub Arc<dyn ClientCertVerifier>);
87
88impl std::fmt::Debug for VerifierHandle {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.debug_struct("VerifierHandle").finish_non_exhaustive()
91 }
92}
93
94#[allow(
96 missing_debug_implementations,
97 reason = "contains ArcSwap and dyn verifier internals"
98)]
99#[non_exhaustive]
100pub struct CrlSet {
101 inner_verifier: ArcSwap<VerifierHandle>,
102 pub cache: RwLock<HashMap<String, CachedCrl>>,
104 pub roots: Arc<RootCertStore>,
106 pub config: MtlsConfig,
108 pub discover_tx: mpsc::UnboundedSender<String>,
110 client: reqwest::Client,
111 seen_urls: Mutex<HashSet<String>>,
114 pending_urls: Mutex<HashSet<String>>,
124 cached_urls: Mutex<HashSet<String>>,
125 global_fetch_sem: Arc<Semaphore>,
127 host_semaphores: Arc<tokio::sync::Mutex<HashMap<String, Arc<Semaphore>>>>,
132 discovery_limiter: Arc<DefaultDirectRateLimiter>,
143 max_response_bytes: u64,
146 last_cap_warn: Mutex<HashMap<&'static str, Instant>>,
147}
148
149impl CrlSet {
150 fn new(
151 roots: Arc<RootCertStore>,
152 config: MtlsConfig,
153 discover_tx: mpsc::UnboundedSender<String>,
154 initial_cache: HashMap<String, CachedCrl>,
155 ) -> Result<Arc<Self>, RmcpServerKitError> {
156 let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
164 let resolver: Arc<dyn reqwest::dns::Resolve> =
165 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
166 Arc::clone(&allowlist),
167 #[cfg(any(test, feature = "test-helpers"))]
168 Arc::new(std::sync::atomic::AtomicBool::new(false)),
169 #[cfg(not(any(test, feature = "test-helpers")))]
170 (),
171 ));
172
173 let client = reqwest::Client::builder()
174 .no_proxy()
176 .dns_resolver(Arc::clone(&resolver))
177 .timeout(config.crl_fetch_timeout)
178 .connect_timeout(CRL_CONNECT_TIMEOUT)
179 .tcp_keepalive(None)
180 .redirect(reqwest::redirect::Policy::none())
181 .user_agent(format!("rmcp-server-kit/{}", env!("CARGO_PKG_VERSION")))
182 .build()
183 .map_err(|error| {
184 RmcpServerKitError::Startup(format!("CRL HTTP client init: {error}"))
185 })?;
186
187 let initial_verifier = rebuild_verifier(&roots, &config, &initial_cache)?;
188 let seen_urls = initial_cache.keys().cloned().collect::<HashSet<_>>();
189 let cached_urls = seen_urls.clone();
190
191 let concurrency = config.crl_max_concurrent_fetches.max(1);
192 let global_fetch_sem = Arc::new(Semaphore::new(concurrency));
193 let host_semaphores = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
194
195 let rate =
196 NonZeroU32::new(config.crl_discovery_rate_per_min.max(1)).unwrap_or(NonZeroU32::MIN);
197 let discovery_limiter = Arc::new(RateLimiter::direct(Quota::per_minute(rate)));
198
199 let max_response_bytes = config.crl_max_response_bytes;
200
201 Ok(Arc::new(Self {
202 inner_verifier: ArcSwap::from_pointee(VerifierHandle(initial_verifier)),
203 cache: RwLock::new(initial_cache),
204 roots,
205 config,
206 discover_tx,
207 client,
208 seen_urls: Mutex::new(seen_urls),
209 pending_urls: Mutex::new(HashSet::new()),
210 cached_urls: Mutex::new(cached_urls),
211 global_fetch_sem,
212 host_semaphores,
213 discovery_limiter,
214 max_response_bytes,
215 last_cap_warn: Mutex::new(HashMap::new()),
216 }))
217 }
218
219 fn warn_cap_exceeded_throttled(&self, which: &'static str) {
220 let now = Instant::now();
221 let cooldown = Duration::from_mins(1);
222 let should_warn = match self.last_cap_warn.lock() {
223 Ok(mut guard) => {
224 let should_emit = guard
225 .get(which)
226 .is_none_or(|last| now.saturating_duration_since(*last) >= cooldown);
227 if should_emit {
228 guard.insert(which, now);
229 }
230 should_emit
231 }
232 Err(poisoned) => {
233 let mut guard = poisoned.into_inner();
234 let should_emit = guard
235 .get(which)
236 .is_none_or(|last| now.saturating_duration_since(*last) >= cooldown);
237 if should_emit {
238 guard.insert(which, now);
239 }
240 should_emit
241 }
242 };
243
244 if should_warn {
245 tracing::warn!(which = which, "CRL map cap exceeded; dropping newest entry");
246 }
247 }
248
249 async fn commit_cache_update_atomically(
250 &self,
251 inserts: Vec<(String, CachedCrl)>,
252 removals: &[String],
253 ) -> Result<bool, RmcpServerKitError> {
254 let mut cache = self.cache.write().await;
255 let mut candidate = cache.clone();
256 let mut admitted_urls = Vec::new();
257
258 for (url, cached) in inserts {
265 if candidate.len() >= self.config.crl_max_cache_entries && !candidate.contains_key(&url)
266 {
267 self.warn_cap_exceeded_throttled("cache");
268 continue;
269 }
270 candidate.insert(url.clone(), cached);
271 admitted_urls.push(url);
272 }
273
274 for url in removals {
275 candidate.remove(url);
276 }
277
278 let verifier = rebuild_verifier(&self.roots, &self.config, &candidate)?;
284 self.inner_verifier
285 .store(Arc::new(VerifierHandle(verifier)));
286 let changed = !admitted_urls.is_empty() || !removals.is_empty();
287 *cache = candidate;
288 drop(cache);
289
290 match self.cached_urls.lock() {
291 Ok(mut cached_urls) => {
292 for url in admitted_urls {
293 cached_urls.insert(url);
294 }
295 for url in removals {
296 cached_urls.remove(url);
297 }
298 }
299 Err(poisoned) => {
300 let mut cached_urls = poisoned.into_inner();
301 for url in admitted_urls {
302 cached_urls.insert(url);
303 }
304 for url in removals {
305 cached_urls.remove(url);
306 }
307 }
308 }
309
310 {
314 let mut seen = self
315 .seen_urls
316 .lock()
317 .unwrap_or_else(std::sync::PoisonError::into_inner);
318 for url in removals {
319 seen.remove(url);
320 }
321 }
322 {
323 let mut pending = self
324 .pending_urls
325 .lock()
326 .unwrap_or_else(std::sync::PoisonError::into_inner);
327 for url in removals {
328 pending.remove(url);
329 }
330 }
331
332 Ok(changed)
333 }
334
335 pub async fn force_refresh(&self) -> Result<(), RmcpServerKitError> {
341 let urls = {
342 let cache = self.cache.read().await;
343 cache.keys().cloned().collect::<Vec<_>>()
344 };
345 self.refresh_urls(urls).await
346 }
347
348 async fn refresh_due_urls(&self) -> Result<(), RmcpServerKitError> {
349 let now = SystemTime::now();
350 let urls = {
351 let cache = self.cache.read().await;
352 cache
353 .iter()
354 .filter(|(_, cached)| {
355 should_refresh_cached(cached, now, self.config.crl_refresh_interval)
356 })
357 .map(|(url, _)| url.clone())
358 .collect::<Vec<_>>()
359 };
360
361 if urls.is_empty() {
362 return Ok(());
363 }
364
365 self.refresh_urls(urls).await
366 }
367
368 async fn refresh_urls(&self, urls: Vec<String>) -> Result<(), RmcpServerKitError> {
369 let results = self.fetch_url_results(urls).await;
370 let now = SystemTime::now();
371 let cache = self.cache.read().await;
372 let mut inserts = Vec::new();
373 let mut removals = Vec::new();
374
375 for (url, result) in results {
376 match result {
377 Ok(cached) => {
378 inserts.push((url, cached));
379 }
380 Err(error) => {
381 let remove_entry = cache.get(&url).is_some_and(|existing| {
382 existing
383 .next_update
384 .and_then(|next| next.checked_add(self.config.crl_stale_grace))
385 .is_some_and(|deadline| now > deadline)
386 });
387 tracing::warn!(url = %url, error = %error, "CRL refresh failed");
388 if remove_entry {
389 removals.push(url);
390 }
391 }
392 }
393 }
394 drop(cache);
395
396 if !inserts.is_empty() || !removals.is_empty() {
397 let _ = self
398 .commit_cache_update_atomically(inserts, &removals)
399 .await?;
400 }
401
402 Ok(())
403 }
404
405 async fn fetch_and_store_url(&self, url: String) -> Result<bool, RmcpServerKitError> {
415 let cached = gated_fetch(
416 &self.client,
417 &self.global_fetch_sem,
418 &self.host_semaphores,
419 &url,
420 self.config.crl_allow_http,
421 self.max_response_bytes,
422 self.config.crl_max_host_semaphores,
423 )
424 .await?;
425 let _ = self
426 .commit_cache_update_atomically(vec![(url.clone(), cached)], &[])
427 .await?;
428 Ok(self.cache.read().await.contains_key(&url))
429 }
430
431 fn promote_pending_to_seen(&self, url: &str) {
434 {
435 let mut pending = self
436 .pending_urls
437 .lock()
438 .unwrap_or_else(std::sync::PoisonError::into_inner);
439 pending.remove(url);
440 }
441 let mut seen = self
442 .seen_urls
443 .lock()
444 .unwrap_or_else(std::sync::PoisonError::into_inner);
445 if seen.len() >= self.config.crl_max_seen_urls && !seen.contains(url) {
446 self.warn_cap_exceeded_throttled("seen_urls");
447 return;
448 }
449 seen.insert(url.to_owned());
450 }
451
452 fn clear_pending(&self, url: &str) {
456 let mut pending = self
457 .pending_urls
458 .lock()
459 .unwrap_or_else(std::sync::PoisonError::into_inner);
460 pending.remove(url);
461 }
462
463 fn note_discovered_urls(
464 &self,
465 end_entity_urls: &[String],
466 intermediate_urls: &[String],
467 ) -> bool {
468 let mut all_urls = Vec::with_capacity(end_entity_urls.len() + intermediate_urls.len());
477 all_urls.extend_from_slice(end_entity_urls);
478 all_urls.extend_from_slice(intermediate_urls);
479 all_urls.sort();
480 all_urls.dedup();
481
482 let candidates: Vec<String> = {
494 let seen = self
495 .seen_urls
496 .lock()
497 .unwrap_or_else(std::sync::PoisonError::into_inner);
498 let pending = self
499 .pending_urls
500 .lock()
501 .unwrap_or_else(std::sync::PoisonError::into_inner);
502 all_urls
503 .iter()
504 .filter(|url| !seen.contains(*url) && !pending.contains(*url))
505 .cloned()
506 .collect()
507 };
508
509 for url in candidates {
513 if self.discovery_limiter.check().is_err() {
514 tracing::warn!(
515 url = %url,
516 "discovery_rate_limited: dropped CDP URL beyond per-minute cap (will be retried on next handshake observing this URL)"
517 );
518 continue;
519 }
520 if self.discover_tx.send(url.clone()).is_err() {
521 tracing::debug!(
524 url = %url,
525 "discover channel closed; dropping CDP URL without marking pending"
526 );
527 continue;
528 }
529 let mut guard = self
532 .pending_urls
533 .lock()
534 .unwrap_or_else(std::sync::PoisonError::into_inner);
535 if guard.len() >= self.config.crl_max_seen_urls {
536 self.warn_cap_exceeded_throttled("pending_urls");
537 break;
538 }
539 guard.insert(url);
540 }
541
542 if self.config.crl_deny_on_unavailable {
543 let cached = self
544 .cached_urls
545 .lock()
546 .ok()
547 .map(|guard| guard.clone())
548 .unwrap_or_default();
549 let relevant_urls = if self.config.crl_end_entity_only {
550 end_entity_urls
551 } else {
552 all_urls.as_slice()
553 };
554 return !relevant_urls.is_empty()
555 && relevant_urls.iter().all(|url| !cached.contains(url));
556 }
557
558 false
559 }
560
561 #[doc(hidden)]
567 pub fn __test_with_prepopulated_crls(
568 roots: Arc<RootCertStore>,
569 config: MtlsConfig,
570 prefilled_crls: Vec<CertificateRevocationListDer<'static>>,
571 ) -> Result<Arc<Self>, RmcpServerKitError> {
572 let (discover_tx, discover_rx) = mpsc::unbounded_channel();
573 drop(discover_rx);
574
575 let mut initial_cache = HashMap::new();
576 for (index, der) in prefilled_crls.into_iter().enumerate() {
577 let source_url = format!("memory://crl/{index}");
578 let (this_update, next_update) = parse_crl_metadata(der.as_ref())?;
579 initial_cache.insert(
580 source_url.clone(),
581 CachedCrl {
582 der,
583 this_update,
584 next_update,
585 fetched_at: SystemTime::now(),
586 source_url,
587 },
588 );
589 }
590
591 Self::new(roots, config, discover_tx, initial_cache)
592 }
593
594 #[doc(hidden)]
606 pub fn __test_with_kept_receiver(
607 roots: Arc<RootCertStore>,
608 config: MtlsConfig,
609 prefilled_crls: Vec<CertificateRevocationListDer<'static>>,
610 ) -> Result<(Arc<Self>, mpsc::UnboundedReceiver<String>), RmcpServerKitError> {
611 let (discover_tx, discover_rx) = mpsc::unbounded_channel();
612
613 let mut initial_cache = HashMap::new();
614 for (index, der) in prefilled_crls.into_iter().enumerate() {
615 let source_url = format!("memory://crl/{index}");
616 let (this_update, next_update) = parse_crl_metadata(der.as_ref())?;
617 initial_cache.insert(
618 source_url.clone(),
619 CachedCrl {
620 der,
621 this_update,
622 next_update,
623 fetched_at: SystemTime::now(),
624 source_url,
625 },
626 );
627 }
628
629 let crl_set = Self::new(roots, config, discover_tx, initial_cache)?;
630 Ok((crl_set, discover_rx))
631 }
632
633 #[doc(hidden)]
638 pub fn __test_check_discovery_rate(&self, urls: &[String]) -> (usize, usize) {
639 let mut accepted = 0usize;
640 let mut dropped = 0usize;
641 for url in urls {
642 if self.discovery_limiter.check().is_ok() {
643 let _ = self.discover_tx.send(url.clone());
644 accepted += 1;
645 } else {
646 dropped += 1;
647 }
648 }
649 (accepted, dropped)
650 }
651
652 #[doc(hidden)]
661 pub fn __test_note_discovered_urls(&self, urls: &[String]) -> bool {
662 let missing_cached = self.note_discovered_urls(urls, &[]);
663 if self.discover_tx.is_closed() {
664 let already_seen: HashSet<String> = {
665 let seen = self
666 .seen_urls
667 .lock()
668 .unwrap_or_else(std::sync::PoisonError::into_inner);
669 urls.iter()
670 .filter(|url| seen.contains(*url))
671 .cloned()
672 .collect()
673 };
674 let mut pending = self
675 .pending_urls
676 .lock()
677 .unwrap_or_else(std::sync::PoisonError::into_inner);
678 for url in urls {
679 if already_seen.contains(url) || pending.contains(url) {
680 continue;
681 }
682 if pending.len() >= self.config.crl_max_seen_urls {
683 self.warn_cap_exceeded_throttled("pending_urls");
684 break;
685 }
686 pending.insert(url.clone());
687 }
688 }
689 missing_cached
690 }
691
692 #[cfg(any(test, feature = "test-helpers"))]
695 #[doc(hidden)]
696 pub fn __test_note_discovered_urls_by_cert(
697 &self,
698 end_entity_urls: &[String],
699 intermediate_urls: &[String],
700 ) -> bool {
701 self.note_discovered_urls(end_entity_urls, intermediate_urls)
702 }
703
704 #[doc(hidden)]
712 pub fn __test_is_seen(&self, url: &str) -> bool {
713 let in_seen = {
714 let seen = self
715 .seen_urls
716 .lock()
717 .unwrap_or_else(std::sync::PoisonError::into_inner);
718 seen.contains(url)
719 };
720 if in_seen {
721 return true;
722 }
723 let pending = self
724 .pending_urls
725 .lock()
726 .unwrap_or_else(std::sync::PoisonError::into_inner);
727 pending.contains(url)
728 }
729
730 #[cfg(any(test, feature = "test-helpers"))]
734 #[doc(hidden)]
735 pub fn __test_is_permanently_seen(&self, url: &str) -> bool {
736 let seen = self
737 .seen_urls
738 .lock()
739 .unwrap_or_else(std::sync::PoisonError::into_inner);
740 seen.contains(url)
741 }
742
743 #[cfg(any(test, feature = "test-helpers"))]
748 #[doc(hidden)]
749 pub fn __test_settle_pending(&self, url: &str, admitted: bool) {
750 if admitted {
751 self.promote_pending_to_seen(url);
752 } else {
753 self.clear_pending(url);
754 }
755 }
756
757 #[cfg(any(test, feature = "test-helpers"))]
760 #[doc(hidden)]
761 pub fn __test_host_semaphore_count(&self) -> usize {
762 self.host_semaphores
763 .try_lock()
764 .map_or(0, |guard| guard.len())
765 }
766
767 #[cfg(any(test, feature = "test-helpers"))]
769 #[doc(hidden)]
770 pub fn __test_cache_len(&self) -> usize {
771 self.cache.try_read().map_or(0, |guard| guard.len())
772 }
773
774 #[cfg(any(test, feature = "test-helpers"))]
776 #[doc(hidden)]
777 pub fn __test_cache_contains(&self, url: &str) -> bool {
778 self.cache
779 .try_read()
780 .is_ok_and(|guard| guard.contains_key(url))
781 }
782
783 #[cfg(any(test, feature = "test-helpers"))]
786 #[doc(hidden)]
787 pub fn __test_cached_url_contains(&self, url: &str) -> bool {
788 self.cached_urls
789 .lock()
790 .is_ok_and(|guard| guard.contains(url))
791 }
792
793 #[cfg(any(test, feature = "test-helpers"))]
800 #[doc(hidden)]
801 pub async fn __test_trigger_fetch(&self, url: &str) -> Result<(), RmcpServerKitError> {
802 if let Err(error) = gated_fetch(
803 &self.client,
804 &self.global_fetch_sem,
805 &self.host_semaphores,
806 url,
807 self.config.crl_allow_http,
808 self.max_response_bytes,
809 self.config.crl_max_host_semaphores,
810 )
811 .await
812 {
813 if error
814 .to_string()
815 .contains("crl_host_semaphore_cap_exceeded")
816 {
817 Err(error)
818 } else {
819 Ok(())
820 }
821 } else {
822 Ok(())
823 }
824 }
825
826 #[cfg(any(test, feature = "test-helpers"))]
838 #[doc(hidden)]
839 pub async fn __test_insert_cache(&self, url: &str, cached: CachedCrl) {
840 let _ = self
841 .commit_cache_update_atomically(vec![(url.to_owned(), cached)], &[])
842 .await;
843 }
844
845 #[cfg(any(test, feature = "test-helpers"))]
847 #[doc(hidden)]
848 pub async fn __test_try_insert_cache(
849 &self,
850 url: &str,
851 cached: CachedCrl,
852 ) -> Result<bool, RmcpServerKitError> {
853 self.commit_cache_update_atomically(vec![(url.to_owned(), cached)], &[])
854 .await
855 }
856
857 #[cfg(any(test, feature = "test-helpers"))]
859 #[doc(hidden)]
860 pub async fn __test_replace_cache_entry_unverified(&self, url: &str, cached: CachedCrl) {
861 let mut cache = self.cache.write().await;
862 cache.insert(url.to_owned(), cached);
863 }
864
865 #[cfg(any(test, feature = "test-helpers"))]
870 #[doc(hidden)]
871 pub async fn __test_trigger_refresh_url(&self, url: &str) -> Result<(), RmcpServerKitError> {
872 self.refresh_urls(vec![url.to_owned()]).await
873 }
874
875 async fn fetch_url_results(
876 &self,
877 urls: Vec<String>,
878 ) -> Vec<(String, Result<CachedCrl, RmcpServerKitError>)> {
879 let mut tasks = JoinSet::new();
880 for url in urls {
881 let client = self.client.clone();
882 let global_sem = Arc::clone(&self.global_fetch_sem);
883 let host_map = Arc::clone(&self.host_semaphores);
884 let allow_http = self.config.crl_allow_http;
885 let max_bytes = self.max_response_bytes;
886 let max_host_semaphores = self.config.crl_max_host_semaphores;
887 tasks.spawn(async move {
888 let result = gated_fetch(
889 &client,
890 &global_sem,
891 &host_map,
892 &url,
893 allow_http,
894 max_bytes,
895 max_host_semaphores,
896 )
897 .await;
898 (url, result)
899 });
900 }
901
902 let mut results = Vec::new();
903 while let Some(joined) = tasks.join_next().await {
904 match joined {
905 Ok(result) => results.push(result),
906 Err(error) => {
907 tracing::warn!(error = %error, "CRL refresh task join failed");
908 }
909 }
910 }
911
912 results
913 }
914}
915
916#[cfg(any(test, feature = "test-helpers"))]
917const SYNTHETIC_TEST_CRL_DER: &[u8] = &[
918 48, 129, 199, 48, 110, 2, 1, 1, 48, 10, 6, 8, 42, 134, 72, 206, 61, 4, 3, 2, 48, 14, 49, 12,
919 48, 10, 6, 3, 85, 4, 3, 12, 3, 99, 114, 108, 23, 13, 50, 54, 48, 49, 48, 49, 48, 48, 48, 48,
920 48, 48, 90, 23, 13, 50, 55, 48, 49, 48, 49, 48, 48, 48, 48, 48, 48, 90, 160, 47, 48, 45, 48,
921 31, 6, 3, 85, 29, 35, 4, 24, 48, 22, 128, 20, 14, 62, 48, 146, 7, 182, 179, 215, 90, 226, 214,
922 90, 201, 83, 149, 116, 34, 31, 26, 255, 48, 10, 6, 3, 85, 29, 20, 4, 3, 2, 1, 1, 48, 10, 6, 8,
923 42, 134, 72, 206, 61, 4, 3, 2, 3, 73, 0, 48, 70, 2, 33, 0, 250, 240, 103, 87, 60, 78, 208, 171,
924 184, 206, 117, 134, 236, 234, 53, 115, 122, 90, 64, 217, 146, 27, 32, 103, 170, 222, 240, 159,
925 137, 187, 116, 6, 2, 33, 0, 188, 23, 204, 232, 130, 84, 135, 249, 43, 208, 224, 220, 202, 57,
926 98, 140, 4, 251, 148, 189, 105, 68, 105, 40, 53, 180, 208, 38, 193, 120, 118, 100,
927];
928
929impl CachedCrl {
930 #[cfg(any(test, feature = "test-helpers"))]
934 #[doc(hidden)]
935 #[must_use]
936 pub fn __test_synthetic(now: SystemTime) -> Self {
937 Self {
938 der: CertificateRevocationListDer::from(SYNTHETIC_TEST_CRL_DER.to_vec()),
939 this_update: now,
940 next_update: now.checked_add(Duration::from_hours(24)),
941 fetched_at: now,
942 source_url: "test://synthetic".to_owned(),
943 }
944 }
945
946 #[cfg(any(test, feature = "test-helpers"))]
950 #[doc(hidden)]
951 #[must_use]
952 pub fn __test_stale(reference_past: SystemTime) -> Self {
953 Self {
954 der: CertificateRevocationListDer::from(vec![0x30, 0x00]),
955 this_update: reference_past,
956 next_update: Some(reference_past),
957 fetched_at: reference_past,
958 source_url: "test://stale".to_owned(),
959 }
960 }
961}
962
963pub struct DynamicClientCertVerifier {
966 inner: Arc<CrlSet>,
967 dn_subjects: Vec<DistinguishedName>,
968}
969
970impl DynamicClientCertVerifier {
971 #[must_use]
973 pub fn new(inner: Arc<CrlSet>) -> Self {
974 Self {
975 dn_subjects: inner.roots.subjects(),
976 inner,
977 }
978 }
979}
980
981impl std::fmt::Debug for DynamicClientCertVerifier {
982 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
983 f.debug_struct("DynamicClientCertVerifier")
984 .field("dn_subjects_len", &self.dn_subjects.len())
985 .finish_non_exhaustive()
986 }
987}
988
989impl ClientCertVerifier for DynamicClientCertVerifier {
990 fn offer_client_auth(&self) -> bool {
991 let verifier = self.inner.inner_verifier.load();
992 verifier.0.offer_client_auth()
993 }
994
995 fn client_auth_mandatory(&self) -> bool {
996 let verifier = self.inner.inner_verifier.load();
997 verifier.0.client_auth_mandatory()
998 }
999
1000 fn root_hint_subjects(&self) -> &[DistinguishedName] {
1001 &self.dn_subjects
1002 }
1003
1004 fn verify_client_cert(
1005 &self,
1006 end_entity: &CertificateDer<'_>,
1007 intermediates: &[CertificateDer<'_>],
1008 now: UnixTime,
1009 ) -> Result<ClientCertVerified, TlsError> {
1010 let mut end_entity_urls =
1022 extract_cdp_urls(end_entity.as_ref(), self.inner.config.crl_allow_http);
1023 end_entity_urls.sort();
1024 end_entity_urls.dedup();
1025
1026 let mut intermediate_urls = Vec::new();
1027 for intermediate in intermediates {
1028 intermediate_urls.extend(extract_cdp_urls(
1029 intermediate.as_ref(),
1030 self.inner.config.crl_allow_http,
1031 ));
1032 }
1033 intermediate_urls.sort();
1034 intermediate_urls.dedup();
1035
1036 if self
1037 .inner
1038 .note_discovered_urls(&end_entity_urls, &intermediate_urls)
1039 {
1040 return Err(TlsError::General(
1041 "client certificate revocation status unavailable".to_owned(),
1042 ));
1043 }
1044
1045 let verifier = self.inner.inner_verifier.load();
1046 verifier
1047 .0
1048 .verify_client_cert(end_entity, intermediates, now)
1049 }
1050
1051 fn verify_tls12_signature(
1052 &self,
1053 message: &[u8],
1054 cert: &CertificateDer<'_>,
1055 dss: &DigitallySignedStruct,
1056 ) -> Result<HandshakeSignatureValid, TlsError> {
1057 let verifier = self.inner.inner_verifier.load();
1058 verifier.0.verify_tls12_signature(message, cert, dss)
1059 }
1060
1061 fn verify_tls13_signature(
1062 &self,
1063 message: &[u8],
1064 cert: &CertificateDer<'_>,
1065 dss: &DigitallySignedStruct,
1066 ) -> Result<HandshakeSignatureValid, TlsError> {
1067 let verifier = self.inner.inner_verifier.load();
1068 verifier.0.verify_tls13_signature(message, cert, dss)
1069 }
1070
1071 fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
1072 let verifier = self.inner.inner_verifier.load();
1073 verifier.0.supported_verify_schemes()
1074 }
1075
1076 fn requires_raw_public_keys(&self) -> bool {
1077 let verifier = self.inner.inner_verifier.load();
1078 verifier.0.requires_raw_public_keys()
1079 }
1080}
1081
1082#[must_use]
1091pub fn extract_cdp_urls(cert_der: &[u8], allow_http: bool) -> Vec<String> {
1092 let Ok((_, cert)) = X509Certificate::from_der(cert_der) else {
1093 return Vec::new();
1094 };
1095
1096 let mut urls = Vec::new();
1097 for ext in cert.extensions() {
1098 if let ParsedExtension::CRLDistributionPoints(cdps) = ext.parsed_extension() {
1099 for point in cdps.iter() {
1100 if let Some(DistributionPointName::FullName(names)) = &point.distribution_point {
1101 for name in names {
1102 if let GeneralName::URI(uri) = name {
1103 let raw = *uri;
1104 let Ok(parsed) = Url::parse(raw) else {
1105 tracing::debug!(url = ?raw, "CDP URL parse failed; dropped");
1109 continue;
1110 };
1111 if let Err(reason) = check_scheme(&parsed, allow_http) {
1112 tracing::debug!(
1113 url = %sanitized_url_for_log(&parsed),
1114 reason,
1115 "CDP URL rejected by scheme guard; dropped"
1116 );
1117 continue;
1118 }
1119 urls.push(parsed.into());
1120 }
1121 }
1122 }
1123 }
1124 }
1125 }
1126
1127 urls
1128}
1129
1130#[allow(
1137 clippy::cognitive_complexity,
1138 reason = "bootstrap coordinates timeout, parallel fetches, and partial-cache recovery"
1139)]
1140pub async fn bootstrap_fetch(
1141 roots: Arc<RootCertStore>,
1142 ca_certs: &[CertificateDer<'static>],
1143 config: MtlsConfig,
1144) -> Result<(Arc<CrlSet>, mpsc::UnboundedReceiver<String>), RmcpServerKitError> {
1145 let (discover_tx, discover_rx) = mpsc::unbounded_channel();
1146
1147 let mut urls = ca_certs
1148 .iter()
1149 .flat_map(|cert| extract_cdp_urls(cert.as_ref(), config.crl_allow_http))
1150 .collect::<Vec<_>>();
1151 urls.sort();
1152 urls.dedup();
1153
1154 let bootstrap_allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
1158 let bootstrap_resolver: Arc<dyn reqwest::dns::Resolve> =
1159 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1160 Arc::clone(&bootstrap_allowlist),
1161 #[cfg(any(test, feature = "test-helpers"))]
1162 Arc::new(std::sync::atomic::AtomicBool::new(false)),
1163 #[cfg(not(any(test, feature = "test-helpers")))]
1164 (),
1165 ));
1166
1167 let client = reqwest::Client::builder()
1168 .no_proxy()
1170 .dns_resolver(Arc::clone(&bootstrap_resolver))
1171 .timeout(config.crl_fetch_timeout)
1172 .connect_timeout(CRL_CONNECT_TIMEOUT)
1173 .tcp_keepalive(None)
1174 .redirect(reqwest::redirect::Policy::none())
1175 .user_agent(format!("rmcp-server-kit/{}", env!("CARGO_PKG_VERSION")))
1176 .build()
1177 .map_err(|error| RmcpServerKitError::Startup(format!("CRL HTTP client init: {error}")))?;
1178
1179 let bootstrap_concurrency = config.crl_max_concurrent_fetches.max(1);
1183 let global_sem = Arc::new(Semaphore::new(bootstrap_concurrency));
1184 let host_semaphores = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
1185 let allow_http = config.crl_allow_http;
1186 let max_bytes = config.crl_max_response_bytes;
1187 let max_host_semaphores = config.crl_max_host_semaphores;
1188
1189 let mut initial_cache = HashMap::new();
1190 let mut tasks = JoinSet::new();
1191 for url in &urls {
1192 let client = client.clone();
1193 let url = url.clone();
1194 let global_sem = Arc::clone(&global_sem);
1195 let host_semaphores = Arc::clone(&host_semaphores);
1196 tasks.spawn(async move {
1197 let result = gated_fetch(
1198 &client,
1199 &global_sem,
1200 &host_semaphores,
1201 &url,
1202 allow_http,
1203 max_bytes,
1204 max_host_semaphores,
1205 )
1206 .await;
1207 (url, result)
1208 });
1209 }
1210
1211 let timeout: Sleep = tokio::time::sleep(BOOTSTRAP_TIMEOUT);
1212 tokio::pin!(timeout);
1213
1214 while !tasks.is_empty() {
1215 tokio::select! {
1219 () = &mut timeout => {
1220 tracing::warn!("CRL bootstrap timed out after {:?}", BOOTSTRAP_TIMEOUT);
1221 break;
1222 }
1223 maybe_joined = tasks.join_next() => {
1224 let Some(joined) = maybe_joined else {
1225 break;
1226 };
1227 match joined {
1228 Ok((url, Ok(cached))) => {
1229 initial_cache.insert(url, cached);
1230 }
1231 Ok((url, Err(error))) => {
1232 tracing::warn!(url = %url, error = %error, "CRL bootstrap fetch failed");
1233 }
1234 Err(error) => {
1235 tracing::warn!(error = %error, "CRL bootstrap task join failed");
1236 }
1237 }
1238 }
1239 }
1240 }
1241
1242 let set = CrlSet::new(roots, config, discover_tx, initial_cache)?;
1243 Ok((set, discover_rx))
1244}
1245
1246#[allow(
1248 clippy::cognitive_complexity,
1249 reason = "refresher loop intentionally handles shutdown, timer, and discovery in one select"
1250)]
1251pub async fn run_crl_refresher(
1252 set: Arc<CrlSet>,
1253 mut discover_rx: mpsc::UnboundedReceiver<String>,
1254 shutdown: CancellationToken,
1255) {
1256 let mut refresh_sleep = schedule_next_refresh(&set).await;
1257
1258 loop {
1259 tokio::select! {
1263 () = shutdown.cancelled() => {
1264 break;
1265 }
1266 () = &mut refresh_sleep => {
1267 if let Err(error) = set.refresh_due_urls().await {
1268 tracing::warn!(error = %error, "CRL periodic refresh failed");
1269 }
1270 refresh_sleep = schedule_next_refresh(&set).await;
1271 }
1272 maybe_url = discover_rx.recv() => {
1273 let Some(url) = maybe_url else {
1274 break;
1275 };
1276 match set.fetch_and_store_url(url.clone()).await {
1277 Ok(true) => set.promote_pending_to_seen(&url),
1279 Ok(false) => {
1284 set.clear_pending(&url);
1285 tracing::warn!(
1286 url = %url,
1287 "CRL fetched but not admitted to cache (cap reached); will retry on a later handshake"
1288 );
1289 }
1290 Err(error) => {
1291 set.clear_pending(&url);
1292 tracing::warn!(
1293 url = %url,
1294 error = %error,
1295 "CRL discovery fetch failed; will retry on a later handshake"
1296 );
1297 }
1298 }
1299 refresh_sleep = schedule_next_refresh(&set).await;
1300 }
1301 }
1302 }
1303}
1304
1305pub fn rebuild_verifier<S: std::hash::BuildHasher>(
1311 roots: &Arc<RootCertStore>,
1312 config: &MtlsConfig,
1313 cache: &HashMap<String, CachedCrl, S>,
1314) -> Result<Arc<dyn ClientCertVerifier>, RmcpServerKitError> {
1315 let mut builder = WebPkiClientVerifier::builder(Arc::clone(roots));
1316
1317 if !cache.is_empty() {
1318 let crls = cache
1319 .values()
1320 .map(|cached| cached.der.clone())
1321 .collect::<Vec<_>>();
1322 builder = builder.with_crls(crls);
1323 }
1324 if config.crl_end_entity_only {
1325 builder = builder.only_check_end_entity_revocation();
1326 }
1327 if !config.crl_deny_on_unavailable {
1328 builder = builder.allow_unknown_revocation_status();
1329 }
1330 if config.crl_enforce_expiration {
1331 builder = builder.enforce_revocation_expiration();
1332 }
1333 if !config.required {
1334 builder = builder.allow_unauthenticated();
1335 }
1336
1337 builder
1338 .build()
1339 .map_err(|error| RmcpServerKitError::Tls(format!("mTLS verifier error: {error}")))
1340}
1341
1342pub fn parse_crl_metadata(
1348 der: &[u8],
1349) -> Result<(SystemTime, Option<SystemTime>), RmcpServerKitError> {
1350 let (_, crl) = CertificateRevocationList::from_der(der)
1351 .map_err(|error| RmcpServerKitError::Tls(format!("invalid CRL DER: {error:?}")))?;
1352
1353 Ok((
1354 asn1_time_to_system_time(crl.last_update()),
1355 crl.next_update().map(asn1_time_to_system_time),
1356 ))
1357}
1358
1359async fn schedule_next_refresh(set: &CrlSet) -> Pin<Box<Sleep>> {
1360 let duration = next_refresh_delay(set).await;
1361 boxed_sleep(duration)
1362}
1363
1364fn boxed_sleep(duration: Duration) -> Pin<Box<Sleep>> {
1365 Box::pin(tokio::time::sleep_until(Instant::now() + duration))
1366}
1367
1368async fn next_refresh_delay(set: &CrlSet) -> Duration {
1369 if let Some(interval) = set.config.crl_refresh_interval {
1370 return clamp_refresh(interval);
1371 }
1372
1373 let now = SystemTime::now();
1374 let cache = set.cache.read().await;
1375 let mut next = MAX_AUTO_REFRESH;
1376
1377 for cached in cache.values() {
1378 if let Some(next_update) = cached.next_update {
1379 let duration = next_update.duration_since(now).unwrap_or(Duration::ZERO);
1380 next = next.min(clamp_refresh(duration));
1381 }
1382 }
1383 drop(cache);
1384
1385 next
1386}
1387
1388fn acquire_host_semaphore(
1398 map: &mut HashMap<String, Arc<Semaphore>>,
1399 host_key: &str,
1400 max_host_semaphores: usize,
1401) -> Result<Arc<Semaphore>, RmcpServerKitError> {
1402 if !map.contains_key(host_key) {
1403 if map.len() >= max_host_semaphores {
1404 map.retain(|_, semaphore| Arc::strong_count(semaphore) > 1);
1406 }
1407 if map.len() >= max_host_semaphores {
1408 return Err(RmcpServerKitError::Config(
1409 "crl_host_semaphore_cap_exceeded: too many distinct CRL hosts in flight".to_owned(),
1410 ));
1411 }
1412 map.insert(host_key.to_owned(), Arc::new(Semaphore::new(1)));
1413 }
1414 match map.get(host_key) {
1415 Some(semaphore) => Ok(Arc::clone(semaphore)),
1416 None => Err(RmcpServerKitError::Tls(
1417 "CRL host semaphore missing after insertion".to_owned(),
1418 )),
1419 }
1420}
1421
1422async fn gated_fetch(
1430 client: &reqwest::Client,
1431 global_sem: &Arc<Semaphore>,
1432 host_semaphores: &Arc<tokio::sync::Mutex<HashMap<String, Arc<Semaphore>>>>,
1433 url: &str,
1434 allow_http: bool,
1435 max_bytes: u64,
1436 max_host_semaphores: usize,
1437) -> Result<CachedCrl, RmcpServerKitError> {
1438 let host_key = Url::parse(url)
1439 .ok()
1440 .and_then(|u| u.host_str().map(str::to_owned))
1441 .unwrap_or_else(|| url.to_owned());
1442
1443 let host_sem = {
1444 let mut map = host_semaphores.lock().await;
1445 acquire_host_semaphore(&mut map, &host_key, max_host_semaphores)?
1446 };
1447
1448 let _global_permit = Arc::clone(global_sem)
1449 .acquire_owned()
1450 .await
1451 .map_err(|error| {
1452 RmcpServerKitError::Tls(format!("CRL global semaphore closed: {error}"))
1453 })?;
1454 let _host_permit = host_sem
1455 .acquire_owned()
1456 .await
1457 .map_err(|error| RmcpServerKitError::Tls(format!("CRL host semaphore closed: {error}")))?;
1458
1459 fetch_crl(client, url, allow_http, max_bytes).await
1460}
1461
1462async fn fetch_crl(
1463 client: &reqwest::Client,
1464 url: &str,
1465 allow_http: bool,
1466 max_bytes: u64,
1467) -> Result<CachedCrl, RmcpServerKitError> {
1468 let parsed = Url::parse(url)
1469 .map_err(|error| RmcpServerKitError::Tls(format!("CRL URL parse {url}: {error}")))?;
1470
1471 if let Err(reason) = check_scheme(&parsed, allow_http) {
1472 let sanitized = sanitized_url_for_log(&parsed);
1475 tracing::warn!(url = %sanitized, reason, "CRL fetch denied: scheme");
1476 return Err(RmcpServerKitError::Tls(format!(
1477 "CRL scheme rejected ({reason}): {sanitized}"
1478 )));
1479 }
1480
1481 let host = parsed
1482 .host_str()
1483 .ok_or_else(|| RmcpServerKitError::Tls(format!("CRL URL has no host: {url}")))?;
1484 let port = parsed
1485 .port_or_known_default()
1486 .ok_or_else(|| RmcpServerKitError::Tls(format!("CRL URL has no known port: {url}")))?;
1487
1488 let addrs = lookup_host((host, port))
1489 .await
1490 .map_err(|error| RmcpServerKitError::Tls(format!("CRL DNS resolution {url}: {error}")))?;
1491
1492 let mut any_addr = false;
1493 for addr in addrs {
1494 any_addr = true;
1495 if let Some(reason) = ip_block_reason(addr.ip()) {
1496 tracing::warn!(
1497 url = %url,
1498 resolved_ip = %addr.ip(),
1499 reason,
1500 "CRL fetch denied: blocked IP"
1501 );
1502 return Err(RmcpServerKitError::Tls(format!(
1503 "CRL host resolved to blocked IP ({reason}): {url}"
1504 )));
1505 }
1506 }
1507 if !any_addr {
1508 return Err(RmcpServerKitError::Tls(format!(
1509 "CRL DNS resolution returned no addresses: {url}"
1510 )));
1511 }
1512
1513 let mut response = client
1514 .get(url)
1515 .send()
1516 .await
1517 .map_err(|error| RmcpServerKitError::Tls(format!("CRL fetch {url}: {error}")))?
1518 .error_for_status()
1519 .map_err(|error| RmcpServerKitError::Tls(format!("CRL fetch {url}: {error}")))?;
1520
1521 let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
1524 let mut body: Vec<u8> = Vec::with_capacity(initial_capacity);
1525 while let Some(chunk) = response
1526 .chunk()
1527 .await
1528 .map_err(|error| RmcpServerKitError::Tls(format!("CRL read {url}: {error}")))?
1529 {
1530 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
1531 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
1532 if body_len.saturating_add(chunk_len) > max_bytes {
1533 return Err(RmcpServerKitError::Tls(format!(
1534 "CRL body exceeded cap of {max_bytes} bytes: {url}"
1535 )));
1536 }
1537 body.extend_from_slice(&chunk);
1538 }
1539
1540 let der = CertificateRevocationListDer::from(body);
1541 let (this_update, next_update) = parse_crl_metadata(der.as_ref())?;
1542
1543 Ok(CachedCrl {
1544 der,
1545 this_update,
1546 next_update,
1547 fetched_at: SystemTime::now(),
1548 source_url: url.to_owned(),
1549 })
1550}
1551
1552fn should_refresh_cached(
1553 cached: &CachedCrl,
1554 now: SystemTime,
1555 fixed_interval: Option<Duration>,
1556) -> bool {
1557 if let Some(interval) = fixed_interval {
1558 return cached
1559 .fetched_at
1560 .checked_add(clamp_refresh(interval))
1561 .is_none_or(|deadline| now >= deadline);
1562 }
1563
1564 cached
1565 .next_update
1566 .is_none_or(|next_update| now >= next_update)
1567}
1568
1569fn clamp_refresh(duration: Duration) -> Duration {
1570 duration.clamp(MIN_AUTO_REFRESH, MAX_AUTO_REFRESH)
1571}
1572
1573const MAX_ASN1_TIMESTAMP_SECS: u64 = 253_402_300_799;
1577
1578fn asn1_time_to_system_time(time: x509_parser::time::ASN1Time) -> SystemTime {
1587 let timestamp = time.timestamp();
1588 if timestamp >= 0 {
1589 let seconds = u64::try_from(timestamp)
1590 .unwrap_or(0)
1591 .min(MAX_ASN1_TIMESTAMP_SECS);
1592 UNIX_EPOCH
1593 .checked_add(Duration::from_secs(seconds))
1594 .unwrap_or(UNIX_EPOCH)
1595 } else {
1596 UNIX_EPOCH
1597 .checked_sub(Duration::from_secs(timestamp.unsigned_abs()))
1598 .unwrap_or(UNIX_EPOCH)
1599 }
1600}
1601
1602#[cfg(test)]
1603mod tests {
1604 use super::*;
1605
1606 fn asn1(timestamp: i64) -> x509_parser::time::ASN1Time {
1607 x509_parser::time::ASN1Time::from_timestamp(timestamp).expect("valid ASN.1 timestamp")
1608 }
1609
1610 #[tokio::test]
1613 async fn fetch_crl_rejects_userinfo_without_echoing_credentials() {
1614 let _ = rustls::crypto::ring::default_provider().install_default();
1618 let client = reqwest::Client::new();
1619 let err = fetch_crl(&client, "https://u:p@crl.example/ca.crl", false, 1024)
1620 .await
1621 .expect_err("userinfo-bearing CRL URL must be rejected");
1622 let rendered = err.to_string();
1623 assert!(
1624 rendered.contains("userinfo_forbidden"),
1625 "error must carry the rejection reason: {rendered}"
1626 );
1627 assert!(
1628 !rendered.contains("u:p"),
1629 "error must not echo the rejected credentials: {rendered}"
1630 );
1631 }
1632
1633 #[test]
1636 fn sanitizer_used_by_rejection_sites_strips_credentials() {
1637 let parsed = Url::parse("https://u:p@crl.example/ca.crl").expect("parse");
1638 let sanitized = sanitized_url_for_log(&parsed);
1639 assert_eq!(sanitized, "https://crl.example");
1640 assert!(!sanitized.contains("u:p"));
1641 }
1642
1643 #[test]
1644 fn asn1_time_clamps_unrepresentable_timestamps() {
1645 let year_1500 = asn1_time_to_system_time(asn1(-14_831_769_600));
1650 assert!(year_1500 <= UNIX_EPOCH);
1651 #[cfg(windows)]
1652 assert_eq!(year_1500, UNIX_EPOCH);
1653
1654 let year_1601 = asn1_time_to_system_time(asn1(-11_644_473_600));
1657 assert!(year_1601 <= UNIX_EPOCH);
1658
1659 assert!(asn1_time_to_system_time(asn1(-2)) <= UNIX_EPOCH);
1661
1662 assert_eq!(
1664 asn1_time_to_system_time(asn1(1_700_000_000)),
1665 UNIX_EPOCH + Duration::from_secs(1_700_000_000)
1666 );
1667
1668 let max = i64::try_from(MAX_ASN1_TIMESTAMP_SECS).expect("fits in i64");
1670 assert_eq!(
1671 asn1_time_to_system_time(asn1(max)),
1672 UNIX_EPOCH + Duration::from_secs(MAX_ASN1_TIMESTAMP_SECS)
1673 );
1674 }
1675
1676 #[test]
1677 fn host_semaphore_evicts_idle_at_cap() {
1678 let mut map = HashMap::new();
1679 for i in 0..4 {
1680 drop(
1682 acquire_host_semaphore(&mut map, &format!("idle-{i}.example"), 4)
1683 .expect("under cap"),
1684 );
1685 }
1686 assert_eq!(map.len(), 4);
1687
1688 let sem = acquire_host_semaphore(&mut map, "new-host.example", 4)
1691 .expect("idle eviction frees space for a new host");
1692 assert!(map.contains_key("new-host.example"));
1693 drop(sem);
1694 }
1695
1696 #[test]
1697 fn host_semaphore_keeps_inflight_at_cap() {
1698 let mut map = HashMap::new();
1699 let inflight = acquire_host_semaphore(&mut map, "busy.example", 3).expect("under cap");
1701 for i in 0..2 {
1702 drop(
1703 acquire_host_semaphore(&mut map, &format!("idle-{i}.example"), 3)
1704 .expect("under cap"),
1705 );
1706 }
1707 assert_eq!(map.len(), 3);
1708
1709 drop(
1710 acquire_host_semaphore(&mut map, "new-host.example", 3)
1711 .expect("idle entries evicted while in-flight survives"),
1712 );
1713 assert!(
1714 map.contains_key("busy.example"),
1715 "in-flight host must survive eviction"
1716 );
1717 assert!(map.contains_key("new-host.example"));
1718 drop(inflight);
1719 }
1720
1721 #[test]
1722 fn host_semaphore_cap_error_when_all_inflight() {
1723 let mut map = HashMap::new();
1724 let held: Vec<_> = (0..2)
1725 .map(|i| {
1726 acquire_host_semaphore(&mut map, &format!("busy-{i}.example"), 2)
1727 .expect("under cap")
1728 })
1729 .collect();
1730
1731 let result = acquire_host_semaphore(&mut map, "new-host.example", 2);
1732 assert!(
1733 result.is_err(),
1734 "cap must still reject when every entry has an in-flight fetch"
1735 );
1736 drop(held);
1737 }
1738}