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::error::{Result, SeerError};
19use crate::rdap::{RdapClient, RdapResponse};
20use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
21
22const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
24
25const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
29
30const MAX_PUBLIC_ERROR_LEN: usize = 256;
32
33static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
35 Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
36
37static LOOKUP_INFLIGHT: Lazy<Mutex<HashMap<String, Weak<Notify>>>> =
41 Lazy::new(|| Mutex::new(HashMap::new()));
42
43static IPV4_RE: Lazy<Regex> =
45 Lazy::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("IPV4_RE is a valid regex"));
46
47static IPV6_CANDIDATE_RE: Lazy<Regex> = Lazy::new(|| {
53 Regex::new(r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b")
54 .expect("IPV6_CANDIDATE_RE is a valid regex")
55});
56
57fn strip_ipv6(msg: &str) -> String {
60 IPV6_CANDIDATE_RE
61 .replace_all(msg, |caps: ®ex::Captures| {
62 let candidate = &caps[0];
63 if Ipv6Addr::from_str(candidate).is_ok() {
64 "[ip-redacted]".to_string()
65 } else {
66 candidate.to_string()
67 }
68 })
69 .into_owned()
70}
71
72#[cfg(test)]
76static LOOKUP_CONCURRENT_CALLS: Lazy<std::sync::atomic::AtomicUsize> =
77 Lazy::new(|| std::sync::atomic::AtomicUsize::new(0));
78
79fn rdap_error_is_404(err: &SeerError) -> bool {
87 if let SeerError::RdapError(msg) = err {
88 msg.contains("query failed with status 404")
89 } else {
90 false
91 }
92}
93
94fn whois_response_is_thin(w: &WhoisResponse) -> bool {
102 w.registrar.is_none() && w.creation_date.is_none() && w.expiration_date.is_none()
103}
104
105fn classify_whois_leg(
112 w: &WhoisResponse,
113 rdap_err: &SeerError,
114) -> Option<(&'static str, &'static str)> {
115 if w.is_available() {
116 return Some(("high", "whois"));
117 }
118 if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
119 return Some(("medium", "whois_thin_response"));
120 }
121 None
122}
123
124fn should_route_to_availability(
131 rdap_returned_200: bool,
132 rdap_seer_error: Option<&SeerError>,
133 whois_data: &WhoisResponse,
134) -> Option<(&'static str, &'static str)> {
135 if rdap_returned_200 {
136 return None;
137 }
138 rdap_seer_error
139 .and_then(|e| classify_whois_leg(whois_data, e))
140 .or_else(|| {
141 if whois_data.is_available() {
144 Some(("high", "whois"))
145 } else {
146 None
147 }
148 })
149}
150
151fn sanitize_error_for_public(msg: &str) -> String {
157 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
158 let s = strip_ipv6(&s);
159 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
160 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
161 trunc.push('…');
162 trunc
163 } else {
164 s
165 }
166}
167
168struct InflightGuard {
181 key: String,
182 notify: Arc<Notify>,
183}
184
185impl Drop for InflightGuard {
186 fn drop(&mut self) {
187 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
200 inflight.remove(&self.key);
201 drop(inflight);
202 self.notify.notify_waiters();
203 }
204}
205
206enum RdapOutcome {
212 Useful(RdapResponse),
213 NoData(RdapResponse),
214 Error(SeerError),
215 GraceTimeout,
218}
219
220pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(tag = "source", rename_all = "lowercase")]
226pub enum LookupResult {
227 Rdap {
228 data: Box<RdapResponse>,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 whois_fallback: Option<WhoisResponse>,
231 },
232 Whois {
233 data: WhoisResponse,
234 rdap_error: Option<String>,
235 #[serde(skip_serializing_if = "Option::is_none")]
236 rdap_fallback: Option<Box<RdapResponse>>,
237 },
238 Available {
239 data: Box<AvailabilityResult>,
240 rdap_error: String,
241 whois_error: String,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
246 whois_data: Option<WhoisResponse>,
247 },
248}
249
250impl LookupResult {
251 pub fn domain_name(&self) -> Option<String> {
253 match self {
254 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
255 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
256 LookupResult::Available { data, .. } => Some(data.domain.clone()),
257 }
258 }
259
260 pub fn registrar(&self) -> Option<String> {
262 match self {
263 LookupResult::Rdap {
264 data,
265 whois_fallback,
266 } => data
267 .get_registrar()
268 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
269 LookupResult::Whois { data, .. } => data.registrar.clone(),
270 LookupResult::Available { .. } => None,
271 }
272 }
273
274 pub fn organization(&self) -> Option<String> {
276 match self {
277 LookupResult::Rdap {
278 data,
279 whois_fallback,
280 } => data
281 .get_registrant_organization()
282 .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
283 LookupResult::Whois { data, .. } => data.organization.clone(),
284 LookupResult::Available { .. } => None,
285 }
286 }
287
288 pub fn is_rdap(&self) -> bool {
290 matches!(self, LookupResult::Rdap { .. })
291 }
292
293 pub fn is_whois(&self) -> bool {
295 matches!(self, LookupResult::Whois { .. })
296 }
297
298 pub fn is_available(&self) -> bool {
300 matches!(self, LookupResult::Available { .. })
301 }
302
303 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
305 match self {
306 LookupResult::Rdap {
307 data,
308 whois_fallback,
309 } => {
310 let expiration_date = data
312 .events
313 .iter()
314 .find(|e| e.event_action == "expiration")
315 .and_then(|e| e.parsed_date())
316 .or_else(|| {
317 whois_fallback.as_ref().and_then(|w| w.expiration_date)
319 });
320
321 let registrar = data
322 .get_registrar()
323 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
324
325 (expiration_date, registrar)
326 }
327 LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
328 LookupResult::Available { .. } => (None, None),
329 }
330 }
331}
332
333fn trim_for_cache(mut result: LookupResult) -> LookupResult {
337 const MAX_RAW: usize = 32 * 1024;
338
339 match result {
340 LookupResult::Whois { ref mut data, .. } => {
341 if data.raw_response.len() > MAX_RAW {
342 data.raw_response.truncate(MAX_RAW);
343 data.raw_response.push_str("\n... [truncated for cache]");
344 }
345 }
346 LookupResult::Rdap {
347 ref mut whois_fallback,
348 ..
349 } => {
350 if let Some(ref mut w) = whois_fallback {
351 if w.raw_response.len() > MAX_RAW {
352 w.raw_response.truncate(MAX_RAW);
353 w.raw_response.push_str("\n... [truncated for cache]");
354 }
355 }
356 }
357 LookupResult::Available {
358 ref mut whois_data, ..
359 } => {
360 if let Some(ref mut w) = whois_data {
361 if w.raw_response.len() > MAX_RAW {
362 w.raw_response.truncate(MAX_RAW);
363 w.raw_response.push_str("\n... [truncated for cache]");
364 }
365 }
366 }
367 }
368
369 result
370}
371
372#[derive(Debug, Clone)]
373pub struct SmartLookup {
374 rdap_client: RdapClient,
375 whois_client: WhoisClient,
376 availability_checker: AvailabilityChecker,
377 prefer_rdap: bool,
379 include_fallback: bool,
381}
382
383impl Default for SmartLookup {
384 fn default() -> Self {
385 Self::new()
386 }
387}
388
389impl SmartLookup {
390 pub fn new() -> Self {
393 Self {
394 rdap_client: RdapClient::new(),
395 whois_client: WhoisClient::new(),
396 availability_checker: AvailabilityChecker::new(),
397 prefer_rdap: true,
398 include_fallback: false,
399 }
400 }
401
402 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
405 pub fn prefer_rdap(mut self, prefer: bool) -> Self {
406 self.prefer_rdap = prefer;
407 self
408 }
409
410 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
413 pub fn include_fallback(mut self, include: bool) -> Self {
414 self.include_fallback = include;
415 self
416 }
417
418 #[instrument(skip(self), fields(domain = %domain))]
422 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
423 self.lookup_with_progress(domain, None).await
424 }
425
426 #[instrument(skip(self, progress), fields(domain = %domain))]
431 pub async fn lookup_with_progress(
432 &self,
433 domain: &str,
434 progress: Option<LookupProgressCallback>,
435 ) -> Result<LookupResult> {
436 let normalized = crate::validation::normalize_domain(domain)?;
437
438 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
440 debug!(domain = %normalized, "Returning cached lookup result");
441 return Ok(cached);
442 }
443
444 let _guard = loop {
456 enum Slot {
457 Waiter(Arc<Notify>),
458 Owner(InflightGuard),
459 }
460
461 let slot = {
462 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
466 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
467 Some(existing) => Slot::Waiter(existing),
468 None => {
469 let n = Arc::new(Notify::new());
470 inflight.insert(normalized.clone(), Arc::downgrade(&n));
471 Slot::Owner(InflightGuard {
472 key: normalized.clone(),
473 notify: n,
474 })
475 }
476 }
477 };
478
479 match slot {
480 Slot::Waiter(n) => {
481 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
482 n.notified().await;
483 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
484 return Ok(cached);
485 }
486 continue;
489 }
490 Slot::Owner(guard) => break guard,
491 }
492 };
493
494 let result = self.lookup_concurrent(&normalized, progress).await?;
495
496 LOOKUP_CACHE.insert(normalized.clone(), trim_for_cache(result.clone()));
499
500 Ok(result)
501 }
502
503 pub fn clear_cache() {
505 LOOKUP_CACHE.clear();
506 }
507
508 #[instrument(skip(self, progress), fields(domain = %domain))]
509 async fn lookup_concurrent(
510 &self,
511 domain: &str,
512 progress: Option<LookupProgressCallback>,
513 ) -> Result<LookupResult> {
514 #[cfg(test)]
515 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
516
517 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
518
519 if let Some(ref cb) = progress {
520 cb("Querying RDAP and WHOIS concurrently");
521 }
522
523 let rdap_fut = self.rdap_client.lookup_domain(domain);
524 let whois_fut = self.whois_client.lookup(domain);
525
526 tokio::pin!(rdap_fut);
527 tokio::pin!(whois_fut);
528
529 enum LegOutcome<T> {
535 Completed(T),
536 GraceTruncated,
537 }
538
539 let (rdap_leg, whois_leg) = tokio::select! {
540 rdap_res = &mut rdap_fut => {
541 let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
543 Ok(res) => LegOutcome::Completed(res),
544 Err(_) => {
545 debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
546 LegOutcome::GraceTruncated
547 }
548 };
549 (LegOutcome::Completed(rdap_res), whois_leg)
550 }
551 whois_res = &mut whois_fut => {
552 let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
554 Ok(res) => LegOutcome::Completed(res),
555 Err(_) => {
556 debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
557 LegOutcome::GraceTruncated
558 }
559 };
560 (rdap_leg, LegOutcome::Completed(whois_res))
561 }
562 };
563
564 let rdap_outcome = match rdap_leg {
566 LegOutcome::Completed(Ok(data)) => {
567 if self.is_rdap_response_useful(&data) {
568 RdapOutcome::Useful(data)
569 } else {
570 RdapOutcome::NoData(data)
571 }
572 }
573 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
574 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
575 };
576
577 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
579 debug!("RDAP lookup successful");
580 let whois_fallback = match whois_leg {
581 LegOutcome::Completed(Ok(w)) => Some(w),
582 _ => None,
583 };
584 return Ok(LookupResult::Rdap {
585 data: Box::new(rdap_data),
586 whois_fallback,
587 });
588 }
589
590 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
601 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
602 RdapOutcome::Useful(_) => {
603 debug!("Unexpected RdapOutcome::Useful in fallback branch");
606 (String::from("RDAP ok"), None, None)
607 }
608 RdapOutcome::NoData(data) => (
609 "RDAP response incomplete".to_string(),
610 Some(Box::new(data)),
611 None,
612 ),
613 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
614 RdapOutcome::GraceTimeout => (
615 format!(
616 "RDAP did not return within {}s grace period after WHOIS won",
617 PROTOCOL_GRACE_PERIOD.as_secs()
618 ),
619 None,
620 None,
621 ),
622 };
623
624 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
625 let availability_match = should_route_to_availability(
629 rdap_returned_200,
630 rdap_seer_error.as_ref(),
631 &whois_data,
632 );
633
634 if let Some((confidence, method)) = availability_match {
635 debug!(
636 domain = %domain,
637 confidence = %confidence,
638 "Reclassifying WHOIS as availability signal"
639 );
640 if let Some(ref cb) = progress {
641 cb("Domain appears unregistered");
642 }
643 let details = match confidence {
644 "high" => Some("WHOIS indicates domain is not registered".to_string()),
645 "medium" => Some(
646 "WHOIS returned no registrar or registration dates; RDAP returned 404"
647 .to_string(),
648 ),
649 _ => None,
650 };
651 let avail = AvailabilityResult {
652 domain: domain.to_string(),
653 available: true,
654 confidence: confidence.to_string(),
655 method: method.to_string(),
656 details,
657 };
658 return Ok(LookupResult::Available {
659 data: Box::new(avail),
660 rdap_error: sanitize_error_for_public(&rdap_error_str),
661 whois_error: String::new(),
662 whois_data: Some(whois_data),
663 });
664 }
665
666 debug!("Using WHOIS result (RDAP not useful)");
667 if let Some(ref cb) = progress {
668 cb("RDAP not available (using WHOIS)");
669 }
670 return Ok(LookupResult::Whois {
671 data: whois_data,
672 rdap_error: Some(rdap_error_str),
673 rdap_fallback: rdap_fallback_data,
674 });
675 }
676
677 let whois_error_str = match whois_leg {
681 LegOutcome::Completed(Err(e)) => e.to_string(),
682 LegOutcome::Completed(Ok(_)) => {
683 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
685 "WHOIS returned but was not used".to_string()
686 }
687 LegOutcome::GraceTruncated => format!(
688 "WHOIS did not return within {}s grace period after RDAP won",
689 PROTOCOL_GRACE_PERIOD.as_secs()
690 ),
691 };
692
693 self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
694 .await
695 }
696
697 async fn availability_fallback(
698 &self,
699 domain: &str,
700 rdap_error: String,
701 whois_error: String,
702 progress: Option<LookupProgressCallback>,
703 ) -> Result<LookupResult> {
704 if let Some(ref cb) = progress {
705 cb("RDAP and WHOIS unavailable (checking availability)");
706 }
707 warn!(
708 domain = %domain,
709 rdap_error = %rdap_error,
710 whois_error = %whois_error,
711 "Both RDAP and WHOIS failed, falling back to availability check"
712 );
713
714 match self.availability_checker.check(domain).await {
715 Ok(avail) => Ok(LookupResult::Available {
716 data: Box::new(avail),
717 rdap_error: sanitize_error_for_public(&rdap_error),
718 whois_error: sanitize_error_for_public(&whois_error),
719 whois_data: None,
720 }),
721 Err(avail_err) => {
722 let tld = get_tld(domain).unwrap_or("unknown");
723 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
724 format!("https://www.iana.org/domains/root/db/{}.html", tld)
725 });
726 Err(SeerError::LookupFailed {
727 domain: domain.to_string(),
728 details: format!(
729 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
730 rdap_error, whois_error, avail_err
731 ),
732 registry_url,
733 })
734 }
735 }
736 }
737
738 fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
739 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
741 let has_dates = response
742 .events
743 .iter()
744 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
745 let has_entities = !response.entities.is_empty();
746 let has_nameservers = !response.nameservers.is_empty();
747 let has_status = !response.status.is_empty();
748
749 has_name && (has_dates || has_entities || has_nameservers || has_status)
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
768
769 #[test]
770 fn test_lookup_result_domain_name_whois() {
771 let result = LookupResult::Whois {
772 data: WhoisResponse {
773 domain: "example.com".to_string(),
774 registrar: Some("Test Registrar".to_string()),
775 registrant: None,
776 organization: None,
777 registrant_email: None,
778 registrant_phone: None,
779 registrant_address: None,
780 registrant_country: None,
781 admin_name: None,
782 admin_organization: None,
783 admin_email: None,
784 admin_phone: None,
785 tech_name: None,
786 tech_organization: None,
787 tech_email: None,
788 tech_phone: None,
789 creation_date: None,
790 expiration_date: None,
791 updated_date: None,
792 status: vec![],
793 nameservers: vec![],
794 dnssec: None,
795 whois_server: "whois.example.com".to_string(),
796 raw_response: String::new(),
797 },
798 rdap_error: None,
799 rdap_fallback: None,
800 };
801
802 assert_eq!(result.domain_name(), Some("example.com".to_string()));
803 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
804 assert!(result.is_whois());
805 assert!(!result.is_rdap());
806 assert!(!result.is_available());
807 }
808
809 #[test]
810 fn test_lookup_result_serialization() {
811 let result = LookupResult::Whois {
812 data: WhoisResponse {
813 domain: "test.com".to_string(),
814 registrar: None,
815 registrant: None,
816 organization: None,
817 registrant_email: None,
818 registrant_phone: None,
819 registrant_address: None,
820 registrant_country: None,
821 admin_name: None,
822 admin_organization: None,
823 admin_email: None,
824 admin_phone: None,
825 tech_name: None,
826 tech_organization: None,
827 tech_email: None,
828 tech_phone: None,
829 creation_date: None,
830 expiration_date: None,
831 updated_date: None,
832 status: vec![],
833 nameservers: vec![],
834 dnssec: None,
835 whois_server: String::new(),
836 raw_response: String::new(),
837 },
838 rdap_error: Some("RDAP failed".to_string()),
839 rdap_fallback: None,
840 };
841
842 let json = serde_json::to_string(&result).unwrap();
843 assert!(json.contains("\"source\":\"whois\""));
844 assert!(json.contains("RDAP failed"));
845 }
846
847 #[test]
848 fn test_lookup_result_available_serialization() {
849 let result = LookupResult::Available {
850 data: Box::new(AvailabilityResult {
851 domain: "test123.xyz".to_string(),
852 available: true,
853 confidence: "medium".to_string(),
854 method: "whois_error".to_string(),
855 details: Some("WHOIS server indicates no matching records".to_string()),
856 }),
857 rdap_error: "RDAP failed".to_string(),
858 whois_error: "WHOIS failed".to_string(),
859 whois_data: None,
860 };
861
862 let json = serde_json::to_string(&result).unwrap();
863 assert!(json.contains("\"source\":\"available\""));
864 assert!(json.contains("\"available\":true"));
865 assert!(json.contains("test123.xyz"));
866
867 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
868 assert!(result.is_available());
869 assert!(!result.is_rdap());
870 assert!(!result.is_whois());
871 assert!(result.registrar().is_none());
872 assert_eq!(result.expiration_info(), (None, None));
873 }
874
875 #[test]
876 #[allow(deprecated)]
877 fn test_smart_lookup_builder() {
878 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
879 assert!(!lookup.prefer_rdap);
880 assert!(lookup.include_fallback);
881 }
882
883 #[test]
884 fn test_lookup_cache_clear() {
885 SmartLookup::clear_cache();
886 assert!(LOOKUP_CACHE.is_empty());
887 }
888
889 #[test]
892 fn test_sanitize_strips_ipv4() {
893 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
894 let sanitized = sanitize_error_for_public(msg);
895 assert!(
896 !sanitized.contains("10.0.0.1"),
897 "IPv4 should be stripped, got: {}",
898 sanitized
899 );
900 assert!(sanitized.contains("[ip-redacted]"));
901 }
902
903 #[test]
904 fn test_sanitize_strips_multiple_ipv4() {
905 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
906 let sanitized = sanitize_error_for_public(msg);
907 assert!(!sanitized.contains("192.168.1.1"));
908 assert!(!sanitized.contains("127.0.0.1"));
909 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
911 }
912
913 #[test]
914 fn test_sanitize_strips_ipv6() {
915 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
916 let sanitized = sanitize_error_for_public(msg);
917 assert!(!sanitized.contains("fe80::1"));
918 assert!(sanitized.contains("[ip-redacted]"));
919 }
920
921 #[test]
922 fn sanitize_leaves_mac_address_like_tokens_alone() {
923 let msg = "error code af:ba:12 at line 5";
924 let out = sanitize_error_for_public(msg);
925 assert!(
926 out.contains("af:ba:12"),
927 "MAC fragment should not be stripped: {}",
928 out
929 );
930 }
931
932 #[test]
933 fn sanitize_strips_real_ipv6() {
934 let msg = "cannot reach 2001:db8::1 — timeout";
935 let out = sanitize_error_for_public(msg);
936 assert!(!out.contains("2001:db8::1"));
937 assert!(out.contains("[ip-redacted]"));
938 }
939
940 #[test]
941 fn sanitize_strips_fe80_link_local() {
942 let msg = "peer at fe80::1 unreachable";
943 let out = sanitize_error_for_public(msg);
944 assert!(out.contains("[ip-redacted]"));
945 }
946
947 #[test]
948 fn test_sanitize_truncates_long_message() {
949 let long = "a".repeat(500);
951 let sanitized = sanitize_error_for_public(&long);
952 let char_count = sanitized.chars().count();
954 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
955 assert!(sanitized.ends_with('…'));
956 }
957
958 #[test]
959 fn test_sanitize_preserves_short_messages() {
960 let msg = "RDAP timed out after 15s";
961 let sanitized = sanitize_error_for_public(msg);
962 assert_eq!(sanitized, msg);
963 }
964
965 #[test]
968 fn test_is_rdap_response_useful_detects_no_data() {
969 use crate::rdap::RdapResponse;
970 let resp = RdapResponse {
974 ldh_name: Some("example.com".to_string()),
975 ..Default::default()
976 };
977 let lookup = SmartLookup::new();
978 assert!(
979 !lookup.is_rdap_response_useful(&resp),
980 "Response with only a name should be classified as NoData"
981 );
982
983 let useful = RdapResponse {
985 ldh_name: Some("example.com".to_string()),
986 status: vec!["active".to_string()],
987 ..Default::default()
988 };
989 assert!(lookup.is_rdap_response_useful(&useful));
990 }
991
992 #[tokio::test]
1000 async fn test_inflight_coalescing_map() {
1001 let _serial = INFLIGHT_TEST_SERIAL
1006 .lock()
1007 .unwrap_or_else(|p| p.into_inner());
1008 let domain = unique_test_key("__coalesce");
1017
1018 {
1020 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1021 m.remove(&domain);
1022 }
1023
1024 let owner_notify = Arc::new(Notify::new());
1026 {
1027 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1028 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1029 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1030 }
1031
1032 let waiter = {
1034 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1035 m.get(&domain)
1036 .and_then(|w| w.upgrade())
1037 .expect("Second caller must observe in-flight entry")
1038 };
1039
1040 let waiter_clone = waiter.clone();
1042 let handle = tokio::spawn(async move {
1043 waiter_clone.notified().await;
1044 });
1045
1046 tokio::time::sleep(Duration::from_millis(20)).await;
1048 {
1049 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1050 m.remove(&domain);
1051 }
1052 owner_notify.notify_waiters();
1053
1054 tokio::time::timeout(Duration::from_secs(1), handle)
1056 .await
1057 .expect("waiter must unblock after notify")
1058 .expect("waiter task joined cleanly");
1059
1060 drop(owner_notify);
1062 drop(waiter);
1063 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1064 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1065 }
1066
1067 fn unique_test_key(prefix: &str) -> String {
1073 use std::sync::atomic::{AtomicU64, Ordering};
1074 use std::time::{SystemTime, UNIX_EPOCH};
1075 static COUNTER: AtomicU64 = AtomicU64::new(0);
1076 let nanos = SystemTime::now()
1077 .duration_since(UNIX_EPOCH)
1078 .map(|d| d.as_nanos())
1079 .unwrap_or(0);
1080 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1081 format!("{}_{}_{}.example.", prefix, nanos, n)
1082 }
1083
1084 #[test]
1090 fn test_sanitize_applied_to_available_fields() {
1091 let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1092 let whois_raw = "connection refused at 192.168.0.5";
1093 let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1094 let sanitized_whois = sanitize_error_for_public(whois_raw);
1095 let result = LookupResult::Available {
1096 data: Box::new(AvailabilityResult {
1097 domain: "unreg.test".to_string(),
1098 available: true,
1099 confidence: "low".to_string(),
1100 method: "heuristic".to_string(),
1101 details: None,
1102 }),
1103 rdap_error: sanitized_rdap,
1104 whois_error: sanitized_whois,
1105 whois_data: None,
1106 };
1107 if let LookupResult::Available {
1108 rdap_error,
1109 whois_error,
1110 ..
1111 } = result
1112 {
1113 assert!(!rdap_error.contains("10.0.0.1"));
1114 assert!(!whois_error.contains("192.168.0.5"));
1115 assert!(rdap_error.contains("[ip-redacted]"));
1116 assert!(whois_error.contains("[ip-redacted]"));
1117 } else {
1118 panic!("expected Available variant");
1119 }
1120 }
1121
1122 #[test]
1123 fn rdap_error_is_404_matches_standard_404() {
1124 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1125 assert!(rdap_error_is_404(&e));
1126 }
1127
1128 #[test]
1129 fn rdap_error_is_404_matches_without_reason_phrase() {
1130 let e = SeerError::RdapError("query failed with status 404".to_string());
1131 assert!(rdap_error_is_404(&e));
1132 }
1133
1134 #[test]
1135 fn rdap_error_is_404_rejects_other_statuses() {
1136 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1137 assert!(!rdap_error_is_404(&e));
1138 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1139 assert!(!rdap_error_is_404(&e));
1140 }
1141
1142 #[test]
1143 fn rdap_error_is_404_rejects_non_http_errors() {
1144 let e = SeerError::RdapError("connection timeout".to_string());
1145 assert!(!rdap_error_is_404(&e));
1146 let e = SeerError::Timeout("rdap".to_string());
1147 assert!(!rdap_error_is_404(&e));
1148 }
1149
1150 #[test]
1151 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1152 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1154 assert!(!rdap_error_is_404(&e));
1155 }
1156
1157 fn empty_whois(domain: &str) -> WhoisResponse {
1160 WhoisResponse {
1161 domain: domain.to_string(),
1162 registrar: None,
1163 registrant: None,
1164 organization: None,
1165 registrant_email: None,
1166 registrant_phone: None,
1167 registrant_address: None,
1168 registrant_country: None,
1169 admin_name: None,
1170 admin_organization: None,
1171 admin_email: None,
1172 admin_phone: None,
1173 tech_name: None,
1174 tech_organization: None,
1175 tech_email: None,
1176 tech_phone: None,
1177 creation_date: None,
1178 expiration_date: None,
1179 updated_date: None,
1180 nameservers: vec![],
1181 status: vec![],
1182 dnssec: None,
1183 whois_server: String::new(),
1184 raw_response: String::new(),
1185 }
1186 }
1187
1188 #[test]
1189 fn whois_response_is_thin_when_all_key_fields_missing() {
1190 let w = empty_whois("example.com");
1191 assert!(whois_response_is_thin(&w));
1192 }
1193
1194 #[test]
1195 fn whois_response_is_not_thin_when_registrar_present() {
1196 let mut w = empty_whois("example.com");
1197 w.registrar = Some("Test Registrar".to_string());
1198 assert!(!whois_response_is_thin(&w));
1199 }
1200
1201 #[test]
1202 fn whois_response_is_not_thin_when_creation_date_present() {
1203 let mut w = empty_whois("example.com");
1204 w.creation_date = Some(Utc::now());
1205 assert!(!whois_response_is_thin(&w));
1206 }
1207
1208 #[test]
1209 fn whois_response_is_not_thin_when_expiration_date_present() {
1210 let mut w = empty_whois("example.com");
1211 w.expiration_date = Some(Utc::now());
1212 assert!(!whois_response_is_thin(&w));
1213 }
1214
1215 #[test]
1216 fn whois_response_is_thin_even_with_nameservers_alone() {
1217 let mut w = empty_whois("example.com");
1218 w.nameservers = vec!["ns1.example.net".to_string()];
1219 assert!(whois_response_is_thin(&w));
1220 }
1221
1222 use crate::rdap::RdapResponse;
1225
1226 #[allow(dead_code)]
1227 fn make_empty_rdap_response() -> RdapResponse {
1228 serde_json::from_value(serde_json::json!({
1229 "objectClassName": "domain",
1230 }))
1231 .expect("valid minimal RDAP response")
1232 }
1233
1234 #[test]
1235 fn classify_whois_leg_case_a_high_confidence() {
1236 let mut w = empty_whois("zaccodes.com");
1237 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1238 assert!(w.is_available());
1239 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1240 let (verdict, method) =
1241 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1242 assert_eq!(verdict, "high");
1243 assert_eq!(method, "whois");
1244 }
1245
1246 #[test]
1247 fn classify_whois_leg_case_b_medium_confidence() {
1248 let w = empty_whois("example.xyz");
1249 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1250 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1251 let (verdict, method) =
1252 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1253 assert_eq!(verdict, "medium");
1254 assert_eq!(method, "whois_thin_response");
1255 }
1256
1257 #[test]
1258 fn classify_whois_leg_rejects_thin_whois_without_404() {
1259 let w = empty_whois("example.xyz");
1260 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1261 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1262 }
1263
1264 #[test]
1265 fn classify_whois_leg_rejects_whois_with_real_data() {
1266 let mut w = empty_whois("legacy.tld");
1267 w.registrar = Some("Legacy Registry".to_string());
1268 w.creation_date = Some(Utc::now());
1269 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1270 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1271 }
1272
1273 #[test]
1274 fn classify_whois_leg_case_a_wins_over_case_b() {
1275 let mut w = empty_whois("example.com");
1276 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1277 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1278 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1279 assert_eq!(verdict, "high");
1280 }
1281
1282 #[test]
1290 fn rdap_200_vetoes_whois_no_match() {
1291 let mut w = empty_whois("freshly-registered.com");
1292 w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1293 assert!(
1295 should_route_to_availability(true, None, &w).is_none(),
1296 "RDAP 200 must veto WHOIS-only availability claim",
1297 );
1298 }
1299
1300 #[test]
1301 fn rdap_200_vetoes_even_with_thin_whois() {
1302 let w = empty_whois("freshly-registered.com");
1303 assert!(
1305 should_route_to_availability(true, None, &w).is_none(),
1306 "RDAP 200 must veto even when WHOIS is thin",
1307 );
1308 }
1309
1310 #[test]
1311 fn rdap_404_with_whois_no_match_routes_to_available() {
1312 let mut w = empty_whois("genuinely-free.com");
1313 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1314 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1315 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1316 assert_eq!(result, Some(("high", "whois")));
1317 }
1318
1319 #[test]
1320 fn rdap_error_with_whois_is_available_still_routes_case_a() {
1321 let mut w = empty_whois("genuinely-free.com");
1322 w.raw_response = "Domain not found".to_string();
1323 let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1326 let result = should_route_to_availability(false, Some(&rdap_err), &w);
1327 assert_eq!(result, Some(("high", "whois")));
1328 }
1329
1330 #[test]
1331 fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1332 let mut w = empty_whois("genuinely-free.com");
1334 w.raw_response = "No match".to_string();
1335 let result = should_route_to_availability(false, None, &w);
1336 assert_eq!(result, Some(("high", "whois")));
1337 }
1338
1339 #[test]
1340 fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1341 let mut w = empty_whois("registered.com");
1342 w.registrar = Some("Example Registrar Ltd".to_string());
1343 assert!(should_route_to_availability(false, None, &w).is_none());
1347 }
1348
1349 #[test]
1359 fn lookup_inflight_recovers_from_poisoned_mutex() {
1360 use std::panic::{catch_unwind, AssertUnwindSafe};
1361
1362 let _serial = INFLIGHT_TEST_SERIAL
1364 .lock()
1365 .unwrap_or_else(|p| p.into_inner());
1366
1367 let _ = catch_unwind(AssertUnwindSafe(|| {
1369 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1370 panic!("poisoning LOOKUP_INFLIGHT for test");
1371 }));
1372
1373 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1377 let canary = unique_test_key("__poison_recovery");
1379 guard.insert(canary.clone(), Weak::new());
1380 assert!(guard.contains_key(&canary));
1381 guard.remove(&canary);
1382 }
1383
1384 #[test]
1387 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1388 use std::panic::{catch_unwind, AssertUnwindSafe};
1389
1390 let _serial = INFLIGHT_TEST_SERIAL
1396 .lock()
1397 .unwrap_or_else(|p| p.into_inner());
1398
1399 let key = unique_test_key("__drop_poison");
1404 let notify = Arc::new(Notify::new());
1405 {
1406 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1407 map.insert(key.clone(), Arc::downgrade(¬ify));
1408 }
1409 let guard = InflightGuard {
1410 key: key.clone(),
1411 notify: notify.clone(),
1412 };
1413
1414 let _ = catch_unwind(AssertUnwindSafe(|| {
1416 let _g = LOOKUP_INFLIGHT.lock().unwrap();
1417 panic!("poisoning LOOKUP_INFLIGHT for drop test");
1418 }));
1419
1420 drop(guard);
1423
1424 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1425 assert!(
1426 !map.contains_key(&key),
1427 "poisoned-mutex drop path should still remove the in-flight entry"
1428 );
1429 }
1430}