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 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
69 Self {
70 rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
71 whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
72 dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
73 }
74 }
75
76 #[instrument(skip(self), fields(domain = %domain))]
78 pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
79 let domain = crate::validation::normalize_domain(domain)?;
80 debug!(domain = %domain, "Checking domain availability");
81
82 match self.rdap_client.lookup_domain(&domain).await {
84 Ok(response) => Ok(decide_from_rdap(&domain, response)),
85 Err(rdap_err) => {
86 debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS + DNS");
87 let (whois_result, dns_presence) = tokio::join!(
92 self.whois_client.lookup(&domain),
93 self.dns_resolver.presence(&domain),
94 );
95 Ok(decide_fallback(
96 &domain,
97 &rdap_err,
98 whois_result,
99 dns_presence,
100 ))
101 }
102 }
103 }
104}
105
106fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
110 let statuses: Vec<String> = response.status.clone();
111 let is_redemption = statuses.iter().any(|s| {
112 let norm: String = s
119 .chars()
120 .filter(|c| !c.is_whitespace())
121 .collect::<String>()
122 .to_lowercase();
123 matches!(norm.as_str(), "redemptionperiod" | "pendingdelete")
124 });
125
126 if is_redemption {
127 return AvailabilityResult {
128 domain: domain.to_string(),
129 available: false,
130 confidence: "medium".to_string(),
131 method: "rdap".to_string(),
132 details: Some("Domain is in redemption/pending delete period".to_string()),
133 };
134 }
135
136 AvailabilityResult {
137 domain: domain.to_string(),
138 available: false,
139 confidence: "high".to_string(),
140 method: "rdap".to_string(),
141 details: Some(format!(
142 "Domain is registered (status: {})",
143 statuses.join(", ")
144 )),
145 }
146}
147
148fn decide_fallback(
155 domain: &str,
156 rdap_err: &crate::error::SeerError,
157 whois_result: Result<crate::whois::WhoisResponse>,
158 dns_presence: DnsPresence,
159) -> AvailabilityResult {
160 match whois_result {
161 Ok(whois_response) => {
162 let thin = whois_response.registrar.is_none()
166 && whois_response.creation_date.is_none()
167 && whois_response.expiration_date.is_none();
168
169 if whois_response.is_available() {
170 AvailabilityResult {
171 domain: domain.to_string(),
172 available: true,
173 confidence: "high".to_string(),
174 method: "whois".to_string(),
175 details: Some("WHOIS indicates domain is not registered".to_string()),
176 }
177 } else if !thin {
178 AvailabilityResult {
181 domain: domain.to_string(),
182 available: false,
183 confidence: "high".to_string(),
184 method: "whois".to_string(),
185 details: whois_response
186 .registrar
187 .map(|r| format!("Registered with {}", r)),
188 }
189 } else if rdap_error_is_404(rdap_err) {
190 AvailabilityResult {
193 domain: domain.to_string(),
194 available: true,
195 confidence: "high".to_string(),
196 method: "rdap".to_string(),
197 details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
198 }
199 } else if whois_response.indicates_registry_refusal() {
200 AvailabilityResult {
207 domain: domain.to_string(),
208 available: false,
209 confidence: "none".to_string(),
210 method: "inconclusive".to_string(),
211 details: Some(
212 "Registry refused or throttled the query; availability is inconclusive"
213 .to_string(),
214 ),
215 }
216 } else if dns_presence == DnsPresence::Absent {
217 AvailabilityResult {
220 domain: domain.to_string(),
221 available: true,
222 confidence: "medium".to_string(),
223 method: "dns_nxdomain".to_string(),
224 details: Some(
225 "No registry data available; domain has no DNS presence (NXDOMAIN)"
226 .to_string(),
227 ),
228 }
229 } else {
230 AvailabilityResult {
233 domain: domain.to_string(),
234 available: false,
235 confidence: "high".to_string(),
236 method: "whois".to_string(),
237 details: None,
238 }
239 }
240 }
241 Err(whois_err) => {
242 if rdap_error_is_404(rdap_err) {
246 return AvailabilityResult {
247 domain: domain.to_string(),
248 available: true,
249 confidence: "high".to_string(),
250 method: "rdap".to_string(),
251 details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
252 };
253 }
254 let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
260 let whois_msg = whois_err.to_string().to_lowercase();
261 whois_msg.contains("no match")
262 || whois_msg.contains("not found")
263 || whois_msg.contains("no data found")
264 || whois_msg.contains("no entries found")
265 };
266
267 if likely_available {
268 AvailabilityResult {
269 domain: domain.to_string(),
270 available: true,
271 confidence: "medium".to_string(),
272 method: "whois_error".to_string(),
273 details: Some("WHOIS server indicates no matching records".to_string()),
274 }
275 } else if dns_presence == DnsPresence::Absent {
276 AvailabilityResult {
279 domain: domain.to_string(),
280 available: true,
281 confidence: "medium".to_string(),
282 method: "dns_nxdomain".to_string(),
283 details: Some(
284 "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
285 .to_string(),
286 ),
287 }
288 } else {
289 AvailabilityResult {
295 domain: domain.to_string(),
296 available: false,
297 confidence: "none".to_string(),
298 method: "inconclusive".to_string(),
299 details: Some(format!(
304 "Could not determine availability. RDAP: {}. WHOIS: {}",
305 rdap_err.sanitized_message(),
306 whois_err.sanitized_message()
307 )),
308 }
309 }
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::error::SeerError;
318 use crate::rdap::RdapResponse;
319 use crate::whois::WhoisResponse;
320
321 #[test]
322 fn verdict_matrix() {
323 let make = |available, confidence: &str| AvailabilityResult {
324 domain: "example.test".to_string(),
325 available,
326 confidence: confidence.to_string(),
327 method: "whois".to_string(),
328 details: None,
329 };
330 assert_eq!(make(true, "high").verdict(), "available");
331 assert_eq!(make(true, "medium").verdict(), "likely_available");
332 assert_eq!(make(false, "high").verdict(), "registered");
333 assert_eq!(make(false, "medium").verdict(), "likely_registered");
334 assert_eq!(make(false, "none").verdict(), "unknown");
335 assert_eq!(make(true, "low").verdict(), "unknown");
336 }
337
338 #[test]
339 fn test_availability_result_serialization() {
340 let result = AvailabilityResult {
341 domain: "example.com".to_string(),
342 available: false,
343 confidence: "high".to_string(),
344 method: "rdap".to_string(),
345 details: Some("Domain is registered".to_string()),
346 };
347 let json = serde_json::to_string(&result).unwrap();
348 assert!(json.contains("\"available\":false"));
349 assert!(json.contains("\"confidence\":\"high\""));
350 }
351
352 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
364 WhoisResponse {
365 domain: "example.test".to_string(),
366 registrar: registrar.map(str::to_string),
367 registrant: None,
368 organization: None,
369 registrant_email: None,
370 registrant_phone: None,
371 registrant_address: None,
372 registrant_country: None,
373 admin_name: None,
374 admin_organization: None,
375 admin_email: None,
376 admin_phone: None,
377 tech_name: None,
378 tech_organization: None,
379 tech_email: None,
380 tech_phone: None,
381 creation_date: None,
382 expiration_date: None,
383 updated_date: None,
384 nameservers: vec![],
385 status: vec![],
386 dnssec: None,
387 whois_server: "whois.test".to_string(),
388 raw_response: raw.to_string(),
389 }
390 }
391
392 fn rdap_with(statuses: &[&str]) -> RdapResponse {
393 RdapResponse {
394 status: statuses.iter().map(|s| s.to_string()).collect(),
395 ldh_name: Some("example.test".to_string()),
396 ..Default::default()
397 }
398 }
399
400 #[test]
403 fn rdap_success_registered_marks_taken_high_confidence() {
404 let rdap = rdap_with(&["active"]);
405 let r = decide_from_rdap("example.test", rdap);
406 assert!(!r.available, "registered domain must be marked taken");
407 assert_eq!(r.confidence, "high");
408 assert_eq!(r.method, "rdap");
409 assert!(
410 r.details.as_deref().unwrap().contains("active"),
411 "details should include status list"
412 );
413 }
414
415 #[test]
416 fn rdap_success_empty_status_marks_taken_high_confidence() {
417 let rdap = rdap_with(&[]);
420 let r = decide_from_rdap("example.test", rdap);
421 assert!(!r.available);
422 assert_eq!(r.confidence, "high");
423 assert_eq!(r.method, "rdap");
424 }
425
426 #[test]
427 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
428 let rdap = rdap_with(&["redemption period"]);
429 let r = decide_from_rdap("example.test", rdap);
430 assert!(!r.available, "redemption period still means taken");
431 assert_eq!(r.confidence, "medium", "redemption drops confidence");
432 assert_eq!(r.method, "rdap");
433 assert!(r.details.as_deref().unwrap().contains("redemption"));
434 }
435
436 #[test]
437 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
438 let rdap = rdap_with(&["pending delete"]);
439 let r = decide_from_rdap("example.test", rdap);
440 assert!(!r.available);
441 assert_eq!(r.confidence, "medium");
442 assert!(r.details.as_deref().unwrap().contains("redemption"));
443 }
444
445 #[test]
446 fn rdap_status_substring_redemption_not_misclassified() {
447 let rdap = rdap_with(&["clientHold (no redemption requested)"]);
451 let r = decide_from_rdap("example.test", rdap);
452 assert!(!r.available, "still registered");
453 assert_eq!(
454 r.confidence, "high",
455 "verbose status must not be downgraded to redemption/medium"
456 );
457 }
458
459 #[test]
460 fn rdap_status_redemption_detected_case_insensitively() {
461 let rdap = rdap_with(&["Redemption Period"]);
464 let r = decide_from_rdap("example.test", rdap);
465 assert_eq!(
466 r.confidence, "medium",
467 "standard token detected regardless of case"
468 );
469 }
470
471 #[test]
474 fn rdap_fail_whois_says_available_high_confidence() {
475 let whois = whois_with("No match for \"example.test\".\n", None);
478 let rdap_err = SeerError::RdapError("404 not found".to_string());
479 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
480 assert!(r.available, "WHOIS 'no match' must mark available");
481 assert_eq!(r.confidence, "high");
482 assert_eq!(r.method, "whois");
483 }
484
485 #[test]
486 fn rdap_fail_whois_says_registered_high_confidence() {
487 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
488 let rdap_err = SeerError::RdapError("404 not found".to_string());
489 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
490 assert!(!r.available);
491 assert_eq!(r.confidence, "high");
492 assert_eq!(r.method, "whois");
493 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
494 }
495
496 #[test]
497 fn rdap_fail_whois_registered_without_registrar_no_detail() {
498 let whois = whois_with("Domain Name: example.test\n", None);
501 let rdap_err = SeerError::RdapError("404".to_string());
502 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
503 assert!(!r.available);
504 assert_eq!(r.confidence, "high");
505 assert!(
506 r.details.is_none(),
507 "no registrar means no details string, got: {:?}",
508 r.details
509 );
510 }
511
512 #[test]
515 fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
516 let rdap_err = SeerError::RdapError("500".to_string());
517 let whois_err =
518 SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
519 let r = decide_fallback(
520 "example.test",
521 &rdap_err,
522 Err(whois_err),
523 DnsPresence::Unknown,
524 );
525 assert!(
526 r.available,
527 "whois error containing 'no match' is available"
528 );
529 assert_eq!(r.confidence, "medium");
530 assert_eq!(r.method, "whois_error");
531 }
532
533 #[test]
534 fn rdap_fail_whois_error_not_found_marks_available_medium() {
535 let rdap_err = SeerError::RdapError("500".to_string());
536 let whois_err = SeerError::WhoisError("Domain not found".to_string());
537 let r = decide_fallback(
538 "example.test",
539 &rdap_err,
540 Err(whois_err),
541 DnsPresence::Unknown,
542 );
543 assert!(r.available);
544 assert_eq!(r.confidence, "medium");
545 assert_eq!(r.method, "whois_error");
546 }
547
548 #[test]
549 fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
550 let rdap_err = SeerError::RdapError("no".to_string());
551 let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
552 let r = decide_fallback(
553 "example.test",
554 &rdap_err,
555 Err(whois_err),
556 DnsPresence::Unknown,
557 );
558 assert!(r.available);
559 assert_eq!(r.confidence, "medium");
560 }
561
562 #[test]
563 fn rdap_fail_whois_error_no_entries_marks_available_medium() {
564 let rdap_err = SeerError::RdapError("no".to_string());
565 let whois_err =
566 SeerError::WhoisError("No entries found for the selected source".to_string());
567 let r = decide_fallback(
568 "example.test",
569 &rdap_err,
570 Err(whois_err),
571 DnsPresence::Unknown,
572 );
573 assert!(r.available);
574 assert_eq!(r.confidence, "medium");
575 }
576
577 #[test]
578 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
579 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
580 let whois_err = SeerError::Timeout("whois timed out".to_string());
581 let r = decide_fallback(
582 "example.test",
583 &rdap_err,
584 Err(whois_err),
585 DnsPresence::Unknown,
586 );
587 assert!(
588 !r.available,
589 "inconclusive means NOT available (fail-safe default)"
590 );
591 assert_eq!(r.confidence, "none");
592 assert_eq!(r.method, "inconclusive");
593 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
594 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
595 }
596
597 #[test]
598 fn rdap_fail_whois_transport_error_with_phrase_not_available() {
599 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
605 let whois_err =
606 SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
607 let r = decide_fallback(
608 "example.test",
609 &rdap_err,
610 Err(whois_err),
611 DnsPresence::Present,
612 );
613 assert!(
614 !r.available,
615 "transport error text must not infer availability"
616 );
617 }
618
619 #[test]
620 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
621 let rdap_err = SeerError::RdapError("connection refused".to_string());
622 let whois_err = SeerError::WhoisError(
623 "failed to connect to whois.example: connection refused".to_string(),
624 );
625 let r = decide_fallback(
626 "example.test",
627 &rdap_err,
628 Err(whois_err),
629 DnsPresence::Unknown,
630 );
631 assert!(!r.available);
632 assert_eq!(r.confidence, "none");
633 assert_eq!(r.method, "inconclusive");
634 }
635
636 #[test]
637 fn rdap_fail_whois_error_case_insensitive_not_found() {
638 let rdap_err = SeerError::RdapError("500".to_string());
641 let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
642 let r = decide_fallback(
643 "example.test",
644 &rdap_err,
645 Err(whois_err),
646 DnsPresence::Unknown,
647 );
648 assert!(r.available, "'NOT FOUND' should classify as available");
649 assert_eq!(r.confidence, "medium");
650 }
651
652 #[test]
655 fn rdap_404_with_blocked_whois_marks_available() {
656 let whois = whois_with("Requests of this client are not permitted.\n", None);
661 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
662 let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
663 assert!(
664 r.available,
665 "RDAP 404 must mark available even with blocked WHOIS"
666 );
667 assert_eq!(r.confidence, "high");
668 assert_eq!(r.method, "rdap");
669 }
670
671 #[test]
672 fn rdap_404_with_whois_error_marks_available() {
673 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
675 let whois_err = SeerError::WhoisError("connection refused".to_string());
676 let r = decide_fallback(
677 "example.test",
678 &rdap_err,
679 Err(whois_err),
680 DnsPresence::Unknown,
681 );
682 assert!(r.available);
683 assert_eq!(r.confidence, "high");
684 assert_eq!(r.method, "rdap");
685 }
686
687 #[test]
688 fn rdap_404_but_whois_has_full_registration_marks_registered() {
689 let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
693 whois.creation_date = Some(chrono::Utc::now());
694 whois.nameservers = vec!["ns1.example.net".to_string()];
695 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
696 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
697 assert!(
698 !r.available,
699 "concrete WHOIS registration must win over RDAP 404"
700 );
701 assert_eq!(r.confidence, "high");
702 assert_eq!(r.method, "whois");
703 }
704
705 #[test]
708 fn thin_whois_non404_dns_absent_marks_likely_available() {
709 let whois = whois_with(
713 "Conditions of use for the whois service via port 43\n",
714 None,
715 );
716 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
717 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
718 assert!(r.available);
719 assert_eq!(r.confidence, "medium");
720 assert_eq!(r.method, "dns_nxdomain");
721 }
722
723 #[test]
724 fn thin_whois_non404_dns_present_stays_unavailable() {
725 let whois = whois_with(
728 "Conditions of use for the whois service via port 43\n",
729 None,
730 );
731 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
732 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
733 assert!(!r.available);
734 assert_ne!(r.method, "dns_nxdomain");
735 }
736
737 #[test]
738 fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
739 let whois = whois_with(
743 "Conditions of use for the whois service via port 43\n",
744 None,
745 );
746 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
747 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
748 assert!(!r.available);
749 }
750
751 #[test]
752 fn both_legs_failed_dns_absent_marks_likely_available() {
753 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
756 let whois_err = SeerError::WhoisError("connection refused".to_string());
757 let r = decide_fallback(
758 "example.test",
759 &rdap_err,
760 Err(whois_err),
761 DnsPresence::Absent,
762 );
763 assert!(r.available);
764 assert_eq!(r.confidence, "medium");
765 assert_eq!(r.method, "dns_nxdomain");
766 }
767
768 #[test]
771 fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
772 let whois = whois_with(
777 "Access rate limited; no data found for unauthenticated clients\n",
778 None,
779 );
780 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
781 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
782 assert!(
783 !r.available,
784 "refusal/throttle must not be called available"
785 );
786 assert_eq!(r.confidence, "none");
787 assert_eq!(r.method, "inconclusive");
788 }
789
790 #[test]
791 fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
792 let whois = whois_with(
796 "This domain name is not available for registration.\n",
797 None,
798 );
799 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
800 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
801 assert!(!r.available);
802 assert_eq!(r.confidence, "none");
803 assert_eq!(r.method, "inconclusive");
804 }
805
806 #[test]
807 fn rdap_404_with_refusal_whois_still_marks_available() {
808 let whois = whois_with("Access rate limited; please try again later.\n", None);
811 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
812 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
813 assert!(r.available, "RDAP 404 is authoritative over a refusal body");
814 assert_eq!(r.confidence, "high");
815 assert_eq!(r.method, "rdap");
816 }
817}