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