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
44#[derive(Debug, Clone)]
46pub struct AvailabilityChecker {
47 rdap_client: RdapClient,
48 whois_client: WhoisClient,
49 dns_resolver: DnsResolver,
50}
51
52impl Default for AvailabilityChecker {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58impl AvailabilityChecker {
59 pub fn new() -> Self {
60 Self {
61 rdap_client: RdapClient::new(),
62 whois_client: WhoisClient::new(),
63 dns_resolver: DnsResolver::new(),
64 }
65 }
66
67 #[instrument(skip(self), fields(domain = %domain))]
69 pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
70 let domain = crate::validation::normalize_domain(domain)?;
71 debug!(domain = %domain, "Checking domain availability");
72
73 match self.rdap_client.lookup_domain(&domain).await {
75 Ok(response) => Ok(decide_from_rdap(&domain, response)),
76 Err(rdap_err) => {
77 debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS + DNS");
78 let (whois_result, dns_presence) = tokio::join!(
83 self.whois_client.lookup(&domain),
84 self.dns_resolver.presence(&domain),
85 );
86 Ok(decide_fallback(
87 &domain,
88 &rdap_err,
89 whois_result,
90 dns_presence,
91 ))
92 }
93 }
94 }
95}
96
97fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
101 let statuses: Vec<String> = response.status.clone();
102 let is_redemption = statuses.iter().any(|s| {
103 let norm: String = s
110 .chars()
111 .filter(|c| !c.is_whitespace())
112 .collect::<String>()
113 .to_lowercase();
114 matches!(norm.as_str(), "redemptionperiod" | "pendingdelete")
115 });
116
117 if is_redemption {
118 return AvailabilityResult {
119 domain: domain.to_string(),
120 available: false,
121 confidence: "medium".to_string(),
122 method: "rdap".to_string(),
123 details: Some("Domain is in redemption/pending delete period".to_string()),
124 };
125 }
126
127 AvailabilityResult {
128 domain: domain.to_string(),
129 available: false,
130 confidence: "high".to_string(),
131 method: "rdap".to_string(),
132 details: Some(format!(
133 "Domain is registered (status: {})",
134 statuses.join(", ")
135 )),
136 }
137}
138
139fn decide_fallback(
146 domain: &str,
147 rdap_err: &crate::error::SeerError,
148 whois_result: Result<crate::whois::WhoisResponse>,
149 dns_presence: DnsPresence,
150) -> AvailabilityResult {
151 match whois_result {
152 Ok(whois_response) => {
153 let thin = whois_response.registrar.is_none()
157 && whois_response.creation_date.is_none()
158 && whois_response.expiration_date.is_none();
159
160 if whois_response.is_available() {
161 AvailabilityResult {
162 domain: domain.to_string(),
163 available: true,
164 confidence: "high".to_string(),
165 method: "whois".to_string(),
166 details: Some("WHOIS indicates domain is not registered".to_string()),
167 }
168 } else if !thin {
169 AvailabilityResult {
172 domain: domain.to_string(),
173 available: false,
174 confidence: "high".to_string(),
175 method: "whois".to_string(),
176 details: whois_response
177 .registrar
178 .map(|r| format!("Registered with {}", r)),
179 }
180 } else if rdap_error_is_404(rdap_err) {
181 AvailabilityResult {
184 domain: domain.to_string(),
185 available: true,
186 confidence: "high".to_string(),
187 method: "rdap".to_string(),
188 details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
189 }
190 } else if whois_response.indicates_registry_refusal() {
191 AvailabilityResult {
198 domain: domain.to_string(),
199 available: false,
200 confidence: "none".to_string(),
201 method: "inconclusive".to_string(),
202 details: Some(
203 "Registry refused or throttled the query; availability is inconclusive"
204 .to_string(),
205 ),
206 }
207 } else if dns_presence == DnsPresence::Absent {
208 AvailabilityResult {
211 domain: domain.to_string(),
212 available: true,
213 confidence: "medium".to_string(),
214 method: "dns_nxdomain".to_string(),
215 details: Some(
216 "No registry data available; domain has no DNS presence (NXDOMAIN)"
217 .to_string(),
218 ),
219 }
220 } else {
221 AvailabilityResult {
224 domain: domain.to_string(),
225 available: false,
226 confidence: "high".to_string(),
227 method: "whois".to_string(),
228 details: None,
229 }
230 }
231 }
232 Err(whois_err) => {
233 if rdap_error_is_404(rdap_err) {
237 return AvailabilityResult {
238 domain: domain.to_string(),
239 available: true,
240 confidence: "high".to_string(),
241 method: "rdap".to_string(),
242 details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
243 };
244 }
245 let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
251 let whois_msg = whois_err.to_string().to_lowercase();
252 whois_msg.contains("no match")
253 || whois_msg.contains("not found")
254 || whois_msg.contains("no data found")
255 || whois_msg.contains("no entries found")
256 };
257
258 if likely_available {
259 AvailabilityResult {
260 domain: domain.to_string(),
261 available: true,
262 confidence: "medium".to_string(),
263 method: "whois_error".to_string(),
264 details: Some("WHOIS server indicates no matching records".to_string()),
265 }
266 } else if dns_presence == DnsPresence::Absent {
267 AvailabilityResult {
270 domain: domain.to_string(),
271 available: true,
272 confidence: "medium".to_string(),
273 method: "dns_nxdomain".to_string(),
274 details: Some(
275 "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
276 .to_string(),
277 ),
278 }
279 } else {
280 AvailabilityResult {
286 domain: domain.to_string(),
287 available: false,
288 confidence: "none".to_string(),
289 method: "inconclusive".to_string(),
290 details: Some(format!(
295 "Could not determine availability. RDAP: {}. WHOIS: {}",
296 rdap_err.sanitized_message(),
297 whois_err.sanitized_message()
298 )),
299 }
300 }
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::error::SeerError;
309 use crate::rdap::RdapResponse;
310 use crate::whois::WhoisResponse;
311
312 #[test]
313 fn verdict_matrix() {
314 let make = |available, confidence: &str| AvailabilityResult {
315 domain: "example.test".to_string(),
316 available,
317 confidence: confidence.to_string(),
318 method: "whois".to_string(),
319 details: None,
320 };
321 assert_eq!(make(true, "high").verdict(), "available");
322 assert_eq!(make(true, "medium").verdict(), "likely_available");
323 assert_eq!(make(false, "high").verdict(), "registered");
324 assert_eq!(make(false, "medium").verdict(), "likely_registered");
325 assert_eq!(make(false, "none").verdict(), "unknown");
326 assert_eq!(make(true, "low").verdict(), "unknown");
327 }
328
329 #[test]
330 fn test_availability_result_serialization() {
331 let result = AvailabilityResult {
332 domain: "example.com".to_string(),
333 available: false,
334 confidence: "high".to_string(),
335 method: "rdap".to_string(),
336 details: Some("Domain is registered".to_string()),
337 };
338 let json = serde_json::to_string(&result).unwrap();
339 assert!(json.contains("\"available\":false"));
340 assert!(json.contains("\"confidence\":\"high\""));
341 }
342
343 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
355 WhoisResponse {
356 domain: "example.test".to_string(),
357 registrar: registrar.map(str::to_string),
358 registrant: None,
359 organization: None,
360 registrant_email: None,
361 registrant_phone: None,
362 registrant_address: None,
363 registrant_country: None,
364 admin_name: None,
365 admin_organization: None,
366 admin_email: None,
367 admin_phone: None,
368 tech_name: None,
369 tech_organization: None,
370 tech_email: None,
371 tech_phone: None,
372 creation_date: None,
373 expiration_date: None,
374 updated_date: None,
375 nameservers: vec![],
376 status: vec![],
377 dnssec: None,
378 whois_server: "whois.test".to_string(),
379 raw_response: raw.to_string(),
380 }
381 }
382
383 fn rdap_with(statuses: &[&str]) -> RdapResponse {
384 RdapResponse {
385 status: statuses.iter().map(|s| s.to_string()).collect(),
386 ldh_name: Some("example.test".to_string()),
387 ..Default::default()
388 }
389 }
390
391 #[test]
394 fn rdap_success_registered_marks_taken_high_confidence() {
395 let rdap = rdap_with(&["active"]);
396 let r = decide_from_rdap("example.test", rdap);
397 assert!(!r.available, "registered domain must be marked taken");
398 assert_eq!(r.confidence, "high");
399 assert_eq!(r.method, "rdap");
400 assert!(
401 r.details.as_deref().unwrap().contains("active"),
402 "details should include status list"
403 );
404 }
405
406 #[test]
407 fn rdap_success_empty_status_marks_taken_high_confidence() {
408 let rdap = rdap_with(&[]);
411 let r = decide_from_rdap("example.test", rdap);
412 assert!(!r.available);
413 assert_eq!(r.confidence, "high");
414 assert_eq!(r.method, "rdap");
415 }
416
417 #[test]
418 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
419 let rdap = rdap_with(&["redemption period"]);
420 let r = decide_from_rdap("example.test", rdap);
421 assert!(!r.available, "redemption period still means taken");
422 assert_eq!(r.confidence, "medium", "redemption drops confidence");
423 assert_eq!(r.method, "rdap");
424 assert!(r.details.as_deref().unwrap().contains("redemption"));
425 }
426
427 #[test]
428 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
429 let rdap = rdap_with(&["pending delete"]);
430 let r = decide_from_rdap("example.test", rdap);
431 assert!(!r.available);
432 assert_eq!(r.confidence, "medium");
433 assert!(r.details.as_deref().unwrap().contains("redemption"));
434 }
435
436 #[test]
437 fn rdap_status_substring_redemption_not_misclassified() {
438 let rdap = rdap_with(&["clientHold (no redemption requested)"]);
442 let r = decide_from_rdap("example.test", rdap);
443 assert!(!r.available, "still registered");
444 assert_eq!(
445 r.confidence, "high",
446 "verbose status must not be downgraded to redemption/medium"
447 );
448 }
449
450 #[test]
451 fn rdap_status_redemption_detected_case_insensitively() {
452 let rdap = rdap_with(&["Redemption Period"]);
455 let r = decide_from_rdap("example.test", rdap);
456 assert_eq!(
457 r.confidence, "medium",
458 "standard token detected regardless of case"
459 );
460 }
461
462 #[test]
465 fn rdap_fail_whois_says_available_high_confidence() {
466 let whois = whois_with("No match for \"example.test\".\n", None);
469 let rdap_err = SeerError::RdapError("404 not found".to_string());
470 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
471 assert!(r.available, "WHOIS 'no match' must mark available");
472 assert_eq!(r.confidence, "high");
473 assert_eq!(r.method, "whois");
474 }
475
476 #[test]
477 fn rdap_fail_whois_says_registered_high_confidence() {
478 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
479 let rdap_err = SeerError::RdapError("404 not found".to_string());
480 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
481 assert!(!r.available);
482 assert_eq!(r.confidence, "high");
483 assert_eq!(r.method, "whois");
484 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
485 }
486
487 #[test]
488 fn rdap_fail_whois_registered_without_registrar_no_detail() {
489 let whois = whois_with("Domain Name: example.test\n", None);
492 let rdap_err = SeerError::RdapError("404".to_string());
493 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
494 assert!(!r.available);
495 assert_eq!(r.confidence, "high");
496 assert!(
497 r.details.is_none(),
498 "no registrar means no details string, got: {:?}",
499 r.details
500 );
501 }
502
503 #[test]
506 fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
507 let rdap_err = SeerError::RdapError("500".to_string());
508 let whois_err =
509 SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
510 let r = decide_fallback(
511 "example.test",
512 &rdap_err,
513 Err(whois_err),
514 DnsPresence::Unknown,
515 );
516 assert!(
517 r.available,
518 "whois error containing 'no match' is available"
519 );
520 assert_eq!(r.confidence, "medium");
521 assert_eq!(r.method, "whois_error");
522 }
523
524 #[test]
525 fn rdap_fail_whois_error_not_found_marks_available_medium() {
526 let rdap_err = SeerError::RdapError("500".to_string());
527 let whois_err = SeerError::WhoisError("Domain not found".to_string());
528 let r = decide_fallback(
529 "example.test",
530 &rdap_err,
531 Err(whois_err),
532 DnsPresence::Unknown,
533 );
534 assert!(r.available);
535 assert_eq!(r.confidence, "medium");
536 assert_eq!(r.method, "whois_error");
537 }
538
539 #[test]
540 fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
541 let rdap_err = SeerError::RdapError("no".to_string());
542 let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
543 let r = decide_fallback(
544 "example.test",
545 &rdap_err,
546 Err(whois_err),
547 DnsPresence::Unknown,
548 );
549 assert!(r.available);
550 assert_eq!(r.confidence, "medium");
551 }
552
553 #[test]
554 fn rdap_fail_whois_error_no_entries_marks_available_medium() {
555 let rdap_err = SeerError::RdapError("no".to_string());
556 let whois_err =
557 SeerError::WhoisError("No entries found for the selected source".to_string());
558 let r = decide_fallback(
559 "example.test",
560 &rdap_err,
561 Err(whois_err),
562 DnsPresence::Unknown,
563 );
564 assert!(r.available);
565 assert_eq!(r.confidence, "medium");
566 }
567
568 #[test]
569 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
570 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
571 let whois_err = SeerError::Timeout("whois timed out".to_string());
572 let r = decide_fallback(
573 "example.test",
574 &rdap_err,
575 Err(whois_err),
576 DnsPresence::Unknown,
577 );
578 assert!(
579 !r.available,
580 "inconclusive means NOT available (fail-safe default)"
581 );
582 assert_eq!(r.confidence, "none");
583 assert_eq!(r.method, "inconclusive");
584 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
585 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
586 }
587
588 #[test]
589 fn rdap_fail_whois_transport_error_with_phrase_not_available() {
590 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
596 let whois_err =
597 SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
598 let r = decide_fallback(
599 "example.test",
600 &rdap_err,
601 Err(whois_err),
602 DnsPresence::Present,
603 );
604 assert!(
605 !r.available,
606 "transport error text must not infer availability"
607 );
608 }
609
610 #[test]
611 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
612 let rdap_err = SeerError::RdapError("connection refused".to_string());
613 let whois_err = SeerError::WhoisError(
614 "failed to connect to whois.example: connection refused".to_string(),
615 );
616 let r = decide_fallback(
617 "example.test",
618 &rdap_err,
619 Err(whois_err),
620 DnsPresence::Unknown,
621 );
622 assert!(!r.available);
623 assert_eq!(r.confidence, "none");
624 assert_eq!(r.method, "inconclusive");
625 }
626
627 #[test]
628 fn rdap_fail_whois_error_case_insensitive_not_found() {
629 let rdap_err = SeerError::RdapError("500".to_string());
632 let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
633 let r = decide_fallback(
634 "example.test",
635 &rdap_err,
636 Err(whois_err),
637 DnsPresence::Unknown,
638 );
639 assert!(r.available, "'NOT FOUND' should classify as available");
640 assert_eq!(r.confidence, "medium");
641 }
642
643 #[test]
646 fn rdap_404_with_blocked_whois_marks_available() {
647 let whois = whois_with("Requests of this client are not permitted.\n", None);
652 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
653 let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
654 assert!(
655 r.available,
656 "RDAP 404 must mark available even with blocked WHOIS"
657 );
658 assert_eq!(r.confidence, "high");
659 assert_eq!(r.method, "rdap");
660 }
661
662 #[test]
663 fn rdap_404_with_whois_error_marks_available() {
664 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
666 let whois_err = SeerError::WhoisError("connection refused".to_string());
667 let r = decide_fallback(
668 "example.test",
669 &rdap_err,
670 Err(whois_err),
671 DnsPresence::Unknown,
672 );
673 assert!(r.available);
674 assert_eq!(r.confidence, "high");
675 assert_eq!(r.method, "rdap");
676 }
677
678 #[test]
679 fn rdap_404_but_whois_has_full_registration_marks_registered() {
680 let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
684 whois.creation_date = Some(chrono::Utc::now());
685 whois.nameservers = vec!["ns1.example.net".to_string()];
686 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
687 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
688 assert!(
689 !r.available,
690 "concrete WHOIS registration must win over RDAP 404"
691 );
692 assert_eq!(r.confidence, "high");
693 assert_eq!(r.method, "whois");
694 }
695
696 #[test]
699 fn thin_whois_non404_dns_absent_marks_likely_available() {
700 let whois = whois_with(
704 "Conditions of use for the whois service via port 43\n",
705 None,
706 );
707 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
708 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
709 assert!(r.available);
710 assert_eq!(r.confidence, "medium");
711 assert_eq!(r.method, "dns_nxdomain");
712 }
713
714 #[test]
715 fn thin_whois_non404_dns_present_stays_unavailable() {
716 let whois = whois_with(
719 "Conditions of use for the whois service via port 43\n",
720 None,
721 );
722 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
723 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
724 assert!(!r.available);
725 assert_ne!(r.method, "dns_nxdomain");
726 }
727
728 #[test]
729 fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
730 let whois = whois_with(
734 "Conditions of use for the whois service via port 43\n",
735 None,
736 );
737 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
738 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
739 assert!(!r.available);
740 }
741
742 #[test]
743 fn both_legs_failed_dns_absent_marks_likely_available() {
744 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
747 let whois_err = SeerError::WhoisError("connection refused".to_string());
748 let r = decide_fallback(
749 "example.test",
750 &rdap_err,
751 Err(whois_err),
752 DnsPresence::Absent,
753 );
754 assert!(r.available);
755 assert_eq!(r.confidence, "medium");
756 assert_eq!(r.method, "dns_nxdomain");
757 }
758
759 #[test]
762 fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
763 let whois = whois_with(
768 "Access rate limited; no data found for unauthenticated clients\n",
769 None,
770 );
771 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
772 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
773 assert!(
774 !r.available,
775 "refusal/throttle must not be called available"
776 );
777 assert_eq!(r.confidence, "none");
778 assert_eq!(r.method, "inconclusive");
779 }
780
781 #[test]
782 fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
783 let whois = whois_with(
787 "This domain name is not available for registration.\n",
788 None,
789 );
790 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
791 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
792 assert!(!r.available);
793 assert_eq!(r.confidence, "none");
794 assert_eq!(r.method, "inconclusive");
795 }
796
797 #[test]
798 fn rdap_404_with_refusal_whois_still_marks_available() {
799 let whois = whois_with("Access rate limited; please try again later.\n", None);
802 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
803 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
804 assert!(r.available, "RDAP 404 is authoritative over a refusal body");
805 assert_eq!(r.confidence, "high");
806 assert_eq!(r.method, "rdap");
807 }
808}