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::{DelegationChecker, DnsNameservers, 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 #[serde(default, skip_serializing_if = "Option::is_none")]
361 dns_nameservers: Option<DnsNameservers>,
362 },
363 Available {
364 data: Box<AvailabilityResult>,
365 rdap_error: String,
366 whois_error: String,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
371 whois_data: Option<WhoisResponse>,
372 },
373}
374
375impl LookupResult {
376 pub fn protocol(&self) -> Option<&'static str> {
380 match self {
381 LookupResult::Rdap { .. } => Some("RDAP"),
382 LookupResult::Whois { .. } => Some("WHOIS"),
383 LookupResult::Available { .. } => None,
384 }
385 }
386
387 pub fn domain_name(&self) -> Option<String> {
393 match self {
394 LookupResult::Rdap { data, .. } => data.domain_name().map(normalize_rdap_name),
395 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
396 LookupResult::Available { data, .. } => Some(data.domain.clone()),
397 }
398 }
399
400 fn rdap_or_whois<T>(
403 &self,
404 rdap: impl FnOnce(&RdapResponse) -> Option<T>,
405 whois: impl FnOnce(&WhoisResponse) -> Option<T>,
406 ) -> Option<T> {
407 match self {
408 LookupResult::Rdap {
409 data,
410 whois_fallback,
411 } => rdap(data).or_else(|| whois_fallback.as_ref().and_then(whois)),
412 LookupResult::Whois { data, .. } => whois(data),
413 LookupResult::Available { .. } => None,
414 }
415 }
416
417 pub fn registrar(&self) -> Option<String> {
419 self.rdap_or_whois(RdapResponse::get_registrar, |w| w.registrar.clone())
420 }
421
422 pub fn organization(&self) -> Option<String> {
424 self.rdap_or_whois(RdapResponse::get_registrant_organization, |w| {
425 w.organization.clone()
426 })
427 }
428
429 pub fn creation_date(&self) -> Option<DateTime<Utc>> {
431 self.rdap_or_whois(RdapResponse::creation_date, |w| w.creation_date)
432 }
433
434 pub fn expiration_date(&self) -> Option<DateTime<Utc>> {
436 self.rdap_or_whois(RdapResponse::expiration_date, |w| w.expiration_date)
437 }
438
439 pub fn is_rdap(&self) -> bool {
441 matches!(self, LookupResult::Rdap { .. })
442 }
443
444 pub fn is_whois(&self) -> bool {
446 matches!(self, LookupResult::Whois { .. })
447 }
448
449 pub fn is_available(&self) -> bool {
451 matches!(self, LookupResult::Available { .. })
452 }
453
454 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
456 (self.expiration_date(), self.registrar())
457 }
458}
459
460fn normalize_rdap_name(name: &str) -> String {
463 let name = name.trim_end_matches('.').to_lowercase();
464 crate::validation::domain_to_ascii(&name).unwrap_or(name)
465}
466
467fn truncate_on_char_boundary(s: &mut String, max: usize) {
474 if s.len() > max {
475 let mut end = max;
476 while end > 0 && !s.is_char_boundary(end) {
477 end -= 1;
478 }
479 s.truncate(end);
480 }
481}
482
483pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
489 const MAX_RAW: usize = 32 * 1024;
490 if whois.raw_response.len() > MAX_RAW {
491 truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
492 whois.raw_response.push_str("\n... [truncated]");
493 }
494}
495
496pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
500 match result {
501 LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
502 LookupResult::Rdap {
503 ref mut whois_fallback,
504 ..
505 } => {
506 if let Some(w) = whois_fallback {
507 trim_whois_raw(w);
508 }
509 }
510 LookupResult::Available {
511 ref mut whois_data, ..
512 } => {
513 if let Some(w) = whois_data {
514 trim_whois_raw(w);
515 }
516 }
517 }
518
519 result
520}
521
522fn wants_dns_nameservers(result: &LookupResult) -> bool {
525 matches!(
526 result,
527 LookupResult::Whois {
528 rdap_fallback: None,
529 ..
530 }
531 )
532}
533
534fn with_dns_nameservers(mut result: LookupResult, delegated: Result<Vec<String>>) -> LookupResult {
537 if !wants_dns_nameservers(&result) {
538 return result;
539 }
540 if let LookupResult::Whois {
541 data,
542 dns_nameservers,
543 ..
544 } = &mut result
545 {
546 match delegated {
547 Ok(delegated) => {
548 *dns_nameservers = Some(DnsNameservers::compare(&data.nameservers, delegated));
549 }
550 Err(e) => debug!(error = %e, "DNS delegation unavailable for the WHOIS cross-check"),
551 }
552 }
553 result
554}
555
556#[derive(Debug, Clone, Default)]
557pub struct SmartLookup {
558 rdap_client: RdapClient,
559 whois_client: WhoisClient,
560 availability_checker: AvailabilityChecker,
561 dns_resolver: DnsResolver,
562 delegation_checker: DelegationChecker,
563}
564
565impl SmartLookup {
566 pub fn new() -> Self {
569 Self::default()
570 }
571
572 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
575 Self {
576 rdap_client: RdapClient::from_config(config),
577 whois_client: WhoisClient::from_config(config),
578 availability_checker: AvailabilityChecker::from_config(config),
579 dns_resolver: DnsResolver::from_config(config),
580 delegation_checker: DelegationChecker::from_config(config),
581 }
582 }
583
584 #[instrument(skip(self), fields(domain = %domain))]
589 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
590 self.lookup_with_progress(domain, None).await
591 }
592
593 #[instrument(skip(self, progress), fields(domain = %domain))]
599 pub async fn lookup_with_progress(
600 &self,
601 domain: &str,
602 progress: Option<LookupProgressCallback>,
603 ) -> Result<LookupResult> {
604 let normalized = crate::validation::normalize_domain(domain)?;
605
606 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
608 debug!(domain = %normalized, "Returning cached lookup result");
609 return Ok(cached);
610 }
611
612 let _guard = loop {
625 let existing: Arc<Notify>;
626 let notified = {
627 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
631 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
632 Some(n) => existing = n,
633 None => {
634 let n = Arc::new(Notify::new());
635 inflight.insert(normalized.clone(), Arc::downgrade(&n));
636 break InflightGuard {
637 key: normalized.clone(),
638 notify: n,
639 };
640 }
641 }
642 existing.notified()
652 };
653 tokio::pin!(notified);
654 notified.as_mut().enable();
656 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
657
658 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
661 return Ok(cached);
662 }
663
664 let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
668
669 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
670 return Ok(cached);
671 }
672 };
675
676 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
682 debug!(domain = %normalized, "Returning result cached by previous owner");
683 return Ok(cached);
684 }
685
686 let result = self
687 .lookup_concurrent(&normalized, progress.clone())
688 .await?;
689 let result = self
690 .attach_dns_nameservers(&normalized, result, progress)
691 .await;
692
693 let ttl = if is_degraded_result(&result) {
698 DEGRADED_LOOKUP_CACHE_TTL
699 } else {
700 LOOKUP_CACHE_TTL
701 };
702 LOOKUP_CACHE.insert_with_ttl(normalized.clone(), trim_raw_response(result.clone()), ttl);
703
704 Ok(result)
705 }
706
707 async fn attach_dns_nameservers(
711 &self,
712 domain: &str,
713 result: LookupResult,
714 progress: Option<LookupProgressCallback>,
715 ) -> LookupResult {
716 if !wants_dns_nameservers(&result) {
717 return result;
718 }
719 if let Some(ref cb) = progress {
720 cb("Checking the DNS delegation");
721 }
722 let delegated = self.delegation_checker.delegated_ns(domain).await;
723 with_dns_nameservers(result, delegated)
724 }
725
726 #[instrument(skip(self, progress), fields(domain = %domain))]
727 async fn lookup_concurrent(
728 &self,
729 domain: &str,
730 progress: Option<LookupProgressCallback>,
731 ) -> Result<LookupResult> {
732 #[cfg(test)]
733 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
734
735 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
736
737 if let Some(ref cb) = progress {
738 cb("Querying RDAP and WHOIS concurrently");
739 }
740
741 let rdap_fut = self.rdap_client.lookup_domain(domain);
742 let whois_fut = self.whois_client.lookup(domain);
743
744 let (rdap_leg, whois_leg) = race_with_grace(
752 rdap_fut,
753 whois_fut,
754 |r| matches!(r, Ok(data) if rdap_response_is_useful(data)),
755 whois_leg_has_data,
756 )
757 .await;
758
759 if matches!(whois_leg, LegOutcome::GraceTruncated) {
760 debug!("WHOIS did not finish within grace period after RDAP answered, proceeding with RDAP only");
761 }
762 if matches!(rdap_leg, LegOutcome::GraceTruncated) {
763 debug!("RDAP did not finish within grace period after WHOIS answered, proceeding with WHOIS only");
764 }
765
766 let rdap_outcome = match rdap_leg {
768 LegOutcome::Completed(Ok(data)) => {
769 if rdap_response_is_useful(&data) {
770 RdapOutcome::Useful(data)
771 } else {
772 RdapOutcome::NoData(data)
773 }
774 }
775 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
776 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
777 };
778
779 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
781 debug!("RDAP lookup successful");
782 let whois_fallback = match whois_leg {
783 LegOutcome::Completed(Ok(w)) => Some(w),
784 _ => None,
785 };
786 return Ok(LookupResult::Rdap {
787 data: Box::new(rdap_data),
788 whois_fallback,
789 });
790 }
791
792 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
803 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
804 RdapOutcome::Useful(_) => {
805 debug!("Unexpected RdapOutcome::Useful in fallback branch");
808 (String::from("RDAP ok"), None, None)
809 }
810 RdapOutcome::NoData(data) => (
811 "RDAP response incomplete".to_string(),
812 Some(Box::new(data)),
813 None,
814 ),
815 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
816 RdapOutcome::GraceTimeout => (grace_truncated_error("RDAP", "WHOIS"), None, None),
817 };
818
819 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
820 let fallback = whois_leg_fallback(
824 domain,
825 rdap_returned_200,
826 rdap_seer_error.as_ref(),
827 &whois_data,
828 );
829 let verdict = match fallback {
830 Fallback::Registered => None,
831 Fallback::Verdict(avail) => Some(avail),
832 Fallback::NeedsDns(tie_break) => {
833 Some(tie_break.decide(self.dns_resolver.presence(domain).await))
834 }
835 };
836 if let Some(avail) = verdict {
837 debug!(
838 domain = %domain,
839 method = %avail.method,
840 confidence = %avail.confidence,
841 "Reading WHOIS leg as an availability verdict"
842 );
843 let avail = self.availability_checker.guard_subdomain_claim(avail).await;
846 if let Some(ref cb) = progress {
847 cb(verdict_progress(&avail));
848 }
849 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
850 }
851 debug!("Using WHOIS result (RDAP not useful)");
852 if let Some(ref cb) = progress {
853 cb("RDAP not available (using WHOIS)");
854 }
855 return Ok(LookupResult::Whois {
856 data: whois_data,
857 rdap_error: Some(sanitize_error_for_public(&rdap_error_str)),
858 rdap_fallback: rdap_fallback_data,
859 dns_nameservers: None,
860 });
861 }
862
863 let whois_error_str = match &whois_leg {
868 LegOutcome::Completed(Err(e)) => e.to_string(),
869 LegOutcome::Completed(Ok(_)) => {
870 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
872 "WHOIS returned but was not used".to_string()
873 }
874 LegOutcome::GraceTruncated => grace_truncated_error("WHOIS", "RDAP"),
878 };
879
880 let prior_rdap = if rdap_returned_200 {
885 match rdap_fallback_data {
886 Some(resp) => PriorRdap::Response(resp),
888 None => PriorRdap::Missing,
889 }
890 } else if let Some(e) = rdap_seer_error {
891 PriorRdap::Failed(e)
892 } else {
893 PriorRdap::Missing
894 };
895 let prior_whois = match whois_leg {
896 LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
897 LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
899 LegOutcome::GraceTruncated => PriorWhois::Missing,
900 };
901
902 self.availability_fallback(
903 domain,
904 prior_rdap,
905 prior_whois,
906 rdap_error_str,
907 whois_error_str,
908 progress,
909 )
910 .await
911 }
912
913 async fn availability_fallback(
914 &self,
915 domain: &str,
916 prior_rdap: PriorRdap,
917 prior_whois: PriorWhois,
918 rdap_error: String,
919 whois_error: String,
920 progress: Option<LookupProgressCallback>,
921 ) -> Result<LookupResult> {
922 if let Some(ref cb) = progress {
923 cb("RDAP and WHOIS unavailable (checking availability)");
924 }
925 warn!(
926 domain = %domain,
927 rdap_error = %rdap_error,
928 whois_error = %whois_error,
929 "Both RDAP and WHOIS failed, falling back to availability check"
930 );
931
932 match self
933 .availability_checker
934 .check_with_prior(domain, prior_rdap, prior_whois)
935 .await
936 {
937 Ok(avail) => Ok(LookupResult::Available {
938 data: Box::new(avail),
939 rdap_error: sanitize_error_for_public(&rdap_error),
940 whois_error: sanitize_error_for_public(&whois_error),
941 whois_data: None,
942 }),
943 Err(avail_err) => Err(SeerError::LookupFailed {
944 domain: domain.to_string(),
945 details: format!(
946 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
947 rdap_error, whois_error, avail_err
948 ),
949 registry_url: get_registry_url(get_tld(domain)),
950 }),
951 }
952 }
953
954 pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
959 self.dns_resolver.presence(domain).await
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use crate::rdap::rdap_error_is_404;
967
968 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
980
981 #[test]
982 fn test_lookup_result_domain_name_whois() {
983 let result = LookupResult::Whois {
984 data: WhoisResponse {
985 domain: "example.com".to_string(),
986 registrar: Some("Test Registrar".to_string()),
987 whois_server: "whois.example.com".to_string(),
988 ..Default::default()
989 },
990 rdap_error: None,
991 rdap_fallback: None,
992 dns_nameservers: None,
993 };
994
995 assert_eq!(result.domain_name(), Some("example.com".to_string()));
996 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
997 assert!(result.is_whois());
998 assert!(!result.is_rdap());
999 assert!(!result.is_available());
1000 }
1001
1002 #[test]
1003 fn rdap_domain_name_is_normalized_like_the_other_protocols() {
1004 for (ldh, unicode) in [
1005 (Some("EXAMPLE.COM"), None),
1006 (Some("Example.Com."), None),
1007 (None, Some("пример.рф")),
1008 ] {
1009 let result = LookupResult::Rdap {
1010 data: Box::new(RdapResponse {
1011 ldh_name: ldh.map(str::to_string),
1012 unicode_name: unicode.map(str::to_string),
1013 ..Default::default()
1014 }),
1015 whois_fallback: None,
1016 };
1017 let expected = if unicode.is_some() {
1018 "xn--e1afmkfd.xn--p1ai"
1019 } else {
1020 "example.com"
1021 };
1022 assert_eq!(result.domain_name().as_deref(), Some(expected));
1023 }
1024 }
1025
1026 #[test]
1027 fn test_lookup_result_serialization() {
1028 let result = LookupResult::Whois {
1029 data: WhoisResponse {
1030 domain: "test.com".to_string(),
1031 ..Default::default()
1032 },
1033 rdap_error: Some("RDAP failed".to_string()),
1034 rdap_fallback: None,
1035 dns_nameservers: None,
1036 };
1037
1038 let json = serde_json::to_string(&result).unwrap();
1039 assert!(json.contains("\"source\":\"whois\""));
1040 assert!(json.contains("RDAP failed"));
1041 }
1042
1043 #[test]
1044 fn test_lookup_result_available_serialization() {
1045 let result = LookupResult::Available {
1046 data: Box::new(
1047 AvailabilityResult::new("test123.xyz", true, "medium", "dns_nxdomain")
1048 .with_details("Registry lookups failed; domain has no DNS presence (NXDOMAIN)"),
1049 ),
1050 rdap_error: "RDAP failed".to_string(),
1051 whois_error: "WHOIS failed".to_string(),
1052 whois_data: None,
1053 };
1054
1055 let json = serde_json::to_string(&result).unwrap();
1056 assert!(json.contains("\"source\":\"available\""));
1057 assert!(json.contains("\"available\":true"));
1058 assert!(json.contains("test123.xyz"));
1059
1060 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1061 assert!(result.is_available());
1062 assert!(!result.is_rdap());
1063 assert!(!result.is_whois());
1064 assert!(result.registrar().is_none());
1065 assert_eq!(result.expiration_info(), (None, None));
1066 }
1067
1068 #[test]
1071 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1072 const MAX_RAW: usize = 32 * 1024;
1073 let mut s = "a".repeat(MAX_RAW - 1);
1077 s.push('€'); s.push_str(&"b".repeat(100));
1079 assert!(!s.is_char_boundary(MAX_RAW));
1080
1081 truncate_on_char_boundary(&mut s, MAX_RAW);
1083 assert!(s.len() <= MAX_RAW);
1084 assert_eq!(s.len(), MAX_RAW - 1);
1087 }
1088
1089 #[test]
1090 fn trim_raw_response_truncates_multibyte_whois_without_panic() {
1091 const MAX_RAW: usize = 32 * 1024;
1092 let mut raw = "a".repeat(MAX_RAW - 1);
1093 raw.push('€');
1094 raw.push_str(&"b".repeat(1000));
1095
1096 let mut w = empty_whois("example.com");
1097 w.raw_response = raw;
1098 let result = trim_raw_response(LookupResult::Whois {
1099 data: w,
1100 rdap_error: None,
1101 rdap_fallback: None,
1102 dns_nameservers: None,
1103 });
1104 if let LookupResult::Whois { data, .. } = result {
1105 assert!(data.raw_response.ends_with("[truncated]"));
1106 } else {
1107 panic!("expected Whois variant");
1108 }
1109 }
1110
1111 #[test]
1114 fn test_sanitize_strips_ipv4() {
1115 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1116 let sanitized = sanitize_error_for_public(msg);
1117 assert!(
1118 !sanitized.contains("10.0.0.1"),
1119 "IPv4 should be stripped, got: {}",
1120 sanitized
1121 );
1122 assert!(sanitized.contains("[ip-redacted]"));
1123 }
1124
1125 #[test]
1126 fn test_sanitize_strips_multiple_ipv4() {
1127 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1128 let sanitized = sanitize_error_for_public(msg);
1129 assert!(!sanitized.contains("192.168.1.1"));
1130 assert!(!sanitized.contains("127.0.0.1"));
1131 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1133 }
1134
1135 #[test]
1136 fn test_sanitize_strips_ipv6() {
1137 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1138 let sanitized = sanitize_error_for_public(msg);
1139 assert!(!sanitized.contains("fe80::1"));
1140 assert!(sanitized.contains("[ip-redacted]"));
1141 }
1142
1143 #[test]
1144 fn sanitize_leaves_mac_address_like_tokens_alone() {
1145 let msg = "error code af:ba:12 at line 5";
1146 let out = sanitize_error_for_public(msg);
1147 assert!(
1148 out.contains("af:ba:12"),
1149 "MAC fragment should not be stripped: {}",
1150 out
1151 );
1152 }
1153
1154 #[test]
1155 fn sanitize_strips_real_ipv6() {
1156 let msg = "cannot reach 2001:db8::1 — timeout";
1157 let out = sanitize_error_for_public(msg);
1158 assert!(!out.contains("2001:db8::1"));
1159 assert!(out.contains("[ip-redacted]"));
1160 }
1161
1162 #[test]
1163 fn sanitize_strips_fe80_link_local() {
1164 let msg = "peer at fe80::1 unreachable";
1165 let out = sanitize_error_for_public(msg);
1166 assert!(out.contains("[ip-redacted]"));
1167 }
1168
1169 #[test]
1170 fn test_sanitize_truncates_long_message() {
1171 let long = "a".repeat(500);
1173 let sanitized = sanitize_error_for_public(&long);
1174 let char_count = sanitized.chars().count();
1176 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1177 assert!(sanitized.ends_with('…'));
1178 }
1179
1180 #[test]
1181 fn test_sanitize_preserves_short_messages() {
1182 let msg = "RDAP timed out after 15s";
1183 let sanitized = sanitize_error_for_public(msg);
1184 assert_eq!(sanitized, msg);
1185 }
1186
1187 #[test]
1190 fn test_is_rdap_response_useful_detects_no_data() {
1191 use crate::rdap::RdapResponse;
1192 let resp = RdapResponse {
1196 ldh_name: Some("example.com".to_string()),
1197 ..Default::default()
1198 };
1199 assert!(
1200 !rdap_response_is_useful(&resp),
1201 "Response with only a name should be classified as NoData"
1202 );
1203
1204 let useful = RdapResponse {
1206 ldh_name: Some("example.com".to_string()),
1207 status: vec!["active".to_string()],
1208 ..Default::default()
1209 };
1210 assert!(rdap_response_is_useful(&useful));
1211 }
1212
1213 #[allow(clippy::await_holding_lock)]
1224 #[tokio::test]
1225 async fn test_inflight_coalescing_map() {
1226 let _serial = INFLIGHT_TEST_SERIAL
1231 .lock()
1232 .unwrap_or_else(|p| p.into_inner());
1233 let domain = unique_test_key("__coalesce");
1242
1243 {
1245 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1246 m.remove(&domain);
1247 }
1248
1249 let owner_notify = Arc::new(Notify::new());
1251 {
1252 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1253 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1254 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1255 }
1256
1257 let waiter = {
1259 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1260 m.get(&domain)
1261 .and_then(|w| w.upgrade())
1262 .expect("Second caller must observe in-flight entry")
1263 };
1264
1265 let waiter_clone = waiter.clone();
1267 let handle = tokio::spawn(async move {
1268 waiter_clone.notified().await;
1269 });
1270
1271 tokio::time::sleep(Duration::from_millis(20)).await;
1273 {
1274 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1275 m.remove(&domain);
1276 }
1277 owner_notify.notify_waiters();
1278
1279 tokio::time::timeout(Duration::from_secs(1), handle)
1281 .await
1282 .expect("waiter must unblock after notify")
1283 .expect("waiter task joined cleanly");
1284
1285 drop(owner_notify);
1287 drop(waiter);
1288 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1289 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1290 }
1291
1292 #[allow(clippy::await_holding_lock)]
1310 #[tokio::test]
1311 async fn waiters_coalesce_on_inflight_lookup_and_read_owners_cache() {
1312 use std::sync::atomic::Ordering;
1313
1314 let _serial = INFLIGHT_TEST_SERIAL
1315 .lock()
1316 .unwrap_or_else(|p| p.into_inner());
1317
1318 let raw = unique_test_key("coalesce-waiter");
1319 let normalized = crate::validation::normalize_domain(&raw).expect("test key normalizes");
1320 LOOKUP_CACHE.remove(&normalized);
1321
1322 let owner_notify = Arc::new(Notify::new());
1324 {
1325 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1326 m.insert(normalized.clone(), Arc::downgrade(&owner_notify));
1327 }
1328
1329 let calls_before = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1330
1331 let lookup = SmartLookup::new();
1333 let spawn_waiter = |l: SmartLookup, d: String| {
1334 tokio::spawn(async move { l.lookup_with_progress(&d, None).await })
1335 };
1336 let h1 = spawn_waiter(lookup.clone(), raw.clone());
1337 let h2 = spawn_waiter(lookup, raw);
1338
1339 tokio::time::sleep(Duration::from_millis(50)).await;
1343
1344 let canned = LookupResult::Whois {
1347 data: empty_whois(&normalized),
1348 rdap_error: None,
1349 rdap_fallback: None,
1350 dns_nameservers: None,
1351 };
1352 LOOKUP_CACHE.insert(normalized.clone(), canned);
1353 {
1354 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1355 m.remove(&normalized);
1356 }
1357 owner_notify.notify_waiters();
1358
1359 for handle in [h1, h2] {
1362 let result = tokio::time::timeout(Duration::from_secs(5), handle)
1363 .await
1364 .expect("waiter must wake via notify, not the bounded-wait timeout")
1365 .expect("waiter task joined cleanly")
1366 .expect("waiter must read the owner's cached result");
1367 assert!(result.is_whois());
1368 assert_eq!(result.domain_name(), Some(normalized.clone()));
1369 }
1370
1371 let calls_after = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1373 assert_eq!(
1374 calls_before, calls_after,
1375 "coalesced waiters must not start a second network race"
1376 );
1377
1378 LOOKUP_CACHE.remove(&normalized);
1379 }
1380
1381 fn unique_test_key(prefix: &str) -> String {
1387 use std::sync::atomic::{AtomicU64, Ordering};
1388 use std::time::{SystemTime, UNIX_EPOCH};
1389 static COUNTER: AtomicU64 = AtomicU64::new(0);
1390 let nanos = SystemTime::now()
1391 .duration_since(UNIX_EPOCH)
1392 .map(|d| d.as_nanos())
1393 .unwrap_or(0);
1394 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1395 format!("{}_{}_{}.example.", prefix, nanos, n)
1396 }
1397
1398 #[test]
1404 fn available_with_whois_sanitizes_rdap_error() {
1405 let avail = AvailabilityResult::new("unreg.test", false, "none", "inconclusive");
1408 let result = available_with_whois(
1409 avail,
1410 "RDAP URL resolves to reserved IP 10.0.0.1",
1411 empty_whois("unreg.test"),
1412 );
1413 let LookupResult::Available {
1414 rdap_error,
1415 whois_error,
1416 whois_data,
1417 ..
1418 } = result
1419 else {
1420 panic!("expected Available variant");
1421 };
1422 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1423 assert!(rdap_error.contains("[ip-redacted]"));
1424 assert!(whois_error.is_empty());
1425 assert!(whois_data.is_some());
1426 }
1427
1428 #[tokio::test]
1429 async fn availability_fallback_sanitizes_both_error_fields() {
1430 let lookup = SmartLookup::new();
1433 let prior = RdapResponse {
1434 ldh_name: Some("example.com".to_string()),
1435 status: vec!["active".to_string()],
1436 ..Default::default()
1437 };
1438 let result = lookup
1439 .availability_fallback(
1440 "example.com",
1441 PriorRdap::Response(Box::new(prior)),
1442 PriorWhois::Missing,
1443 "RDAP URL resolves to reserved IP 10.0.0.1".to_string(),
1444 "connection refused at 192.168.0.5 and fe80::1".to_string(),
1445 None,
1446 )
1447 .await
1448 .expect("prior-response fallback must not error");
1449 let LookupResult::Available {
1450 rdap_error,
1451 whois_error,
1452 ..
1453 } = result
1454 else {
1455 panic!("expected Available variant");
1456 };
1457 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1458 assert!(!whois_error.contains("192.168.0.5"), "{whois_error}");
1459 assert!(!whois_error.contains("fe80::1"), "{whois_error}");
1460 assert!(rdap_error.contains("[ip-redacted]"));
1461 assert!(whois_error.contains("[ip-redacted]"));
1462 }
1463
1464 fn available_via(method: &str, available: bool, confidence: &str) -> LookupResult {
1467 LookupResult::Available {
1468 data: Box::new(AvailabilityResult::new(
1469 "example.test",
1470 available,
1471 confidence,
1472 method,
1473 )),
1474 rdap_error: String::new(),
1475 whois_error: String::new(),
1476 whois_data: None,
1477 }
1478 }
1479
1480 #[test]
1481 fn degraded_results_are_identified_for_short_caching() {
1482 assert!(is_degraded_result(&available_via(
1484 "inconclusive",
1485 false,
1486 "none"
1487 )));
1488 assert!(is_degraded_result(&available_via(
1489 "dns_present",
1490 false,
1491 "high"
1492 )));
1493 assert!(is_degraded_result(&available_via(
1494 "dns_nxdomain",
1495 true,
1496 "medium"
1497 )));
1498 assert!(is_degraded_result(&LookupResult::Whois {
1501 data: empty_whois("example.test"),
1502 rdap_error: Some("RDAP response incomplete".to_string()),
1503 rdap_fallback: None,
1504 dns_nameservers: None,
1505 }));
1506 assert!(!is_degraded_result(&available_via("rdap", true, "high")));
1508 assert!(!is_degraded_result(&available_via("whois", true, "high")));
1509 assert!(!is_degraded_result(&LookupResult::Whois {
1510 data: denic_whois(),
1511 rdap_error: None,
1512 rdap_fallback: None,
1513 dns_nameservers: None,
1514 }));
1515 const { assert!(DEGRADED_LOOKUP_CACHE_TTL.as_secs() < LOOKUP_CACHE_TTL.as_secs()) };
1516 }
1517
1518 #[test]
1519 fn rdap_error_is_404_matches_standard_404() {
1520 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1521 assert!(rdap_error_is_404(&e));
1522 }
1523
1524 #[test]
1525 fn rdap_error_is_404_matches_without_reason_phrase() {
1526 let e = SeerError::RdapError("query failed with status 404".to_string());
1527 assert!(rdap_error_is_404(&e));
1528 }
1529
1530 #[test]
1531 fn rdap_error_is_404_rejects_other_statuses() {
1532 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1533 assert!(!rdap_error_is_404(&e));
1534 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1535 assert!(!rdap_error_is_404(&e));
1536 }
1537
1538 #[test]
1539 fn rdap_error_is_404_rejects_non_http_errors() {
1540 let e = SeerError::RdapError("connection timeout".to_string());
1541 assert!(!rdap_error_is_404(&e));
1542 let e = SeerError::Timeout("rdap".to_string());
1543 assert!(!rdap_error_is_404(&e));
1544 }
1545
1546 #[test]
1547 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1548 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1550 assert!(!rdap_error_is_404(&e));
1551 }
1552
1553 fn empty_whois(domain: &str) -> WhoisResponse {
1556 WhoisResponse {
1557 domain: domain.to_string(),
1558 ..Default::default()
1559 }
1560 }
1561
1562 #[test]
1563 fn whois_response_is_thin_when_all_key_fields_missing() {
1564 let w = empty_whois("example.com");
1565 assert!(w.is_thin());
1566 }
1567
1568 #[test]
1569 fn whois_response_is_not_thin_when_registrar_present() {
1570 let mut w = empty_whois("example.com");
1571 w.registrar = Some("Test Registrar".to_string());
1572 assert!(!w.is_thin());
1573 }
1574
1575 #[test]
1576 fn whois_response_is_not_thin_when_creation_date_present() {
1577 let mut w = empty_whois("example.com");
1578 w.creation_date = Some(Utc::now());
1579 assert!(!w.is_thin());
1580 }
1581
1582 #[test]
1583 fn whois_response_is_not_thin_when_expiration_date_present() {
1584 let mut w = empty_whois("example.com");
1585 w.expiration_date = Some(Utc::now());
1586 assert!(!w.is_thin());
1587 }
1588
1589 fn denic_whois() -> WhoisResponse {
1592 let mut w = empty_whois("example.de");
1593 w.nameservers = vec!["ns1.example.net".to_string()];
1594 w.status = vec!["active".to_string()];
1595 w.updated_date = Some(Utc::now());
1596 w.raw_response = "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n".into();
1597 w
1598 }
1599
1600 #[test]
1601 fn whois_response_with_nameservers_is_not_thin() {
1602 let mut w = empty_whois("example.com");
1603 w.nameservers = vec!["ns1.example.net".to_string()];
1604 assert!(!w.is_thin());
1605 }
1606
1607 #[test]
1608 fn denic_whois_stays_on_the_whois_path() {
1609 let w = denic_whois();
1613 let bootstrap_miss =
1614 SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
1615 assert!(!w.is_thin());
1616 assert!(whois_leg_has_data(&Ok(w.clone())));
1617 assert!(matches!(
1618 whois_leg_fallback("example.de", false, Some(&bootstrap_miss), &w),
1619 Fallback::Registered
1620 ));
1621 }
1622
1623 fn verdict_of(fallback: Fallback, dns: DnsPresence) -> Option<(bool, String)> {
1630 let avail = match fallback {
1631 Fallback::Registered => return None,
1632 Fallback::Verdict(v) => v,
1633 Fallback::NeedsDns(t) => t.decide(dns),
1634 };
1635 Some((avail.available, avail.method))
1636 }
1637
1638 #[test]
1639 fn rdap_200_vetoes_whois_no_match_and_thin_bodies() {
1640 let mut no_match = empty_whois("freshly-registered.com");
1644 no_match.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1645 for w in [no_match, empty_whois("freshly-registered.com")] {
1646 assert!(matches!(
1647 whois_leg_fallback("freshly-registered.com", true, None, &w),
1648 Fallback::Registered
1649 ));
1650 }
1651 }
1652
1653 #[test]
1654 fn whois_no_match_routes_to_available_for_any_rdap_failure() {
1655 let mut w = empty_whois("genuinely-free.com");
1656 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1657 let r404 = SeerError::RdapError("query failed with status 404".to_string());
1658 let bootstrap = SeerError::RdapBootstrapError("all registries failed".to_string());
1659 for rdap in [Some(&r404), Some(&bootstrap), None] {
1661 assert_eq!(
1662 verdict_of(
1663 whois_leg_fallback("genuinely-free.com", false, rdap, &w),
1664 DnsPresence::Unknown
1665 ),
1666 Some((true, "whois".to_string()))
1667 );
1668 }
1669 }
1670
1671 #[test]
1672 fn thin_whois_with_rdap_404_is_available_via_rdap() {
1673 let r404 = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1676 let fallback = whois_leg_fallback("example.xyz", false, Some(&r404), &empty_whois("x"));
1677 let Fallback::Verdict(v) = fallback else {
1678 panic!("expected a verdict");
1679 };
1680 assert_eq!(
1681 (v.available, v.confidence.as_str(), v.method.as_str()),
1682 (true, "high", "rdap")
1683 );
1684 }
1685
1686 #[test]
1687 fn thin_whois_with_silent_rdap_is_decided_by_dns() {
1688 let rdap = SeerError::RdapError("query failed with status 429".to_string());
1693 let w = empty_whois("zac.email");
1694 let read = |dns| verdict_of(whois_leg_fallback("zac.email", false, Some(&rdap), &w), dns);
1695 assert_eq!(
1696 read(DnsPresence::Present),
1697 Some((false, "dns_present".to_string()))
1698 );
1699 assert_eq!(
1700 read(DnsPresence::Absent),
1701 Some((true, "dns_nxdomain".to_string()))
1702 );
1703 assert_eq!(
1704 read(DnsPresence::Unknown),
1705 Some((false, "inconclusive".to_string()))
1706 );
1707 }
1708
1709 #[test]
1710 fn thin_whois_refusal_is_inconclusive_regardless_of_dns() {
1711 let rdap = SeerError::RdapError("query failed with status 503".to_string());
1714 let mut w = empty_whois("example.test");
1715 w.raw_response = "Access rate limited; please try again later.\n".to_string();
1716 for dns in [
1717 DnsPresence::Absent,
1718 DnsPresence::Present,
1719 DnsPresence::Unknown,
1720 ] {
1721 assert_eq!(
1722 verdict_of(
1723 whois_leg_fallback("example.test", false, Some(&rdap), &w),
1724 dns
1725 ),
1726 Some((false, "inconclusive".to_string()))
1727 );
1728 }
1729 }
1730
1731 #[test]
1732 fn whois_with_registration_data_stays_a_whois_record() {
1733 let mut w = empty_whois("registered.com");
1734 w.registrar = Some("Example Registrar Ltd".to_string());
1735 let rdap = SeerError::RdapError("connection timeout".to_string());
1736 for rdap in [Some(&rdap), None] {
1737 assert!(matches!(
1738 whois_leg_fallback("registered.com", false, rdap, &w),
1739 Fallback::Registered
1740 ));
1741 }
1742 }
1743
1744 #[test]
1745 fn verdict_progress_names_the_deciding_signal() {
1746 let v =
1747 |available, method: &str| AvailabilityResult::new("x.test", available, "high", method);
1748 assert_eq!(
1749 verdict_progress(&v(true, "rdap")),
1750 "Domain appears unregistered"
1751 );
1752 assert!(verdict_progress(&v(false, "inconclusive")).contains("inconclusive"));
1753 assert!(verdict_progress(&v(false, "dns_present")).contains("registered"));
1754 assert!(verdict_progress(&v(false, "registrable_parent")).contains("parent"));
1755 }
1756
1757 async fn leg<T>(secs: u64, value: T) -> T {
1769 tokio::time::sleep(Duration::from_secs(secs)).await;
1770 value
1771 }
1772
1773 #[tokio::test(start_paused = true)]
1774 async fn race_awaits_whois_fully_when_rdap_errors_first() {
1775 let rdap = leg(0, Err::<&str, &str>("no RDAP server for example.ru"));
1776 let whois = leg(20, Ok::<&str, &str>("whois data"));
1778 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1779 assert!(matches!(r, LegOutcome::Completed(Err(_))));
1780 assert!(
1781 matches!(w, LegOutcome::Completed(Ok("whois data"))),
1782 "WHOIS must not be grace-truncated behind an RDAP failure"
1783 );
1784 }
1785
1786 #[tokio::test(start_paused = true)]
1787 async fn race_awaits_rdap_fully_when_whois_errors_first() {
1788 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1789 let whois = leg(0, Err::<&str, &str>("connection refused"));
1790 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1791 assert!(
1792 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1793 "RDAP must not be grace-truncated behind a WHOIS failure"
1794 );
1795 assert!(matches!(w, LegOutcome::Completed(Err(_))));
1796 }
1797
1798 #[tokio::test(start_paused = true)]
1799 async fn race_truncates_whois_when_rdap_answers_with_data() {
1800 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1801 let whois = leg(20, Ok::<&str, &str>("whois data"));
1802 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1803 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1804 assert!(
1805 matches!(w, LegOutcome::GraceTruncated),
1806 "a data-bearing RDAP winner only owes WHOIS the grace period"
1807 );
1808 }
1809
1810 #[tokio::test(start_paused = true)]
1811 async fn race_truncates_rdap_when_whois_answers_with_data() {
1812 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1813 let whois = leg(0, Ok::<&str, &str>("whois data"));
1814 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1815 assert!(matches!(r, LegOutcome::GraceTruncated));
1816 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1817 }
1818
1819 #[tokio::test(start_paused = true)]
1820 async fn race_loser_inside_grace_period_still_completes() {
1821 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1822 let whois = leg(2, Ok::<&str, &str>("whois data"));
1824 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1825 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1826 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1827 }
1828
1829 #[tokio::test(start_paused = true)]
1830 async fn race_awaits_whois_fully_when_rdap_wins_with_unusable_data() {
1831 let rdap = leg(0, Ok::<&str, &str>("thin"));
1834 let whois = leg(20, Ok::<&str, &str>("whois data"));
1835 let (r, w) = race_with_grace(
1836 rdap,
1837 whois,
1838 |r| matches!(r, Ok(s) if *s == "useful"),
1839 |w| w.is_ok(),
1840 )
1841 .await;
1842 assert!(matches!(r, LegOutcome::Completed(Ok("thin"))));
1843 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1844 }
1845
1846 #[tokio::test(start_paused = true)]
1847 async fn race_awaits_rdap_fully_when_whois_wins_with_thin_body() {
1848 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1856 let whois = leg(0, Ok::<_, SeerError>(empty_whois("zac.email")));
1857 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
1858 assert!(
1859 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1860 "RDAP must not be grace-truncated behind a thin WHOIS answer"
1861 );
1862 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1863 }
1864
1865 #[tokio::test(start_paused = true)]
1866 async fn race_truncates_rdap_when_whois_wins_with_real_data() {
1867 let mut whois_data = empty_whois("example.com");
1870 whois_data.registrar = Some("Mock Registrar".to_string());
1871 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1872 let whois = leg(0, Ok::<_, SeerError>(whois_data));
1873 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
1874 assert!(
1875 matches!(r, LegOutcome::GraceTruncated),
1876 "a data-bearing WHOIS winner only owes RDAP the grace period"
1877 );
1878 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1879 }
1880
1881 #[test]
1884 fn whois_leg_has_data_rejects_thin_ok_and_errors() {
1885 assert!(!whois_leg_has_data(&Ok(empty_whois("example.email"))));
1889 let mut no_match = empty_whois("example.com");
1890 no_match.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1891 assert!(!whois_leg_has_data(&Ok(no_match)));
1892 assert!(!whois_leg_has_data(&Err(SeerError::WhoisError(
1893 "connection refused".to_string()
1894 ))));
1895 }
1896
1897 #[test]
1898 fn whois_leg_has_data_accepts_registration_data() {
1899 let mut w = empty_whois("example.com");
1902 w.registrar = Some("Mock Registrar".to_string());
1903 assert!(whois_leg_has_data(&Ok(w)));
1904 let mut w = empty_whois("example.com");
1905 w.expiration_date = Some(Utc::now());
1906 assert!(whois_leg_has_data(&Ok(w)));
1907 }
1908
1909 #[test]
1912 fn grace_truncated_error_says_answered_not_won() {
1913 let msg = grace_truncated_error("WHOIS", "RDAP");
1917 assert_eq!(
1918 msg,
1919 format!(
1920 "WHOIS did not respond within the {}s grace period after RDAP answered",
1921 PROTOCOL_GRACE_PERIOD.as_secs()
1922 )
1923 );
1924 assert!(!msg.contains("won"));
1925 }
1926
1927 #[test]
1928 fn grace_truncated_error_is_symmetric() {
1929 let msg = grace_truncated_error("RDAP", "WHOIS");
1930 assert!(msg.starts_with("RDAP did not respond"));
1931 assert!(msg.ends_with("after WHOIS answered"));
1932 }
1933
1934 #[test]
1944 fn lookup_inflight_recovers_from_poisoned_mutex() {
1945 use std::panic::{catch_unwind, AssertUnwindSafe};
1946
1947 let _serial = INFLIGHT_TEST_SERIAL
1949 .lock()
1950 .unwrap_or_else(|p| p.into_inner());
1951
1952 let _ = catch_unwind(AssertUnwindSafe(|| {
1954 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1955 panic!("poisoning LOOKUP_INFLIGHT for test");
1956 }));
1957
1958 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1962 let canary = unique_test_key("__poison_recovery");
1964 guard.insert(canary.clone(), Weak::new());
1965 assert!(guard.contains_key(&canary));
1966 guard.remove(&canary);
1967 }
1968
1969 #[test]
1972 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1973 use std::panic::{catch_unwind, AssertUnwindSafe};
1974
1975 let _serial = INFLIGHT_TEST_SERIAL
1981 .lock()
1982 .unwrap_or_else(|p| p.into_inner());
1983
1984 let key = unique_test_key("__drop_poison");
1989 let notify = Arc::new(Notify::new());
1990 {
1991 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1992 map.insert(key.clone(), Arc::downgrade(¬ify));
1993 }
1994 let guard = InflightGuard {
1995 key: key.clone(),
1996 notify: notify.clone(),
1997 };
1998
1999 let _ = catch_unwind(AssertUnwindSafe(|| {
2001 let _g = LOOKUP_INFLIGHT.lock().unwrap();
2002 panic!("poisoning LOOKUP_INFLIGHT for drop test");
2003 }));
2004
2005 drop(guard);
2008
2009 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2010 assert!(
2011 !map.contains_key(&key),
2012 "poisoned-mutex drop path should still remove the in-flight entry"
2013 );
2014 }
2015
2016 fn whois_result(
2019 nameservers: &[&str],
2020 rdap_fallback: Option<Box<RdapResponse>>,
2021 ) -> LookupResult {
2022 LookupResult::Whois {
2023 data: WhoisResponse {
2024 domain: "nic.tg".to_string(),
2025 nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
2026 ..Default::default()
2027 },
2028 rdap_error: None,
2029 rdap_fallback,
2030 dns_nameservers: None,
2031 }
2032 }
2033
2034 fn delegated(hosts: &[&str]) -> Result<Vec<String>> {
2035 Ok(hosts.iter().map(|s| s.to_string()).collect())
2036 }
2037
2038 #[test]
2039 fn whois_only_result_gets_the_dns_delegation() {
2040 let result = with_dns_nameservers(
2041 whois_result(&["ns1.nic.tg", "ns2.nic.tg"], None),
2042 delegated(&["ns1.nic.tg", "ns2.nic.tg", "ns4.nic.tg"]),
2043 );
2044 let LookupResult::Whois {
2045 dns_nameservers: Some(ns),
2046 ..
2047 } = result
2048 else {
2049 panic!("expected a DNS delegation on {result:?}");
2050 };
2051 assert!(!ns.in_sync);
2052 assert_eq!(ns.missing_from_whois, vec!["ns4.nic.tg"]);
2053 }
2054
2055 #[test]
2056 fn result_with_an_rdap_record_is_not_cross_checked() {
2057 assert!(!wants_dns_nameservers(&whois_result(
2058 &[],
2059 Some(Box::default())
2060 )));
2061 let rdap = LookupResult::Rdap {
2062 data: Box::default(),
2063 whois_fallback: None,
2064 };
2065 assert!(!wants_dns_nameservers(&rdap));
2066 let result = with_dns_nameservers(
2067 whois_result(&[], Some(Box::default())),
2068 delegated(&["ns1.x"]),
2069 );
2070 assert!(matches!(
2071 result,
2072 LookupResult::Whois {
2073 dns_nameservers: None,
2074 ..
2075 }
2076 ));
2077 }
2078
2079 #[test]
2080 fn failed_dns_query_leaves_the_result_unchanged() {
2081 let result = with_dns_nameservers(
2082 whois_result(&["ns1.nic.tg"], None),
2083 Err(SeerError::DnsError("no parent server answered".into())),
2084 );
2085 assert!(matches!(
2086 result,
2087 LookupResult::Whois {
2088 dns_nameservers: None,
2089 ..
2090 }
2091 ));
2092 }
2093
2094 #[test]
2095 fn dns_nameservers_round_trip_and_stay_optional() {
2096 let old = r#"{"source":"whois","data":{"domain":"nic.tg","registrar":null,"registrant":null,"organization":null,"creation_date":null,"expiration_date":null,"updated_date":null,"nameservers":[],"status":[],"dnssec":null,"whois_server":"whois.nic.tg"},"rdap_error":null}"#;
2098 let parsed: LookupResult = serde_json::from_str(old).expect("old shape deserializes");
2099 assert!(matches!(
2100 parsed,
2101 LookupResult::Whois {
2102 dns_nameservers: None,
2103 ..
2104 }
2105 ));
2106 let with = with_dns_nameservers(whois_result(&["a.x"], None), delegated(&["a.x"]));
2107 let json = serde_json::to_string(&with).unwrap();
2108 assert!(json.contains("\"dns_nameservers\""), "{json}");
2109 let back: LookupResult = serde_json::from_str(&json).unwrap();
2110 assert!(matches!(
2111 back,
2112 LookupResult::Whois {
2113 dns_nameservers: Some(_),
2114 ..
2115 }
2116 ));
2117 }
2118}