1use serde::{Deserialize, Serialize};
7use tracing::{debug, instrument};
8
9use crate::dns::{DnsPresence, DnsResolver};
10use crate::error::Result;
11use crate::rdap::{rdap_error_is_404, RdapClient};
12use crate::whois::WhoisClient;
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 fn verdict(&self) -> &'static str {
34 match (self.available, self.confidence.as_str()) {
35 (true, "high") => "available",
36 (true, "medium") => "likely_available",
37 (false, "high") => "registered",
38 (false, "medium") => "likely_registered",
39 _ => "unknown",
40 }
41 }
42}
43
44pub(crate) enum PriorRdap {
48 Response(Box<crate::rdap::RdapResponse>),
51 Failed(crate::error::SeerError),
53 Missing,
55}
56
57pub(crate) enum PriorWhois {
63 Failed(crate::error::SeerError),
65 Missing,
67}
68
69#[derive(Debug, Clone)]
71pub struct AvailabilityChecker {
72 rdap_client: RdapClient,
73 whois_client: WhoisClient,
74 dns_resolver: DnsResolver,
75}
76
77impl Default for AvailabilityChecker {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl AvailabilityChecker {
84 pub fn new() -> Self {
85 Self {
86 rdap_client: RdapClient::new(),
87 whois_client: WhoisClient::new(),
88 dns_resolver: DnsResolver::new(),
89 }
90 }
91
92 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
94 Self {
95 rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
96 whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
97 dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
98 }
99 }
100
101 #[instrument(skip(self), fields(domain = %domain))]
107 pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
108 let domain = crate::validation::normalize_domain(domain)?;
109 debug!(domain = %domain, "Checking domain availability");
110 let result = self.check_registry(&domain).await;
111 Ok(self.guard_subdomain_claim(result).await)
112 }
113
114 async fn check_registry(&self, domain: &str) -> AvailabilityResult {
117 match self.rdap_client.lookup_domain(domain).await {
119 Ok(response) => decide_from_rdap(domain, response),
120 Err(rdap_err) => {
121 debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS + DNS");
122 let (whois_result, dns_presence) = tokio::join!(
128 self.whois_client.lookup(domain),
129 self.dns_resolver.presence(domain),
130 );
131 decide_fallback(domain, &rdap_err, whois_result, dns_presence)
132 }
133 }
134 }
135
136 pub(crate) async fn guard_subdomain_claim(
147 &self,
148 result: AvailabilityResult,
149 ) -> AvailabilityResult {
150 if !result.available {
151 return result;
152 }
153 let Some(parent) = crate::psl::registrable_parent(&result.domain) else {
154 return result;
155 };
156 debug!(
157 domain = %result.domain,
158 parent = %parent,
159 "No registry object for a subdomain; checking its registrable parent"
160 );
161 let parent_result = self.check_registry(parent).await;
162 subdomain_verdict(&result.domain, &parent_result)
163 }
164
165 #[instrument(skip(self, prior_rdap, prior_whois), fields(domain = %domain))]
175 pub(crate) async fn check_with_prior(
176 &self,
177 domain: &str,
178 prior_rdap: PriorRdap,
179 prior_whois: PriorWhois,
180 ) -> Result<AvailabilityResult> {
181 let domain = crate::validation::normalize_domain(domain)?;
182 debug!(domain = %domain, "Checking availability (reusing prior protocol outcomes)");
183
184 let rdap = match prior_rdap {
187 PriorRdap::Response(r) => Ok(*r),
188 PriorRdap::Failed(e) => Err(e),
189 PriorRdap::Missing => self.rdap_client.lookup_domain(&domain).await,
190 };
191
192 let result = match rdap {
193 Ok(response) => decide_from_rdap(&domain, response),
194 Err(rdap_err) => {
195 let (whois_result, dns_presence) = match prior_whois {
200 PriorWhois::Failed(e) => (Err(e), self.dns_resolver.presence(&domain).await),
201 PriorWhois::Missing => tokio::join!(
202 self.whois_client.lookup(&domain),
203 self.dns_resolver.presence(&domain),
204 ),
205 };
206 decide_fallback(&domain, &rdap_err, whois_result, dns_presence)
207 }
208 };
209 Ok(self.guard_subdomain_claim(result).await)
210 }
211}
212
213pub(crate) fn whois_is_thin(w: &crate::whois::WhoisResponse) -> bool {
226 w.registrar.is_none()
227 && w.creation_date.is_none()
228 && w.expiration_date.is_none()
229 && w.nameservers.is_empty()
230}
231
232fn subdomain_verdict(domain: &str, parent: &AvailabilityResult) -> AvailabilityResult {
242 let p = &parent.domain;
243 let (confidence, status) = match parent.verdict() {
244 "registered" | "likely_registered" => {
245 (parent.confidence.clone(), format!("{p} is registered"))
246 }
247 "available" | "likely_available" => (
248 "none".to_string(),
249 format!("{p} appears available; register {p} to obtain {domain}"),
250 ),
251 _ => (
252 "none".to_string(),
253 format!("the registration status of {p} could not be determined"),
254 ),
255 };
256 AvailabilityResult {
257 domain: domain.to_string(),
258 available: false,
259 confidence,
260 method: "registrable_parent".to_string(),
261 details: Some(format!(
262 "{domain} is not itself registrable: it is a name under the registrable domain {p}, \
263 and registries hold no object for subdomains. {status}."
264 )),
265 }
266}
267
268fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
272 let statuses: Vec<String> = response.status.clone();
273 let is_redemption = statuses.iter().any(|s| {
274 let norm: String = s
281 .chars()
282 .filter(|c| !c.is_whitespace())
283 .collect::<String>()
284 .to_lowercase();
285 matches!(norm.as_str(), "redemptionperiod" | "pendingdelete")
286 });
287
288 if is_redemption {
289 return AvailabilityResult {
290 domain: domain.to_string(),
291 available: false,
292 confidence: "medium".to_string(),
293 method: "rdap".to_string(),
294 details: Some("Domain is in redemption/pending delete period".to_string()),
295 };
296 }
297
298 AvailabilityResult {
299 domain: domain.to_string(),
300 available: false,
301 confidence: "high".to_string(),
302 method: "rdap".to_string(),
303 details: Some(format!(
304 "Domain is registered (status: {})",
305 statuses.join(", ")
306 )),
307 }
308}
309
310fn decide_fallback(
319 domain: &str,
320 rdap_err: &crate::error::SeerError,
321 whois_result: Result<crate::whois::WhoisResponse>,
322 dns_presence: DnsPresence,
323) -> AvailabilityResult {
324 match whois_result {
325 Ok(whois_response) => {
326 let thin = whois_is_thin(&whois_response);
329
330 if whois_response.is_available() {
331 AvailabilityResult {
332 domain: domain.to_string(),
333 available: true,
334 confidence: "high".to_string(),
335 method: "whois".to_string(),
336 details: Some("WHOIS indicates domain is not registered".to_string()),
337 }
338 } else if !thin {
339 AvailabilityResult {
342 domain: domain.to_string(),
343 available: false,
344 confidence: "high".to_string(),
345 method: "whois".to_string(),
346 details: whois_response
347 .registrar
348 .map(|r| format!("Registered with {}", r)),
349 }
350 } else if rdap_error_is_404(rdap_err) {
351 AvailabilityResult {
354 domain: domain.to_string(),
355 available: true,
356 confidence: "high".to_string(),
357 method: "rdap".to_string(),
358 details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
359 }
360 } else if whois_response.indicates_registry_refusal() {
361 AvailabilityResult {
368 domain: domain.to_string(),
369 available: false,
370 confidence: "none".to_string(),
371 method: "inconclusive".to_string(),
372 details: Some(
373 "Registry refused or throttled the query; availability is inconclusive"
374 .to_string(),
375 ),
376 }
377 } else if dns_presence == DnsPresence::Absent {
378 AvailabilityResult {
381 domain: domain.to_string(),
382 available: true,
383 confidence: "medium".to_string(),
384 method: "dns_nxdomain".to_string(),
385 details: Some(
386 "No registry data available; domain has no DNS presence (NXDOMAIN)"
387 .to_string(),
388 ),
389 }
390 } else {
391 AvailabilityResult {
394 domain: domain.to_string(),
395 available: false,
396 confidence: "high".to_string(),
397 method: "whois".to_string(),
398 details: None,
399 }
400 }
401 }
402 Err(whois_err) => {
403 if rdap_error_is_404(rdap_err) {
407 return AvailabilityResult {
408 domain: domain.to_string(),
409 available: true,
410 confidence: "high".to_string(),
411 method: "rdap".to_string(),
412 details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
413 };
414 }
415 let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
421 let whois_msg = whois_err.to_string().to_lowercase();
422 whois_msg.contains("no match")
423 || whois_msg.contains("not found")
424 || whois_msg.contains("no data found")
425 || whois_msg.contains("no entries found")
426 };
427
428 if likely_available {
429 AvailabilityResult {
430 domain: domain.to_string(),
431 available: true,
432 confidence: "medium".to_string(),
433 method: "whois_error".to_string(),
434 details: Some("WHOIS server indicates no matching records".to_string()),
435 }
436 } else if dns_presence == DnsPresence::Absent {
437 AvailabilityResult {
440 domain: domain.to_string(),
441 available: true,
442 confidence: "medium".to_string(),
443 method: "dns_nxdomain".to_string(),
444 details: Some(
445 "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
446 .to_string(),
447 ),
448 }
449 } else if dns_presence == DnsPresence::Present {
450 AvailabilityResult {
460 domain: domain.to_string(),
461 available: false,
462 confidence: "medium".to_string(),
463 method: "dns_present".to_string(),
464 details: Some(
465 "Registry lookups failed, but the apex is delegated in DNS \
466 (NS records present) — the domain is almost certainly registered"
467 .to_string(),
468 ),
469 }
470 } else {
471 AvailabilityResult {
476 domain: domain.to_string(),
477 available: false,
478 confidence: "none".to_string(),
479 method: "inconclusive".to_string(),
480 details: Some(format!(
485 "Could not determine availability. RDAP: {}. WHOIS: {}",
486 rdap_err.sanitized_message(),
487 whois_err.sanitized_message()
488 )),
489 }
490 }
491 }
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::error::SeerError;
499 use crate::rdap::RdapResponse;
500 use crate::whois::WhoisResponse;
501
502 #[test]
503 fn verdict_matrix() {
504 let make = |available, confidence: &str| AvailabilityResult {
505 domain: "example.test".to_string(),
506 available,
507 confidence: confidence.to_string(),
508 method: "whois".to_string(),
509 details: None,
510 };
511 assert_eq!(make(true, "high").verdict(), "available");
512 assert_eq!(make(true, "medium").verdict(), "likely_available");
513 assert_eq!(make(false, "high").verdict(), "registered");
514 assert_eq!(make(false, "medium").verdict(), "likely_registered");
515 assert_eq!(make(false, "none").verdict(), "unknown");
516 assert_eq!(make(true, "low").verdict(), "unknown");
517 }
518
519 #[test]
520 fn test_availability_result_serialization() {
521 let result = AvailabilityResult {
522 domain: "example.com".to_string(),
523 available: false,
524 confidence: "high".to_string(),
525 method: "rdap".to_string(),
526 details: Some("Domain is registered".to_string()),
527 };
528 let json = serde_json::to_string(&result).unwrap();
529 assert!(json.contains("\"available\":false"));
530 assert!(json.contains("\"confidence\":\"high\""));
531 }
532
533 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
545 WhoisResponse {
546 domain: "example.test".to_string(),
547 registrar: registrar.map(str::to_string),
548 registrant: None,
549 organization: None,
550 registrant_email: None,
551 registrant_phone: None,
552 registrant_address: None,
553 registrant_country: None,
554 admin_name: None,
555 admin_organization: None,
556 admin_email: None,
557 admin_phone: None,
558 tech_name: None,
559 tech_organization: None,
560 tech_email: None,
561 tech_phone: None,
562 creation_date: None,
563 expiration_date: None,
564 updated_date: None,
565 nameservers: vec![],
566 status: vec![],
567 dnssec: None,
568 whois_server: "whois.test".to_string(),
569 raw_response: raw.to_string(),
570 }
571 }
572
573 fn rdap_with(statuses: &[&str]) -> RdapResponse {
574 RdapResponse {
575 status: statuses.iter().map(|s| s.to_string()).collect(),
576 ldh_name: Some("example.test".to_string()),
577 ..Default::default()
578 }
579 }
580
581 #[test]
584 fn rdap_success_registered_marks_taken_high_confidence() {
585 let rdap = rdap_with(&["active"]);
586 let r = decide_from_rdap("example.test", rdap);
587 assert!(!r.available, "registered domain must be marked taken");
588 assert_eq!(r.confidence, "high");
589 assert_eq!(r.method, "rdap");
590 assert!(
591 r.details.as_deref().unwrap().contains("active"),
592 "details should include status list"
593 );
594 }
595
596 #[test]
597 fn rdap_success_empty_status_marks_taken_high_confidence() {
598 let rdap = rdap_with(&[]);
601 let r = decide_from_rdap("example.test", rdap);
602 assert!(!r.available);
603 assert_eq!(r.confidence, "high");
604 assert_eq!(r.method, "rdap");
605 }
606
607 #[test]
608 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
609 let rdap = rdap_with(&["redemption period"]);
610 let r = decide_from_rdap("example.test", rdap);
611 assert!(!r.available, "redemption period still means taken");
612 assert_eq!(r.confidence, "medium", "redemption drops confidence");
613 assert_eq!(r.method, "rdap");
614 assert!(r.details.as_deref().unwrap().contains("redemption"));
615 }
616
617 #[test]
618 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
619 let rdap = rdap_with(&["pending delete"]);
620 let r = decide_from_rdap("example.test", rdap);
621 assert!(!r.available);
622 assert_eq!(r.confidence, "medium");
623 assert!(r.details.as_deref().unwrap().contains("redemption"));
624 }
625
626 #[test]
627 fn rdap_status_substring_redemption_not_misclassified() {
628 let rdap = rdap_with(&["clientHold (no redemption requested)"]);
632 let r = decide_from_rdap("example.test", rdap);
633 assert!(!r.available, "still registered");
634 assert_eq!(
635 r.confidence, "high",
636 "verbose status must not be downgraded to redemption/medium"
637 );
638 }
639
640 #[test]
641 fn rdap_status_redemption_detected_case_insensitively() {
642 let rdap = rdap_with(&["Redemption Period"]);
645 let r = decide_from_rdap("example.test", rdap);
646 assert_eq!(
647 r.confidence, "medium",
648 "standard token detected regardless of case"
649 );
650 }
651
652 #[test]
655 fn rdap_fail_whois_says_available_high_confidence() {
656 let whois = whois_with("No match for \"example.test\".\n", None);
659 let rdap_err = SeerError::RdapError("404 not found".to_string());
660 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
661 assert!(r.available, "WHOIS 'no match' must mark available");
662 assert_eq!(r.confidence, "high");
663 assert_eq!(r.method, "whois");
664 }
665
666 #[test]
667 fn rdap_fail_whois_says_registered_high_confidence() {
668 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
669 let rdap_err = SeerError::RdapError("404 not found".to_string());
670 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
671 assert!(!r.available);
672 assert_eq!(r.confidence, "high");
673 assert_eq!(r.method, "whois");
674 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
675 }
676
677 #[test]
678 fn rdap_fail_whois_registered_without_registrar_no_detail() {
679 let whois = whois_with("Domain Name: example.test\n", None);
682 let rdap_err = SeerError::RdapError("404".to_string());
683 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
684 assert!(!r.available);
685 assert_eq!(r.confidence, "high");
686 assert!(
687 r.details.is_none(),
688 "no registrar means no details string, got: {:?}",
689 r.details
690 );
691 }
692
693 #[test]
696 fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
697 let rdap_err = SeerError::RdapError("500".to_string());
698 let whois_err =
699 SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
700 let r = decide_fallback(
701 "example.test",
702 &rdap_err,
703 Err(whois_err),
704 DnsPresence::Unknown,
705 );
706 assert!(
707 r.available,
708 "whois error containing 'no match' is available"
709 );
710 assert_eq!(r.confidence, "medium");
711 assert_eq!(r.method, "whois_error");
712 }
713
714 #[test]
715 fn rdap_fail_whois_error_not_found_marks_available_medium() {
716 let rdap_err = SeerError::RdapError("500".to_string());
717 let whois_err = SeerError::WhoisError("Domain not found".to_string());
718 let r = decide_fallback(
719 "example.test",
720 &rdap_err,
721 Err(whois_err),
722 DnsPresence::Unknown,
723 );
724 assert!(r.available);
725 assert_eq!(r.confidence, "medium");
726 assert_eq!(r.method, "whois_error");
727 }
728
729 #[test]
730 fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
731 let rdap_err = SeerError::RdapError("no".to_string());
732 let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
733 let r = decide_fallback(
734 "example.test",
735 &rdap_err,
736 Err(whois_err),
737 DnsPresence::Unknown,
738 );
739 assert!(r.available);
740 assert_eq!(r.confidence, "medium");
741 }
742
743 #[test]
744 fn rdap_fail_whois_error_no_entries_marks_available_medium() {
745 let rdap_err = SeerError::RdapError("no".to_string());
746 let whois_err =
747 SeerError::WhoisError("No entries found for the selected source".to_string());
748 let r = decide_fallback(
749 "example.test",
750 &rdap_err,
751 Err(whois_err),
752 DnsPresence::Unknown,
753 );
754 assert!(r.available);
755 assert_eq!(r.confidence, "medium");
756 }
757
758 #[test]
759 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
760 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
761 let whois_err = SeerError::Timeout("whois timed out".to_string());
762 let r = decide_fallback(
763 "example.test",
764 &rdap_err,
765 Err(whois_err),
766 DnsPresence::Unknown,
767 );
768 assert!(
769 !r.available,
770 "inconclusive means NOT available (fail-safe default)"
771 );
772 assert_eq!(r.confidence, "none");
773 assert_eq!(r.method, "inconclusive");
774 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
775 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
776 }
777
778 #[test]
779 fn rdap_fail_whois_transport_error_with_phrase_not_available() {
780 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
786 let whois_err =
787 SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
788 let r = decide_fallback(
789 "example.test",
790 &rdap_err,
791 Err(whois_err),
792 DnsPresence::Present,
793 );
794 assert!(
795 !r.available,
796 "transport error text must not infer availability"
797 );
798 }
799
800 #[test]
801 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
802 let rdap_err = SeerError::RdapError("connection refused".to_string());
803 let whois_err = SeerError::WhoisError(
804 "failed to connect to whois.example: connection refused".to_string(),
805 );
806 let r = decide_fallback(
807 "example.test",
808 &rdap_err,
809 Err(whois_err),
810 DnsPresence::Unknown,
811 );
812 assert!(!r.available);
813 assert_eq!(r.confidence, "none");
814 assert_eq!(r.method, "inconclusive");
815 }
816
817 #[test]
818 fn rdap_fail_whois_error_case_insensitive_not_found() {
819 let rdap_err = SeerError::RdapError("500".to_string());
822 let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
823 let r = decide_fallback(
824 "example.test",
825 &rdap_err,
826 Err(whois_err),
827 DnsPresence::Unknown,
828 );
829 assert!(r.available, "'NOT FOUND' should classify as available");
830 assert_eq!(r.confidence, "medium");
831 }
832
833 #[test]
836 fn rdap_404_with_blocked_whois_marks_available() {
837 let whois = whois_with("Requests of this client are not permitted.\n", None);
842 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
843 let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
844 assert!(
845 r.available,
846 "RDAP 404 must mark available even with blocked WHOIS"
847 );
848 assert_eq!(r.confidence, "high");
849 assert_eq!(r.method, "rdap");
850 }
851
852 #[test]
853 fn rdap_404_with_whois_error_marks_available() {
854 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
856 let whois_err = SeerError::WhoisError("connection refused".to_string());
857 let r = decide_fallback(
858 "example.test",
859 &rdap_err,
860 Err(whois_err),
861 DnsPresence::Unknown,
862 );
863 assert!(r.available);
864 assert_eq!(r.confidence, "high");
865 assert_eq!(r.method, "rdap");
866 }
867
868 #[test]
869 fn rdap_404_but_whois_has_full_registration_marks_registered() {
870 let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
874 whois.creation_date = Some(chrono::Utc::now());
875 whois.nameservers = vec!["ns1.example.net".to_string()];
876 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
877 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
878 assert!(
879 !r.available,
880 "concrete WHOIS registration must win over RDAP 404"
881 );
882 assert_eq!(r.confidence, "high");
883 assert_eq!(r.method, "whois");
884 }
885
886 #[test]
889 fn thin_whois_non404_dns_absent_marks_likely_available() {
890 let whois = whois_with(
894 "Conditions of use for the whois service via port 43\n",
895 None,
896 );
897 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
898 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
899 assert!(r.available);
900 assert_eq!(r.confidence, "medium");
901 assert_eq!(r.method, "dns_nxdomain");
902 }
903
904 #[test]
905 fn thin_whois_non404_dns_present_stays_unavailable() {
906 let whois = whois_with(
909 "Conditions of use for the whois service via port 43\n",
910 None,
911 );
912 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
913 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
914 assert!(!r.available);
915 assert_ne!(r.method, "dns_nxdomain");
916 }
917
918 #[test]
919 fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
920 let whois = whois_with(
924 "Conditions of use for the whois service via port 43\n",
925 None,
926 );
927 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
928 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
929 assert!(!r.available);
930 }
931
932 #[test]
933 fn both_legs_failed_dns_absent_marks_likely_available() {
934 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
937 let whois_err = SeerError::WhoisError("connection refused".to_string());
938 let r = decide_fallback(
939 "example.test",
940 &rdap_err,
941 Err(whois_err),
942 DnsPresence::Absent,
943 );
944 assert!(r.available);
945 assert_eq!(r.confidence, "medium");
946 assert_eq!(r.method, "dns_nxdomain");
947 }
948
949 #[test]
950 fn both_legs_failed_dns_present_marks_likely_registered() {
951 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.ru".to_string());
957 let whois_err = SeerError::Timeout(
958 "Operation failed after 3 attempts: Operation timed out".to_string(),
959 );
960 let r = decide_fallback(
961 "example.ru",
962 &rdap_err,
963 Err(whois_err),
964 DnsPresence::Present,
965 );
966 assert!(
967 !r.available,
968 "a delegated apex must never read as available"
969 );
970 assert_eq!(r.confidence, "medium");
971 assert_eq!(r.method, "dns_present");
972 assert_eq!(r.verdict(), "likely_registered");
973 assert!(
974 r.details.as_deref().unwrap().contains("delegated"),
975 "details should explain the DNS-delegation evidence"
976 );
977 }
978
979 #[test]
982 fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
983 let whois = whois_with(
988 "Access rate limited; no data found for unauthenticated clients\n",
989 None,
990 );
991 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
992 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
993 assert!(
994 !r.available,
995 "refusal/throttle must not be called available"
996 );
997 assert_eq!(r.confidence, "none");
998 assert_eq!(r.method, "inconclusive");
999 }
1000
1001 #[test]
1002 fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
1003 let whois = whois_with(
1007 "This domain name is not available for registration.\n",
1008 None,
1009 );
1010 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
1011 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
1012 assert!(!r.available);
1013 assert_eq!(r.confidence, "none");
1014 assert_eq!(r.method, "inconclusive");
1015 }
1016
1017 #[test]
1018 fn rdap_404_with_refusal_whois_still_marks_available() {
1019 let whois = whois_with("Access rate limited; please try again later.\n", None);
1022 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1023 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
1024 assert!(r.available, "RDAP 404 is authoritative over a refusal body");
1025 assert_eq!(r.confidence, "high");
1026 assert_eq!(r.method, "rdap");
1027 }
1028
1029 #[tokio::test]
1032 async fn check_with_prior_reuses_rdap_response_without_network() {
1033 let checker = AvailabilityChecker::new();
1038 let rdap = rdap_with(&["active"]);
1039 let r = checker
1040 .check_with_prior(
1041 "example.test",
1042 PriorRdap::Response(Box::new(rdap)),
1043 PriorWhois::Missing,
1044 )
1045 .await
1046 .expect("prior-response path must not error");
1047 assert!(!r.available, "an existing RDAP object means registered");
1048 assert_eq!(r.confidence, "high");
1049 assert_eq!(r.method, "rdap");
1050 }
1051
1052 #[tokio::test]
1053 async fn check_with_prior_reuses_redemption_status_verdict() {
1054 let checker = AvailabilityChecker::new();
1057 let rdap = rdap_with(&["redemption period"]);
1058 let r = checker
1059 .check_with_prior(
1060 "example.test",
1061 PriorRdap::Response(Box::new(rdap)),
1062 PriorWhois::Missing,
1063 )
1064 .await
1065 .expect("prior-response path must not error");
1066 assert!(!r.available);
1067 assert_eq!(r.confidence, "medium");
1068 assert_eq!(r.method, "rdap");
1069 }
1070
1071 fn denic_whois() -> WhoisResponse {
1076 let mut w = whois_with(
1077 "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n",
1078 None,
1079 );
1080 w.nameservers = vec!["ns1.example.net".to_string()];
1081 w.status = vec!["active".to_string()];
1082 w
1083 }
1084
1085 #[test]
1086 fn whois_with_nameservers_is_not_thin() {
1087 assert!(whois_is_thin(&whois_with("", None)));
1088 assert!(!whois_is_thin(&denic_whois()));
1089 }
1090
1091 #[test]
1092 fn denic_style_whois_without_registrar_or_dates_is_registered() {
1093 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
1097 let r = decide_fallback(
1098 "example.de",
1099 &rdap_err,
1100 Ok(denic_whois()),
1101 DnsPresence::Absent,
1102 );
1103 assert!(!r.available, "a delegated DENIC record is registered");
1104 assert_eq!(r.confidence, "high");
1105 assert_eq!(r.method, "whois");
1106 }
1107
1108 fn avail(domain: &str, available: bool, confidence: &str) -> AvailabilityResult {
1111 AvailabilityResult {
1112 domain: domain.to_string(),
1113 available,
1114 confidence: confidence.to_string(),
1115 method: "rdap".to_string(),
1116 details: None,
1117 }
1118 }
1119
1120 #[test]
1121 fn subdomain_under_registered_parent_is_registered_not_available() {
1122 let r = subdomain_verdict("mail.google.com", &avail("google.com", false, "high"));
1123 assert!(!r.available);
1124 assert_eq!(r.domain, "mail.google.com");
1125 assert_eq!(r.verdict(), "registered");
1126 assert_eq!(r.method, "registrable_parent");
1127 let details = r.details.unwrap();
1128 assert!(details.contains("google.com is registered"), "{details}");
1129 }
1130
1131 #[test]
1132 fn subdomain_under_available_parent_is_never_claimed_available() {
1133 let r = subdomain_verdict("mail.freebrand.com", &avail("freebrand.com", true, "high"));
1134 assert!(!r.available, "a subdomain itself is never registrable");
1135 assert_eq!(r.verdict(), "unknown");
1136 assert!(r.details.unwrap().contains("register freebrand.com"));
1137
1138 let r = subdomain_verdict("a.b.co.uk", &avail("b.co.uk", false, "none"));
1139 assert!(!r.available);
1140 assert_eq!(r.verdict(), "unknown");
1141 assert!(r.details.unwrap().contains("could not be determined"));
1142 }
1143
1144 #[tokio::test]
1145 async fn subdomain_guard_leaves_registrable_names_and_negative_results_alone() {
1146 let checker = AvailabilityChecker::new();
1149 let r = checker
1150 .guard_subdomain_claim(avail("example.co.uk", true, "high"))
1151 .await;
1152 assert!(r.available);
1153 assert_eq!(r.method, "rdap");
1154
1155 let r = checker
1156 .guard_subdomain_claim(avail("mail.example.com", false, "high"))
1157 .await;
1158 assert!(!r.available);
1159 assert_eq!(r.method, "rdap");
1160 }
1161}