1use std::collections::HashMap;
2use std::net::Ipv6Addr;
3use std::str::FromStr;
4use std::sync::{Arc, Mutex, Weak};
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use once_cell::sync::Lazy;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use tokio::sync::Notify;
12use tracing::{debug, instrument, warn};
13
14use tokio::time::timeout as tokio_timeout;
15
16use crate::availability::{AvailabilityChecker, AvailabilityResult, PriorRdap, PriorWhois};
17use crate::cache::TtlCache;
18use crate::dns::{DnsPresence, DnsResolver};
19use crate::error::{Result, SeerError};
20use crate::rdap::{rdap_error_is_404, RdapClient, RdapResponse};
21use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
22
23const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
25
26const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
30
31const MAX_PUBLIC_ERROR_LEN: usize = 256;
33
34const DEFAULT_INFLIGHT_WAIT: Duration = Duration::from_secs(30);
41
42static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
44 Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
45
46static LOOKUP_INFLIGHT: Lazy<Mutex<HashMap<String, Weak<Notify>>>> =
50 Lazy::new(|| Mutex::new(HashMap::new()));
51
52static IPV4_RE: Lazy<Regex> =
54 Lazy::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("IPV4_RE is a valid regex"));
55
56static IPV6_CANDIDATE_RE: Lazy<Regex> = Lazy::new(|| {
62 Regex::new(r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b")
63 .expect("IPV6_CANDIDATE_RE is a valid regex")
64});
65
66fn strip_ipv6(msg: &str) -> String {
69 IPV6_CANDIDATE_RE
70 .replace_all(msg, |caps: ®ex::Captures| {
71 let candidate = &caps[0];
72 if Ipv6Addr::from_str(candidate).is_ok() {
73 "[ip-redacted]".to_string()
74 } else {
75 candidate.to_string()
76 }
77 })
78 .into_owned()
79}
80
81#[cfg(test)]
85static LOOKUP_CONCURRENT_CALLS: Lazy<std::sync::atomic::AtomicUsize> =
86 Lazy::new(|| std::sync::atomic::AtomicUsize::new(0));
87
88fn whois_response_is_thin(w: &WhoisResponse) -> bool {
96 w.registrar.is_none() && w.creation_date.is_none() && w.expiration_date.is_none()
97}
98
99fn classify_whois_leg(
106 w: &WhoisResponse,
107 rdap_err: &SeerError,
108) -> Option<(&'static str, &'static str)> {
109 if w.is_available() {
110 return Some(("high", "whois"));
111 }
112 if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
113 return Some(("high", "rdap"));
117 }
118 None
119}
120
121fn should_route_to_availability(
128 rdap_returned_200: bool,
129 rdap_seer_error: Option<&SeerError>,
130 whois_data: &WhoisResponse,
131) -> Option<(&'static str, &'static str)> {
132 if rdap_returned_200 {
133 return None;
134 }
135 if whois_data.is_available() {
141 return Some(("high", "whois"));
142 }
143 rdap_seer_error.and_then(|e| {
144 classify_whois_leg(whois_data, e)
149 })
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155enum ThinFallback {
156 Inconclusive,
159 Available,
161 Registered,
163 UseWhois,
165}
166
167fn classify_thin_fallback(
181 is_thin: bool,
182 rdap_returned_200: bool,
183 whois_refuses: bool,
184 dns: DnsPresence,
185) -> ThinFallback {
186 if !is_thin || rdap_returned_200 {
187 return ThinFallback::UseWhois;
188 }
189 if whois_refuses {
190 return ThinFallback::Inconclusive;
191 }
192 match dns {
193 DnsPresence::Absent => ThinFallback::Available,
194 DnsPresence::Present => ThinFallback::Registered,
195 DnsPresence::Unknown => ThinFallback::UseWhois,
196 }
197}
198
199fn sanitize_error_for_public(msg: &str) -> String {
205 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
206 let s = strip_ipv6(&s);
207 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
208 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
209 trunc.push('…');
210 trunc
211 } else {
212 s
213 }
214}
215
216struct InflightGuard {
229 key: String,
230 notify: Arc<Notify>,
231}
232
233impl Drop for InflightGuard {
234 fn drop(&mut self) {
235 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
248 inflight.remove(&self.key);
249 drop(inflight);
250 self.notify.notify_waiters();
251 }
252}
253
254enum RdapOutcome {
260 Useful(RdapResponse),
261 NoData(RdapResponse),
262 Error(SeerError),
263 GraceTimeout,
266}
267
268pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(tag = "source", rename_all = "lowercase")]
274pub enum LookupResult {
275 Rdap {
276 data: Box<RdapResponse>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 whois_fallback: Option<WhoisResponse>,
279 },
280 Whois {
281 data: WhoisResponse,
282 rdap_error: Option<String>,
283 #[serde(skip_serializing_if = "Option::is_none")]
284 rdap_fallback: Option<Box<RdapResponse>>,
285 },
286 Available {
287 data: Box<AvailabilityResult>,
288 rdap_error: String,
289 whois_error: String,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
294 whois_data: Option<WhoisResponse>,
295 },
296}
297
298impl LookupResult {
299 pub fn domain_name(&self) -> Option<String> {
301 match self {
302 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
303 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
304 LookupResult::Available { data, .. } => Some(data.domain.clone()),
305 }
306 }
307
308 pub fn registrar(&self) -> Option<String> {
310 match self {
311 LookupResult::Rdap {
312 data,
313 whois_fallback,
314 } => data
315 .get_registrar()
316 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
317 LookupResult::Whois { data, .. } => data.registrar.clone(),
318 LookupResult::Available { .. } => None,
319 }
320 }
321
322 pub fn organization(&self) -> Option<String> {
324 match self {
325 LookupResult::Rdap {
326 data,
327 whois_fallback,
328 } => data
329 .get_registrant_organization()
330 .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
331 LookupResult::Whois { data, .. } => data.organization.clone(),
332 LookupResult::Available { .. } => None,
333 }
334 }
335
336 pub fn is_rdap(&self) -> bool {
338 matches!(self, LookupResult::Rdap { .. })
339 }
340
341 pub fn is_whois(&self) -> bool {
343 matches!(self, LookupResult::Whois { .. })
344 }
345
346 pub fn is_available(&self) -> bool {
348 matches!(self, LookupResult::Available { .. })
349 }
350
351 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
353 match self {
354 LookupResult::Rdap {
355 data,
356 whois_fallback,
357 } => {
358 let expiration_date = data
360 .events
361 .iter()
362 .find(|e| e.event_action == "expiration")
363 .and_then(|e| e.parsed_date())
364 .or_else(|| {
365 whois_fallback.as_ref().and_then(|w| w.expiration_date)
367 });
368
369 let registrar = data
370 .get_registrar()
371 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
372
373 (expiration_date, registrar)
374 }
375 LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
376 LookupResult::Available { .. } => (None, None),
377 }
378 }
379}
380
381fn truncate_on_char_boundary(s: &mut String, max: usize) {
388 if s.len() > max {
389 let mut end = max;
390 while end > 0 && !s.is_char_boundary(end) {
391 end -= 1;
392 }
393 s.truncate(end);
394 }
395}
396
397pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
403 const MAX_RAW: usize = 32 * 1024;
404 if whois.raw_response.len() > MAX_RAW {
405 truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
406 whois.raw_response.push_str("\n... [truncated]");
407 }
408}
409
410pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
414 match result {
415 LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
416 LookupResult::Rdap {
417 ref mut whois_fallback,
418 ..
419 } => {
420 if let Some(w) = whois_fallback {
421 trim_whois_raw(w);
422 }
423 }
424 LookupResult::Available {
425 ref mut whois_data, ..
426 } => {
427 if let Some(w) = whois_data {
428 trim_whois_raw(w);
429 }
430 }
431 }
432
433 result
434}
435
436#[derive(Debug, Clone)]
437pub struct SmartLookup {
438 rdap_client: RdapClient,
439 whois_client: WhoisClient,
440 availability_checker: AvailabilityChecker,
441 dns_resolver: DnsResolver,
442 prefer_rdap: bool,
444 include_fallback: bool,
446}
447
448impl Default for SmartLookup {
449 fn default() -> Self {
450 Self::new()
451 }
452}
453
454impl SmartLookup {
455 pub fn new() -> Self {
458 Self {
459 rdap_client: RdapClient::new(),
460 whois_client: WhoisClient::new(),
461 availability_checker: AvailabilityChecker::new(),
462 dns_resolver: DnsResolver::new(),
463 prefer_rdap: true,
464 include_fallback: false,
465 }
466 }
467
468 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
471 Self {
472 rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
473 whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
474 availability_checker: AvailabilityChecker::from_config(config),
475 dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
476 prefer_rdap: true,
477 include_fallback: false,
478 }
479 }
480
481 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
484 pub fn prefer_rdap(mut self, prefer: bool) -> Self {
485 self.prefer_rdap = prefer;
486 self
487 }
488
489 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
492 pub fn include_fallback(mut self, include: bool) -> Self {
493 self.include_fallback = include;
494 self
495 }
496
497 #[instrument(skip(self), fields(domain = %domain))]
501 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
502 self.lookup_with_progress(domain, None).await
503 }
504
505 #[instrument(skip(self, progress), fields(domain = %domain))]
510 pub async fn lookup_with_progress(
511 &self,
512 domain: &str,
513 progress: Option<LookupProgressCallback>,
514 ) -> Result<LookupResult> {
515 let normalized = crate::validation::normalize_domain(domain)?;
516
517 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
519 debug!(domain = %normalized, "Returning cached lookup result");
520 return Ok(cached);
521 }
522
523 let _guard = loop {
535 enum Slot {
536 Waiter(Arc<Notify>),
537 Owner(InflightGuard),
538 }
539
540 let slot = {
541 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
545 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
546 Some(existing) => Slot::Waiter(existing),
547 None => {
548 let n = Arc::new(Notify::new());
549 inflight.insert(normalized.clone(), Arc::downgrade(&n));
550 Slot::Owner(InflightGuard {
551 key: normalized.clone(),
552 notify: n,
553 })
554 }
555 }
556 };
557
558 match slot {
559 Slot::Waiter(n) => {
560 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
561 let notified = n.notified();
572 tokio::pin!(notified);
573
574 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
578 return Ok(cached);
579 }
580
581 let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
587
588 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
589 return Ok(cached);
590 }
591 continue;
594 }
595 Slot::Owner(guard) => break guard,
596 }
597 };
598
599 let result = self.lookup_concurrent(&normalized, progress).await?;
600
601 LOOKUP_CACHE.insert(normalized.clone(), trim_raw_response(result.clone()));
604
605 Ok(result)
606 }
607
608 pub fn clear_cache() {
610 LOOKUP_CACHE.clear();
611 }
612
613 #[instrument(skip(self, progress), fields(domain = %domain))]
614 async fn lookup_concurrent(
615 &self,
616 domain: &str,
617 progress: Option<LookupProgressCallback>,
618 ) -> Result<LookupResult> {
619 #[cfg(test)]
620 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
621
622 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
623
624 if let Some(ref cb) = progress {
625 cb("Querying RDAP and WHOIS concurrently");
626 }
627
628 let rdap_fut = self.rdap_client.lookup_domain(domain);
629 let whois_fut = self.whois_client.lookup(domain);
630
631 tokio::pin!(rdap_fut);
632 tokio::pin!(whois_fut);
633
634 enum LegOutcome<T> {
640 Completed(T),
641 GraceTruncated,
642 }
643
644 let (rdap_leg, whois_leg) = tokio::select! {
645 rdap_res = &mut rdap_fut => {
646 let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
648 Ok(res) => LegOutcome::Completed(res),
649 Err(_) => {
650 debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
651 LegOutcome::GraceTruncated
652 }
653 };
654 (LegOutcome::Completed(rdap_res), whois_leg)
655 }
656 whois_res = &mut whois_fut => {
657 let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
659 Ok(res) => LegOutcome::Completed(res),
660 Err(_) => {
661 debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
662 LegOutcome::GraceTruncated
663 }
664 };
665 (rdap_leg, LegOutcome::Completed(whois_res))
666 }
667 };
668
669 let rdap_outcome = match rdap_leg {
671 LegOutcome::Completed(Ok(data)) => {
672 if self.is_rdap_response_useful(&data) {
673 RdapOutcome::Useful(data)
674 } else {
675 RdapOutcome::NoData(data)
676 }
677 }
678 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
679 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
680 };
681
682 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
684 debug!("RDAP lookup successful");
685 let whois_fallback = match whois_leg {
686 LegOutcome::Completed(Ok(w)) => Some(w),
687 _ => None,
688 };
689 return Ok(LookupResult::Rdap {
690 data: Box::new(rdap_data),
691 whois_fallback,
692 });
693 }
694
695 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
706 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
707 RdapOutcome::Useful(_) => {
708 debug!("Unexpected RdapOutcome::Useful in fallback branch");
711 (String::from("RDAP ok"), None, None)
712 }
713 RdapOutcome::NoData(data) => (
714 "RDAP response incomplete".to_string(),
715 Some(Box::new(data)),
716 None,
717 ),
718 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
719 RdapOutcome::GraceTimeout => (
720 format!(
721 "RDAP did not return within {}s grace period after WHOIS won",
722 PROTOCOL_GRACE_PERIOD.as_secs()
723 ),
724 None,
725 None,
726 ),
727 };
728
729 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
730 let availability_match = should_route_to_availability(
734 rdap_returned_200,
735 rdap_seer_error.as_ref(),
736 &whois_data,
737 );
738
739 if let Some((confidence, method)) = availability_match {
740 debug!(
741 domain = %domain,
742 confidence = %confidence,
743 "Reclassifying WHOIS as availability signal"
744 );
745 if let Some(ref cb) = progress {
746 cb("Domain appears unregistered");
747 }
748 let details = match method {
752 "whois" => Some("WHOIS indicates domain is not registered".to_string()),
753 "rdap" => Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
754 _ => None,
755 };
756 let avail = AvailabilityResult {
757 domain: domain.to_string(),
758 available: true,
759 confidence: confidence.to_string(),
760 method: method.to_string(),
761 details,
762 };
763 return Ok(LookupResult::Available {
764 data: Box::new(avail),
765 rdap_error: sanitize_error_for_public(&rdap_error_str),
766 whois_error: String::new(),
767 whois_data: Some(whois_data),
768 });
769 }
770
771 let whois_is_thin = whois_response_is_thin(&whois_data);
782 if whois_is_thin && !rdap_returned_200 {
783 let whois_refuses = whois_data.indicates_registry_refusal();
784 let dns_presence = if whois_refuses {
785 DnsPresence::Unknown
787 } else {
788 self.dns_resolver.presence(domain).await
789 };
790 match classify_thin_fallback(
791 whois_is_thin,
792 rdap_returned_200,
793 whois_refuses,
794 dns_presence,
795 ) {
796 ThinFallback::Inconclusive => {
797 debug!(domain = %domain, "Thin WHOIS is a registry refusal/throttle; availability inconclusive (issue #45)");
798 if let Some(ref cb) = progress {
799 cb("Registry refused or throttled the query (availability inconclusive)");
800 }
801 let avail = AvailabilityResult {
802 domain: domain.to_string(),
803 available: false,
804 confidence: "none".to_string(),
805 method: "inconclusive".to_string(),
806 details: Some(
807 "Registry refused or throttled the query; availability is inconclusive"
808 .to_string(),
809 ),
810 };
811 return Ok(LookupResult::Available {
812 data: Box::new(avail),
813 rdap_error: sanitize_error_for_public(&rdap_error_str),
814 whois_error: String::new(),
815 whois_data: Some(whois_data),
816 });
817 }
818 ThinFallback::Available => {
819 debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
820 if let Some(ref cb) = progress {
821 cb("Domain appears unregistered (no DNS presence)");
822 }
823 let avail = AvailabilityResult {
824 domain: domain.to_string(),
825 available: true,
826 confidence: "medium".to_string(),
827 method: "dns_nxdomain".to_string(),
828 details: Some(
829 "No registry data available; domain has no DNS presence (NXDOMAIN)"
830 .to_string(),
831 ),
832 };
833 return Ok(LookupResult::Available {
834 data: Box::new(avail),
835 rdap_error: sanitize_error_for_public(&rdap_error_str),
836 whois_error: String::new(),
837 whois_data: Some(whois_data),
838 });
839 }
840 ThinFallback::Registered => {
841 debug!(domain = %domain, "Thin/no-service WHOIS + DNS delegation, reporting registered");
842 if let Some(ref cb) = progress {
843 cb("Domain is registered (registry detail unavailable)");
844 }
845 let details = if whois_data.registry_unavailable() {
846 "Domain is registered (the apex is delegated in DNS). This TLD's \
847 registry provides no port-43 WHOIS data and RDAP was unavailable \
848 (rate-limited or unreachable); retry shortly for full RDAP detail."
849 } else {
850 "Domain is registered (the apex is delegated in DNS). Registry detail \
851 was unavailable (RDAP rate-limited or unreachable and WHOIS returned \
852 no data); retry shortly for full detail."
853 };
854 let avail = AvailabilityResult {
855 domain: domain.to_string(),
856 available: false,
857 confidence: "high".to_string(),
858 method: "dns_present".to_string(),
859 details: Some(details.to_string()),
860 };
861 return Ok(LookupResult::Available {
862 data: Box::new(avail),
863 rdap_error: sanitize_error_for_public(&rdap_error_str),
864 whois_error: String::new(),
865 whois_data: Some(whois_data),
866 });
867 }
868 ThinFallback::UseWhois => {}
869 }
870 }
871 debug!("Using WHOIS result (RDAP not useful)");
872 if let Some(ref cb) = progress {
873 cb("RDAP not available (using WHOIS)");
874 }
875 return Ok(LookupResult::Whois {
876 data: whois_data,
877 rdap_error: Some(sanitize_error_for_public(&rdap_error_str)),
878 rdap_fallback: rdap_fallback_data,
879 });
880 }
881
882 let whois_error_str = match &whois_leg {
887 LegOutcome::Completed(Err(e)) => e.to_string(),
888 LegOutcome::Completed(Ok(_)) => {
889 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
891 "WHOIS returned but was not used".to_string()
892 }
893 LegOutcome::GraceTruncated => format!(
894 "WHOIS did not return within {}s grace period after RDAP won",
895 PROTOCOL_GRACE_PERIOD.as_secs()
896 ),
897 };
898
899 let prior_rdap = if rdap_returned_200 {
904 match rdap_fallback_data {
905 Some(resp) => PriorRdap::Response(resp),
907 None => PriorRdap::Missing,
908 }
909 } else if let Some(e) = rdap_seer_error {
910 PriorRdap::Failed(e)
911 } else {
912 PriorRdap::Missing
913 };
914 let prior_whois = match whois_leg {
915 LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
916 LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
918 LegOutcome::GraceTruncated => PriorWhois::Missing,
919 };
920
921 self.availability_fallback(
922 domain,
923 prior_rdap,
924 prior_whois,
925 rdap_error_str,
926 whois_error_str,
927 progress,
928 )
929 .await
930 }
931
932 async fn availability_fallback(
933 &self,
934 domain: &str,
935 prior_rdap: PriorRdap,
936 prior_whois: PriorWhois,
937 rdap_error: String,
938 whois_error: String,
939 progress: Option<LookupProgressCallback>,
940 ) -> Result<LookupResult> {
941 if let Some(ref cb) = progress {
942 cb("RDAP and WHOIS unavailable (checking availability)");
943 }
944 warn!(
945 domain = %domain,
946 rdap_error = %rdap_error,
947 whois_error = %whois_error,
948 "Both RDAP and WHOIS failed, falling back to availability check"
949 );
950
951 match self
952 .availability_checker
953 .check_with_prior(domain, prior_rdap, prior_whois)
954 .await
955 {
956 Ok(avail) => Ok(LookupResult::Available {
957 data: Box::new(avail),
958 rdap_error: sanitize_error_for_public(&rdap_error),
959 whois_error: sanitize_error_for_public(&whois_error),
960 whois_data: None,
961 }),
962 Err(avail_err) => {
963 let tld = get_tld(domain).unwrap_or("unknown");
964 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
965 format!("https://www.iana.org/domains/root/db/{}.html", tld)
966 });
967 Err(SeerError::LookupFailed {
968 domain: domain.to_string(),
969 details: format!(
970 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
971 rdap_error, whois_error, avail_err
972 ),
973 registry_url,
974 })
975 }
976 }
977 }
978
979 fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
980 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
982 let has_dates = response
983 .events
984 .iter()
985 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
986 let has_entities = !response.entities.is_empty();
987 let has_nameservers = !response.nameservers.is_empty();
988 let has_status = !response.status.is_empty();
989
990 has_name && (has_dates || has_entities || has_nameservers || has_status)
992 }
993
994 pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
999 self.dns_resolver.presence(domain).await
1000 }
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use super::*;
1006
1007 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
1017
1018 #[test]
1019 fn test_lookup_result_domain_name_whois() {
1020 let result = LookupResult::Whois {
1021 data: WhoisResponse {
1022 domain: "example.com".to_string(),
1023 registrar: Some("Test Registrar".to_string()),
1024 registrant: None,
1025 organization: None,
1026 registrant_email: None,
1027 registrant_phone: None,
1028 registrant_address: None,
1029 registrant_country: None,
1030 admin_name: None,
1031 admin_organization: None,
1032 admin_email: None,
1033 admin_phone: None,
1034 tech_name: None,
1035 tech_organization: None,
1036 tech_email: None,
1037 tech_phone: None,
1038 creation_date: None,
1039 expiration_date: None,
1040 updated_date: None,
1041 status: vec![],
1042 nameservers: vec![],
1043 dnssec: None,
1044 whois_server: "whois.example.com".to_string(),
1045 raw_response: String::new(),
1046 },
1047 rdap_error: None,
1048 rdap_fallback: None,
1049 };
1050
1051 assert_eq!(result.domain_name(), Some("example.com".to_string()));
1052 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
1053 assert!(result.is_whois());
1054 assert!(!result.is_rdap());
1055 assert!(!result.is_available());
1056 }
1057
1058 #[test]
1059 fn test_lookup_result_serialization() {
1060 let result = LookupResult::Whois {
1061 data: WhoisResponse {
1062 domain: "test.com".to_string(),
1063 registrar: None,
1064 registrant: None,
1065 organization: None,
1066 registrant_email: None,
1067 registrant_phone: None,
1068 registrant_address: None,
1069 registrant_country: None,
1070 admin_name: None,
1071 admin_organization: None,
1072 admin_email: None,
1073 admin_phone: None,
1074 tech_name: None,
1075 tech_organization: None,
1076 tech_email: None,
1077 tech_phone: None,
1078 creation_date: None,
1079 expiration_date: None,
1080 updated_date: None,
1081 status: vec![],
1082 nameservers: vec![],
1083 dnssec: None,
1084 whois_server: String::new(),
1085 raw_response: String::new(),
1086 },
1087 rdap_error: Some("RDAP failed".to_string()),
1088 rdap_fallback: None,
1089 };
1090
1091 let json = serde_json::to_string(&result).unwrap();
1092 assert!(json.contains("\"source\":\"whois\""));
1093 assert!(json.contains("RDAP failed"));
1094 }
1095
1096 #[test]
1097 fn test_lookup_result_available_serialization() {
1098 let result = LookupResult::Available {
1099 data: Box::new(AvailabilityResult {
1100 domain: "test123.xyz".to_string(),
1101 available: true,
1102 confidence: "medium".to_string(),
1103 method: "whois_error".to_string(),
1104 details: Some("WHOIS server indicates no matching records".to_string()),
1105 }),
1106 rdap_error: "RDAP failed".to_string(),
1107 whois_error: "WHOIS failed".to_string(),
1108 whois_data: None,
1109 };
1110
1111 let json = serde_json::to_string(&result).unwrap();
1112 assert!(json.contains("\"source\":\"available\""));
1113 assert!(json.contains("\"available\":true"));
1114 assert!(json.contains("test123.xyz"));
1115
1116 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1117 assert!(result.is_available());
1118 assert!(!result.is_rdap());
1119 assert!(!result.is_whois());
1120 assert!(result.registrar().is_none());
1121 assert_eq!(result.expiration_info(), (None, None));
1122 }
1123
1124 #[test]
1125 #[allow(deprecated)]
1126 fn test_smart_lookup_builder() {
1127 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
1128 assert!(!lookup.prefer_rdap);
1129 assert!(lookup.include_fallback);
1130 }
1131
1132 #[test]
1133 fn test_lookup_cache_clear() {
1134 SmartLookup::clear_cache();
1135 assert!(LOOKUP_CACHE.is_empty());
1136 }
1137
1138 #[test]
1141 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1142 const MAX_RAW: usize = 32 * 1024;
1143 let mut s = "a".repeat(MAX_RAW - 1);
1147 s.push('€'); s.push_str(&"b".repeat(100));
1149 assert!(!s.is_char_boundary(MAX_RAW));
1150
1151 truncate_on_char_boundary(&mut s, MAX_RAW);
1153 assert!(s.len() <= MAX_RAW);
1154 assert_eq!(s.len(), MAX_RAW - 1);
1157 }
1158
1159 #[test]
1160 fn trim_raw_response_truncates_multibyte_whois_without_panic() {
1161 const MAX_RAW: usize = 32 * 1024;
1162 let mut raw = "a".repeat(MAX_RAW - 1);
1163 raw.push('€');
1164 raw.push_str(&"b".repeat(1000));
1165
1166 let mut w = empty_whois("example.com");
1167 w.raw_response = raw;
1168 let result = trim_raw_response(LookupResult::Whois {
1169 data: w,
1170 rdap_error: None,
1171 rdap_fallback: None,
1172 });
1173 if let LookupResult::Whois { data, .. } = result {
1174 assert!(data.raw_response.ends_with("[truncated]"));
1175 } else {
1176 panic!("expected Whois variant");
1177 }
1178 }
1179
1180 #[test]
1183 fn test_sanitize_strips_ipv4() {
1184 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1185 let sanitized = sanitize_error_for_public(msg);
1186 assert!(
1187 !sanitized.contains("10.0.0.1"),
1188 "IPv4 should be stripped, got: {}",
1189 sanitized
1190 );
1191 assert!(sanitized.contains("[ip-redacted]"));
1192 }
1193
1194 #[test]
1195 fn test_sanitize_strips_multiple_ipv4() {
1196 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1197 let sanitized = sanitize_error_for_public(msg);
1198 assert!(!sanitized.contains("192.168.1.1"));
1199 assert!(!sanitized.contains("127.0.0.1"));
1200 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1202 }
1203
1204 #[test]
1205 fn test_sanitize_strips_ipv6() {
1206 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1207 let sanitized = sanitize_error_for_public(msg);
1208 assert!(!sanitized.contains("fe80::1"));
1209 assert!(sanitized.contains("[ip-redacted]"));
1210 }
1211
1212 #[test]
1213 fn sanitize_leaves_mac_address_like_tokens_alone() {
1214 let msg = "error code af:ba:12 at line 5";
1215 let out = sanitize_error_for_public(msg);
1216 assert!(
1217 out.contains("af:ba:12"),
1218 "MAC fragment should not be stripped: {}",
1219 out
1220 );
1221 }
1222
1223 #[test]
1224 fn sanitize_strips_real_ipv6() {
1225 let msg = "cannot reach 2001:db8::1 — timeout";
1226 let out = sanitize_error_for_public(msg);
1227 assert!(!out.contains("2001:db8::1"));
1228 assert!(out.contains("[ip-redacted]"));
1229 }
1230
1231 #[test]
1232 fn sanitize_strips_fe80_link_local() {
1233 let msg = "peer at fe80::1 unreachable";
1234 let out = sanitize_error_for_public(msg);
1235 assert!(out.contains("[ip-redacted]"));
1236 }
1237
1238 #[test]
1239 fn test_sanitize_truncates_long_message() {
1240 let long = "a".repeat(500);
1242 let sanitized = sanitize_error_for_public(&long);
1243 let char_count = sanitized.chars().count();
1245 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1246 assert!(sanitized.ends_with('…'));
1247 }
1248
1249 #[test]
1250 fn test_sanitize_preserves_short_messages() {
1251 let msg = "RDAP timed out after 15s";
1252 let sanitized = sanitize_error_for_public(msg);
1253 assert_eq!(sanitized, msg);
1254 }
1255
1256 #[test]
1259 fn test_is_rdap_response_useful_detects_no_data() {
1260 use crate::rdap::RdapResponse;
1261 let resp = RdapResponse {
1265 ldh_name: Some("example.com".to_string()),
1266 ..Default::default()
1267 };
1268 let lookup = SmartLookup::new();
1269 assert!(
1270 !lookup.is_rdap_response_useful(&resp),
1271 "Response with only a name should be classified as NoData"
1272 );
1273
1274 let useful = RdapResponse {
1276 ldh_name: Some("example.com".to_string()),
1277 status: vec!["active".to_string()],
1278 ..Default::default()
1279 };
1280 assert!(lookup.is_rdap_response_useful(&useful));
1281 }
1282
1283 #[tokio::test]
1291 async fn test_inflight_coalescing_map() {
1292 let _serial = INFLIGHT_TEST_SERIAL
1297 .lock()
1298 .unwrap_or_else(|p| p.into_inner());
1299 let domain = unique_test_key("__coalesce");
1308
1309 {
1311 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1312 m.remove(&domain);
1313 }
1314
1315 let owner_notify = Arc::new(Notify::new());
1317 {
1318 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1319 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1320 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1321 }
1322
1323 let waiter = {
1325 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1326 m.get(&domain)
1327 .and_then(|w| w.upgrade())
1328 .expect("Second caller must observe in-flight entry")
1329 };
1330
1331 let waiter_clone = waiter.clone();
1333 let handle = tokio::spawn(async move {
1334 waiter_clone.notified().await;
1335 });
1336
1337 tokio::time::sleep(Duration::from_millis(20)).await;
1339 {
1340 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1341 m.remove(&domain);
1342 }
1343 owner_notify.notify_waiters();
1344
1345 tokio::time::timeout(Duration::from_secs(1), handle)
1347 .await
1348 .expect("waiter must unblock after notify")
1349 .expect("waiter task joined cleanly");
1350
1351 drop(owner_notify);
1353 drop(waiter);
1354 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1355 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1356 }
1357
1358 fn unique_test_key(prefix: &str) -> String {
1364 use std::sync::atomic::{AtomicU64, Ordering};
1365 use std::time::{SystemTime, UNIX_EPOCH};
1366 static COUNTER: AtomicU64 = AtomicU64::new(0);
1367 let nanos = SystemTime::now()
1368 .duration_since(UNIX_EPOCH)
1369 .map(|d| d.as_nanos())
1370 .unwrap_or(0);
1371 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1372 format!("{}_{}_{}.example.", prefix, nanos, n)
1373 }
1374
1375 #[test]
1381 fn test_sanitize_applied_to_available_fields() {
1382 let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1383 let whois_raw = "connection refused at 192.168.0.5";
1384 let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1385 let sanitized_whois = sanitize_error_for_public(whois_raw);
1386 let result = LookupResult::Available {
1387 data: Box::new(AvailabilityResult {
1388 domain: "unreg.test".to_string(),
1389 available: true,
1390 confidence: "low".to_string(),
1391 method: "heuristic".to_string(),
1392 details: None,
1393 }),
1394 rdap_error: sanitized_rdap,
1395 whois_error: sanitized_whois,
1396 whois_data: None,
1397 };
1398 if let LookupResult::Available {
1399 rdap_error,
1400 whois_error,
1401 ..
1402 } = result
1403 {
1404 assert!(!rdap_error.contains("10.0.0.1"));
1405 assert!(!whois_error.contains("192.168.0.5"));
1406 assert!(rdap_error.contains("[ip-redacted]"));
1407 assert!(whois_error.contains("[ip-redacted]"));
1408 } else {
1409 panic!("expected Available variant");
1410 }
1411 }
1412
1413 #[test]
1414 fn rdap_error_is_404_matches_standard_404() {
1415 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1416 assert!(rdap_error_is_404(&e));
1417 }
1418
1419 #[test]
1420 fn rdap_error_is_404_matches_without_reason_phrase() {
1421 let e = SeerError::RdapError("query failed with status 404".to_string());
1422 assert!(rdap_error_is_404(&e));
1423 }
1424
1425 #[test]
1426 fn rdap_error_is_404_rejects_other_statuses() {
1427 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1428 assert!(!rdap_error_is_404(&e));
1429 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1430 assert!(!rdap_error_is_404(&e));
1431 }
1432
1433 #[test]
1434 fn rdap_error_is_404_rejects_non_http_errors() {
1435 let e = SeerError::RdapError("connection timeout".to_string());
1436 assert!(!rdap_error_is_404(&e));
1437 let e = SeerError::Timeout("rdap".to_string());
1438 assert!(!rdap_error_is_404(&e));
1439 }
1440
1441 #[test]
1442 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1443 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1445 assert!(!rdap_error_is_404(&e));
1446 }
1447
1448 fn empty_whois(domain: &str) -> WhoisResponse {
1451 WhoisResponse {
1452 domain: domain.to_string(),
1453 registrar: None,
1454 registrant: None,
1455 organization: None,
1456 registrant_email: None,
1457 registrant_phone: None,
1458 registrant_address: None,
1459 registrant_country: None,
1460 admin_name: None,
1461 admin_organization: None,
1462 admin_email: None,
1463 admin_phone: None,
1464 tech_name: None,
1465 tech_organization: None,
1466 tech_email: None,
1467 tech_phone: None,
1468 creation_date: None,
1469 expiration_date: None,
1470 updated_date: None,
1471 nameservers: vec![],
1472 status: vec![],
1473 dnssec: None,
1474 whois_server: String::new(),
1475 raw_response: String::new(),
1476 }
1477 }
1478
1479 #[test]
1480 fn whois_response_is_thin_when_all_key_fields_missing() {
1481 let w = empty_whois("example.com");
1482 assert!(whois_response_is_thin(&w));
1483 }
1484
1485 #[test]
1486 fn whois_response_is_not_thin_when_registrar_present() {
1487 let mut w = empty_whois("example.com");
1488 w.registrar = Some("Test Registrar".to_string());
1489 assert!(!whois_response_is_thin(&w));
1490 }
1491
1492 #[test]
1493 fn whois_response_is_not_thin_when_creation_date_present() {
1494 let mut w = empty_whois("example.com");
1495 w.creation_date = Some(Utc::now());
1496 assert!(!whois_response_is_thin(&w));
1497 }
1498
1499 #[test]
1500 fn whois_response_is_not_thin_when_expiration_date_present() {
1501 let mut w = empty_whois("example.com");
1502 w.expiration_date = Some(Utc::now());
1503 assert!(!whois_response_is_thin(&w));
1504 }
1505
1506 #[test]
1507 fn whois_response_is_thin_even_with_nameservers_alone() {
1508 let mut w = empty_whois("example.com");
1509 w.nameservers = vec!["ns1.example.net".to_string()];
1510 assert!(whois_response_is_thin(&w));
1511 }
1512
1513 use crate::rdap::RdapResponse;
1516
1517 #[allow(dead_code)]
1518 fn make_empty_rdap_response() -> RdapResponse {
1519 serde_json::from_value(serde_json::json!({
1520 "objectClassName": "domain",
1521 }))
1522 .expect("valid minimal RDAP response")
1523 }
1524
1525 #[test]
1526 fn classify_whois_leg_case_a_high_confidence() {
1527 let mut w = empty_whois("zaccodes.com");
1528 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1529 assert!(w.is_available());
1530 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1531 let (verdict, method) =
1532 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1533 assert_eq!(verdict, "high");
1534 assert_eq!(method, "whois");
1535 }
1536
1537 #[test]
1538 fn classify_whois_leg_case_b_matches_decide_fallback() {
1539 let w = empty_whois("example.xyz");
1546 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1547 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1548 let (verdict, method) =
1549 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1550 assert_eq!(verdict, "high");
1551 assert_eq!(method, "rdap");
1552 }
1553
1554 #[test]
1555 fn classify_whois_leg_rejects_thin_whois_without_404() {
1556 let w = empty_whois("example.xyz");
1557 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1558 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1559 }
1560
1561 #[test]
1562 fn classify_whois_leg_rejects_whois_with_real_data() {
1563 let mut w = empty_whois("legacy.tld");
1564 w.registrar = Some("Legacy Registry".to_string());
1565 w.creation_date = Some(Utc::now());
1566 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1567 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1568 }
1569
1570 #[test]
1571 fn classify_whois_leg_case_a_wins_over_case_b() {
1572 let mut w = empty_whois("example.com");
1573 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1574 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1575 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1576 assert_eq!(verdict, "high");
1577 }
1578
1579 #[test]
1587 fn rdap_200_vetoes_whois_no_match() {
1588 let mut w = empty_whois("freshly-registered.com");
1589 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1590 assert!(
1592 should_route_to_availability(true, None, &w).is_none(),
1593 "RDAP 200 must veto WHOIS-only availability claim",
1594 );
1595 }
1596
1597 #[test]
1598 fn rdap_200_vetoes_even_with_thin_whois() {
1599 let w = empty_whois("freshly-registered.com");
1600 assert!(
1602 should_route_to_availability(true, None, &w).is_none(),
1603 "RDAP 200 must veto even when WHOIS is thin",
1604 );
1605 }
1606
1607 #[test]
1608 fn rdap_404_with_whois_no_match_routes_to_available() {
1609 let mut w = empty_whois("genuinely-free.com");
1610 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1611 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1612 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1613 assert_eq!(result, Some(("high", "whois")));
1614 }
1615
1616 #[test]
1617 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1618 let mut w = empty_whois("genuinely-free.com");
1619 w.raw_response = "Domain not found".to_string();
1620 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1623 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1624 assert_eq!(result, Some(("high", "whois")));
1625 }
1626
1627 #[test]
1628 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1629 let mut w = empty_whois("genuinely-free.com");
1631 w.raw_response = "No match".to_string();
1632 let result = should_route_to_availability(false, None, &w);
1633 assert_eq!(result, Some(("high", "whois")));
1634 }
1635
1636 #[test]
1637 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1638 let mut w = empty_whois("registered.com");
1639 w.registrar = Some("Example Registrar Ltd".to_string());
1640 assert!(should_route_to_availability(false, None, &w).is_none());
1644 }
1645
1646 #[test]
1649 fn thin_fallback_refusal_is_inconclusive_regardless_of_dns() {
1650 for dns in [
1655 DnsPresence::Absent,
1656 DnsPresence::Present,
1657 DnsPresence::Unknown,
1658 ] {
1659 assert_eq!(
1660 classify_thin_fallback(true, false, true, dns),
1661 ThinFallback::Inconclusive
1662 );
1663 }
1664 }
1665
1666 #[test]
1667 fn thin_fallback_nxdomain_is_available() {
1668 assert_eq!(
1669 classify_thin_fallback(true, false, false, DnsPresence::Absent),
1670 ThinFallback::Available
1671 );
1672 }
1673
1674 #[test]
1675 fn thin_fallback_delegated_is_registered() {
1676 assert_eq!(
1680 classify_thin_fallback(true, false, false, DnsPresence::Present),
1681 ThinFallback::Registered
1682 );
1683 }
1684
1685 #[test]
1686 fn thin_fallback_unknown_dns_uses_whois() {
1687 assert_eq!(
1689 classify_thin_fallback(true, false, false, DnsPresence::Unknown),
1690 ThinFallback::UseWhois
1691 );
1692 }
1693
1694 #[test]
1695 fn thin_fallback_requires_thin_whois() {
1696 assert_eq!(
1698 classify_thin_fallback(false, false, false, DnsPresence::Absent),
1699 ThinFallback::UseWhois
1700 );
1701 }
1702
1703 #[test]
1704 fn thin_fallback_vetoed_by_rdap_200() {
1705 assert_eq!(
1708 classify_thin_fallback(true, true, false, DnsPresence::Absent),
1709 ThinFallback::UseWhois
1710 );
1711 assert_eq!(
1712 classify_thin_fallback(true, true, true, DnsPresence::Present),
1713 ThinFallback::UseWhois
1714 );
1715 }
1716
1717 #[test]
1727 fn lookup_inflight_recovers_from_poisoned_mutex() {
1728 use std::panic::{catch_unwind, AssertUnwindSafe};
1729
1730 let _serial = INFLIGHT_TEST_SERIAL
1732 .lock()
1733 .unwrap_or_else(|p| p.into_inner());
1734
1735 let _ = catch_unwind(AssertUnwindSafe(|| {
1737 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1738 panic!("poisoning LOOKUP_INFLIGHT for test");
1739 }));
1740
1741 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1745 let canary = unique_test_key("__poison_recovery");
1747 guard.insert(canary.clone(), Weak::new());
1748 assert!(guard.contains_key(&canary));
1749 guard.remove(&canary);
1750 }
1751
1752 #[test]
1755 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1756 use std::panic::{catch_unwind, AssertUnwindSafe};
1757
1758 let _serial = INFLIGHT_TEST_SERIAL
1764 .lock()
1765 .unwrap_or_else(|p| p.into_inner());
1766
1767 let key = unique_test_key("__drop_poison");
1772 let notify = Arc::new(Notify::new());
1773 {
1774 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1775 map.insert(key.clone(), Arc::downgrade(¬ify));
1776 }
1777 let guard = InflightGuard {
1778 key: key.clone(),
1779 notify: notify.clone(),
1780 };
1781
1782 let _ = catch_unwind(AssertUnwindSafe(|| {
1784 let _g = LOOKUP_INFLIGHT.lock().unwrap();
1785 panic!("poisoning LOOKUP_INFLIGHT for drop test");
1786 }));
1787
1788 drop(guard);
1791
1792 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1793 assert!(
1794 !map.contains_key(&key),
1795 "poisoned-mutex drop path should still remove the in-flight entry"
1796 );
1797 }
1798}