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);
34
35const MAX_PUBLIC_ERROR_LEN: usize = 256;
37
38const DEFAULT_INFLIGHT_WAIT: Duration = Duration::from_secs(30);
45
46static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
48 Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
49
50static LOOKUP_INFLIGHT: Lazy<Mutex<HashMap<String, Weak<Notify>>>> =
54 Lazy::new(|| Mutex::new(HashMap::new()));
55
56static IPV4_RE: Lazy<Regex> =
58 Lazy::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("IPV4_RE is a valid regex"));
59
60static IPV6_CANDIDATE_RE: Lazy<Regex> = Lazy::new(|| {
66 Regex::new(r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b")
67 .expect("IPV6_CANDIDATE_RE is a valid regex")
68});
69
70fn strip_ipv6(msg: &str) -> String {
73 IPV6_CANDIDATE_RE
74 .replace_all(msg, |caps: ®ex::Captures| {
75 let candidate = &caps[0];
76 if Ipv6Addr::from_str(candidate).is_ok() {
77 "[ip-redacted]".to_string()
78 } else {
79 candidate.to_string()
80 }
81 })
82 .into_owned()
83}
84
85#[cfg(test)]
89static LOOKUP_CONCURRENT_CALLS: Lazy<std::sync::atomic::AtomicUsize> =
90 Lazy::new(|| std::sync::atomic::AtomicUsize::new(0));
91
92fn whois_response_is_thin(w: &WhoisResponse) -> bool {
100 w.registrar.is_none() && w.creation_date.is_none() && w.expiration_date.is_none()
101}
102
103fn rdap_response_is_useful(response: &RdapResponse) -> bool {
107 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
108 let has_dates = response
109 .events
110 .iter()
111 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
112 let has_entities = !response.entities.is_empty();
113 let has_nameservers = !response.nameservers.is_empty();
114 let has_status = !response.status.is_empty();
115
116 has_name && (has_dates || has_entities || has_nameservers || has_status)
117}
118
119fn whois_leg_has_data(w: &Result<WhoisResponse>) -> bool {
129 matches!(w, Ok(data) if !whois_response_is_thin(data))
130}
131
132fn classify_whois_leg(
139 w: &WhoisResponse,
140 rdap_err: &SeerError,
141) -> Option<(&'static str, &'static str)> {
142 if w.is_available() {
143 return Some(("high", "whois"));
144 }
145 if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
146 return Some(("high", "rdap"));
150 }
151 None
152}
153
154fn should_route_to_availability(
161 rdap_returned_200: bool,
162 rdap_seer_error: Option<&SeerError>,
163 whois_data: &WhoisResponse,
164) -> Option<(&'static str, &'static str)> {
165 if rdap_returned_200 {
166 return None;
167 }
168 if whois_data.is_available() {
174 return Some(("high", "whois"));
175 }
176 rdap_seer_error.and_then(|e| {
177 classify_whois_leg(whois_data, e)
182 })
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188enum ThinFallback {
189 Inconclusive,
192 Available,
194 Registered,
196 UseWhois,
198}
199
200fn classify_thin_fallback(
214 is_thin: bool,
215 rdap_returned_200: bool,
216 whois_refuses: bool,
217 dns: DnsPresence,
218) -> ThinFallback {
219 if !is_thin || rdap_returned_200 {
220 return ThinFallback::UseWhois;
221 }
222 if whois_refuses {
223 return ThinFallback::Inconclusive;
224 }
225 match dns {
226 DnsPresence::Absent => ThinFallback::Available,
227 DnsPresence::Present => ThinFallback::Registered,
228 DnsPresence::Unknown => ThinFallback::UseWhois,
229 }
230}
231
232fn sanitize_error_for_public(msg: &str) -> String {
238 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
239 let s = strip_ipv6(&s);
240 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
241 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
242 trunc.push('…');
243 trunc
244 } else {
245 s
246 }
247}
248
249struct InflightGuard {
262 key: String,
263 notify: Arc<Notify>,
264}
265
266impl Drop for InflightGuard {
267 fn drop(&mut self) {
268 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
281 inflight.remove(&self.key);
282 drop(inflight);
283 self.notify.notify_waiters();
284 }
285}
286
287enum LegOutcome<T> {
293 Completed(T),
295 GraceTruncated,
299}
300
301async fn race_with_grace<R, W>(
316 rdap_fut: impl std::future::Future<Output = R>,
317 whois_fut: impl std::future::Future<Output = W>,
318 rdap_has_data: impl Fn(&R) -> bool,
319 whois_has_data: impl Fn(&W) -> bool,
320) -> (LegOutcome<R>, LegOutcome<W>) {
321 tokio::pin!(rdap_fut);
322 tokio::pin!(whois_fut);
323
324 tokio::select! {
325 rdap_res = &mut rdap_fut => {
326 let whois_leg = if rdap_has_data(&rdap_res) {
327 match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
328 Ok(res) => LegOutcome::Completed(res),
329 Err(_) => LegOutcome::GraceTruncated,
330 }
331 } else {
332 LegOutcome::Completed(whois_fut.await)
333 };
334 (LegOutcome::Completed(rdap_res), whois_leg)
335 }
336 whois_res = &mut whois_fut => {
337 let rdap_leg = if whois_has_data(&whois_res) {
338 match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
339 Ok(res) => LegOutcome::Completed(res),
340 Err(_) => LegOutcome::GraceTruncated,
341 }
342 } else {
343 LegOutcome::Completed(rdap_fut.await)
344 };
345 (rdap_leg, LegOutcome::Completed(whois_res))
346 }
347 }
348}
349
350fn grace_truncated_error(truncated: &str, winner: &str) -> String {
356 format!(
357 "{} did not respond within the {}s grace period after {} answered",
358 truncated,
359 PROTOCOL_GRACE_PERIOD.as_secs(),
360 winner
361 )
362}
363
364enum RdapOutcome {
370 Useful(RdapResponse),
371 NoData(RdapResponse),
372 Error(SeerError),
373 GraceTimeout,
376}
377
378pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
383#[serde(tag = "source", rename_all = "lowercase")]
384pub enum LookupResult {
385 Rdap {
386 data: Box<RdapResponse>,
387 #[serde(skip_serializing_if = "Option::is_none")]
388 whois_fallback: Option<WhoisResponse>,
389 },
390 Whois {
391 data: WhoisResponse,
392 rdap_error: Option<String>,
393 #[serde(skip_serializing_if = "Option::is_none")]
394 rdap_fallback: Option<Box<RdapResponse>>,
395 },
396 Available {
397 data: Box<AvailabilityResult>,
398 rdap_error: String,
399 whois_error: String,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
404 whois_data: Option<WhoisResponse>,
405 },
406}
407
408impl LookupResult {
409 pub fn domain_name(&self) -> Option<String> {
411 match self {
412 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
413 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
414 LookupResult::Available { data, .. } => Some(data.domain.clone()),
415 }
416 }
417
418 pub fn registrar(&self) -> Option<String> {
420 match self {
421 LookupResult::Rdap {
422 data,
423 whois_fallback,
424 } => data
425 .get_registrar()
426 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
427 LookupResult::Whois { data, .. } => data.registrar.clone(),
428 LookupResult::Available { .. } => None,
429 }
430 }
431
432 pub fn organization(&self) -> Option<String> {
434 match self {
435 LookupResult::Rdap {
436 data,
437 whois_fallback,
438 } => data
439 .get_registrant_organization()
440 .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
441 LookupResult::Whois { data, .. } => data.organization.clone(),
442 LookupResult::Available { .. } => None,
443 }
444 }
445
446 pub fn is_rdap(&self) -> bool {
448 matches!(self, LookupResult::Rdap { .. })
449 }
450
451 pub fn is_whois(&self) -> bool {
453 matches!(self, LookupResult::Whois { .. })
454 }
455
456 pub fn is_available(&self) -> bool {
458 matches!(self, LookupResult::Available { .. })
459 }
460
461 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
463 match self {
464 LookupResult::Rdap {
465 data,
466 whois_fallback,
467 } => {
468 let expiration_date = data
470 .events
471 .iter()
472 .find(|e| e.event_action == "expiration")
473 .and_then(|e| e.parsed_date())
474 .or_else(|| {
475 whois_fallback.as_ref().and_then(|w| w.expiration_date)
477 });
478
479 let registrar = data
480 .get_registrar()
481 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
482
483 (expiration_date, registrar)
484 }
485 LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
486 LookupResult::Available { .. } => (None, None),
487 }
488 }
489}
490
491fn truncate_on_char_boundary(s: &mut String, max: usize) {
498 if s.len() > max {
499 let mut end = max;
500 while end > 0 && !s.is_char_boundary(end) {
501 end -= 1;
502 }
503 s.truncate(end);
504 }
505}
506
507pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
513 const MAX_RAW: usize = 32 * 1024;
514 if whois.raw_response.len() > MAX_RAW {
515 truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
516 whois.raw_response.push_str("\n... [truncated]");
517 }
518}
519
520pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
524 match result {
525 LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
526 LookupResult::Rdap {
527 ref mut whois_fallback,
528 ..
529 } => {
530 if let Some(w) = whois_fallback {
531 trim_whois_raw(w);
532 }
533 }
534 LookupResult::Available {
535 ref mut whois_data, ..
536 } => {
537 if let Some(w) = whois_data {
538 trim_whois_raw(w);
539 }
540 }
541 }
542
543 result
544}
545
546#[derive(Debug, Clone)]
547pub struct SmartLookup {
548 rdap_client: RdapClient,
549 whois_client: WhoisClient,
550 availability_checker: AvailabilityChecker,
551 dns_resolver: DnsResolver,
552 prefer_rdap: bool,
554 include_fallback: bool,
556}
557
558impl Default for SmartLookup {
559 fn default() -> Self {
560 Self::new()
561 }
562}
563
564impl SmartLookup {
565 pub fn new() -> Self {
568 Self {
569 rdap_client: RdapClient::new(),
570 whois_client: WhoisClient::new(),
571 availability_checker: AvailabilityChecker::new(),
572 dns_resolver: DnsResolver::new(),
573 prefer_rdap: true,
574 include_fallback: false,
575 }
576 }
577
578 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
581 Self {
582 rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
583 whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
584 availability_checker: AvailabilityChecker::from_config(config),
585 dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
586 prefer_rdap: true,
587 include_fallback: false,
588 }
589 }
590
591 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
594 pub fn prefer_rdap(mut self, prefer: bool) -> Self {
595 self.prefer_rdap = prefer;
596 self
597 }
598
599 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
602 pub fn include_fallback(mut self, include: bool) -> Self {
603 self.include_fallback = include;
604 self
605 }
606
607 #[instrument(skip(self), fields(domain = %domain))]
611 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
612 self.lookup_with_progress(domain, None).await
613 }
614
615 #[instrument(skip(self, progress), fields(domain = %domain))]
620 pub async fn lookup_with_progress(
621 &self,
622 domain: &str,
623 progress: Option<LookupProgressCallback>,
624 ) -> Result<LookupResult> {
625 let normalized = crate::validation::normalize_domain(domain)?;
626
627 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
629 debug!(domain = %normalized, "Returning cached lookup result");
630 return Ok(cached);
631 }
632
633 let _guard = loop {
645 enum Slot {
646 Waiter(Arc<Notify>),
647 Owner(InflightGuard),
648 }
649
650 let slot = {
651 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
655 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
656 Some(existing) => Slot::Waiter(existing),
657 None => {
658 let n = Arc::new(Notify::new());
659 inflight.insert(normalized.clone(), Arc::downgrade(&n));
660 Slot::Owner(InflightGuard {
661 key: normalized.clone(),
662 notify: n,
663 })
664 }
665 }
666 };
667
668 match slot {
669 Slot::Waiter(n) => {
670 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
671 let notified = n.notified();
682 tokio::pin!(notified);
683
684 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
688 return Ok(cached);
689 }
690
691 let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
697
698 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
699 return Ok(cached);
700 }
701 continue;
704 }
705 Slot::Owner(guard) => break guard,
706 }
707 };
708
709 let result = self.lookup_concurrent(&normalized, progress).await?;
710
711 LOOKUP_CACHE.insert(normalized.clone(), trim_raw_response(result.clone()));
714
715 Ok(result)
716 }
717
718 pub fn clear_cache() {
720 LOOKUP_CACHE.clear();
721 }
722
723 #[instrument(skip(self, progress), fields(domain = %domain))]
724 async fn lookup_concurrent(
725 &self,
726 domain: &str,
727 progress: Option<LookupProgressCallback>,
728 ) -> Result<LookupResult> {
729 #[cfg(test)]
730 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
731
732 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
733
734 if let Some(ref cb) = progress {
735 cb("Querying RDAP and WHOIS concurrently");
736 }
737
738 let rdap_fut = self.rdap_client.lookup_domain(domain);
739 let whois_fut = self.whois_client.lookup(domain);
740
741 let (rdap_leg, whois_leg) = race_with_grace(
749 rdap_fut,
750 whois_fut,
751 |r| matches!(r, Ok(data) if rdap_response_is_useful(data)),
752 whois_leg_has_data,
753 )
754 .await;
755
756 if matches!(whois_leg, LegOutcome::GraceTruncated) {
757 debug!("WHOIS did not finish within grace period after RDAP answered, proceeding with RDAP only");
758 }
759 if matches!(rdap_leg, LegOutcome::GraceTruncated) {
760 debug!("RDAP did not finish within grace period after WHOIS answered, proceeding with WHOIS only");
761 }
762
763 let rdap_outcome = match rdap_leg {
765 LegOutcome::Completed(Ok(data)) => {
766 if rdap_response_is_useful(&data) {
767 RdapOutcome::Useful(data)
768 } else {
769 RdapOutcome::NoData(data)
770 }
771 }
772 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
773 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
774 };
775
776 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
778 debug!("RDAP lookup successful");
779 let whois_fallback = match whois_leg {
780 LegOutcome::Completed(Ok(w)) => Some(w),
781 _ => None,
782 };
783 return Ok(LookupResult::Rdap {
784 data: Box::new(rdap_data),
785 whois_fallback,
786 });
787 }
788
789 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
800 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
801 RdapOutcome::Useful(_) => {
802 debug!("Unexpected RdapOutcome::Useful in fallback branch");
805 (String::from("RDAP ok"), None, None)
806 }
807 RdapOutcome::NoData(data) => (
808 "RDAP response incomplete".to_string(),
809 Some(Box::new(data)),
810 None,
811 ),
812 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
813 RdapOutcome::GraceTimeout => (grace_truncated_error("RDAP", "WHOIS"), None, None),
814 };
815
816 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
817 let availability_match = should_route_to_availability(
821 rdap_returned_200,
822 rdap_seer_error.as_ref(),
823 &whois_data,
824 );
825
826 if let Some((confidence, method)) = availability_match {
827 debug!(
828 domain = %domain,
829 confidence = %confidence,
830 "Reclassifying WHOIS as availability signal"
831 );
832 if let Some(ref cb) = progress {
833 cb("Domain appears unregistered");
834 }
835 let details = match method {
839 "whois" => Some("WHOIS indicates domain is not registered".to_string()),
840 "rdap" => Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
841 _ => None,
842 };
843 let avail = AvailabilityResult {
844 domain: domain.to_string(),
845 available: true,
846 confidence: confidence.to_string(),
847 method: method.to_string(),
848 details,
849 };
850 return Ok(LookupResult::Available {
851 data: Box::new(avail),
852 rdap_error: sanitize_error_for_public(&rdap_error_str),
853 whois_error: String::new(),
854 whois_data: Some(whois_data),
855 });
856 }
857
858 let whois_is_thin = whois_response_is_thin(&whois_data);
869 if whois_is_thin && !rdap_returned_200 {
870 let whois_refuses = whois_data.indicates_registry_refusal();
871 let dns_presence = if whois_refuses {
872 DnsPresence::Unknown
874 } else {
875 self.dns_resolver.presence(domain).await
876 };
877 match classify_thin_fallback(
878 whois_is_thin,
879 rdap_returned_200,
880 whois_refuses,
881 dns_presence,
882 ) {
883 ThinFallback::Inconclusive => {
884 debug!(domain = %domain, "Thin WHOIS is a registry refusal/throttle; availability inconclusive (issue #45)");
885 if let Some(ref cb) = progress {
886 cb("Registry refused or throttled the query (availability inconclusive)");
887 }
888 let avail = AvailabilityResult {
889 domain: domain.to_string(),
890 available: false,
891 confidence: "none".to_string(),
892 method: "inconclusive".to_string(),
893 details: Some(
894 "Registry refused or throttled the query; availability is inconclusive"
895 .to_string(),
896 ),
897 };
898 return Ok(LookupResult::Available {
899 data: Box::new(avail),
900 rdap_error: sanitize_error_for_public(&rdap_error_str),
901 whois_error: String::new(),
902 whois_data: Some(whois_data),
903 });
904 }
905 ThinFallback::Available => {
906 debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
907 if let Some(ref cb) = progress {
908 cb("Domain appears unregistered (no DNS presence)");
909 }
910 let avail = AvailabilityResult {
911 domain: domain.to_string(),
912 available: true,
913 confidence: "medium".to_string(),
914 method: "dns_nxdomain".to_string(),
915 details: Some(
916 "No registry data available; domain has no DNS presence (NXDOMAIN)"
917 .to_string(),
918 ),
919 };
920 return Ok(LookupResult::Available {
921 data: Box::new(avail),
922 rdap_error: sanitize_error_for_public(&rdap_error_str),
923 whois_error: String::new(),
924 whois_data: Some(whois_data),
925 });
926 }
927 ThinFallback::Registered => {
928 debug!(domain = %domain, "Thin/no-service WHOIS + DNS delegation, reporting registered");
929 if let Some(ref cb) = progress {
930 cb("Domain is registered (registry detail unavailable)");
931 }
932 let details = if whois_data.registry_unavailable() {
933 "Domain is registered (the apex is delegated in DNS). This TLD's \
934 registry provides no port-43 WHOIS data and RDAP was unavailable \
935 (rate-limited or unreachable); retry shortly for full RDAP detail."
936 } else {
937 "Domain is registered (the apex is delegated in DNS). Registry detail \
938 was unavailable (RDAP rate-limited or unreachable and WHOIS returned \
939 no data); retry shortly for full detail."
940 };
941 let avail = AvailabilityResult {
942 domain: domain.to_string(),
943 available: false,
944 confidence: "high".to_string(),
945 method: "dns_present".to_string(),
946 details: Some(details.to_string()),
947 };
948 return Ok(LookupResult::Available {
949 data: Box::new(avail),
950 rdap_error: sanitize_error_for_public(&rdap_error_str),
951 whois_error: String::new(),
952 whois_data: Some(whois_data),
953 });
954 }
955 ThinFallback::UseWhois => {}
956 }
957 }
958 debug!("Using WHOIS result (RDAP not useful)");
959 if let Some(ref cb) = progress {
960 cb("RDAP not available (using WHOIS)");
961 }
962 return Ok(LookupResult::Whois {
963 data: whois_data,
964 rdap_error: Some(sanitize_error_for_public(&rdap_error_str)),
965 rdap_fallback: rdap_fallback_data,
966 });
967 }
968
969 let whois_error_str = match &whois_leg {
974 LegOutcome::Completed(Err(e)) => e.to_string(),
975 LegOutcome::Completed(Ok(_)) => {
976 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
978 "WHOIS returned but was not used".to_string()
979 }
980 LegOutcome::GraceTruncated => grace_truncated_error("WHOIS", "RDAP"),
984 };
985
986 let prior_rdap = if rdap_returned_200 {
991 match rdap_fallback_data {
992 Some(resp) => PriorRdap::Response(resp),
994 None => PriorRdap::Missing,
995 }
996 } else if let Some(e) = rdap_seer_error {
997 PriorRdap::Failed(e)
998 } else {
999 PriorRdap::Missing
1000 };
1001 let prior_whois = match whois_leg {
1002 LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
1003 LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
1005 LegOutcome::GraceTruncated => PriorWhois::Missing,
1006 };
1007
1008 self.availability_fallback(
1009 domain,
1010 prior_rdap,
1011 prior_whois,
1012 rdap_error_str,
1013 whois_error_str,
1014 progress,
1015 )
1016 .await
1017 }
1018
1019 async fn availability_fallback(
1020 &self,
1021 domain: &str,
1022 prior_rdap: PriorRdap,
1023 prior_whois: PriorWhois,
1024 rdap_error: String,
1025 whois_error: String,
1026 progress: Option<LookupProgressCallback>,
1027 ) -> Result<LookupResult> {
1028 if let Some(ref cb) = progress {
1029 cb("RDAP and WHOIS unavailable (checking availability)");
1030 }
1031 warn!(
1032 domain = %domain,
1033 rdap_error = %rdap_error,
1034 whois_error = %whois_error,
1035 "Both RDAP and WHOIS failed, falling back to availability check"
1036 );
1037
1038 match self
1039 .availability_checker
1040 .check_with_prior(domain, prior_rdap, prior_whois)
1041 .await
1042 {
1043 Ok(avail) => Ok(LookupResult::Available {
1044 data: Box::new(avail),
1045 rdap_error: sanitize_error_for_public(&rdap_error),
1046 whois_error: sanitize_error_for_public(&whois_error),
1047 whois_data: None,
1048 }),
1049 Err(avail_err) => {
1050 let tld = get_tld(domain).unwrap_or("unknown");
1051 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
1052 format!("https://www.iana.org/domains/root/db/{}.html", tld)
1053 });
1054 Err(SeerError::LookupFailed {
1055 domain: domain.to_string(),
1056 details: format!(
1057 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
1058 rdap_error, whois_error, avail_err
1059 ),
1060 registry_url,
1061 })
1062 }
1063 }
1064 }
1065
1066 pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
1071 self.dns_resolver.presence(domain).await
1072 }
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use super::*;
1078
1079 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
1091
1092 #[test]
1093 fn test_lookup_result_domain_name_whois() {
1094 let result = LookupResult::Whois {
1095 data: WhoisResponse {
1096 domain: "example.com".to_string(),
1097 registrar: Some("Test Registrar".to_string()),
1098 registrant: None,
1099 organization: None,
1100 registrant_email: None,
1101 registrant_phone: None,
1102 registrant_address: None,
1103 registrant_country: None,
1104 admin_name: None,
1105 admin_organization: None,
1106 admin_email: None,
1107 admin_phone: None,
1108 tech_name: None,
1109 tech_organization: None,
1110 tech_email: None,
1111 tech_phone: None,
1112 creation_date: None,
1113 expiration_date: None,
1114 updated_date: None,
1115 status: vec![],
1116 nameservers: vec![],
1117 dnssec: None,
1118 whois_server: "whois.example.com".to_string(),
1119 raw_response: String::new(),
1120 },
1121 rdap_error: None,
1122 rdap_fallback: None,
1123 };
1124
1125 assert_eq!(result.domain_name(), Some("example.com".to_string()));
1126 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
1127 assert!(result.is_whois());
1128 assert!(!result.is_rdap());
1129 assert!(!result.is_available());
1130 }
1131
1132 #[test]
1133 fn test_lookup_result_serialization() {
1134 let result = LookupResult::Whois {
1135 data: WhoisResponse {
1136 domain: "test.com".to_string(),
1137 registrar: None,
1138 registrant: None,
1139 organization: None,
1140 registrant_email: None,
1141 registrant_phone: None,
1142 registrant_address: None,
1143 registrant_country: None,
1144 admin_name: None,
1145 admin_organization: None,
1146 admin_email: None,
1147 admin_phone: None,
1148 tech_name: None,
1149 tech_organization: None,
1150 tech_email: None,
1151 tech_phone: None,
1152 creation_date: None,
1153 expiration_date: None,
1154 updated_date: None,
1155 status: vec![],
1156 nameservers: vec![],
1157 dnssec: None,
1158 whois_server: String::new(),
1159 raw_response: String::new(),
1160 },
1161 rdap_error: Some("RDAP failed".to_string()),
1162 rdap_fallback: None,
1163 };
1164
1165 let json = serde_json::to_string(&result).unwrap();
1166 assert!(json.contains("\"source\":\"whois\""));
1167 assert!(json.contains("RDAP failed"));
1168 }
1169
1170 #[test]
1171 fn test_lookup_result_available_serialization() {
1172 let result = LookupResult::Available {
1173 data: Box::new(AvailabilityResult {
1174 domain: "test123.xyz".to_string(),
1175 available: true,
1176 confidence: "medium".to_string(),
1177 method: "whois_error".to_string(),
1178 details: Some("WHOIS server indicates no matching records".to_string()),
1179 }),
1180 rdap_error: "RDAP failed".to_string(),
1181 whois_error: "WHOIS failed".to_string(),
1182 whois_data: None,
1183 };
1184
1185 let json = serde_json::to_string(&result).unwrap();
1186 assert!(json.contains("\"source\":\"available\""));
1187 assert!(json.contains("\"available\":true"));
1188 assert!(json.contains("test123.xyz"));
1189
1190 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1191 assert!(result.is_available());
1192 assert!(!result.is_rdap());
1193 assert!(!result.is_whois());
1194 assert!(result.registrar().is_none());
1195 assert_eq!(result.expiration_info(), (None, None));
1196 }
1197
1198 #[test]
1199 #[allow(deprecated)]
1200 fn test_smart_lookup_builder() {
1201 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
1202 assert!(!lookup.prefer_rdap);
1203 assert!(lookup.include_fallback);
1204 }
1205
1206 #[test]
1207 fn test_lookup_cache_clear() {
1208 let _serial = INFLIGHT_TEST_SERIAL
1212 .lock()
1213 .unwrap_or_else(|p| p.into_inner());
1214 SmartLookup::clear_cache();
1215 assert!(LOOKUP_CACHE.is_empty());
1216 }
1217
1218 #[test]
1221 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1222 const MAX_RAW: usize = 32 * 1024;
1223 let mut s = "a".repeat(MAX_RAW - 1);
1227 s.push('€'); s.push_str(&"b".repeat(100));
1229 assert!(!s.is_char_boundary(MAX_RAW));
1230
1231 truncate_on_char_boundary(&mut s, MAX_RAW);
1233 assert!(s.len() <= MAX_RAW);
1234 assert_eq!(s.len(), MAX_RAW - 1);
1237 }
1238
1239 #[test]
1240 fn trim_raw_response_truncates_multibyte_whois_without_panic() {
1241 const MAX_RAW: usize = 32 * 1024;
1242 let mut raw = "a".repeat(MAX_RAW - 1);
1243 raw.push('€');
1244 raw.push_str(&"b".repeat(1000));
1245
1246 let mut w = empty_whois("example.com");
1247 w.raw_response = raw;
1248 let result = trim_raw_response(LookupResult::Whois {
1249 data: w,
1250 rdap_error: None,
1251 rdap_fallback: None,
1252 });
1253 if let LookupResult::Whois { data, .. } = result {
1254 assert!(data.raw_response.ends_with("[truncated]"));
1255 } else {
1256 panic!("expected Whois variant");
1257 }
1258 }
1259
1260 #[test]
1263 fn test_sanitize_strips_ipv4() {
1264 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1265 let sanitized = sanitize_error_for_public(msg);
1266 assert!(
1267 !sanitized.contains("10.0.0.1"),
1268 "IPv4 should be stripped, got: {}",
1269 sanitized
1270 );
1271 assert!(sanitized.contains("[ip-redacted]"));
1272 }
1273
1274 #[test]
1275 fn test_sanitize_strips_multiple_ipv4() {
1276 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1277 let sanitized = sanitize_error_for_public(msg);
1278 assert!(!sanitized.contains("192.168.1.1"));
1279 assert!(!sanitized.contains("127.0.0.1"));
1280 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1282 }
1283
1284 #[test]
1285 fn test_sanitize_strips_ipv6() {
1286 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1287 let sanitized = sanitize_error_for_public(msg);
1288 assert!(!sanitized.contains("fe80::1"));
1289 assert!(sanitized.contains("[ip-redacted]"));
1290 }
1291
1292 #[test]
1293 fn sanitize_leaves_mac_address_like_tokens_alone() {
1294 let msg = "error code af:ba:12 at line 5";
1295 let out = sanitize_error_for_public(msg);
1296 assert!(
1297 out.contains("af:ba:12"),
1298 "MAC fragment should not be stripped: {}",
1299 out
1300 );
1301 }
1302
1303 #[test]
1304 fn sanitize_strips_real_ipv6() {
1305 let msg = "cannot reach 2001:db8::1 — timeout";
1306 let out = sanitize_error_for_public(msg);
1307 assert!(!out.contains("2001:db8::1"));
1308 assert!(out.contains("[ip-redacted]"));
1309 }
1310
1311 #[test]
1312 fn sanitize_strips_fe80_link_local() {
1313 let msg = "peer at fe80::1 unreachable";
1314 let out = sanitize_error_for_public(msg);
1315 assert!(out.contains("[ip-redacted]"));
1316 }
1317
1318 #[test]
1319 fn test_sanitize_truncates_long_message() {
1320 let long = "a".repeat(500);
1322 let sanitized = sanitize_error_for_public(&long);
1323 let char_count = sanitized.chars().count();
1325 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1326 assert!(sanitized.ends_with('…'));
1327 }
1328
1329 #[test]
1330 fn test_sanitize_preserves_short_messages() {
1331 let msg = "RDAP timed out after 15s";
1332 let sanitized = sanitize_error_for_public(msg);
1333 assert_eq!(sanitized, msg);
1334 }
1335
1336 #[test]
1339 fn test_is_rdap_response_useful_detects_no_data() {
1340 use crate::rdap::RdapResponse;
1341 let resp = RdapResponse {
1345 ldh_name: Some("example.com".to_string()),
1346 ..Default::default()
1347 };
1348 assert!(
1349 !rdap_response_is_useful(&resp),
1350 "Response with only a name should be classified as NoData"
1351 );
1352
1353 let useful = RdapResponse {
1355 ldh_name: Some("example.com".to_string()),
1356 status: vec!["active".to_string()],
1357 ..Default::default()
1358 };
1359 assert!(rdap_response_is_useful(&useful));
1360 }
1361
1362 #[allow(clippy::await_holding_lock)]
1373 #[tokio::test]
1374 async fn test_inflight_coalescing_map() {
1375 let _serial = INFLIGHT_TEST_SERIAL
1380 .lock()
1381 .unwrap_or_else(|p| p.into_inner());
1382 let domain = unique_test_key("__coalesce");
1391
1392 {
1394 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1395 m.remove(&domain);
1396 }
1397
1398 let owner_notify = Arc::new(Notify::new());
1400 {
1401 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1402 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1403 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1404 }
1405
1406 let waiter = {
1408 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1409 m.get(&domain)
1410 .and_then(|w| w.upgrade())
1411 .expect("Second caller must observe in-flight entry")
1412 };
1413
1414 let waiter_clone = waiter.clone();
1416 let handle = tokio::spawn(async move {
1417 waiter_clone.notified().await;
1418 });
1419
1420 tokio::time::sleep(Duration::from_millis(20)).await;
1422 {
1423 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1424 m.remove(&domain);
1425 }
1426 owner_notify.notify_waiters();
1427
1428 tokio::time::timeout(Duration::from_secs(1), handle)
1430 .await
1431 .expect("waiter must unblock after notify")
1432 .expect("waiter task joined cleanly");
1433
1434 drop(owner_notify);
1436 drop(waiter);
1437 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1438 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1439 }
1440
1441 #[allow(clippy::await_holding_lock)]
1459 #[tokio::test]
1460 async fn waiters_coalesce_on_inflight_lookup_and_read_owners_cache() {
1461 use std::sync::atomic::Ordering;
1462
1463 let _serial = INFLIGHT_TEST_SERIAL
1464 .lock()
1465 .unwrap_or_else(|p| p.into_inner());
1466
1467 let raw = unique_test_key("coalesce-waiter");
1468 let normalized = crate::validation::normalize_domain(&raw).expect("test key normalizes");
1469 LOOKUP_CACHE.remove(&normalized);
1470
1471 let owner_notify = Arc::new(Notify::new());
1473 {
1474 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1475 m.insert(normalized.clone(), Arc::downgrade(&owner_notify));
1476 }
1477
1478 let calls_before = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1479
1480 let lookup = SmartLookup::new();
1482 let spawn_waiter = |l: SmartLookup, d: String| {
1483 tokio::spawn(async move { l.lookup_with_progress(&d, None).await })
1484 };
1485 let h1 = spawn_waiter(lookup.clone(), raw.clone());
1486 let h2 = spawn_waiter(lookup, raw);
1487
1488 tokio::time::sleep(Duration::from_millis(50)).await;
1492
1493 let canned = LookupResult::Whois {
1496 data: empty_whois(&normalized),
1497 rdap_error: None,
1498 rdap_fallback: None,
1499 };
1500 LOOKUP_CACHE.insert(normalized.clone(), canned);
1501 {
1502 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1503 m.remove(&normalized);
1504 }
1505 owner_notify.notify_waiters();
1506
1507 for handle in [h1, h2] {
1510 let result = tokio::time::timeout(Duration::from_secs(5), handle)
1511 .await
1512 .expect("waiter must wake via notify, not the bounded-wait timeout")
1513 .expect("waiter task joined cleanly")
1514 .expect("waiter must read the owner's cached result");
1515 assert!(result.is_whois());
1516 assert_eq!(result.domain_name(), Some(normalized.clone()));
1517 }
1518
1519 let calls_after = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1521 assert_eq!(
1522 calls_before, calls_after,
1523 "coalesced waiters must not start a second network race"
1524 );
1525
1526 LOOKUP_CACHE.remove(&normalized);
1527 }
1528
1529 fn unique_test_key(prefix: &str) -> String {
1535 use std::sync::atomic::{AtomicU64, Ordering};
1536 use std::time::{SystemTime, UNIX_EPOCH};
1537 static COUNTER: AtomicU64 = AtomicU64::new(0);
1538 let nanos = SystemTime::now()
1539 .duration_since(UNIX_EPOCH)
1540 .map(|d| d.as_nanos())
1541 .unwrap_or(0);
1542 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1543 format!("{}_{}_{}.example.", prefix, nanos, n)
1544 }
1545
1546 #[test]
1552 fn test_sanitize_applied_to_available_fields() {
1553 let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1554 let whois_raw = "connection refused at 192.168.0.5";
1555 let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1556 let sanitized_whois = sanitize_error_for_public(whois_raw);
1557 let result = LookupResult::Available {
1558 data: Box::new(AvailabilityResult {
1559 domain: "unreg.test".to_string(),
1560 available: true,
1561 confidence: "low".to_string(),
1562 method: "heuristic".to_string(),
1563 details: None,
1564 }),
1565 rdap_error: sanitized_rdap,
1566 whois_error: sanitized_whois,
1567 whois_data: None,
1568 };
1569 if let LookupResult::Available {
1570 rdap_error,
1571 whois_error,
1572 ..
1573 } = result
1574 {
1575 assert!(!rdap_error.contains("10.0.0.1"));
1576 assert!(!whois_error.contains("192.168.0.5"));
1577 assert!(rdap_error.contains("[ip-redacted]"));
1578 assert!(whois_error.contains("[ip-redacted]"));
1579 } else {
1580 panic!("expected Available variant");
1581 }
1582 }
1583
1584 #[test]
1585 fn rdap_error_is_404_matches_standard_404() {
1586 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1587 assert!(rdap_error_is_404(&e));
1588 }
1589
1590 #[test]
1591 fn rdap_error_is_404_matches_without_reason_phrase() {
1592 let e = SeerError::RdapError("query failed with status 404".to_string());
1593 assert!(rdap_error_is_404(&e));
1594 }
1595
1596 #[test]
1597 fn rdap_error_is_404_rejects_other_statuses() {
1598 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1599 assert!(!rdap_error_is_404(&e));
1600 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1601 assert!(!rdap_error_is_404(&e));
1602 }
1603
1604 #[test]
1605 fn rdap_error_is_404_rejects_non_http_errors() {
1606 let e = SeerError::RdapError("connection timeout".to_string());
1607 assert!(!rdap_error_is_404(&e));
1608 let e = SeerError::Timeout("rdap".to_string());
1609 assert!(!rdap_error_is_404(&e));
1610 }
1611
1612 #[test]
1613 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1614 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1616 assert!(!rdap_error_is_404(&e));
1617 }
1618
1619 fn empty_whois(domain: &str) -> WhoisResponse {
1622 WhoisResponse {
1623 domain: domain.to_string(),
1624 registrar: None,
1625 registrant: None,
1626 organization: None,
1627 registrant_email: None,
1628 registrant_phone: None,
1629 registrant_address: None,
1630 registrant_country: None,
1631 admin_name: None,
1632 admin_organization: None,
1633 admin_email: None,
1634 admin_phone: None,
1635 tech_name: None,
1636 tech_organization: None,
1637 tech_email: None,
1638 tech_phone: None,
1639 creation_date: None,
1640 expiration_date: None,
1641 updated_date: None,
1642 nameservers: vec![],
1643 status: vec![],
1644 dnssec: None,
1645 whois_server: String::new(),
1646 raw_response: String::new(),
1647 }
1648 }
1649
1650 #[test]
1651 fn whois_response_is_thin_when_all_key_fields_missing() {
1652 let w = empty_whois("example.com");
1653 assert!(whois_response_is_thin(&w));
1654 }
1655
1656 #[test]
1657 fn whois_response_is_not_thin_when_registrar_present() {
1658 let mut w = empty_whois("example.com");
1659 w.registrar = Some("Test Registrar".to_string());
1660 assert!(!whois_response_is_thin(&w));
1661 }
1662
1663 #[test]
1664 fn whois_response_is_not_thin_when_creation_date_present() {
1665 let mut w = empty_whois("example.com");
1666 w.creation_date = Some(Utc::now());
1667 assert!(!whois_response_is_thin(&w));
1668 }
1669
1670 #[test]
1671 fn whois_response_is_not_thin_when_expiration_date_present() {
1672 let mut w = empty_whois("example.com");
1673 w.expiration_date = Some(Utc::now());
1674 assert!(!whois_response_is_thin(&w));
1675 }
1676
1677 #[test]
1678 fn whois_response_is_thin_even_with_nameservers_alone() {
1679 let mut w = empty_whois("example.com");
1680 w.nameservers = vec!["ns1.example.net".to_string()];
1681 assert!(whois_response_is_thin(&w));
1682 }
1683
1684 use crate::rdap::RdapResponse;
1687
1688 #[allow(dead_code)]
1689 fn make_empty_rdap_response() -> RdapResponse {
1690 serde_json::from_value(serde_json::json!({
1691 "objectClassName": "domain",
1692 }))
1693 .expect("valid minimal RDAP response")
1694 }
1695
1696 #[test]
1697 fn classify_whois_leg_case_a_high_confidence() {
1698 let mut w = empty_whois("zaccodes.com");
1699 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1700 assert!(w.is_available());
1701 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1702 let (verdict, method) =
1703 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1704 assert_eq!(verdict, "high");
1705 assert_eq!(method, "whois");
1706 }
1707
1708 #[test]
1709 fn classify_whois_leg_case_b_matches_decide_fallback() {
1710 let w = empty_whois("example.xyz");
1717 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1718 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1719 let (verdict, method) =
1720 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1721 assert_eq!(verdict, "high");
1722 assert_eq!(method, "rdap");
1723 }
1724
1725 #[test]
1726 fn classify_whois_leg_rejects_thin_whois_without_404() {
1727 let w = empty_whois("example.xyz");
1728 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1729 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1730 }
1731
1732 #[test]
1733 fn classify_whois_leg_rejects_whois_with_real_data() {
1734 let mut w = empty_whois("legacy.tld");
1735 w.registrar = Some("Legacy Registry".to_string());
1736 w.creation_date = Some(Utc::now());
1737 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1738 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1739 }
1740
1741 #[test]
1742 fn classify_whois_leg_case_a_wins_over_case_b() {
1743 let mut w = empty_whois("example.com");
1744 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1745 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1746 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1747 assert_eq!(verdict, "high");
1748 }
1749
1750 #[test]
1758 fn rdap_200_vetoes_whois_no_match() {
1759 let mut w = empty_whois("freshly-registered.com");
1760 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1761 assert!(
1763 should_route_to_availability(true, None, &w).is_none(),
1764 "RDAP 200 must veto WHOIS-only availability claim",
1765 );
1766 }
1767
1768 #[test]
1769 fn rdap_200_vetoes_even_with_thin_whois() {
1770 let w = empty_whois("freshly-registered.com");
1771 assert!(
1773 should_route_to_availability(true, None, &w).is_none(),
1774 "RDAP 200 must veto even when WHOIS is thin",
1775 );
1776 }
1777
1778 #[test]
1779 fn rdap_404_with_whois_no_match_routes_to_available() {
1780 let mut w = empty_whois("genuinely-free.com");
1781 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1782 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1783 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1784 assert_eq!(result, Some(("high", "whois")));
1785 }
1786
1787 #[test]
1788 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1789 let mut w = empty_whois("genuinely-free.com");
1790 w.raw_response = "Domain not found".to_string();
1791 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1794 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1795 assert_eq!(result, Some(("high", "whois")));
1796 }
1797
1798 #[test]
1799 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1800 let mut w = empty_whois("genuinely-free.com");
1802 w.raw_response = "No match".to_string();
1803 let result = should_route_to_availability(false, None, &w);
1804 assert_eq!(result, Some(("high", "whois")));
1805 }
1806
1807 #[test]
1808 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1809 let mut w = empty_whois("registered.com");
1810 w.registrar = Some("Example Registrar Ltd".to_string());
1811 assert!(should_route_to_availability(false, None, &w).is_none());
1815 }
1816
1817 #[test]
1820 fn thin_fallback_refusal_is_inconclusive_regardless_of_dns() {
1821 for dns in [
1826 DnsPresence::Absent,
1827 DnsPresence::Present,
1828 DnsPresence::Unknown,
1829 ] {
1830 assert_eq!(
1831 classify_thin_fallback(true, false, true, dns),
1832 ThinFallback::Inconclusive
1833 );
1834 }
1835 }
1836
1837 #[test]
1838 fn thin_fallback_nxdomain_is_available() {
1839 assert_eq!(
1840 classify_thin_fallback(true, false, false, DnsPresence::Absent),
1841 ThinFallback::Available
1842 );
1843 }
1844
1845 #[test]
1846 fn thin_fallback_delegated_is_registered() {
1847 assert_eq!(
1851 classify_thin_fallback(true, false, false, DnsPresence::Present),
1852 ThinFallback::Registered
1853 );
1854 }
1855
1856 #[test]
1857 fn thin_fallback_unknown_dns_uses_whois() {
1858 assert_eq!(
1860 classify_thin_fallback(true, false, false, DnsPresence::Unknown),
1861 ThinFallback::UseWhois
1862 );
1863 }
1864
1865 #[test]
1866 fn thin_fallback_requires_thin_whois() {
1867 assert_eq!(
1869 classify_thin_fallback(false, false, false, DnsPresence::Absent),
1870 ThinFallback::UseWhois
1871 );
1872 }
1873
1874 #[test]
1875 fn thin_fallback_vetoed_by_rdap_200() {
1876 assert_eq!(
1879 classify_thin_fallback(true, true, false, DnsPresence::Absent),
1880 ThinFallback::UseWhois
1881 );
1882 assert_eq!(
1883 classify_thin_fallback(true, true, true, DnsPresence::Present),
1884 ThinFallback::UseWhois
1885 );
1886 }
1887
1888 async fn leg<T>(secs: u64, value: T) -> T {
1900 tokio::time::sleep(Duration::from_secs(secs)).await;
1901 value
1902 }
1903
1904 #[tokio::test(start_paused = true)]
1905 async fn race_awaits_whois_fully_when_rdap_errors_first() {
1906 let rdap = leg(0, Err::<&str, &str>("no RDAP server for example.ru"));
1907 let whois = leg(20, Ok::<&str, &str>("whois data"));
1909 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1910 assert!(matches!(r, LegOutcome::Completed(Err(_))));
1911 assert!(
1912 matches!(w, LegOutcome::Completed(Ok("whois data"))),
1913 "WHOIS must not be grace-truncated behind an RDAP failure"
1914 );
1915 }
1916
1917 #[tokio::test(start_paused = true)]
1918 async fn race_awaits_rdap_fully_when_whois_errors_first() {
1919 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1920 let whois = leg(0, Err::<&str, &str>("connection refused"));
1921 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1922 assert!(
1923 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1924 "RDAP must not be grace-truncated behind a WHOIS failure"
1925 );
1926 assert!(matches!(w, LegOutcome::Completed(Err(_))));
1927 }
1928
1929 #[tokio::test(start_paused = true)]
1930 async fn race_truncates_whois_when_rdap_answers_with_data() {
1931 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1932 let whois = leg(20, Ok::<&str, &str>("whois data"));
1933 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1934 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1935 assert!(
1936 matches!(w, LegOutcome::GraceTruncated),
1937 "a data-bearing RDAP winner only owes WHOIS the grace period"
1938 );
1939 }
1940
1941 #[tokio::test(start_paused = true)]
1942 async fn race_truncates_rdap_when_whois_answers_with_data() {
1943 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1944 let whois = leg(0, Ok::<&str, &str>("whois data"));
1945 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1946 assert!(matches!(r, LegOutcome::GraceTruncated));
1947 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1948 }
1949
1950 #[tokio::test(start_paused = true)]
1951 async fn race_loser_inside_grace_period_still_completes() {
1952 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1953 let whois = leg(2, Ok::<&str, &str>("whois data"));
1955 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1956 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1957 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1958 }
1959
1960 #[tokio::test(start_paused = true)]
1961 async fn race_awaits_whois_fully_when_rdap_wins_with_unusable_data() {
1962 let rdap = leg(0, Ok::<&str, &str>("thin"));
1965 let whois = leg(20, Ok::<&str, &str>("whois data"));
1966 let (r, w) = race_with_grace(
1967 rdap,
1968 whois,
1969 |r| matches!(r, Ok(s) if *s == "useful"),
1970 |w| w.is_ok(),
1971 )
1972 .await;
1973 assert!(matches!(r, LegOutcome::Completed(Ok("thin"))));
1974 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1975 }
1976
1977 #[tokio::test(start_paused = true)]
1978 async fn race_awaits_rdap_fully_when_whois_wins_with_thin_body() {
1979 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1987 let whois = leg(0, Ok::<_, SeerError>(empty_whois("zac.email")));
1988 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
1989 assert!(
1990 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1991 "RDAP must not be grace-truncated behind a thin WHOIS answer"
1992 );
1993 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1994 }
1995
1996 #[tokio::test(start_paused = true)]
1997 async fn race_truncates_rdap_when_whois_wins_with_real_data() {
1998 let mut whois_data = empty_whois("example.com");
2001 whois_data.registrar = Some("Mock Registrar".to_string());
2002 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
2003 let whois = leg(0, Ok::<_, SeerError>(whois_data));
2004 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
2005 assert!(
2006 matches!(r, LegOutcome::GraceTruncated),
2007 "a data-bearing WHOIS winner only owes RDAP the grace period"
2008 );
2009 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
2010 }
2011
2012 #[test]
2015 fn whois_leg_has_data_rejects_thin_ok_and_errors() {
2016 assert!(!whois_leg_has_data(&Ok(empty_whois("example.email"))));
2020 let mut no_match = empty_whois("example.com");
2021 no_match.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
2022 assert!(!whois_leg_has_data(&Ok(no_match)));
2023 assert!(!whois_leg_has_data(&Err(SeerError::WhoisError(
2024 "connection refused".to_string()
2025 ))));
2026 }
2027
2028 #[test]
2029 fn whois_leg_has_data_accepts_registration_data() {
2030 let mut w = empty_whois("example.com");
2033 w.registrar = Some("Mock Registrar".to_string());
2034 assert!(whois_leg_has_data(&Ok(w)));
2035 let mut w = empty_whois("example.com");
2036 w.expiration_date = Some(Utc::now());
2037 assert!(whois_leg_has_data(&Ok(w)));
2038 }
2039
2040 #[test]
2043 fn grace_truncated_error_says_answered_not_won() {
2044 let msg = grace_truncated_error("WHOIS", "RDAP");
2048 assert_eq!(
2049 msg,
2050 format!(
2051 "WHOIS did not respond within the {}s grace period after RDAP answered",
2052 PROTOCOL_GRACE_PERIOD.as_secs()
2053 )
2054 );
2055 assert!(!msg.contains("won"));
2056 }
2057
2058 #[test]
2059 fn grace_truncated_error_is_symmetric() {
2060 let msg = grace_truncated_error("RDAP", "WHOIS");
2061 assert!(msg.starts_with("RDAP did not respond"));
2062 assert!(msg.ends_with("after WHOIS answered"));
2063 }
2064
2065 #[test]
2075 fn lookup_inflight_recovers_from_poisoned_mutex() {
2076 use std::panic::{catch_unwind, AssertUnwindSafe};
2077
2078 let _serial = INFLIGHT_TEST_SERIAL
2080 .lock()
2081 .unwrap_or_else(|p| p.into_inner());
2082
2083 let _ = catch_unwind(AssertUnwindSafe(|| {
2085 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
2086 panic!("poisoning LOOKUP_INFLIGHT for test");
2087 }));
2088
2089 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2093 let canary = unique_test_key("__poison_recovery");
2095 guard.insert(canary.clone(), Weak::new());
2096 assert!(guard.contains_key(&canary));
2097 guard.remove(&canary);
2098 }
2099
2100 #[test]
2103 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
2104 use std::panic::{catch_unwind, AssertUnwindSafe};
2105
2106 let _serial = INFLIGHT_TEST_SERIAL
2112 .lock()
2113 .unwrap_or_else(|p| p.into_inner());
2114
2115 let key = unique_test_key("__drop_poison");
2120 let notify = Arc::new(Notify::new());
2121 {
2122 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2123 map.insert(key.clone(), Arc::downgrade(¬ify));
2124 }
2125 let guard = InflightGuard {
2126 key: key.clone(),
2127 notify: notify.clone(),
2128 };
2129
2130 let _ = catch_unwind(AssertUnwindSafe(|| {
2132 let _g = LOOKUP_INFLIGHT.lock().unwrap();
2133 panic!("poisoning LOOKUP_INFLIGHT for drop test");
2134 }));
2135
2136 drop(guard);
2139
2140 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2141 assert!(
2142 !map.contains_key(&key),
2143 "poisoned-mutex drop path should still remove the in-flight entry"
2144 );
2145 }
2146}