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
34static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
36 Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
37
38static LOOKUP_INFLIGHT: Lazy<Mutex<HashMap<String, Weak<Notify>>>> =
42 Lazy::new(|| Mutex::new(HashMap::new()));
43
44static IPV4_RE: Lazy<Regex> =
46 Lazy::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("IPV4_RE is a valid regex"));
47
48static IPV6_CANDIDATE_RE: Lazy<Regex> = Lazy::new(|| {
54 Regex::new(r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b")
55 .expect("IPV6_CANDIDATE_RE is a valid regex")
56});
57
58fn strip_ipv6(msg: &str) -> String {
61 IPV6_CANDIDATE_RE
62 .replace_all(msg, |caps: ®ex::Captures| {
63 let candidate = &caps[0];
64 if Ipv6Addr::from_str(candidate).is_ok() {
65 "[ip-redacted]".to_string()
66 } else {
67 candidate.to_string()
68 }
69 })
70 .into_owned()
71}
72
73#[cfg(test)]
77static LOOKUP_CONCURRENT_CALLS: Lazy<std::sync::atomic::AtomicUsize> =
78 Lazy::new(|| std::sync::atomic::AtomicUsize::new(0));
79
80fn whois_response_is_thin(w: &WhoisResponse) -> bool {
88 w.registrar.is_none() && w.creation_date.is_none() && w.expiration_date.is_none()
89}
90
91fn classify_whois_leg(
98 w: &WhoisResponse,
99 rdap_err: &SeerError,
100) -> Option<(&'static str, &'static str)> {
101 if w.is_available() {
102 return Some(("high", "whois"));
103 }
104 if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
105 return Some(("medium", "whois_thin_response"));
106 }
107 None
108}
109
110fn should_route_to_availability(
117 rdap_returned_200: bool,
118 rdap_seer_error: Option<&SeerError>,
119 whois_data: &WhoisResponse,
120) -> Option<(&'static str, &'static str)> {
121 if rdap_returned_200 {
122 return None;
123 }
124 if whois_data.is_available() {
130 return Some(("high", "whois"));
131 }
132 rdap_seer_error.and_then(|e| {
133 classify_whois_leg(whois_data, e)
138 })
139}
140
141fn nxdomain_confirms_available(is_thin: bool, rdap_returned_200: bool, dns: DnsPresence) -> bool {
151 is_thin && !rdap_returned_200 && matches!(dns, DnsPresence::Absent)
152}
153
154fn dns_present_confirms_registered(
169 is_thin: bool,
170 rdap_returned_200: bool,
171 dns: DnsPresence,
172) -> bool {
173 is_thin && !rdap_returned_200 && matches!(dns, DnsPresence::Present)
174}
175
176fn sanitize_error_for_public(msg: &str) -> String {
182 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
183 let s = strip_ipv6(&s);
184 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
185 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
186 trunc.push('…');
187 trunc
188 } else {
189 s
190 }
191}
192
193struct InflightGuard {
206 key: String,
207 notify: Arc<Notify>,
208}
209
210impl Drop for InflightGuard {
211 fn drop(&mut self) {
212 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
225 inflight.remove(&self.key);
226 drop(inflight);
227 self.notify.notify_waiters();
228 }
229}
230
231enum RdapOutcome {
237 Useful(RdapResponse),
238 NoData(RdapResponse),
239 Error(SeerError),
240 GraceTimeout,
243}
244
245pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(tag = "source", rename_all = "lowercase")]
251pub enum LookupResult {
252 Rdap {
253 data: Box<RdapResponse>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 whois_fallback: Option<WhoisResponse>,
256 },
257 Whois {
258 data: WhoisResponse,
259 rdap_error: Option<String>,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 rdap_fallback: Option<Box<RdapResponse>>,
262 },
263 Available {
264 data: Box<AvailabilityResult>,
265 rdap_error: String,
266 whois_error: String,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
271 whois_data: Option<WhoisResponse>,
272 },
273}
274
275impl LookupResult {
276 pub fn domain_name(&self) -> Option<String> {
278 match self {
279 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
280 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
281 LookupResult::Available { data, .. } => Some(data.domain.clone()),
282 }
283 }
284
285 pub fn registrar(&self) -> Option<String> {
287 match self {
288 LookupResult::Rdap {
289 data,
290 whois_fallback,
291 } => data
292 .get_registrar()
293 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
294 LookupResult::Whois { data, .. } => data.registrar.clone(),
295 LookupResult::Available { .. } => None,
296 }
297 }
298
299 pub fn organization(&self) -> Option<String> {
301 match self {
302 LookupResult::Rdap {
303 data,
304 whois_fallback,
305 } => data
306 .get_registrant_organization()
307 .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
308 LookupResult::Whois { data, .. } => data.organization.clone(),
309 LookupResult::Available { .. } => None,
310 }
311 }
312
313 pub fn is_rdap(&self) -> bool {
315 matches!(self, LookupResult::Rdap { .. })
316 }
317
318 pub fn is_whois(&self) -> bool {
320 matches!(self, LookupResult::Whois { .. })
321 }
322
323 pub fn is_available(&self) -> bool {
325 matches!(self, LookupResult::Available { .. })
326 }
327
328 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
330 match self {
331 LookupResult::Rdap {
332 data,
333 whois_fallback,
334 } => {
335 let expiration_date = data
337 .events
338 .iter()
339 .find(|e| e.event_action == "expiration")
340 .and_then(|e| e.parsed_date())
341 .or_else(|| {
342 whois_fallback.as_ref().and_then(|w| w.expiration_date)
344 });
345
346 let registrar = data
347 .get_registrar()
348 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
349
350 (expiration_date, registrar)
351 }
352 LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
353 LookupResult::Available { .. } => (None, None),
354 }
355 }
356}
357
358fn trim_for_cache(mut result: LookupResult) -> LookupResult {
362 const MAX_RAW: usize = 32 * 1024;
363
364 match result {
365 LookupResult::Whois { ref mut data, .. } => {
366 if data.raw_response.len() > MAX_RAW {
367 data.raw_response.truncate(MAX_RAW);
368 data.raw_response.push_str("\n... [truncated for cache]");
369 }
370 }
371 LookupResult::Rdap {
372 ref mut whois_fallback,
373 ..
374 } => {
375 if let Some(ref mut w) = whois_fallback {
376 if w.raw_response.len() > MAX_RAW {
377 w.raw_response.truncate(MAX_RAW);
378 w.raw_response.push_str("\n... [truncated for cache]");
379 }
380 }
381 }
382 LookupResult::Available {
383 ref mut whois_data, ..
384 } => {
385 if let Some(ref mut w) = whois_data {
386 if w.raw_response.len() > MAX_RAW {
387 w.raw_response.truncate(MAX_RAW);
388 w.raw_response.push_str("\n... [truncated for cache]");
389 }
390 }
391 }
392 }
393
394 result
395}
396
397#[derive(Debug, Clone)]
398pub struct SmartLookup {
399 rdap_client: RdapClient,
400 whois_client: WhoisClient,
401 availability_checker: AvailabilityChecker,
402 dns_resolver: DnsResolver,
403 prefer_rdap: bool,
405 include_fallback: bool,
407}
408
409impl Default for SmartLookup {
410 fn default() -> Self {
411 Self::new()
412 }
413}
414
415impl SmartLookup {
416 pub fn new() -> Self {
419 Self {
420 rdap_client: RdapClient::new(),
421 whois_client: WhoisClient::new(),
422 availability_checker: AvailabilityChecker::new(),
423 dns_resolver: DnsResolver::new(),
424 prefer_rdap: true,
425 include_fallback: false,
426 }
427 }
428
429 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
432 pub fn prefer_rdap(mut self, prefer: bool) -> Self {
433 self.prefer_rdap = prefer;
434 self
435 }
436
437 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
440 pub fn include_fallback(mut self, include: bool) -> Self {
441 self.include_fallback = include;
442 self
443 }
444
445 #[instrument(skip(self), fields(domain = %domain))]
449 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
450 self.lookup_with_progress(domain, None).await
451 }
452
453 #[instrument(skip(self, progress), fields(domain = %domain))]
458 pub async fn lookup_with_progress(
459 &self,
460 domain: &str,
461 progress: Option<LookupProgressCallback>,
462 ) -> Result<LookupResult> {
463 let normalized = crate::validation::normalize_domain(domain)?;
464
465 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
467 debug!(domain = %normalized, "Returning cached lookup result");
468 return Ok(cached);
469 }
470
471 let _guard = loop {
483 enum Slot {
484 Waiter(Arc<Notify>),
485 Owner(InflightGuard),
486 }
487
488 let slot = {
489 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
493 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
494 Some(existing) => Slot::Waiter(existing),
495 None => {
496 let n = Arc::new(Notify::new());
497 inflight.insert(normalized.clone(), Arc::downgrade(&n));
498 Slot::Owner(InflightGuard {
499 key: normalized.clone(),
500 notify: n,
501 })
502 }
503 }
504 };
505
506 match slot {
507 Slot::Waiter(n) => {
508 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
509 n.notified().await;
510 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
511 return Ok(cached);
512 }
513 continue;
516 }
517 Slot::Owner(guard) => break guard,
518 }
519 };
520
521 let result = self.lookup_concurrent(&normalized, progress).await?;
522
523 LOOKUP_CACHE.insert(normalized.clone(), trim_for_cache(result.clone()));
526
527 Ok(result)
528 }
529
530 pub fn clear_cache() {
532 LOOKUP_CACHE.clear();
533 }
534
535 #[instrument(skip(self, progress), fields(domain = %domain))]
536 async fn lookup_concurrent(
537 &self,
538 domain: &str,
539 progress: Option<LookupProgressCallback>,
540 ) -> Result<LookupResult> {
541 #[cfg(test)]
542 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
543
544 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
545
546 if let Some(ref cb) = progress {
547 cb("Querying RDAP and WHOIS concurrently");
548 }
549
550 let rdap_fut = self.rdap_client.lookup_domain(domain);
551 let whois_fut = self.whois_client.lookup(domain);
552
553 tokio::pin!(rdap_fut);
554 tokio::pin!(whois_fut);
555
556 enum LegOutcome<T> {
562 Completed(T),
563 GraceTruncated,
564 }
565
566 let (rdap_leg, whois_leg) = tokio::select! {
567 rdap_res = &mut rdap_fut => {
568 let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
570 Ok(res) => LegOutcome::Completed(res),
571 Err(_) => {
572 debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
573 LegOutcome::GraceTruncated
574 }
575 };
576 (LegOutcome::Completed(rdap_res), whois_leg)
577 }
578 whois_res = &mut whois_fut => {
579 let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
581 Ok(res) => LegOutcome::Completed(res),
582 Err(_) => {
583 debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
584 LegOutcome::GraceTruncated
585 }
586 };
587 (rdap_leg, LegOutcome::Completed(whois_res))
588 }
589 };
590
591 let rdap_outcome = match rdap_leg {
593 LegOutcome::Completed(Ok(data)) => {
594 if self.is_rdap_response_useful(&data) {
595 RdapOutcome::Useful(data)
596 } else {
597 RdapOutcome::NoData(data)
598 }
599 }
600 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
601 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
602 };
603
604 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
606 debug!("RDAP lookup successful");
607 let whois_fallback = match whois_leg {
608 LegOutcome::Completed(Ok(w)) => Some(w),
609 _ => None,
610 };
611 return Ok(LookupResult::Rdap {
612 data: Box::new(rdap_data),
613 whois_fallback,
614 });
615 }
616
617 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
628 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
629 RdapOutcome::Useful(_) => {
630 debug!("Unexpected RdapOutcome::Useful in fallback branch");
633 (String::from("RDAP ok"), None, None)
634 }
635 RdapOutcome::NoData(data) => (
636 "RDAP response incomplete".to_string(),
637 Some(Box::new(data)),
638 None,
639 ),
640 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
641 RdapOutcome::GraceTimeout => (
642 format!(
643 "RDAP did not return within {}s grace period after WHOIS won",
644 PROTOCOL_GRACE_PERIOD.as_secs()
645 ),
646 None,
647 None,
648 ),
649 };
650
651 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
652 let availability_match = should_route_to_availability(
656 rdap_returned_200,
657 rdap_seer_error.as_ref(),
658 &whois_data,
659 );
660
661 if let Some((confidence, method)) = availability_match {
662 debug!(
663 domain = %domain,
664 confidence = %confidence,
665 "Reclassifying WHOIS as availability signal"
666 );
667 if let Some(ref cb) = progress {
668 cb("Domain appears unregistered");
669 }
670 let details = match confidence {
671 "high" => Some("WHOIS indicates domain is not registered".to_string()),
672 "medium" => Some(
673 "WHOIS returned no registrar or registration dates; RDAP returned 404"
674 .to_string(),
675 ),
676 _ => None,
677 };
678 let avail = AvailabilityResult {
679 domain: domain.to_string(),
680 available: true,
681 confidence: confidence.to_string(),
682 method: method.to_string(),
683 details,
684 };
685 return Ok(LookupResult::Available {
686 data: Box::new(avail),
687 rdap_error: sanitize_error_for_public(&rdap_error_str),
688 whois_error: String::new(),
689 whois_data: Some(whois_data),
690 });
691 }
692
693 let whois_is_thin = whois_response_is_thin(&whois_data);
700 if whois_is_thin && !rdap_returned_200 {
701 let dns_presence = self.dns_resolver.presence(domain).await;
702 if nxdomain_confirms_available(whois_is_thin, rdap_returned_200, dns_presence) {
703 debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
704 if let Some(ref cb) = progress {
705 cb("Domain appears unregistered (no DNS presence)");
706 }
707 let avail = AvailabilityResult {
708 domain: domain.to_string(),
709 available: true,
710 confidence: "medium".to_string(),
711 method: "dns_nxdomain".to_string(),
712 details: Some(
713 "No registry data available; domain has no DNS presence (NXDOMAIN)"
714 .to_string(),
715 ),
716 };
717 return Ok(LookupResult::Available {
718 data: Box::new(avail),
719 rdap_error: sanitize_error_for_public(&rdap_error_str),
720 whois_error: String::new(),
721 whois_data: Some(whois_data),
722 });
723 }
724
725 if dns_present_confirms_registered(whois_is_thin, rdap_returned_200, dns_presence) {
733 debug!(domain = %domain, "Thin/no-service WHOIS + DNS delegation, reporting registered");
734 if let Some(ref cb) = progress {
735 cb("Domain is registered (registry detail unavailable)");
736 }
737 let details = if whois_data.registry_unavailable() {
738 "Domain is registered (the apex is delegated in DNS). This TLD's \
739 registry provides no port-43 WHOIS data and RDAP was unavailable \
740 (rate-limited or unreachable); retry shortly for full RDAP detail."
741 } else {
742 "Domain is registered (the apex is delegated in DNS). Registry detail \
743 was unavailable (RDAP rate-limited or unreachable and WHOIS returned \
744 no data); retry shortly for full detail."
745 };
746 let avail = AvailabilityResult {
747 domain: domain.to_string(),
748 available: false,
749 confidence: "high".to_string(),
750 method: "dns_present".to_string(),
751 details: Some(details.to_string()),
752 };
753 return Ok(LookupResult::Available {
754 data: Box::new(avail),
755 rdap_error: sanitize_error_for_public(&rdap_error_str),
756 whois_error: String::new(),
757 whois_data: Some(whois_data),
758 });
759 }
760 }
761 debug!("Using WHOIS result (RDAP not useful)");
762 if let Some(ref cb) = progress {
763 cb("RDAP not available (using WHOIS)");
764 }
765 return Ok(LookupResult::Whois {
766 data: whois_data,
767 rdap_error: Some(rdap_error_str),
768 rdap_fallback: rdap_fallback_data,
769 });
770 }
771
772 let whois_error_str = match whois_leg {
776 LegOutcome::Completed(Err(e)) => e.to_string(),
777 LegOutcome::Completed(Ok(_)) => {
778 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
780 "WHOIS returned but was not used".to_string()
781 }
782 LegOutcome::GraceTruncated => format!(
783 "WHOIS did not return within {}s grace period after RDAP won",
784 PROTOCOL_GRACE_PERIOD.as_secs()
785 ),
786 };
787
788 self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
789 .await
790 }
791
792 async fn availability_fallback(
793 &self,
794 domain: &str,
795 rdap_error: String,
796 whois_error: String,
797 progress: Option<LookupProgressCallback>,
798 ) -> Result<LookupResult> {
799 if let Some(ref cb) = progress {
800 cb("RDAP and WHOIS unavailable (checking availability)");
801 }
802 warn!(
803 domain = %domain,
804 rdap_error = %rdap_error,
805 whois_error = %whois_error,
806 "Both RDAP and WHOIS failed, falling back to availability check"
807 );
808
809 match self.availability_checker.check(domain).await {
810 Ok(avail) => Ok(LookupResult::Available {
811 data: Box::new(avail),
812 rdap_error: sanitize_error_for_public(&rdap_error),
813 whois_error: sanitize_error_for_public(&whois_error),
814 whois_data: None,
815 }),
816 Err(avail_err) => {
817 let tld = get_tld(domain).unwrap_or("unknown");
818 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
819 format!("https://www.iana.org/domains/root/db/{}.html", tld)
820 });
821 Err(SeerError::LookupFailed {
822 domain: domain.to_string(),
823 details: format!(
824 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
825 rdap_error, whois_error, avail_err
826 ),
827 registry_url,
828 })
829 }
830 }
831 }
832
833 fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
834 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
836 let has_dates = response
837 .events
838 .iter()
839 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
840 let has_entities = !response.entities.is_empty();
841 let has_nameservers = !response.nameservers.is_empty();
842 let has_status = !response.status.is_empty();
843
844 has_name && (has_dates || has_entities || has_nameservers || has_status)
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852
853 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
863
864 #[test]
865 fn test_lookup_result_domain_name_whois() {
866 let result = LookupResult::Whois {
867 data: WhoisResponse {
868 domain: "example.com".to_string(),
869 registrar: Some("Test Registrar".to_string()),
870 registrant: None,
871 organization: None,
872 registrant_email: None,
873 registrant_phone: None,
874 registrant_address: None,
875 registrant_country: None,
876 admin_name: None,
877 admin_organization: None,
878 admin_email: None,
879 admin_phone: None,
880 tech_name: None,
881 tech_organization: None,
882 tech_email: None,
883 tech_phone: None,
884 creation_date: None,
885 expiration_date: None,
886 updated_date: None,
887 status: vec![],
888 nameservers: vec![],
889 dnssec: None,
890 whois_server: "whois.example.com".to_string(),
891 raw_response: String::new(),
892 },
893 rdap_error: None,
894 rdap_fallback: None,
895 };
896
897 assert_eq!(result.domain_name(), Some("example.com".to_string()));
898 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
899 assert!(result.is_whois());
900 assert!(!result.is_rdap());
901 assert!(!result.is_available());
902 }
903
904 #[test]
905 fn test_lookup_result_serialization() {
906 let result = LookupResult::Whois {
907 data: WhoisResponse {
908 domain: "test.com".to_string(),
909 registrar: None,
910 registrant: None,
911 organization: None,
912 registrant_email: None,
913 registrant_phone: None,
914 registrant_address: None,
915 registrant_country: None,
916 admin_name: None,
917 admin_organization: None,
918 admin_email: None,
919 admin_phone: None,
920 tech_name: None,
921 tech_organization: None,
922 tech_email: None,
923 tech_phone: None,
924 creation_date: None,
925 expiration_date: None,
926 updated_date: None,
927 status: vec![],
928 nameservers: vec![],
929 dnssec: None,
930 whois_server: String::new(),
931 raw_response: String::new(),
932 },
933 rdap_error: Some("RDAP failed".to_string()),
934 rdap_fallback: None,
935 };
936
937 let json = serde_json::to_string(&result).unwrap();
938 assert!(json.contains("\"source\":\"whois\""));
939 assert!(json.contains("RDAP failed"));
940 }
941
942 #[test]
943 fn test_lookup_result_available_serialization() {
944 let result = LookupResult::Available {
945 data: Box::new(AvailabilityResult {
946 domain: "test123.xyz".to_string(),
947 available: true,
948 confidence: "medium".to_string(),
949 method: "whois_error".to_string(),
950 details: Some("WHOIS server indicates no matching records".to_string()),
951 }),
952 rdap_error: "RDAP failed".to_string(),
953 whois_error: "WHOIS failed".to_string(),
954 whois_data: None,
955 };
956
957 let json = serde_json::to_string(&result).unwrap();
958 assert!(json.contains("\"source\":\"available\""));
959 assert!(json.contains("\"available\":true"));
960 assert!(json.contains("test123.xyz"));
961
962 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
963 assert!(result.is_available());
964 assert!(!result.is_rdap());
965 assert!(!result.is_whois());
966 assert!(result.registrar().is_none());
967 assert_eq!(result.expiration_info(), (None, None));
968 }
969
970 #[test]
971 #[allow(deprecated)]
972 fn test_smart_lookup_builder() {
973 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
974 assert!(!lookup.prefer_rdap);
975 assert!(lookup.include_fallback);
976 }
977
978 #[test]
979 fn test_lookup_cache_clear() {
980 SmartLookup::clear_cache();
981 assert!(LOOKUP_CACHE.is_empty());
982 }
983
984 #[test]
987 fn test_sanitize_strips_ipv4() {
988 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
989 let sanitized = sanitize_error_for_public(msg);
990 assert!(
991 !sanitized.contains("10.0.0.1"),
992 "IPv4 should be stripped, got: {}",
993 sanitized
994 );
995 assert!(sanitized.contains("[ip-redacted]"));
996 }
997
998 #[test]
999 fn test_sanitize_strips_multiple_ipv4() {
1000 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1001 let sanitized = sanitize_error_for_public(msg);
1002 assert!(!sanitized.contains("192.168.1.1"));
1003 assert!(!sanitized.contains("127.0.0.1"));
1004 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1006 }
1007
1008 #[test]
1009 fn test_sanitize_strips_ipv6() {
1010 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1011 let sanitized = sanitize_error_for_public(msg);
1012 assert!(!sanitized.contains("fe80::1"));
1013 assert!(sanitized.contains("[ip-redacted]"));
1014 }
1015
1016 #[test]
1017 fn sanitize_leaves_mac_address_like_tokens_alone() {
1018 let msg = "error code af:ba:12 at line 5";
1019 let out = sanitize_error_for_public(msg);
1020 assert!(
1021 out.contains("af:ba:12"),
1022 "MAC fragment should not be stripped: {}",
1023 out
1024 );
1025 }
1026
1027 #[test]
1028 fn sanitize_strips_real_ipv6() {
1029 let msg = "cannot reach 2001:db8::1 — timeout";
1030 let out = sanitize_error_for_public(msg);
1031 assert!(!out.contains("2001:db8::1"));
1032 assert!(out.contains("[ip-redacted]"));
1033 }
1034
1035 #[test]
1036 fn sanitize_strips_fe80_link_local() {
1037 let msg = "peer at fe80::1 unreachable";
1038 let out = sanitize_error_for_public(msg);
1039 assert!(out.contains("[ip-redacted]"));
1040 }
1041
1042 #[test]
1043 fn test_sanitize_truncates_long_message() {
1044 let long = "a".repeat(500);
1046 let sanitized = sanitize_error_for_public(&long);
1047 let char_count = sanitized.chars().count();
1049 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1050 assert!(sanitized.ends_with('…'));
1051 }
1052
1053 #[test]
1054 fn test_sanitize_preserves_short_messages() {
1055 let msg = "RDAP timed out after 15s";
1056 let sanitized = sanitize_error_for_public(msg);
1057 assert_eq!(sanitized, msg);
1058 }
1059
1060 #[test]
1063 fn test_is_rdap_response_useful_detects_no_data() {
1064 use crate::rdap::RdapResponse;
1065 let resp = RdapResponse {
1069 ldh_name: Some("example.com".to_string()),
1070 ..Default::default()
1071 };
1072 let lookup = SmartLookup::new();
1073 assert!(
1074 !lookup.is_rdap_response_useful(&resp),
1075 "Response with only a name should be classified as NoData"
1076 );
1077
1078 let useful = RdapResponse {
1080 ldh_name: Some("example.com".to_string()),
1081 status: vec!["active".to_string()],
1082 ..Default::default()
1083 };
1084 assert!(lookup.is_rdap_response_useful(&useful));
1085 }
1086
1087 #[tokio::test]
1095 async fn test_inflight_coalescing_map() {
1096 let _serial = INFLIGHT_TEST_SERIAL
1101 .lock()
1102 .unwrap_or_else(|p| p.into_inner());
1103 let domain = unique_test_key("__coalesce");
1112
1113 {
1115 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1116 m.remove(&domain);
1117 }
1118
1119 let owner_notify = Arc::new(Notify::new());
1121 {
1122 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1123 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1124 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1125 }
1126
1127 let waiter = {
1129 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1130 m.get(&domain)
1131 .and_then(|w| w.upgrade())
1132 .expect("Second caller must observe in-flight entry")
1133 };
1134
1135 let waiter_clone = waiter.clone();
1137 let handle = tokio::spawn(async move {
1138 waiter_clone.notified().await;
1139 });
1140
1141 tokio::time::sleep(Duration::from_millis(20)).await;
1143 {
1144 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1145 m.remove(&domain);
1146 }
1147 owner_notify.notify_waiters();
1148
1149 tokio::time::timeout(Duration::from_secs(1), handle)
1151 .await
1152 .expect("waiter must unblock after notify")
1153 .expect("waiter task joined cleanly");
1154
1155 drop(owner_notify);
1157 drop(waiter);
1158 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1159 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1160 }
1161
1162 fn unique_test_key(prefix: &str) -> String {
1168 use std::sync::atomic::{AtomicU64, Ordering};
1169 use std::time::{SystemTime, UNIX_EPOCH};
1170 static COUNTER: AtomicU64 = AtomicU64::new(0);
1171 let nanos = SystemTime::now()
1172 .duration_since(UNIX_EPOCH)
1173 .map(|d| d.as_nanos())
1174 .unwrap_or(0);
1175 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1176 format!("{}_{}_{}.example.", prefix, nanos, n)
1177 }
1178
1179 #[test]
1185 fn test_sanitize_applied_to_available_fields() {
1186 let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1187 let whois_raw = "connection refused at 192.168.0.5";
1188 let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1189 let sanitized_whois = sanitize_error_for_public(whois_raw);
1190 let result = LookupResult::Available {
1191 data: Box::new(AvailabilityResult {
1192 domain: "unreg.test".to_string(),
1193 available: true,
1194 confidence: "low".to_string(),
1195 method: "heuristic".to_string(),
1196 details: None,
1197 }),
1198 rdap_error: sanitized_rdap,
1199 whois_error: sanitized_whois,
1200 whois_data: None,
1201 };
1202 if let LookupResult::Available {
1203 rdap_error,
1204 whois_error,
1205 ..
1206 } = result
1207 {
1208 assert!(!rdap_error.contains("10.0.0.1"));
1209 assert!(!whois_error.contains("192.168.0.5"));
1210 assert!(rdap_error.contains("[ip-redacted]"));
1211 assert!(whois_error.contains("[ip-redacted]"));
1212 } else {
1213 panic!("expected Available variant");
1214 }
1215 }
1216
1217 #[test]
1218 fn rdap_error_is_404_matches_standard_404() {
1219 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1220 assert!(rdap_error_is_404(&e));
1221 }
1222
1223 #[test]
1224 fn rdap_error_is_404_matches_without_reason_phrase() {
1225 let e = SeerError::RdapError("query failed with status 404".to_string());
1226 assert!(rdap_error_is_404(&e));
1227 }
1228
1229 #[test]
1230 fn rdap_error_is_404_rejects_other_statuses() {
1231 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1232 assert!(!rdap_error_is_404(&e));
1233 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1234 assert!(!rdap_error_is_404(&e));
1235 }
1236
1237 #[test]
1238 fn rdap_error_is_404_rejects_non_http_errors() {
1239 let e = SeerError::RdapError("connection timeout".to_string());
1240 assert!(!rdap_error_is_404(&e));
1241 let e = SeerError::Timeout("rdap".to_string());
1242 assert!(!rdap_error_is_404(&e));
1243 }
1244
1245 #[test]
1246 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1247 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1249 assert!(!rdap_error_is_404(&e));
1250 }
1251
1252 fn empty_whois(domain: &str) -> WhoisResponse {
1255 WhoisResponse {
1256 domain: domain.to_string(),
1257 registrar: None,
1258 registrant: None,
1259 organization: None,
1260 registrant_email: None,
1261 registrant_phone: None,
1262 registrant_address: None,
1263 registrant_country: None,
1264 admin_name: None,
1265 admin_organization: None,
1266 admin_email: None,
1267 admin_phone: None,
1268 tech_name: None,
1269 tech_organization: None,
1270 tech_email: None,
1271 tech_phone: None,
1272 creation_date: None,
1273 expiration_date: None,
1274 updated_date: None,
1275 nameservers: vec![],
1276 status: vec![],
1277 dnssec: None,
1278 whois_server: String::new(),
1279 raw_response: String::new(),
1280 }
1281 }
1282
1283 #[test]
1284 fn whois_response_is_thin_when_all_key_fields_missing() {
1285 let w = empty_whois("example.com");
1286 assert!(whois_response_is_thin(&w));
1287 }
1288
1289 #[test]
1290 fn whois_response_is_not_thin_when_registrar_present() {
1291 let mut w = empty_whois("example.com");
1292 w.registrar = Some("Test Registrar".to_string());
1293 assert!(!whois_response_is_thin(&w));
1294 }
1295
1296 #[test]
1297 fn whois_response_is_not_thin_when_creation_date_present() {
1298 let mut w = empty_whois("example.com");
1299 w.creation_date = Some(Utc::now());
1300 assert!(!whois_response_is_thin(&w));
1301 }
1302
1303 #[test]
1304 fn whois_response_is_not_thin_when_expiration_date_present() {
1305 let mut w = empty_whois("example.com");
1306 w.expiration_date = Some(Utc::now());
1307 assert!(!whois_response_is_thin(&w));
1308 }
1309
1310 #[test]
1311 fn whois_response_is_thin_even_with_nameservers_alone() {
1312 let mut w = empty_whois("example.com");
1313 w.nameservers = vec!["ns1.example.net".to_string()];
1314 assert!(whois_response_is_thin(&w));
1315 }
1316
1317 use crate::rdap::RdapResponse;
1320
1321 #[allow(dead_code)]
1322 fn make_empty_rdap_response() -> RdapResponse {
1323 serde_json::from_value(serde_json::json!({
1324 "objectClassName": "domain",
1325 }))
1326 .expect("valid minimal RDAP response")
1327 }
1328
1329 #[test]
1330 fn classify_whois_leg_case_a_high_confidence() {
1331 let mut w = empty_whois("zaccodes.com");
1332 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1333 assert!(w.is_available());
1334 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1335 let (verdict, method) =
1336 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1337 assert_eq!(verdict, "high");
1338 assert_eq!(method, "whois");
1339 }
1340
1341 #[test]
1342 fn classify_whois_leg_case_b_medium_confidence() {
1343 let w = empty_whois("example.xyz");
1344 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1345 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1346 let (verdict, method) =
1347 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1348 assert_eq!(verdict, "medium");
1349 assert_eq!(method, "whois_thin_response");
1350 }
1351
1352 #[test]
1353 fn classify_whois_leg_rejects_thin_whois_without_404() {
1354 let w = empty_whois("example.xyz");
1355 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1356 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1357 }
1358
1359 #[test]
1360 fn classify_whois_leg_rejects_whois_with_real_data() {
1361 let mut w = empty_whois("legacy.tld");
1362 w.registrar = Some("Legacy Registry".to_string());
1363 w.creation_date = Some(Utc::now());
1364 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1365 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1366 }
1367
1368 #[test]
1369 fn classify_whois_leg_case_a_wins_over_case_b() {
1370 let mut w = empty_whois("example.com");
1371 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1372 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1373 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1374 assert_eq!(verdict, "high");
1375 }
1376
1377 #[test]
1385 fn rdap_200_vetoes_whois_no_match() {
1386 let mut w = empty_whois("freshly-registered.com");
1387 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1388 assert!(
1390 should_route_to_availability(true, None, &w).is_none(),
1391 "RDAP 200 must veto WHOIS-only availability claim",
1392 );
1393 }
1394
1395 #[test]
1396 fn rdap_200_vetoes_even_with_thin_whois() {
1397 let w = empty_whois("freshly-registered.com");
1398 assert!(
1400 should_route_to_availability(true, None, &w).is_none(),
1401 "RDAP 200 must veto even when WHOIS is thin",
1402 );
1403 }
1404
1405 #[test]
1406 fn rdap_404_with_whois_no_match_routes_to_available() {
1407 let mut w = empty_whois("genuinely-free.com");
1408 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1409 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1410 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1411 assert_eq!(result, Some(("high", "whois")));
1412 }
1413
1414 #[test]
1415 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1416 let mut w = empty_whois("genuinely-free.com");
1417 w.raw_response = "Domain not found".to_string();
1418 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1421 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1422 assert_eq!(result, Some(("high", "whois")));
1423 }
1424
1425 #[test]
1426 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1427 let mut w = empty_whois("genuinely-free.com");
1429 w.raw_response = "No match".to_string();
1430 let result = should_route_to_availability(false, None, &w);
1431 assert_eq!(result, Some(("high", "whois")));
1432 }
1433
1434 #[test]
1435 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1436 let mut w = empty_whois("registered.com");
1437 w.registrar = Some("Example Registrar Ltd".to_string());
1438 assert!(should_route_to_availability(false, None, &w).is_none());
1442 }
1443
1444 #[test]
1447 fn nxdomain_confirms_available_thin_no200_absent() {
1448 assert!(nxdomain_confirms_available(
1449 true,
1450 false,
1451 DnsPresence::Absent
1452 ));
1453 }
1454
1455 #[test]
1456 fn nxdomain_confirms_available_vetoed_by_rdap_200() {
1457 assert!(!nxdomain_confirms_available(
1460 true,
1461 true,
1462 DnsPresence::Absent
1463 ));
1464 }
1465
1466 #[test]
1467 fn nxdomain_confirms_available_requires_thin_whois() {
1468 assert!(!nxdomain_confirms_available(
1470 false,
1471 false,
1472 DnsPresence::Absent
1473 ));
1474 }
1475
1476 #[test]
1477 fn nxdomain_confirms_available_requires_absent_dns() {
1478 assert!(!nxdomain_confirms_available(
1479 true,
1480 false,
1481 DnsPresence::Present
1482 ));
1483 assert!(!nxdomain_confirms_available(
1484 true,
1485 false,
1486 DnsPresence::Unknown
1487 ));
1488 }
1489
1490 #[test]
1493 fn dns_present_confirms_registered_thin_no200_present() {
1494 assert!(dns_present_confirms_registered(
1498 true,
1499 false,
1500 DnsPresence::Present
1501 ));
1502 }
1503
1504 #[test]
1505 fn dns_present_confirms_registered_requires_present_dns() {
1506 assert!(!dns_present_confirms_registered(
1509 true,
1510 false,
1511 DnsPresence::Absent
1512 ));
1513 assert!(!dns_present_confirms_registered(
1514 true,
1515 false,
1516 DnsPresence::Unknown
1517 ));
1518 }
1519
1520 #[test]
1521 fn dns_present_confirms_registered_requires_thin_whois() {
1522 assert!(!dns_present_confirms_registered(
1524 false,
1525 false,
1526 DnsPresence::Present
1527 ));
1528 }
1529
1530 #[test]
1531 fn dns_present_confirms_registered_vetoed_by_rdap_200() {
1532 assert!(!dns_present_confirms_registered(
1534 true,
1535 true,
1536 DnsPresence::Present
1537 ));
1538 }
1539
1540 #[test]
1550 fn lookup_inflight_recovers_from_poisoned_mutex() {
1551 use std::panic::{catch_unwind, AssertUnwindSafe};
1552
1553 let _serial = INFLIGHT_TEST_SERIAL
1555 .lock()
1556 .unwrap_or_else(|p| p.into_inner());
1557
1558 let _ = catch_unwind(AssertUnwindSafe(|| {
1560 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1561 panic!("poisoning LOOKUP_INFLIGHT for test");
1562 }));
1563
1564 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1568 let canary = unique_test_key("__poison_recovery");
1570 guard.insert(canary.clone(), Weak::new());
1571 assert!(guard.contains_key(&canary));
1572 guard.remove(&canary);
1573 }
1574
1575 #[test]
1578 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1579 use std::panic::{catch_unwind, AssertUnwindSafe};
1580
1581 let _serial = INFLIGHT_TEST_SERIAL
1587 .lock()
1588 .unwrap_or_else(|p| p.into_inner());
1589
1590 let key = unique_test_key("__drop_poison");
1595 let notify = Arc::new(Notify::new());
1596 {
1597 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1598 map.insert(key.clone(), Arc::downgrade(¬ify));
1599 }
1600 let guard = InflightGuard {
1601 key: key.clone(),
1602 notify: notify.clone(),
1603 };
1604
1605 let _ = catch_unwind(AssertUnwindSafe(|| {
1607 let _g = LOOKUP_INFLIGHT.lock().unwrap();
1608 panic!("poisoning LOOKUP_INFLIGHT for drop test");
1609 }));
1610
1611 drop(guard);
1614
1615 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1616 assert!(
1617 !map.contains_key(&key),
1618 "poisoned-mutex drop path should still remove the in-flight entry"
1619 );
1620 }
1621}