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 serde::{Deserialize, Serialize};
9use std::sync::LazyLock;
10use tokio::sync::Notify;
11use tracing::{debug, instrument, warn};
12
13use tokio::time::timeout as tokio_timeout;
14
15use crate::availability::{AvailabilityChecker, AvailabilityResult, PriorRdap, PriorWhois};
16use crate::cache::TtlCache;
17use crate::dns::{DnsPresence, DnsResolver};
18use crate::error::{Result, SeerError};
19use crate::rdap::{rdap_error_is_404, RdapClient, RdapResponse};
20use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
21
22const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
24
25const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
33
34const MAX_PUBLIC_ERROR_LEN: usize = 256;
36
37const DEFAULT_INFLIGHT_WAIT: Duration = Duration::from_secs(30);
44
45static LOOKUP_CACHE: LazyLock<TtlCache<String, LookupResult>> =
47 LazyLock::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
48
49static LOOKUP_INFLIGHT: LazyLock<Mutex<HashMap<String, Weak<Notify>>>> =
53 LazyLock::new(|| Mutex::new(HashMap::new()));
54
55static_regex! {
57 IPV4_RE = r"\b(?:\d{1,3}\.){3}\d{1,3}\b";
58
59 IPV6_CANDIDATE_RE = r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b";
65}
66
67fn strip_ipv6(msg: &str) -> String {
70 IPV6_CANDIDATE_RE
71 .replace_all(msg, |caps: ®ex::Captures| {
72 let candidate = &caps[0];
73 if Ipv6Addr::from_str(candidate).is_ok() {
74 "[ip-redacted]".to_string()
75 } else {
76 candidate.to_string()
77 }
78 })
79 .into_owned()
80}
81
82#[cfg(test)]
86static LOOKUP_CONCURRENT_CALLS: LazyLock<std::sync::atomic::AtomicUsize> =
87 LazyLock::new(|| std::sync::atomic::AtomicUsize::new(0));
88
89const DEGRADED_LOOKUP_CACHE_TTL: Duration = Duration::from_secs(30);
96
97fn is_degraded_result(result: &LookupResult) -> bool {
102 match result {
103 LookupResult::Available { data, .. } => matches!(
104 data.method.as_str(),
105 "inconclusive" | "dns_present" | "dns_nxdomain" | "whois_error"
106 ),
107 LookupResult::Rdap { .. } | LookupResult::Whois { .. } => false,
108 }
109}
110
111fn available_with_whois(
116 avail: AvailabilityResult,
117 rdap_error: &str,
118 whois_data: WhoisResponse,
119) -> LookupResult {
120 LookupResult::Available {
121 data: Box::new(avail),
122 rdap_error: sanitize_error_for_public(rdap_error),
123 whois_error: String::new(),
124 whois_data: Some(whois_data),
125 }
126}
127
128fn rdap_response_is_useful(response: &RdapResponse) -> bool {
132 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
133 let has_dates = response
134 .events
135 .iter()
136 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
137 let has_entities = !response.entities.is_empty();
138 let has_nameservers = !response.nameservers.is_empty();
139 let has_status = !response.status.is_empty();
140
141 has_name && (has_dates || has_entities || has_nameservers || has_status)
142}
143
144fn whois_leg_has_data(w: &Result<WhoisResponse>) -> bool {
154 matches!(w, Ok(data) if !data.is_thin())
155}
156
157fn classify_whois_leg(
164 w: &WhoisResponse,
165 rdap_err: &SeerError,
166) -> Option<(&'static str, &'static str)> {
167 if w.is_available() {
168 return Some(("high", "whois"));
169 }
170 if w.is_thin() && rdap_error_is_404(rdap_err) {
171 return Some(("high", "rdap"));
175 }
176 None
177}
178
179fn should_route_to_availability(
186 rdap_returned_200: bool,
187 rdap_seer_error: Option<&SeerError>,
188 whois_data: &WhoisResponse,
189) -> Option<(&'static str, &'static str)> {
190 if rdap_returned_200 {
191 return None;
192 }
193 if whois_data.is_available() {
199 return Some(("high", "whois"));
200 }
201 rdap_seer_error.and_then(|e| {
202 classify_whois_leg(whois_data, e)
207 })
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213enum ThinFallback {
214 Inconclusive,
217 Available,
219 Registered,
221 UseWhois,
223}
224
225fn classify_thin_fallback(
239 is_thin: bool,
240 rdap_returned_200: bool,
241 whois_refuses: bool,
242 dns: DnsPresence,
243) -> ThinFallback {
244 if !is_thin || rdap_returned_200 {
245 return ThinFallback::UseWhois;
246 }
247 if whois_refuses {
248 return ThinFallback::Inconclusive;
249 }
250 match dns {
251 DnsPresence::Absent => ThinFallback::Available,
252 DnsPresence::Present => ThinFallback::Registered,
253 DnsPresence::Unknown => ThinFallback::UseWhois,
254 }
255}
256
257fn sanitize_error_for_public(msg: &str) -> String {
263 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
264 let s = strip_ipv6(&s);
265 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
266 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
267 trunc.push('…');
268 trunc
269 } else {
270 s
271 }
272}
273
274struct InflightGuard {
287 key: String,
288 notify: Arc<Notify>,
289}
290
291impl Drop for InflightGuard {
292 fn drop(&mut self) {
293 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
306 inflight.remove(&self.key);
307 drop(inflight);
308 self.notify.notify_waiters();
309 }
310}
311
312enum LegOutcome<T> {
318 Completed(T),
320 GraceTruncated,
324}
325
326async fn race_with_grace<R, W>(
341 rdap_fut: impl std::future::Future<Output = R>,
342 whois_fut: impl std::future::Future<Output = W>,
343 rdap_has_data: impl Fn(&R) -> bool,
344 whois_has_data: impl Fn(&W) -> bool,
345) -> (LegOutcome<R>, LegOutcome<W>) {
346 tokio::pin!(rdap_fut);
347 tokio::pin!(whois_fut);
348
349 tokio::select! {
350 rdap_res = &mut rdap_fut => {
351 let whois_leg = if rdap_has_data(&rdap_res) {
352 match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
353 Ok(res) => LegOutcome::Completed(res),
354 Err(_) => LegOutcome::GraceTruncated,
355 }
356 } else {
357 LegOutcome::Completed(whois_fut.await)
358 };
359 (LegOutcome::Completed(rdap_res), whois_leg)
360 }
361 whois_res = &mut whois_fut => {
362 let rdap_leg = if whois_has_data(&whois_res) {
363 match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
364 Ok(res) => LegOutcome::Completed(res),
365 Err(_) => LegOutcome::GraceTruncated,
366 }
367 } else {
368 LegOutcome::Completed(rdap_fut.await)
369 };
370 (rdap_leg, LegOutcome::Completed(whois_res))
371 }
372 }
373}
374
375fn grace_truncated_error(truncated: &str, winner: &str) -> String {
381 format!(
382 "{} did not respond within the {}s grace period after {} answered",
383 truncated,
384 PROTOCOL_GRACE_PERIOD.as_secs(),
385 winner
386 )
387}
388
389enum RdapOutcome {
395 Useful(RdapResponse),
396 NoData(RdapResponse),
397 Error(SeerError),
398 GraceTimeout,
401}
402
403pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
408#[serde(tag = "source", rename_all = "lowercase")]
409pub enum LookupResult {
410 Rdap {
411 data: Box<RdapResponse>,
412 #[serde(skip_serializing_if = "Option::is_none")]
413 whois_fallback: Option<WhoisResponse>,
414 },
415 Whois {
416 data: WhoisResponse,
417 rdap_error: Option<String>,
418 #[serde(skip_serializing_if = "Option::is_none")]
419 rdap_fallback: Option<Box<RdapResponse>>,
420 },
421 Available {
422 data: Box<AvailabilityResult>,
423 rdap_error: String,
424 whois_error: String,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
429 whois_data: Option<WhoisResponse>,
430 },
431}
432
433impl LookupResult {
434 pub fn domain_name(&self) -> Option<String> {
436 match self {
437 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
438 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
439 LookupResult::Available { data, .. } => Some(data.domain.clone()),
440 }
441 }
442
443 fn rdap_or_whois<T>(
446 &self,
447 rdap: impl FnOnce(&RdapResponse) -> Option<T>,
448 whois: impl FnOnce(&WhoisResponse) -> Option<T>,
449 ) -> Option<T> {
450 match self {
451 LookupResult::Rdap {
452 data,
453 whois_fallback,
454 } => rdap(data).or_else(|| whois_fallback.as_ref().and_then(whois)),
455 LookupResult::Whois { data, .. } => whois(data),
456 LookupResult::Available { .. } => None,
457 }
458 }
459
460 pub fn registrar(&self) -> Option<String> {
462 self.rdap_or_whois(RdapResponse::get_registrar, |w| w.registrar.clone())
463 }
464
465 pub fn organization(&self) -> Option<String> {
467 self.rdap_or_whois(RdapResponse::get_registrant_organization, |w| {
468 w.organization.clone()
469 })
470 }
471
472 pub fn creation_date(&self) -> Option<DateTime<Utc>> {
474 self.rdap_or_whois(RdapResponse::creation_date, |w| w.creation_date)
475 }
476
477 pub fn expiration_date(&self) -> Option<DateTime<Utc>> {
479 self.rdap_or_whois(RdapResponse::expiration_date, |w| w.expiration_date)
480 }
481
482 pub fn is_rdap(&self) -> bool {
484 matches!(self, LookupResult::Rdap { .. })
485 }
486
487 pub fn is_whois(&self) -> bool {
489 matches!(self, LookupResult::Whois { .. })
490 }
491
492 pub fn is_available(&self) -> bool {
494 matches!(self, LookupResult::Available { .. })
495 }
496
497 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
499 (self.expiration_date(), self.registrar())
500 }
501}
502
503fn truncate_on_char_boundary(s: &mut String, max: usize) {
510 if s.len() > max {
511 let mut end = max;
512 while end > 0 && !s.is_char_boundary(end) {
513 end -= 1;
514 }
515 s.truncate(end);
516 }
517}
518
519pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
525 const MAX_RAW: usize = 32 * 1024;
526 if whois.raw_response.len() > MAX_RAW {
527 truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
528 whois.raw_response.push_str("\n... [truncated]");
529 }
530}
531
532pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
536 match result {
537 LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
538 LookupResult::Rdap {
539 ref mut whois_fallback,
540 ..
541 } => {
542 if let Some(w) = whois_fallback {
543 trim_whois_raw(w);
544 }
545 }
546 LookupResult::Available {
547 ref mut whois_data, ..
548 } => {
549 if let Some(w) = whois_data {
550 trim_whois_raw(w);
551 }
552 }
553 }
554
555 result
556}
557
558#[derive(Debug, Clone, Default)]
559pub struct SmartLookup {
560 rdap_client: RdapClient,
561 whois_client: WhoisClient,
562 availability_checker: AvailabilityChecker,
563 dns_resolver: DnsResolver,
564}
565
566impl SmartLookup {
567 pub fn new() -> Self {
570 Self::default()
571 }
572
573 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
576 Self {
577 rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
578 whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
579 availability_checker: AvailabilityChecker::from_config(config),
580 dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
581 }
582 }
583
584 #[instrument(skip(self), fields(domain = %domain))]
589 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
590 self.lookup_with_progress(domain, None).await
591 }
592
593 #[instrument(skip(self, progress), fields(domain = %domain))]
599 pub async fn lookup_with_progress(
600 &self,
601 domain: &str,
602 progress: Option<LookupProgressCallback>,
603 ) -> Result<LookupResult> {
604 let normalized = crate::validation::normalize_domain(domain)?;
605
606 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
608 debug!(domain = %normalized, "Returning cached lookup result");
609 return Ok(cached);
610 }
611
612 let _guard = loop {
625 let existing: Arc<Notify>;
626 let notified = {
627 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
631 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
632 Some(n) => existing = n,
633 None => {
634 let n = Arc::new(Notify::new());
635 inflight.insert(normalized.clone(), Arc::downgrade(&n));
636 break InflightGuard {
637 key: normalized.clone(),
638 notify: n,
639 };
640 }
641 }
642 existing.notified()
652 };
653 tokio::pin!(notified);
654 notified.as_mut().enable();
656 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
657
658 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
661 return Ok(cached);
662 }
663
664 let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
668
669 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
670 return Ok(cached);
671 }
672 };
675
676 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
682 debug!(domain = %normalized, "Returning result cached by previous owner");
683 return Ok(cached);
684 }
685
686 let result = self.lookup_concurrent(&normalized, progress).await?;
687
688 let ttl = if is_degraded_result(&result) {
693 DEGRADED_LOOKUP_CACHE_TTL
694 } else {
695 LOOKUP_CACHE_TTL
696 };
697 LOOKUP_CACHE.insert_with_ttl(normalized.clone(), trim_raw_response(result.clone()), ttl);
698
699 Ok(result)
700 }
701
702 #[instrument(skip(self, progress), fields(domain = %domain))]
703 async fn lookup_concurrent(
704 &self,
705 domain: &str,
706 progress: Option<LookupProgressCallback>,
707 ) -> Result<LookupResult> {
708 #[cfg(test)]
709 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
710
711 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
712
713 if let Some(ref cb) = progress {
714 cb("Querying RDAP and WHOIS concurrently");
715 }
716
717 let rdap_fut = self.rdap_client.lookup_domain(domain);
718 let whois_fut = self.whois_client.lookup(domain);
719
720 let (rdap_leg, whois_leg) = race_with_grace(
728 rdap_fut,
729 whois_fut,
730 |r| matches!(r, Ok(data) if rdap_response_is_useful(data)),
731 whois_leg_has_data,
732 )
733 .await;
734
735 if matches!(whois_leg, LegOutcome::GraceTruncated) {
736 debug!("WHOIS did not finish within grace period after RDAP answered, proceeding with RDAP only");
737 }
738 if matches!(rdap_leg, LegOutcome::GraceTruncated) {
739 debug!("RDAP did not finish within grace period after WHOIS answered, proceeding with WHOIS only");
740 }
741
742 let rdap_outcome = match rdap_leg {
744 LegOutcome::Completed(Ok(data)) => {
745 if rdap_response_is_useful(&data) {
746 RdapOutcome::Useful(data)
747 } else {
748 RdapOutcome::NoData(data)
749 }
750 }
751 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
752 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
753 };
754
755 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
757 debug!("RDAP lookup successful");
758 let whois_fallback = match whois_leg {
759 LegOutcome::Completed(Ok(w)) => Some(w),
760 _ => None,
761 };
762 return Ok(LookupResult::Rdap {
763 data: Box::new(rdap_data),
764 whois_fallback,
765 });
766 }
767
768 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
779 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
780 RdapOutcome::Useful(_) => {
781 debug!("Unexpected RdapOutcome::Useful in fallback branch");
784 (String::from("RDAP ok"), None, None)
785 }
786 RdapOutcome::NoData(data) => (
787 "RDAP response incomplete".to_string(),
788 Some(Box::new(data)),
789 None,
790 ),
791 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
792 RdapOutcome::GraceTimeout => (grace_truncated_error("RDAP", "WHOIS"), None, None),
793 };
794
795 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
796 let availability_match = should_route_to_availability(
800 rdap_returned_200,
801 rdap_seer_error.as_ref(),
802 &whois_data,
803 );
804
805 if let Some((confidence, method)) = availability_match {
806 debug!(
807 domain = %domain,
808 confidence = %confidence,
809 "Reclassifying WHOIS as availability signal"
810 );
811 let details = match method {
815 "whois" => Some("WHOIS indicates domain is not registered".to_string()),
816 "rdap" => Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
817 _ => None,
818 };
819 let avail = AvailabilityResult {
820 details,
821 ..AvailabilityResult::new(domain, true, confidence, method)
822 };
823 let avail = self.availability_checker.guard_subdomain_claim(avail).await;
826 if let Some(ref cb) = progress {
827 cb(if avail.available {
828 "Domain appears unregistered"
829 } else {
830 "Name is below a registrable domain (checked its parent)"
831 });
832 }
833 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
834 }
835
836 let whois_is_thin = whois_data.is_thin();
847 if whois_is_thin && !rdap_returned_200 {
848 let whois_refuses = whois_data.indicates_registry_refusal();
849 let dns_presence = if whois_refuses {
850 DnsPresence::Unknown
852 } else {
853 self.dns_resolver.presence(domain).await
854 };
855 match classify_thin_fallback(
856 whois_is_thin,
857 rdap_returned_200,
858 whois_refuses,
859 dns_presence,
860 ) {
861 ThinFallback::Inconclusive => {
862 debug!(domain = %domain, "Thin WHOIS is a registry refusal/throttle; availability inconclusive (issue #45)");
863 if let Some(ref cb) = progress {
864 cb("Registry refused or throttled the query (availability inconclusive)");
865 }
866 let avail = AvailabilityResult::new(domain, false, "none", "inconclusive")
867 .with_details(crate::availability::REFUSED_DETAILS);
868 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
869 }
870 ThinFallback::Available => {
871 debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
872 let avail = AvailabilityResult::new(domain, true, "medium", "dns_nxdomain")
873 .with_details(crate::availability::THIN_NXDOMAIN_DETAILS);
874 let avail = self.availability_checker.guard_subdomain_claim(avail).await;
875 if let Some(ref cb) = progress {
876 cb(if avail.available {
877 "Domain appears unregistered (no DNS presence)"
878 } else {
879 "Name is below a registrable domain (checked its parent)"
880 });
881 }
882 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
883 }
884 ThinFallback::Registered => {
885 debug!(domain = %domain, "Thin/no-service WHOIS + DNS delegation, reporting registered");
886 if let Some(ref cb) = progress {
887 cb("Domain is registered (registry detail unavailable)");
888 }
889 let details = if whois_data.registry_unavailable() {
890 "Domain is registered (the apex is delegated in DNS). This TLD's \
891 registry provides no port-43 WHOIS data and RDAP was unavailable \
892 (rate-limited or unreachable); retry shortly for full RDAP detail."
893 } else {
894 "Domain is registered (the apex is delegated in DNS). Registry detail \
895 was unavailable (RDAP rate-limited or unreachable and WHOIS returned \
896 no data); retry shortly for full detail."
897 };
898 let avail = AvailabilityResult::new(domain, false, "high", "dns_present")
899 .with_details(details);
900 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
901 }
902 ThinFallback::UseWhois => {}
903 }
904 }
905 debug!("Using WHOIS result (RDAP not useful)");
906 if let Some(ref cb) = progress {
907 cb("RDAP not available (using WHOIS)");
908 }
909 return Ok(LookupResult::Whois {
910 data: whois_data,
911 rdap_error: Some(sanitize_error_for_public(&rdap_error_str)),
912 rdap_fallback: rdap_fallback_data,
913 });
914 }
915
916 let whois_error_str = match &whois_leg {
921 LegOutcome::Completed(Err(e)) => e.to_string(),
922 LegOutcome::Completed(Ok(_)) => {
923 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
925 "WHOIS returned but was not used".to_string()
926 }
927 LegOutcome::GraceTruncated => grace_truncated_error("WHOIS", "RDAP"),
931 };
932
933 let prior_rdap = if rdap_returned_200 {
938 match rdap_fallback_data {
939 Some(resp) => PriorRdap::Response(resp),
941 None => PriorRdap::Missing,
942 }
943 } else if let Some(e) = rdap_seer_error {
944 PriorRdap::Failed(e)
945 } else {
946 PriorRdap::Missing
947 };
948 let prior_whois = match whois_leg {
949 LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
950 LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
952 LegOutcome::GraceTruncated => PriorWhois::Missing,
953 };
954
955 self.availability_fallback(
956 domain,
957 prior_rdap,
958 prior_whois,
959 rdap_error_str,
960 whois_error_str,
961 progress,
962 )
963 .await
964 }
965
966 async fn availability_fallback(
967 &self,
968 domain: &str,
969 prior_rdap: PriorRdap,
970 prior_whois: PriorWhois,
971 rdap_error: String,
972 whois_error: String,
973 progress: Option<LookupProgressCallback>,
974 ) -> Result<LookupResult> {
975 if let Some(ref cb) = progress {
976 cb("RDAP and WHOIS unavailable (checking availability)");
977 }
978 warn!(
979 domain = %domain,
980 rdap_error = %rdap_error,
981 whois_error = %whois_error,
982 "Both RDAP and WHOIS failed, falling back to availability check"
983 );
984
985 match self
986 .availability_checker
987 .check_with_prior(domain, prior_rdap, prior_whois)
988 .await
989 {
990 Ok(avail) => Ok(LookupResult::Available {
991 data: Box::new(avail),
992 rdap_error: sanitize_error_for_public(&rdap_error),
993 whois_error: sanitize_error_for_public(&whois_error),
994 whois_data: None,
995 }),
996 Err(avail_err) => {
997 let tld = get_tld(domain).unwrap_or("unknown");
998 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
999 format!("https://www.iana.org/domains/root/db/{}.html", tld)
1000 });
1001 Err(SeerError::LookupFailed {
1002 domain: domain.to_string(),
1003 details: format!(
1004 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
1005 rdap_error, whois_error, avail_err
1006 ),
1007 registry_url,
1008 })
1009 }
1010 }
1011 }
1012
1013 pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
1018 self.dns_resolver.presence(domain).await
1019 }
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025
1026 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
1038
1039 #[test]
1040 fn test_lookup_result_domain_name_whois() {
1041 let result = LookupResult::Whois {
1042 data: WhoisResponse {
1043 domain: "example.com".to_string(),
1044 registrar: Some("Test Registrar".to_string()),
1045 whois_server: "whois.example.com".to_string(),
1046 ..Default::default()
1047 },
1048 rdap_error: None,
1049 rdap_fallback: None,
1050 };
1051
1052 assert_eq!(result.domain_name(), Some("example.com".to_string()));
1053 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
1054 assert!(result.is_whois());
1055 assert!(!result.is_rdap());
1056 assert!(!result.is_available());
1057 }
1058
1059 #[test]
1060 fn test_lookup_result_serialization() {
1061 let result = LookupResult::Whois {
1062 data: WhoisResponse {
1063 domain: "test.com".to_string(),
1064 ..Default::default()
1065 },
1066 rdap_error: Some("RDAP failed".to_string()),
1067 rdap_fallback: None,
1068 };
1069
1070 let json = serde_json::to_string(&result).unwrap();
1071 assert!(json.contains("\"source\":\"whois\""));
1072 assert!(json.contains("RDAP failed"));
1073 }
1074
1075 #[test]
1076 fn test_lookup_result_available_serialization() {
1077 let result = LookupResult::Available {
1078 data: Box::new(
1079 AvailabilityResult::new("test123.xyz", true, "medium", "whois_error")
1080 .with_details("WHOIS server indicates no matching records"),
1081 ),
1082 rdap_error: "RDAP failed".to_string(),
1083 whois_error: "WHOIS failed".to_string(),
1084 whois_data: None,
1085 };
1086
1087 let json = serde_json::to_string(&result).unwrap();
1088 assert!(json.contains("\"source\":\"available\""));
1089 assert!(json.contains("\"available\":true"));
1090 assert!(json.contains("test123.xyz"));
1091
1092 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1093 assert!(result.is_available());
1094 assert!(!result.is_rdap());
1095 assert!(!result.is_whois());
1096 assert!(result.registrar().is_none());
1097 assert_eq!(result.expiration_info(), (None, None));
1098 }
1099
1100 #[test]
1103 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1104 const MAX_RAW: usize = 32 * 1024;
1105 let mut s = "a".repeat(MAX_RAW - 1);
1109 s.push('€'); s.push_str(&"b".repeat(100));
1111 assert!(!s.is_char_boundary(MAX_RAW));
1112
1113 truncate_on_char_boundary(&mut s, MAX_RAW);
1115 assert!(s.len() <= MAX_RAW);
1116 assert_eq!(s.len(), MAX_RAW - 1);
1119 }
1120
1121 #[test]
1122 fn trim_raw_response_truncates_multibyte_whois_without_panic() {
1123 const MAX_RAW: usize = 32 * 1024;
1124 let mut raw = "a".repeat(MAX_RAW - 1);
1125 raw.push('€');
1126 raw.push_str(&"b".repeat(1000));
1127
1128 let mut w = empty_whois("example.com");
1129 w.raw_response = raw;
1130 let result = trim_raw_response(LookupResult::Whois {
1131 data: w,
1132 rdap_error: None,
1133 rdap_fallback: None,
1134 });
1135 if let LookupResult::Whois { data, .. } = result {
1136 assert!(data.raw_response.ends_with("[truncated]"));
1137 } else {
1138 panic!("expected Whois variant");
1139 }
1140 }
1141
1142 #[test]
1145 fn test_sanitize_strips_ipv4() {
1146 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1147 let sanitized = sanitize_error_for_public(msg);
1148 assert!(
1149 !sanitized.contains("10.0.0.1"),
1150 "IPv4 should be stripped, got: {}",
1151 sanitized
1152 );
1153 assert!(sanitized.contains("[ip-redacted]"));
1154 }
1155
1156 #[test]
1157 fn test_sanitize_strips_multiple_ipv4() {
1158 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1159 let sanitized = sanitize_error_for_public(msg);
1160 assert!(!sanitized.contains("192.168.1.1"));
1161 assert!(!sanitized.contains("127.0.0.1"));
1162 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1164 }
1165
1166 #[test]
1167 fn test_sanitize_strips_ipv6() {
1168 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1169 let sanitized = sanitize_error_for_public(msg);
1170 assert!(!sanitized.contains("fe80::1"));
1171 assert!(sanitized.contains("[ip-redacted]"));
1172 }
1173
1174 #[test]
1175 fn sanitize_leaves_mac_address_like_tokens_alone() {
1176 let msg = "error code af:ba:12 at line 5";
1177 let out = sanitize_error_for_public(msg);
1178 assert!(
1179 out.contains("af:ba:12"),
1180 "MAC fragment should not be stripped: {}",
1181 out
1182 );
1183 }
1184
1185 #[test]
1186 fn sanitize_strips_real_ipv6() {
1187 let msg = "cannot reach 2001:db8::1 — timeout";
1188 let out = sanitize_error_for_public(msg);
1189 assert!(!out.contains("2001:db8::1"));
1190 assert!(out.contains("[ip-redacted]"));
1191 }
1192
1193 #[test]
1194 fn sanitize_strips_fe80_link_local() {
1195 let msg = "peer at fe80::1 unreachable";
1196 let out = sanitize_error_for_public(msg);
1197 assert!(out.contains("[ip-redacted]"));
1198 }
1199
1200 #[test]
1201 fn test_sanitize_truncates_long_message() {
1202 let long = "a".repeat(500);
1204 let sanitized = sanitize_error_for_public(&long);
1205 let char_count = sanitized.chars().count();
1207 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1208 assert!(sanitized.ends_with('…'));
1209 }
1210
1211 #[test]
1212 fn test_sanitize_preserves_short_messages() {
1213 let msg = "RDAP timed out after 15s";
1214 let sanitized = sanitize_error_for_public(msg);
1215 assert_eq!(sanitized, msg);
1216 }
1217
1218 #[test]
1221 fn test_is_rdap_response_useful_detects_no_data() {
1222 use crate::rdap::RdapResponse;
1223 let resp = RdapResponse {
1227 ldh_name: Some("example.com".to_string()),
1228 ..Default::default()
1229 };
1230 assert!(
1231 !rdap_response_is_useful(&resp),
1232 "Response with only a name should be classified as NoData"
1233 );
1234
1235 let useful = RdapResponse {
1237 ldh_name: Some("example.com".to_string()),
1238 status: vec!["active".to_string()],
1239 ..Default::default()
1240 };
1241 assert!(rdap_response_is_useful(&useful));
1242 }
1243
1244 #[allow(clippy::await_holding_lock)]
1255 #[tokio::test]
1256 async fn test_inflight_coalescing_map() {
1257 let _serial = INFLIGHT_TEST_SERIAL
1262 .lock()
1263 .unwrap_or_else(|p| p.into_inner());
1264 let domain = unique_test_key("__coalesce");
1273
1274 {
1276 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1277 m.remove(&domain);
1278 }
1279
1280 let owner_notify = Arc::new(Notify::new());
1282 {
1283 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1284 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1285 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1286 }
1287
1288 let waiter = {
1290 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1291 m.get(&domain)
1292 .and_then(|w| w.upgrade())
1293 .expect("Second caller must observe in-flight entry")
1294 };
1295
1296 let waiter_clone = waiter.clone();
1298 let handle = tokio::spawn(async move {
1299 waiter_clone.notified().await;
1300 });
1301
1302 tokio::time::sleep(Duration::from_millis(20)).await;
1304 {
1305 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1306 m.remove(&domain);
1307 }
1308 owner_notify.notify_waiters();
1309
1310 tokio::time::timeout(Duration::from_secs(1), handle)
1312 .await
1313 .expect("waiter must unblock after notify")
1314 .expect("waiter task joined cleanly");
1315
1316 drop(owner_notify);
1318 drop(waiter);
1319 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1320 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1321 }
1322
1323 #[allow(clippy::await_holding_lock)]
1341 #[tokio::test]
1342 async fn waiters_coalesce_on_inflight_lookup_and_read_owners_cache() {
1343 use std::sync::atomic::Ordering;
1344
1345 let _serial = INFLIGHT_TEST_SERIAL
1346 .lock()
1347 .unwrap_or_else(|p| p.into_inner());
1348
1349 let raw = unique_test_key("coalesce-waiter");
1350 let normalized = crate::validation::normalize_domain(&raw).expect("test key normalizes");
1351 LOOKUP_CACHE.remove(&normalized);
1352
1353 let owner_notify = Arc::new(Notify::new());
1355 {
1356 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1357 m.insert(normalized.clone(), Arc::downgrade(&owner_notify));
1358 }
1359
1360 let calls_before = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1361
1362 let lookup = SmartLookup::new();
1364 let spawn_waiter = |l: SmartLookup, d: String| {
1365 tokio::spawn(async move { l.lookup_with_progress(&d, None).await })
1366 };
1367 let h1 = spawn_waiter(lookup.clone(), raw.clone());
1368 let h2 = spawn_waiter(lookup, raw);
1369
1370 tokio::time::sleep(Duration::from_millis(50)).await;
1374
1375 let canned = LookupResult::Whois {
1378 data: empty_whois(&normalized),
1379 rdap_error: None,
1380 rdap_fallback: None,
1381 };
1382 LOOKUP_CACHE.insert(normalized.clone(), canned);
1383 {
1384 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1385 m.remove(&normalized);
1386 }
1387 owner_notify.notify_waiters();
1388
1389 for handle in [h1, h2] {
1392 let result = tokio::time::timeout(Duration::from_secs(5), handle)
1393 .await
1394 .expect("waiter must wake via notify, not the bounded-wait timeout")
1395 .expect("waiter task joined cleanly")
1396 .expect("waiter must read the owner's cached result");
1397 assert!(result.is_whois());
1398 assert_eq!(result.domain_name(), Some(normalized.clone()));
1399 }
1400
1401 let calls_after = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1403 assert_eq!(
1404 calls_before, calls_after,
1405 "coalesced waiters must not start a second network race"
1406 );
1407
1408 LOOKUP_CACHE.remove(&normalized);
1409 }
1410
1411 fn unique_test_key(prefix: &str) -> String {
1417 use std::sync::atomic::{AtomicU64, Ordering};
1418 use std::time::{SystemTime, UNIX_EPOCH};
1419 static COUNTER: AtomicU64 = AtomicU64::new(0);
1420 let nanos = SystemTime::now()
1421 .duration_since(UNIX_EPOCH)
1422 .map(|d| d.as_nanos())
1423 .unwrap_or(0);
1424 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1425 format!("{}_{}_{}.example.", prefix, nanos, n)
1426 }
1427
1428 #[test]
1434 fn available_with_whois_sanitizes_rdap_error() {
1435 let avail = AvailabilityResult::new("unreg.test", false, "none", "inconclusive");
1438 let result = available_with_whois(
1439 avail,
1440 "RDAP URL resolves to reserved IP 10.0.0.1",
1441 empty_whois("unreg.test"),
1442 );
1443 let LookupResult::Available {
1444 rdap_error,
1445 whois_error,
1446 whois_data,
1447 ..
1448 } = result
1449 else {
1450 panic!("expected Available variant");
1451 };
1452 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1453 assert!(rdap_error.contains("[ip-redacted]"));
1454 assert!(whois_error.is_empty());
1455 assert!(whois_data.is_some());
1456 }
1457
1458 #[tokio::test]
1459 async fn availability_fallback_sanitizes_both_error_fields() {
1460 let lookup = SmartLookup::new();
1463 let prior = RdapResponse {
1464 ldh_name: Some("example.com".to_string()),
1465 status: vec!["active".to_string()],
1466 ..Default::default()
1467 };
1468 let result = lookup
1469 .availability_fallback(
1470 "example.com",
1471 PriorRdap::Response(Box::new(prior)),
1472 PriorWhois::Missing,
1473 "RDAP URL resolves to reserved IP 10.0.0.1".to_string(),
1474 "connection refused at 192.168.0.5 and fe80::1".to_string(),
1475 None,
1476 )
1477 .await
1478 .expect("prior-response fallback must not error");
1479 let LookupResult::Available {
1480 rdap_error,
1481 whois_error,
1482 ..
1483 } = result
1484 else {
1485 panic!("expected Available variant");
1486 };
1487 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1488 assert!(!whois_error.contains("192.168.0.5"), "{whois_error}");
1489 assert!(!whois_error.contains("fe80::1"), "{whois_error}");
1490 assert!(rdap_error.contains("[ip-redacted]"));
1491 assert!(whois_error.contains("[ip-redacted]"));
1492 }
1493
1494 fn available_via(method: &str, available: bool, confidence: &str) -> LookupResult {
1497 LookupResult::Available {
1498 data: Box::new(AvailabilityResult::new(
1499 "example.test",
1500 available,
1501 confidence,
1502 method,
1503 )),
1504 rdap_error: String::new(),
1505 whois_error: String::new(),
1506 whois_data: None,
1507 }
1508 }
1509
1510 #[test]
1511 fn degraded_results_are_identified_for_short_caching() {
1512 assert!(is_degraded_result(&available_via(
1514 "inconclusive",
1515 false,
1516 "none"
1517 )));
1518 assert!(is_degraded_result(&available_via(
1519 "dns_present",
1520 false,
1521 "high"
1522 )));
1523 assert!(is_degraded_result(&available_via(
1524 "dns_nxdomain",
1525 true,
1526 "medium"
1527 )));
1528 assert!(is_degraded_result(&available_via(
1529 "whois_error",
1530 true,
1531 "medium"
1532 )));
1533 assert!(!is_degraded_result(&available_via("rdap", true, "high")));
1535 assert!(!is_degraded_result(&available_via("whois", true, "high")));
1536 assert!(!is_degraded_result(&LookupResult::Whois {
1537 data: empty_whois("example.test"),
1538 rdap_error: None,
1539 rdap_fallback: None,
1540 }));
1541 const { assert!(DEGRADED_LOOKUP_CACHE_TTL.as_secs() < LOOKUP_CACHE_TTL.as_secs()) };
1542 }
1543
1544 #[test]
1545 fn rdap_error_is_404_matches_standard_404() {
1546 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1547 assert!(rdap_error_is_404(&e));
1548 }
1549
1550 #[test]
1551 fn rdap_error_is_404_matches_without_reason_phrase() {
1552 let e = SeerError::RdapError("query failed with status 404".to_string());
1553 assert!(rdap_error_is_404(&e));
1554 }
1555
1556 #[test]
1557 fn rdap_error_is_404_rejects_other_statuses() {
1558 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1559 assert!(!rdap_error_is_404(&e));
1560 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1561 assert!(!rdap_error_is_404(&e));
1562 }
1563
1564 #[test]
1565 fn rdap_error_is_404_rejects_non_http_errors() {
1566 let e = SeerError::RdapError("connection timeout".to_string());
1567 assert!(!rdap_error_is_404(&e));
1568 let e = SeerError::Timeout("rdap".to_string());
1569 assert!(!rdap_error_is_404(&e));
1570 }
1571
1572 #[test]
1573 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1574 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1576 assert!(!rdap_error_is_404(&e));
1577 }
1578
1579 fn empty_whois(domain: &str) -> WhoisResponse {
1582 WhoisResponse {
1583 domain: domain.to_string(),
1584 ..Default::default()
1585 }
1586 }
1587
1588 #[test]
1589 fn whois_response_is_thin_when_all_key_fields_missing() {
1590 let w = empty_whois("example.com");
1591 assert!(w.is_thin());
1592 }
1593
1594 #[test]
1595 fn whois_response_is_not_thin_when_registrar_present() {
1596 let mut w = empty_whois("example.com");
1597 w.registrar = Some("Test Registrar".to_string());
1598 assert!(!w.is_thin());
1599 }
1600
1601 #[test]
1602 fn whois_response_is_not_thin_when_creation_date_present() {
1603 let mut w = empty_whois("example.com");
1604 w.creation_date = Some(Utc::now());
1605 assert!(!w.is_thin());
1606 }
1607
1608 #[test]
1609 fn whois_response_is_not_thin_when_expiration_date_present() {
1610 let mut w = empty_whois("example.com");
1611 w.expiration_date = Some(Utc::now());
1612 assert!(!w.is_thin());
1613 }
1614
1615 fn denic_whois() -> WhoisResponse {
1618 let mut w = empty_whois("example.de");
1619 w.nameservers = vec!["ns1.example.net".to_string()];
1620 w.status = vec!["active".to_string()];
1621 w.updated_date = Some(Utc::now());
1622 w.raw_response = "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n".into();
1623 w
1624 }
1625
1626 #[test]
1627 fn whois_response_with_nameservers_is_not_thin() {
1628 let mut w = empty_whois("example.com");
1629 w.nameservers = vec!["ns1.example.net".to_string()];
1630 assert!(!w.is_thin());
1631 }
1632
1633 #[test]
1634 fn denic_whois_stays_on_the_whois_path() {
1635 let w = denic_whois();
1639 let bootstrap_miss =
1640 SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
1641 assert!(!w.is_thin());
1642 assert!(whois_leg_has_data(&Ok(w.clone())));
1643 assert_eq!(
1644 should_route_to_availability(false, Some(&bootstrap_miss), &w),
1645 None
1646 );
1647 assert_eq!(
1648 classify_thin_fallback(
1649 w.is_thin(),
1650 false,
1651 w.indicates_registry_refusal(),
1652 DnsPresence::Present,
1653 ),
1654 ThinFallback::UseWhois
1655 );
1656 }
1657
1658 use crate::rdap::RdapResponse;
1661
1662 #[allow(dead_code)]
1663 fn make_empty_rdap_response() -> RdapResponse {
1664 serde_json::from_value(serde_json::json!({
1665 "objectClassName": "domain",
1666 }))
1667 .expect("valid minimal RDAP response")
1668 }
1669
1670 #[test]
1671 fn classify_whois_leg_case_a_high_confidence() {
1672 let mut w = empty_whois("zaccodes.com");
1673 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1674 assert!(w.is_available());
1675 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1676 let (verdict, method) =
1677 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1678 assert_eq!(verdict, "high");
1679 assert_eq!(method, "whois");
1680 }
1681
1682 #[test]
1683 fn classify_whois_leg_case_b_matches_decide_fallback() {
1684 let w = empty_whois("example.xyz");
1691 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1692 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1693 let (verdict, method) =
1694 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1695 assert_eq!(verdict, "high");
1696 assert_eq!(method, "rdap");
1697 }
1698
1699 #[test]
1700 fn classify_whois_leg_rejects_thin_whois_without_404() {
1701 let w = empty_whois("example.xyz");
1702 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1703 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1704 }
1705
1706 #[test]
1707 fn classify_whois_leg_rejects_whois_with_real_data() {
1708 let mut w = empty_whois("legacy.tld");
1709 w.registrar = Some("Legacy Registry".to_string());
1710 w.creation_date = Some(Utc::now());
1711 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1712 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1713 }
1714
1715 #[test]
1716 fn classify_whois_leg_case_a_wins_over_case_b() {
1717 let mut w = empty_whois("example.com");
1718 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1719 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1720 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1721 assert_eq!(verdict, "high");
1722 }
1723
1724 #[test]
1732 fn rdap_200_vetoes_whois_no_match() {
1733 let mut w = empty_whois("freshly-registered.com");
1734 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1735 assert!(
1737 should_route_to_availability(true, None, &w).is_none(),
1738 "RDAP 200 must veto WHOIS-only availability claim",
1739 );
1740 }
1741
1742 #[test]
1743 fn rdap_200_vetoes_even_with_thin_whois() {
1744 let w = empty_whois("freshly-registered.com");
1745 assert!(
1747 should_route_to_availability(true, None, &w).is_none(),
1748 "RDAP 200 must veto even when WHOIS is thin",
1749 );
1750 }
1751
1752 #[test]
1753 fn rdap_404_with_whois_no_match_routes_to_available() {
1754 let mut w = empty_whois("genuinely-free.com");
1755 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1756 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1757 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1758 assert_eq!(result, Some(("high", "whois")));
1759 }
1760
1761 #[test]
1762 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1763 let mut w = empty_whois("genuinely-free.com");
1764 w.raw_response = "Domain not found".to_string();
1765 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1768 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1769 assert_eq!(result, Some(("high", "whois")));
1770 }
1771
1772 #[test]
1773 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1774 let mut w = empty_whois("genuinely-free.com");
1776 w.raw_response = "No match".to_string();
1777 let result = should_route_to_availability(false, None, &w);
1778 assert_eq!(result, Some(("high", "whois")));
1779 }
1780
1781 #[test]
1782 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1783 let mut w = empty_whois("registered.com");
1784 w.registrar = Some("Example Registrar Ltd".to_string());
1785 assert!(should_route_to_availability(false, None, &w).is_none());
1789 }
1790
1791 #[test]
1794 fn thin_fallback_refusal_is_inconclusive_regardless_of_dns() {
1795 for dns in [
1800 DnsPresence::Absent,
1801 DnsPresence::Present,
1802 DnsPresence::Unknown,
1803 ] {
1804 assert_eq!(
1805 classify_thin_fallback(true, false, true, dns),
1806 ThinFallback::Inconclusive
1807 );
1808 }
1809 }
1810
1811 #[test]
1812 fn thin_fallback_nxdomain_is_available() {
1813 assert_eq!(
1814 classify_thin_fallback(true, false, false, DnsPresence::Absent),
1815 ThinFallback::Available
1816 );
1817 }
1818
1819 #[test]
1820 fn thin_fallback_delegated_is_registered() {
1821 assert_eq!(
1825 classify_thin_fallback(true, false, false, DnsPresence::Present),
1826 ThinFallback::Registered
1827 );
1828 }
1829
1830 #[test]
1831 fn thin_fallback_unknown_dns_uses_whois() {
1832 assert_eq!(
1834 classify_thin_fallback(true, false, false, DnsPresence::Unknown),
1835 ThinFallback::UseWhois
1836 );
1837 }
1838
1839 #[test]
1840 fn thin_fallback_requires_thin_whois() {
1841 assert_eq!(
1843 classify_thin_fallback(false, false, false, DnsPresence::Absent),
1844 ThinFallback::UseWhois
1845 );
1846 }
1847
1848 #[test]
1849 fn thin_fallback_vetoed_by_rdap_200() {
1850 assert_eq!(
1853 classify_thin_fallback(true, true, false, DnsPresence::Absent),
1854 ThinFallback::UseWhois
1855 );
1856 assert_eq!(
1857 classify_thin_fallback(true, true, true, DnsPresence::Present),
1858 ThinFallback::UseWhois
1859 );
1860 }
1861
1862 async fn leg<T>(secs: u64, value: T) -> T {
1874 tokio::time::sleep(Duration::from_secs(secs)).await;
1875 value
1876 }
1877
1878 #[tokio::test(start_paused = true)]
1879 async fn race_awaits_whois_fully_when_rdap_errors_first() {
1880 let rdap = leg(0, Err::<&str, &str>("no RDAP server for example.ru"));
1881 let whois = leg(20, Ok::<&str, &str>("whois data"));
1883 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1884 assert!(matches!(r, LegOutcome::Completed(Err(_))));
1885 assert!(
1886 matches!(w, LegOutcome::Completed(Ok("whois data"))),
1887 "WHOIS must not be grace-truncated behind an RDAP failure"
1888 );
1889 }
1890
1891 #[tokio::test(start_paused = true)]
1892 async fn race_awaits_rdap_fully_when_whois_errors_first() {
1893 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1894 let whois = leg(0, Err::<&str, &str>("connection refused"));
1895 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1896 assert!(
1897 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1898 "RDAP must not be grace-truncated behind a WHOIS failure"
1899 );
1900 assert!(matches!(w, LegOutcome::Completed(Err(_))));
1901 }
1902
1903 #[tokio::test(start_paused = true)]
1904 async fn race_truncates_whois_when_rdap_answers_with_data() {
1905 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1906 let whois = leg(20, Ok::<&str, &str>("whois data"));
1907 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1908 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1909 assert!(
1910 matches!(w, LegOutcome::GraceTruncated),
1911 "a data-bearing RDAP winner only owes WHOIS the grace period"
1912 );
1913 }
1914
1915 #[tokio::test(start_paused = true)]
1916 async fn race_truncates_rdap_when_whois_answers_with_data() {
1917 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1918 let whois = leg(0, Ok::<&str, &str>("whois data"));
1919 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1920 assert!(matches!(r, LegOutcome::GraceTruncated));
1921 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1922 }
1923
1924 #[tokio::test(start_paused = true)]
1925 async fn race_loser_inside_grace_period_still_completes() {
1926 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1927 let whois = leg(2, Ok::<&str, &str>("whois data"));
1929 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1930 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1931 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1932 }
1933
1934 #[tokio::test(start_paused = true)]
1935 async fn race_awaits_whois_fully_when_rdap_wins_with_unusable_data() {
1936 let rdap = leg(0, Ok::<&str, &str>("thin"));
1939 let whois = leg(20, Ok::<&str, &str>("whois data"));
1940 let (r, w) = race_with_grace(
1941 rdap,
1942 whois,
1943 |r| matches!(r, Ok(s) if *s == "useful"),
1944 |w| w.is_ok(),
1945 )
1946 .await;
1947 assert!(matches!(r, LegOutcome::Completed(Ok("thin"))));
1948 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1949 }
1950
1951 #[tokio::test(start_paused = true)]
1952 async fn race_awaits_rdap_fully_when_whois_wins_with_thin_body() {
1953 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1961 let whois = leg(0, Ok::<_, SeerError>(empty_whois("zac.email")));
1962 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
1963 assert!(
1964 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1965 "RDAP must not be grace-truncated behind a thin WHOIS answer"
1966 );
1967 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1968 }
1969
1970 #[tokio::test(start_paused = true)]
1971 async fn race_truncates_rdap_when_whois_wins_with_real_data() {
1972 let mut whois_data = empty_whois("example.com");
1975 whois_data.registrar = Some("Mock Registrar".to_string());
1976 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1977 let whois = leg(0, Ok::<_, SeerError>(whois_data));
1978 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
1979 assert!(
1980 matches!(r, LegOutcome::GraceTruncated),
1981 "a data-bearing WHOIS winner only owes RDAP the grace period"
1982 );
1983 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1984 }
1985
1986 #[test]
1989 fn whois_leg_has_data_rejects_thin_ok_and_errors() {
1990 assert!(!whois_leg_has_data(&Ok(empty_whois("example.email"))));
1994 let mut no_match = empty_whois("example.com");
1995 no_match.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1996 assert!(!whois_leg_has_data(&Ok(no_match)));
1997 assert!(!whois_leg_has_data(&Err(SeerError::WhoisError(
1998 "connection refused".to_string()
1999 ))));
2000 }
2001
2002 #[test]
2003 fn whois_leg_has_data_accepts_registration_data() {
2004 let mut w = empty_whois("example.com");
2007 w.registrar = Some("Mock Registrar".to_string());
2008 assert!(whois_leg_has_data(&Ok(w)));
2009 let mut w = empty_whois("example.com");
2010 w.expiration_date = Some(Utc::now());
2011 assert!(whois_leg_has_data(&Ok(w)));
2012 }
2013
2014 #[test]
2017 fn grace_truncated_error_says_answered_not_won() {
2018 let msg = grace_truncated_error("WHOIS", "RDAP");
2022 assert_eq!(
2023 msg,
2024 format!(
2025 "WHOIS did not respond within the {}s grace period after RDAP answered",
2026 PROTOCOL_GRACE_PERIOD.as_secs()
2027 )
2028 );
2029 assert!(!msg.contains("won"));
2030 }
2031
2032 #[test]
2033 fn grace_truncated_error_is_symmetric() {
2034 let msg = grace_truncated_error("RDAP", "WHOIS");
2035 assert!(msg.starts_with("RDAP did not respond"));
2036 assert!(msg.ends_with("after WHOIS answered"));
2037 }
2038
2039 #[test]
2049 fn lookup_inflight_recovers_from_poisoned_mutex() {
2050 use std::panic::{catch_unwind, AssertUnwindSafe};
2051
2052 let _serial = INFLIGHT_TEST_SERIAL
2054 .lock()
2055 .unwrap_or_else(|p| p.into_inner());
2056
2057 let _ = catch_unwind(AssertUnwindSafe(|| {
2059 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
2060 panic!("poisoning LOOKUP_INFLIGHT for test");
2061 }));
2062
2063 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2067 let canary = unique_test_key("__poison_recovery");
2069 guard.insert(canary.clone(), Weak::new());
2070 assert!(guard.contains_key(&canary));
2071 guard.remove(&canary);
2072 }
2073
2074 #[test]
2077 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
2078 use std::panic::{catch_unwind, AssertUnwindSafe};
2079
2080 let _serial = INFLIGHT_TEST_SERIAL
2086 .lock()
2087 .unwrap_or_else(|p| p.into_inner());
2088
2089 let key = unique_test_key("__drop_poison");
2094 let notify = Arc::new(Notify::new());
2095 {
2096 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2097 map.insert(key.clone(), Arc::downgrade(¬ify));
2098 }
2099 let guard = InflightGuard {
2100 key: key.clone(),
2101 notify: notify.clone(),
2102 };
2103
2104 let _ = catch_unwind(AssertUnwindSafe(|| {
2106 let _g = LOOKUP_INFLIGHT.lock().unwrap();
2107 panic!("poisoning LOOKUP_INFLIGHT for drop test");
2108 }));
2109
2110 drop(guard);
2113
2114 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2115 assert!(
2116 !map.contains_key(&key),
2117 "poisoned-mutex drop path should still remove the in-flight entry"
2118 );
2119 }
2120}