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 sanitize_error_for_public(msg: &str) -> String {
130 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
131 let s = strip_ipv6(&s);
132 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
133 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
134 trunc.push('…');
135 trunc
136 } else {
137 s
138 }
139}
140
141struct InflightGuard {
154 key: String,
155 notify: Arc<Notify>,
156}
157
158impl Drop for InflightGuard {
159 fn drop(&mut self) {
160 match LOOKUP_INFLIGHT.try_lock() {
168 Ok(mut inflight) => {
169 inflight.remove(&self.key);
170 }
171 Err(std::sync::TryLockError::Poisoned(p)) => {
172 let mut inflight = p.into_inner();
173 inflight.remove(&self.key);
174 }
175 Err(std::sync::TryLockError::WouldBlock) => {
176 tracing::debug!(
177 key = %self.key,
178 "InflightGuard drop: skipping cleanup under contention"
179 );
180 }
181 }
182 self.notify.notify_waiters();
183 }
184}
185
186enum RdapOutcome {
192 Useful(RdapResponse),
193 NoData(RdapResponse),
194 Error(SeerError),
195 GraceTimeout,
198}
199
200pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(tag = "source", rename_all = "lowercase")]
206pub enum LookupResult {
207 Rdap {
208 data: Box<RdapResponse>,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 whois_fallback: Option<WhoisResponse>,
211 },
212 Whois {
213 data: WhoisResponse,
214 rdap_error: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none")]
216 rdap_fallback: Option<Box<RdapResponse>>,
217 },
218 Available {
219 data: Box<AvailabilityResult>,
220 rdap_error: String,
221 whois_error: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
226 whois_data: Option<WhoisResponse>,
227 },
228}
229
230impl LookupResult {
231 pub fn domain_name(&self) -> Option<String> {
233 match self {
234 LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
235 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
236 LookupResult::Available { data, .. } => Some(data.domain.clone()),
237 }
238 }
239
240 pub fn registrar(&self) -> Option<String> {
242 match self {
243 LookupResult::Rdap {
244 data,
245 whois_fallback,
246 } => data
247 .get_registrar()
248 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
249 LookupResult::Whois { data, .. } => data.registrar.clone(),
250 LookupResult::Available { .. } => None,
251 }
252 }
253
254 pub fn organization(&self) -> Option<String> {
256 match self {
257 LookupResult::Rdap {
258 data,
259 whois_fallback,
260 } => data
261 .get_registrant_organization()
262 .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
263 LookupResult::Whois { data, .. } => data.organization.clone(),
264 LookupResult::Available { .. } => None,
265 }
266 }
267
268 pub fn is_rdap(&self) -> bool {
270 matches!(self, LookupResult::Rdap { .. })
271 }
272
273 pub fn is_whois(&self) -> bool {
275 matches!(self, LookupResult::Whois { .. })
276 }
277
278 pub fn is_available(&self) -> bool {
280 matches!(self, LookupResult::Available { .. })
281 }
282
283 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
285 match self {
286 LookupResult::Rdap {
287 data,
288 whois_fallback,
289 } => {
290 let expiration_date = data
292 .events
293 .iter()
294 .find(|e| e.event_action == "expiration")
295 .and_then(|e| e.parsed_date())
296 .or_else(|| {
297 whois_fallback.as_ref().and_then(|w| w.expiration_date)
299 });
300
301 let registrar = data
302 .get_registrar()
303 .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
304
305 (expiration_date, registrar)
306 }
307 LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
308 LookupResult::Available { .. } => (None, None),
309 }
310 }
311}
312
313fn trim_for_cache(mut result: LookupResult) -> LookupResult {
317 const MAX_RAW: usize = 32 * 1024;
318
319 match result {
320 LookupResult::Whois { ref mut data, .. } => {
321 if data.raw_response.len() > MAX_RAW {
322 data.raw_response.truncate(MAX_RAW);
323 data.raw_response.push_str("\n... [truncated for cache]");
324 }
325 }
326 LookupResult::Rdap {
327 ref mut whois_fallback,
328 ..
329 } => {
330 if let Some(ref mut w) = whois_fallback {
331 if w.raw_response.len() > MAX_RAW {
332 w.raw_response.truncate(MAX_RAW);
333 w.raw_response.push_str("\n... [truncated for cache]");
334 }
335 }
336 }
337 LookupResult::Available {
338 ref mut whois_data, ..
339 } => {
340 if let Some(ref mut w) = whois_data {
341 if w.raw_response.len() > MAX_RAW {
342 w.raw_response.truncate(MAX_RAW);
343 w.raw_response.push_str("\n... [truncated for cache]");
344 }
345 }
346 }
347 }
348
349 result
350}
351
352#[derive(Debug, Clone)]
353pub struct SmartLookup {
354 rdap_client: RdapClient,
355 whois_client: WhoisClient,
356 availability_checker: AvailabilityChecker,
357 prefer_rdap: bool,
359 include_fallback: bool,
361}
362
363impl Default for SmartLookup {
364 fn default() -> Self {
365 Self::new()
366 }
367}
368
369impl SmartLookup {
370 pub fn new() -> Self {
373 Self {
374 rdap_client: RdapClient::new(),
375 whois_client: WhoisClient::new(),
376 availability_checker: AvailabilityChecker::new(),
377 prefer_rdap: true,
378 include_fallback: false,
379 }
380 }
381
382 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
385 pub fn prefer_rdap(mut self, prefer: bool) -> Self {
386 self.prefer_rdap = prefer;
387 self
388 }
389
390 #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
393 pub fn include_fallback(mut self, include: bool) -> Self {
394 self.include_fallback = include;
395 self
396 }
397
398 #[instrument(skip(self), fields(domain = %domain))]
402 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
403 self.lookup_with_progress(domain, None).await
404 }
405
406 #[instrument(skip(self, progress), fields(domain = %domain))]
411 pub async fn lookup_with_progress(
412 &self,
413 domain: &str,
414 progress: Option<LookupProgressCallback>,
415 ) -> Result<LookupResult> {
416 let normalized = crate::validation::normalize_domain(domain)?;
417
418 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
420 debug!(domain = %normalized, "Returning cached lookup result");
421 return Ok(cached);
422 }
423
424 let _guard = loop {
436 enum Slot {
437 Waiter(Arc<Notify>),
438 Owner(InflightGuard),
439 }
440
441 let slot = {
442 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
446 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
447 Some(existing) => Slot::Waiter(existing),
448 None => {
449 let n = Arc::new(Notify::new());
450 inflight.insert(normalized.clone(), Arc::downgrade(&n));
451 Slot::Owner(InflightGuard {
452 key: normalized.clone(),
453 notify: n,
454 })
455 }
456 }
457 };
458
459 match slot {
460 Slot::Waiter(n) => {
461 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
462 n.notified().await;
463 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
464 return Ok(cached);
465 }
466 continue;
469 }
470 Slot::Owner(guard) => break guard,
471 }
472 };
473
474 let result = self.lookup_concurrent(&normalized, progress).await?;
475
476 LOOKUP_CACHE.insert(normalized.clone(), trim_for_cache(result.clone()));
479
480 Ok(result)
481 }
482
483 pub fn clear_cache() {
485 LOOKUP_CACHE.clear();
486 }
487
488 #[instrument(skip(self, progress), fields(domain = %domain))]
489 async fn lookup_concurrent(
490 &self,
491 domain: &str,
492 progress: Option<LookupProgressCallback>,
493 ) -> Result<LookupResult> {
494 #[cfg(test)]
495 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
496
497 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
498
499 if let Some(ref cb) = progress {
500 cb("Querying RDAP and WHOIS concurrently");
501 }
502
503 let rdap_fut = self.rdap_client.lookup_domain(domain);
504 let whois_fut = self.whois_client.lookup(domain);
505
506 tokio::pin!(rdap_fut);
507 tokio::pin!(whois_fut);
508
509 enum LegOutcome<T> {
515 Completed(T),
516 GraceTruncated,
517 }
518
519 let (rdap_leg, whois_leg) = tokio::select! {
520 rdap_res = &mut rdap_fut => {
521 let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
523 Ok(res) => LegOutcome::Completed(res),
524 Err(_) => {
525 debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
526 LegOutcome::GraceTruncated
527 }
528 };
529 (LegOutcome::Completed(rdap_res), whois_leg)
530 }
531 whois_res = &mut whois_fut => {
532 let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
534 Ok(res) => LegOutcome::Completed(res),
535 Err(_) => {
536 debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
537 LegOutcome::GraceTruncated
538 }
539 };
540 (rdap_leg, LegOutcome::Completed(whois_res))
541 }
542 };
543
544 let rdap_outcome = match rdap_leg {
546 LegOutcome::Completed(Ok(data)) => {
547 if self.is_rdap_response_useful(&data) {
548 RdapOutcome::Useful(data)
549 } else {
550 RdapOutcome::NoData(data)
551 }
552 }
553 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
554 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
555 };
556
557 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
559 debug!("RDAP lookup successful");
560 let whois_fallback = match whois_leg {
561 LegOutcome::Completed(Ok(w)) => Some(w),
562 _ => None,
563 };
564 return Ok(LookupResult::Rdap {
565 data: Box::new(rdap_data),
566 whois_fallback,
567 });
568 }
569
570 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
581 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
582 RdapOutcome::Useful(_) => {
583 debug!("Unexpected RdapOutcome::Useful in fallback branch");
586 (String::from("RDAP ok"), None, None)
587 }
588 RdapOutcome::NoData(data) => (
589 "RDAP response incomplete".to_string(),
590 Some(Box::new(data)),
591 None,
592 ),
593 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
594 RdapOutcome::GraceTimeout => (
595 format!(
596 "RDAP did not return within {}s grace period after WHOIS won",
597 PROTOCOL_GRACE_PERIOD.as_secs()
598 ),
599 None,
600 None,
601 ),
602 };
603
604 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
605 let availability_match = if rdap_returned_200 {
611 None
612 } else {
613 rdap_seer_error
614 .as_ref()
615 .and_then(|e| classify_whois_leg(&whois_data, e))
616 .or_else(|| {
617 if whois_data.is_available() {
620 Some(("high", "whois"))
621 } else {
622 None
623 }
624 })
625 };
626
627 if let Some((confidence, method)) = availability_match {
628 debug!(
629 domain = %domain,
630 confidence = %confidence,
631 "Reclassifying WHOIS as availability signal"
632 );
633 if let Some(ref cb) = progress {
634 cb("Domain appears unregistered");
635 }
636 let details = match confidence {
637 "high" => Some("WHOIS indicates domain is not registered".to_string()),
638 "medium" => Some(
639 "WHOIS returned no registrar or registration dates; RDAP returned 404"
640 .to_string(),
641 ),
642 _ => None,
643 };
644 let avail = AvailabilityResult {
645 domain: domain.to_string(),
646 available: true,
647 confidence: confidence.to_string(),
648 method: method.to_string(),
649 details,
650 };
651 return Ok(LookupResult::Available {
652 data: Box::new(avail),
653 rdap_error: sanitize_error_for_public(&rdap_error_str),
654 whois_error: String::new(),
655 whois_data: Some(whois_data),
656 });
657 }
658
659 debug!("Using WHOIS result (RDAP not useful)");
660 if let Some(ref cb) = progress {
661 cb("RDAP not available (using WHOIS)");
662 }
663 return Ok(LookupResult::Whois {
664 data: whois_data,
665 rdap_error: Some(rdap_error_str),
666 rdap_fallback: rdap_fallback_data,
667 });
668 }
669
670 let whois_error_str = match whois_leg {
674 LegOutcome::Completed(Err(e)) => e.to_string(),
675 LegOutcome::Completed(Ok(_)) => {
676 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
678 "WHOIS returned but was not used".to_string()
679 }
680 LegOutcome::GraceTruncated => format!(
681 "WHOIS did not return within {}s grace period after RDAP won",
682 PROTOCOL_GRACE_PERIOD.as_secs()
683 ),
684 };
685
686 self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
687 .await
688 }
689
690 async fn availability_fallback(
691 &self,
692 domain: &str,
693 rdap_error: String,
694 whois_error: String,
695 progress: Option<LookupProgressCallback>,
696 ) -> Result<LookupResult> {
697 if let Some(ref cb) = progress {
698 cb("RDAP and WHOIS unavailable (checking availability)");
699 }
700 warn!(
701 domain = %domain,
702 rdap_error = %rdap_error,
703 whois_error = %whois_error,
704 "Both RDAP and WHOIS failed, falling back to availability check"
705 );
706
707 match self.availability_checker.check(domain).await {
708 Ok(avail) => Ok(LookupResult::Available {
709 data: Box::new(avail),
710 rdap_error: sanitize_error_for_public(&rdap_error),
711 whois_error: sanitize_error_for_public(&whois_error),
712 whois_data: None,
713 }),
714 Err(avail_err) => {
715 let tld = get_tld(domain).unwrap_or("unknown");
716 let registry_url = get_registry_url(tld).unwrap_or_else(|| {
717 format!("https://www.iana.org/domains/root/db/{}.html", tld)
718 });
719 Err(SeerError::LookupFailed {
720 domain: domain.to_string(),
721 details: format!(
722 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
723 rdap_error, whois_error, avail_err
724 ),
725 registry_url,
726 })
727 }
728 }
729 }
730
731 fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
732 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
734 let has_dates = response
735 .events
736 .iter()
737 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
738 let has_entities = !response.entities.is_empty();
739 let has_nameservers = !response.nameservers.is_empty();
740 let has_status = !response.status.is_empty();
741
742 has_name && (has_dates || has_entities || has_nameservers || has_status)
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
761
762 #[test]
763 fn test_lookup_result_domain_name_whois() {
764 let result = LookupResult::Whois {
765 data: WhoisResponse {
766 domain: "example.com".to_string(),
767 registrar: Some("Test Registrar".to_string()),
768 registrant: None,
769 organization: None,
770 registrant_email: None,
771 registrant_phone: None,
772 registrant_address: None,
773 registrant_country: None,
774 admin_name: None,
775 admin_organization: None,
776 admin_email: None,
777 admin_phone: None,
778 tech_name: None,
779 tech_organization: None,
780 tech_email: None,
781 tech_phone: None,
782 creation_date: None,
783 expiration_date: None,
784 updated_date: None,
785 status: vec![],
786 nameservers: vec![],
787 dnssec: None,
788 whois_server: "whois.example.com".to_string(),
789 raw_response: String::new(),
790 },
791 rdap_error: None,
792 rdap_fallback: None,
793 };
794
795 assert_eq!(result.domain_name(), Some("example.com".to_string()));
796 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
797 assert!(result.is_whois());
798 assert!(!result.is_rdap());
799 assert!(!result.is_available());
800 }
801
802 #[test]
803 fn test_lookup_result_serialization() {
804 let result = LookupResult::Whois {
805 data: WhoisResponse {
806 domain: "test.com".to_string(),
807 registrar: None,
808 registrant: None,
809 organization: None,
810 registrant_email: None,
811 registrant_phone: None,
812 registrant_address: None,
813 registrant_country: None,
814 admin_name: None,
815 admin_organization: None,
816 admin_email: None,
817 admin_phone: None,
818 tech_name: None,
819 tech_organization: None,
820 tech_email: None,
821 tech_phone: None,
822 creation_date: None,
823 expiration_date: None,
824 updated_date: None,
825 status: vec![],
826 nameservers: vec![],
827 dnssec: None,
828 whois_server: String::new(),
829 raw_response: String::new(),
830 },
831 rdap_error: Some("RDAP failed".to_string()),
832 rdap_fallback: None,
833 };
834
835 let json = serde_json::to_string(&result).unwrap();
836 assert!(json.contains("\"source\":\"whois\""));
837 assert!(json.contains("RDAP failed"));
838 }
839
840 #[test]
841 fn test_lookup_result_available_serialization() {
842 let result = LookupResult::Available {
843 data: Box::new(AvailabilityResult {
844 domain: "test123.xyz".to_string(),
845 available: true,
846 confidence: "medium".to_string(),
847 method: "whois_error".to_string(),
848 details: Some("WHOIS server indicates no matching records".to_string()),
849 }),
850 rdap_error: "RDAP failed".to_string(),
851 whois_error: "WHOIS failed".to_string(),
852 whois_data: None,
853 };
854
855 let json = serde_json::to_string(&result).unwrap();
856 assert!(json.contains("\"source\":\"available\""));
857 assert!(json.contains("\"available\":true"));
858 assert!(json.contains("test123.xyz"));
859
860 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
861 assert!(result.is_available());
862 assert!(!result.is_rdap());
863 assert!(!result.is_whois());
864 assert!(result.registrar().is_none());
865 assert_eq!(result.expiration_info(), (None, None));
866 }
867
868 #[test]
869 #[allow(deprecated)]
870 fn test_smart_lookup_builder() {
871 let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
872 assert!(!lookup.prefer_rdap);
873 assert!(lookup.include_fallback);
874 }
875
876 #[test]
877 fn test_lookup_cache_clear() {
878 SmartLookup::clear_cache();
879 assert!(LOOKUP_CACHE.is_empty());
880 }
881
882 #[test]
885 fn test_sanitize_strips_ipv4() {
886 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
887 let sanitized = sanitize_error_for_public(msg);
888 assert!(
889 !sanitized.contains("10.0.0.1"),
890 "IPv4 should be stripped, got: {}",
891 sanitized
892 );
893 assert!(sanitized.contains("[ip-redacted]"));
894 }
895
896 #[test]
897 fn test_sanitize_strips_multiple_ipv4() {
898 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
899 let sanitized = sanitize_error_for_public(msg);
900 assert!(!sanitized.contains("192.168.1.1"));
901 assert!(!sanitized.contains("127.0.0.1"));
902 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
904 }
905
906 #[test]
907 fn test_sanitize_strips_ipv6() {
908 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
909 let sanitized = sanitize_error_for_public(msg);
910 assert!(!sanitized.contains("fe80::1"));
911 assert!(sanitized.contains("[ip-redacted]"));
912 }
913
914 #[test]
915 fn sanitize_leaves_mac_address_like_tokens_alone() {
916 let msg = "error code af:ba:12 at line 5";
917 let out = sanitize_error_for_public(msg);
918 assert!(
919 out.contains("af:ba:12"),
920 "MAC fragment should not be stripped: {}",
921 out
922 );
923 }
924
925 #[test]
926 fn sanitize_strips_real_ipv6() {
927 let msg = "cannot reach 2001:db8::1 — timeout";
928 let out = sanitize_error_for_public(msg);
929 assert!(!out.contains("2001:db8::1"));
930 assert!(out.contains("[ip-redacted]"));
931 }
932
933 #[test]
934 fn sanitize_strips_fe80_link_local() {
935 let msg = "peer at fe80::1 unreachable";
936 let out = sanitize_error_for_public(msg);
937 assert!(out.contains("[ip-redacted]"));
938 }
939
940 #[test]
941 fn test_sanitize_truncates_long_message() {
942 let long = "a".repeat(500);
944 let sanitized = sanitize_error_for_public(&long);
945 let char_count = sanitized.chars().count();
947 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
948 assert!(sanitized.ends_with('…'));
949 }
950
951 #[test]
952 fn test_sanitize_preserves_short_messages() {
953 let msg = "RDAP timed out after 15s";
954 let sanitized = sanitize_error_for_public(msg);
955 assert_eq!(sanitized, msg);
956 }
957
958 #[test]
961 fn test_is_rdap_response_useful_detects_no_data() {
962 use crate::rdap::RdapResponse;
963 let resp = RdapResponse {
967 ldh_name: Some("example.com".to_string()),
968 ..Default::default()
969 };
970 let lookup = SmartLookup::new();
971 assert!(
972 !lookup.is_rdap_response_useful(&resp),
973 "Response with only a name should be classified as NoData"
974 );
975
976 let useful = RdapResponse {
978 ldh_name: Some("example.com".to_string()),
979 status: vec!["active".to_string()],
980 ..Default::default()
981 };
982 assert!(lookup.is_rdap_response_useful(&useful));
983 }
984
985 #[tokio::test]
993 async fn test_inflight_coalescing_map() {
994 let _serial = INFLIGHT_TEST_SERIAL
999 .lock()
1000 .unwrap_or_else(|p| p.into_inner());
1001 let domain = unique_test_key("__coalesce");
1010
1011 {
1013 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1014 m.remove(&domain);
1015 }
1016
1017 let owner_notify = Arc::new(Notify::new());
1019 {
1020 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1021 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1022 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1023 }
1024
1025 let waiter = {
1027 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1028 m.get(&domain)
1029 .and_then(|w| w.upgrade())
1030 .expect("Second caller must observe in-flight entry")
1031 };
1032
1033 let waiter_clone = waiter.clone();
1035 let handle = tokio::spawn(async move {
1036 waiter_clone.notified().await;
1037 });
1038
1039 tokio::time::sleep(Duration::from_millis(20)).await;
1041 {
1042 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1043 m.remove(&domain);
1044 }
1045 owner_notify.notify_waiters();
1046
1047 tokio::time::timeout(Duration::from_secs(1), handle)
1049 .await
1050 .expect("waiter must unblock after notify")
1051 .expect("waiter task joined cleanly");
1052
1053 drop(owner_notify);
1055 drop(waiter);
1056 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1057 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1058 }
1059
1060 fn unique_test_key(prefix: &str) -> String {
1066 use std::sync::atomic::{AtomicU64, Ordering};
1067 use std::time::{SystemTime, UNIX_EPOCH};
1068 static COUNTER: AtomicU64 = AtomicU64::new(0);
1069 let nanos = SystemTime::now()
1070 .duration_since(UNIX_EPOCH)
1071 .map(|d| d.as_nanos())
1072 .unwrap_or(0);
1073 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1074 format!("{}_{}_{}.example.", prefix, nanos, n)
1075 }
1076
1077 #[test]
1083 fn test_sanitize_applied_to_available_fields() {
1084 let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1085 let whois_raw = "connection refused at 192.168.0.5";
1086 let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1087 let sanitized_whois = sanitize_error_for_public(whois_raw);
1088 let result = LookupResult::Available {
1089 data: Box::new(AvailabilityResult {
1090 domain: "unreg.test".to_string(),
1091 available: true,
1092 confidence: "low".to_string(),
1093 method: "heuristic".to_string(),
1094 details: None,
1095 }),
1096 rdap_error: sanitized_rdap,
1097 whois_error: sanitized_whois,
1098 whois_data: None,
1099 };
1100 if let LookupResult::Available {
1101 rdap_error,
1102 whois_error,
1103 ..
1104 } = result
1105 {
1106 assert!(!rdap_error.contains("10.0.0.1"));
1107 assert!(!whois_error.contains("192.168.0.5"));
1108 assert!(rdap_error.contains("[ip-redacted]"));
1109 assert!(whois_error.contains("[ip-redacted]"));
1110 } else {
1111 panic!("expected Available variant");
1112 }
1113 }
1114
1115 #[test]
1116 fn rdap_error_is_404_matches_standard_404() {
1117 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1118 assert!(rdap_error_is_404(&e));
1119 }
1120
1121 #[test]
1122 fn rdap_error_is_404_matches_without_reason_phrase() {
1123 let e = SeerError::RdapError("query failed with status 404".to_string());
1124 assert!(rdap_error_is_404(&e));
1125 }
1126
1127 #[test]
1128 fn rdap_error_is_404_rejects_other_statuses() {
1129 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1130 assert!(!rdap_error_is_404(&e));
1131 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1132 assert!(!rdap_error_is_404(&e));
1133 }
1134
1135 #[test]
1136 fn rdap_error_is_404_rejects_non_http_errors() {
1137 let e = SeerError::RdapError("connection timeout".to_string());
1138 assert!(!rdap_error_is_404(&e));
1139 let e = SeerError::Timeout("rdap".to_string());
1140 assert!(!rdap_error_is_404(&e));
1141 }
1142
1143 #[test]
1144 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1145 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1147 assert!(!rdap_error_is_404(&e));
1148 }
1149
1150 fn empty_whois(domain: &str) -> WhoisResponse {
1153 WhoisResponse {
1154 domain: domain.to_string(),
1155 registrar: None,
1156 registrant: None,
1157 organization: None,
1158 registrant_email: None,
1159 registrant_phone: None,
1160 registrant_address: None,
1161 registrant_country: None,
1162 admin_name: None,
1163 admin_organization: None,
1164 admin_email: None,
1165 admin_phone: None,
1166 tech_name: None,
1167 tech_organization: None,
1168 tech_email: None,
1169 tech_phone: None,
1170 creation_date: None,
1171 expiration_date: None,
1172 updated_date: None,
1173 nameservers: vec![],
1174 status: vec![],
1175 dnssec: None,
1176 whois_server: String::new(),
1177 raw_response: String::new(),
1178 }
1179 }
1180
1181 #[test]
1182 fn whois_response_is_thin_when_all_key_fields_missing() {
1183 let w = empty_whois("example.com");
1184 assert!(whois_response_is_thin(&w));
1185 }
1186
1187 #[test]
1188 fn whois_response_is_not_thin_when_registrar_present() {
1189 let mut w = empty_whois("example.com");
1190 w.registrar = Some("Test Registrar".to_string());
1191 assert!(!whois_response_is_thin(&w));
1192 }
1193
1194 #[test]
1195 fn whois_response_is_not_thin_when_creation_date_present() {
1196 let mut w = empty_whois("example.com");
1197 w.creation_date = Some(Utc::now());
1198 assert!(!whois_response_is_thin(&w));
1199 }
1200
1201 #[test]
1202 fn whois_response_is_not_thin_when_expiration_date_present() {
1203 let mut w = empty_whois("example.com");
1204 w.expiration_date = Some(Utc::now());
1205 assert!(!whois_response_is_thin(&w));
1206 }
1207
1208 #[test]
1209 fn whois_response_is_thin_even_with_nameservers_alone() {
1210 let mut w = empty_whois("example.com");
1211 w.nameservers = vec!["ns1.example.net".to_string()];
1212 assert!(whois_response_is_thin(&w));
1213 }
1214
1215 use crate::rdap::RdapResponse;
1218
1219 #[allow(dead_code)]
1220 fn make_empty_rdap_response() -> RdapResponse {
1221 serde_json::from_value(serde_json::json!({
1222 "objectClassName": "domain",
1223 }))
1224 .expect("valid minimal RDAP response")
1225 }
1226
1227 #[test]
1228 fn classify_whois_leg_case_a_high_confidence() {
1229 let mut w = empty_whois("zaccodes.com");
1230 w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1231 assert!(w.is_available());
1232 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1233 let (verdict, method) =
1234 classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1235 assert_eq!(verdict, "high");
1236 assert_eq!(method, "whois");
1237 }
1238
1239 #[test]
1240 fn classify_whois_leg_case_b_medium_confidence() {
1241 let w = empty_whois("example.xyz");
1242 assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
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, "medium");
1247 assert_eq!(method, "whois_thin_response");
1248 }
1249
1250 #[test]
1251 fn classify_whois_leg_rejects_thin_whois_without_404() {
1252 let w = empty_whois("example.xyz");
1253 let rdap_err = SeerError::RdapError("connection timeout".to_string());
1254 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1255 }
1256
1257 #[test]
1258 fn classify_whois_leg_rejects_whois_with_real_data() {
1259 let mut w = empty_whois("legacy.tld");
1260 w.registrar = Some("Legacy Registry".to_string());
1261 w.creation_date = Some(Utc::now());
1262 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1263 assert!(classify_whois_leg(&w, &rdap_err).is_none());
1264 }
1265
1266 #[test]
1267 fn classify_whois_leg_case_a_wins_over_case_b() {
1268 let mut w = empty_whois("example.com");
1269 w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1270 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1271 let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1272 assert_eq!(verdict, "high");
1273 }
1274
1275 #[test]
1285 fn lookup_inflight_recovers_from_poisoned_mutex() {
1286 use std::panic::{catch_unwind, AssertUnwindSafe};
1287
1288 let _serial = INFLIGHT_TEST_SERIAL
1290 .lock()
1291 .unwrap_or_else(|p| p.into_inner());
1292
1293 let _ = catch_unwind(AssertUnwindSafe(|| {
1295 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1296 panic!("poisoning LOOKUP_INFLIGHT for test");
1297 }));
1298
1299 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1303 let canary = unique_test_key("__poison_recovery");
1305 guard.insert(canary.clone(), Weak::new());
1306 assert!(guard.contains_key(&canary));
1307 guard.remove(&canary);
1308 }
1309
1310 #[test]
1313 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1314 use std::panic::{catch_unwind, AssertUnwindSafe};
1315
1316 let _serial = INFLIGHT_TEST_SERIAL
1322 .lock()
1323 .unwrap_or_else(|p| p.into_inner());
1324
1325 let key = unique_test_key("__drop_poison");
1330 let notify = Arc::new(Notify::new());
1331 {
1332 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1333 map.insert(key.clone(), Arc::downgrade(¬ify));
1334 }
1335 let guard = InflightGuard {
1336 key: key.clone(),
1337 notify: notify.clone(),
1338 };
1339
1340 let _ = catch_unwind(AssertUnwindSafe(|| {
1342 let _g = LOOKUP_INFLIGHT.lock().unwrap();
1343 panic!("poisoning LOOKUP_INFLIGHT for drop test");
1344 }));
1345
1346 drop(guard);
1349
1350 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1351 assert!(
1352 !map.contains_key(&key),
1353 "poisoned-mutex drop path should still remove the in-flight entry"
1354 );
1355 }
1356}