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 sanitize_error_for_public(msg: &str) -> String {
160 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
161 let s = strip_ipv6(&s);
162 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
163 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
164 trunc.push('…');
165 trunc
166 } else {
167 s
168 }
169}
170
171struct InflightGuard {
184 key: String,
185 notify: Arc<Notify>,
186}
187
188impl Drop for InflightGuard {
189 fn drop(&mut self) {
190 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
203 inflight.remove(&self.key);
204 drop(inflight);
205 self.notify.notify_waiters();
206 }
207}
208
209enum RdapOutcome {
215 Useful(RdapResponse),
216 NoData(RdapResponse),
217 Error(SeerError),
218 GraceTimeout,
221}
222
223pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(tag = "source", rename_all = "lowercase")]
229pub enum LookupResult {
230 Rdap {
231 data: Box<RdapResponse>,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 whois_fallback: Option<WhoisResponse>,
234 },
235 Whois {
236 data: WhoisResponse,
237 rdap_error: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 rdap_fallback: Option<Box<RdapResponse>>,
240 },
241 Available {
242 data: Box<AvailabilityResult>,
243 rdap_error: String,
244 whois_error: String,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
249 whois_data: Option<WhoisResponse>,
250 },
251}
252
253impl LookupResult {
254 pub fn domain_name(&self) -> Option<String> {
256 match self {
257 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
258 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
259 LookupResult::Available { data, .. } => Some(data.domain.clone()),
260 }
261 }
262
263 pub fn registrar(&self) -> Option<String> {
265 match self {
266 LookupResult::Rdap {
267 data,
268 whois_fallback,
269 } => data
270 .get_registrar()
271 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
272 LookupResult::Whois { data, .. } => data.registrar.clone(),
273 LookupResult::Available { .. } => None,
274 }
275 }
276
277 pub fn organization(&self) -> Option<String> {
279 match self {
280 LookupResult::Rdap {
281 data,
282 whois_fallback,
283 } => data
284 .get_registrant_organization()
285 .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
286 LookupResult::Whois { data, .. } => data.organization.clone(),
287 LookupResult::Available { .. } => None,
288 }
289 }
290
291 pub fn is_rdap(&self) -> bool {
293 matches!(self, LookupResult::Rdap { .. })
294 }
295
296 pub fn is_whois(&self) -> bool {
298 matches!(self, LookupResult::Whois { .. })
299 }
300
301 pub fn is_available(&self) -> bool {
303 matches!(self, LookupResult::Available { .. })
304 }
305
306 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
308 match self {
309 LookupResult::Rdap {
310 data,
311 whois_fallback,
312 } => {
313 let expiration_date = data
315 .events
316 .iter()
317 .find(|e| e.event_action == "expiration")
318 .and_then(|e| e.parsed_date())
319 .or_else(|| {
320 whois_fallback.as_ref().and_then(|w| w.expiration_date)
322 });
323
324 let registrar = data
325 .get_registrar()
326 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
327
328 (expiration_date, registrar)
329 }
330 LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
331 LookupResult::Available { .. } => (None, None),
332 }
333 }
334}
335
336fn trim_for_cache(mut result: LookupResult) -> LookupResult {
340 const MAX_RAW: usize = 32 * 1024;
341
342 match result {
343 LookupResult::Whois { ref mut data, .. } => {
344 if data.raw_response.len() > MAX_RAW {
345 data.raw_response.truncate(MAX_RAW);
346 data.raw_response.push_str("\n... [truncated for cache]");
347 }
348 }
349 LookupResult::Rdap {
350 ref mut whois_fallback,
351 ..
352 } => {
353 if let Some(ref mut w) = whois_fallback {
354 if w.raw_response.len() > MAX_RAW {
355 w.raw_response.truncate(MAX_RAW);
356 w.raw_response.push_str("\n... [truncated for cache]");
357 }
358 }
359 }
360 LookupResult::Available {
361 ref mut whois_data, ..
362 } => {
363 if let Some(ref mut w) = whois_data {
364 if w.raw_response.len() > MAX_RAW {
365 w.raw_response.truncate(MAX_RAW);
366 w.raw_response.push_str("\n... [truncated for cache]");
367 }
368 }
369 }
370 }
371
372 result
373}
374
375#[derive(Debug, Clone)]
376pub struct SmartLookup {
377 rdap_client: RdapClient,
378 whois_client: WhoisClient,
379 availability_checker: AvailabilityChecker,
380 dns_resolver: DnsResolver,
381 prefer_rdap: bool,
383 include_fallback: bool,
385}
386
387impl Default for SmartLookup {
388 fn default() -> Self {
389 Self::new()
390 }
391}
392
393impl SmartLookup {
394 pub fn new() -> Self {
397 Self {
398 rdap_client: RdapClient::new(),
399 whois_client: WhoisClient::new(),
400 availability_checker: AvailabilityChecker::new(),
401 dns_resolver: DnsResolver::new(),
402 prefer_rdap: true,
403 include_fallback: false,
404 }
405 }
406
407 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
410 pub fn prefer_rdap(mut self, prefer: bool) -> Self {
411 self.prefer_rdap = prefer;
412 self
413 }
414
415 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
418 pub fn include_fallback(mut self, include: bool) -> Self {
419 self.include_fallback = include;
420 self
421 }
422
423 #[instrument(skip(self), fields(domain = %domain))]
427 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
428 self.lookup_with_progress(domain, None).await
429 }
430
431 #[instrument(skip(self, progress), fields(domain = %domain))]
436 pub async fn lookup_with_progress(
437 &self,
438 domain: &str,
439 progress: Option<LookupProgressCallback>,
440 ) -> Result<LookupResult> {
441 let normalized = crate::validation::normalize_domain(domain)?;
442
443 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
445 debug!(domain = %normalized, "Returning cached lookup result");
446 return Ok(cached);
447 }
448
449 let _guard = loop {
461 enum Slot {
462 Waiter(Arc<Notify>),
463 Owner(InflightGuard),
464 }
465
466 let slot = {
467 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
471 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
472 Some(existing) => Slot::Waiter(existing),
473 None => {
474 let n = Arc::new(Notify::new());
475 inflight.insert(normalized.clone(), Arc::downgrade(&n));
476 Slot::Owner(InflightGuard {
477 key: normalized.clone(),
478 notify: n,
479 })
480 }
481 }
482 };
483
484 match slot {
485 Slot::Waiter(n) => {
486 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
487 n.notified().await;
488 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
489 return Ok(cached);
490 }
491 continue;
494 }
495 Slot::Owner(guard) => break guard,
496 }
497 };
498
499 let result = self.lookup_concurrent(&normalized, progress).await?;
500
501 LOOKUP_CACHE.insert(normalized.clone(), trim_for_cache(result.clone()));
504
505 Ok(result)
506 }
507
508 pub fn clear_cache() {
510 LOOKUP_CACHE.clear();
511 }
512
513 #[instrument(skip(self, progress), fields(domain = %domain))]
514 async fn lookup_concurrent(
515 &self,
516 domain: &str,
517 progress: Option<LookupProgressCallback>,
518 ) -> Result<LookupResult> {
519 #[cfg(test)]
520 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
521
522 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
523
524 if let Some(ref cb) = progress {
525 cb("Querying RDAP and WHOIS concurrently");
526 }
527
528 let rdap_fut = self.rdap_client.lookup_domain(domain);
529 let whois_fut = self.whois_client.lookup(domain);
530
531 tokio::pin!(rdap_fut);
532 tokio::pin!(whois_fut);
533
534 enum LegOutcome<T> {
540 Completed(T),
541 GraceTruncated,
542 }
543
544 let (rdap_leg, whois_leg) = tokio::select! {
545 rdap_res = &mut rdap_fut => {
546 let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
548 Ok(res) => LegOutcome::Completed(res),
549 Err(_) => {
550 debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
551 LegOutcome::GraceTruncated
552 }
553 };
554 (LegOutcome::Completed(rdap_res), whois_leg)
555 }
556 whois_res = &mut whois_fut => {
557 let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
559 Ok(res) => LegOutcome::Completed(res),
560 Err(_) => {
561 debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
562 LegOutcome::GraceTruncated
563 }
564 };
565 (rdap_leg, LegOutcome::Completed(whois_res))
566 }
567 };
568
569 let rdap_outcome = match rdap_leg {
571 LegOutcome::Completed(Ok(data)) => {
572 if self.is_rdap_response_useful(&data) {
573 RdapOutcome::Useful(data)
574 } else {
575 RdapOutcome::NoData(data)
576 }
577 }
578 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
579 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
580 };
581
582 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
584 debug!("RDAP lookup successful");
585 let whois_fallback = match whois_leg {
586 LegOutcome::Completed(Ok(w)) => Some(w),
587 _ => None,
588 };
589 return Ok(LookupResult::Rdap {
590 data: Box::new(rdap_data),
591 whois_fallback,
592 });
593 }
594
595 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
606 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
607 RdapOutcome::Useful(_) => {
608 debug!("Unexpected RdapOutcome::Useful in fallback branch");
611 (String::from("RDAP ok"), None, None)
612 }
613 RdapOutcome::NoData(data) => (
614 "RDAP response incomplete".to_string(),
615 Some(Box::new(data)),
616 None,
617 ),
618 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
619 RdapOutcome::GraceTimeout => (
620 format!(
621 "RDAP did not return within {}s grace period after WHOIS won",
622 PROTOCOL_GRACE_PERIOD.as_secs()
623 ),
624 None,
625 None,
626 ),
627 };
628
629 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
630 let availability_match = should_route_to_availability(
634 rdap_returned_200,
635 rdap_seer_error.as_ref(),
636 &whois_data,
637 );
638
639 if let Some((confidence, method)) = availability_match {
640 debug!(
641 domain = %domain,
642 confidence = %confidence,
643 "Reclassifying WHOIS as availability signal"
644 );
645 if let Some(ref cb) = progress {
646 cb("Domain appears unregistered");
647 }
648 let details = match confidence {
649 "high" => Some("WHOIS indicates domain is not registered".to_string()),
650 "medium" => Some(
651 "WHOIS returned no registrar or registration dates; RDAP returned 404"
652 .to_string(),
653 ),
654 _ => None,
655 };
656 let avail = AvailabilityResult {
657 domain: domain.to_string(),
658 available: true,
659 confidence: confidence.to_string(),
660 method: method.to_string(),
661 details,
662 };
663 return Ok(LookupResult::Available {
664 data: Box::new(avail),
665 rdap_error: sanitize_error_for_public(&rdap_error_str),
666 whois_error: String::new(),
667 whois_data: Some(whois_data),
668 });
669 }
670
671 let whois_is_thin = whois_response_is_thin(&whois_data);
678 if whois_is_thin && !rdap_returned_200 {
679 let dns_presence = self.dns_resolver.presence(domain).await;
680 if nxdomain_confirms_available(whois_is_thin, rdap_returned_200, dns_presence) {
681 debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
682 if let Some(ref cb) = progress {
683 cb("Domain appears unregistered (no DNS presence)");
684 }
685 let avail = AvailabilityResult {
686 domain: domain.to_string(),
687 available: true,
688 confidence: "medium".to_string(),
689 method: "dns_nxdomain".to_string(),
690 details: Some(
691 "No registry data available; domain has no DNS presence (NXDOMAIN)"
692 .to_string(),
693 ),
694 };
695 return Ok(LookupResult::Available {
696 data: Box::new(avail),
697 rdap_error: sanitize_error_for_public(&rdap_error_str),
698 whois_error: String::new(),
699 whois_data: Some(whois_data),
700 });
701 }
702 }
703 debug!("Using WHOIS result (RDAP not useful)");
704 if let Some(ref cb) = progress {
705 cb("RDAP not available (using WHOIS)");
706 }
707 return Ok(LookupResult::Whois {
708 data: whois_data,
709 rdap_error: Some(rdap_error_str),
710 rdap_fallback: rdap_fallback_data,
711 });
712 }
713
714 let whois_error_str = match whois_leg {
718 LegOutcome::Completed(Err(e)) => e.to_string(),
719 LegOutcome::Completed(Ok(_)) => {
720 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
722 "WHOIS returned but was not used".to_string()
723 }
724 LegOutcome::GraceTruncated => format!(
725 "WHOIS did not return within {}s grace period after RDAP won",
726 PROTOCOL_GRACE_PERIOD.as_secs()
727 ),
728 };
729
730 self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
731 .await
732 }
733
734 async fn availability_fallback(
735 &self,
736 domain: &str,
737 rdap_error: String,
738 whois_error: String,
739 progress: Option<LookupProgressCallback>,
740 ) -> Result<LookupResult> {
741 if let Some(ref cb) = progress {
742 cb("RDAP and WHOIS unavailable (checking availability)");
743 }
744 warn!(
745 domain = %domain,
746 rdap_error = %rdap_error,
747 whois_error = %whois_error,
748 "Both RDAP and WHOIS failed, falling back to availability check"
749 );
750
751 match self.availability_checker.check(domain).await {
752 Ok(avail) => Ok(LookupResult::Available {
753 data: Box::new(avail),
754 rdap_error: sanitize_error_for_public(&rdap_error),
755 whois_error: sanitize_error_for_public(&whois_error),
756 whois_data: None,
757 }),
758 Err(avail_err) => {
759 let tld = get_tld(domain).unwrap_or("unknown");
760 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
761 format!("https://www.iana.org/domains/root/db/{}.html", tld)
762 });
763 Err(SeerError::LookupFailed {
764 domain: domain.to_string(),
765 details: format!(
766 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
767 rdap_error, whois_error, avail_err
768 ),
769 registry_url,
770 })
771 }
772 }
773 }
774
775 fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
776 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
778 let has_dates = response
779 .events
780 .iter()
781 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
782 let has_entities = !response.entities.is_empty();
783 let has_nameservers = !response.nameservers.is_empty();
784 let has_status = !response.status.is_empty();
785
786 has_name && (has_dates || has_entities || has_nameservers || has_status)
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794
795 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
805
806 #[test]
807 fn test_lookup_result_domain_name_whois() {
808 let result = LookupResult::Whois {
809 data: WhoisResponse {
810 domain: "example.com".to_string(),
811 registrar: Some("Test Registrar".to_string()),
812 registrant: None,
813 organization: None,
814 registrant_email: None,
815 registrant_phone: None,
816 registrant_address: None,
817 registrant_country: None,
818 admin_name: None,
819 admin_organization: None,
820 admin_email: None,
821 admin_phone: None,
822 tech_name: None,
823 tech_organization: None,
824 tech_email: None,
825 tech_phone: None,
826 creation_date: None,
827 expiration_date: None,
828 updated_date: None,
829 status: vec![],
830 nameservers: vec![],
831 dnssec: None,
832 whois_server: "whois.example.com".to_string(),
833 raw_response: String::new(),
834 },
835 rdap_error: None,
836 rdap_fallback: None,
837 };
838
839 assert_eq!(result.domain_name(), Some("example.com".to_string()));
840 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
841 assert!(result.is_whois());
842 assert!(!result.is_rdap());
843 assert!(!result.is_available());
844 }
845
846 #[test]
847 fn test_lookup_result_serialization() {
848 let result = LookupResult::Whois {
849 data: WhoisResponse {
850 domain: "test.com".to_string(),
851 registrar: None,
852 registrant: None,
853 organization: None,
854 registrant_email: None,
855 registrant_phone: None,
856 registrant_address: None,
857 registrant_country: None,
858 admin_name: None,
859 admin_organization: None,
860 admin_email: None,
861 admin_phone: None,
862 tech_name: None,
863 tech_organization: None,
864 tech_email: None,
865 tech_phone: None,
866 creation_date: None,
867 expiration_date: None,
868 updated_date: None,
869 status: vec![],
870 nameservers: vec![],
871 dnssec: None,
872 whois_server: String::new(),
873 raw_response: String::new(),
874 },
875 rdap_error: Some("RDAP failed".to_string()),
876 rdap_fallback: None,
877 };
878
879 let json = serde_json::to_string(&result).unwrap();
880 assert!(json.contains("\"source\":\"whois\""));
881 assert!(json.contains("RDAP failed"));
882 }
883
884 #[test]
885 fn test_lookup_result_available_serialization() {
886 let result = LookupResult::Available {
887 data: Box::new(AvailabilityResult {
888 domain: "test123.xyz".to_string(),
889 available: true,
890 confidence: "medium".to_string(),
891 method: "whois_error".to_string(),
892 details: Some("WHOIS server indicates no matching records".to_string()),
893 }),
894 rdap_error: "RDAP failed".to_string(),
895 whois_error: "WHOIS failed".to_string(),
896 whois_data: None,
897 };
898
899 let json = serde_json::to_string(&result).unwrap();
900 assert!(json.contains("\"source\":\"available\""));
901 assert!(json.contains("\"available\":true"));
902 assert!(json.contains("test123.xyz"));
903
904 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
905 assert!(result.is_available());
906 assert!(!result.is_rdap());
907 assert!(!result.is_whois());
908 assert!(result.registrar().is_none());
909 assert_eq!(result.expiration_info(), (None, None));
910 }
911
912 #[test]
913 #[allow(deprecated)]
914 fn test_smart_lookup_builder() {
915 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
916 assert!(!lookup.prefer_rdap);
917 assert!(lookup.include_fallback);
918 }
919
920 #[test]
921 fn test_lookup_cache_clear() {
922 SmartLookup::clear_cache();
923 assert!(LOOKUP_CACHE.is_empty());
924 }
925
926 #[test]
929 fn test_sanitize_strips_ipv4() {
930 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
931 let sanitized = sanitize_error_for_public(msg);
932 assert!(
933 !sanitized.contains("10.0.0.1"),
934 "IPv4 should be stripped, got: {}",
935 sanitized
936 );
937 assert!(sanitized.contains("[ip-redacted]"));
938 }
939
940 #[test]
941 fn test_sanitize_strips_multiple_ipv4() {
942 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
943 let sanitized = sanitize_error_for_public(msg);
944 assert!(!sanitized.contains("192.168.1.1"));
945 assert!(!sanitized.contains("127.0.0.1"));
946 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
948 }
949
950 #[test]
951 fn test_sanitize_strips_ipv6() {
952 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
953 let sanitized = sanitize_error_for_public(msg);
954 assert!(!sanitized.contains("fe80::1"));
955 assert!(sanitized.contains("[ip-redacted]"));
956 }
957
958 #[test]
959 fn sanitize_leaves_mac_address_like_tokens_alone() {
960 let msg = "error code af:ba:12 at line 5";
961 let out = sanitize_error_for_public(msg);
962 assert!(
963 out.contains("af:ba:12"),
964 "MAC fragment should not be stripped: {}",
965 out
966 );
967 }
968
969 #[test]
970 fn sanitize_strips_real_ipv6() {
971 let msg = "cannot reach 2001:db8::1 — timeout";
972 let out = sanitize_error_for_public(msg);
973 assert!(!out.contains("2001:db8::1"));
974 assert!(out.contains("[ip-redacted]"));
975 }
976
977 #[test]
978 fn sanitize_strips_fe80_link_local() {
979 let msg = "peer at fe80::1 unreachable";
980 let out = sanitize_error_for_public(msg);
981 assert!(out.contains("[ip-redacted]"));
982 }
983
984 #[test]
985 fn test_sanitize_truncates_long_message() {
986 let long = "a".repeat(500);
988 let sanitized = sanitize_error_for_public(&long);
989 let char_count = sanitized.chars().count();
991 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
992 assert!(sanitized.ends_with('…'));
993 }
994
995 #[test]
996 fn test_sanitize_preserves_short_messages() {
997 let msg = "RDAP timed out after 15s";
998 let sanitized = sanitize_error_for_public(msg);
999 assert_eq!(sanitized, msg);
1000 }
1001
1002 #[test]
1005 fn test_is_rdap_response_useful_detects_no_data() {
1006 use crate::rdap::RdapResponse;
1007 let resp = RdapResponse {
1011 ldh_name: Some("example.com".to_string()),
1012 ..Default::default()
1013 };
1014 let lookup = SmartLookup::new();
1015 assert!(
1016 !lookup.is_rdap_response_useful(&resp),
1017 "Response with only a name should be classified as NoData"
1018 );
1019
1020 let useful = RdapResponse {
1022 ldh_name: Some("example.com".to_string()),
1023 status: vec!["active".to_string()],
1024 ..Default::default()
1025 };
1026 assert!(lookup.is_rdap_response_useful(&useful));
1027 }
1028
1029 #[tokio::test]
1037 async fn test_inflight_coalescing_map() {
1038 let _serial = INFLIGHT_TEST_SERIAL
1043 .lock()
1044 .unwrap_or_else(|p| p.into_inner());
1045 let domain = unique_test_key("__coalesce");
1054
1055 {
1057 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1058 m.remove(&domain);
1059 }
1060
1061 let owner_notify = Arc::new(Notify::new());
1063 {
1064 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1065 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1066 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1067 }
1068
1069 let waiter = {
1071 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1072 m.get(&domain)
1073 .and_then(|w| w.upgrade())
1074 .expect("Second caller must observe in-flight entry")
1075 };
1076
1077 let waiter_clone = waiter.clone();
1079 let handle = tokio::spawn(async move {
1080 waiter_clone.notified().await;
1081 });
1082
1083 tokio::time::sleep(Duration::from_millis(20)).await;
1085 {
1086 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1087 m.remove(&domain);
1088 }
1089 owner_notify.notify_waiters();
1090
1091 tokio::time::timeout(Duration::from_secs(1), handle)
1093 .await
1094 .expect("waiter must unblock after notify")
1095 .expect("waiter task joined cleanly");
1096
1097 drop(owner_notify);
1099 drop(waiter);
1100 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1101 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1102 }
1103
1104 fn unique_test_key(prefix: &str) -> String {
1110 use std::sync::atomic::{AtomicU64, Ordering};
1111 use std::time::{SystemTime, UNIX_EPOCH};
1112 static COUNTER: AtomicU64 = AtomicU64::new(0);
1113 let nanos = SystemTime::now()
1114 .duration_since(UNIX_EPOCH)
1115 .map(|d| d.as_nanos())
1116 .unwrap_or(0);
1117 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1118 format!("{}_{}_{}.example.", prefix, nanos, n)
1119 }
1120
1121 #[test]
1127 fn test_sanitize_applied_to_available_fields() {
1128 let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1129 let whois_raw = "connection refused at 192.168.0.5";
1130 let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1131 let sanitized_whois = sanitize_error_for_public(whois_raw);
1132 let result = LookupResult::Available {
1133 data: Box::new(AvailabilityResult {
1134 domain: "unreg.test".to_string(),
1135 available: true,
1136 confidence: "low".to_string(),
1137 method: "heuristic".to_string(),
1138 details: None,
1139 }),
1140 rdap_error: sanitized_rdap,
1141 whois_error: sanitized_whois,
1142 whois_data: None,
1143 };
1144 if let LookupResult::Available {
1145 rdap_error,
1146 whois_error,
1147 ..
1148 } = result
1149 {
1150 assert!(!rdap_error.contains("10.0.0.1"));
1151 assert!(!whois_error.contains("192.168.0.5"));
1152 assert!(rdap_error.contains("[ip-redacted]"));
1153 assert!(whois_error.contains("[ip-redacted]"));
1154 } else {
1155 panic!("expected Available variant");
1156 }
1157 }
1158
1159 #[test]
1160 fn rdap_error_is_404_matches_standard_404() {
1161 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1162 assert!(rdap_error_is_404(&e));
1163 }
1164
1165 #[test]
1166 fn rdap_error_is_404_matches_without_reason_phrase() {
1167 let e = SeerError::RdapError("query failed with status 404".to_string());
1168 assert!(rdap_error_is_404(&e));
1169 }
1170
1171 #[test]
1172 fn rdap_error_is_404_rejects_other_statuses() {
1173 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1174 assert!(!rdap_error_is_404(&e));
1175 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1176 assert!(!rdap_error_is_404(&e));
1177 }
1178
1179 #[test]
1180 fn rdap_error_is_404_rejects_non_http_errors() {
1181 let e = SeerError::RdapError("connection timeout".to_string());
1182 assert!(!rdap_error_is_404(&e));
1183 let e = SeerError::Timeout("rdap".to_string());
1184 assert!(!rdap_error_is_404(&e));
1185 }
1186
1187 #[test]
1188 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1189 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1191 assert!(!rdap_error_is_404(&e));
1192 }
1193
1194 fn empty_whois(domain: &str) -> WhoisResponse {
1197 WhoisResponse {
1198 domain: domain.to_string(),
1199 registrar: None,
1200 registrant: None,
1201 organization: None,
1202 registrant_email: None,
1203 registrant_phone: None,
1204 registrant_address: None,
1205 registrant_country: None,
1206 admin_name: None,
1207 admin_organization: None,
1208 admin_email: None,
1209 admin_phone: None,
1210 tech_name: None,
1211 tech_organization: None,
1212 tech_email: None,
1213 tech_phone: None,
1214 creation_date: None,
1215 expiration_date: None,
1216 updated_date: None,
1217 nameservers: vec![],
1218 status: vec![],
1219 dnssec: None,
1220 whois_server: String::new(),
1221 raw_response: String::new(),
1222 }
1223 }
1224
1225 #[test]
1226 fn whois_response_is_thin_when_all_key_fields_missing() {
1227 let w = empty_whois("example.com");
1228 assert!(whois_response_is_thin(&w));
1229 }
1230
1231 #[test]
1232 fn whois_response_is_not_thin_when_registrar_present() {
1233 let mut w = empty_whois("example.com");
1234 w.registrar = Some("Test Registrar".to_string());
1235 assert!(!whois_response_is_thin(&w));
1236 }
1237
1238 #[test]
1239 fn whois_response_is_not_thin_when_creation_date_present() {
1240 let mut w = empty_whois("example.com");
1241 w.creation_date = Some(Utc::now());
1242 assert!(!whois_response_is_thin(&w));
1243 }
1244
1245 #[test]
1246 fn whois_response_is_not_thin_when_expiration_date_present() {
1247 let mut w = empty_whois("example.com");
1248 w.expiration_date = Some(Utc::now());
1249 assert!(!whois_response_is_thin(&w));
1250 }
1251
1252 #[test]
1253 fn whois_response_is_thin_even_with_nameservers_alone() {
1254 let mut w = empty_whois("example.com");
1255 w.nameservers = vec!["ns1.example.net".to_string()];
1256 assert!(whois_response_is_thin(&w));
1257 }
1258
1259 use crate::rdap::RdapResponse;
1262
1263 #[allow(dead_code)]
1264 fn make_empty_rdap_response() -> RdapResponse {
1265 serde_json::from_value(serde_json::json!({
1266 "objectClassName": "domain",
1267 }))
1268 .expect("valid minimal RDAP response")
1269 }
1270
1271 #[test]
1272 fn classify_whois_leg_case_a_high_confidence() {
1273 let mut w = empty_whois("zaccodes.com");
1274 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1275 assert!(w.is_available());
1276 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1277 let (verdict, method) =
1278 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1279 assert_eq!(verdict, "high");
1280 assert_eq!(method, "whois");
1281 }
1282
1283 #[test]
1284 fn classify_whois_leg_case_b_medium_confidence() {
1285 let w = empty_whois("example.xyz");
1286 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1287 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1288 let (verdict, method) =
1289 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1290 assert_eq!(verdict, "medium");
1291 assert_eq!(method, "whois_thin_response");
1292 }
1293
1294 #[test]
1295 fn classify_whois_leg_rejects_thin_whois_without_404() {
1296 let w = empty_whois("example.xyz");
1297 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1298 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1299 }
1300
1301 #[test]
1302 fn classify_whois_leg_rejects_whois_with_real_data() {
1303 let mut w = empty_whois("legacy.tld");
1304 w.registrar = Some("Legacy Registry".to_string());
1305 w.creation_date = Some(Utc::now());
1306 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1307 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1308 }
1309
1310 #[test]
1311 fn classify_whois_leg_case_a_wins_over_case_b() {
1312 let mut w = empty_whois("example.com");
1313 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1314 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1315 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1316 assert_eq!(verdict, "high");
1317 }
1318
1319 #[test]
1327 fn rdap_200_vetoes_whois_no_match() {
1328 let mut w = empty_whois("freshly-registered.com");
1329 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1330 assert!(
1332 should_route_to_availability(true, None, &w).is_none(),
1333 "RDAP 200 must veto WHOIS-only availability claim",
1334 );
1335 }
1336
1337 #[test]
1338 fn rdap_200_vetoes_even_with_thin_whois() {
1339 let w = empty_whois("freshly-registered.com");
1340 assert!(
1342 should_route_to_availability(true, None, &w).is_none(),
1343 "RDAP 200 must veto even when WHOIS is thin",
1344 );
1345 }
1346
1347 #[test]
1348 fn rdap_404_with_whois_no_match_routes_to_available() {
1349 let mut w = empty_whois("genuinely-free.com");
1350 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1351 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1352 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1353 assert_eq!(result, Some(("high", "whois")));
1354 }
1355
1356 #[test]
1357 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1358 let mut w = empty_whois("genuinely-free.com");
1359 w.raw_response = "Domain not found".to_string();
1360 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1363 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1364 assert_eq!(result, Some(("high", "whois")));
1365 }
1366
1367 #[test]
1368 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1369 let mut w = empty_whois("genuinely-free.com");
1371 w.raw_response = "No match".to_string();
1372 let result = should_route_to_availability(false, None, &w);
1373 assert_eq!(result, Some(("high", "whois")));
1374 }
1375
1376 #[test]
1377 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1378 let mut w = empty_whois("registered.com");
1379 w.registrar = Some("Example Registrar Ltd".to_string());
1380 assert!(should_route_to_availability(false, None, &w).is_none());
1384 }
1385
1386 #[test]
1389 fn nxdomain_confirms_available_thin_no200_absent() {
1390 assert!(nxdomain_confirms_available(
1391 true,
1392 false,
1393 DnsPresence::Absent
1394 ));
1395 }
1396
1397 #[test]
1398 fn nxdomain_confirms_available_vetoed_by_rdap_200() {
1399 assert!(!nxdomain_confirms_available(
1402 true,
1403 true,
1404 DnsPresence::Absent
1405 ));
1406 }
1407
1408 #[test]
1409 fn nxdomain_confirms_available_requires_thin_whois() {
1410 assert!(!nxdomain_confirms_available(
1412 false,
1413 false,
1414 DnsPresence::Absent
1415 ));
1416 }
1417
1418 #[test]
1419 fn nxdomain_confirms_available_requires_absent_dns() {
1420 assert!(!nxdomain_confirms_available(
1421 true,
1422 false,
1423 DnsPresence::Present
1424 ));
1425 assert!(!nxdomain_confirms_available(
1426 true,
1427 false,
1428 DnsPresence::Unknown
1429 ));
1430 }
1431
1432 #[test]
1442 fn lookup_inflight_recovers_from_poisoned_mutex() {
1443 use std::panic::{catch_unwind, AssertUnwindSafe};
1444
1445 let _serial = INFLIGHT_TEST_SERIAL
1447 .lock()
1448 .unwrap_or_else(|p| p.into_inner());
1449
1450 let _ = catch_unwind(AssertUnwindSafe(|| {
1452 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1453 panic!("poisoning LOOKUP_INFLIGHT for test");
1454 }));
1455
1456 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1460 let canary = unique_test_key("__poison_recovery");
1462 guard.insert(canary.clone(), Weak::new());
1463 assert!(guard.contains_key(&canary));
1464 guard.remove(&canary);
1465 }
1466
1467 #[test]
1470 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1471 use std::panic::{catch_unwind, AssertUnwindSafe};
1472
1473 let _serial = INFLIGHT_TEST_SERIAL
1479 .lock()
1480 .unwrap_or_else(|p| p.into_inner());
1481
1482 let key = unique_test_key("__drop_poison");
1487 let notify = Arc::new(Notify::new());
1488 {
1489 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1490 map.insert(key.clone(), Arc::downgrade(¬ify));
1491 }
1492 let guard = InflightGuard {
1493 key: key.clone(),
1494 notify: notify.clone(),
1495 };
1496
1497 let _ = catch_unwind(AssertUnwindSafe(|| {
1499 let _g = LOOKUP_INFLIGHT.lock().unwrap();
1500 panic!("poisoning LOOKUP_INFLIGHT for drop test");
1501 }));
1502
1503 drop(guard);
1506
1507 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1508 assert!(
1509 !map.contains_key(&key),
1510 "poisoned-mutex drop path should still remove the in-flight entry"
1511 );
1512 }
1513}