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 serde::{Deserialize, Serialize};
9use std::sync::LazyLock;
10use tokio::sync::Notify;
11use tracing::{debug, instrument, warn};
12
13use tokio::time::timeout as tokio_timeout;
14
15use crate::availability::{
16 classify_fallback, AvailabilityChecker, AvailabilityResult, Fallback, PriorRdap, PriorWhois,
17};
18use crate::cache::TtlCache;
19use crate::dns::{DnsPresence, DnsResolver};
20use crate::error::{Result, SeerError};
21use crate::rdap::{RdapClient, RdapResponse};
22use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
23
24const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
26
27const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
35
36const MAX_PUBLIC_ERROR_LEN: usize = 256;
38
39const DEFAULT_INFLIGHT_WAIT: Duration = Duration::from_secs(30);
48
49static LOOKUP_CACHE: LazyLock<TtlCache<String, LookupResult>> =
51 LazyLock::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
52
53static LOOKUP_INFLIGHT: LazyLock<Mutex<HashMap<String, Weak<Notify>>>> =
57 LazyLock::new(|| Mutex::new(HashMap::new()));
58
59static_regex! {
61 IPV4_RE = r"\b(?:\d{1,3}\.){3}\d{1,3}\b";
62
63 IPV6_CANDIDATE_RE = r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b";
69}
70
71fn strip_ipv6(msg: &str) -> String {
74 IPV6_CANDIDATE_RE
75 .replace_all(msg, |caps: ®ex::Captures| {
76 let candidate = &caps[0];
77 if Ipv6Addr::from_str(candidate).is_ok() {
78 "[ip-redacted]".to_string()
79 } else {
80 candidate.to_string()
81 }
82 })
83 .into_owned()
84}
85
86#[cfg(test)]
90static LOOKUP_CONCURRENT_CALLS: LazyLock<std::sync::atomic::AtomicUsize> =
91 LazyLock::new(|| std::sync::atomic::AtomicUsize::new(0));
92
93const DEGRADED_LOOKUP_CACHE_TTL: Duration = Duration::from_secs(30);
100
101fn is_degraded_result(result: &LookupResult) -> bool {
107 match result {
108 LookupResult::Available { data, .. } => matches!(
109 data.method.as_str(),
110 "inconclusive" | "dns_present" | "dns_nxdomain"
111 ),
112 LookupResult::Whois { data, .. } => data.is_thin(),
113 LookupResult::Rdap { .. } => false,
114 }
115}
116
117fn available_with_whois(
122 avail: AvailabilityResult,
123 rdap_error: &str,
124 whois_data: WhoisResponse,
125) -> LookupResult {
126 LookupResult::Available {
127 data: Box::new(avail),
128 rdap_error: sanitize_error_for_public(rdap_error),
129 whois_error: String::new(),
130 whois_data: Some(whois_data),
131 }
132}
133
134fn rdap_response_is_useful(response: &RdapResponse) -> bool {
138 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
139 let has_dates = response
140 .events
141 .iter()
142 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
143 let has_entities = !response.entities.is_empty();
144 let has_nameservers = !response.nameservers.is_empty();
145 let has_status = !response.status.is_empty();
146
147 has_name && (has_dates || has_entities || has_nameservers || has_status)
148}
149
150fn whois_leg_has_data(w: &Result<WhoisResponse>) -> bool {
160 matches!(w, Ok(data) if !data.is_thin())
161}
162
163fn whois_leg_fallback(
171 domain: &str,
172 rdap_returned_200: bool,
173 rdap_err: Option<&SeerError>,
174 whois: &WhoisResponse,
175) -> Fallback {
176 if rdap_returned_200 {
177 return Fallback::Registered;
178 }
179 classify_fallback(domain, rdap_err, Ok(whois))
180}
181
182fn verdict_progress(avail: &AvailabilityResult) -> &'static str {
184 match avail.method.as_str() {
185 "registrable_parent" => "Name is below a registrable domain (checked its parent)",
186 "inconclusive" => "Registry gave no usable answer (availability inconclusive)",
187 "dns_present" => "Domain is registered (registry detail unavailable)",
188 "dns_nxdomain" => "Domain appears unregistered (no DNS presence)",
189 _ if avail.available => "Domain appears unregistered",
190 _ => "Domain is registered",
191 }
192}
193
194fn sanitize_error_for_public(msg: &str) -> String {
200 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
201 let s = strip_ipv6(&s);
202 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
203 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
204 trunc.push('…');
205 trunc
206 } else {
207 s
208 }
209}
210
211struct InflightGuard {
224 key: String,
225 notify: Arc<Notify>,
226}
227
228impl Drop for InflightGuard {
229 fn drop(&mut self) {
230 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
243 inflight.remove(&self.key);
244 drop(inflight);
245 self.notify.notify_waiters();
246 }
247}
248
249enum LegOutcome<T> {
255 Completed(T),
257 GraceTruncated,
261}
262
263async fn race_with_grace<R, W>(
278 rdap_fut: impl std::future::Future<Output = R>,
279 whois_fut: impl std::future::Future<Output = W>,
280 rdap_has_data: impl Fn(&R) -> bool,
281 whois_has_data: impl Fn(&W) -> bool,
282) -> (LegOutcome<R>, LegOutcome<W>) {
283 tokio::pin!(rdap_fut);
284 tokio::pin!(whois_fut);
285
286 tokio::select! {
287 rdap_res = &mut rdap_fut => {
288 let whois_leg = if rdap_has_data(&rdap_res) {
289 match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
290 Ok(res) => LegOutcome::Completed(res),
291 Err(_) => LegOutcome::GraceTruncated,
292 }
293 } else {
294 LegOutcome::Completed(whois_fut.await)
295 };
296 (LegOutcome::Completed(rdap_res), whois_leg)
297 }
298 whois_res = &mut whois_fut => {
299 let rdap_leg = if whois_has_data(&whois_res) {
300 match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
301 Ok(res) => LegOutcome::Completed(res),
302 Err(_) => LegOutcome::GraceTruncated,
303 }
304 } else {
305 LegOutcome::Completed(rdap_fut.await)
306 };
307 (rdap_leg, LegOutcome::Completed(whois_res))
308 }
309 }
310}
311
312fn grace_truncated_error(truncated: &str, winner: &str) -> String {
318 format!(
319 "{} did not respond within the {}s grace period after {} answered",
320 truncated,
321 PROTOCOL_GRACE_PERIOD.as_secs(),
322 winner
323 )
324}
325
326enum RdapOutcome {
332 Useful(RdapResponse),
333 NoData(RdapResponse),
334 Error(SeerError),
335 GraceTimeout,
338}
339
340pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(tag = "source", rename_all = "lowercase")]
346pub enum LookupResult {
347 Rdap {
348 data: Box<RdapResponse>,
349 #[serde(skip_serializing_if = "Option::is_none")]
350 whois_fallback: Option<WhoisResponse>,
351 },
352 Whois {
353 data: WhoisResponse,
354 rdap_error: Option<String>,
355 #[serde(skip_serializing_if = "Option::is_none")]
356 rdap_fallback: Option<Box<RdapResponse>>,
357 },
358 Available {
359 data: Box<AvailabilityResult>,
360 rdap_error: String,
361 whois_error: String,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
366 whois_data: Option<WhoisResponse>,
367 },
368}
369
370impl LookupResult {
371 pub fn protocol(&self) -> Option<&'static str> {
375 match self {
376 LookupResult::Rdap { .. } => Some("RDAP"),
377 LookupResult::Whois { .. } => Some("WHOIS"),
378 LookupResult::Available { .. } => None,
379 }
380 }
381
382 pub fn domain_name(&self) -> Option<String> {
388 match self {
389 LookupResult::Rdap { data, .. } => data.domain_name().map(normalize_rdap_name),
390 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
391 LookupResult::Available { data, .. } => Some(data.domain.clone()),
392 }
393 }
394
395 fn rdap_or_whois<T>(
398 &self,
399 rdap: impl FnOnce(&RdapResponse) -> Option<T>,
400 whois: impl FnOnce(&WhoisResponse) -> Option<T>,
401 ) -> Option<T> {
402 match self {
403 LookupResult::Rdap {
404 data,
405 whois_fallback,
406 } => rdap(data).or_else(|| whois_fallback.as_ref().and_then(whois)),
407 LookupResult::Whois { data, .. } => whois(data),
408 LookupResult::Available { .. } => None,
409 }
410 }
411
412 pub fn registrar(&self) -> Option<String> {
414 self.rdap_or_whois(RdapResponse::get_registrar, |w| w.registrar.clone())
415 }
416
417 pub fn organization(&self) -> Option<String> {
419 self.rdap_or_whois(RdapResponse::get_registrant_organization, |w| {
420 w.organization.clone()
421 })
422 }
423
424 pub fn creation_date(&self) -> Option<DateTime<Utc>> {
426 self.rdap_or_whois(RdapResponse::creation_date, |w| w.creation_date)
427 }
428
429 pub fn expiration_date(&self) -> Option<DateTime<Utc>> {
431 self.rdap_or_whois(RdapResponse::expiration_date, |w| w.expiration_date)
432 }
433
434 pub fn is_rdap(&self) -> bool {
436 matches!(self, LookupResult::Rdap { .. })
437 }
438
439 pub fn is_whois(&self) -> bool {
441 matches!(self, LookupResult::Whois { .. })
442 }
443
444 pub fn is_available(&self) -> bool {
446 matches!(self, LookupResult::Available { .. })
447 }
448
449 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
451 (self.expiration_date(), self.registrar())
452 }
453}
454
455fn normalize_rdap_name(name: &str) -> String {
458 let name = name.trim_end_matches('.').to_lowercase();
459 crate::validation::domain_to_ascii(&name).unwrap_or(name)
460}
461
462fn truncate_on_char_boundary(s: &mut String, max: usize) {
469 if s.len() > max {
470 let mut end = max;
471 while end > 0 && !s.is_char_boundary(end) {
472 end -= 1;
473 }
474 s.truncate(end);
475 }
476}
477
478pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
484 const MAX_RAW: usize = 32 * 1024;
485 if whois.raw_response.len() > MAX_RAW {
486 truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
487 whois.raw_response.push_str("\n... [truncated]");
488 }
489}
490
491pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
495 match result {
496 LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
497 LookupResult::Rdap {
498 ref mut whois_fallback,
499 ..
500 } => {
501 if let Some(w) = whois_fallback {
502 trim_whois_raw(w);
503 }
504 }
505 LookupResult::Available {
506 ref mut whois_data, ..
507 } => {
508 if let Some(w) = whois_data {
509 trim_whois_raw(w);
510 }
511 }
512 }
513
514 result
515}
516
517#[derive(Debug, Clone, Default)]
518pub struct SmartLookup {
519 rdap_client: RdapClient,
520 whois_client: WhoisClient,
521 availability_checker: AvailabilityChecker,
522 dns_resolver: DnsResolver,
523}
524
525impl SmartLookup {
526 pub fn new() -> Self {
529 Self::default()
530 }
531
532 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
535 Self {
536 rdap_client: RdapClient::from_config(config),
537 whois_client: WhoisClient::from_config(config),
538 availability_checker: AvailabilityChecker::from_config(config),
539 dns_resolver: DnsResolver::from_config(config),
540 }
541 }
542
543 #[instrument(skip(self), fields(domain = %domain))]
548 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
549 self.lookup_with_progress(domain, None).await
550 }
551
552 #[instrument(skip(self, progress), fields(domain = %domain))]
558 pub async fn lookup_with_progress(
559 &self,
560 domain: &str,
561 progress: Option<LookupProgressCallback>,
562 ) -> Result<LookupResult> {
563 let normalized = crate::validation::normalize_domain(domain)?;
564
565 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
567 debug!(domain = %normalized, "Returning cached lookup result");
568 return Ok(cached);
569 }
570
571 let _guard = loop {
584 let existing: Arc<Notify>;
585 let notified = {
586 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
590 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
591 Some(n) => existing = n,
592 None => {
593 let n = Arc::new(Notify::new());
594 inflight.insert(normalized.clone(), Arc::downgrade(&n));
595 break InflightGuard {
596 key: normalized.clone(),
597 notify: n,
598 };
599 }
600 }
601 existing.notified()
611 };
612 tokio::pin!(notified);
613 notified.as_mut().enable();
615 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
616
617 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
620 return Ok(cached);
621 }
622
623 let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
627
628 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
629 return Ok(cached);
630 }
631 };
634
635 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
641 debug!(domain = %normalized, "Returning result cached by previous owner");
642 return Ok(cached);
643 }
644
645 let result = self.lookup_concurrent(&normalized, progress).await?;
646
647 let ttl = if is_degraded_result(&result) {
652 DEGRADED_LOOKUP_CACHE_TTL
653 } else {
654 LOOKUP_CACHE_TTL
655 };
656 LOOKUP_CACHE.insert_with_ttl(normalized.clone(), trim_raw_response(result.clone()), ttl);
657
658 Ok(result)
659 }
660
661 #[instrument(skip(self, progress), fields(domain = %domain))]
662 async fn lookup_concurrent(
663 &self,
664 domain: &str,
665 progress: Option<LookupProgressCallback>,
666 ) -> Result<LookupResult> {
667 #[cfg(test)]
668 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
669
670 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
671
672 if let Some(ref cb) = progress {
673 cb("Querying RDAP and WHOIS concurrently");
674 }
675
676 let rdap_fut = self.rdap_client.lookup_domain(domain);
677 let whois_fut = self.whois_client.lookup(domain);
678
679 let (rdap_leg, whois_leg) = race_with_grace(
687 rdap_fut,
688 whois_fut,
689 |r| matches!(r, Ok(data) if rdap_response_is_useful(data)),
690 whois_leg_has_data,
691 )
692 .await;
693
694 if matches!(whois_leg, LegOutcome::GraceTruncated) {
695 debug!("WHOIS did not finish within grace period after RDAP answered, proceeding with RDAP only");
696 }
697 if matches!(rdap_leg, LegOutcome::GraceTruncated) {
698 debug!("RDAP did not finish within grace period after WHOIS answered, proceeding with WHOIS only");
699 }
700
701 let rdap_outcome = match rdap_leg {
703 LegOutcome::Completed(Ok(data)) => {
704 if rdap_response_is_useful(&data) {
705 RdapOutcome::Useful(data)
706 } else {
707 RdapOutcome::NoData(data)
708 }
709 }
710 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
711 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
712 };
713
714 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
716 debug!("RDAP lookup successful");
717 let whois_fallback = match whois_leg {
718 LegOutcome::Completed(Ok(w)) => Some(w),
719 _ => None,
720 };
721 return Ok(LookupResult::Rdap {
722 data: Box::new(rdap_data),
723 whois_fallback,
724 });
725 }
726
727 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
738 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
739 RdapOutcome::Useful(_) => {
740 debug!("Unexpected RdapOutcome::Useful in fallback branch");
743 (String::from("RDAP ok"), None, None)
744 }
745 RdapOutcome::NoData(data) => (
746 "RDAP response incomplete".to_string(),
747 Some(Box::new(data)),
748 None,
749 ),
750 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
751 RdapOutcome::GraceTimeout => (grace_truncated_error("RDAP", "WHOIS"), None, None),
752 };
753
754 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
755 let fallback = whois_leg_fallback(
759 domain,
760 rdap_returned_200,
761 rdap_seer_error.as_ref(),
762 &whois_data,
763 );
764 let verdict = match fallback {
765 Fallback::Registered => None,
766 Fallback::Verdict(avail) => Some(avail),
767 Fallback::NeedsDns(tie_break) => {
768 Some(tie_break.decide(self.dns_resolver.presence(domain).await))
769 }
770 };
771 if let Some(avail) = verdict {
772 debug!(
773 domain = %domain,
774 method = %avail.method,
775 confidence = %avail.confidence,
776 "Reading WHOIS leg as an availability verdict"
777 );
778 let avail = self.availability_checker.guard_subdomain_claim(avail).await;
781 if let Some(ref cb) = progress {
782 cb(verdict_progress(&avail));
783 }
784 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
785 }
786 debug!("Using WHOIS result (RDAP not useful)");
787 if let Some(ref cb) = progress {
788 cb("RDAP not available (using WHOIS)");
789 }
790 return Ok(LookupResult::Whois {
791 data: whois_data,
792 rdap_error: Some(sanitize_error_for_public(&rdap_error_str)),
793 rdap_fallback: rdap_fallback_data,
794 });
795 }
796
797 let whois_error_str = match &whois_leg {
802 LegOutcome::Completed(Err(e)) => e.to_string(),
803 LegOutcome::Completed(Ok(_)) => {
804 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
806 "WHOIS returned but was not used".to_string()
807 }
808 LegOutcome::GraceTruncated => grace_truncated_error("WHOIS", "RDAP"),
812 };
813
814 let prior_rdap = if rdap_returned_200 {
819 match rdap_fallback_data {
820 Some(resp) => PriorRdap::Response(resp),
822 None => PriorRdap::Missing,
823 }
824 } else if let Some(e) = rdap_seer_error {
825 PriorRdap::Failed(e)
826 } else {
827 PriorRdap::Missing
828 };
829 let prior_whois = match whois_leg {
830 LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
831 LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
833 LegOutcome::GraceTruncated => PriorWhois::Missing,
834 };
835
836 self.availability_fallback(
837 domain,
838 prior_rdap,
839 prior_whois,
840 rdap_error_str,
841 whois_error_str,
842 progress,
843 )
844 .await
845 }
846
847 async fn availability_fallback(
848 &self,
849 domain: &str,
850 prior_rdap: PriorRdap,
851 prior_whois: PriorWhois,
852 rdap_error: String,
853 whois_error: String,
854 progress: Option<LookupProgressCallback>,
855 ) -> Result<LookupResult> {
856 if let Some(ref cb) = progress {
857 cb("RDAP and WHOIS unavailable (checking availability)");
858 }
859 warn!(
860 domain = %domain,
861 rdap_error = %rdap_error,
862 whois_error = %whois_error,
863 "Both RDAP and WHOIS failed, falling back to availability check"
864 );
865
866 match self
867 .availability_checker
868 .check_with_prior(domain, prior_rdap, prior_whois)
869 .await
870 {
871 Ok(avail) => Ok(LookupResult::Available {
872 data: Box::new(avail),
873 rdap_error: sanitize_error_for_public(&rdap_error),
874 whois_error: sanitize_error_for_public(&whois_error),
875 whois_data: None,
876 }),
877 Err(avail_err) => Err(SeerError::LookupFailed {
878 domain: domain.to_string(),
879 details: format!(
880 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
881 rdap_error, whois_error, avail_err
882 ),
883 registry_url: get_registry_url(get_tld(domain)),
884 }),
885 }
886 }
887
888 pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
893 self.dns_resolver.presence(domain).await
894 }
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use crate::rdap::rdap_error_is_404;
901
902 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
914
915 #[test]
916 fn test_lookup_result_domain_name_whois() {
917 let result = LookupResult::Whois {
918 data: WhoisResponse {
919 domain: "example.com".to_string(),
920 registrar: Some("Test Registrar".to_string()),
921 whois_server: "whois.example.com".to_string(),
922 ..Default::default()
923 },
924 rdap_error: None,
925 rdap_fallback: None,
926 };
927
928 assert_eq!(result.domain_name(), Some("example.com".to_string()));
929 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
930 assert!(result.is_whois());
931 assert!(!result.is_rdap());
932 assert!(!result.is_available());
933 }
934
935 #[test]
936 fn rdap_domain_name_is_normalized_like_the_other_protocols() {
937 for (ldh, unicode) in [
938 (Some("EXAMPLE.COM"), None),
939 (Some("Example.Com."), None),
940 (None, Some("пример.рф")),
941 ] {
942 let result = LookupResult::Rdap {
943 data: Box::new(RdapResponse {
944 ldh_name: ldh.map(str::to_string),
945 unicode_name: unicode.map(str::to_string),
946 ..Default::default()
947 }),
948 whois_fallback: None,
949 };
950 let expected = if unicode.is_some() {
951 "xn--e1afmkfd.xn--p1ai"
952 } else {
953 "example.com"
954 };
955 assert_eq!(result.domain_name().as_deref(), Some(expected));
956 }
957 }
958
959 #[test]
960 fn test_lookup_result_serialization() {
961 let result = LookupResult::Whois {
962 data: WhoisResponse {
963 domain: "test.com".to_string(),
964 ..Default::default()
965 },
966 rdap_error: Some("RDAP failed".to_string()),
967 rdap_fallback: None,
968 };
969
970 let json = serde_json::to_string(&result).unwrap();
971 assert!(json.contains("\"source\":\"whois\""));
972 assert!(json.contains("RDAP failed"));
973 }
974
975 #[test]
976 fn test_lookup_result_available_serialization() {
977 let result = LookupResult::Available {
978 data: Box::new(
979 AvailabilityResult::new("test123.xyz", true, "medium", "dns_nxdomain")
980 .with_details("Registry lookups failed; domain has no DNS presence (NXDOMAIN)"),
981 ),
982 rdap_error: "RDAP failed".to_string(),
983 whois_error: "WHOIS failed".to_string(),
984 whois_data: None,
985 };
986
987 let json = serde_json::to_string(&result).unwrap();
988 assert!(json.contains("\"source\":\"available\""));
989 assert!(json.contains("\"available\":true"));
990 assert!(json.contains("test123.xyz"));
991
992 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
993 assert!(result.is_available());
994 assert!(!result.is_rdap());
995 assert!(!result.is_whois());
996 assert!(result.registrar().is_none());
997 assert_eq!(result.expiration_info(), (None, None));
998 }
999
1000 #[test]
1003 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1004 const MAX_RAW: usize = 32 * 1024;
1005 let mut s = "a".repeat(MAX_RAW - 1);
1009 s.push('€'); s.push_str(&"b".repeat(100));
1011 assert!(!s.is_char_boundary(MAX_RAW));
1012
1013 truncate_on_char_boundary(&mut s, MAX_RAW);
1015 assert!(s.len() <= MAX_RAW);
1016 assert_eq!(s.len(), MAX_RAW - 1);
1019 }
1020
1021 #[test]
1022 fn trim_raw_response_truncates_multibyte_whois_without_panic() {
1023 const MAX_RAW: usize = 32 * 1024;
1024 let mut raw = "a".repeat(MAX_RAW - 1);
1025 raw.push('€');
1026 raw.push_str(&"b".repeat(1000));
1027
1028 let mut w = empty_whois("example.com");
1029 w.raw_response = raw;
1030 let result = trim_raw_response(LookupResult::Whois {
1031 data: w,
1032 rdap_error: None,
1033 rdap_fallback: None,
1034 });
1035 if let LookupResult::Whois { data, .. } = result {
1036 assert!(data.raw_response.ends_with("[truncated]"));
1037 } else {
1038 panic!("expected Whois variant");
1039 }
1040 }
1041
1042 #[test]
1045 fn test_sanitize_strips_ipv4() {
1046 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1047 let sanitized = sanitize_error_for_public(msg);
1048 assert!(
1049 !sanitized.contains("10.0.0.1"),
1050 "IPv4 should be stripped, got: {}",
1051 sanitized
1052 );
1053 assert!(sanitized.contains("[ip-redacted]"));
1054 }
1055
1056 #[test]
1057 fn test_sanitize_strips_multiple_ipv4() {
1058 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1059 let sanitized = sanitize_error_for_public(msg);
1060 assert!(!sanitized.contains("192.168.1.1"));
1061 assert!(!sanitized.contains("127.0.0.1"));
1062 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1064 }
1065
1066 #[test]
1067 fn test_sanitize_strips_ipv6() {
1068 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1069 let sanitized = sanitize_error_for_public(msg);
1070 assert!(!sanitized.contains("fe80::1"));
1071 assert!(sanitized.contains("[ip-redacted]"));
1072 }
1073
1074 #[test]
1075 fn sanitize_leaves_mac_address_like_tokens_alone() {
1076 let msg = "error code af:ba:12 at line 5";
1077 let out = sanitize_error_for_public(msg);
1078 assert!(
1079 out.contains("af:ba:12"),
1080 "MAC fragment should not be stripped: {}",
1081 out
1082 );
1083 }
1084
1085 #[test]
1086 fn sanitize_strips_real_ipv6() {
1087 let msg = "cannot reach 2001:db8::1 — timeout";
1088 let out = sanitize_error_for_public(msg);
1089 assert!(!out.contains("2001:db8::1"));
1090 assert!(out.contains("[ip-redacted]"));
1091 }
1092
1093 #[test]
1094 fn sanitize_strips_fe80_link_local() {
1095 let msg = "peer at fe80::1 unreachable";
1096 let out = sanitize_error_for_public(msg);
1097 assert!(out.contains("[ip-redacted]"));
1098 }
1099
1100 #[test]
1101 fn test_sanitize_truncates_long_message() {
1102 let long = "a".repeat(500);
1104 let sanitized = sanitize_error_for_public(&long);
1105 let char_count = sanitized.chars().count();
1107 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1108 assert!(sanitized.ends_with('…'));
1109 }
1110
1111 #[test]
1112 fn test_sanitize_preserves_short_messages() {
1113 let msg = "RDAP timed out after 15s";
1114 let sanitized = sanitize_error_for_public(msg);
1115 assert_eq!(sanitized, msg);
1116 }
1117
1118 #[test]
1121 fn test_is_rdap_response_useful_detects_no_data() {
1122 use crate::rdap::RdapResponse;
1123 let resp = RdapResponse {
1127 ldh_name: Some("example.com".to_string()),
1128 ..Default::default()
1129 };
1130 assert!(
1131 !rdap_response_is_useful(&resp),
1132 "Response with only a name should be classified as NoData"
1133 );
1134
1135 let useful = RdapResponse {
1137 ldh_name: Some("example.com".to_string()),
1138 status: vec!["active".to_string()],
1139 ..Default::default()
1140 };
1141 assert!(rdap_response_is_useful(&useful));
1142 }
1143
1144 #[allow(clippy::await_holding_lock)]
1155 #[tokio::test]
1156 async fn test_inflight_coalescing_map() {
1157 let _serial = INFLIGHT_TEST_SERIAL
1162 .lock()
1163 .unwrap_or_else(|p| p.into_inner());
1164 let domain = unique_test_key("__coalesce");
1173
1174 {
1176 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1177 m.remove(&domain);
1178 }
1179
1180 let owner_notify = Arc::new(Notify::new());
1182 {
1183 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1184 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1185 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1186 }
1187
1188 let waiter = {
1190 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1191 m.get(&domain)
1192 .and_then(|w| w.upgrade())
1193 .expect("Second caller must observe in-flight entry")
1194 };
1195
1196 let waiter_clone = waiter.clone();
1198 let handle = tokio::spawn(async move {
1199 waiter_clone.notified().await;
1200 });
1201
1202 tokio::time::sleep(Duration::from_millis(20)).await;
1204 {
1205 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1206 m.remove(&domain);
1207 }
1208 owner_notify.notify_waiters();
1209
1210 tokio::time::timeout(Duration::from_secs(1), handle)
1212 .await
1213 .expect("waiter must unblock after notify")
1214 .expect("waiter task joined cleanly");
1215
1216 drop(owner_notify);
1218 drop(waiter);
1219 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1220 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1221 }
1222
1223 #[allow(clippy::await_holding_lock)]
1241 #[tokio::test]
1242 async fn waiters_coalesce_on_inflight_lookup_and_read_owners_cache() {
1243 use std::sync::atomic::Ordering;
1244
1245 let _serial = INFLIGHT_TEST_SERIAL
1246 .lock()
1247 .unwrap_or_else(|p| p.into_inner());
1248
1249 let raw = unique_test_key("coalesce-waiter");
1250 let normalized = crate::validation::normalize_domain(&raw).expect("test key normalizes");
1251 LOOKUP_CACHE.remove(&normalized);
1252
1253 let owner_notify = Arc::new(Notify::new());
1255 {
1256 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1257 m.insert(normalized.clone(), Arc::downgrade(&owner_notify));
1258 }
1259
1260 let calls_before = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1261
1262 let lookup = SmartLookup::new();
1264 let spawn_waiter = |l: SmartLookup, d: String| {
1265 tokio::spawn(async move { l.lookup_with_progress(&d, None).await })
1266 };
1267 let h1 = spawn_waiter(lookup.clone(), raw.clone());
1268 let h2 = spawn_waiter(lookup, raw);
1269
1270 tokio::time::sleep(Duration::from_millis(50)).await;
1274
1275 let canned = LookupResult::Whois {
1278 data: empty_whois(&normalized),
1279 rdap_error: None,
1280 rdap_fallback: None,
1281 };
1282 LOOKUP_CACHE.insert(normalized.clone(), canned);
1283 {
1284 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1285 m.remove(&normalized);
1286 }
1287 owner_notify.notify_waiters();
1288
1289 for handle in [h1, h2] {
1292 let result = tokio::time::timeout(Duration::from_secs(5), handle)
1293 .await
1294 .expect("waiter must wake via notify, not the bounded-wait timeout")
1295 .expect("waiter task joined cleanly")
1296 .expect("waiter must read the owner's cached result");
1297 assert!(result.is_whois());
1298 assert_eq!(result.domain_name(), Some(normalized.clone()));
1299 }
1300
1301 let calls_after = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1303 assert_eq!(
1304 calls_before, calls_after,
1305 "coalesced waiters must not start a second network race"
1306 );
1307
1308 LOOKUP_CACHE.remove(&normalized);
1309 }
1310
1311 fn unique_test_key(prefix: &str) -> String {
1317 use std::sync::atomic::{AtomicU64, Ordering};
1318 use std::time::{SystemTime, UNIX_EPOCH};
1319 static COUNTER: AtomicU64 = AtomicU64::new(0);
1320 let nanos = SystemTime::now()
1321 .duration_since(UNIX_EPOCH)
1322 .map(|d| d.as_nanos())
1323 .unwrap_or(0);
1324 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1325 format!("{}_{}_{}.example.", prefix, nanos, n)
1326 }
1327
1328 #[test]
1334 fn available_with_whois_sanitizes_rdap_error() {
1335 let avail = AvailabilityResult::new("unreg.test", false, "none", "inconclusive");
1338 let result = available_with_whois(
1339 avail,
1340 "RDAP URL resolves to reserved IP 10.0.0.1",
1341 empty_whois("unreg.test"),
1342 );
1343 let LookupResult::Available {
1344 rdap_error,
1345 whois_error,
1346 whois_data,
1347 ..
1348 } = result
1349 else {
1350 panic!("expected Available variant");
1351 };
1352 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1353 assert!(rdap_error.contains("[ip-redacted]"));
1354 assert!(whois_error.is_empty());
1355 assert!(whois_data.is_some());
1356 }
1357
1358 #[tokio::test]
1359 async fn availability_fallback_sanitizes_both_error_fields() {
1360 let lookup = SmartLookup::new();
1363 let prior = RdapResponse {
1364 ldh_name: Some("example.com".to_string()),
1365 status: vec!["active".to_string()],
1366 ..Default::default()
1367 };
1368 let result = lookup
1369 .availability_fallback(
1370 "example.com",
1371 PriorRdap::Response(Box::new(prior)),
1372 PriorWhois::Missing,
1373 "RDAP URL resolves to reserved IP 10.0.0.1".to_string(),
1374 "connection refused at 192.168.0.5 and fe80::1".to_string(),
1375 None,
1376 )
1377 .await
1378 .expect("prior-response fallback must not error");
1379 let LookupResult::Available {
1380 rdap_error,
1381 whois_error,
1382 ..
1383 } = result
1384 else {
1385 panic!("expected Available variant");
1386 };
1387 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1388 assert!(!whois_error.contains("192.168.0.5"), "{whois_error}");
1389 assert!(!whois_error.contains("fe80::1"), "{whois_error}");
1390 assert!(rdap_error.contains("[ip-redacted]"));
1391 assert!(whois_error.contains("[ip-redacted]"));
1392 }
1393
1394 fn available_via(method: &str, available: bool, confidence: &str) -> LookupResult {
1397 LookupResult::Available {
1398 data: Box::new(AvailabilityResult::new(
1399 "example.test",
1400 available,
1401 confidence,
1402 method,
1403 )),
1404 rdap_error: String::new(),
1405 whois_error: String::new(),
1406 whois_data: None,
1407 }
1408 }
1409
1410 #[test]
1411 fn degraded_results_are_identified_for_short_caching() {
1412 assert!(is_degraded_result(&available_via(
1414 "inconclusive",
1415 false,
1416 "none"
1417 )));
1418 assert!(is_degraded_result(&available_via(
1419 "dns_present",
1420 false,
1421 "high"
1422 )));
1423 assert!(is_degraded_result(&available_via(
1424 "dns_nxdomain",
1425 true,
1426 "medium"
1427 )));
1428 assert!(is_degraded_result(&LookupResult::Whois {
1431 data: empty_whois("example.test"),
1432 rdap_error: Some("RDAP response incomplete".to_string()),
1433 rdap_fallback: None,
1434 }));
1435 assert!(!is_degraded_result(&available_via("rdap", true, "high")));
1437 assert!(!is_degraded_result(&available_via("whois", true, "high")));
1438 assert!(!is_degraded_result(&LookupResult::Whois {
1439 data: denic_whois(),
1440 rdap_error: None,
1441 rdap_fallback: None,
1442 }));
1443 const { assert!(DEGRADED_LOOKUP_CACHE_TTL.as_secs() < LOOKUP_CACHE_TTL.as_secs()) };
1444 }
1445
1446 #[test]
1447 fn rdap_error_is_404_matches_standard_404() {
1448 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1449 assert!(rdap_error_is_404(&e));
1450 }
1451
1452 #[test]
1453 fn rdap_error_is_404_matches_without_reason_phrase() {
1454 let e = SeerError::RdapError("query failed with status 404".to_string());
1455 assert!(rdap_error_is_404(&e));
1456 }
1457
1458 #[test]
1459 fn rdap_error_is_404_rejects_other_statuses() {
1460 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1461 assert!(!rdap_error_is_404(&e));
1462 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1463 assert!(!rdap_error_is_404(&e));
1464 }
1465
1466 #[test]
1467 fn rdap_error_is_404_rejects_non_http_errors() {
1468 let e = SeerError::RdapError("connection timeout".to_string());
1469 assert!(!rdap_error_is_404(&e));
1470 let e = SeerError::Timeout("rdap".to_string());
1471 assert!(!rdap_error_is_404(&e));
1472 }
1473
1474 #[test]
1475 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1476 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1478 assert!(!rdap_error_is_404(&e));
1479 }
1480
1481 fn empty_whois(domain: &str) -> WhoisResponse {
1484 WhoisResponse {
1485 domain: domain.to_string(),
1486 ..Default::default()
1487 }
1488 }
1489
1490 #[test]
1491 fn whois_response_is_thin_when_all_key_fields_missing() {
1492 let w = empty_whois("example.com");
1493 assert!(w.is_thin());
1494 }
1495
1496 #[test]
1497 fn whois_response_is_not_thin_when_registrar_present() {
1498 let mut w = empty_whois("example.com");
1499 w.registrar = Some("Test Registrar".to_string());
1500 assert!(!w.is_thin());
1501 }
1502
1503 #[test]
1504 fn whois_response_is_not_thin_when_creation_date_present() {
1505 let mut w = empty_whois("example.com");
1506 w.creation_date = Some(Utc::now());
1507 assert!(!w.is_thin());
1508 }
1509
1510 #[test]
1511 fn whois_response_is_not_thin_when_expiration_date_present() {
1512 let mut w = empty_whois("example.com");
1513 w.expiration_date = Some(Utc::now());
1514 assert!(!w.is_thin());
1515 }
1516
1517 fn denic_whois() -> WhoisResponse {
1520 let mut w = empty_whois("example.de");
1521 w.nameservers = vec!["ns1.example.net".to_string()];
1522 w.status = vec!["active".to_string()];
1523 w.updated_date = Some(Utc::now());
1524 w.raw_response = "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n".into();
1525 w
1526 }
1527
1528 #[test]
1529 fn whois_response_with_nameservers_is_not_thin() {
1530 let mut w = empty_whois("example.com");
1531 w.nameservers = vec!["ns1.example.net".to_string()];
1532 assert!(!w.is_thin());
1533 }
1534
1535 #[test]
1536 fn denic_whois_stays_on_the_whois_path() {
1537 let w = denic_whois();
1541 let bootstrap_miss =
1542 SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
1543 assert!(!w.is_thin());
1544 assert!(whois_leg_has_data(&Ok(w.clone())));
1545 assert!(matches!(
1546 whois_leg_fallback("example.de", false, Some(&bootstrap_miss), &w),
1547 Fallback::Registered
1548 ));
1549 }
1550
1551 fn verdict_of(fallback: Fallback, dns: DnsPresence) -> Option<(bool, String)> {
1558 let avail = match fallback {
1559 Fallback::Registered => return None,
1560 Fallback::Verdict(v) => v,
1561 Fallback::NeedsDns(t) => t.decide(dns),
1562 };
1563 Some((avail.available, avail.method))
1564 }
1565
1566 #[test]
1567 fn rdap_200_vetoes_whois_no_match_and_thin_bodies() {
1568 let mut no_match = empty_whois("freshly-registered.com");
1572 no_match.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1573 for w in [no_match, empty_whois("freshly-registered.com")] {
1574 assert!(matches!(
1575 whois_leg_fallback("freshly-registered.com", true, None, &w),
1576 Fallback::Registered
1577 ));
1578 }
1579 }
1580
1581 #[test]
1582 fn whois_no_match_routes_to_available_for_any_rdap_failure() {
1583 let mut w = empty_whois("genuinely-free.com");
1584 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1585 let r404 = SeerError::RdapError("query failed with status 404".to_string());
1586 let bootstrap = SeerError::RdapBootstrapError("all registries failed".to_string());
1587 for rdap in [Some(&r404), Some(&bootstrap), None] {
1589 assert_eq!(
1590 verdict_of(
1591 whois_leg_fallback("genuinely-free.com", false, rdap, &w),
1592 DnsPresence::Unknown
1593 ),
1594 Some((true, "whois".to_string()))
1595 );
1596 }
1597 }
1598
1599 #[test]
1600 fn thin_whois_with_rdap_404_is_available_via_rdap() {
1601 let r404 = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1604 let fallback = whois_leg_fallback("example.xyz", false, Some(&r404), &empty_whois("x"));
1605 let Fallback::Verdict(v) = fallback else {
1606 panic!("expected a verdict");
1607 };
1608 assert_eq!(
1609 (v.available, v.confidence.as_str(), v.method.as_str()),
1610 (true, "high", "rdap")
1611 );
1612 }
1613
1614 #[test]
1615 fn thin_whois_with_silent_rdap_is_decided_by_dns() {
1616 let rdap = SeerError::RdapError("query failed with status 429".to_string());
1621 let w = empty_whois("zac.email");
1622 let read = |dns| verdict_of(whois_leg_fallback("zac.email", false, Some(&rdap), &w), dns);
1623 assert_eq!(
1624 read(DnsPresence::Present),
1625 Some((false, "dns_present".to_string()))
1626 );
1627 assert_eq!(
1628 read(DnsPresence::Absent),
1629 Some((true, "dns_nxdomain".to_string()))
1630 );
1631 assert_eq!(
1632 read(DnsPresence::Unknown),
1633 Some((false, "inconclusive".to_string()))
1634 );
1635 }
1636
1637 #[test]
1638 fn thin_whois_refusal_is_inconclusive_regardless_of_dns() {
1639 let rdap = SeerError::RdapError("query failed with status 503".to_string());
1642 let mut w = empty_whois("example.test");
1643 w.raw_response = "Access rate limited; please try again later.\n".to_string();
1644 for dns in [
1645 DnsPresence::Absent,
1646 DnsPresence::Present,
1647 DnsPresence::Unknown,
1648 ] {
1649 assert_eq!(
1650 verdict_of(
1651 whois_leg_fallback("example.test", false, Some(&rdap), &w),
1652 dns
1653 ),
1654 Some((false, "inconclusive".to_string()))
1655 );
1656 }
1657 }
1658
1659 #[test]
1660 fn whois_with_registration_data_stays_a_whois_record() {
1661 let mut w = empty_whois("registered.com");
1662 w.registrar = Some("Example Registrar Ltd".to_string());
1663 let rdap = SeerError::RdapError("connection timeout".to_string());
1664 for rdap in [Some(&rdap), None] {
1665 assert!(matches!(
1666 whois_leg_fallback("registered.com", false, rdap, &w),
1667 Fallback::Registered
1668 ));
1669 }
1670 }
1671
1672 #[test]
1673 fn verdict_progress_names_the_deciding_signal() {
1674 let v =
1675 |available, method: &str| AvailabilityResult::new("x.test", available, "high", method);
1676 assert_eq!(
1677 verdict_progress(&v(true, "rdap")),
1678 "Domain appears unregistered"
1679 );
1680 assert!(verdict_progress(&v(false, "inconclusive")).contains("inconclusive"));
1681 assert!(verdict_progress(&v(false, "dns_present")).contains("registered"));
1682 assert!(verdict_progress(&v(false, "registrable_parent")).contains("parent"));
1683 }
1684
1685 async fn leg<T>(secs: u64, value: T) -> T {
1697 tokio::time::sleep(Duration::from_secs(secs)).await;
1698 value
1699 }
1700
1701 #[tokio::test(start_paused = true)]
1702 async fn race_awaits_whois_fully_when_rdap_errors_first() {
1703 let rdap = leg(0, Err::<&str, &str>("no RDAP server for example.ru"));
1704 let whois = leg(20, Ok::<&str, &str>("whois data"));
1706 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1707 assert!(matches!(r, LegOutcome::Completed(Err(_))));
1708 assert!(
1709 matches!(w, LegOutcome::Completed(Ok("whois data"))),
1710 "WHOIS must not be grace-truncated behind an RDAP failure"
1711 );
1712 }
1713
1714 #[tokio::test(start_paused = true)]
1715 async fn race_awaits_rdap_fully_when_whois_errors_first() {
1716 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1717 let whois = leg(0, Err::<&str, &str>("connection refused"));
1718 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1719 assert!(
1720 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1721 "RDAP must not be grace-truncated behind a WHOIS failure"
1722 );
1723 assert!(matches!(w, LegOutcome::Completed(Err(_))));
1724 }
1725
1726 #[tokio::test(start_paused = true)]
1727 async fn race_truncates_whois_when_rdap_answers_with_data() {
1728 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1729 let whois = leg(20, Ok::<&str, &str>("whois data"));
1730 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1731 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1732 assert!(
1733 matches!(w, LegOutcome::GraceTruncated),
1734 "a data-bearing RDAP winner only owes WHOIS the grace period"
1735 );
1736 }
1737
1738 #[tokio::test(start_paused = true)]
1739 async fn race_truncates_rdap_when_whois_answers_with_data() {
1740 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1741 let whois = leg(0, Ok::<&str, &str>("whois data"));
1742 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1743 assert!(matches!(r, LegOutcome::GraceTruncated));
1744 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1745 }
1746
1747 #[tokio::test(start_paused = true)]
1748 async fn race_loser_inside_grace_period_still_completes() {
1749 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1750 let whois = leg(2, Ok::<&str, &str>("whois data"));
1752 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1753 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1754 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1755 }
1756
1757 #[tokio::test(start_paused = true)]
1758 async fn race_awaits_whois_fully_when_rdap_wins_with_unusable_data() {
1759 let rdap = leg(0, Ok::<&str, &str>("thin"));
1762 let whois = leg(20, Ok::<&str, &str>("whois data"));
1763 let (r, w) = race_with_grace(
1764 rdap,
1765 whois,
1766 |r| matches!(r, Ok(s) if *s == "useful"),
1767 |w| w.is_ok(),
1768 )
1769 .await;
1770 assert!(matches!(r, LegOutcome::Completed(Ok("thin"))));
1771 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1772 }
1773
1774 #[tokio::test(start_paused = true)]
1775 async fn race_awaits_rdap_fully_when_whois_wins_with_thin_body() {
1776 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1784 let whois = leg(0, Ok::<_, SeerError>(empty_whois("zac.email")));
1785 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
1786 assert!(
1787 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1788 "RDAP must not be grace-truncated behind a thin WHOIS answer"
1789 );
1790 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1791 }
1792
1793 #[tokio::test(start_paused = true)]
1794 async fn race_truncates_rdap_when_whois_wins_with_real_data() {
1795 let mut whois_data = empty_whois("example.com");
1798 whois_data.registrar = Some("Mock Registrar".to_string());
1799 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1800 let whois = leg(0, Ok::<_, SeerError>(whois_data));
1801 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
1802 assert!(
1803 matches!(r, LegOutcome::GraceTruncated),
1804 "a data-bearing WHOIS winner only owes RDAP the grace period"
1805 );
1806 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1807 }
1808
1809 #[test]
1812 fn whois_leg_has_data_rejects_thin_ok_and_errors() {
1813 assert!(!whois_leg_has_data(&Ok(empty_whois("example.email"))));
1817 let mut no_match = empty_whois("example.com");
1818 no_match.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1819 assert!(!whois_leg_has_data(&Ok(no_match)));
1820 assert!(!whois_leg_has_data(&Err(SeerError::WhoisError(
1821 "connection refused".to_string()
1822 ))));
1823 }
1824
1825 #[test]
1826 fn whois_leg_has_data_accepts_registration_data() {
1827 let mut w = empty_whois("example.com");
1830 w.registrar = Some("Mock Registrar".to_string());
1831 assert!(whois_leg_has_data(&Ok(w)));
1832 let mut w = empty_whois("example.com");
1833 w.expiration_date = Some(Utc::now());
1834 assert!(whois_leg_has_data(&Ok(w)));
1835 }
1836
1837 #[test]
1840 fn grace_truncated_error_says_answered_not_won() {
1841 let msg = grace_truncated_error("WHOIS", "RDAP");
1845 assert_eq!(
1846 msg,
1847 format!(
1848 "WHOIS did not respond within the {}s grace period after RDAP answered",
1849 PROTOCOL_GRACE_PERIOD.as_secs()
1850 )
1851 );
1852 assert!(!msg.contains("won"));
1853 }
1854
1855 #[test]
1856 fn grace_truncated_error_is_symmetric() {
1857 let msg = grace_truncated_error("RDAP", "WHOIS");
1858 assert!(msg.starts_with("RDAP did not respond"));
1859 assert!(msg.ends_with("after WHOIS answered"));
1860 }
1861
1862 #[test]
1872 fn lookup_inflight_recovers_from_poisoned_mutex() {
1873 use std::panic::{catch_unwind, AssertUnwindSafe};
1874
1875 let _serial = INFLIGHT_TEST_SERIAL
1877 .lock()
1878 .unwrap_or_else(|p| p.into_inner());
1879
1880 let _ = catch_unwind(AssertUnwindSafe(|| {
1882 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1883 panic!("poisoning LOOKUP_INFLIGHT for test");
1884 }));
1885
1886 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1890 let canary = unique_test_key("__poison_recovery");
1892 guard.insert(canary.clone(), Weak::new());
1893 assert!(guard.contains_key(&canary));
1894 guard.remove(&canary);
1895 }
1896
1897 #[test]
1900 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1901 use std::panic::{catch_unwind, AssertUnwindSafe};
1902
1903 let _serial = INFLIGHT_TEST_SERIAL
1909 .lock()
1910 .unwrap_or_else(|p| p.into_inner());
1911
1912 let key = unique_test_key("__drop_poison");
1917 let notify = Arc::new(Notify::new());
1918 {
1919 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1920 map.insert(key.clone(), Arc::downgrade(¬ify));
1921 }
1922 let guard = InflightGuard {
1923 key: key.clone(),
1924 notify: notify.clone(),
1925 };
1926
1927 let _ = catch_unwind(AssertUnwindSafe(|| {
1929 let _g = LOOKUP_INFLIGHT.lock().unwrap();
1930 panic!("poisoning LOOKUP_INFLIGHT for drop test");
1931 }));
1932
1933 drop(guard);
1936
1937 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1938 assert!(
1939 !map.contains_key(&key),
1940 "poisoned-mutex drop path should still remove the in-flight entry"
1941 );
1942 }
1943}