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};
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(("medium", "whois_thin_response"));
114 }
115 None
116}
117
118fn should_route_to_availability(
125 rdap_returned_200: bool,
126 rdap_seer_error: Option<&SeerError>,
127 whois_data: &WhoisResponse,
128) -> Option<(&'static str, &'static str)> {
129 if rdap_returned_200 {
130 return None;
131 }
132 if whois_data.is_available() {
138 return Some(("high", "whois"));
139 }
140 rdap_seer_error.and_then(|e| {
141 classify_whois_leg(whois_data, e)
146 })
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152enum ThinFallback {
153 Inconclusive,
156 Available,
158 Registered,
160 UseWhois,
162}
163
164fn classify_thin_fallback(
178 is_thin: bool,
179 rdap_returned_200: bool,
180 whois_refuses: bool,
181 dns: DnsPresence,
182) -> ThinFallback {
183 if !is_thin || rdap_returned_200 {
184 return ThinFallback::UseWhois;
185 }
186 if whois_refuses {
187 return ThinFallback::Inconclusive;
188 }
189 match dns {
190 DnsPresence::Absent => ThinFallback::Available,
191 DnsPresence::Present => ThinFallback::Registered,
192 DnsPresence::Unknown => ThinFallback::UseWhois,
193 }
194}
195
196fn sanitize_error_for_public(msg: &str) -> String {
202 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
203 let s = strip_ipv6(&s);
204 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
205 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
206 trunc.push('…');
207 trunc
208 } else {
209 s
210 }
211}
212
213struct InflightGuard {
226 key: String,
227 notify: Arc<Notify>,
228}
229
230impl Drop for InflightGuard {
231 fn drop(&mut self) {
232 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
245 inflight.remove(&self.key);
246 drop(inflight);
247 self.notify.notify_waiters();
248 }
249}
250
251enum RdapOutcome {
257 Useful(RdapResponse),
258 NoData(RdapResponse),
259 Error(SeerError),
260 GraceTimeout,
263}
264
265pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(tag = "source", rename_all = "lowercase")]
271pub enum LookupResult {
272 Rdap {
273 data: Box<RdapResponse>,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 whois_fallback: Option<WhoisResponse>,
276 },
277 Whois {
278 data: WhoisResponse,
279 rdap_error: Option<String>,
280 #[serde(skip_serializing_if = "Option::is_none")]
281 rdap_fallback: Option<Box<RdapResponse>>,
282 },
283 Available {
284 data: Box<AvailabilityResult>,
285 rdap_error: String,
286 whois_error: String,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
291 whois_data: Option<WhoisResponse>,
292 },
293}
294
295impl LookupResult {
296 pub fn domain_name(&self) -> Option<String> {
298 match self {
299 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
300 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
301 LookupResult::Available { data, .. } => Some(data.domain.clone()),
302 }
303 }
304
305 pub fn registrar(&self) -> Option<String> {
307 match self {
308 LookupResult::Rdap {
309 data,
310 whois_fallback,
311 } => data
312 .get_registrar()
313 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
314 LookupResult::Whois { data, .. } => data.registrar.clone(),
315 LookupResult::Available { .. } => None,
316 }
317 }
318
319 pub fn organization(&self) -> Option<String> {
321 match self {
322 LookupResult::Rdap {
323 data,
324 whois_fallback,
325 } => data
326 .get_registrant_organization()
327 .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
328 LookupResult::Whois { data, .. } => data.organization.clone(),
329 LookupResult::Available { .. } => None,
330 }
331 }
332
333 pub fn is_rdap(&self) -> bool {
335 matches!(self, LookupResult::Rdap { .. })
336 }
337
338 pub fn is_whois(&self) -> bool {
340 matches!(self, LookupResult::Whois { .. })
341 }
342
343 pub fn is_available(&self) -> bool {
345 matches!(self, LookupResult::Available { .. })
346 }
347
348 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
350 match self {
351 LookupResult::Rdap {
352 data,
353 whois_fallback,
354 } => {
355 let expiration_date = data
357 .events
358 .iter()
359 .find(|e| e.event_action == "expiration")
360 .and_then(|e| e.parsed_date())
361 .or_else(|| {
362 whois_fallback.as_ref().and_then(|w| w.expiration_date)
364 });
365
366 let registrar = data
367 .get_registrar()
368 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
369
370 (expiration_date, registrar)
371 }
372 LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
373 LookupResult::Available { .. } => (None, None),
374 }
375 }
376}
377
378fn truncate_on_char_boundary(s: &mut String, max: usize) {
385 if s.len() > max {
386 let mut end = max;
387 while end > 0 && !s.is_char_boundary(end) {
388 end -= 1;
389 }
390 s.truncate(end);
391 }
392}
393
394fn trim_for_cache(mut result: LookupResult) -> LookupResult {
398 const MAX_RAW: usize = 32 * 1024;
399
400 match result {
401 LookupResult::Whois { ref mut data, .. } => {
402 if data.raw_response.len() > MAX_RAW {
403 truncate_on_char_boundary(&mut data.raw_response, MAX_RAW);
404 data.raw_response.push_str("\n... [truncated for cache]");
405 }
406 }
407 LookupResult::Rdap {
408 ref mut whois_fallback,
409 ..
410 } => {
411 if let Some(ref mut w) = whois_fallback {
412 if w.raw_response.len() > MAX_RAW {
413 truncate_on_char_boundary(&mut w.raw_response, MAX_RAW);
414 w.raw_response.push_str("\n... [truncated for cache]");
415 }
416 }
417 }
418 LookupResult::Available {
419 ref mut whois_data, ..
420 } => {
421 if let Some(ref mut w) = whois_data {
422 if w.raw_response.len() > MAX_RAW {
423 truncate_on_char_boundary(&mut w.raw_response, MAX_RAW);
424 w.raw_response.push_str("\n... [truncated for cache]");
425 }
426 }
427 }
428 }
429
430 result
431}
432
433#[derive(Debug, Clone)]
434pub struct SmartLookup {
435 rdap_client: RdapClient,
436 whois_client: WhoisClient,
437 availability_checker: AvailabilityChecker,
438 dns_resolver: DnsResolver,
439 prefer_rdap: bool,
441 include_fallback: bool,
443}
444
445impl Default for SmartLookup {
446 fn default() -> Self {
447 Self::new()
448 }
449}
450
451impl SmartLookup {
452 pub fn new() -> Self {
455 Self {
456 rdap_client: RdapClient::new(),
457 whois_client: WhoisClient::new(),
458 availability_checker: AvailabilityChecker::new(),
459 dns_resolver: DnsResolver::new(),
460 prefer_rdap: true,
461 include_fallback: false,
462 }
463 }
464
465 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
468 Self {
469 rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
470 whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
471 availability_checker: AvailabilityChecker::from_config(config),
472 dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
473 prefer_rdap: true,
474 include_fallback: false,
475 }
476 }
477
478 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
481 pub fn prefer_rdap(mut self, prefer: bool) -> Self {
482 self.prefer_rdap = prefer;
483 self
484 }
485
486 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
489 pub fn include_fallback(mut self, include: bool) -> Self {
490 self.include_fallback = include;
491 self
492 }
493
494 #[instrument(skip(self), fields(domain = %domain))]
498 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
499 self.lookup_with_progress(domain, None).await
500 }
501
502 #[instrument(skip(self, progress), fields(domain = %domain))]
507 pub async fn lookup_with_progress(
508 &self,
509 domain: &str,
510 progress: Option<LookupProgressCallback>,
511 ) -> Result<LookupResult> {
512 let normalized = crate::validation::normalize_domain(domain)?;
513
514 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
516 debug!(domain = %normalized, "Returning cached lookup result");
517 return Ok(cached);
518 }
519
520 let _guard = loop {
532 enum Slot {
533 Waiter(Arc<Notify>),
534 Owner(InflightGuard),
535 }
536
537 let slot = {
538 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
542 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
543 Some(existing) => Slot::Waiter(existing),
544 None => {
545 let n = Arc::new(Notify::new());
546 inflight.insert(normalized.clone(), Arc::downgrade(&n));
547 Slot::Owner(InflightGuard {
548 key: normalized.clone(),
549 notify: n,
550 })
551 }
552 }
553 };
554
555 match slot {
556 Slot::Waiter(n) => {
557 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
558 let notified = n.notified();
569 tokio::pin!(notified);
570
571 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
575 return Ok(cached);
576 }
577
578 let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
584
585 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
586 return Ok(cached);
587 }
588 continue;
591 }
592 Slot::Owner(guard) => break guard,
593 }
594 };
595
596 let result = self.lookup_concurrent(&normalized, progress).await?;
597
598 LOOKUP_CACHE.insert(normalized.clone(), trim_for_cache(result.clone()));
601
602 Ok(result)
603 }
604
605 pub fn clear_cache() {
607 LOOKUP_CACHE.clear();
608 }
609
610 #[instrument(skip(self, progress), fields(domain = %domain))]
611 async fn lookup_concurrent(
612 &self,
613 domain: &str,
614 progress: Option<LookupProgressCallback>,
615 ) -> Result<LookupResult> {
616 #[cfg(test)]
617 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
618
619 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
620
621 if let Some(ref cb) = progress {
622 cb("Querying RDAP and WHOIS concurrently");
623 }
624
625 let rdap_fut = self.rdap_client.lookup_domain(domain);
626 let whois_fut = self.whois_client.lookup(domain);
627
628 tokio::pin!(rdap_fut);
629 tokio::pin!(whois_fut);
630
631 enum LegOutcome<T> {
637 Completed(T),
638 GraceTruncated,
639 }
640
641 let (rdap_leg, whois_leg) = tokio::select! {
642 rdap_res = &mut rdap_fut => {
643 let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
645 Ok(res) => LegOutcome::Completed(res),
646 Err(_) => {
647 debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
648 LegOutcome::GraceTruncated
649 }
650 };
651 (LegOutcome::Completed(rdap_res), whois_leg)
652 }
653 whois_res = &mut whois_fut => {
654 let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
656 Ok(res) => LegOutcome::Completed(res),
657 Err(_) => {
658 debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
659 LegOutcome::GraceTruncated
660 }
661 };
662 (rdap_leg, LegOutcome::Completed(whois_res))
663 }
664 };
665
666 let rdap_outcome = match rdap_leg {
668 LegOutcome::Completed(Ok(data)) => {
669 if self.is_rdap_response_useful(&data) {
670 RdapOutcome::Useful(data)
671 } else {
672 RdapOutcome::NoData(data)
673 }
674 }
675 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
676 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
677 };
678
679 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
681 debug!("RDAP lookup successful");
682 let whois_fallback = match whois_leg {
683 LegOutcome::Completed(Ok(w)) => Some(w),
684 _ => None,
685 };
686 return Ok(LookupResult::Rdap {
687 data: Box::new(rdap_data),
688 whois_fallback,
689 });
690 }
691
692 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
703 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
704 RdapOutcome::Useful(_) => {
705 debug!("Unexpected RdapOutcome::Useful in fallback branch");
708 (String::from("RDAP ok"), None, None)
709 }
710 RdapOutcome::NoData(data) => (
711 "RDAP response incomplete".to_string(),
712 Some(Box::new(data)),
713 None,
714 ),
715 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
716 RdapOutcome::GraceTimeout => (
717 format!(
718 "RDAP did not return within {}s grace period after WHOIS won",
719 PROTOCOL_GRACE_PERIOD.as_secs()
720 ),
721 None,
722 None,
723 ),
724 };
725
726 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
727 let availability_match = should_route_to_availability(
731 rdap_returned_200,
732 rdap_seer_error.as_ref(),
733 &whois_data,
734 );
735
736 if let Some((confidence, method)) = availability_match {
737 debug!(
738 domain = %domain,
739 confidence = %confidence,
740 "Reclassifying WHOIS as availability signal"
741 );
742 if let Some(ref cb) = progress {
743 cb("Domain appears unregistered");
744 }
745 let details = match confidence {
746 "high" => Some("WHOIS indicates domain is not registered".to_string()),
747 "medium" => Some(
748 "WHOIS returned no registrar or registration dates; RDAP returned 404"
749 .to_string(),
750 ),
751 _ => None,
752 };
753 let avail = AvailabilityResult {
754 domain: domain.to_string(),
755 available: true,
756 confidence: confidence.to_string(),
757 method: method.to_string(),
758 details,
759 };
760 return Ok(LookupResult::Available {
761 data: Box::new(avail),
762 rdap_error: sanitize_error_for_public(&rdap_error_str),
763 whois_error: String::new(),
764 whois_data: Some(whois_data),
765 });
766 }
767
768 let whois_is_thin = whois_response_is_thin(&whois_data);
779 if whois_is_thin && !rdap_returned_200 {
780 let whois_refuses = whois_data.indicates_registry_refusal();
781 let dns_presence = if whois_refuses {
782 DnsPresence::Unknown
784 } else {
785 self.dns_resolver.presence(domain).await
786 };
787 match classify_thin_fallback(
788 whois_is_thin,
789 rdap_returned_200,
790 whois_refuses,
791 dns_presence,
792 ) {
793 ThinFallback::Inconclusive => {
794 debug!(domain = %domain, "Thin WHOIS is a registry refusal/throttle; availability inconclusive (issue #45)");
795 if let Some(ref cb) = progress {
796 cb("Registry refused or throttled the query (availability inconclusive)");
797 }
798 let avail = AvailabilityResult {
799 domain: domain.to_string(),
800 available: false,
801 confidence: "none".to_string(),
802 method: "inconclusive".to_string(),
803 details: Some(
804 "Registry refused or throttled the query; availability is inconclusive"
805 .to_string(),
806 ),
807 };
808 return Ok(LookupResult::Available {
809 data: Box::new(avail),
810 rdap_error: sanitize_error_for_public(&rdap_error_str),
811 whois_error: String::new(),
812 whois_data: Some(whois_data),
813 });
814 }
815 ThinFallback::Available => {
816 debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
817 if let Some(ref cb) = progress {
818 cb("Domain appears unregistered (no DNS presence)");
819 }
820 let avail = AvailabilityResult {
821 domain: domain.to_string(),
822 available: true,
823 confidence: "medium".to_string(),
824 method: "dns_nxdomain".to_string(),
825 details: Some(
826 "No registry data available; domain has no DNS presence (NXDOMAIN)"
827 .to_string(),
828 ),
829 };
830 return Ok(LookupResult::Available {
831 data: Box::new(avail),
832 rdap_error: sanitize_error_for_public(&rdap_error_str),
833 whois_error: String::new(),
834 whois_data: Some(whois_data),
835 });
836 }
837 ThinFallback::Registered => {
838 debug!(domain = %domain, "Thin/no-service WHOIS + DNS delegation, reporting registered");
839 if let Some(ref cb) = progress {
840 cb("Domain is registered (registry detail unavailable)");
841 }
842 let details = if whois_data.registry_unavailable() {
843 "Domain is registered (the apex is delegated in DNS). This TLD's \
844 registry provides no port-43 WHOIS data and RDAP was unavailable \
845 (rate-limited or unreachable); retry shortly for full RDAP detail."
846 } else {
847 "Domain is registered (the apex is delegated in DNS). Registry detail \
848 was unavailable (RDAP rate-limited or unreachable and WHOIS returned \
849 no data); retry shortly for full detail."
850 };
851 let avail = AvailabilityResult {
852 domain: domain.to_string(),
853 available: false,
854 confidence: "high".to_string(),
855 method: "dns_present".to_string(),
856 details: Some(details.to_string()),
857 };
858 return Ok(LookupResult::Available {
859 data: Box::new(avail),
860 rdap_error: sanitize_error_for_public(&rdap_error_str),
861 whois_error: String::new(),
862 whois_data: Some(whois_data),
863 });
864 }
865 ThinFallback::UseWhois => {}
866 }
867 }
868 debug!("Using WHOIS result (RDAP not useful)");
869 if let Some(ref cb) = progress {
870 cb("RDAP not available (using WHOIS)");
871 }
872 return Ok(LookupResult::Whois {
873 data: whois_data,
874 rdap_error: Some(rdap_error_str),
875 rdap_fallback: rdap_fallback_data,
876 });
877 }
878
879 let whois_error_str = match whois_leg {
883 LegOutcome::Completed(Err(e)) => e.to_string(),
884 LegOutcome::Completed(Ok(_)) => {
885 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
887 "WHOIS returned but was not used".to_string()
888 }
889 LegOutcome::GraceTruncated => format!(
890 "WHOIS did not return within {}s grace period after RDAP won",
891 PROTOCOL_GRACE_PERIOD.as_secs()
892 ),
893 };
894
895 self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
896 .await
897 }
898
899 async fn availability_fallback(
900 &self,
901 domain: &str,
902 rdap_error: String,
903 whois_error: String,
904 progress: Option<LookupProgressCallback>,
905 ) -> Result<LookupResult> {
906 if let Some(ref cb) = progress {
907 cb("RDAP and WHOIS unavailable (checking availability)");
908 }
909 warn!(
910 domain = %domain,
911 rdap_error = %rdap_error,
912 whois_error = %whois_error,
913 "Both RDAP and WHOIS failed, falling back to availability check"
914 );
915
916 match self.availability_checker.check(domain).await {
917 Ok(avail) => Ok(LookupResult::Available {
918 data: Box::new(avail),
919 rdap_error: sanitize_error_for_public(&rdap_error),
920 whois_error: sanitize_error_for_public(&whois_error),
921 whois_data: None,
922 }),
923 Err(avail_err) => {
924 let tld = get_tld(domain).unwrap_or("unknown");
925 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
926 format!("https://www.iana.org/domains/root/db/{}.html", tld)
927 });
928 Err(SeerError::LookupFailed {
929 domain: domain.to_string(),
930 details: format!(
931 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
932 rdap_error, whois_error, avail_err
933 ),
934 registry_url,
935 })
936 }
937 }
938 }
939
940 fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
941 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
943 let has_dates = response
944 .events
945 .iter()
946 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
947 let has_entities = !response.entities.is_empty();
948 let has_nameservers = !response.nameservers.is_empty();
949 let has_status = !response.status.is_empty();
950
951 has_name && (has_dates || has_entities || has_nameservers || has_status)
953 }
954}
955
956#[cfg(test)]
957mod tests {
958 use super::*;
959
960 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
970
971 #[test]
972 fn test_lookup_result_domain_name_whois() {
973 let result = LookupResult::Whois {
974 data: WhoisResponse {
975 domain: "example.com".to_string(),
976 registrar: Some("Test Registrar".to_string()),
977 registrant: None,
978 organization: None,
979 registrant_email: None,
980 registrant_phone: None,
981 registrant_address: None,
982 registrant_country: None,
983 admin_name: None,
984 admin_organization: None,
985 admin_email: None,
986 admin_phone: None,
987 tech_name: None,
988 tech_organization: None,
989 tech_email: None,
990 tech_phone: None,
991 creation_date: None,
992 expiration_date: None,
993 updated_date: None,
994 status: vec![],
995 nameservers: vec![],
996 dnssec: None,
997 whois_server: "whois.example.com".to_string(),
998 raw_response: String::new(),
999 },
1000 rdap_error: None,
1001 rdap_fallback: None,
1002 };
1003
1004 assert_eq!(result.domain_name(), Some("example.com".to_string()));
1005 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
1006 assert!(result.is_whois());
1007 assert!(!result.is_rdap());
1008 assert!(!result.is_available());
1009 }
1010
1011 #[test]
1012 fn test_lookup_result_serialization() {
1013 let result = LookupResult::Whois {
1014 data: WhoisResponse {
1015 domain: "test.com".to_string(),
1016 registrar: None,
1017 registrant: None,
1018 organization: None,
1019 registrant_email: None,
1020 registrant_phone: None,
1021 registrant_address: None,
1022 registrant_country: None,
1023 admin_name: None,
1024 admin_organization: None,
1025 admin_email: None,
1026 admin_phone: None,
1027 tech_name: None,
1028 tech_organization: None,
1029 tech_email: None,
1030 tech_phone: None,
1031 creation_date: None,
1032 expiration_date: None,
1033 updated_date: None,
1034 status: vec![],
1035 nameservers: vec![],
1036 dnssec: None,
1037 whois_server: String::new(),
1038 raw_response: String::new(),
1039 },
1040 rdap_error: Some("RDAP failed".to_string()),
1041 rdap_fallback: None,
1042 };
1043
1044 let json = serde_json::to_string(&result).unwrap();
1045 assert!(json.contains("\"source\":\"whois\""));
1046 assert!(json.contains("RDAP failed"));
1047 }
1048
1049 #[test]
1050 fn test_lookup_result_available_serialization() {
1051 let result = LookupResult::Available {
1052 data: Box::new(AvailabilityResult {
1053 domain: "test123.xyz".to_string(),
1054 available: true,
1055 confidence: "medium".to_string(),
1056 method: "whois_error".to_string(),
1057 details: Some("WHOIS server indicates no matching records".to_string()),
1058 }),
1059 rdap_error: "RDAP failed".to_string(),
1060 whois_error: "WHOIS failed".to_string(),
1061 whois_data: None,
1062 };
1063
1064 let json = serde_json::to_string(&result).unwrap();
1065 assert!(json.contains("\"source\":\"available\""));
1066 assert!(json.contains("\"available\":true"));
1067 assert!(json.contains("test123.xyz"));
1068
1069 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1070 assert!(result.is_available());
1071 assert!(!result.is_rdap());
1072 assert!(!result.is_whois());
1073 assert!(result.registrar().is_none());
1074 assert_eq!(result.expiration_info(), (None, None));
1075 }
1076
1077 #[test]
1078 #[allow(deprecated)]
1079 fn test_smart_lookup_builder() {
1080 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
1081 assert!(!lookup.prefer_rdap);
1082 assert!(lookup.include_fallback);
1083 }
1084
1085 #[test]
1086 fn test_lookup_cache_clear() {
1087 SmartLookup::clear_cache();
1088 assert!(LOOKUP_CACHE.is_empty());
1089 }
1090
1091 #[test]
1094 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1095 const MAX_RAW: usize = 32 * 1024;
1096 let mut s = "a".repeat(MAX_RAW - 1);
1100 s.push('€'); s.push_str(&"b".repeat(100));
1102 assert!(!s.is_char_boundary(MAX_RAW));
1103
1104 truncate_on_char_boundary(&mut s, MAX_RAW);
1106 assert!(s.len() <= MAX_RAW);
1107 assert_eq!(s.len(), MAX_RAW - 1);
1110 }
1111
1112 #[test]
1113 fn trim_for_cache_truncates_multibyte_whois_without_panic() {
1114 const MAX_RAW: usize = 32 * 1024;
1115 let mut raw = "a".repeat(MAX_RAW - 1);
1116 raw.push('€');
1117 raw.push_str(&"b".repeat(1000));
1118
1119 let mut w = empty_whois("example.com");
1120 w.raw_response = raw;
1121 let result = trim_for_cache(LookupResult::Whois {
1122 data: w,
1123 rdap_error: None,
1124 rdap_fallback: None,
1125 });
1126 if let LookupResult::Whois { data, .. } = result {
1127 assert!(data.raw_response.ends_with("[truncated for cache]"));
1128 } else {
1129 panic!("expected Whois variant");
1130 }
1131 }
1132
1133 #[test]
1136 fn test_sanitize_strips_ipv4() {
1137 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1138 let sanitized = sanitize_error_for_public(msg);
1139 assert!(
1140 !sanitized.contains("10.0.0.1"),
1141 "IPv4 should be stripped, got: {}",
1142 sanitized
1143 );
1144 assert!(sanitized.contains("[ip-redacted]"));
1145 }
1146
1147 #[test]
1148 fn test_sanitize_strips_multiple_ipv4() {
1149 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1150 let sanitized = sanitize_error_for_public(msg);
1151 assert!(!sanitized.contains("192.168.1.1"));
1152 assert!(!sanitized.contains("127.0.0.1"));
1153 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1155 }
1156
1157 #[test]
1158 fn test_sanitize_strips_ipv6() {
1159 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1160 let sanitized = sanitize_error_for_public(msg);
1161 assert!(!sanitized.contains("fe80::1"));
1162 assert!(sanitized.contains("[ip-redacted]"));
1163 }
1164
1165 #[test]
1166 fn sanitize_leaves_mac_address_like_tokens_alone() {
1167 let msg = "error code af:ba:12 at line 5";
1168 let out = sanitize_error_for_public(msg);
1169 assert!(
1170 out.contains("af:ba:12"),
1171 "MAC fragment should not be stripped: {}",
1172 out
1173 );
1174 }
1175
1176 #[test]
1177 fn sanitize_strips_real_ipv6() {
1178 let msg = "cannot reach 2001:db8::1 — timeout";
1179 let out = sanitize_error_for_public(msg);
1180 assert!(!out.contains("2001:db8::1"));
1181 assert!(out.contains("[ip-redacted]"));
1182 }
1183
1184 #[test]
1185 fn sanitize_strips_fe80_link_local() {
1186 let msg = "peer at fe80::1 unreachable";
1187 let out = sanitize_error_for_public(msg);
1188 assert!(out.contains("[ip-redacted]"));
1189 }
1190
1191 #[test]
1192 fn test_sanitize_truncates_long_message() {
1193 let long = "a".repeat(500);
1195 let sanitized = sanitize_error_for_public(&long);
1196 let char_count = sanitized.chars().count();
1198 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1199 assert!(sanitized.ends_with('…'));
1200 }
1201
1202 #[test]
1203 fn test_sanitize_preserves_short_messages() {
1204 let msg = "RDAP timed out after 15s";
1205 let sanitized = sanitize_error_for_public(msg);
1206 assert_eq!(sanitized, msg);
1207 }
1208
1209 #[test]
1212 fn test_is_rdap_response_useful_detects_no_data() {
1213 use crate::rdap::RdapResponse;
1214 let resp = RdapResponse {
1218 ldh_name: Some("example.com".to_string()),
1219 ..Default::default()
1220 };
1221 let lookup = SmartLookup::new();
1222 assert!(
1223 !lookup.is_rdap_response_useful(&resp),
1224 "Response with only a name should be classified as NoData"
1225 );
1226
1227 let useful = RdapResponse {
1229 ldh_name: Some("example.com".to_string()),
1230 status: vec!["active".to_string()],
1231 ..Default::default()
1232 };
1233 assert!(lookup.is_rdap_response_useful(&useful));
1234 }
1235
1236 #[tokio::test]
1244 async fn test_inflight_coalescing_map() {
1245 let _serial = INFLIGHT_TEST_SERIAL
1250 .lock()
1251 .unwrap_or_else(|p| p.into_inner());
1252 let domain = unique_test_key("__coalesce");
1261
1262 {
1264 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1265 m.remove(&domain);
1266 }
1267
1268 let owner_notify = Arc::new(Notify::new());
1270 {
1271 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1272 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1273 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1274 }
1275
1276 let waiter = {
1278 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1279 m.get(&domain)
1280 .and_then(|w| w.upgrade())
1281 .expect("Second caller must observe in-flight entry")
1282 };
1283
1284 let waiter_clone = waiter.clone();
1286 let handle = tokio::spawn(async move {
1287 waiter_clone.notified().await;
1288 });
1289
1290 tokio::time::sleep(Duration::from_millis(20)).await;
1292 {
1293 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1294 m.remove(&domain);
1295 }
1296 owner_notify.notify_waiters();
1297
1298 tokio::time::timeout(Duration::from_secs(1), handle)
1300 .await
1301 .expect("waiter must unblock after notify")
1302 .expect("waiter task joined cleanly");
1303
1304 drop(owner_notify);
1306 drop(waiter);
1307 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1308 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1309 }
1310
1311 fn unique_test_key(prefix: &str) -> String {
1317 use std::sync::atomic::{AtomicU64, Ordering};
1318 use std::time::{SystemTime, UNIX_EPOCH};
1319 static COUNTER: AtomicU64 = AtomicU64::new(0);
1320 let nanos = SystemTime::now()
1321 .duration_since(UNIX_EPOCH)
1322 .map(|d| d.as_nanos())
1323 .unwrap_or(0);
1324 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1325 format!("{}_{}_{}.example.", prefix, nanos, n)
1326 }
1327
1328 #[test]
1334 fn test_sanitize_applied_to_available_fields() {
1335 let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1336 let whois_raw = "connection refused at 192.168.0.5";
1337 let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1338 let sanitized_whois = sanitize_error_for_public(whois_raw);
1339 let result = LookupResult::Available {
1340 data: Box::new(AvailabilityResult {
1341 domain: "unreg.test".to_string(),
1342 available: true,
1343 confidence: "low".to_string(),
1344 method: "heuristic".to_string(),
1345 details: None,
1346 }),
1347 rdap_error: sanitized_rdap,
1348 whois_error: sanitized_whois,
1349 whois_data: None,
1350 };
1351 if let LookupResult::Available {
1352 rdap_error,
1353 whois_error,
1354 ..
1355 } = result
1356 {
1357 assert!(!rdap_error.contains("10.0.0.1"));
1358 assert!(!whois_error.contains("192.168.0.5"));
1359 assert!(rdap_error.contains("[ip-redacted]"));
1360 assert!(whois_error.contains("[ip-redacted]"));
1361 } else {
1362 panic!("expected Available variant");
1363 }
1364 }
1365
1366 #[test]
1367 fn rdap_error_is_404_matches_standard_404() {
1368 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1369 assert!(rdap_error_is_404(&e));
1370 }
1371
1372 #[test]
1373 fn rdap_error_is_404_matches_without_reason_phrase() {
1374 let e = SeerError::RdapError("query failed with status 404".to_string());
1375 assert!(rdap_error_is_404(&e));
1376 }
1377
1378 #[test]
1379 fn rdap_error_is_404_rejects_other_statuses() {
1380 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1381 assert!(!rdap_error_is_404(&e));
1382 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1383 assert!(!rdap_error_is_404(&e));
1384 }
1385
1386 #[test]
1387 fn rdap_error_is_404_rejects_non_http_errors() {
1388 let e = SeerError::RdapError("connection timeout".to_string());
1389 assert!(!rdap_error_is_404(&e));
1390 let e = SeerError::Timeout("rdap".to_string());
1391 assert!(!rdap_error_is_404(&e));
1392 }
1393
1394 #[test]
1395 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1396 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1398 assert!(!rdap_error_is_404(&e));
1399 }
1400
1401 fn empty_whois(domain: &str) -> WhoisResponse {
1404 WhoisResponse {
1405 domain: domain.to_string(),
1406 registrar: None,
1407 registrant: None,
1408 organization: None,
1409 registrant_email: None,
1410 registrant_phone: None,
1411 registrant_address: None,
1412 registrant_country: None,
1413 admin_name: None,
1414 admin_organization: None,
1415 admin_email: None,
1416 admin_phone: None,
1417 tech_name: None,
1418 tech_organization: None,
1419 tech_email: None,
1420 tech_phone: None,
1421 creation_date: None,
1422 expiration_date: None,
1423 updated_date: None,
1424 nameservers: vec![],
1425 status: vec![],
1426 dnssec: None,
1427 whois_server: String::new(),
1428 raw_response: String::new(),
1429 }
1430 }
1431
1432 #[test]
1433 fn whois_response_is_thin_when_all_key_fields_missing() {
1434 let w = empty_whois("example.com");
1435 assert!(whois_response_is_thin(&w));
1436 }
1437
1438 #[test]
1439 fn whois_response_is_not_thin_when_registrar_present() {
1440 let mut w = empty_whois("example.com");
1441 w.registrar = Some("Test Registrar".to_string());
1442 assert!(!whois_response_is_thin(&w));
1443 }
1444
1445 #[test]
1446 fn whois_response_is_not_thin_when_creation_date_present() {
1447 let mut w = empty_whois("example.com");
1448 w.creation_date = Some(Utc::now());
1449 assert!(!whois_response_is_thin(&w));
1450 }
1451
1452 #[test]
1453 fn whois_response_is_not_thin_when_expiration_date_present() {
1454 let mut w = empty_whois("example.com");
1455 w.expiration_date = Some(Utc::now());
1456 assert!(!whois_response_is_thin(&w));
1457 }
1458
1459 #[test]
1460 fn whois_response_is_thin_even_with_nameservers_alone() {
1461 let mut w = empty_whois("example.com");
1462 w.nameservers = vec!["ns1.example.net".to_string()];
1463 assert!(whois_response_is_thin(&w));
1464 }
1465
1466 use crate::rdap::RdapResponse;
1469
1470 #[allow(dead_code)]
1471 fn make_empty_rdap_response() -> RdapResponse {
1472 serde_json::from_value(serde_json::json!({
1473 "objectClassName": "domain",
1474 }))
1475 .expect("valid minimal RDAP response")
1476 }
1477
1478 #[test]
1479 fn classify_whois_leg_case_a_high_confidence() {
1480 let mut w = empty_whois("zaccodes.com");
1481 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1482 assert!(w.is_available());
1483 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1484 let (verdict, method) =
1485 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1486 assert_eq!(verdict, "high");
1487 assert_eq!(method, "whois");
1488 }
1489
1490 #[test]
1491 fn classify_whois_leg_case_b_medium_confidence() {
1492 let w = empty_whois("example.xyz");
1493 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1494 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1495 let (verdict, method) =
1496 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1497 assert_eq!(verdict, "medium");
1498 assert_eq!(method, "whois_thin_response");
1499 }
1500
1501 #[test]
1502 fn classify_whois_leg_rejects_thin_whois_without_404() {
1503 let w = empty_whois("example.xyz");
1504 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1505 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1506 }
1507
1508 #[test]
1509 fn classify_whois_leg_rejects_whois_with_real_data() {
1510 let mut w = empty_whois("legacy.tld");
1511 w.registrar = Some("Legacy Registry".to_string());
1512 w.creation_date = Some(Utc::now());
1513 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1514 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1515 }
1516
1517 #[test]
1518 fn classify_whois_leg_case_a_wins_over_case_b() {
1519 let mut w = empty_whois("example.com");
1520 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1521 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1522 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1523 assert_eq!(verdict, "high");
1524 }
1525
1526 #[test]
1534 fn rdap_200_vetoes_whois_no_match() {
1535 let mut w = empty_whois("freshly-registered.com");
1536 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1537 assert!(
1539 should_route_to_availability(true, None, &w).is_none(),
1540 "RDAP 200 must veto WHOIS-only availability claim",
1541 );
1542 }
1543
1544 #[test]
1545 fn rdap_200_vetoes_even_with_thin_whois() {
1546 let w = empty_whois("freshly-registered.com");
1547 assert!(
1549 should_route_to_availability(true, None, &w).is_none(),
1550 "RDAP 200 must veto even when WHOIS is thin",
1551 );
1552 }
1553
1554 #[test]
1555 fn rdap_404_with_whois_no_match_routes_to_available() {
1556 let mut w = empty_whois("genuinely-free.com");
1557 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1558 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1559 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1560 assert_eq!(result, Some(("high", "whois")));
1561 }
1562
1563 #[test]
1564 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1565 let mut w = empty_whois("genuinely-free.com");
1566 w.raw_response = "Domain not found".to_string();
1567 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1570 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1571 assert_eq!(result, Some(("high", "whois")));
1572 }
1573
1574 #[test]
1575 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1576 let mut w = empty_whois("genuinely-free.com");
1578 w.raw_response = "No match".to_string();
1579 let result = should_route_to_availability(false, None, &w);
1580 assert_eq!(result, Some(("high", "whois")));
1581 }
1582
1583 #[test]
1584 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1585 let mut w = empty_whois("registered.com");
1586 w.registrar = Some("Example Registrar Ltd".to_string());
1587 assert!(should_route_to_availability(false, None, &w).is_none());
1591 }
1592
1593 #[test]
1596 fn thin_fallback_refusal_is_inconclusive_regardless_of_dns() {
1597 for dns in [
1602 DnsPresence::Absent,
1603 DnsPresence::Present,
1604 DnsPresence::Unknown,
1605 ] {
1606 assert_eq!(
1607 classify_thin_fallback(true, false, true, dns),
1608 ThinFallback::Inconclusive
1609 );
1610 }
1611 }
1612
1613 #[test]
1614 fn thin_fallback_nxdomain_is_available() {
1615 assert_eq!(
1616 classify_thin_fallback(true, false, false, DnsPresence::Absent),
1617 ThinFallback::Available
1618 );
1619 }
1620
1621 #[test]
1622 fn thin_fallback_delegated_is_registered() {
1623 assert_eq!(
1627 classify_thin_fallback(true, false, false, DnsPresence::Present),
1628 ThinFallback::Registered
1629 );
1630 }
1631
1632 #[test]
1633 fn thin_fallback_unknown_dns_uses_whois() {
1634 assert_eq!(
1636 classify_thin_fallback(true, false, false, DnsPresence::Unknown),
1637 ThinFallback::UseWhois
1638 );
1639 }
1640
1641 #[test]
1642 fn thin_fallback_requires_thin_whois() {
1643 assert_eq!(
1645 classify_thin_fallback(false, false, false, DnsPresence::Absent),
1646 ThinFallback::UseWhois
1647 );
1648 }
1649
1650 #[test]
1651 fn thin_fallback_vetoed_by_rdap_200() {
1652 assert_eq!(
1655 classify_thin_fallback(true, true, false, DnsPresence::Absent),
1656 ThinFallback::UseWhois
1657 );
1658 assert_eq!(
1659 classify_thin_fallback(true, true, true, DnsPresence::Present),
1660 ThinFallback::UseWhois
1661 );
1662 }
1663
1664 #[test]
1674 fn lookup_inflight_recovers_from_poisoned_mutex() {
1675 use std::panic::{catch_unwind, AssertUnwindSafe};
1676
1677 let _serial = INFLIGHT_TEST_SERIAL
1679 .lock()
1680 .unwrap_or_else(|p| p.into_inner());
1681
1682 let _ = catch_unwind(AssertUnwindSafe(|| {
1684 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1685 panic!("poisoning LOOKUP_INFLIGHT for test");
1686 }));
1687
1688 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1692 let canary = unique_test_key("__poison_recovery");
1694 guard.insert(canary.clone(), Weak::new());
1695 assert!(guard.contains_key(&canary));
1696 guard.remove(&canary);
1697 }
1698
1699 #[test]
1702 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1703 use std::panic::{catch_unwind, AssertUnwindSafe};
1704
1705 let _serial = INFLIGHT_TEST_SERIAL
1711 .lock()
1712 .unwrap_or_else(|p| p.into_inner());
1713
1714 let key = unique_test_key("__drop_poison");
1719 let notify = Arc::new(Notify::new());
1720 {
1721 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1722 map.insert(key.clone(), Arc::downgrade(¬ify));
1723 }
1724 let guard = InflightGuard {
1725 key: key.clone(),
1726 notify: notify.clone(),
1727 };
1728
1729 let _ = catch_unwind(AssertUnwindSafe(|| {
1731 let _g = LOOKUP_INFLIGHT.lock().unwrap();
1732 panic!("poisoning LOOKUP_INFLIGHT for drop test");
1733 }));
1734
1735 drop(guard);
1738
1739 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1740 assert!(
1741 !map.contains_key(&key),
1742 "poisoned-mutex drop path should still remove the in-flight entry"
1743 );
1744 }
1745}