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 {
109 match result {
110 LookupResult::Available { data, .. } => match data.method.as_str() {
111 "inconclusive" | "dns_present" | "dns_nxdomain" => true,
112 "registrable_parent" => data.confidence != "high",
117 _ => false,
118 },
119 LookupResult::Whois { data, .. } => data.is_thin(),
120 LookupResult::Rdap { .. } => false,
121 }
122}
123
124fn available_with_whois(
129 avail: AvailabilityResult,
130 rdap_error: &str,
131 whois_data: WhoisResponse,
132) -> LookupResult {
133 LookupResult::Available {
134 data: Box::new(avail),
135 rdap_error: sanitize_error_for_public(rdap_error),
136 whois_error: String::new(),
137 whois_data: Some(whois_data),
138 }
139}
140
141fn rdap_response_is_useful(response: &RdapResponse) -> bool {
145 let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
146 let has_dates = response
147 .events
148 .iter()
149 .any(|e| e.event_action == "registration" || e.event_action == "expiration");
150 let has_entities = !response.entities.is_empty();
151 let has_nameservers = !response.nameservers.is_empty();
152 let has_status = !response.status.is_empty();
153
154 has_name && (has_dates || has_entities || has_nameservers || has_status)
155}
156
157fn whois_leg_has_data(w: &Result<WhoisResponse>) -> bool {
167 matches!(w, Ok(data) if !data.is_thin())
168}
169
170fn whois_leg_fallback(
178 domain: &str,
179 rdap_returned_200: bool,
180 rdap_err: Option<&SeerError>,
181 whois: &WhoisResponse,
182) -> Fallback {
183 if rdap_returned_200 {
184 return Fallback::Registered;
185 }
186 classify_fallback(domain, rdap_err, Ok(whois))
187}
188
189fn verdict_progress(avail: &AvailabilityResult) -> &'static str {
191 match avail.method.as_str() {
192 "registrable_parent" => "Name is below a registrable domain (checked its parent)",
193 "inconclusive" => "Registry gave no usable answer (availability inconclusive)",
194 "dns_present" => "Domain is registered (registry detail unavailable)",
195 "dns_nxdomain" => "Domain appears unregistered (no DNS presence)",
196 _ if avail.available => "Domain appears unregistered",
197 _ => "Domain is registered",
198 }
199}
200
201fn sanitize_error_for_public(msg: &str) -> String {
207 let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
208 let s = strip_ipv6(&s);
209 if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
210 let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
211 trunc.push('…');
212 trunc
213 } else {
214 s
215 }
216}
217
218struct InflightGuard {
231 key: String,
232 notify: Arc<Notify>,
233}
234
235impl Drop for InflightGuard {
236 fn drop(&mut self) {
237 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
250 inflight.remove(&self.key);
251 drop(inflight);
252 self.notify.notify_waiters();
253 }
254}
255
256enum LegOutcome<T> {
262 Completed(T),
264 GraceTruncated,
268}
269
270async fn race_with_grace<R, W>(
285 rdap_fut: impl std::future::Future<Output = R>,
286 whois_fut: impl std::future::Future<Output = W>,
287 rdap_has_data: impl Fn(&R) -> bool,
288 whois_has_data: impl Fn(&W) -> bool,
289) -> (LegOutcome<R>, LegOutcome<W>) {
290 tokio::pin!(rdap_fut);
291 tokio::pin!(whois_fut);
292
293 tokio::select! {
294 rdap_res = &mut rdap_fut => {
295 let whois_leg = if rdap_has_data(&rdap_res) {
296 match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
297 Ok(res) => LegOutcome::Completed(res),
298 Err(_) => LegOutcome::GraceTruncated,
299 }
300 } else {
301 LegOutcome::Completed(whois_fut.await)
302 };
303 (LegOutcome::Completed(rdap_res), whois_leg)
304 }
305 whois_res = &mut whois_fut => {
306 let rdap_leg = if whois_has_data(&whois_res) {
307 match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
308 Ok(res) => LegOutcome::Completed(res),
309 Err(_) => LegOutcome::GraceTruncated,
310 }
311 } else {
312 LegOutcome::Completed(rdap_fut.await)
313 };
314 (rdap_leg, LegOutcome::Completed(whois_res))
315 }
316 }
317}
318
319fn grace_truncated_error(truncated: &str, winner: &str) -> String {
325 format!(
326 "{} did not respond within the {}s grace period after {} answered",
327 truncated,
328 PROTOCOL_GRACE_PERIOD.as_secs(),
329 winner
330 )
331}
332
333enum RdapOutcome {
339 Useful(RdapResponse),
340 NoData(RdapResponse),
341 Error(SeerError),
342 GraceTimeout,
345}
346
347pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
352#[serde(tag = "source", rename_all = "lowercase")]
353pub enum LookupResult {
354 Rdap {
355 data: Box<RdapResponse>,
356 #[serde(skip_serializing_if = "Option::is_none")]
357 whois_fallback: Option<WhoisResponse>,
358 },
359 Whois {
360 data: WhoisResponse,
361 rdap_error: Option<String>,
362 #[serde(skip_serializing_if = "Option::is_none")]
363 rdap_fallback: Option<Box<RdapResponse>>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
368 dns_nameservers: Option<DnsNameservers>,
369 },
370 Available {
371 data: Box<AvailabilityResult>,
372 rdap_error: String,
373 whois_error: String,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
378 whois_data: Option<WhoisResponse>,
379 },
380}
381
382impl LookupResult {
383 pub fn protocol(&self) -> Option<&'static str> {
387 match self {
388 LookupResult::Rdap { .. } => Some("RDAP"),
389 LookupResult::Whois { .. } => Some("WHOIS"),
390 LookupResult::Available { .. } => None,
391 }
392 }
393
394 pub fn domain_name(&self) -> Option<String> {
400 match self {
401 LookupResult::Rdap { data, .. } => data.domain_name().map(normalize_rdap_name),
402 LookupResult::Whois { data, .. } => Some(data.domain.clone()),
403 LookupResult::Available { data, .. } => Some(data.domain.clone()),
404 }
405 }
406
407 fn rdap_or_whois<T>(
410 &self,
411 rdap: impl FnOnce(&RdapResponse) -> Option<T>,
412 whois: impl FnOnce(&WhoisResponse) -> Option<T>,
413 ) -> Option<T> {
414 match self {
415 LookupResult::Rdap {
416 data,
417 whois_fallback,
418 } => rdap(data).or_else(|| whois_fallback.as_ref().and_then(whois)),
419 LookupResult::Whois { data, .. } => whois(data),
420 LookupResult::Available { .. } => None,
421 }
422 }
423
424 pub fn registrar(&self) -> Option<String> {
426 self.rdap_or_whois(RdapResponse::get_registrar, |w| w.registrar.clone())
427 }
428
429 pub fn organization(&self) -> Option<String> {
431 self.rdap_or_whois(RdapResponse::get_registrant_organization, |w| {
432 w.organization.clone()
433 })
434 }
435
436 pub fn creation_date(&self) -> Option<DateTime<Utc>> {
438 self.rdap_or_whois(RdapResponse::creation_date, |w| w.creation_date)
439 }
440
441 pub fn expiration_date(&self) -> Option<DateTime<Utc>> {
443 self.rdap_or_whois(RdapResponse::expiration_date, |w| w.expiration_date)
444 }
445
446 pub fn is_rdap(&self) -> bool {
448 matches!(self, LookupResult::Rdap { .. })
449 }
450
451 pub fn is_whois(&self) -> bool {
453 matches!(self, LookupResult::Whois { .. })
454 }
455
456 pub fn is_available(&self) -> bool {
458 matches!(self, LookupResult::Available { .. })
459 }
460
461 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
463 (self.expiration_date(), self.registrar())
464 }
465}
466
467fn normalize_rdap_name(name: &str) -> String {
470 let name = name.trim_end_matches('.').to_lowercase();
471 crate::validation::domain_to_ascii(&name).unwrap_or(name)
472}
473
474fn truncate_on_char_boundary(s: &mut String, max: usize) {
481 if s.len() > max {
482 let mut end = max;
483 while end > 0 && !s.is_char_boundary(end) {
484 end -= 1;
485 }
486 s.truncate(end);
487 }
488}
489
490pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
496 const MAX_RAW: usize = 32 * 1024;
497 if whois.raw_response.len() > MAX_RAW {
498 truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
499 whois.raw_response.push_str("\n... [truncated]");
500 }
501}
502
503pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
507 match result {
508 LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
509 LookupResult::Rdap {
510 ref mut whois_fallback,
511 ..
512 } => {
513 if let Some(w) = whois_fallback {
514 trim_whois_raw(w);
515 }
516 }
517 LookupResult::Available {
518 ref mut whois_data, ..
519 } => {
520 if let Some(w) = whois_data {
521 trim_whois_raw(w);
522 }
523 }
524 }
525
526 result
527}
528
529fn wants_dns_nameservers(result: &LookupResult) -> bool {
532 matches!(
533 result,
534 LookupResult::Whois {
535 rdap_fallback: None,
536 ..
537 }
538 )
539}
540
541fn with_dns_nameservers(mut result: LookupResult, delegated: Result<Vec<String>>) -> LookupResult {
544 if let LookupResult::Whois {
546 data,
547 rdap_fallback: None,
548 dns_nameservers,
549 ..
550 } = &mut result
551 {
552 match delegated {
553 Ok(delegated) => {
554 *dns_nameservers = Some(DnsNameservers::compare(&data.nameservers, delegated));
555 }
556 Err(e) => debug!(error = %e, "DNS delegation unavailable for the WHOIS cross-check"),
557 }
558 }
559 result
560}
561
562#[derive(Debug, Clone, Default)]
563pub struct SmartLookup {
564 rdap_client: RdapClient,
565 whois_client: WhoisClient,
566 availability_checker: AvailabilityChecker,
567 dns_resolver: DnsResolver,
568 delegation_checker: DelegationChecker,
569}
570
571impl SmartLookup {
572 pub fn new() -> Self {
575 Self::default()
576 }
577
578 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
581 Self {
582 rdap_client: RdapClient::from_config(config),
583 whois_client: WhoisClient::from_config(config),
584 availability_checker: AvailabilityChecker::from_config(config),
585 dns_resolver: DnsResolver::from_config(config),
586 delegation_checker: DelegationChecker::from_config(config),
587 }
588 }
589
590 #[instrument(skip(self), fields(domain = %domain))]
595 pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
596 self.lookup_with_progress(domain, None).await
597 }
598
599 #[instrument(skip(self, progress), fields(domain = %domain))]
605 pub async fn lookup_with_progress(
606 &self,
607 domain: &str,
608 progress: Option<LookupProgressCallback>,
609 ) -> Result<LookupResult> {
610 let normalized = crate::validation::normalize_domain(domain)?;
611
612 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
614 debug!(domain = %normalized, "Returning cached lookup result");
615 return Ok(cached);
616 }
617
618 let _guard = loop {
631 let existing: Arc<Notify>;
632 let notified = {
633 let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
637 match inflight.get(&normalized).and_then(|w| w.upgrade()) {
638 Some(n) => existing = n,
639 None => {
640 let n = Arc::new(Notify::new());
641 inflight.insert(normalized.clone(), Arc::downgrade(&n));
642 break InflightGuard {
643 key: normalized.clone(),
644 notify: n,
645 };
646 }
647 }
648 existing.notified()
658 };
659 tokio::pin!(notified);
660 notified.as_mut().enable();
662 debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
663
664 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
667 return Ok(cached);
668 }
669
670 let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
674
675 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
676 return Ok(cached);
677 }
678 };
681
682 if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
688 debug!(domain = %normalized, "Returning result cached by previous owner");
689 return Ok(cached);
690 }
691
692 let result = self
693 .lookup_concurrent(&normalized, progress.clone())
694 .await?;
695 let result = self
696 .attach_dns_nameservers(&normalized, result, progress)
697 .await;
698
699 let ttl = if is_degraded_result(&result) {
704 DEGRADED_LOOKUP_CACHE_TTL
705 } else {
706 LOOKUP_CACHE_TTL
707 };
708 LOOKUP_CACHE.insert_with_ttl(normalized.clone(), trim_raw_response(result.clone()), ttl);
709
710 Ok(result)
711 }
712
713 async fn attach_dns_nameservers(
717 &self,
718 domain: &str,
719 result: LookupResult,
720 progress: Option<LookupProgressCallback>,
721 ) -> LookupResult {
722 if !wants_dns_nameservers(&result) {
723 return result;
724 }
725 if let Some(ref cb) = progress {
726 cb("Checking the DNS delegation");
727 }
728 let delegated = self.delegation_checker.delegated_ns(domain).await;
729 with_dns_nameservers(result, delegated)
730 }
731
732 #[instrument(skip(self, progress), fields(domain = %domain))]
733 async fn lookup_concurrent(
734 &self,
735 domain: &str,
736 progress: Option<LookupProgressCallback>,
737 ) -> Result<LookupResult> {
738 #[cfg(test)]
739 LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
740
741 debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
742
743 if let Some(ref cb) = progress {
744 cb("Querying RDAP and WHOIS concurrently");
745 }
746
747 let rdap_fut = self.rdap_client.lookup_domain(domain);
748 let whois_fut = self.whois_client.lookup(domain);
749
750 let (rdap_leg, whois_leg) = race_with_grace(
758 rdap_fut,
759 whois_fut,
760 |r| matches!(r, Ok(data) if rdap_response_is_useful(data)),
761 whois_leg_has_data,
762 )
763 .await;
764
765 if matches!(whois_leg, LegOutcome::GraceTruncated) {
766 debug!("WHOIS did not finish within grace period after RDAP answered, proceeding with RDAP only");
767 }
768 if matches!(rdap_leg, LegOutcome::GraceTruncated) {
769 debug!("RDAP did not finish within grace period after WHOIS answered, proceeding with WHOIS only");
770 }
771
772 let rdap_outcome = match rdap_leg {
774 LegOutcome::Completed(Ok(data)) => {
775 if rdap_response_is_useful(&data) {
776 RdapOutcome::Useful(data)
777 } else {
778 RdapOutcome::NoData(data)
779 }
780 }
781 LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
782 LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
783 };
784
785 if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
787 debug!("RDAP lookup successful");
788 let whois_fallback = match whois_leg {
789 LegOutcome::Completed(Ok(w)) => Some(w),
790 _ => None,
791 };
792 return Ok(LookupResult::Rdap {
793 data: Box::new(rdap_data),
794 whois_fallback,
795 });
796 }
797
798 let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
809 let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
810 RdapOutcome::Useful(_) => {
811 debug!("Unexpected RdapOutcome::Useful in fallback branch");
814 (String::from("RDAP ok"), None, None)
815 }
816 RdapOutcome::NoData(data) => (
817 "RDAP response incomplete".to_string(),
818 Some(Box::new(data)),
819 None,
820 ),
821 RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
822 RdapOutcome::GraceTimeout => (grace_truncated_error("RDAP", "WHOIS"), None, None),
823 };
824
825 if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
826 let fallback = whois_leg_fallback(
830 domain,
831 rdap_returned_200,
832 rdap_seer_error.as_ref(),
833 &whois_data,
834 );
835 let verdict = match fallback {
836 Fallback::Registered => None,
837 Fallback::Verdict(avail) => Some(avail),
838 Fallback::NeedsDns(tie_break) => {
839 Some(tie_break.decide(self.dns_resolver.presence(domain).await))
840 }
841 };
842 if let Some(avail) = verdict {
843 debug!(
844 domain = %domain,
845 method = %avail.method,
846 confidence = %avail.confidence,
847 "Reading WHOIS leg as an availability verdict"
848 );
849 let avail = self.availability_checker.guard_subdomain_claim(avail).await;
852 if let Some(ref cb) = progress {
853 cb(verdict_progress(&avail));
854 }
855 return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
856 }
857 debug!("Using WHOIS result (RDAP not useful)");
858 if let Some(ref cb) = progress {
859 cb("RDAP not available (using WHOIS)");
860 }
861 return Ok(LookupResult::Whois {
862 data: whois_data,
863 rdap_error: Some(sanitize_error_for_public(&rdap_error_str)),
864 rdap_fallback: rdap_fallback_data,
865 dns_nameservers: None,
866 });
867 }
868
869 let whois_error_str = match &whois_leg {
874 LegOutcome::Completed(Err(e)) => e.to_string(),
875 LegOutcome::Completed(Ok(_)) => {
876 debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
878 "WHOIS returned but was not used".to_string()
879 }
880 LegOutcome::GraceTruncated => grace_truncated_error("WHOIS", "RDAP"),
884 };
885
886 let prior_rdap = if rdap_returned_200 {
891 match rdap_fallback_data {
892 Some(resp) => PriorRdap::Response(resp),
894 None => PriorRdap::Missing,
895 }
896 } else if let Some(e) = rdap_seer_error {
897 PriorRdap::Failed(e)
898 } else {
899 PriorRdap::Missing
900 };
901 let prior_whois = match whois_leg {
902 LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
903 LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
905 LegOutcome::GraceTruncated => PriorWhois::Missing,
906 };
907
908 self.availability_fallback(
909 domain,
910 prior_rdap,
911 prior_whois,
912 rdap_error_str,
913 whois_error_str,
914 progress,
915 )
916 .await
917 }
918
919 async fn availability_fallback(
920 &self,
921 domain: &str,
922 prior_rdap: PriorRdap,
923 prior_whois: PriorWhois,
924 rdap_error: String,
925 whois_error: String,
926 progress: Option<LookupProgressCallback>,
927 ) -> Result<LookupResult> {
928 if let Some(ref cb) = progress {
929 cb("RDAP and WHOIS unavailable (checking availability)");
930 }
931 warn!(
932 domain = %domain,
933 rdap_error = %rdap_error,
934 whois_error = %whois_error,
935 "Both RDAP and WHOIS failed, falling back to availability check"
936 );
937
938 match self
939 .availability_checker
940 .check_with_prior(domain, prior_rdap, prior_whois)
941 .await
942 {
943 Ok(avail) => Ok(LookupResult::Available {
944 data: Box::new(avail),
945 rdap_error: sanitize_error_for_public(&rdap_error),
946 whois_error: sanitize_error_for_public(&whois_error),
947 whois_data: None,
948 }),
949 Err(avail_err) => Err(SeerError::LookupFailed {
950 domain: domain.to_string(),
951 details: format!(
952 "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
953 rdap_error, whois_error, avail_err
954 ),
955 registry_url: get_registry_url(get_tld(domain)),
956 }),
957 }
958 }
959
960 pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
965 self.dns_resolver.presence(domain).await
966 }
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972 use crate::rdap::rdap_error_is_404;
973
974 static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
986
987 #[test]
988 fn test_lookup_result_domain_name_whois() {
989 let result = LookupResult::Whois {
990 data: WhoisResponse {
991 domain: "example.com".to_string(),
992 registrar: Some("Test Registrar".to_string()),
993 whois_server: "whois.example.com".to_string(),
994 ..Default::default()
995 },
996 rdap_error: None,
997 rdap_fallback: None,
998 dns_nameservers: None,
999 };
1000
1001 assert_eq!(result.domain_name(), Some("example.com".to_string()));
1002 assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
1003 assert!(result.is_whois());
1004 assert!(!result.is_rdap());
1005 assert!(!result.is_available());
1006 }
1007
1008 #[test]
1009 fn rdap_domain_name_is_normalized_like_the_other_protocols() {
1010 for (ldh, unicode) in [
1011 (Some("EXAMPLE.COM"), None),
1012 (Some("Example.Com."), None),
1013 (None, Some("пример.рф")),
1014 ] {
1015 let result = LookupResult::Rdap {
1016 data: Box::new(RdapResponse {
1017 ldh_name: ldh.map(str::to_string),
1018 unicode_name: unicode.map(str::to_string),
1019 ..Default::default()
1020 }),
1021 whois_fallback: None,
1022 };
1023 let expected = if unicode.is_some() {
1024 "xn--e1afmkfd.xn--p1ai"
1025 } else {
1026 "example.com"
1027 };
1028 assert_eq!(result.domain_name().as_deref(), Some(expected));
1029 }
1030 }
1031
1032 #[test]
1033 fn test_lookup_result_serialization() {
1034 let result = LookupResult::Whois {
1035 data: WhoisResponse {
1036 domain: "test.com".to_string(),
1037 ..Default::default()
1038 },
1039 rdap_error: Some("RDAP failed".to_string()),
1040 rdap_fallback: None,
1041 dns_nameservers: None,
1042 };
1043
1044 let json = serde_json::to_string(&result).unwrap();
1045 assert!(json.contains("\"source\":\"whois\""));
1046 assert!(json.contains("RDAP failed"));
1047 }
1048
1049 #[test]
1050 fn test_lookup_result_available_serialization() {
1051 let result = LookupResult::Available {
1052 data: Box::new(
1053 AvailabilityResult::new("test123.xyz", true, "medium", "dns_nxdomain")
1054 .with_details("Registry lookups failed; domain has no DNS presence (NXDOMAIN)"),
1055 ),
1056 rdap_error: "RDAP failed".to_string(),
1057 whois_error: "WHOIS failed".to_string(),
1058 whois_data: None,
1059 };
1060
1061 let json = serde_json::to_string(&result).unwrap();
1062 assert!(json.contains("\"source\":\"available\""));
1063 assert!(json.contains("\"available\":true"));
1064 assert!(json.contains("test123.xyz"));
1065
1066 assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1067 assert!(result.is_available());
1068 assert!(!result.is_rdap());
1069 assert!(!result.is_whois());
1070 assert!(result.registrar().is_none());
1071 assert_eq!(result.expiration_info(), (None, None));
1072 }
1073
1074 #[test]
1077 fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1078 const MAX_RAW: usize = 32 * 1024;
1079 let mut s = "a".repeat(MAX_RAW - 1);
1083 s.push('€'); s.push_str(&"b".repeat(100));
1085 assert!(!s.is_char_boundary(MAX_RAW));
1086
1087 truncate_on_char_boundary(&mut s, MAX_RAW);
1089 assert!(s.len() <= MAX_RAW);
1090 assert_eq!(s.len(), MAX_RAW - 1);
1093 }
1094
1095 #[test]
1096 fn trim_raw_response_truncates_multibyte_whois_without_panic() {
1097 const MAX_RAW: usize = 32 * 1024;
1098 let mut raw = "a".repeat(MAX_RAW - 1);
1099 raw.push('€');
1100 raw.push_str(&"b".repeat(1000));
1101
1102 let mut w = empty_whois("example.com");
1103 w.raw_response = raw;
1104 let result = trim_raw_response(LookupResult::Whois {
1105 data: w,
1106 rdap_error: None,
1107 rdap_fallback: None,
1108 dns_nameservers: None,
1109 });
1110 if let LookupResult::Whois { data, .. } = result {
1111 assert!(data.raw_response.ends_with("[truncated]"));
1112 } else {
1113 panic!("expected Whois variant");
1114 }
1115 }
1116
1117 #[test]
1120 fn test_sanitize_strips_ipv4() {
1121 let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1122 let sanitized = sanitize_error_for_public(msg);
1123 assert!(
1124 !sanitized.contains("10.0.0.1"),
1125 "IPv4 should be stripped, got: {}",
1126 sanitized
1127 );
1128 assert!(sanitized.contains("[ip-redacted]"));
1129 }
1130
1131 #[test]
1132 fn test_sanitize_strips_multiple_ipv4() {
1133 let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1134 let sanitized = sanitize_error_for_public(msg);
1135 assert!(!sanitized.contains("192.168.1.1"));
1136 assert!(!sanitized.contains("127.0.0.1"));
1137 assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1139 }
1140
1141 #[test]
1142 fn test_sanitize_strips_ipv6() {
1143 let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1144 let sanitized = sanitize_error_for_public(msg);
1145 assert!(!sanitized.contains("fe80::1"));
1146 assert!(sanitized.contains("[ip-redacted]"));
1147 }
1148
1149 #[test]
1150 fn sanitize_leaves_mac_address_like_tokens_alone() {
1151 let msg = "error code af:ba:12 at line 5";
1152 let out = sanitize_error_for_public(msg);
1153 assert!(
1154 out.contains("af:ba:12"),
1155 "MAC fragment should not be stripped: {}",
1156 out
1157 );
1158 }
1159
1160 #[test]
1161 fn sanitize_strips_real_ipv6() {
1162 let msg = "cannot reach 2001:db8::1 — timeout";
1163 let out = sanitize_error_for_public(msg);
1164 assert!(!out.contains("2001:db8::1"));
1165 assert!(out.contains("[ip-redacted]"));
1166 }
1167
1168 #[test]
1169 fn sanitize_strips_fe80_link_local() {
1170 let msg = "peer at fe80::1 unreachable";
1171 let out = sanitize_error_for_public(msg);
1172 assert!(out.contains("[ip-redacted]"));
1173 }
1174
1175 #[test]
1176 fn test_sanitize_truncates_long_message() {
1177 let long = "a".repeat(500);
1179 let sanitized = sanitize_error_for_public(&long);
1180 let char_count = sanitized.chars().count();
1182 assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1183 assert!(sanitized.ends_with('…'));
1184 }
1185
1186 #[test]
1187 fn test_sanitize_preserves_short_messages() {
1188 let msg = "RDAP timed out after 15s";
1189 let sanitized = sanitize_error_for_public(msg);
1190 assert_eq!(sanitized, msg);
1191 }
1192
1193 #[test]
1196 fn test_is_rdap_response_useful_detects_no_data() {
1197 use crate::rdap::RdapResponse;
1198 let resp = RdapResponse {
1202 ldh_name: Some("example.com".to_string()),
1203 ..Default::default()
1204 };
1205 assert!(
1206 !rdap_response_is_useful(&resp),
1207 "Response with only a name should be classified as NoData"
1208 );
1209
1210 let useful = RdapResponse {
1212 ldh_name: Some("example.com".to_string()),
1213 status: vec!["active".to_string()],
1214 ..Default::default()
1215 };
1216 assert!(rdap_response_is_useful(&useful));
1217 }
1218
1219 #[allow(clippy::await_holding_lock)]
1230 #[tokio::test]
1231 async fn test_inflight_coalescing_map() {
1232 let _serial = INFLIGHT_TEST_SERIAL
1237 .lock()
1238 .unwrap_or_else(|p| p.into_inner());
1239 let domain = unique_test_key("__coalesce");
1248
1249 {
1251 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1252 m.remove(&domain);
1253 }
1254
1255 let owner_notify = Arc::new(Notify::new());
1257 {
1258 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1259 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1260 m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1261 }
1262
1263 let waiter = {
1265 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1266 m.get(&domain)
1267 .and_then(|w| w.upgrade())
1268 .expect("Second caller must observe in-flight entry")
1269 };
1270
1271 let waiter_clone = waiter.clone();
1273 let handle = tokio::spawn(async move {
1274 waiter_clone.notified().await;
1275 });
1276
1277 tokio::time::sleep(Duration::from_millis(20)).await;
1279 {
1280 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1281 m.remove(&domain);
1282 }
1283 owner_notify.notify_waiters();
1284
1285 tokio::time::timeout(Duration::from_secs(1), handle)
1287 .await
1288 .expect("waiter must unblock after notify")
1289 .expect("waiter task joined cleanly");
1290
1291 drop(owner_notify);
1293 drop(waiter);
1294 let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1295 assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1296 }
1297
1298 #[allow(clippy::await_holding_lock)]
1316 #[tokio::test]
1317 async fn waiters_coalesce_on_inflight_lookup_and_read_owners_cache() {
1318 use std::sync::atomic::Ordering;
1319
1320 let _serial = INFLIGHT_TEST_SERIAL
1321 .lock()
1322 .unwrap_or_else(|p| p.into_inner());
1323
1324 let raw = unique_test_key("coalesce-waiter");
1325 let normalized = crate::validation::normalize_domain(&raw).expect("test key normalizes");
1326 LOOKUP_CACHE.remove(&normalized);
1327
1328 let owner_notify = Arc::new(Notify::new());
1330 {
1331 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1332 m.insert(normalized.clone(), Arc::downgrade(&owner_notify));
1333 }
1334
1335 let calls_before = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1336
1337 let lookup = SmartLookup::new();
1339 let spawn_waiter = |l: SmartLookup, d: String| {
1340 tokio::spawn(async move { l.lookup_with_progress(&d, None).await })
1341 };
1342 let h1 = spawn_waiter(lookup.clone(), raw.clone());
1343 let h2 = spawn_waiter(lookup, raw);
1344
1345 tokio::time::sleep(Duration::from_millis(50)).await;
1349
1350 let canned = LookupResult::Whois {
1353 data: empty_whois(&normalized),
1354 rdap_error: None,
1355 rdap_fallback: None,
1356 dns_nameservers: None,
1357 };
1358 LOOKUP_CACHE.insert(normalized.clone(), canned);
1359 {
1360 let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1361 m.remove(&normalized);
1362 }
1363 owner_notify.notify_waiters();
1364
1365 for handle in [h1, h2] {
1368 let result = tokio::time::timeout(Duration::from_secs(5), handle)
1369 .await
1370 .expect("waiter must wake via notify, not the bounded-wait timeout")
1371 .expect("waiter task joined cleanly")
1372 .expect("waiter must read the owner's cached result");
1373 assert!(result.is_whois());
1374 assert_eq!(result.domain_name(), Some(normalized.clone()));
1375 }
1376
1377 let calls_after = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
1379 assert_eq!(
1380 calls_before, calls_after,
1381 "coalesced waiters must not start a second network race"
1382 );
1383
1384 LOOKUP_CACHE.remove(&normalized);
1385 }
1386
1387 fn unique_test_key(prefix: &str) -> String {
1393 use std::sync::atomic::{AtomicU64, Ordering};
1394 use std::time::{SystemTime, UNIX_EPOCH};
1395 static COUNTER: AtomicU64 = AtomicU64::new(0);
1396 let nanos = SystemTime::now()
1397 .duration_since(UNIX_EPOCH)
1398 .map(|d| d.as_nanos())
1399 .unwrap_or(0);
1400 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1401 format!("{}_{}_{}.example.", prefix, nanos, n)
1402 }
1403
1404 #[test]
1410 fn available_with_whois_sanitizes_rdap_error() {
1411 let avail = AvailabilityResult::new("unreg.test", false, "none", "inconclusive");
1414 let result = available_with_whois(
1415 avail,
1416 "RDAP URL resolves to reserved IP 10.0.0.1",
1417 empty_whois("unreg.test"),
1418 );
1419 let LookupResult::Available {
1420 rdap_error,
1421 whois_error,
1422 whois_data,
1423 ..
1424 } = result
1425 else {
1426 panic!("expected Available variant");
1427 };
1428 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1429 assert!(rdap_error.contains("[ip-redacted]"));
1430 assert!(whois_error.is_empty());
1431 assert!(whois_data.is_some());
1432 }
1433
1434 #[tokio::test]
1435 async fn availability_fallback_sanitizes_both_error_fields() {
1436 let lookup = SmartLookup::new();
1439 let prior = RdapResponse {
1440 ldh_name: Some("example.com".to_string()),
1441 status: vec!["active".to_string()],
1442 ..Default::default()
1443 };
1444 let result = lookup
1445 .availability_fallback(
1446 "example.com",
1447 PriorRdap::Response(Box::new(prior)),
1448 PriorWhois::Missing,
1449 "RDAP URL resolves to reserved IP 10.0.0.1".to_string(),
1450 "connection refused at 192.168.0.5 and fe80::1".to_string(),
1451 None,
1452 )
1453 .await
1454 .expect("prior-response fallback must not error");
1455 let LookupResult::Available {
1456 rdap_error,
1457 whois_error,
1458 ..
1459 } = result
1460 else {
1461 panic!("expected Available variant");
1462 };
1463 assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
1464 assert!(!whois_error.contains("192.168.0.5"), "{whois_error}");
1465 assert!(!whois_error.contains("fe80::1"), "{whois_error}");
1466 assert!(rdap_error.contains("[ip-redacted]"));
1467 assert!(whois_error.contains("[ip-redacted]"));
1468 }
1469
1470 fn available_via(method: &str, available: bool, confidence: &str) -> LookupResult {
1473 LookupResult::Available {
1474 data: Box::new(AvailabilityResult::new(
1475 "example.test",
1476 available,
1477 confidence,
1478 method,
1479 )),
1480 rdap_error: String::new(),
1481 whois_error: String::new(),
1482 whois_data: None,
1483 }
1484 }
1485
1486 #[test]
1487 fn degraded_results_are_identified_for_short_caching() {
1488 assert!(is_degraded_result(&available_via(
1490 "inconclusive",
1491 false,
1492 "none"
1493 )));
1494 assert!(is_degraded_result(&available_via(
1495 "dns_present",
1496 false,
1497 "high"
1498 )));
1499 assert!(is_degraded_result(&available_via(
1500 "dns_nxdomain",
1501 true,
1502 "medium"
1503 )));
1504 assert!(is_degraded_result(&LookupResult::Whois {
1507 data: empty_whois("example.test"),
1508 rdap_error: Some("RDAP response incomplete".to_string()),
1509 rdap_fallback: None,
1510 dns_nameservers: None,
1511 }));
1512 assert!(!is_degraded_result(&available_via("rdap", true, "high")));
1514 assert!(!is_degraded_result(&available_via("whois", true, "high")));
1515 assert!(!is_degraded_result(&LookupResult::Whois {
1516 data: denic_whois(),
1517 rdap_error: None,
1518 rdap_fallback: None,
1519 dns_nameservers: None,
1520 }));
1521 const { assert!(DEGRADED_LOOKUP_CACHE_TTL.as_secs() < LOOKUP_CACHE_TTL.as_secs()) };
1522 }
1523
1524 #[test]
1530 fn a_registrable_parent_verdict_is_degraded_when_the_parents_was() {
1531 use crate::availability::subdomain_verdict;
1532 let under = |method: &str, available: bool, confidence: &str| {
1533 let parent = AvailabilityResult::new("example.test", available, confidence, method);
1534 LookupResult::Available {
1535 data: Box::new(subdomain_verdict("www.example.test", &parent)),
1536 rdap_error: String::new(),
1537 whois_error: String::new(),
1538 whois_data: None,
1539 }
1540 };
1541 assert!(is_degraded_result(&under("inconclusive", false, "none")));
1542 assert!(is_degraded_result(&under("dns_present", false, "medium")));
1543 assert!(is_degraded_result(&under("dns_nxdomain", true, "medium")));
1544 assert!(!is_degraded_result(&under("rdap", false, "high")));
1546 assert!(!is_degraded_result(&under("whois", false, "high")));
1547 }
1548
1549 #[test]
1550 fn rdap_error_is_404_matches_standard_404() {
1551 let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1552 assert!(rdap_error_is_404(&e));
1553 }
1554
1555 #[test]
1556 fn rdap_error_is_404_matches_without_reason_phrase() {
1557 let e = SeerError::RdapError("query failed with status 404".to_string());
1558 assert!(rdap_error_is_404(&e));
1559 }
1560
1561 #[test]
1562 fn rdap_error_is_404_rejects_other_statuses() {
1563 let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1564 assert!(!rdap_error_is_404(&e));
1565 let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1566 assert!(!rdap_error_is_404(&e));
1567 }
1568
1569 #[test]
1570 fn rdap_error_is_404_rejects_non_http_errors() {
1571 let e = SeerError::RdapError("connection timeout".to_string());
1572 assert!(!rdap_error_is_404(&e));
1573 let e = SeerError::Timeout("rdap".to_string());
1574 assert!(!rdap_error_is_404(&e));
1575 }
1576
1577 #[test]
1578 fn rdap_error_is_404_rejects_incidental_404_in_message() {
1579 let e = SeerError::RdapError("error 40404: database corruption".to_string());
1581 assert!(!rdap_error_is_404(&e));
1582 }
1583
1584 fn empty_whois(domain: &str) -> WhoisResponse {
1587 WhoisResponse {
1588 domain: domain.to_string(),
1589 ..Default::default()
1590 }
1591 }
1592
1593 #[test]
1594 fn whois_response_is_thin_when_all_key_fields_missing() {
1595 let w = empty_whois("example.com");
1596 assert!(w.is_thin());
1597 }
1598
1599 #[test]
1600 fn whois_response_is_not_thin_when_registrar_present() {
1601 let mut w = empty_whois("example.com");
1602 w.registrar = Some("Test Registrar".to_string());
1603 assert!(!w.is_thin());
1604 }
1605
1606 #[test]
1607 fn whois_response_is_not_thin_when_creation_date_present() {
1608 let mut w = empty_whois("example.com");
1609 w.creation_date = Some(Utc::now());
1610 assert!(!w.is_thin());
1611 }
1612
1613 #[test]
1614 fn whois_response_is_not_thin_when_expiration_date_present() {
1615 let mut w = empty_whois("example.com");
1616 w.expiration_date = Some(Utc::now());
1617 assert!(!w.is_thin());
1618 }
1619
1620 fn denic_whois() -> WhoisResponse {
1623 let mut w = empty_whois("example.de");
1624 w.nameservers = vec!["ns1.example.net".to_string()];
1625 w.status = vec!["active".to_string()];
1626 w.updated_date = Some(Utc::now());
1627 w.raw_response = "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n".into();
1628 w
1629 }
1630
1631 #[test]
1632 fn whois_response_with_nameservers_is_not_thin() {
1633 let mut w = empty_whois("example.com");
1634 w.nameservers = vec!["ns1.example.net".to_string()];
1635 assert!(!w.is_thin());
1636 }
1637
1638 #[test]
1639 fn denic_whois_stays_on_the_whois_path() {
1640 let w = denic_whois();
1644 let bootstrap_miss =
1645 SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
1646 assert!(!w.is_thin());
1647 assert!(whois_leg_has_data(&Ok(w.clone())));
1648 assert!(matches!(
1649 whois_leg_fallback("example.de", false, Some(&bootstrap_miss), &w),
1650 Fallback::Registered
1651 ));
1652 }
1653
1654 fn verdict_of(fallback: Fallback, dns: DnsPresence) -> Option<(bool, String)> {
1661 let avail = match fallback {
1662 Fallback::Registered => return None,
1663 Fallback::Verdict(v) => v,
1664 Fallback::NeedsDns(t) => t.decide(dns),
1665 };
1666 Some((avail.available, avail.method))
1667 }
1668
1669 #[test]
1670 fn rdap_200_vetoes_whois_no_match_and_thin_bodies() {
1671 let mut no_match = empty_whois("freshly-registered.com");
1675 no_match.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1676 for w in [no_match, empty_whois("freshly-registered.com")] {
1677 assert!(matches!(
1678 whois_leg_fallback("freshly-registered.com", true, None, &w),
1679 Fallback::Registered
1680 ));
1681 }
1682 }
1683
1684 #[test]
1685 fn whois_no_match_routes_to_available_for_any_rdap_failure() {
1686 let mut w = empty_whois("genuinely-free.com");
1687 w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1688 let r404 = SeerError::RdapError("query failed with status 404".to_string());
1689 let bootstrap = SeerError::RdapBootstrapError("all registries failed".to_string());
1690 for rdap in [Some(&r404), Some(&bootstrap), None] {
1692 assert_eq!(
1693 verdict_of(
1694 whois_leg_fallback("genuinely-free.com", false, rdap, &w),
1695 DnsPresence::Unknown
1696 ),
1697 Some((true, "whois".to_string()))
1698 );
1699 }
1700 }
1701
1702 #[test]
1703 fn thin_whois_with_rdap_404_is_available_via_rdap() {
1704 let r404 = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1707 let fallback = whois_leg_fallback("example.xyz", false, Some(&r404), &empty_whois("x"));
1708 let Fallback::Verdict(v) = fallback else {
1709 panic!("expected a verdict");
1710 };
1711 assert_eq!(
1712 (v.available, v.confidence.as_str(), v.method.as_str()),
1713 (true, "high", "rdap")
1714 );
1715 }
1716
1717 #[test]
1718 fn thin_whois_with_silent_rdap_is_decided_by_dns() {
1719 let rdap = SeerError::RdapError("query failed with status 429".to_string());
1724 let w = empty_whois("zac.email");
1725 let read = |dns| verdict_of(whois_leg_fallback("zac.email", false, Some(&rdap), &w), dns);
1726 assert_eq!(
1727 read(DnsPresence::Present),
1728 Some((false, "dns_present".to_string()))
1729 );
1730 assert_eq!(
1731 read(DnsPresence::Absent),
1732 Some((true, "dns_nxdomain".to_string()))
1733 );
1734 assert_eq!(
1735 read(DnsPresence::Unknown),
1736 Some((false, "inconclusive".to_string()))
1737 );
1738 }
1739
1740 #[test]
1741 fn thin_whois_refusal_is_inconclusive_regardless_of_dns() {
1742 let rdap = SeerError::RdapError("query failed with status 503".to_string());
1745 let mut w = empty_whois("example.test");
1746 w.raw_response = "Access rate limited; please try again later.\n".to_string();
1747 for dns in [
1748 DnsPresence::Absent,
1749 DnsPresence::Present,
1750 DnsPresence::Unknown,
1751 ] {
1752 assert_eq!(
1753 verdict_of(
1754 whois_leg_fallback("example.test", false, Some(&rdap), &w),
1755 dns
1756 ),
1757 Some((false, "inconclusive".to_string()))
1758 );
1759 }
1760 }
1761
1762 #[test]
1763 fn whois_with_registration_data_stays_a_whois_record() {
1764 let mut w = empty_whois("registered.com");
1765 w.registrar = Some("Example Registrar Ltd".to_string());
1766 let rdap = SeerError::RdapError("connection timeout".to_string());
1767 for rdap in [Some(&rdap), None] {
1768 assert!(matches!(
1769 whois_leg_fallback("registered.com", false, rdap, &w),
1770 Fallback::Registered
1771 ));
1772 }
1773 }
1774
1775 #[test]
1776 fn verdict_progress_names_the_deciding_signal() {
1777 let v =
1778 |available, method: &str| AvailabilityResult::new("x.test", available, "high", method);
1779 assert_eq!(
1780 verdict_progress(&v(true, "rdap")),
1781 "Domain appears unregistered"
1782 );
1783 assert!(verdict_progress(&v(false, "inconclusive")).contains("inconclusive"));
1784 assert!(verdict_progress(&v(false, "dns_present")).contains("registered"));
1785 assert!(verdict_progress(&v(false, "registrable_parent")).contains("parent"));
1786 }
1787
1788 async fn leg<T>(secs: u64, value: T) -> T {
1800 tokio::time::sleep(Duration::from_secs(secs)).await;
1801 value
1802 }
1803
1804 #[tokio::test(start_paused = true)]
1805 async fn race_awaits_whois_fully_when_rdap_errors_first() {
1806 let rdap = leg(0, Err::<&str, &str>("no RDAP server for example.ru"));
1807 let whois = leg(20, Ok::<&str, &str>("whois data"));
1809 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1810 assert!(matches!(r, LegOutcome::Completed(Err(_))));
1811 assert!(
1812 matches!(w, LegOutcome::Completed(Ok("whois data"))),
1813 "WHOIS must not be grace-truncated behind an RDAP failure"
1814 );
1815 }
1816
1817 #[tokio::test(start_paused = true)]
1818 async fn race_awaits_rdap_fully_when_whois_errors_first() {
1819 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1820 let whois = leg(0, Err::<&str, &str>("connection refused"));
1821 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1822 assert!(
1823 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1824 "RDAP must not be grace-truncated behind a WHOIS failure"
1825 );
1826 assert!(matches!(w, LegOutcome::Completed(Err(_))));
1827 }
1828
1829 #[tokio::test(start_paused = true)]
1830 async fn race_truncates_whois_when_rdap_answers_with_data() {
1831 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1832 let whois = leg(20, Ok::<&str, &str>("whois data"));
1833 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1834 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1835 assert!(
1836 matches!(w, LegOutcome::GraceTruncated),
1837 "a data-bearing RDAP winner only owes WHOIS the grace period"
1838 );
1839 }
1840
1841 #[tokio::test(start_paused = true)]
1842 async fn race_truncates_rdap_when_whois_answers_with_data() {
1843 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1844 let whois = leg(0, Ok::<&str, &str>("whois data"));
1845 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1846 assert!(matches!(r, LegOutcome::GraceTruncated));
1847 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1848 }
1849
1850 #[tokio::test(start_paused = true)]
1851 async fn race_loser_inside_grace_period_still_completes() {
1852 let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
1853 let whois = leg(2, Ok::<&str, &str>("whois data"));
1855 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
1856 assert!(matches!(r, LegOutcome::Completed(Ok(_))));
1857 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1858 }
1859
1860 #[tokio::test(start_paused = true)]
1861 async fn race_awaits_whois_fully_when_rdap_wins_with_unusable_data() {
1862 let rdap = leg(0, Ok::<&str, &str>("thin"));
1865 let whois = leg(20, Ok::<&str, &str>("whois data"));
1866 let (r, w) = race_with_grace(
1867 rdap,
1868 whois,
1869 |r| matches!(r, Ok(s) if *s == "useful"),
1870 |w| w.is_ok(),
1871 )
1872 .await;
1873 assert!(matches!(r, LegOutcome::Completed(Ok("thin"))));
1874 assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
1875 }
1876
1877 #[tokio::test(start_paused = true)]
1878 async fn race_awaits_rdap_fully_when_whois_wins_with_thin_body() {
1879 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1887 let whois = leg(0, Ok::<_, SeerError>(empty_whois("zac.email")));
1888 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
1889 assert!(
1890 matches!(r, LegOutcome::Completed(Ok("rdap data"))),
1891 "RDAP must not be grace-truncated behind a thin WHOIS answer"
1892 );
1893 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1894 }
1895
1896 #[tokio::test(start_paused = true)]
1897 async fn race_truncates_rdap_when_whois_wins_with_real_data() {
1898 let mut whois_data = empty_whois("example.com");
1901 whois_data.registrar = Some("Mock Registrar".to_string());
1902 let rdap = leg(20, Ok::<&str, &str>("rdap data"));
1903 let whois = leg(0, Ok::<_, SeerError>(whois_data));
1904 let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
1905 assert!(
1906 matches!(r, LegOutcome::GraceTruncated),
1907 "a data-bearing WHOIS winner only owes RDAP the grace period"
1908 );
1909 assert!(matches!(w, LegOutcome::Completed(Ok(_))));
1910 }
1911
1912 #[test]
1915 fn whois_leg_has_data_rejects_thin_ok_and_errors() {
1916 assert!(!whois_leg_has_data(&Ok(empty_whois("example.email"))));
1920 let mut no_match = empty_whois("example.com");
1921 no_match.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1922 assert!(!whois_leg_has_data(&Ok(no_match)));
1923 assert!(!whois_leg_has_data(&Err(SeerError::WhoisError(
1924 "connection refused".to_string()
1925 ))));
1926 }
1927
1928 #[test]
1929 fn whois_leg_has_data_accepts_registration_data() {
1930 let mut w = empty_whois("example.com");
1933 w.registrar = Some("Mock Registrar".to_string());
1934 assert!(whois_leg_has_data(&Ok(w)));
1935 let mut w = empty_whois("example.com");
1936 w.expiration_date = Some(Utc::now());
1937 assert!(whois_leg_has_data(&Ok(w)));
1938 }
1939
1940 #[test]
1943 fn grace_truncated_error_says_answered_not_won() {
1944 let msg = grace_truncated_error("WHOIS", "RDAP");
1948 assert_eq!(
1949 msg,
1950 format!(
1951 "WHOIS did not respond within the {}s grace period after RDAP answered",
1952 PROTOCOL_GRACE_PERIOD.as_secs()
1953 )
1954 );
1955 assert!(!msg.contains("won"));
1956 }
1957
1958 #[test]
1959 fn grace_truncated_error_is_symmetric() {
1960 let msg = grace_truncated_error("RDAP", "WHOIS");
1961 assert!(msg.starts_with("RDAP did not respond"));
1962 assert!(msg.ends_with("after WHOIS answered"));
1963 }
1964
1965 #[test]
1975 fn lookup_inflight_recovers_from_poisoned_mutex() {
1976 use std::panic::{catch_unwind, AssertUnwindSafe};
1977
1978 let _serial = INFLIGHT_TEST_SERIAL
1980 .lock()
1981 .unwrap_or_else(|p| p.into_inner());
1982
1983 let _ = catch_unwind(AssertUnwindSafe(|| {
1985 let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1986 panic!("poisoning LOOKUP_INFLIGHT for test");
1987 }));
1988
1989 let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1993 let canary = unique_test_key("__poison_recovery");
1995 guard.insert(canary.clone(), Weak::new());
1996 assert!(guard.contains_key(&canary));
1997 guard.remove(&canary);
1998 }
1999
2000 #[test]
2003 fn inflight_guard_drop_recovers_from_poisoned_mutex() {
2004 use std::panic::{catch_unwind, AssertUnwindSafe};
2005
2006 let _serial = INFLIGHT_TEST_SERIAL
2012 .lock()
2013 .unwrap_or_else(|p| p.into_inner());
2014
2015 let key = unique_test_key("__drop_poison");
2020 let notify = Arc::new(Notify::new());
2021 {
2022 let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2023 map.insert(key.clone(), Arc::downgrade(¬ify));
2024 }
2025 let guard = InflightGuard {
2026 key: key.clone(),
2027 notify: notify.clone(),
2028 };
2029
2030 let _ = catch_unwind(AssertUnwindSafe(|| {
2032 let _g = LOOKUP_INFLIGHT.lock().unwrap();
2033 panic!("poisoning LOOKUP_INFLIGHT for drop test");
2034 }));
2035
2036 drop(guard);
2039
2040 let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
2041 assert!(
2042 !map.contains_key(&key),
2043 "poisoned-mutex drop path should still remove the in-flight entry"
2044 );
2045 }
2046
2047 fn whois_result(
2050 nameservers: &[&str],
2051 rdap_fallback: Option<Box<RdapResponse>>,
2052 ) -> LookupResult {
2053 LookupResult::Whois {
2054 data: WhoisResponse {
2055 domain: "nic.tg".to_string(),
2056 nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
2057 ..Default::default()
2058 },
2059 rdap_error: None,
2060 rdap_fallback,
2061 dns_nameservers: None,
2062 }
2063 }
2064
2065 fn delegated(hosts: &[&str]) -> Result<Vec<String>> {
2066 Ok(hosts.iter().map(|s| s.to_string()).collect())
2067 }
2068
2069 #[test]
2070 fn whois_only_result_gets_the_dns_delegation() {
2071 let result = with_dns_nameservers(
2072 whois_result(&["ns1.nic.tg", "ns2.nic.tg"], None),
2073 delegated(&["ns1.nic.tg", "ns2.nic.tg", "ns4.nic.tg"]),
2074 );
2075 let LookupResult::Whois {
2076 dns_nameservers: Some(ns),
2077 ..
2078 } = result
2079 else {
2080 panic!("expected a DNS delegation on {result:?}");
2081 };
2082 assert!(!ns.in_sync);
2083 assert_eq!(ns.missing_from_whois, vec!["ns4.nic.tg"]);
2084 }
2085
2086 #[test]
2087 fn result_with_an_rdap_record_is_not_cross_checked() {
2088 assert!(!wants_dns_nameservers(&whois_result(
2089 &[],
2090 Some(Box::default())
2091 )));
2092 let rdap = LookupResult::Rdap {
2093 data: Box::default(),
2094 whois_fallback: None,
2095 };
2096 assert!(!wants_dns_nameservers(&rdap));
2097 let result = with_dns_nameservers(
2098 whois_result(&[], Some(Box::default())),
2099 delegated(&["ns1.x"]),
2100 );
2101 assert!(matches!(
2102 result,
2103 LookupResult::Whois {
2104 dns_nameservers: None,
2105 ..
2106 }
2107 ));
2108 }
2109
2110 #[test]
2111 fn failed_dns_query_leaves_the_result_unchanged() {
2112 let result = with_dns_nameservers(
2113 whois_result(&["ns1.nic.tg"], None),
2114 Err(SeerError::DnsError("no parent server answered".into())),
2115 );
2116 assert!(matches!(
2117 result,
2118 LookupResult::Whois {
2119 dns_nameservers: None,
2120 ..
2121 }
2122 ));
2123 }
2124
2125 #[test]
2126 fn dns_nameservers_round_trip_and_stay_optional() {
2127 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}"#;
2129 let parsed: LookupResult = serde_json::from_str(old).expect("old shape deserializes");
2130 assert!(matches!(
2131 parsed,
2132 LookupResult::Whois {
2133 dns_nameservers: None,
2134 ..
2135 }
2136 ));
2137 let with = with_dns_nameservers(whois_result(&["a.x"], None), delegated(&["a.x"]));
2138 let json = serde_json::to_string(&with).unwrap();
2139 assert!(json.contains("\"dns_nameservers\""), "{json}");
2140 let back: LookupResult = serde_json::from_str(&json).unwrap();
2141 assert!(matches!(
2142 back,
2143 LookupResult::Whois {
2144 dns_nameservers: Some(_),
2145 ..
2146 }
2147 ));
2148 }
2149}