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(("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
394pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
400 const MAX_RAW: usize = 32 * 1024;
401 if whois.raw_response.len() > MAX_RAW {
402 truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
403 whois.raw_response.push_str("\n... [truncated]");
404 }
405}
406
407pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
411 match result {
412 LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
413 LookupResult::Rdap {
414 ref mut whois_fallback,
415 ..
416 } => {
417 if let Some(w) = whois_fallback {
418 trim_whois_raw(w);
419 }
420 }
421 LookupResult::Available {
422 ref mut whois_data, ..
423 } => {
424 if let Some(w) = whois_data {
425 trim_whois_raw(w);
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_raw_response(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(sanitize_error_for_public(&rdap_error_str)),
875 rdap_fallback: rdap_fallback_data,
876 });
877 }
878
879 let whois_error_str = match &whois_leg {
884 LegOutcome::Completed(Err(e)) => e.to_string(),
885 LegOutcome::Completed(Ok(_)) => {
886 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
888 "WHOIS returned but was not used".to_string()
889 }
890 LegOutcome::GraceTruncated => format!(
891 "WHOIS did not return within {}s grace period after RDAP won",
892 PROTOCOL_GRACE_PERIOD.as_secs()
893 ),
894 };
895
896 let prior_rdap = if rdap_returned_200 {
901 match rdap_fallback_data {
902 Some(resp) => PriorRdap::Response(resp),
904 None => PriorRdap::Missing,
905 }
906 } else if let Some(e) = rdap_seer_error {
907 PriorRdap::Failed(e)
908 } else {
909 PriorRdap::Missing
910 };
911 let prior_whois = match whois_leg {
912 LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
913 LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
915 LegOutcome::GraceTruncated => PriorWhois::Missing,
916 };
917
918 self.availability_fallback(
919 domain,
920 prior_rdap,
921 prior_whois,
922 rdap_error_str,
923 whois_error_str,
924 progress,
925 )
926 .await
927 }
928
929 async fn availability_fallback(
930 &self,
931 domain: &str,
932 prior_rdap: PriorRdap,
933 prior_whois: PriorWhois,
934 rdap_error: String,
935 whois_error: String,
936 progress: Option<LookupProgressCallback>,
937 ) -> Result<LookupResult> {
938 if let Some(ref cb) = progress {
939 cb("RDAP and WHOIS unavailable (checking availability)");
940 }
941 warn!(
942 domain = %domain,
943 rdap_error = %rdap_error,
944 whois_error = %whois_error,
945 "Both RDAP and WHOIS failed, falling back to availability check"
946 );
947
948 match self
949 .availability_checker
950 .check_with_prior(domain, prior_rdap, prior_whois)
951 .await
952 {
953 Ok(avail) => Ok(LookupResult::Available {
954 data: Box::new(avail),
955 rdap_error: sanitize_error_for_public(&rdap_error),
956 whois_error: sanitize_error_for_public(&whois_error),
957 whois_data: None,
958 }),
959 Err(avail_err) => {
960 let tld = get_tld(domain).unwrap_or("unknown");
961 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
962 format!("https://www.iana.org/domains/root/db/{}.html", tld)
963 });
964 Err(SeerError::LookupFailed {
965 domain: domain.to_string(),
966 details: format!(
967 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
968 rdap_error, whois_error, avail_err
969 ),
970 registry_url,
971 })
972 }
973 }
974 }
975
976 fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
977 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
979 let has_dates = response
980 .events
981 .iter()
982 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
983 let has_entities = !response.entities.is_empty();
984 let has_nameservers = !response.nameservers.is_empty();
985 let has_status = !response.status.is_empty();
986
987 has_name && (has_dates || has_entities || has_nameservers || has_status)
989 }
990
991 pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
996 self.dns_resolver.presence(domain).await
997 }
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003
1004 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
1014
1015 #[test]
1016 fn test_lookup_result_domain_name_whois() {
1017 let result = LookupResult::Whois {
1018 data: WhoisResponse {
1019 domain: "example.com".to_string(),
1020 registrar: Some("Test Registrar".to_string()),
1021 registrant: None,
1022 organization: None,
1023 registrant_email: None,
1024 registrant_phone: None,
1025 registrant_address: None,
1026 registrant_country: None,
1027 admin_name: None,
1028 admin_organization: None,
1029 admin_email: None,
1030 admin_phone: None,
1031 tech_name: None,
1032 tech_organization: None,
1033 tech_email: None,
1034 tech_phone: None,
1035 creation_date: None,
1036 expiration_date: None,
1037 updated_date: None,
1038 status: vec![],
1039 nameservers: vec![],
1040 dnssec: None,
1041 whois_server: "whois.example.com".to_string(),
1042 raw_response: String::new(),
1043 },
1044 rdap_error: None,
1045 rdap_fallback: None,
1046 };
1047
1048 assert_eq!(result.domain_name(), Some("example.com".to_string()));
1049 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
1050 assert!(result.is_whois());
1051 assert!(!result.is_rdap());
1052 assert!(!result.is_available());
1053 }
1054
1055 #[test]
1056 fn test_lookup_result_serialization() {
1057 let result = LookupResult::Whois {
1058 data: WhoisResponse {
1059 domain: "test.com".to_string(),
1060 registrar: None,
1061 registrant: None,
1062 organization: None,
1063 registrant_email: None,
1064 registrant_phone: None,
1065 registrant_address: None,
1066 registrant_country: None,
1067 admin_name: None,
1068 admin_organization: None,
1069 admin_email: None,
1070 admin_phone: None,
1071 tech_name: None,
1072 tech_organization: None,
1073 tech_email: None,
1074 tech_phone: None,
1075 creation_date: None,
1076 expiration_date: None,
1077 updated_date: None,
1078 status: vec![],
1079 nameservers: vec![],
1080 dnssec: None,
1081 whois_server: String::new(),
1082 raw_response: String::new(),
1083 },
1084 rdap_error: Some("RDAP failed".to_string()),
1085 rdap_fallback: None,
1086 };
1087
1088 let json = serde_json::to_string(&result).unwrap();
1089 assert!(json.contains("\"source\":\"whois\""));
1090 assert!(json.contains("RDAP failed"));
1091 }
1092
1093 #[test]
1094 fn test_lookup_result_available_serialization() {
1095 let result = LookupResult::Available {
1096 data: Box::new(AvailabilityResult {
1097 domain: "test123.xyz".to_string(),
1098 available: true,
1099 confidence: "medium".to_string(),
1100 method: "whois_error".to_string(),
1101 details: Some("WHOIS server indicates no matching records".to_string()),
1102 }),
1103 rdap_error: "RDAP failed".to_string(),
1104 whois_error: "WHOIS failed".to_string(),
1105 whois_data: None,
1106 };
1107
1108 let json = serde_json::to_string(&result).unwrap();
1109 assert!(json.contains("\"source\":\"available\""));
1110 assert!(json.contains("\"available\":true"));
1111 assert!(json.contains("test123.xyz"));
1112
1113 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1114 assert!(result.is_available());
1115 assert!(!result.is_rdap());
1116 assert!(!result.is_whois());
1117 assert!(result.registrar().is_none());
1118 assert_eq!(result.expiration_info(), (None, None));
1119 }
1120
1121 #[test]
1122 #[allow(deprecated)]
1123 fn test_smart_lookup_builder() {
1124 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
1125 assert!(!lookup.prefer_rdap);
1126 assert!(lookup.include_fallback);
1127 }
1128
1129 #[test]
1130 fn test_lookup_cache_clear() {
1131 SmartLookup::clear_cache();
1132 assert!(LOOKUP_CACHE.is_empty());
1133 }
1134
1135 #[test]
1138 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1139 const MAX_RAW: usize = 32 * 1024;
1140 let mut s = "a".repeat(MAX_RAW - 1);
1144 s.push('€'); s.push_str(&"b".repeat(100));
1146 assert!(!s.is_char_boundary(MAX_RAW));
1147
1148 truncate_on_char_boundary(&mut s, MAX_RAW);
1150 assert!(s.len() <= MAX_RAW);
1151 assert_eq!(s.len(), MAX_RAW - 1);
1154 }
1155
1156 #[test]
1157 fn trim_raw_response_truncates_multibyte_whois_without_panic() {
1158 const MAX_RAW: usize = 32 * 1024;
1159 let mut raw = "a".repeat(MAX_RAW - 1);
1160 raw.push('€');
1161 raw.push_str(&"b".repeat(1000));
1162
1163 let mut w = empty_whois("example.com");
1164 w.raw_response = raw;
1165 let result = trim_raw_response(LookupResult::Whois {
1166 data: w,
1167 rdap_error: None,
1168 rdap_fallback: None,
1169 });
1170 if let LookupResult::Whois { data, .. } = result {
1171 assert!(data.raw_response.ends_with("[truncated]"));
1172 } else {
1173 panic!("expected Whois variant");
1174 }
1175 }
1176
1177 #[test]
1180 fn test_sanitize_strips_ipv4() {
1181 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1182 let sanitized = sanitize_error_for_public(msg);
1183 assert!(
1184 !sanitized.contains("10.0.0.1"),
1185 "IPv4 should be stripped, got: {}",
1186 sanitized
1187 );
1188 assert!(sanitized.contains("[ip-redacted]"));
1189 }
1190
1191 #[test]
1192 fn test_sanitize_strips_multiple_ipv4() {
1193 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1194 let sanitized = sanitize_error_for_public(msg);
1195 assert!(!sanitized.contains("192.168.1.1"));
1196 assert!(!sanitized.contains("127.0.0.1"));
1197 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1199 }
1200
1201 #[test]
1202 fn test_sanitize_strips_ipv6() {
1203 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1204 let sanitized = sanitize_error_for_public(msg);
1205 assert!(!sanitized.contains("fe80::1"));
1206 assert!(sanitized.contains("[ip-redacted]"));
1207 }
1208
1209 #[test]
1210 fn sanitize_leaves_mac_address_like_tokens_alone() {
1211 let msg = "error code af:ba:12 at line 5";
1212 let out = sanitize_error_for_public(msg);
1213 assert!(
1214 out.contains("af:ba:12"),
1215 "MAC fragment should not be stripped: {}",
1216 out
1217 );
1218 }
1219
1220 #[test]
1221 fn sanitize_strips_real_ipv6() {
1222 let msg = "cannot reach 2001:db8::1 — timeout";
1223 let out = sanitize_error_for_public(msg);
1224 assert!(!out.contains("2001:db8::1"));
1225 assert!(out.contains("[ip-redacted]"));
1226 }
1227
1228 #[test]
1229 fn sanitize_strips_fe80_link_local() {
1230 let msg = "peer at fe80::1 unreachable";
1231 let out = sanitize_error_for_public(msg);
1232 assert!(out.contains("[ip-redacted]"));
1233 }
1234
1235 #[test]
1236 fn test_sanitize_truncates_long_message() {
1237 let long = "a".repeat(500);
1239 let sanitized = sanitize_error_for_public(&long);
1240 let char_count = sanitized.chars().count();
1242 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1243 assert!(sanitized.ends_with('…'));
1244 }
1245
1246 #[test]
1247 fn test_sanitize_preserves_short_messages() {
1248 let msg = "RDAP timed out after 15s";
1249 let sanitized = sanitize_error_for_public(msg);
1250 assert_eq!(sanitized, msg);
1251 }
1252
1253 #[test]
1256 fn test_is_rdap_response_useful_detects_no_data() {
1257 use crate::rdap::RdapResponse;
1258 let resp = RdapResponse {
1262 ldh_name: Some("example.com".to_string()),
1263 ..Default::default()
1264 };
1265 let lookup = SmartLookup::new();
1266 assert!(
1267 !lookup.is_rdap_response_useful(&resp),
1268 "Response with only a name should be classified as NoData"
1269 );
1270
1271 let useful = RdapResponse {
1273 ldh_name: Some("example.com".to_string()),
1274 status: vec!["active".to_string()],
1275 ..Default::default()
1276 };
1277 assert!(lookup.is_rdap_response_useful(&useful));
1278 }
1279
1280 #[tokio::test]
1288 async fn test_inflight_coalescing_map() {
1289 let _serial = INFLIGHT_TEST_SERIAL
1294 .lock()
1295 .unwrap_or_else(|p| p.into_inner());
1296 let domain = unique_test_key("__coalesce");
1305
1306 {
1308 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1309 m.remove(&domain);
1310 }
1311
1312 let owner_notify = Arc::new(Notify::new());
1314 {
1315 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1316 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1317 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1318 }
1319
1320 let waiter = {
1322 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1323 m.get(&domain)
1324 .and_then(|w| w.upgrade())
1325 .expect("Second caller must observe in-flight entry")
1326 };
1327
1328 let waiter_clone = waiter.clone();
1330 let handle = tokio::spawn(async move {
1331 waiter_clone.notified().await;
1332 });
1333
1334 tokio::time::sleep(Duration::from_millis(20)).await;
1336 {
1337 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1338 m.remove(&domain);
1339 }
1340 owner_notify.notify_waiters();
1341
1342 tokio::time::timeout(Duration::from_secs(1), handle)
1344 .await
1345 .expect("waiter must unblock after notify")
1346 .expect("waiter task joined cleanly");
1347
1348 drop(owner_notify);
1350 drop(waiter);
1351 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1352 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1353 }
1354
1355 fn unique_test_key(prefix: &str) -> String {
1361 use std::sync::atomic::{AtomicU64, Ordering};
1362 use std::time::{SystemTime, UNIX_EPOCH};
1363 static COUNTER: AtomicU64 = AtomicU64::new(0);
1364 let nanos = SystemTime::now()
1365 .duration_since(UNIX_EPOCH)
1366 .map(|d| d.as_nanos())
1367 .unwrap_or(0);
1368 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1369 format!("{}_{}_{}.example.", prefix, nanos, n)
1370 }
1371
1372 #[test]
1378 fn test_sanitize_applied_to_available_fields() {
1379 let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1380 let whois_raw = "connection refused at 192.168.0.5";
1381 let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1382 let sanitized_whois = sanitize_error_for_public(whois_raw);
1383 let result = LookupResult::Available {
1384 data: Box::new(AvailabilityResult {
1385 domain: "unreg.test".to_string(),
1386 available: true,
1387 confidence: "low".to_string(),
1388 method: "heuristic".to_string(),
1389 details: None,
1390 }),
1391 rdap_error: sanitized_rdap,
1392 whois_error: sanitized_whois,
1393 whois_data: None,
1394 };
1395 if let LookupResult::Available {
1396 rdap_error,
1397 whois_error,
1398 ..
1399 } = result
1400 {
1401 assert!(!rdap_error.contains("10.0.0.1"));
1402 assert!(!whois_error.contains("192.168.0.5"));
1403 assert!(rdap_error.contains("[ip-redacted]"));
1404 assert!(whois_error.contains("[ip-redacted]"));
1405 } else {
1406 panic!("expected Available variant");
1407 }
1408 }
1409
1410 #[test]
1411 fn rdap_error_is_404_matches_standard_404() {
1412 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1413 assert!(rdap_error_is_404(&e));
1414 }
1415
1416 #[test]
1417 fn rdap_error_is_404_matches_without_reason_phrase() {
1418 let e = SeerError::RdapError("query failed with status 404".to_string());
1419 assert!(rdap_error_is_404(&e));
1420 }
1421
1422 #[test]
1423 fn rdap_error_is_404_rejects_other_statuses() {
1424 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1425 assert!(!rdap_error_is_404(&e));
1426 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1427 assert!(!rdap_error_is_404(&e));
1428 }
1429
1430 #[test]
1431 fn rdap_error_is_404_rejects_non_http_errors() {
1432 let e = SeerError::RdapError("connection timeout".to_string());
1433 assert!(!rdap_error_is_404(&e));
1434 let e = SeerError::Timeout("rdap".to_string());
1435 assert!(!rdap_error_is_404(&e));
1436 }
1437
1438 #[test]
1439 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1440 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1442 assert!(!rdap_error_is_404(&e));
1443 }
1444
1445 fn empty_whois(domain: &str) -> WhoisResponse {
1448 WhoisResponse {
1449 domain: domain.to_string(),
1450 registrar: None,
1451 registrant: None,
1452 organization: None,
1453 registrant_email: None,
1454 registrant_phone: None,
1455 registrant_address: None,
1456 registrant_country: None,
1457 admin_name: None,
1458 admin_organization: None,
1459 admin_email: None,
1460 admin_phone: None,
1461 tech_name: None,
1462 tech_organization: None,
1463 tech_email: None,
1464 tech_phone: None,
1465 creation_date: None,
1466 expiration_date: None,
1467 updated_date: None,
1468 nameservers: vec![],
1469 status: vec![],
1470 dnssec: None,
1471 whois_server: String::new(),
1472 raw_response: String::new(),
1473 }
1474 }
1475
1476 #[test]
1477 fn whois_response_is_thin_when_all_key_fields_missing() {
1478 let w = empty_whois("example.com");
1479 assert!(whois_response_is_thin(&w));
1480 }
1481
1482 #[test]
1483 fn whois_response_is_not_thin_when_registrar_present() {
1484 let mut w = empty_whois("example.com");
1485 w.registrar = Some("Test Registrar".to_string());
1486 assert!(!whois_response_is_thin(&w));
1487 }
1488
1489 #[test]
1490 fn whois_response_is_not_thin_when_creation_date_present() {
1491 let mut w = empty_whois("example.com");
1492 w.creation_date = Some(Utc::now());
1493 assert!(!whois_response_is_thin(&w));
1494 }
1495
1496 #[test]
1497 fn whois_response_is_not_thin_when_expiration_date_present() {
1498 let mut w = empty_whois("example.com");
1499 w.expiration_date = Some(Utc::now());
1500 assert!(!whois_response_is_thin(&w));
1501 }
1502
1503 #[test]
1504 fn whois_response_is_thin_even_with_nameservers_alone() {
1505 let mut w = empty_whois("example.com");
1506 w.nameservers = vec!["ns1.example.net".to_string()];
1507 assert!(whois_response_is_thin(&w));
1508 }
1509
1510 use crate::rdap::RdapResponse;
1513
1514 #[allow(dead_code)]
1515 fn make_empty_rdap_response() -> RdapResponse {
1516 serde_json::from_value(serde_json::json!({
1517 "objectClassName": "domain",
1518 }))
1519 .expect("valid minimal RDAP response")
1520 }
1521
1522 #[test]
1523 fn classify_whois_leg_case_a_high_confidence() {
1524 let mut w = empty_whois("zaccodes.com");
1525 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1526 assert!(w.is_available());
1527 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1528 let (verdict, method) =
1529 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1530 assert_eq!(verdict, "high");
1531 assert_eq!(method, "whois");
1532 }
1533
1534 #[test]
1535 fn classify_whois_leg_case_b_medium_confidence() {
1536 let w = empty_whois("example.xyz");
1537 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1538 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1539 let (verdict, method) =
1540 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1541 assert_eq!(verdict, "medium");
1542 assert_eq!(method, "whois_thin_response");
1543 }
1544
1545 #[test]
1546 fn classify_whois_leg_rejects_thin_whois_without_404() {
1547 let w = empty_whois("example.xyz");
1548 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1549 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1550 }
1551
1552 #[test]
1553 fn classify_whois_leg_rejects_whois_with_real_data() {
1554 let mut w = empty_whois("legacy.tld");
1555 w.registrar = Some("Legacy Registry".to_string());
1556 w.creation_date = Some(Utc::now());
1557 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1558 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1559 }
1560
1561 #[test]
1562 fn classify_whois_leg_case_a_wins_over_case_b() {
1563 let mut w = empty_whois("example.com");
1564 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1565 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1566 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1567 assert_eq!(verdict, "high");
1568 }
1569
1570 #[test]
1578 fn rdap_200_vetoes_whois_no_match() {
1579 let mut w = empty_whois("freshly-registered.com");
1580 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1581 assert!(
1583 should_route_to_availability(true, None, &w).is_none(),
1584 "RDAP 200 must veto WHOIS-only availability claim",
1585 );
1586 }
1587
1588 #[test]
1589 fn rdap_200_vetoes_even_with_thin_whois() {
1590 let w = empty_whois("freshly-registered.com");
1591 assert!(
1593 should_route_to_availability(true, None, &w).is_none(),
1594 "RDAP 200 must veto even when WHOIS is thin",
1595 );
1596 }
1597
1598 #[test]
1599 fn rdap_404_with_whois_no_match_routes_to_available() {
1600 let mut w = empty_whois("genuinely-free.com");
1601 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1602 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1603 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1604 assert_eq!(result, Some(("high", "whois")));
1605 }
1606
1607 #[test]
1608 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1609 let mut w = empty_whois("genuinely-free.com");
1610 w.raw_response = "Domain not found".to_string();
1611 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1614 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1615 assert_eq!(result, Some(("high", "whois")));
1616 }
1617
1618 #[test]
1619 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1620 let mut w = empty_whois("genuinely-free.com");
1622 w.raw_response = "No match".to_string();
1623 let result = should_route_to_availability(false, None, &w);
1624 assert_eq!(result, Some(("high", "whois")));
1625 }
1626
1627 #[test]
1628 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1629 let mut w = empty_whois("registered.com");
1630 w.registrar = Some("Example Registrar Ltd".to_string());
1631 assert!(should_route_to_availability(false, None, &w).is_none());
1635 }
1636
1637 #[test]
1640 fn thin_fallback_refusal_is_inconclusive_regardless_of_dns() {
1641 for dns in [
1646 DnsPresence::Absent,
1647 DnsPresence::Present,
1648 DnsPresence::Unknown,
1649 ] {
1650 assert_eq!(
1651 classify_thin_fallback(true, false, true, dns),
1652 ThinFallback::Inconclusive
1653 );
1654 }
1655 }
1656
1657 #[test]
1658 fn thin_fallback_nxdomain_is_available() {
1659 assert_eq!(
1660 classify_thin_fallback(true, false, false, DnsPresence::Absent),
1661 ThinFallback::Available
1662 );
1663 }
1664
1665 #[test]
1666 fn thin_fallback_delegated_is_registered() {
1667 assert_eq!(
1671 classify_thin_fallback(true, false, false, DnsPresence::Present),
1672 ThinFallback::Registered
1673 );
1674 }
1675
1676 #[test]
1677 fn thin_fallback_unknown_dns_uses_whois() {
1678 assert_eq!(
1680 classify_thin_fallback(true, false, false, DnsPresence::Unknown),
1681 ThinFallback::UseWhois
1682 );
1683 }
1684
1685 #[test]
1686 fn thin_fallback_requires_thin_whois() {
1687 assert_eq!(
1689 classify_thin_fallback(false, false, false, DnsPresence::Absent),
1690 ThinFallback::UseWhois
1691 );
1692 }
1693
1694 #[test]
1695 fn thin_fallback_vetoed_by_rdap_200() {
1696 assert_eq!(
1699 classify_thin_fallback(true, true, false, DnsPresence::Absent),
1700 ThinFallback::UseWhois
1701 );
1702 assert_eq!(
1703 classify_thin_fallback(true, true, true, DnsPresence::Present),
1704 ThinFallback::UseWhois
1705 );
1706 }
1707
1708 #[test]
1718 fn lookup_inflight_recovers_from_poisoned_mutex() {
1719 use std::panic::{catch_unwind, AssertUnwindSafe};
1720
1721 let _serial = INFLIGHT_TEST_SERIAL
1723 .lock()
1724 .unwrap_or_else(|p| p.into_inner());
1725
1726 let _ = catch_unwind(AssertUnwindSafe(|| {
1728 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1729 panic!("poisoning LOOKUP_INFLIGHT for test");
1730 }));
1731
1732 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1736 let canary = unique_test_key("__poison_recovery");
1738 guard.insert(canary.clone(), Weak::new());
1739 assert!(guard.contains_key(&canary));
1740 guard.remove(&canary);
1741 }
1742
1743 #[test]
1746 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1747 use std::panic::{catch_unwind, AssertUnwindSafe};
1748
1749 let _serial = INFLIGHT_TEST_SERIAL
1755 .lock()
1756 .unwrap_or_else(|p| p.into_inner());
1757
1758 let key = unique_test_key("__drop_poison");
1763 let notify = Arc::new(Notify::new());
1764 {
1765 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1766 map.insert(key.clone(), Arc::downgrade(¬ify));
1767 }
1768 let guard = InflightGuard {
1769 key: key.clone(),
1770 notify: notify.clone(),
1771 };
1772
1773 let _ = catch_unwind(AssertUnwindSafe(|| {
1775 let _g = LOOKUP_INFLIGHT.lock().unwrap();
1776 panic!("poisoning LOOKUP_INFLIGHT for drop test");
1777 }));
1778
1779 drop(guard);
1782
1783 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1784 assert!(
1785 !map.contains_key(&key),
1786 "poisoned-mutex drop path should still remove the in-flight entry"
1787 );
1788 }
1789}