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 {
103 crate::availability::whois_is_thin(w)
104}
105
106const DEGRADED_LOOKUP_CACHE_TTL: Duration = Duration::from_secs(30);
113
114fn is_degraded_result(result: &LookupResult) -> bool {
119 match result {
120 LookupResult::Available { data, .. } => matches!(
121 data.method.as_str(),
122 "inconclusive" | "dns_present" | "dns_nxdomain" | "whois_error"
123 ),
124 LookupResult::Rdap { .. } | LookupResult::Whois { .. } => false,
125 }
126}
127
128fn available_with_whois(
133 avail: AvailabilityResult,
134 rdap_error: &str,
135 whois_data: WhoisResponse,
136) -> LookupResult {
137 LookupResult::Available {
138 data: Box::new(avail),
139 rdap_error: sanitize_error_for_public(rdap_error),
140 whois_error: String::new(),
141 whois_data: Some(whois_data),
142 }
143}
144
145fn rdap_response_is_useful(response: &RdapResponse) -> bool {
149 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
150 let has_dates = response
151 .events
152 .iter()
153 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
154 let has_entities = !response.entities.is_empty();
155 let has_nameservers = !response.nameservers.is_empty();
156 let has_status = !response.status.is_empty();
157
158 has_name && (has_dates || has_entities || has_nameservers || has_status)
159}
160
161fn whois_leg_has_data(w: &Result<WhoisResponse>) -> bool {
171 matches!(w, Ok(data) if !whois_response_is_thin(data))
172}
173
174fn classify_whois_leg(
181 w: &WhoisResponse,
182 rdap_err: &SeerError,
183) -> Option<(&'static str, &'static str)> {
184 if w.is_available() {
185 return Some(("high", "whois"));
186 }
187 if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
188 return Some(("high", "rdap"));
192 }
193 None
194}
195
196fn should_route_to_availability(
203 rdap_returned_200: bool,
204 rdap_seer_error: Option<&SeerError>,
205 whois_data: &WhoisResponse,
206) -> Option<(&'static str, &'static str)> {
207 if rdap_returned_200 {
208 return None;
209 }
210 if whois_data.is_available() {
216 return Some(("high", "whois"));
217 }
218 rdap_seer_error.and_then(|e| {
219 classify_whois_leg(whois_data, e)
224 })
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230enum ThinFallback {
231 Inconclusive,
234 Available,
236 Registered,
238 UseWhois,
240}
241
242fn classify_thin_fallback(
256 is_thin: bool,
257 rdap_returned_200: bool,
258 whois_refuses: bool,
259 dns: DnsPresence,
260) -> ThinFallback {
261 if !is_thin || rdap_returned_200 {
262 return ThinFallback::UseWhois;
263 }
264 if whois_refuses {
265 return ThinFallback::Inconclusive;
266 }
267 match dns {
268 DnsPresence::Absent => ThinFallback::Available,
269 DnsPresence::Present => ThinFallback::Registered,
270 DnsPresence::Unknown => ThinFallback::UseWhois,
271 }
272}
273
274fn sanitize_error_for_public(msg: &str) -> String {
280 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
281 let s = strip_ipv6(&s);
282 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
283 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
284 trunc.push('…');
285 trunc
286 } else {
287 s
288 }
289}
290
291struct InflightGuard {
304 key: String,
305 notify: Arc<Notify>,
306}
307
308impl Drop for InflightGuard {
309 fn drop(&mut self) {
310 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
323 inflight.remove(&self.key);
324 drop(inflight);
325 self.notify.notify_waiters();
326 }
327}
328
329enum LegOutcome<T> {
335 Completed(T),
337 GraceTruncated,
341}
342
343async fn race_with_grace<R, W>(
358 rdap_fut: impl std::future::Future<Output = R>,
359 whois_fut: impl std::future::Future<Output = W>,
360 rdap_has_data: impl Fn(&R) -> bool,
361 whois_has_data: impl Fn(&W) -> bool,
362) -> (LegOutcome<R>, LegOutcome<W>) {
363 tokio::pin!(rdap_fut);
364 tokio::pin!(whois_fut);
365
366 tokio::select! {
367 rdap_res = &mut rdap_fut => {
368 let whois_leg = if rdap_has_data(&rdap_res) {
369 match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
370 Ok(res) => LegOutcome::Completed(res),
371 Err(_) => LegOutcome::GraceTruncated,
372 }
373 } else {
374 LegOutcome::Completed(whois_fut.await)
375 };
376 (LegOutcome::Completed(rdap_res), whois_leg)
377 }
378 whois_res = &mut whois_fut => {
379 let rdap_leg = if whois_has_data(&whois_res) {
380 match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
381 Ok(res) => LegOutcome::Completed(res),
382 Err(_) => LegOutcome::GraceTruncated,
383 }
384 } else {
385 LegOutcome::Completed(rdap_fut.await)
386 };
387 (rdap_leg, LegOutcome::Completed(whois_res))
388 }
389 }
390}
391
392fn grace_truncated_error(truncated: &str, winner: &str) -> String {
398 format!(
399 "{} did not respond within the {}s grace period after {} answered",
400 truncated,
401 PROTOCOL_GRACE_PERIOD.as_secs(),
402 winner
403 )
404}
405
406enum RdapOutcome {
412 Useful(RdapResponse),
413 NoData(RdapResponse),
414 Error(SeerError),
415 GraceTimeout,
418}
419
420pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
425#[serde(tag = "source", rename_all = "lowercase")]
426pub enum LookupResult {
427 Rdap {
428 data: Box<RdapResponse>,
429 #[serde(skip_serializing_if = "Option::is_none")]
430 whois_fallback: Option<WhoisResponse>,
431 },
432 Whois {
433 data: WhoisResponse,
434 rdap_error: Option<String>,
435 #[serde(skip_serializing_if = "Option::is_none")]
436 rdap_fallback: Option<Box<RdapResponse>>,
437 },
438 Available {
439 data: Box<AvailabilityResult>,
440 rdap_error: String,
441 whois_error: String,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
446 whois_data: Option<WhoisResponse>,
447 },
448}
449
450impl LookupResult {
451 pub fn domain_name(&self) -> Option<String> {
453 match self {
454 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
455 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
456 LookupResult::Available { data, .. } => Some(data.domain.clone()),
457 }
458 }
459
460 pub fn registrar(&self) -> Option<String> {
462 match self {
463 LookupResult::Rdap {
464 data,
465 whois_fallback,
466 } => data
467 .get_registrar()
468 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
469 LookupResult::Whois { data, .. } => data.registrar.clone(),
470 LookupResult::Available { .. } => None,
471 }
472 }
473
474 pub fn organization(&self) -> Option<String> {
476 match self {
477 LookupResult::Rdap {
478 data,
479 whois_fallback,
480 } => data
481 .get_registrant_organization()
482 .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
483 LookupResult::Whois { data, .. } => data.organization.clone(),
484 LookupResult::Available { .. } => None,
485 }
486 }
487
488 pub fn is_rdap(&self) -> bool {
490 matches!(self, LookupResult::Rdap { .. })
491 }
492
493 pub fn is_whois(&self) -> bool {
495 matches!(self, LookupResult::Whois { .. })
496 }
497
498 pub fn is_available(&self) -> bool {
500 matches!(self, LookupResult::Available { .. })
501 }
502
503 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
505 match self {
506 LookupResult::Rdap {
507 data,
508 whois_fallback,
509 } => {
510 let expiration_date = data
512 .events
513 .iter()
514 .find(|e| e.event_action == "expiration")
515 .and_then(|e| e.parsed_date())
516 .or_else(|| {
517 whois_fallback.as_ref().and_then(|w| w.expiration_date)
519 });
520
521 let registrar = data
522 .get_registrar()
523 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
524
525 (expiration_date, registrar)
526 }
527 LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
528 LookupResult::Available { .. } => (None, None),
529 }
530 }
531}
532
533fn truncate_on_char_boundary(s: &mut String, max: usize) {
540 if s.len() > max {
541 let mut end = max;
542 while end > 0 && !s.is_char_boundary(end) {
543 end -= 1;
544 }
545 s.truncate(end);
546 }
547}
548
549pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
555 const MAX_RAW: usize = 32 * 1024;
556 if whois.raw_response.len() > MAX_RAW {
557 truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
558 whois.raw_response.push_str("\n... [truncated]");
559 }
560}
561
562pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
566 match result {
567 LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
568 LookupResult::Rdap {
569 ref mut whois_fallback,
570 ..
571 } => {
572 if let Some(w) = whois_fallback {
573 trim_whois_raw(w);
574 }
575 }
576 LookupResult::Available {
577 ref mut whois_data, ..
578 } => {
579 if let Some(w) = whois_data {
580 trim_whois_raw(w);
581 }
582 }
583 }
584
585 result
586}
587
588#[derive(Debug, Clone)]
589pub struct SmartLookup {
590 rdap_client: RdapClient,
591 whois_client: WhoisClient,
592 availability_checker: AvailabilityChecker,
593 dns_resolver: DnsResolver,
594 prefer_rdap: bool,
596 include_fallback: bool,
598}
599
600impl Default for SmartLookup {
601 fn default() -> Self {
602 Self::new()
603 }
604}
605
606impl SmartLookup {
607 pub fn new() -> Self {
610 Self {
611 rdap_client: RdapClient::new(),
612 whois_client: WhoisClient::new(),
613 availability_checker: AvailabilityChecker::new(),
614 dns_resolver: DnsResolver::new(),
615 prefer_rdap: true,
616 include_fallback: false,
617 }
618 }
619
620 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
623 Self {
624 rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
625 whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
626 availability_checker: AvailabilityChecker::from_config(config),
627 dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
628 prefer_rdap: true,
629 include_fallback: false,
630 }
631 }
632
633 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
636 pub fn prefer_rdap(mut self, prefer: bool) -> Self {
637 self.prefer_rdap = prefer;
638 self
639 }
640
641 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
644 pub fn include_fallback(mut self, include: bool) -> Self {
645 self.include_fallback = include;
646 self
647 }
648
649 #[instrument(skip(self), fields(domain = %domain))]
654 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
655 self.lookup_with_progress(domain, None).await
656 }
657
658 #[instrument(skip(self, progress), fields(domain = %domain))]
664 pub async fn lookup_with_progress(
665 &self,
666 domain: &str,
667 progress: Option<LookupProgressCallback>,
668 ) -> Result<LookupResult> {
669 let normalized = crate::validation::normalize_domain(domain)?;
670
671 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
673 debug!(domain = %normalized, "Returning cached lookup result");
674 return Ok(cached);
675 }
676
677 let _guard = loop {
690 let existing: Arc<Notify>;
691 let notified = {
692 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
696 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
697 Some(n) => existing = n,
698 None => {
699 let n = Arc::new(Notify::new());
700 inflight.insert(normalized.clone(), Arc::downgrade(&n));
701 break InflightGuard {
702 key: normalized.clone(),
703 notify: n,
704 };
705 }
706 }
707 existing.notified()
717 };
718 tokio::pin!(notified);
719 notified.as_mut().enable();
721 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
722
723 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
726 return Ok(cached);
727 }
728
729 let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
733
734 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
735 return Ok(cached);
736 }
737 };
740
741 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
747 debug!(domain = %normalized, "Returning result cached by previous owner");
748 return Ok(cached);
749 }
750
751 let result = self.lookup_concurrent(&normalized, progress).await?;
752
753 let ttl = if is_degraded_result(&result) {
758 DEGRADED_LOOKUP_CACHE_TTL
759 } else {
760 LOOKUP_CACHE_TTL
761 };
762 LOOKUP_CACHE.insert_with_ttl(normalized.clone(), trim_raw_response(result.clone()), ttl);
763
764 Ok(result)
765 }
766
767 pub fn clear_cache() {
769 LOOKUP_CACHE.clear();
770 }
771
772 #[instrument(skip(self, progress), fields(domain = %domain))]
773 async fn lookup_concurrent(
774 &self,
775 domain: &str,
776 progress: Option<LookupProgressCallback>,
777 ) -> Result<LookupResult> {
778 #[cfg(test)]
779 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
780
781 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
782
783 if let Some(ref cb) = progress {
784 cb("Querying RDAP and WHOIS concurrently");
785 }
786
787 let rdap_fut = self.rdap_client.lookup_domain(domain);
788 let whois_fut = self.whois_client.lookup(domain);
789
790 let (rdap_leg, whois_leg) = race_with_grace(
798 rdap_fut,
799 whois_fut,
800 |r| matches!(r, Ok(data) if rdap_response_is_useful(data)),
801 whois_leg_has_data,
802 )
803 .await;
804
805 if matches!(whois_leg, LegOutcome::GraceTruncated) {
806 debug!("WHOIS did not finish within grace period after RDAP answered, proceeding with RDAP only");
807 }
808 if matches!(rdap_leg, LegOutcome::GraceTruncated) {
809 debug!("RDAP did not finish within grace period after WHOIS answered, proceeding with WHOIS only");
810 }
811
812 let rdap_outcome = match rdap_leg {
814 LegOutcome::Completed(Ok(data)) => {
815 if rdap_response_is_useful(&data) {
816 RdapOutcome::Useful(data)
817 } else {
818 RdapOutcome::NoData(data)
819 }
820 }
821 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
822 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
823 };
824
825 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
827 debug!("RDAP lookup successful");
828 let whois_fallback = match whois_leg {
829 LegOutcome::Completed(Ok(w)) => Some(w),
830 _ => None,
831 };
832 return Ok(LookupResult::Rdap {
833 data: Box::new(rdap_data),
834 whois_fallback,
835 });
836 }
837
838 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
849 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
850 RdapOutcome::Useful(_) => {
851 debug!("Unexpected RdapOutcome::Useful in fallback branch");
854 (String::from("RDAP ok"), None, None)
855 }
856 RdapOutcome::NoData(data) => (
857 "RDAP response incomplete".to_string(),
858 Some(Box::new(data)),
859 None,
860 ),
861 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
862 RdapOutcome::GraceTimeout => (grace_truncated_error("RDAP", "WHOIS"), None, None),
863 };
864
865 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
866 let availability_match = should_route_to_availability(
870 rdap_returned_200,
871 rdap_seer_error.as_ref(),
872 &whois_data,
873 );
874
875 if let Some((confidence, method)) = availability_match {
876 debug!(
877 domain = %domain,
878 confidence = %confidence,
879 "Reclassifying WHOIS as availability signal"
880 );
881 let details = match method {
885 "whois" => Some("WHOIS indicates domain is not registered".to_string()),
886 "rdap" => Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
887 _ => None,
888 };
889 let avail = AvailabilityResult {
890 domain: domain.to_string(),
891 available: true,
892 confidence: confidence.to_string(),
893 method: method.to_string(),
894 details,
895 };
896 let avail = self.availability_checker.guard_subdomain_claim(avail).await;
899 if let Some(ref cb) = progress {
900 cb(if avail.available {
901 "Domain appears unregistered"
902 } else {
903 "Name is below a registrable domain (checked its parent)"
904 });
905 }
906 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
907 }
908
909 let whois_is_thin = whois_response_is_thin(&whois_data);
920 if whois_is_thin && !rdap_returned_200 {
921 let whois_refuses = whois_data.indicates_registry_refusal();
922 let dns_presence = if whois_refuses {
923 DnsPresence::Unknown
925 } else {
926 self.dns_resolver.presence(domain).await
927 };
928 match classify_thin_fallback(
929 whois_is_thin,
930 rdap_returned_200,
931 whois_refuses,
932 dns_presence,
933 ) {
934 ThinFallback::Inconclusive => {
935 debug!(domain = %domain, "Thin WHOIS is a registry refusal/throttle; availability inconclusive (issue #45)");
936 if let Some(ref cb) = progress {
937 cb("Registry refused or throttled the query (availability inconclusive)");
938 }
939 let avail = AvailabilityResult {
940 domain: domain.to_string(),
941 available: false,
942 confidence: "none".to_string(),
943 method: "inconclusive".to_string(),
944 details: Some(
945 "Registry refused or throttled the query; availability is inconclusive"
946 .to_string(),
947 ),
948 };
949 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
950 }
951 ThinFallback::Available => {
952 debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
953 let avail = AvailabilityResult {
954 domain: domain.to_string(),
955 available: true,
956 confidence: "medium".to_string(),
957 method: "dns_nxdomain".to_string(),
958 details: Some(
959 "No registry data available; domain has no DNS presence (NXDOMAIN)"
960 .to_string(),
961 ),
962 };
963 let avail = self.availability_checker.guard_subdomain_claim(avail).await;
964 if let Some(ref cb) = progress {
965 cb(if avail.available {
966 "Domain appears unregistered (no DNS presence)"
967 } else {
968 "Name is below a registrable domain (checked its parent)"
969 });
970 }
971 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
972 }
973 ThinFallback::Registered => {
974 debug!(domain = %domain, "Thin/no-service WHOIS + DNS delegation, reporting registered");
975 if let Some(ref cb) = progress {
976 cb("Domain is registered (registry detail unavailable)");
977 }
978 let details = if whois_data.registry_unavailable() {
979 "Domain is registered (the apex is delegated in DNS). This TLD's \
980 registry provides no port-43 WHOIS data and RDAP was unavailable \
981 (rate-limited or unreachable); retry shortly for full RDAP detail."
982 } else {
983 "Domain is registered (the apex is delegated in DNS). Registry detail \
984 was unavailable (RDAP rate-limited or unreachable and WHOIS returned \
985 no data); retry shortly for full detail."
986 };
987 let avail = AvailabilityResult {
988 domain: domain.to_string(),
989 available: false,
990 confidence: "high".to_string(),
991 method: "dns_present".to_string(),
992 details: Some(details.to_string()),
993 };
994 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
995 }
996 ThinFallback::UseWhois => {}
997 }
998 }
999 debug!("Using WHOIS result (RDAP not useful)");
1000 if let Some(ref cb) = progress {
1001 cb("RDAP not available (using WHOIS)");
1002 }
1003 return Ok(LookupResult::Whois {
1004 data: whois_data,
1005 rdap_error: Some(sanitize_error_for_public(&rdap_error_str)),
1006 rdap_fallback: rdap_fallback_data,
1007 });
1008 }
1009
1010 let whois_error_str = match &whois_leg {
1015 LegOutcome::Completed(Err(e)) => e.to_string(),
1016 LegOutcome::Completed(Ok(_)) => {
1017 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
1019 "WHOIS returned but was not used".to_string()
1020 }
1021 LegOutcome::GraceTruncated => grace_truncated_error("WHOIS", "RDAP"),
1025 };
1026
1027 let prior_rdap = if rdap_returned_200 {
1032 match rdap_fallback_data {
1033 Some(resp) => PriorRdap::Response(resp),
1035 None => PriorRdap::Missing,
1036 }
1037 } else if let Some(e) = rdap_seer_error {
1038 PriorRdap::Failed(e)
1039 } else {
1040 PriorRdap::Missing
1041 };
1042 let prior_whois = match whois_leg {
1043 LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
1044 LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
1046 LegOutcome::GraceTruncated => PriorWhois::Missing,
1047 };
1048
1049 self.availability_fallback(
1050 domain,
1051 prior_rdap,
1052 prior_whois,
1053 rdap_error_str,
1054 whois_error_str,
1055 progress,
1056 )
1057 .await
1058 }
1059
1060 async fn availability_fallback(
1061 &self,
1062 domain: &str,
1063 prior_rdap: PriorRdap,
1064 prior_whois: PriorWhois,
1065 rdap_error: String,
1066 whois_error: String,
1067 progress: Option<LookupProgressCallback>,
1068 ) -> Result<LookupResult> {
1069 if let Some(ref cb) = progress {
1070 cb("RDAP and WHOIS unavailable (checking availability)");
1071 }
1072 warn!(
1073 domain = %domain,
1074 rdap_error = %rdap_error,
1075 whois_error = %whois_error,
1076 "Both RDAP and WHOIS failed, falling back to availability check"
1077 );
1078
1079 match self
1080 .availability_checker
1081 .check_with_prior(domain, prior_rdap, prior_whois)
1082 .await
1083 {
1084 Ok(avail) => Ok(LookupResult::Available {
1085 data: Box::new(avail),
1086 rdap_error: sanitize_error_for_public(&rdap_error),
1087 whois_error: sanitize_error_for_public(&whois_error),
1088 whois_data: None,
1089 }),
1090 Err(avail_err) => {
1091 let tld = get_tld(domain).unwrap_or("unknown");
1092 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
1093 format!("https://www.iana.org/domains/root/db/{}.html", tld)
1094 });
1095 Err(SeerError::LookupFailed {
1096 domain: domain.to_string(),
1097 details: format!(
1098 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
1099 rdap_error, whois_error, avail_err
1100 ),
1101 registry_url,
1102 })
1103 }
1104 }
1105 }
1106
1107 pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
1112 self.dns_resolver.presence(domain).await
1113 }
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119
1120 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
1132
1133 #[test]
1134 fn test_lookup_result_domain_name_whois() {
1135 let result = LookupResult::Whois {
1136 data: WhoisResponse {
1137 domain: "example.com".to_string(),
1138 registrar: Some("Test Registrar".to_string()),
1139 registrant: None,
1140 organization: None,
1141 registrant_email: None,
1142 registrant_phone: None,
1143 registrant_address: None,
1144 registrant_country: None,
1145 admin_name: None,
1146 admin_organization: None,
1147 admin_email: None,
1148 admin_phone: None,
1149 tech_name: None,
1150 tech_organization: None,
1151 tech_email: None,
1152 tech_phone: None,
1153 creation_date: None,
1154 expiration_date: None,
1155 updated_date: None,
1156 status: vec![],
1157 nameservers: vec![],
1158 dnssec: None,
1159 whois_server: "whois.example.com".to_string(),
1160 raw_response: String::new(),
1161 },
1162 rdap_error: None,
1163 rdap_fallback: None,
1164 };
1165
1166 assert_eq!(result.domain_name(), Some("example.com".to_string()));
1167 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
1168 assert!(result.is_whois());
1169 assert!(!result.is_rdap());
1170 assert!(!result.is_available());
1171 }
1172
1173 #[test]
1174 fn test_lookup_result_serialization() {
1175 let result = LookupResult::Whois {
1176 data: WhoisResponse {
1177 domain: "test.com".to_string(),
1178 registrar: None,
1179 registrant: None,
1180 organization: None,
1181 registrant_email: None,
1182 registrant_phone: None,
1183 registrant_address: None,
1184 registrant_country: None,
1185 admin_name: None,
1186 admin_organization: None,
1187 admin_email: None,
1188 admin_phone: None,
1189 tech_name: None,
1190 tech_organization: None,
1191 tech_email: None,
1192 tech_phone: None,
1193 creation_date: None,
1194 expiration_date: None,
1195 updated_date: None,
1196 status: vec![],
1197 nameservers: vec![],
1198 dnssec: None,
1199 whois_server: String::new(),
1200 raw_response: String::new(),
1201 },
1202 rdap_error: Some("RDAP failed".to_string()),
1203 rdap_fallback: None,
1204 };
1205
1206 let json = serde_json::to_string(&result).unwrap();
1207 assert!(json.contains("\"source\":\"whois\""));
1208 assert!(json.contains("RDAP failed"));
1209 }
1210
1211 #[test]
1212 fn test_lookup_result_available_serialization() {
1213 let result = LookupResult::Available {
1214 data: Box::new(AvailabilityResult {
1215 domain: "test123.xyz".to_string(),
1216 available: true,
1217 confidence: "medium".to_string(),
1218 method: "whois_error".to_string(),
1219 details: Some("WHOIS server indicates no matching records".to_string()),
1220 }),
1221 rdap_error: "RDAP failed".to_string(),
1222 whois_error: "WHOIS failed".to_string(),
1223 whois_data: None,
1224 };
1225
1226 let json = serde_json::to_string(&result).unwrap();
1227 assert!(json.contains("\"source\":\"available\""));
1228 assert!(json.contains("\"available\":true"));
1229 assert!(json.contains("test123.xyz"));
1230
1231 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1232 assert!(result.is_available());
1233 assert!(!result.is_rdap());
1234 assert!(!result.is_whois());
1235 assert!(result.registrar().is_none());
1236 assert_eq!(result.expiration_info(), (None, None));
1237 }
1238
1239 #[test]
1240 #[allow(deprecated)]
1241 fn test_smart_lookup_builder() {
1242 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
1243 assert!(!lookup.prefer_rdap);
1244 assert!(lookup.include_fallback);
1245 }
1246
1247 #[test]
1248 fn test_lookup_cache_clear() {
1249 let _serial = INFLIGHT_TEST_SERIAL
1253 .lock()
1254 .unwrap_or_else(|p| p.into_inner());
1255 SmartLookup::clear_cache();
1256 assert!(LOOKUP_CACHE.is_empty());
1257 }
1258
1259 #[test]
1262 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1263 const MAX_RAW: usize = 32 * 1024;
1264 let mut s = "a".repeat(MAX_RAW - 1);
1268 s.push('€'); s.push_str(&"b".repeat(100));
1270 assert!(!s.is_char_boundary(MAX_RAW));
1271
1272 truncate_on_char_boundary(&mut s, MAX_RAW);
1274 assert!(s.len() <= MAX_RAW);
1275 assert_eq!(s.len(), MAX_RAW - 1);
1278 }
1279
1280 #[test]
1281 fn trim_raw_response_truncates_multibyte_whois_without_panic() {
1282 const MAX_RAW: usize = 32 * 1024;
1283 let mut raw = "a".repeat(MAX_RAW - 1);
1284 raw.push('€');
1285 raw.push_str(&"b".repeat(1000));
1286
1287 let mut w = empty_whois("example.com");
1288 w.raw_response = raw;
1289 let result = trim_raw_response(LookupResult::Whois {
1290 data: w,
1291 rdap_error: None,
1292 rdap_fallback: None,
1293 });
1294 if let LookupResult::Whois { data, .. } = result {
1295 assert!(data.raw_response.ends_with("[truncated]"));
1296 } else {
1297 panic!("expected Whois variant");
1298 }
1299 }
1300
1301 #[test]
1304 fn test_sanitize_strips_ipv4() {
1305 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1306 let sanitized = sanitize_error_for_public(msg);
1307 assert!(
1308 !sanitized.contains("10.0.0.1"),
1309 "IPv4 should be stripped, got: {}",
1310 sanitized
1311 );
1312 assert!(sanitized.contains("[ip-redacted]"));
1313 }
1314
1315 #[test]
1316 fn test_sanitize_strips_multiple_ipv4() {
1317 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1318 let sanitized = sanitize_error_for_public(msg);
1319 assert!(!sanitized.contains("192.168.1.1"));
1320 assert!(!sanitized.contains("127.0.0.1"));
1321 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1323 }
1324
1325 #[test]
1326 fn test_sanitize_strips_ipv6() {
1327 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1328 let sanitized = sanitize_error_for_public(msg);
1329 assert!(!sanitized.contains("fe80::1"));
1330 assert!(sanitized.contains("[ip-redacted]"));
1331 }
1332
1333 #[test]
1334 fn sanitize_leaves_mac_address_like_tokens_alone() {
1335 let msg = "error code af:ba:12 at line 5";
1336 let out = sanitize_error_for_public(msg);
1337 assert!(
1338 out.contains("af:ba:12"),
1339 "MAC fragment should not be stripped: {}",
1340 out
1341 );
1342 }
1343
1344 #[test]
1345 fn sanitize_strips_real_ipv6() {
1346 let msg = "cannot reach 2001:db8::1 — timeout";
1347 let out = sanitize_error_for_public(msg);
1348 assert!(!out.contains("2001:db8::1"));
1349 assert!(out.contains("[ip-redacted]"));
1350 }
1351
1352 #[test]
1353 fn sanitize_strips_fe80_link_local() {
1354 let msg = "peer at fe80::1 unreachable";
1355 let out = sanitize_error_for_public(msg);
1356 assert!(out.contains("[ip-redacted]"));
1357 }
1358
1359 #[test]
1360 fn test_sanitize_truncates_long_message() {
1361 let long = "a".repeat(500);
1363 let sanitized = sanitize_error_for_public(&long);
1364 let char_count = sanitized.chars().count();
1366 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1367 assert!(sanitized.ends_with('…'));
1368 }
1369
1370 #[test]
1371 fn test_sanitize_preserves_short_messages() {
1372 let msg = "RDAP timed out after 15s";
1373 let sanitized = sanitize_error_for_public(msg);
1374 assert_eq!(sanitized, msg);
1375 }
1376
1377 #[test]
1380 fn test_is_rdap_response_useful_detects_no_data() {
1381 use crate::rdap::RdapResponse;
1382 let resp = RdapResponse {
1386 ldh_name: Some("example.com".to_string()),
1387 ..Default::default()
1388 };
1389 assert!(
1390 !rdap_response_is_useful(&resp),
1391 "Response with only a name should be classified as NoData"
1392 );
1393
1394 let useful = RdapResponse {
1396 ldh_name: Some("example.com".to_string()),
1397 status: vec!["active".to_string()],
1398 ..Default::default()
1399 };
1400 assert!(rdap_response_is_useful(&useful));
1401 }
1402
1403 #[allow(clippy::await_holding_lock)]
1414 #[tokio::test]
1415 async fn test_inflight_coalescing_map() {
1416 let _serial = INFLIGHT_TEST_SERIAL
1421 .lock()
1422 .unwrap_or_else(|p| p.into_inner());
1423 let domain = unique_test_key("__coalesce");
1432
1433 {
1435 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1436 m.remove(&domain);
1437 }
1438
1439 let owner_notify = Arc::new(Notify::new());
1441 {
1442 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1443 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1444 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1445 }
1446
1447 let waiter = {
1449 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1450 m.get(&domain)
1451 .and_then(|w| w.upgrade())
1452 .expect("Second caller must observe in-flight entry")
1453 };
1454
1455 let waiter_clone = waiter.clone();
1457 let handle = tokio::spawn(async move {
1458 waiter_clone.notified().await;
1459 });
1460
1461 tokio::time::sleep(Duration::from_millis(20)).await;
1463 {
1464 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1465 m.remove(&domain);
1466 }
1467 owner_notify.notify_waiters();
1468
1469 tokio::time::timeout(Duration::from_secs(1), handle)
1471 .await
1472 .expect("waiter must unblock after notify")
1473 .expect("waiter task joined cleanly");
1474
1475 drop(owner_notify);
1477 drop(waiter);
1478 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1479 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1480 }
1481
1482 #[allow(clippy::await_holding_lock)]
1500 #[tokio::test]
1501 async fn waiters_coalesce_on_inflight_lookup_and_read_owners_cache() {
1502 use std::sync::atomic::Ordering;
1503
1504 let _serial = INFLIGHT_TEST_SERIAL
1505 .lock()
1506 .unwrap_or_else(|p| p.into_inner());
1507
1508 let raw = unique_test_key("coalesce-waiter");
1509 let normalized = crate::validation::normalize_domain(&raw).expect("test key normalizes");
1510 LOOKUP_CACHE.remove(&normalized);
1511
1512 let owner_notify = Arc::new(Notify::new());
1514 {
1515 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1516 m.insert(normalized.clone(), Arc::downgrade(&owner_notify));
1517 }
1518
1519 let calls_before = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1520
1521 let lookup = SmartLookup::new();
1523 let spawn_waiter = |l: SmartLookup, d: String| {
1524 tokio::spawn(async move { l.lookup_with_progress(&d, None).await })
1525 };
1526 let h1 = spawn_waiter(lookup.clone(), raw.clone());
1527 let h2 = spawn_waiter(lookup, raw);
1528
1529 tokio::time::sleep(Duration::from_millis(50)).await;
1533
1534 let canned = LookupResult::Whois {
1537 data: empty_whois(&normalized),
1538 rdap_error: None,
1539 rdap_fallback: None,
1540 };
1541 LOOKUP_CACHE.insert(normalized.clone(), canned);
1542 {
1543 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1544 m.remove(&normalized);
1545 }
1546 owner_notify.notify_waiters();
1547
1548 for handle in [h1, h2] {
1551 let result = tokio::time::timeout(Duration::from_secs(5), handle)
1552 .await
1553 .expect("waiter must wake via notify, not the bounded-wait timeout")
1554 .expect("waiter task joined cleanly")
1555 .expect("waiter must read the owner's cached result");
1556 assert!(result.is_whois());
1557 assert_eq!(result.domain_name(), Some(normalized.clone()));
1558 }
1559
1560 let calls_after = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1562 assert_eq!(
1563 calls_before, calls_after,
1564 "coalesced waiters must not start a second network race"
1565 );
1566
1567 LOOKUP_CACHE.remove(&normalized);
1568 }
1569
1570 fn unique_test_key(prefix: &str) -> String {
1576 use std::sync::atomic::{AtomicU64, Ordering};
1577 use std::time::{SystemTime, UNIX_EPOCH};
1578 static COUNTER: AtomicU64 = AtomicU64::new(0);
1579 let nanos = SystemTime::now()
1580 .duration_since(UNIX_EPOCH)
1581 .map(|d| d.as_nanos())
1582 .unwrap_or(0);
1583 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1584 format!("{}_{}_{}.example.", prefix, nanos, n)
1585 }
1586
1587 #[test]
1593 fn available_with_whois_sanitizes_rdap_error() {
1594 let avail = AvailabilityResult {
1597 domain: "unreg.test".to_string(),
1598 available: false,
1599 confidence: "none".to_string(),
1600 method: "inconclusive".to_string(),
1601 details: None,
1602 };
1603 let result = available_with_whois(
1604 avail,
1605 "RDAP URL resolves to reserved IP 10.0.0.1",
1606 empty_whois("unreg.test"),
1607 );
1608 let LookupResult::Available {
1609 rdap_error,
1610 whois_error,
1611 whois_data,
1612 ..
1613 } = result
1614 else {
1615 panic!("expected Available variant");
1616 };
1617 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1618 assert!(rdap_error.contains("[ip-redacted]"));
1619 assert!(whois_error.is_empty());
1620 assert!(whois_data.is_some());
1621 }
1622
1623 #[tokio::test]
1624 async fn availability_fallback_sanitizes_both_error_fields() {
1625 let lookup = SmartLookup::new();
1628 let prior = RdapResponse {
1629 ldh_name: Some("example.com".to_string()),
1630 status: vec!["active".to_string()],
1631 ..Default::default()
1632 };
1633 let result = lookup
1634 .availability_fallback(
1635 "example.com",
1636 PriorRdap::Response(Box::new(prior)),
1637 PriorWhois::Missing,
1638 "RDAP URL resolves to reserved IP 10.0.0.1".to_string(),
1639 "connection refused at 192.168.0.5 and fe80::1".to_string(),
1640 None,
1641 )
1642 .await
1643 .expect("prior-response fallback must not error");
1644 let LookupResult::Available {
1645 rdap_error,
1646 whois_error,
1647 ..
1648 } = result
1649 else {
1650 panic!("expected Available variant");
1651 };
1652 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1653 assert!(!whois_error.contains("192.168.0.5"), "{whois_error}");
1654 assert!(!whois_error.contains("fe80::1"), "{whois_error}");
1655 assert!(rdap_error.contains("[ip-redacted]"));
1656 assert!(whois_error.contains("[ip-redacted]"));
1657 }
1658
1659 fn available_via(method: &str, available: bool, confidence: &str) -> LookupResult {
1662 LookupResult::Available {
1663 data: Box::new(AvailabilityResult {
1664 domain: "example.test".to_string(),
1665 available,
1666 confidence: confidence.to_string(),
1667 method: method.to_string(),
1668 details: None,
1669 }),
1670 rdap_error: String::new(),
1671 whois_error: String::new(),
1672 whois_data: None,
1673 }
1674 }
1675
1676 #[test]
1677 fn degraded_results_are_identified_for_short_caching() {
1678 assert!(is_degraded_result(&available_via(
1680 "inconclusive",
1681 false,
1682 "none"
1683 )));
1684 assert!(is_degraded_result(&available_via(
1685 "dns_present",
1686 false,
1687 "high"
1688 )));
1689 assert!(is_degraded_result(&available_via(
1690 "dns_nxdomain",
1691 true,
1692 "medium"
1693 )));
1694 assert!(is_degraded_result(&available_via(
1695 "whois_error",
1696 true,
1697 "medium"
1698 )));
1699 assert!(!is_degraded_result(&available_via("rdap", true, "high")));
1701 assert!(!is_degraded_result(&available_via("whois", true, "high")));
1702 assert!(!is_degraded_result(&LookupResult::Whois {
1703 data: empty_whois("example.test"),
1704 rdap_error: None,
1705 rdap_fallback: None,
1706 }));
1707 const { assert!(DEGRADED_LOOKUP_CACHE_TTL.as_secs() < LOOKUP_CACHE_TTL.as_secs()) };
1708 }
1709
1710 #[test]
1711 fn rdap_error_is_404_matches_standard_404() {
1712 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1713 assert!(rdap_error_is_404(&e));
1714 }
1715
1716 #[test]
1717 fn rdap_error_is_404_matches_without_reason_phrase() {
1718 let e = SeerError::RdapError("query failed with status 404".to_string());
1719 assert!(rdap_error_is_404(&e));
1720 }
1721
1722 #[test]
1723 fn rdap_error_is_404_rejects_other_statuses() {
1724 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1725 assert!(!rdap_error_is_404(&e));
1726 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1727 assert!(!rdap_error_is_404(&e));
1728 }
1729
1730 #[test]
1731 fn rdap_error_is_404_rejects_non_http_errors() {
1732 let e = SeerError::RdapError("connection timeout".to_string());
1733 assert!(!rdap_error_is_404(&e));
1734 let e = SeerError::Timeout("rdap".to_string());
1735 assert!(!rdap_error_is_404(&e));
1736 }
1737
1738 #[test]
1739 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1740 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1742 assert!(!rdap_error_is_404(&e));
1743 }
1744
1745 fn empty_whois(domain: &str) -> WhoisResponse {
1748 WhoisResponse {
1749 domain: domain.to_string(),
1750 registrar: None,
1751 registrant: None,
1752 organization: None,
1753 registrant_email: None,
1754 registrant_phone: None,
1755 registrant_address: None,
1756 registrant_country: None,
1757 admin_name: None,
1758 admin_organization: None,
1759 admin_email: None,
1760 admin_phone: None,
1761 tech_name: None,
1762 tech_organization: None,
1763 tech_email: None,
1764 tech_phone: None,
1765 creation_date: None,
1766 expiration_date: None,
1767 updated_date: None,
1768 nameservers: vec![],
1769 status: vec![],
1770 dnssec: None,
1771 whois_server: String::new(),
1772 raw_response: String::new(),
1773 }
1774 }
1775
1776 #[test]
1777 fn whois_response_is_thin_when_all_key_fields_missing() {
1778 let w = empty_whois("example.com");
1779 assert!(whois_response_is_thin(&w));
1780 }
1781
1782 #[test]
1783 fn whois_response_is_not_thin_when_registrar_present() {
1784 let mut w = empty_whois("example.com");
1785 w.registrar = Some("Test Registrar".to_string());
1786 assert!(!whois_response_is_thin(&w));
1787 }
1788
1789 #[test]
1790 fn whois_response_is_not_thin_when_creation_date_present() {
1791 let mut w = empty_whois("example.com");
1792 w.creation_date = Some(Utc::now());
1793 assert!(!whois_response_is_thin(&w));
1794 }
1795
1796 #[test]
1797 fn whois_response_is_not_thin_when_expiration_date_present() {
1798 let mut w = empty_whois("example.com");
1799 w.expiration_date = Some(Utc::now());
1800 assert!(!whois_response_is_thin(&w));
1801 }
1802
1803 fn denic_whois() -> WhoisResponse {
1806 let mut w = empty_whois("example.de");
1807 w.nameservers = vec!["ns1.example.net".to_string()];
1808 w.status = vec!["active".to_string()];
1809 w.updated_date = Some(Utc::now());
1810 w.raw_response = "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n".into();
1811 w
1812 }
1813
1814 #[test]
1815 fn whois_response_with_nameservers_is_not_thin() {
1816 let mut w = empty_whois("example.com");
1817 w.nameservers = vec!["ns1.example.net".to_string()];
1818 assert!(!whois_response_is_thin(&w));
1819 }
1820
1821 #[test]
1822 fn denic_whois_stays_on_the_whois_path() {
1823 let w = denic_whois();
1827 let bootstrap_miss =
1828 SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
1829 assert!(!whois_response_is_thin(&w));
1830 assert!(whois_leg_has_data(&Ok(w.clone())));
1831 assert_eq!(
1832 should_route_to_availability(false, Some(&bootstrap_miss), &w),
1833 None
1834 );
1835 assert_eq!(
1836 classify_thin_fallback(
1837 whois_response_is_thin(&w),
1838 false,
1839 w.indicates_registry_refusal(),
1840 DnsPresence::Present,
1841 ),
1842 ThinFallback::UseWhois
1843 );
1844 }
1845
1846 use crate::rdap::RdapResponse;
1849
1850 #[allow(dead_code)]
1851 fn make_empty_rdap_response() -> RdapResponse {
1852 serde_json::from_value(serde_json::json!({
1853 "objectClassName": "domain",
1854 }))
1855 .expect("valid minimal RDAP response")
1856 }
1857
1858 #[test]
1859 fn classify_whois_leg_case_a_high_confidence() {
1860 let mut w = empty_whois("zaccodes.com");
1861 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1862 assert!(w.is_available());
1863 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1864 let (verdict, method) =
1865 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1866 assert_eq!(verdict, "high");
1867 assert_eq!(method, "whois");
1868 }
1869
1870 #[test]
1871 fn classify_whois_leg_case_b_matches_decide_fallback() {
1872 let w = empty_whois("example.xyz");
1879 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1880 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1881 let (verdict, method) =
1882 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1883 assert_eq!(verdict, "high");
1884 assert_eq!(method, "rdap");
1885 }
1886
1887 #[test]
1888 fn classify_whois_leg_rejects_thin_whois_without_404() {
1889 let w = empty_whois("example.xyz");
1890 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1891 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1892 }
1893
1894 #[test]
1895 fn classify_whois_leg_rejects_whois_with_real_data() {
1896 let mut w = empty_whois("legacy.tld");
1897 w.registrar = Some("Legacy Registry".to_string());
1898 w.creation_date = Some(Utc::now());
1899 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1900 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1901 }
1902
1903 #[test]
1904 fn classify_whois_leg_case_a_wins_over_case_b() {
1905 let mut w = empty_whois("example.com");
1906 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1907 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1908 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1909 assert_eq!(verdict, "high");
1910 }
1911
1912 #[test]
1920 fn rdap_200_vetoes_whois_no_match() {
1921 let mut w = empty_whois("freshly-registered.com");
1922 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1923 assert!(
1925 should_route_to_availability(true, None, &w).is_none(),
1926 "RDAP 200 must veto WHOIS-only availability claim",
1927 );
1928 }
1929
1930 #[test]
1931 fn rdap_200_vetoes_even_with_thin_whois() {
1932 let w = empty_whois("freshly-registered.com");
1933 assert!(
1935 should_route_to_availability(true, None, &w).is_none(),
1936 "RDAP 200 must veto even when WHOIS is thin",
1937 );
1938 }
1939
1940 #[test]
1941 fn rdap_404_with_whois_no_match_routes_to_available() {
1942 let mut w = empty_whois("genuinely-free.com");
1943 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1944 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1945 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1946 assert_eq!(result, Some(("high", "whois")));
1947 }
1948
1949 #[test]
1950 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1951 let mut w = empty_whois("genuinely-free.com");
1952 w.raw_response = "Domain not found".to_string();
1953 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1956 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1957 assert_eq!(result, Some(("high", "whois")));
1958 }
1959
1960 #[test]
1961 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1962 let mut w = empty_whois("genuinely-free.com");
1964 w.raw_response = "No match".to_string();
1965 let result = should_route_to_availability(false, None, &w);
1966 assert_eq!(result, Some(("high", "whois")));
1967 }
1968
1969 #[test]
1970 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1971 let mut w = empty_whois("registered.com");
1972 w.registrar = Some("Example Registrar Ltd".to_string());
1973 assert!(should_route_to_availability(false, None, &w).is_none());
1977 }
1978
1979 #[test]
1982 fn thin_fallback_refusal_is_inconclusive_regardless_of_dns() {
1983 for dns in [
1988 DnsPresence::Absent,
1989 DnsPresence::Present,
1990 DnsPresence::Unknown,
1991 ] {
1992 assert_eq!(
1993 classify_thin_fallback(true, false, true, dns),
1994 ThinFallback::Inconclusive
1995 );
1996 }
1997 }
1998
1999 #[test]
2000 fn thin_fallback_nxdomain_is_available() {
2001 assert_eq!(
2002 classify_thin_fallback(true, false, false, DnsPresence::Absent),
2003 ThinFallback::Available
2004 );
2005 }
2006
2007 #[test]
2008 fn thin_fallback_delegated_is_registered() {
2009 assert_eq!(
2013 classify_thin_fallback(true, false, false, DnsPresence::Present),
2014 ThinFallback::Registered
2015 );
2016 }
2017
2018 #[test]
2019 fn thin_fallback_unknown_dns_uses_whois() {
2020 assert_eq!(
2022 classify_thin_fallback(true, false, false, DnsPresence::Unknown),
2023 ThinFallback::UseWhois
2024 );
2025 }
2026
2027 #[test]
2028 fn thin_fallback_requires_thin_whois() {
2029 assert_eq!(
2031 classify_thin_fallback(false, false, false, DnsPresence::Absent),
2032 ThinFallback::UseWhois
2033 );
2034 }
2035
2036 #[test]
2037 fn thin_fallback_vetoed_by_rdap_200() {
2038 assert_eq!(
2041 classify_thin_fallback(true, true, false, DnsPresence::Absent),
2042 ThinFallback::UseWhois
2043 );
2044 assert_eq!(
2045 classify_thin_fallback(true, true, true, DnsPresence::Present),
2046 ThinFallback::UseWhois
2047 );
2048 }
2049
2050 async fn leg<T>(secs: u64, value: T) -> T {
2062 tokio::time::sleep(Duration::from_secs(secs)).await;
2063 value
2064 }
2065
2066 #[tokio::test(start_paused = true)]
2067 async fn race_awaits_whois_fully_when_rdap_errors_first() {
2068 let rdap = leg(0, Err::<&str, &str>("no RDAP server for example.ru"));
2069 let whois = leg(20, Ok::<&str, &str>("whois data"));
2071 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
2072 assert!(matches!(r, LegOutcome::Completed(Err(_))));
2073 assert!(
2074 matches!(w, LegOutcome::Completed(Ok("whois data"))),
2075 "WHOIS must not be grace-truncated behind an RDAP failure"
2076 );
2077 }
2078
2079 #[tokio::test(start_paused = true)]
2080 async fn race_awaits_rdap_fully_when_whois_errors_first() {
2081 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
2082 let whois = leg(0, Err::<&str, &str>("connection refused"));
2083 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
2084 assert!(
2085 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
2086 "RDAP must not be grace-truncated behind a WHOIS failure"
2087 );
2088 assert!(matches!(w, LegOutcome::Completed(Err(_))));
2089 }
2090
2091 #[tokio::test(start_paused = true)]
2092 async fn race_truncates_whois_when_rdap_answers_with_data() {
2093 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
2094 let whois = leg(20, Ok::<&str, &str>("whois data"));
2095 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
2096 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
2097 assert!(
2098 matches!(w, LegOutcome::GraceTruncated),
2099 "a data-bearing RDAP winner only owes WHOIS the grace period"
2100 );
2101 }
2102
2103 #[tokio::test(start_paused = true)]
2104 async fn race_truncates_rdap_when_whois_answers_with_data() {
2105 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
2106 let whois = leg(0, Ok::<&str, &str>("whois data"));
2107 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
2108 assert!(matches!(r, LegOutcome::GraceTruncated));
2109 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
2110 }
2111
2112 #[tokio::test(start_paused = true)]
2113 async fn race_loser_inside_grace_period_still_completes() {
2114 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
2115 let whois = leg(2, Ok::<&str, &str>("whois data"));
2117 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
2118 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
2119 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
2120 }
2121
2122 #[tokio::test(start_paused = true)]
2123 async fn race_awaits_whois_fully_when_rdap_wins_with_unusable_data() {
2124 let rdap = leg(0, Ok::<&str, &str>("thin"));
2127 let whois = leg(20, Ok::<&str, &str>("whois data"));
2128 let (r, w) = race_with_grace(
2129 rdap,
2130 whois,
2131 |r| matches!(r, Ok(s) if *s == "useful"),
2132 |w| w.is_ok(),
2133 )
2134 .await;
2135 assert!(matches!(r, LegOutcome::Completed(Ok("thin"))));
2136 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
2137 }
2138
2139 #[tokio::test(start_paused = true)]
2140 async fn race_awaits_rdap_fully_when_whois_wins_with_thin_body() {
2141 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
2149 let whois = leg(0, Ok::<_, SeerError>(empty_whois("zac.email")));
2150 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
2151 assert!(
2152 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
2153 "RDAP must not be grace-truncated behind a thin WHOIS answer"
2154 );
2155 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
2156 }
2157
2158 #[tokio::test(start_paused = true)]
2159 async fn race_truncates_rdap_when_whois_wins_with_real_data() {
2160 let mut whois_data = empty_whois("example.com");
2163 whois_data.registrar = Some("Mock Registrar".to_string());
2164 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
2165 let whois = leg(0, Ok::<_, SeerError>(whois_data));
2166 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
2167 assert!(
2168 matches!(r, LegOutcome::GraceTruncated),
2169 "a data-bearing WHOIS winner only owes RDAP the grace period"
2170 );
2171 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
2172 }
2173
2174 #[test]
2177 fn whois_leg_has_data_rejects_thin_ok_and_errors() {
2178 assert!(!whois_leg_has_data(&Ok(empty_whois("example.email"))));
2182 let mut no_match = empty_whois("example.com");
2183 no_match.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
2184 assert!(!whois_leg_has_data(&Ok(no_match)));
2185 assert!(!whois_leg_has_data(&Err(SeerError::WhoisError(
2186 "connection refused".to_string()
2187 ))));
2188 }
2189
2190 #[test]
2191 fn whois_leg_has_data_accepts_registration_data() {
2192 let mut w = empty_whois("example.com");
2195 w.registrar = Some("Mock Registrar".to_string());
2196 assert!(whois_leg_has_data(&Ok(w)));
2197 let mut w = empty_whois("example.com");
2198 w.expiration_date = Some(Utc::now());
2199 assert!(whois_leg_has_data(&Ok(w)));
2200 }
2201
2202 #[test]
2205 fn grace_truncated_error_says_answered_not_won() {
2206 let msg = grace_truncated_error("WHOIS", "RDAP");
2210 assert_eq!(
2211 msg,
2212 format!(
2213 "WHOIS did not respond within the {}s grace period after RDAP answered",
2214 PROTOCOL_GRACE_PERIOD.as_secs()
2215 )
2216 );
2217 assert!(!msg.contains("won"));
2218 }
2219
2220 #[test]
2221 fn grace_truncated_error_is_symmetric() {
2222 let msg = grace_truncated_error("RDAP", "WHOIS");
2223 assert!(msg.starts_with("RDAP did not respond"));
2224 assert!(msg.ends_with("after WHOIS answered"));
2225 }
2226
2227 #[test]
2237 fn lookup_inflight_recovers_from_poisoned_mutex() {
2238 use std::panic::{catch_unwind, AssertUnwindSafe};
2239
2240 let _serial = INFLIGHT_TEST_SERIAL
2242 .lock()
2243 .unwrap_or_else(|p| p.into_inner());
2244
2245 let _ = catch_unwind(AssertUnwindSafe(|| {
2247 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
2248 panic!("poisoning LOOKUP_INFLIGHT for test");
2249 }));
2250
2251 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2255 let canary = unique_test_key("__poison_recovery");
2257 guard.insert(canary.clone(), Weak::new());
2258 assert!(guard.contains_key(&canary));
2259 guard.remove(&canary);
2260 }
2261
2262 #[test]
2265 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
2266 use std::panic::{catch_unwind, AssertUnwindSafe};
2267
2268 let _serial = INFLIGHT_TEST_SERIAL
2274 .lock()
2275 .unwrap_or_else(|p| p.into_inner());
2276
2277 let key = unique_test_key("__drop_poison");
2282 let notify = Arc::new(Notify::new());
2283 {
2284 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2285 map.insert(key.clone(), Arc::downgrade(¬ify));
2286 }
2287 let guard = InflightGuard {
2288 key: key.clone(),
2289 notify: notify.clone(),
2290 };
2291
2292 let _ = catch_unwind(AssertUnwindSafe(|| {
2294 let _g = LOOKUP_INFLIGHT.lock().unwrap();
2295 panic!("poisoning LOOKUP_INFLIGHT for drop test");
2296 }));
2297
2298 drop(guard);
2301
2302 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2303 assert!(
2304 !map.contains_key(&key),
2305 "poisoned-mutex drop path should still remove the in-flight entry"
2306 );
2307 }
2308}