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