1use std::collections::HashMap;
2use std::net::Ipv6Addr;
3use std::str::FromStr;
4use std::sync::{Arc, Mutex, Weak};
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use once_cell::sync::Lazy;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use tokio::sync::Notify;
12use tracing::{debug, instrument, warn};
13
14use tokio::time::timeout as tokio_timeout;
15
16use crate::availability::{AvailabilityChecker, AvailabilityResult};
17use crate::cache::TtlCache;
18use crate::dns::{DnsPresence, DnsResolver};
19use crate::error::{Result, SeerError};
20use crate::rdap::{rdap_error_is_404, RdapClient, RdapResponse};
21use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
22
23const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
25
26const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
30
31const MAX_PUBLIC_ERROR_LEN: usize = 256;
33
34const DEFAULT_INFLIGHT_WAIT: Duration = Duration::from_secs(30);
41
42static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
44 Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
45
46static LOOKUP_INFLIGHT: Lazy<Mutex<HashMap<String, Weak<Notify>>>> =
50 Lazy::new(|| Mutex::new(HashMap::new()));
51
52static IPV4_RE: Lazy<Regex> =
54 Lazy::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("IPV4_RE is a valid regex"));
55
56static IPV6_CANDIDATE_RE: Lazy<Regex> = Lazy::new(|| {
62 Regex::new(r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b")
63 .expect("IPV6_CANDIDATE_RE is a valid regex")
64});
65
66fn strip_ipv6(msg: &str) -> String {
69 IPV6_CANDIDATE_RE
70 .replace_all(msg, |caps: ®ex::Captures| {
71 let candidate = &caps[0];
72 if Ipv6Addr::from_str(candidate).is_ok() {
73 "[ip-redacted]".to_string()
74 } else {
75 candidate.to_string()
76 }
77 })
78 .into_owned()
79}
80
81#[cfg(test)]
85static LOOKUP_CONCURRENT_CALLS: Lazy<std::sync::atomic::AtomicUsize> =
86 Lazy::new(|| std::sync::atomic::AtomicUsize::new(0));
87
88fn whois_response_is_thin(w: &WhoisResponse) -> bool {
96 w.registrar.is_none() && w.creation_date.is_none() && w.expiration_date.is_none()
97}
98
99fn classify_whois_leg(
106 w: &WhoisResponse,
107 rdap_err: &SeerError,
108) -> Option<(&'static str, &'static str)> {
109 if w.is_available() {
110 return Some(("high", "whois"));
111 }
112 if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
113 return Some(("medium", "whois_thin_response"));
114 }
115 None
116}
117
118fn should_route_to_availability(
125 rdap_returned_200: bool,
126 rdap_seer_error: Option<&SeerError>,
127 whois_data: &WhoisResponse,
128) -> Option<(&'static str, &'static str)> {
129 if rdap_returned_200 {
130 return None;
131 }
132 if whois_data.is_available() {
138 return Some(("high", "whois"));
139 }
140 rdap_seer_error.and_then(|e| {
141 classify_whois_leg(whois_data, e)
146 })
147}
148
149fn nxdomain_confirms_available(is_thin: bool, rdap_returned_200: bool, dns: DnsPresence) -> bool {
159 is_thin && !rdap_returned_200 && matches!(dns, DnsPresence::Absent)
160}
161
162fn dns_present_confirms_registered(
177 is_thin: bool,
178 rdap_returned_200: bool,
179 dns: DnsPresence,
180) -> bool {
181 is_thin && !rdap_returned_200 && matches!(dns, DnsPresence::Present)
182}
183
184fn sanitize_error_for_public(msg: &str) -> String {
190 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
191 let s = strip_ipv6(&s);
192 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
193 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
194 trunc.push('…');
195 trunc
196 } else {
197 s
198 }
199}
200
201struct InflightGuard {
214 key: String,
215 notify: Arc<Notify>,
216}
217
218impl Drop for InflightGuard {
219 fn drop(&mut self) {
220 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
233 inflight.remove(&self.key);
234 drop(inflight);
235 self.notify.notify_waiters();
236 }
237}
238
239enum RdapOutcome {
245 Useful(RdapResponse),
246 NoData(RdapResponse),
247 Error(SeerError),
248 GraceTimeout,
251}
252
253pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258#[serde(tag = "source", rename_all = "lowercase")]
259pub enum LookupResult {
260 Rdap {
261 data: Box<RdapResponse>,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 whois_fallback: Option<WhoisResponse>,
264 },
265 Whois {
266 data: WhoisResponse,
267 rdap_error: Option<String>,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 rdap_fallback: Option<Box<RdapResponse>>,
270 },
271 Available {
272 data: Box<AvailabilityResult>,
273 rdap_error: String,
274 whois_error: String,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
279 whois_data: Option<WhoisResponse>,
280 },
281}
282
283impl LookupResult {
284 pub fn domain_name(&self) -> Option<String> {
286 match self {
287 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
288 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
289 LookupResult::Available { data, .. } => Some(data.domain.clone()),
290 }
291 }
292
293 pub fn registrar(&self) -> Option<String> {
295 match self {
296 LookupResult::Rdap {
297 data,
298 whois_fallback,
299 } => data
300 .get_registrar()
301 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
302 LookupResult::Whois { data, .. } => data.registrar.clone(),
303 LookupResult::Available { .. } => None,
304 }
305 }
306
307 pub fn organization(&self) -> Option<String> {
309 match self {
310 LookupResult::Rdap {
311 data,
312 whois_fallback,
313 } => data
314 .get_registrant_organization()
315 .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
316 LookupResult::Whois { data, .. } => data.organization.clone(),
317 LookupResult::Available { .. } => None,
318 }
319 }
320
321 pub fn is_rdap(&self) -> bool {
323 matches!(self, LookupResult::Rdap { .. })
324 }
325
326 pub fn is_whois(&self) -> bool {
328 matches!(self, LookupResult::Whois { .. })
329 }
330
331 pub fn is_available(&self) -> bool {
333 matches!(self, LookupResult::Available { .. })
334 }
335
336 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
338 match self {
339 LookupResult::Rdap {
340 data,
341 whois_fallback,
342 } => {
343 let expiration_date = data
345 .events
346 .iter()
347 .find(|e| e.event_action == "expiration")
348 .and_then(|e| e.parsed_date())
349 .or_else(|| {
350 whois_fallback.as_ref().and_then(|w| w.expiration_date)
352 });
353
354 let registrar = data
355 .get_registrar()
356 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
357
358 (expiration_date, registrar)
359 }
360 LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
361 LookupResult::Available { .. } => (None, None),
362 }
363 }
364}
365
366fn truncate_on_char_boundary(s: &mut String, max: usize) {
373 if s.len() > max {
374 let mut end = max;
375 while end > 0 && !s.is_char_boundary(end) {
376 end -= 1;
377 }
378 s.truncate(end);
379 }
380}
381
382fn trim_for_cache(mut result: LookupResult) -> LookupResult {
386 const MAX_RAW: usize = 32 * 1024;
387
388 match result {
389 LookupResult::Whois { ref mut data, .. } => {
390 if data.raw_response.len() > MAX_RAW {
391 truncate_on_char_boundary(&mut data.raw_response, MAX_RAW);
392 data.raw_response.push_str("\n... [truncated for cache]");
393 }
394 }
395 LookupResult::Rdap {
396 ref mut whois_fallback,
397 ..
398 } => {
399 if let Some(ref mut w) = whois_fallback {
400 if w.raw_response.len() > MAX_RAW {
401 truncate_on_char_boundary(&mut w.raw_response, MAX_RAW);
402 w.raw_response.push_str("\n... [truncated for cache]");
403 }
404 }
405 }
406 LookupResult::Available {
407 ref mut whois_data, ..
408 } => {
409 if let Some(ref mut w) = whois_data {
410 if w.raw_response.len() > MAX_RAW {
411 truncate_on_char_boundary(&mut w.raw_response, MAX_RAW);
412 w.raw_response.push_str("\n... [truncated for cache]");
413 }
414 }
415 }
416 }
417
418 result
419}
420
421#[derive(Debug, Clone)]
422pub struct SmartLookup {
423 rdap_client: RdapClient,
424 whois_client: WhoisClient,
425 availability_checker: AvailabilityChecker,
426 dns_resolver: DnsResolver,
427 prefer_rdap: bool,
429 include_fallback: bool,
431}
432
433impl Default for SmartLookup {
434 fn default() -> Self {
435 Self::new()
436 }
437}
438
439impl SmartLookup {
440 pub fn new() -> Self {
443 Self {
444 rdap_client: RdapClient::new(),
445 whois_client: WhoisClient::new(),
446 availability_checker: AvailabilityChecker::new(),
447 dns_resolver: DnsResolver::new(),
448 prefer_rdap: true,
449 include_fallback: false,
450 }
451 }
452
453 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
456 pub fn prefer_rdap(mut self, prefer: bool) -> Self {
457 self.prefer_rdap = prefer;
458 self
459 }
460
461 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
464 pub fn include_fallback(mut self, include: bool) -> Self {
465 self.include_fallback = include;
466 self
467 }
468
469 #[instrument(skip(self), fields(domain = %domain))]
473 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
474 self.lookup_with_progress(domain, None).await
475 }
476
477 #[instrument(skip(self, progress), fields(domain = %domain))]
482 pub async fn lookup_with_progress(
483 &self,
484 domain: &str,
485 progress: Option<LookupProgressCallback>,
486 ) -> Result<LookupResult> {
487 let normalized = crate::validation::normalize_domain(domain)?;
488
489 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
491 debug!(domain = %normalized, "Returning cached lookup result");
492 return Ok(cached);
493 }
494
495 let _guard = loop {
507 enum Slot {
508 Waiter(Arc<Notify>),
509 Owner(InflightGuard),
510 }
511
512 let slot = {
513 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
517 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
518 Some(existing) => Slot::Waiter(existing),
519 None => {
520 let n = Arc::new(Notify::new());
521 inflight.insert(normalized.clone(), Arc::downgrade(&n));
522 Slot::Owner(InflightGuard {
523 key: normalized.clone(),
524 notify: n,
525 })
526 }
527 }
528 };
529
530 match slot {
531 Slot::Waiter(n) => {
532 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
533 let notified = n.notified();
544 tokio::pin!(notified);
545
546 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
550 return Ok(cached);
551 }
552
553 let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
559
560 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
561 return Ok(cached);
562 }
563 continue;
566 }
567 Slot::Owner(guard) => break guard,
568 }
569 };
570
571 let result = self.lookup_concurrent(&normalized, progress).await?;
572
573 LOOKUP_CACHE.insert(normalized.clone(), trim_for_cache(result.clone()));
576
577 Ok(result)
578 }
579
580 pub fn clear_cache() {
582 LOOKUP_CACHE.clear();
583 }
584
585 #[instrument(skip(self, progress), fields(domain = %domain))]
586 async fn lookup_concurrent(
587 &self,
588 domain: &str,
589 progress: Option<LookupProgressCallback>,
590 ) -> Result<LookupResult> {
591 #[cfg(test)]
592 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
593
594 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
595
596 if let Some(ref cb) = progress {
597 cb("Querying RDAP and WHOIS concurrently");
598 }
599
600 let rdap_fut = self.rdap_client.lookup_domain(domain);
601 let whois_fut = self.whois_client.lookup(domain);
602
603 tokio::pin!(rdap_fut);
604 tokio::pin!(whois_fut);
605
606 enum LegOutcome<T> {
612 Completed(T),
613 GraceTruncated,
614 }
615
616 let (rdap_leg, whois_leg) = tokio::select! {
617 rdap_res = &mut rdap_fut => {
618 let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
620 Ok(res) => LegOutcome::Completed(res),
621 Err(_) => {
622 debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
623 LegOutcome::GraceTruncated
624 }
625 };
626 (LegOutcome::Completed(rdap_res), whois_leg)
627 }
628 whois_res = &mut whois_fut => {
629 let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
631 Ok(res) => LegOutcome::Completed(res),
632 Err(_) => {
633 debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
634 LegOutcome::GraceTruncated
635 }
636 };
637 (rdap_leg, LegOutcome::Completed(whois_res))
638 }
639 };
640
641 let rdap_outcome = match rdap_leg {
643 LegOutcome::Completed(Ok(data)) => {
644 if self.is_rdap_response_useful(&data) {
645 RdapOutcome::Useful(data)
646 } else {
647 RdapOutcome::NoData(data)
648 }
649 }
650 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
651 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
652 };
653
654 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
656 debug!("RDAP lookup successful");
657 let whois_fallback = match whois_leg {
658 LegOutcome::Completed(Ok(w)) => Some(w),
659 _ => None,
660 };
661 return Ok(LookupResult::Rdap {
662 data: Box::new(rdap_data),
663 whois_fallback,
664 });
665 }
666
667 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
678 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
679 RdapOutcome::Useful(_) => {
680 debug!("Unexpected RdapOutcome::Useful in fallback branch");
683 (String::from("RDAP ok"), None, None)
684 }
685 RdapOutcome::NoData(data) => (
686 "RDAP response incomplete".to_string(),
687 Some(Box::new(data)),
688 None,
689 ),
690 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
691 RdapOutcome::GraceTimeout => (
692 format!(
693 "RDAP did not return within {}s grace period after WHOIS won",
694 PROTOCOL_GRACE_PERIOD.as_secs()
695 ),
696 None,
697 None,
698 ),
699 };
700
701 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
702 let availability_match = should_route_to_availability(
706 rdap_returned_200,
707 rdap_seer_error.as_ref(),
708 &whois_data,
709 );
710
711 if let Some((confidence, method)) = availability_match {
712 debug!(
713 domain = %domain,
714 confidence = %confidence,
715 "Reclassifying WHOIS as availability signal"
716 );
717 if let Some(ref cb) = progress {
718 cb("Domain appears unregistered");
719 }
720 let details = match confidence {
721 "high" => Some("WHOIS indicates domain is not registered".to_string()),
722 "medium" => Some(
723 "WHOIS returned no registrar or registration dates; RDAP returned 404"
724 .to_string(),
725 ),
726 _ => None,
727 };
728 let avail = AvailabilityResult {
729 domain: domain.to_string(),
730 available: true,
731 confidence: confidence.to_string(),
732 method: method.to_string(),
733 details,
734 };
735 return Ok(LookupResult::Available {
736 data: Box::new(avail),
737 rdap_error: sanitize_error_for_public(&rdap_error_str),
738 whois_error: String::new(),
739 whois_data: Some(whois_data),
740 });
741 }
742
743 let whois_is_thin = whois_response_is_thin(&whois_data);
750 if whois_is_thin && !rdap_returned_200 {
751 let dns_presence = self.dns_resolver.presence(domain).await;
752 if nxdomain_confirms_available(whois_is_thin, rdap_returned_200, dns_presence) {
753 debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
754 if let Some(ref cb) = progress {
755 cb("Domain appears unregistered (no DNS presence)");
756 }
757 let avail = AvailabilityResult {
758 domain: domain.to_string(),
759 available: true,
760 confidence: "medium".to_string(),
761 method: "dns_nxdomain".to_string(),
762 details: Some(
763 "No registry data available; domain has no DNS presence (NXDOMAIN)"
764 .to_string(),
765 ),
766 };
767 return Ok(LookupResult::Available {
768 data: Box::new(avail),
769 rdap_error: sanitize_error_for_public(&rdap_error_str),
770 whois_error: String::new(),
771 whois_data: Some(whois_data),
772 });
773 }
774
775 if dns_present_confirms_registered(whois_is_thin, rdap_returned_200, dns_presence) {
783 debug!(domain = %domain, "Thin/no-service WHOIS + DNS delegation, reporting registered");
784 if let Some(ref cb) = progress {
785 cb("Domain is registered (registry detail unavailable)");
786 }
787 let details = if whois_data.registry_unavailable() {
788 "Domain is registered (the apex is delegated in DNS). This TLD's \
789 registry provides no port-43 WHOIS data and RDAP was unavailable \
790 (rate-limited or unreachable); retry shortly for full RDAP detail."
791 } else {
792 "Domain is registered (the apex is delegated in DNS). Registry detail \
793 was unavailable (RDAP rate-limited or unreachable and WHOIS returned \
794 no data); retry shortly for full detail."
795 };
796 let avail = AvailabilityResult {
797 domain: domain.to_string(),
798 available: false,
799 confidence: "high".to_string(),
800 method: "dns_present".to_string(),
801 details: Some(details.to_string()),
802 };
803 return Ok(LookupResult::Available {
804 data: Box::new(avail),
805 rdap_error: sanitize_error_for_public(&rdap_error_str),
806 whois_error: String::new(),
807 whois_data: Some(whois_data),
808 });
809 }
810 }
811 debug!("Using WHOIS result (RDAP not useful)");
812 if let Some(ref cb) = progress {
813 cb("RDAP not available (using WHOIS)");
814 }
815 return Ok(LookupResult::Whois {
816 data: whois_data,
817 rdap_error: Some(rdap_error_str),
818 rdap_fallback: rdap_fallback_data,
819 });
820 }
821
822 let whois_error_str = match whois_leg {
826 LegOutcome::Completed(Err(e)) => e.to_string(),
827 LegOutcome::Completed(Ok(_)) => {
828 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
830 "WHOIS returned but was not used".to_string()
831 }
832 LegOutcome::GraceTruncated => format!(
833 "WHOIS did not return within {}s grace period after RDAP won",
834 PROTOCOL_GRACE_PERIOD.as_secs()
835 ),
836 };
837
838 self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
839 .await
840 }
841
842 async fn availability_fallback(
843 &self,
844 domain: &str,
845 rdap_error: String,
846 whois_error: String,
847 progress: Option<LookupProgressCallback>,
848 ) -> Result<LookupResult> {
849 if let Some(ref cb) = progress {
850 cb("RDAP and WHOIS unavailable (checking availability)");
851 }
852 warn!(
853 domain = %domain,
854 rdap_error = %rdap_error,
855 whois_error = %whois_error,
856 "Both RDAP and WHOIS failed, falling back to availability check"
857 );
858
859 match self.availability_checker.check(domain).await {
860 Ok(avail) => Ok(LookupResult::Available {
861 data: Box::new(avail),
862 rdap_error: sanitize_error_for_public(&rdap_error),
863 whois_error: sanitize_error_for_public(&whois_error),
864 whois_data: None,
865 }),
866 Err(avail_err) => {
867 let tld = get_tld(domain).unwrap_or("unknown");
868 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
869 format!("https://www.iana.org/domains/root/db/{}.html", tld)
870 });
871 Err(SeerError::LookupFailed {
872 domain: domain.to_string(),
873 details: format!(
874 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
875 rdap_error, whois_error, avail_err
876 ),
877 registry_url,
878 })
879 }
880 }
881 }
882
883 fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
884 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
886 let has_dates = response
887 .events
888 .iter()
889 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
890 let has_entities = !response.entities.is_empty();
891 let has_nameservers = !response.nameservers.is_empty();
892 let has_status = !response.status.is_empty();
893
894 has_name && (has_dates || has_entities || has_nameservers || has_status)
896 }
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902
903 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
913
914 #[test]
915 fn test_lookup_result_domain_name_whois() {
916 let result = LookupResult::Whois {
917 data: WhoisResponse {
918 domain: "example.com".to_string(),
919 registrar: Some("Test Registrar".to_string()),
920 registrant: None,
921 organization: None,
922 registrant_email: None,
923 registrant_phone: None,
924 registrant_address: None,
925 registrant_country: None,
926 admin_name: None,
927 admin_organization: None,
928 admin_email: None,
929 admin_phone: None,
930 tech_name: None,
931 tech_organization: None,
932 tech_email: None,
933 tech_phone: None,
934 creation_date: None,
935 expiration_date: None,
936 updated_date: None,
937 status: vec![],
938 nameservers: vec![],
939 dnssec: None,
940 whois_server: "whois.example.com".to_string(),
941 raw_response: String::new(),
942 },
943 rdap_error: None,
944 rdap_fallback: None,
945 };
946
947 assert_eq!(result.domain_name(), Some("example.com".to_string()));
948 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
949 assert!(result.is_whois());
950 assert!(!result.is_rdap());
951 assert!(!result.is_available());
952 }
953
954 #[test]
955 fn test_lookup_result_serialization() {
956 let result = LookupResult::Whois {
957 data: WhoisResponse {
958 domain: "test.com".to_string(),
959 registrar: None,
960 registrant: None,
961 organization: None,
962 registrant_email: None,
963 registrant_phone: None,
964 registrant_address: None,
965 registrant_country: None,
966 admin_name: None,
967 admin_organization: None,
968 admin_email: None,
969 admin_phone: None,
970 tech_name: None,
971 tech_organization: None,
972 tech_email: None,
973 tech_phone: None,
974 creation_date: None,
975 expiration_date: None,
976 updated_date: None,
977 status: vec![],
978 nameservers: vec![],
979 dnssec: None,
980 whois_server: String::new(),
981 raw_response: String::new(),
982 },
983 rdap_error: Some("RDAP failed".to_string()),
984 rdap_fallback: None,
985 };
986
987 let json = serde_json::to_string(&result).unwrap();
988 assert!(json.contains("\"source\":\"whois\""));
989 assert!(json.contains("RDAP failed"));
990 }
991
992 #[test]
993 fn test_lookup_result_available_serialization() {
994 let result = LookupResult::Available {
995 data: Box::new(AvailabilityResult {
996 domain: "test123.xyz".to_string(),
997 available: true,
998 confidence: "medium".to_string(),
999 method: "whois_error".to_string(),
1000 details: Some("WHOIS server indicates no matching records".to_string()),
1001 }),
1002 rdap_error: "RDAP failed".to_string(),
1003 whois_error: "WHOIS failed".to_string(),
1004 whois_data: None,
1005 };
1006
1007 let json = serde_json::to_string(&result).unwrap();
1008 assert!(json.contains("\"source\":\"available\""));
1009 assert!(json.contains("\"available\":true"));
1010 assert!(json.contains("test123.xyz"));
1011
1012 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1013 assert!(result.is_available());
1014 assert!(!result.is_rdap());
1015 assert!(!result.is_whois());
1016 assert!(result.registrar().is_none());
1017 assert_eq!(result.expiration_info(), (None, None));
1018 }
1019
1020 #[test]
1021 #[allow(deprecated)]
1022 fn test_smart_lookup_builder() {
1023 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
1024 assert!(!lookup.prefer_rdap);
1025 assert!(lookup.include_fallback);
1026 }
1027
1028 #[test]
1029 fn test_lookup_cache_clear() {
1030 SmartLookup::clear_cache();
1031 assert!(LOOKUP_CACHE.is_empty());
1032 }
1033
1034 #[test]
1037 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1038 const MAX_RAW: usize = 32 * 1024;
1039 let mut s = "a".repeat(MAX_RAW - 1);
1043 s.push('€'); s.push_str(&"b".repeat(100));
1045 assert!(!s.is_char_boundary(MAX_RAW));
1046
1047 truncate_on_char_boundary(&mut s, MAX_RAW);
1049 assert!(s.len() <= MAX_RAW);
1050 assert_eq!(s.len(), MAX_RAW - 1);
1053 }
1054
1055 #[test]
1056 fn trim_for_cache_truncates_multibyte_whois_without_panic() {
1057 const MAX_RAW: usize = 32 * 1024;
1058 let mut raw = "a".repeat(MAX_RAW - 1);
1059 raw.push('€');
1060 raw.push_str(&"b".repeat(1000));
1061
1062 let mut w = empty_whois("example.com");
1063 w.raw_response = raw;
1064 let result = trim_for_cache(LookupResult::Whois {
1065 data: w,
1066 rdap_error: None,
1067 rdap_fallback: None,
1068 });
1069 if let LookupResult::Whois { data, .. } = result {
1070 assert!(data.raw_response.ends_with("[truncated for cache]"));
1071 } else {
1072 panic!("expected Whois variant");
1073 }
1074 }
1075
1076 #[test]
1079 fn test_sanitize_strips_ipv4() {
1080 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1081 let sanitized = sanitize_error_for_public(msg);
1082 assert!(
1083 !sanitized.contains("10.0.0.1"),
1084 "IPv4 should be stripped, got: {}",
1085 sanitized
1086 );
1087 assert!(sanitized.contains("[ip-redacted]"));
1088 }
1089
1090 #[test]
1091 fn test_sanitize_strips_multiple_ipv4() {
1092 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1093 let sanitized = sanitize_error_for_public(msg);
1094 assert!(!sanitized.contains("192.168.1.1"));
1095 assert!(!sanitized.contains("127.0.0.1"));
1096 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1098 }
1099
1100 #[test]
1101 fn test_sanitize_strips_ipv6() {
1102 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1103 let sanitized = sanitize_error_for_public(msg);
1104 assert!(!sanitized.contains("fe80::1"));
1105 assert!(sanitized.contains("[ip-redacted]"));
1106 }
1107
1108 #[test]
1109 fn sanitize_leaves_mac_address_like_tokens_alone() {
1110 let msg = "error code af:ba:12 at line 5";
1111 let out = sanitize_error_for_public(msg);
1112 assert!(
1113 out.contains("af:ba:12"),
1114 "MAC fragment should not be stripped: {}",
1115 out
1116 );
1117 }
1118
1119 #[test]
1120 fn sanitize_strips_real_ipv6() {
1121 let msg = "cannot reach 2001:db8::1 — timeout";
1122 let out = sanitize_error_for_public(msg);
1123 assert!(!out.contains("2001:db8::1"));
1124 assert!(out.contains("[ip-redacted]"));
1125 }
1126
1127 #[test]
1128 fn sanitize_strips_fe80_link_local() {
1129 let msg = "peer at fe80::1 unreachable";
1130 let out = sanitize_error_for_public(msg);
1131 assert!(out.contains("[ip-redacted]"));
1132 }
1133
1134 #[test]
1135 fn test_sanitize_truncates_long_message() {
1136 let long = "a".repeat(500);
1138 let sanitized = sanitize_error_for_public(&long);
1139 let char_count = sanitized.chars().count();
1141 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1142 assert!(sanitized.ends_with('…'));
1143 }
1144
1145 #[test]
1146 fn test_sanitize_preserves_short_messages() {
1147 let msg = "RDAP timed out after 15s";
1148 let sanitized = sanitize_error_for_public(msg);
1149 assert_eq!(sanitized, msg);
1150 }
1151
1152 #[test]
1155 fn test_is_rdap_response_useful_detects_no_data() {
1156 use crate::rdap::RdapResponse;
1157 let resp = RdapResponse {
1161 ldh_name: Some("example.com".to_string()),
1162 ..Default::default()
1163 };
1164 let lookup = SmartLookup::new();
1165 assert!(
1166 !lookup.is_rdap_response_useful(&resp),
1167 "Response with only a name should be classified as NoData"
1168 );
1169
1170 let useful = RdapResponse {
1172 ldh_name: Some("example.com".to_string()),
1173 status: vec!["active".to_string()],
1174 ..Default::default()
1175 };
1176 assert!(lookup.is_rdap_response_useful(&useful));
1177 }
1178
1179 #[tokio::test]
1187 async fn test_inflight_coalescing_map() {
1188 let _serial = INFLIGHT_TEST_SERIAL
1193 .lock()
1194 .unwrap_or_else(|p| p.into_inner());
1195 let domain = unique_test_key("__coalesce");
1204
1205 {
1207 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1208 m.remove(&domain);
1209 }
1210
1211 let owner_notify = Arc::new(Notify::new());
1213 {
1214 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1215 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1216 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1217 }
1218
1219 let waiter = {
1221 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1222 m.get(&domain)
1223 .and_then(|w| w.upgrade())
1224 .expect("Second caller must observe in-flight entry")
1225 };
1226
1227 let waiter_clone = waiter.clone();
1229 let handle = tokio::spawn(async move {
1230 waiter_clone.notified().await;
1231 });
1232
1233 tokio::time::sleep(Duration::from_millis(20)).await;
1235 {
1236 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1237 m.remove(&domain);
1238 }
1239 owner_notify.notify_waiters();
1240
1241 tokio::time::timeout(Duration::from_secs(1), handle)
1243 .await
1244 .expect("waiter must unblock after notify")
1245 .expect("waiter task joined cleanly");
1246
1247 drop(owner_notify);
1249 drop(waiter);
1250 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1251 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1252 }
1253
1254 fn unique_test_key(prefix: &str) -> String {
1260 use std::sync::atomic::{AtomicU64, Ordering};
1261 use std::time::{SystemTime, UNIX_EPOCH};
1262 static COUNTER: AtomicU64 = AtomicU64::new(0);
1263 let nanos = SystemTime::now()
1264 .duration_since(UNIX_EPOCH)
1265 .map(|d| d.as_nanos())
1266 .unwrap_or(0);
1267 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1268 format!("{}_{}_{}.example.", prefix, nanos, n)
1269 }
1270
1271 #[test]
1277 fn test_sanitize_applied_to_available_fields() {
1278 let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1279 let whois_raw = "connection refused at 192.168.0.5";
1280 let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1281 let sanitized_whois = sanitize_error_for_public(whois_raw);
1282 let result = LookupResult::Available {
1283 data: Box::new(AvailabilityResult {
1284 domain: "unreg.test".to_string(),
1285 available: true,
1286 confidence: "low".to_string(),
1287 method: "heuristic".to_string(),
1288 details: None,
1289 }),
1290 rdap_error: sanitized_rdap,
1291 whois_error: sanitized_whois,
1292 whois_data: None,
1293 };
1294 if let LookupResult::Available {
1295 rdap_error,
1296 whois_error,
1297 ..
1298 } = result
1299 {
1300 assert!(!rdap_error.contains("10.0.0.1"));
1301 assert!(!whois_error.contains("192.168.0.5"));
1302 assert!(rdap_error.contains("[ip-redacted]"));
1303 assert!(whois_error.contains("[ip-redacted]"));
1304 } else {
1305 panic!("expected Available variant");
1306 }
1307 }
1308
1309 #[test]
1310 fn rdap_error_is_404_matches_standard_404() {
1311 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1312 assert!(rdap_error_is_404(&e));
1313 }
1314
1315 #[test]
1316 fn rdap_error_is_404_matches_without_reason_phrase() {
1317 let e = SeerError::RdapError("query failed with status 404".to_string());
1318 assert!(rdap_error_is_404(&e));
1319 }
1320
1321 #[test]
1322 fn rdap_error_is_404_rejects_other_statuses() {
1323 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1324 assert!(!rdap_error_is_404(&e));
1325 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1326 assert!(!rdap_error_is_404(&e));
1327 }
1328
1329 #[test]
1330 fn rdap_error_is_404_rejects_non_http_errors() {
1331 let e = SeerError::RdapError("connection timeout".to_string());
1332 assert!(!rdap_error_is_404(&e));
1333 let e = SeerError::Timeout("rdap".to_string());
1334 assert!(!rdap_error_is_404(&e));
1335 }
1336
1337 #[test]
1338 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1339 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1341 assert!(!rdap_error_is_404(&e));
1342 }
1343
1344 fn empty_whois(domain: &str) -> WhoisResponse {
1347 WhoisResponse {
1348 domain: domain.to_string(),
1349 registrar: None,
1350 registrant: None,
1351 organization: None,
1352 registrant_email: None,
1353 registrant_phone: None,
1354 registrant_address: None,
1355 registrant_country: None,
1356 admin_name: None,
1357 admin_organization: None,
1358 admin_email: None,
1359 admin_phone: None,
1360 tech_name: None,
1361 tech_organization: None,
1362 tech_email: None,
1363 tech_phone: None,
1364 creation_date: None,
1365 expiration_date: None,
1366 updated_date: None,
1367 nameservers: vec![],
1368 status: vec![],
1369 dnssec: None,
1370 whois_server: String::new(),
1371 raw_response: String::new(),
1372 }
1373 }
1374
1375 #[test]
1376 fn whois_response_is_thin_when_all_key_fields_missing() {
1377 let w = empty_whois("example.com");
1378 assert!(whois_response_is_thin(&w));
1379 }
1380
1381 #[test]
1382 fn whois_response_is_not_thin_when_registrar_present() {
1383 let mut w = empty_whois("example.com");
1384 w.registrar = Some("Test Registrar".to_string());
1385 assert!(!whois_response_is_thin(&w));
1386 }
1387
1388 #[test]
1389 fn whois_response_is_not_thin_when_creation_date_present() {
1390 let mut w = empty_whois("example.com");
1391 w.creation_date = Some(Utc::now());
1392 assert!(!whois_response_is_thin(&w));
1393 }
1394
1395 #[test]
1396 fn whois_response_is_not_thin_when_expiration_date_present() {
1397 let mut w = empty_whois("example.com");
1398 w.expiration_date = Some(Utc::now());
1399 assert!(!whois_response_is_thin(&w));
1400 }
1401
1402 #[test]
1403 fn whois_response_is_thin_even_with_nameservers_alone() {
1404 let mut w = empty_whois("example.com");
1405 w.nameservers = vec!["ns1.example.net".to_string()];
1406 assert!(whois_response_is_thin(&w));
1407 }
1408
1409 use crate::rdap::RdapResponse;
1412
1413 #[allow(dead_code)]
1414 fn make_empty_rdap_response() -> RdapResponse {
1415 serde_json::from_value(serde_json::json!({
1416 "objectClassName": "domain",
1417 }))
1418 .expect("valid minimal RDAP response")
1419 }
1420
1421 #[test]
1422 fn classify_whois_leg_case_a_high_confidence() {
1423 let mut w = empty_whois("zaccodes.com");
1424 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1425 assert!(w.is_available());
1426 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1427 let (verdict, method) =
1428 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1429 assert_eq!(verdict, "high");
1430 assert_eq!(method, "whois");
1431 }
1432
1433 #[test]
1434 fn classify_whois_leg_case_b_medium_confidence() {
1435 let w = empty_whois("example.xyz");
1436 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1437 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1438 let (verdict, method) =
1439 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1440 assert_eq!(verdict, "medium");
1441 assert_eq!(method, "whois_thin_response");
1442 }
1443
1444 #[test]
1445 fn classify_whois_leg_rejects_thin_whois_without_404() {
1446 let w = empty_whois("example.xyz");
1447 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1448 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1449 }
1450
1451 #[test]
1452 fn classify_whois_leg_rejects_whois_with_real_data() {
1453 let mut w = empty_whois("legacy.tld");
1454 w.registrar = Some("Legacy Registry".to_string());
1455 w.creation_date = Some(Utc::now());
1456 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1457 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1458 }
1459
1460 #[test]
1461 fn classify_whois_leg_case_a_wins_over_case_b() {
1462 let mut w = empty_whois("example.com");
1463 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1464 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1465 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1466 assert_eq!(verdict, "high");
1467 }
1468
1469 #[test]
1477 fn rdap_200_vetoes_whois_no_match() {
1478 let mut w = empty_whois("freshly-registered.com");
1479 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1480 assert!(
1482 should_route_to_availability(true, None, &w).is_none(),
1483 "RDAP 200 must veto WHOIS-only availability claim",
1484 );
1485 }
1486
1487 #[test]
1488 fn rdap_200_vetoes_even_with_thin_whois() {
1489 let w = empty_whois("freshly-registered.com");
1490 assert!(
1492 should_route_to_availability(true, None, &w).is_none(),
1493 "RDAP 200 must veto even when WHOIS is thin",
1494 );
1495 }
1496
1497 #[test]
1498 fn rdap_404_with_whois_no_match_routes_to_available() {
1499 let mut w = empty_whois("genuinely-free.com");
1500 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1501 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1502 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1503 assert_eq!(result, Some(("high", "whois")));
1504 }
1505
1506 #[test]
1507 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1508 let mut w = empty_whois("genuinely-free.com");
1509 w.raw_response = "Domain not found".to_string();
1510 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1513 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1514 assert_eq!(result, Some(("high", "whois")));
1515 }
1516
1517 #[test]
1518 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1519 let mut w = empty_whois("genuinely-free.com");
1521 w.raw_response = "No match".to_string();
1522 let result = should_route_to_availability(false, None, &w);
1523 assert_eq!(result, Some(("high", "whois")));
1524 }
1525
1526 #[test]
1527 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1528 let mut w = empty_whois("registered.com");
1529 w.registrar = Some("Example Registrar Ltd".to_string());
1530 assert!(should_route_to_availability(false, None, &w).is_none());
1534 }
1535
1536 #[test]
1539 fn nxdomain_confirms_available_thin_no200_absent() {
1540 assert!(nxdomain_confirms_available(
1541 true,
1542 false,
1543 DnsPresence::Absent
1544 ));
1545 }
1546
1547 #[test]
1548 fn nxdomain_confirms_available_vetoed_by_rdap_200() {
1549 assert!(!nxdomain_confirms_available(
1552 true,
1553 true,
1554 DnsPresence::Absent
1555 ));
1556 }
1557
1558 #[test]
1559 fn nxdomain_confirms_available_requires_thin_whois() {
1560 assert!(!nxdomain_confirms_available(
1562 false,
1563 false,
1564 DnsPresence::Absent
1565 ));
1566 }
1567
1568 #[test]
1569 fn nxdomain_confirms_available_requires_absent_dns() {
1570 assert!(!nxdomain_confirms_available(
1571 true,
1572 false,
1573 DnsPresence::Present
1574 ));
1575 assert!(!nxdomain_confirms_available(
1576 true,
1577 false,
1578 DnsPresence::Unknown
1579 ));
1580 }
1581
1582 #[test]
1585 fn dns_present_confirms_registered_thin_no200_present() {
1586 assert!(dns_present_confirms_registered(
1590 true,
1591 false,
1592 DnsPresence::Present
1593 ));
1594 }
1595
1596 #[test]
1597 fn dns_present_confirms_registered_requires_present_dns() {
1598 assert!(!dns_present_confirms_registered(
1601 true,
1602 false,
1603 DnsPresence::Absent
1604 ));
1605 assert!(!dns_present_confirms_registered(
1606 true,
1607 false,
1608 DnsPresence::Unknown
1609 ));
1610 }
1611
1612 #[test]
1613 fn dns_present_confirms_registered_requires_thin_whois() {
1614 assert!(!dns_present_confirms_registered(
1616 false,
1617 false,
1618 DnsPresence::Present
1619 ));
1620 }
1621
1622 #[test]
1623 fn dns_present_confirms_registered_vetoed_by_rdap_200() {
1624 assert!(!dns_present_confirms_registered(
1626 true,
1627 true,
1628 DnsPresence::Present
1629 ));
1630 }
1631
1632 #[test]
1642 fn lookup_inflight_recovers_from_poisoned_mutex() {
1643 use std::panic::{catch_unwind, AssertUnwindSafe};
1644
1645 let _serial = INFLIGHT_TEST_SERIAL
1647 .lock()
1648 .unwrap_or_else(|p| p.into_inner());
1649
1650 let _ = catch_unwind(AssertUnwindSafe(|| {
1652 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1653 panic!("poisoning LOOKUP_INFLIGHT for test");
1654 }));
1655
1656 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1660 let canary = unique_test_key("__poison_recovery");
1662 guard.insert(canary.clone(), Weak::new());
1663 assert!(guard.contains_key(&canary));
1664 guard.remove(&canary);
1665 }
1666
1667 #[test]
1670 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1671 use std::panic::{catch_unwind, AssertUnwindSafe};
1672
1673 let _serial = INFLIGHT_TEST_SERIAL
1679 .lock()
1680 .unwrap_or_else(|p| p.into_inner());
1681
1682 let key = unique_test_key("__drop_poison");
1687 let notify = Arc::new(Notify::new());
1688 {
1689 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1690 map.insert(key.clone(), Arc::downgrade(¬ify));
1691 }
1692 let guard = InflightGuard {
1693 key: key.clone(),
1694 notify: notify.clone(),
1695 };
1696
1697 let _ = catch_unwind(AssertUnwindSafe(|| {
1699 let _g = LOOKUP_INFLIGHT.lock().unwrap();
1700 panic!("poisoning LOOKUP_INFLIGHT for drop test");
1701 }));
1702
1703 drop(guard);
1706
1707 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1708 assert!(
1709 !map.contains_key(&key),
1710 "poisoned-mutex drop path should still remove the in-flight entry"
1711 );
1712 }
1713}