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