1use serde::{Deserialize, Serialize};
7use tracing::{debug, instrument};
8
9use crate::dns::{DnsPresence, DnsResolver};
10use crate::error::{Result, SeerError};
11use crate::rdap::{rdap_error_is_404, RdapClient};
12use crate::whois::{WhoisClient, WhoisResponse};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AvailabilityResult {
17 pub domain: String,
19 pub available: bool,
21 pub confidence: String,
23 pub method: String,
25 pub details: Option<String>,
27}
28
29impl AvailabilityResult {
30 pub(crate) fn new(domain: &str, available: bool, confidence: &str, method: &str) -> Self {
33 Self {
34 domain: domain.to_string(),
35 available,
36 confidence: confidence.to_string(),
37 method: method.to_string(),
38 details: None,
39 }
40 }
41
42 pub(crate) fn with_details(mut self, details: impl Into<String>) -> Self {
43 self.details = Some(details.into());
44 self
45 }
46
47 pub fn verdict(&self) -> &'static str {
51 match (self.available, self.confidence.as_str()) {
52 (true, "high") => "available",
53 (true, "medium") => "likely_available",
54 (false, "high") => "registered",
55 (false, "medium") => "likely_registered",
56 _ => "unknown",
57 }
58 }
59}
60
61pub(crate) enum PriorRdap {
65 Response(Box<crate::rdap::RdapResponse>),
68 Failed(SeerError),
70 Missing,
72}
73
74pub(crate) enum PriorWhois {
80 Failed(SeerError),
82 Missing,
84}
85
86#[derive(Debug, Clone, Default)]
88pub struct AvailabilityChecker {
89 rdap_client: RdapClient,
90 whois_client: WhoisClient,
91 dns_resolver: DnsResolver,
92}
93
94impl AvailabilityChecker {
95 pub fn new() -> Self {
96 Self::default()
97 }
98
99 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
101 Self {
102 rdap_client: RdapClient::from_config(config),
103 whois_client: WhoisClient::from_config(config),
104 dns_resolver: DnsResolver::from_config(config),
105 }
106 }
107
108 #[instrument(skip(self), fields(domain = %domain))]
115 pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
116 let domain = crate::validation::normalize_domain(domain)?;
117 debug!(domain = %domain, "Checking domain availability");
118 let result = self.check_registry(&domain).await;
119 Ok(self.guard_subdomain_claim(result).await)
120 }
121
122 async fn check_registry(&self, domain: &str) -> AvailabilityResult {
125 match self.rdap_client.lookup_domain(domain).await {
127 Ok(response) => decide_from_rdap(domain, response),
128 Err(rdap_err) => {
129 debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS + DNS");
130 let (whois_result, dns_presence) = tokio::join!(
136 self.whois_client.lookup(domain),
137 self.dns_resolver.presence(domain),
138 );
139 decide_fallback(domain, &rdap_err, whois_result, dns_presence)
140 }
141 }
142 }
143
144 pub(crate) async fn guard_subdomain_claim(
155 &self,
156 result: AvailabilityResult,
157 ) -> AvailabilityResult {
158 if !result.available {
159 return result;
160 }
161 let Some(parent) = crate::psl::registrable_parent(&result.domain) else {
162 return result;
163 };
164 debug!(
165 domain = %result.domain,
166 parent = %parent,
167 "No registry object for a subdomain; checking its registrable parent"
168 );
169 let parent_result = self.check_registry(parent).await;
170 subdomain_verdict(&result.domain, &parent_result)
171 }
172
173 #[instrument(skip(self, prior_rdap, prior_whois), fields(domain = %domain))]
183 pub(crate) async fn check_with_prior(
184 &self,
185 domain: &str,
186 prior_rdap: PriorRdap,
187 prior_whois: PriorWhois,
188 ) -> Result<AvailabilityResult> {
189 let domain = crate::validation::normalize_domain(domain)?;
190 debug!(domain = %domain, "Checking availability (reusing prior protocol outcomes)");
191
192 let rdap = match prior_rdap {
195 PriorRdap::Response(r) => Ok(*r),
196 PriorRdap::Failed(e) => Err(e),
197 PriorRdap::Missing => self.rdap_client.lookup_domain(&domain).await,
198 };
199
200 let result = match rdap {
201 Ok(response) => decide_from_rdap(&domain, response),
202 Err(rdap_err) => {
203 let (whois_result, dns_presence) = match prior_whois {
208 PriorWhois::Failed(e) => (Err(e), self.dns_resolver.presence(&domain).await),
209 PriorWhois::Missing => tokio::join!(
210 self.whois_client.lookup(&domain),
211 self.dns_resolver.presence(&domain),
212 ),
213 };
214 decide_fallback(&domain, &rdap_err, whois_result, dns_presence)
215 }
216 };
217 Ok(self.guard_subdomain_claim(result).await)
218 }
219}
220
221pub(crate) const REFUSED_DETAILS: &str =
224 "Registry refused or throttled the query; availability is inconclusive";
225
226pub(crate) const THIN_NXDOMAIN_DETAILS: &str =
228 "No registry data available; domain has no DNS presence (NXDOMAIN)";
229
230pub(crate) fn subdomain_verdict(domain: &str, parent: &AvailabilityResult) -> AvailabilityResult {
240 let p = &parent.domain;
241 let (confidence, status) = match parent.verdict() {
242 "registered" | "likely_registered" => {
243 (parent.confidence.clone(), format!("{p} is registered"))
244 }
245 "available" | "likely_available" => (
246 "none".to_string(),
247 format!("{p} appears available; register {p} to obtain {domain}"),
248 ),
249 _ => (
250 "none".to_string(),
251 format!("the registration status of {p} could not be determined"),
252 ),
253 };
254 AvailabilityResult::new(domain, false, &confidence, "registrable_parent").with_details(format!(
255 "{domain} is not itself registrable: it is a name under the registrable domain {p}, \
256 and registries hold no object for subdomains. {status}."
257 ))
258}
259
260fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
264 let statuses: Vec<String> = response.status.clone();
265 let is_redemption = statuses.iter().any(|s| {
266 let norm: String = s
273 .chars()
274 .filter(|c| !c.is_whitespace())
275 .collect::<String>()
276 .to_lowercase();
277 matches!(norm.as_str(), "redemptionperiod" | "pendingdelete")
278 });
279
280 if is_redemption {
281 return AvailabilityResult::new(domain, false, "medium", "rdap")
282 .with_details("Domain is in redemption/pending delete period");
283 }
284
285 AvailabilityResult::new(domain, false, "high", "rdap").with_details(format!(
286 "Domain is registered (status: {})",
287 statuses.join(", ")
288 ))
289}
290
291const RDAP_404_DETAILS: &str = "Registry RDAP reports no such domain (HTTP 404)";
293
294pub(crate) enum Fallback {
298 Registered,
301 Verdict(AvailabilityResult),
303 NeedsDns(DnsTieBreak),
306}
307
308pub(crate) struct DnsTieBreak {
310 domain: String,
311 cause: SilentRegistry,
312}
313
314enum SilentRegistry {
316 ThinWhois { no_service: bool },
320 BothFailed { rdap: String, whois: String },
322}
323
324pub(crate) fn classify_fallback(
337 domain: &str,
338 rdap_err: Option<&SeerError>,
339 whois: std::result::Result<&WhoisResponse, &SeerError>,
340) -> Fallback {
341 let rdap_404 = rdap_err.is_some_and(rdap_error_is_404);
342 let rdap_404_verdict = || {
343 Fallback::Verdict(
344 AvailabilityResult::new(domain, true, "high", "rdap").with_details(RDAP_404_DETAILS),
345 )
346 };
347 let cause = match whois {
348 Ok(w) if w.is_available() => {
349 return Fallback::Verdict(
350 AvailabilityResult::new(domain, true, "high", "whois")
351 .with_details("WHOIS indicates domain is not registered"),
352 )
353 }
354 Ok(w) if !w.is_thin() => return Fallback::Registered,
355 Ok(_) if rdap_404 => return rdap_404_verdict(),
358 Ok(w) if w.indicates_registry_refusal() => {
359 return Fallback::Verdict(
360 AvailabilityResult::new(domain, false, "none", "inconclusive")
361 .with_details(REFUSED_DETAILS),
362 )
363 }
364 Ok(w) => SilentRegistry::ThinWhois {
365 no_service: w.registry_unavailable(),
366 },
367 Err(_) if rdap_404 => return rdap_404_verdict(),
369 Err(whois_err) => SilentRegistry::BothFailed {
372 rdap: rdap_err.map_or_else(|| "no response".to_string(), SeerError::sanitized_message),
373 whois: whois_err.sanitized_message(),
374 },
375 };
376 Fallback::NeedsDns(DnsTieBreak {
377 domain: domain.to_string(),
378 cause,
379 })
380}
381
382impl DnsTieBreak {
383 pub(crate) fn decide(self, dns: DnsPresence) -> AvailabilityResult {
389 let d = self.domain.as_str();
390 match (dns, self.cause) {
391 (DnsPresence::Absent, SilentRegistry::ThinWhois { .. }) => {
392 AvailabilityResult::new(d, true, "medium", "dns_nxdomain")
393 .with_details(THIN_NXDOMAIN_DETAILS)
394 }
395 (DnsPresence::Absent, SilentRegistry::BothFailed { .. }) => {
396 AvailabilityResult::new(d, true, "medium", "dns_nxdomain")
397 .with_details("Registry lookups failed; domain has no DNS presence (NXDOMAIN)")
398 }
399 (DnsPresence::Present, SilentRegistry::ThinWhois { no_service: true }) => {
400 AvailabilityResult::new(d, false, "medium", "dns_present").with_details(
401 "The apex is delegated in DNS, so the domain is almost certainly \
402 registered. This TLD's registry provides no port-43 WHOIS data and \
403 RDAP was unavailable (rate-limited or unreachable); retry shortly for \
404 full RDAP detail.",
405 )
406 }
407 (DnsPresence::Present, SilentRegistry::ThinWhois { no_service: false }) => {
408 AvailabilityResult::new(d, false, "medium", "dns_present").with_details(
409 "The apex is delegated in DNS, so the domain is almost certainly \
410 registered. Registry detail was unavailable (RDAP rate-limited or \
411 unreachable and WHOIS returned no data); retry shortly for full detail.",
412 )
413 }
414 (DnsPresence::Present, SilentRegistry::BothFailed { .. }) => {
415 AvailabilityResult::new(d, false, "medium", "dns_present").with_details(
416 "Registry lookups failed, but the apex is delegated in DNS \
417 (NS records present) — the domain is almost certainly registered",
418 )
419 }
420 (DnsPresence::Unknown, SilentRegistry::ThinWhois { .. }) => {
421 AvailabilityResult::new(d, false, "none", "inconclusive").with_details(
422 "Could not determine availability: WHOIS returned no registration data, \
423 RDAP failed and the DNS presence probe failed",
424 )
425 }
426 (DnsPresence::Unknown, SilentRegistry::BothFailed { rdap, whois }) => {
430 AvailabilityResult::new(d, false, "none", "inconclusive").with_details(format!(
431 "Could not determine availability. RDAP: {rdap}. WHOIS: {whois}"
432 ))
433 }
434 }
435 }
436}
437
438fn decide_fallback(
442 domain: &str,
443 rdap_err: &SeerError,
444 whois_result: Result<WhoisResponse>,
445 dns_presence: DnsPresence,
446) -> AvailabilityResult {
447 match classify_fallback(domain, Some(rdap_err), whois_result.as_ref()) {
448 Fallback::Registered => AvailabilityResult {
449 details: whois_result
450 .ok()
451 .and_then(|w| w.registrar)
452 .map(|r| format!("Registered with {}", r)),
453 ..AvailabilityResult::new(domain, false, "high", "whois")
454 },
455 Fallback::Verdict(verdict) => verdict,
456 Fallback::NeedsDns(tie_break) => tie_break.decide(dns_presence),
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::error::SeerError;
464 use crate::rdap::RdapResponse;
465 use crate::whois::WhoisResponse;
466
467 #[test]
468 fn verdict_matrix() {
469 let make = |available, confidence: &str| {
470 AvailabilityResult::new("example.test", available, confidence, "whois")
471 };
472 assert_eq!(make(true, "high").verdict(), "available");
473 assert_eq!(make(true, "medium").verdict(), "likely_available");
474 assert_eq!(make(false, "high").verdict(), "registered");
475 assert_eq!(make(false, "medium").verdict(), "likely_registered");
476 assert_eq!(make(false, "none").verdict(), "unknown");
477 assert_eq!(make(true, "low").verdict(), "unknown");
478 }
479
480 #[test]
481 fn test_availability_result_serialization() {
482 let result = AvailabilityResult::new("example.com", false, "high", "rdap")
483 .with_details("Domain is registered");
484 let json = serde_json::to_string(&result).unwrap();
485 assert!(json.contains("\"available\":false"));
486 assert!(json.contains("\"confidence\":\"high\""));
487 }
488
489 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
501 WhoisResponse {
502 domain: "example.test".to_string(),
503 registrar: registrar.map(str::to_string),
504 whois_server: "whois.test".to_string(),
505 raw_response: raw.to_string(),
506 ..Default::default()
507 }
508 }
509
510 fn rdap_with(statuses: &[&str]) -> RdapResponse {
511 RdapResponse {
512 status: statuses.iter().map(|s| s.to_string()).collect(),
513 ldh_name: Some("example.test".to_string()),
514 ..Default::default()
515 }
516 }
517
518 #[test]
521 fn rdap_success_registered_marks_taken_high_confidence() {
522 let rdap = rdap_with(&["active"]);
523 let r = decide_from_rdap("example.test", rdap);
524 assert!(!r.available, "registered domain must be marked taken");
525 assert_eq!(r.confidence, "high");
526 assert_eq!(r.method, "rdap");
527 assert!(
528 r.details.as_deref().unwrap().contains("active"),
529 "details should include status list"
530 );
531 }
532
533 #[test]
534 fn rdap_success_empty_status_marks_taken_high_confidence() {
535 let rdap = rdap_with(&[]);
538 let r = decide_from_rdap("example.test", rdap);
539 assert!(!r.available);
540 assert_eq!(r.confidence, "high");
541 assert_eq!(r.method, "rdap");
542 }
543
544 #[test]
545 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
546 let rdap = rdap_with(&["redemption period"]);
547 let r = decide_from_rdap("example.test", rdap);
548 assert!(!r.available, "redemption period still means taken");
549 assert_eq!(r.confidence, "medium", "redemption drops confidence");
550 assert_eq!(r.method, "rdap");
551 assert!(r.details.as_deref().unwrap().contains("redemption"));
552 }
553
554 #[test]
555 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
556 let rdap = rdap_with(&["pending delete"]);
557 let r = decide_from_rdap("example.test", rdap);
558 assert!(!r.available);
559 assert_eq!(r.confidence, "medium");
560 assert!(r.details.as_deref().unwrap().contains("redemption"));
561 }
562
563 #[test]
564 fn rdap_status_substring_redemption_not_misclassified() {
565 let rdap = rdap_with(&["clientHold (no redemption requested)"]);
569 let r = decide_from_rdap("example.test", rdap);
570 assert!(!r.available, "still registered");
571 assert_eq!(
572 r.confidence, "high",
573 "verbose status must not be downgraded to redemption/medium"
574 );
575 }
576
577 #[test]
578 fn rdap_status_redemption_detected_case_insensitively() {
579 let rdap = rdap_with(&["Redemption Period"]);
582 let r = decide_from_rdap("example.test", rdap);
583 assert_eq!(
584 r.confidence, "medium",
585 "standard token detected regardless of case"
586 );
587 }
588
589 #[test]
592 fn rdap_fail_whois_says_available_high_confidence() {
593 let whois = whois_with("No match for \"example.test\".\n", None);
596 let rdap_err = SeerError::RdapError("404 not found".to_string());
597 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
598 assert!(r.available, "WHOIS 'no match' must mark available");
599 assert_eq!(r.confidence, "high");
600 assert_eq!(r.method, "whois");
601 }
602
603 #[test]
604 fn rdap_fail_whois_says_registered_high_confidence() {
605 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
606 let rdap_err = SeerError::RdapError("404 not found".to_string());
607 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
608 assert!(!r.available);
609 assert_eq!(r.confidence, "high");
610 assert_eq!(r.method, "whois");
611 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
612 }
613
614 #[test]
615 fn rdap_fail_whois_without_registration_data_is_not_called_registered() {
616 let whois = whois_with("Domain Name: example.test\n", None);
620 let rdap_err = SeerError::RdapError("404".to_string());
621 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
622 assert!(!r.available);
623 assert_eq!(r.confidence, "none");
624 assert_eq!(r.method, "inconclusive");
625 }
626
627 #[test]
630 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
631 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
632 let whois_err = SeerError::Timeout("whois timed out".to_string());
633 let r = decide_fallback(
634 "example.test",
635 &rdap_err,
636 Err(whois_err),
637 DnsPresence::Unknown,
638 );
639 assert!(
640 !r.available,
641 "inconclusive means NOT available (fail-safe default)"
642 );
643 assert_eq!(r.confidence, "none");
644 assert_eq!(r.method, "inconclusive");
645 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
646 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
647 }
648
649 #[test]
650 fn rdap_fail_whois_transport_error_with_phrase_not_available() {
651 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
657 let whois_err =
658 SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
659 let r = decide_fallback(
660 "example.test",
661 &rdap_err,
662 Err(whois_err),
663 DnsPresence::Present,
664 );
665 assert!(
666 !r.available,
667 "transport error text must not infer availability"
668 );
669 }
670
671 #[test]
672 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
673 let rdap_err = SeerError::RdapError("connection refused".to_string());
674 let whois_err = SeerError::WhoisError(
675 "failed to connect to whois.example: connection refused".to_string(),
676 );
677 let r = decide_fallback(
678 "example.test",
679 &rdap_err,
680 Err(whois_err),
681 DnsPresence::Unknown,
682 );
683 assert!(!r.available);
684 assert_eq!(r.confidence, "none");
685 assert_eq!(r.method, "inconclusive");
686 }
687
688 #[test]
691 fn rdap_404_with_blocked_whois_marks_available() {
692 let whois = whois_with("Requests of this client are not permitted.\n", None);
697 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
698 let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
699 assert!(
700 r.available,
701 "RDAP 404 must mark available even with blocked WHOIS"
702 );
703 assert_eq!(r.confidence, "high");
704 assert_eq!(r.method, "rdap");
705 }
706
707 #[test]
708 fn rdap_404_with_whois_error_marks_available() {
709 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
711 let whois_err = SeerError::WhoisError("connection refused".to_string());
712 let r = decide_fallback(
713 "example.test",
714 &rdap_err,
715 Err(whois_err),
716 DnsPresence::Unknown,
717 );
718 assert!(r.available);
719 assert_eq!(r.confidence, "high");
720 assert_eq!(r.method, "rdap");
721 }
722
723 #[test]
724 fn rdap_404_but_whois_has_full_registration_marks_registered() {
725 let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
729 whois.creation_date = Some(chrono::Utc::now());
730 whois.nameservers = vec!["ns1.example.net".to_string()];
731 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
732 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
733 assert!(
734 !r.available,
735 "concrete WHOIS registration must win over RDAP 404"
736 );
737 assert_eq!(r.confidence, "high");
738 assert_eq!(r.method, "whois");
739 }
740
741 #[test]
744 fn thin_whois_non404_dns_absent_marks_likely_available() {
745 let whois = whois_with(
749 "Conditions of use for the whois service via port 43\n",
750 None,
751 );
752 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
753 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
754 assert!(r.available);
755 assert_eq!(r.confidence, "medium");
756 assert_eq!(r.method, "dns_nxdomain");
757 }
758
759 #[test]
760 fn thin_whois_non404_dns_present_stays_unavailable() {
761 let whois = whois_with(
764 "Conditions of use for the whois service via port 43\n",
765 None,
766 );
767 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
768 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
769 assert!(!r.available);
770 assert_ne!(r.method, "dns_nxdomain");
771 }
772
773 #[test]
774 fn thin_whois_non404_dns_unknown_is_inconclusive() {
775 let whois = whois_with(
779 "Conditions of use for the whois service via port 43\n",
780 None,
781 );
782 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
783 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
784 assert!(!r.available);
785 assert_eq!(r.confidence, "none");
786 assert_eq!(r.method, "inconclusive");
787 assert!(r.details.is_some());
788 }
789
790 #[derive(Debug, PartialEq)]
794 enum Expect {
795 Registered,
796 Verdict(bool, &'static str, &'static str),
797 }
798
799 fn read(
800 rdap: Option<&SeerError>,
801 whois: std::result::Result<&WhoisResponse, &SeerError>,
802 dns: DnsPresence,
803 ) -> Expect {
804 let v = match classify_fallback("example.test", rdap, whois) {
805 Fallback::Registered => return Expect::Registered,
806 Fallback::Verdict(v) => v,
807 Fallback::NeedsDns(t) => t.decide(dns),
808 };
809 assert!(v.details.is_some(), "every verdict explains itself: {v:?}");
810 Expect::Verdict(
811 v.available,
812 match v.confidence.as_str() {
813 "high" => "high",
814 "medium" => "medium",
815 "none" => "none",
816 other => panic!("unexpected confidence {other}"),
817 },
818 match v.method.as_str() {
819 "whois" => "whois",
820 "rdap" => "rdap",
821 "inconclusive" => "inconclusive",
822 "dns_nxdomain" => "dns_nxdomain",
823 "dns_present" => "dns_present",
824 other => panic!("unexpected method {other}"),
825 },
826 )
827 }
828
829 #[test]
833 fn fallback_ladder_covers_every_input_combination() {
834 use Expect::*;
835 let r404 = SeerError::RdapError("query failed with status 404 Not Found".to_string());
836 let r503 = SeerError::RdapError("query failed with status 503".to_string());
837 let no_match = whois_with("No match for \"EXAMPLE.TEST\".\n", None);
838 let mut registered = whois_with("Domain Name: example.test\n", Some("Registrar Inc."));
839 registered.creation_date = Some(chrono::Utc::now());
840 let thin = whois_with(
841 "Conditions of use for the whois service via port 43\n",
842 None,
843 );
844 let no_service = whois_with("TLD is not supported.\n", None);
845 let refusal = whois_with("Access rate limited; please try again later.\n", None);
846 let failed = SeerError::Timeout("whois timed out".to_string());
847 assert!(no_service.registry_unavailable() && !no_service.indicates_registry_refusal());
848 assert!(!thin.registry_unavailable() && !thin.indicates_registry_refusal());
849
850 let all_dns = [
851 DnsPresence::Absent,
852 DnsPresence::Present,
853 DnsPresence::Unknown,
854 ];
855 for rdap in [Some(&r404), Some(&r503), None] {
856 let is_404 = matches!(rdap, Some(e) if rdap_error_is_404(e));
857 for dns in all_dns {
858 assert_eq!(
860 read(rdap, Ok(&no_match), dns),
861 Verdict(true, "high", "whois")
862 );
863 assert_eq!(read(rdap, Ok(®istered), dns), Registered);
864 if is_404 {
865 for w in [Ok(&thin), Ok(&no_service), Ok(&refusal), Err(&failed)] {
866 assert_eq!(read(rdap, w, dns), Verdict(true, "high", "rdap"));
867 }
868 continue;
869 }
870 assert_eq!(
871 read(rdap, Ok(&refusal), dns),
872 Verdict(false, "none", "inconclusive"),
873 "refusal is inconclusive whatever DNS says (#45)"
874 );
875 let expected = match dns {
877 DnsPresence::Absent => Verdict(true, "medium", "dns_nxdomain"),
878 DnsPresence::Present => Verdict(false, "medium", "dns_present"),
879 DnsPresence::Unknown => Verdict(false, "none", "inconclusive"),
880 };
881 for w in [Ok(&thin), Ok(&no_service), Err(&failed)] {
882 assert_eq!(read(rdap, w, dns), expected, "{rdap:?} {w:?} {dns:?}");
883 }
884 }
885 }
886 }
887
888 #[test]
889 fn both_legs_failed_dns_absent_marks_likely_available() {
890 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
893 let whois_err = SeerError::WhoisError("connection refused".to_string());
894 let r = decide_fallback(
895 "example.test",
896 &rdap_err,
897 Err(whois_err),
898 DnsPresence::Absent,
899 );
900 assert!(r.available);
901 assert_eq!(r.confidence, "medium");
902 assert_eq!(r.method, "dns_nxdomain");
903 }
904
905 #[test]
906 fn both_legs_failed_dns_present_marks_likely_registered() {
907 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.ru".to_string());
913 let whois_err = SeerError::Timeout(
914 "Operation failed after 3 attempts: Operation timed out".to_string(),
915 );
916 let r = decide_fallback(
917 "example.ru",
918 &rdap_err,
919 Err(whois_err),
920 DnsPresence::Present,
921 );
922 assert!(
923 !r.available,
924 "a delegated apex must never read as available"
925 );
926 assert_eq!(r.confidence, "medium");
927 assert_eq!(r.method, "dns_present");
928 assert_eq!(r.verdict(), "likely_registered");
929 assert!(
930 r.details.as_deref().unwrap().contains("delegated"),
931 "details should explain the DNS-delegation evidence"
932 );
933 }
934
935 #[test]
938 fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
939 let whois = whois_with(
944 "Access rate limited; no data found for unauthenticated clients\n",
945 None,
946 );
947 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
948 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
949 assert!(
950 !r.available,
951 "refusal/throttle must not be called available"
952 );
953 assert_eq!(r.confidence, "none");
954 assert_eq!(r.method, "inconclusive");
955 }
956
957 #[test]
958 fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
959 let whois = whois_with(
963 "This domain name is not available for registration.\n",
964 None,
965 );
966 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
967 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
968 assert!(!r.available);
969 assert_eq!(r.confidence, "none");
970 assert_eq!(r.method, "inconclusive");
971 }
972
973 #[test]
974 fn rdap_404_with_refusal_whois_still_marks_available() {
975 let whois = whois_with("Access rate limited; please try again later.\n", None);
978 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
979 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
980 assert!(r.available, "RDAP 404 is authoritative over a refusal body");
981 assert_eq!(r.confidence, "high");
982 assert_eq!(r.method, "rdap");
983 }
984
985 #[tokio::test]
988 async fn check_with_prior_reuses_rdap_response_without_network() {
989 let checker = AvailabilityChecker::new();
994 let rdap = rdap_with(&["active"]);
995 let r = checker
996 .check_with_prior(
997 "example.test",
998 PriorRdap::Response(Box::new(rdap)),
999 PriorWhois::Missing,
1000 )
1001 .await
1002 .expect("prior-response path must not error");
1003 assert!(!r.available, "an existing RDAP object means registered");
1004 assert_eq!(r.confidence, "high");
1005 assert_eq!(r.method, "rdap");
1006 }
1007
1008 #[tokio::test]
1009 async fn check_with_prior_reuses_redemption_status_verdict() {
1010 let checker = AvailabilityChecker::new();
1013 let rdap = rdap_with(&["redemption period"]);
1014 let r = checker
1015 .check_with_prior(
1016 "example.test",
1017 PriorRdap::Response(Box::new(rdap)),
1018 PriorWhois::Missing,
1019 )
1020 .await
1021 .expect("prior-response path must not error");
1022 assert!(!r.available);
1023 assert_eq!(r.confidence, "medium");
1024 assert_eq!(r.method, "rdap");
1025 }
1026
1027 fn denic_whois() -> WhoisResponse {
1032 let mut w = whois_with(
1033 "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n",
1034 None,
1035 );
1036 w.nameservers = vec!["ns1.example.net".to_string()];
1037 w.status = vec!["active".to_string()];
1038 w
1039 }
1040
1041 #[test]
1042 fn whois_with_nameservers_is_not_thin() {
1043 assert!(whois_with("", None).is_thin());
1044 assert!(!denic_whois().is_thin());
1045 }
1046
1047 #[test]
1048 fn denic_style_whois_without_registrar_or_dates_is_registered() {
1049 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
1053 let r = decide_fallback(
1054 "example.de",
1055 &rdap_err,
1056 Ok(denic_whois()),
1057 DnsPresence::Absent,
1058 );
1059 assert!(!r.available, "a delegated DENIC record is registered");
1060 assert_eq!(r.confidence, "high");
1061 assert_eq!(r.method, "whois");
1062 }
1063
1064 fn avail(domain: &str, available: bool, confidence: &str) -> AvailabilityResult {
1067 AvailabilityResult::new(domain, available, confidence, "rdap")
1068 }
1069
1070 #[test]
1071 fn subdomain_under_registered_parent_is_registered_not_available() {
1072 let r = subdomain_verdict("mail.google.com", &avail("google.com", false, "high"));
1073 assert!(!r.available);
1074 assert_eq!(r.domain, "mail.google.com");
1075 assert_eq!(r.verdict(), "registered");
1076 assert_eq!(r.method, "registrable_parent");
1077 let details = r.details.unwrap();
1078 assert!(details.contains("google.com is registered"), "{details}");
1079 }
1080
1081 #[test]
1082 fn subdomain_under_available_parent_is_never_claimed_available() {
1083 let r = subdomain_verdict("mail.freebrand.com", &avail("freebrand.com", true, "high"));
1084 assert!(!r.available, "a subdomain itself is never registrable");
1085 assert_eq!(r.verdict(), "unknown");
1086 assert!(r.details.unwrap().contains("register freebrand.com"));
1087
1088 let r = subdomain_verdict("a.b.co.uk", &avail("b.co.uk", false, "none"));
1089 assert!(!r.available);
1090 assert_eq!(r.verdict(), "unknown");
1091 assert!(r.details.unwrap().contains("could not be determined"));
1092 }
1093
1094 #[tokio::test]
1095 async fn subdomain_guard_leaves_registrable_names_and_negative_results_alone() {
1096 let checker = AvailabilityChecker::new();
1099 let r = checker
1100 .guard_subdomain_claim(avail("example.co.uk", true, "high"))
1101 .await;
1102 assert!(r.available);
1103 assert_eq!(r.method, "rdap");
1104
1105 let r = checker
1106 .guard_subdomain_claim(avail("mail.example.com", false, "high"))
1107 .await;
1108 assert!(!r.available);
1109 assert_eq!(r.method, "rdap");
1110 }
1111}