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