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 dns_presence == DnsPresence::Absent {
191 AvailabilityResult {
194 domain: domain.to_string(),
195 available: true,
196 confidence: "medium".to_string(),
197 method: "dns_nxdomain".to_string(),
198 details: Some(
199 "No registry data available; domain has no DNS presence (NXDOMAIN)"
200 .to_string(),
201 ),
202 }
203 } else {
204 AvailabilityResult {
207 domain: domain.to_string(),
208 available: false,
209 confidence: "high".to_string(),
210 method: "whois".to_string(),
211 details: None,
212 }
213 }
214 }
215 Err(whois_err) => {
216 if rdap_error_is_404(rdap_err) {
220 return AvailabilityResult {
221 domain: domain.to_string(),
222 available: true,
223 confidence: "high".to_string(),
224 method: "rdap".to_string(),
225 details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
226 };
227 }
228 let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
234 let whois_msg = whois_err.to_string().to_lowercase();
235 whois_msg.contains("no match")
236 || whois_msg.contains("not found")
237 || whois_msg.contains("no data found")
238 || whois_msg.contains("no entries found")
239 };
240
241 if likely_available {
242 AvailabilityResult {
243 domain: domain.to_string(),
244 available: true,
245 confidence: "medium".to_string(),
246 method: "whois_error".to_string(),
247 details: Some("WHOIS server indicates no matching records".to_string()),
248 }
249 } else if dns_presence == DnsPresence::Absent {
250 AvailabilityResult {
253 domain: domain.to_string(),
254 available: true,
255 confidence: "medium".to_string(),
256 method: "dns_nxdomain".to_string(),
257 details: Some(
258 "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
259 .to_string(),
260 ),
261 }
262 } else {
263 AvailabilityResult {
269 domain: domain.to_string(),
270 available: false,
271 confidence: "none".to_string(),
272 method: "inconclusive".to_string(),
273 details: Some(format!(
278 "Could not determine availability. RDAP: {}. WHOIS: {}",
279 rdap_err.sanitized_message(),
280 whois_err.sanitized_message()
281 )),
282 }
283 }
284 }
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::error::SeerError;
292 use crate::rdap::RdapResponse;
293 use crate::whois::WhoisResponse;
294
295 #[test]
296 fn verdict_matrix() {
297 let make = |available, confidence: &str| AvailabilityResult {
298 domain: "example.test".to_string(),
299 available,
300 confidence: confidence.to_string(),
301 method: "whois".to_string(),
302 details: None,
303 };
304 assert_eq!(make(true, "high").verdict(), "available");
305 assert_eq!(make(true, "medium").verdict(), "likely_available");
306 assert_eq!(make(false, "high").verdict(), "registered");
307 assert_eq!(make(false, "medium").verdict(), "likely_registered");
308 assert_eq!(make(false, "none").verdict(), "unknown");
309 assert_eq!(make(true, "low").verdict(), "unknown");
310 }
311
312 #[test]
313 fn test_availability_result_serialization() {
314 let result = AvailabilityResult {
315 domain: "example.com".to_string(),
316 available: false,
317 confidence: "high".to_string(),
318 method: "rdap".to_string(),
319 details: Some("Domain is registered".to_string()),
320 };
321 let json = serde_json::to_string(&result).unwrap();
322 assert!(json.contains("\"available\":false"));
323 assert!(json.contains("\"confidence\":\"high\""));
324 }
325
326 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
338 WhoisResponse {
339 domain: "example.test".to_string(),
340 registrar: registrar.map(str::to_string),
341 registrant: None,
342 organization: None,
343 registrant_email: None,
344 registrant_phone: None,
345 registrant_address: None,
346 registrant_country: None,
347 admin_name: None,
348 admin_organization: None,
349 admin_email: None,
350 admin_phone: None,
351 tech_name: None,
352 tech_organization: None,
353 tech_email: None,
354 tech_phone: None,
355 creation_date: None,
356 expiration_date: None,
357 updated_date: None,
358 nameservers: vec![],
359 status: vec![],
360 dnssec: None,
361 whois_server: "whois.test".to_string(),
362 raw_response: raw.to_string(),
363 }
364 }
365
366 fn rdap_with(statuses: &[&str]) -> RdapResponse {
367 RdapResponse {
368 status: statuses.iter().map(|s| s.to_string()).collect(),
369 ldh_name: Some("example.test".to_string()),
370 ..Default::default()
371 }
372 }
373
374 #[test]
377 fn rdap_success_registered_marks_taken_high_confidence() {
378 let rdap = rdap_with(&["active"]);
379 let r = decide_from_rdap("example.test", rdap);
380 assert!(!r.available, "registered domain must be marked taken");
381 assert_eq!(r.confidence, "high");
382 assert_eq!(r.method, "rdap");
383 assert!(
384 r.details.as_deref().unwrap().contains("active"),
385 "details should include status list"
386 );
387 }
388
389 #[test]
390 fn rdap_success_empty_status_marks_taken_high_confidence() {
391 let rdap = rdap_with(&[]);
394 let r = decide_from_rdap("example.test", rdap);
395 assert!(!r.available);
396 assert_eq!(r.confidence, "high");
397 assert_eq!(r.method, "rdap");
398 }
399
400 #[test]
401 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
402 let rdap = rdap_with(&["redemption period"]);
403 let r = decide_from_rdap("example.test", rdap);
404 assert!(!r.available, "redemption period still means taken");
405 assert_eq!(r.confidence, "medium", "redemption drops confidence");
406 assert_eq!(r.method, "rdap");
407 assert!(r.details.as_deref().unwrap().contains("redemption"));
408 }
409
410 #[test]
411 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
412 let rdap = rdap_with(&["pending delete"]);
413 let r = decide_from_rdap("example.test", rdap);
414 assert!(!r.available);
415 assert_eq!(r.confidence, "medium");
416 assert!(r.details.as_deref().unwrap().contains("redemption"));
417 }
418
419 #[test]
420 fn rdap_status_substring_redemption_not_misclassified() {
421 let rdap = rdap_with(&["clientHold (no redemption requested)"]);
425 let r = decide_from_rdap("example.test", rdap);
426 assert!(!r.available, "still registered");
427 assert_eq!(
428 r.confidence, "high",
429 "verbose status must not be downgraded to redemption/medium"
430 );
431 }
432
433 #[test]
434 fn rdap_status_redemption_detected_case_insensitively() {
435 let rdap = rdap_with(&["Redemption Period"]);
438 let r = decide_from_rdap("example.test", rdap);
439 assert_eq!(
440 r.confidence, "medium",
441 "standard token detected regardless of case"
442 );
443 }
444
445 #[test]
448 fn rdap_fail_whois_says_available_high_confidence() {
449 let whois = whois_with("No match for \"example.test\".\n", None);
452 let rdap_err = SeerError::RdapError("404 not found".to_string());
453 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
454 assert!(r.available, "WHOIS 'no match' must mark available");
455 assert_eq!(r.confidence, "high");
456 assert_eq!(r.method, "whois");
457 }
458
459 #[test]
460 fn rdap_fail_whois_says_registered_high_confidence() {
461 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
462 let rdap_err = SeerError::RdapError("404 not found".to_string());
463 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
464 assert!(!r.available);
465 assert_eq!(r.confidence, "high");
466 assert_eq!(r.method, "whois");
467 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
468 }
469
470 #[test]
471 fn rdap_fail_whois_registered_without_registrar_no_detail() {
472 let whois = whois_with("Domain Name: example.test\n", None);
475 let rdap_err = SeerError::RdapError("404".to_string());
476 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
477 assert!(!r.available);
478 assert_eq!(r.confidence, "high");
479 assert!(
480 r.details.is_none(),
481 "no registrar means no details string, got: {:?}",
482 r.details
483 );
484 }
485
486 #[test]
489 fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
490 let rdap_err = SeerError::RdapError("500".to_string());
491 let whois_err =
492 SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
493 let r = decide_fallback(
494 "example.test",
495 &rdap_err,
496 Err(whois_err),
497 DnsPresence::Unknown,
498 );
499 assert!(
500 r.available,
501 "whois error containing 'no match' is available"
502 );
503 assert_eq!(r.confidence, "medium");
504 assert_eq!(r.method, "whois_error");
505 }
506
507 #[test]
508 fn rdap_fail_whois_error_not_found_marks_available_medium() {
509 let rdap_err = SeerError::RdapError("500".to_string());
510 let whois_err = SeerError::WhoisError("Domain not found".to_string());
511 let r = decide_fallback(
512 "example.test",
513 &rdap_err,
514 Err(whois_err),
515 DnsPresence::Unknown,
516 );
517 assert!(r.available);
518 assert_eq!(r.confidence, "medium");
519 assert_eq!(r.method, "whois_error");
520 }
521
522 #[test]
523 fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
524 let rdap_err = SeerError::RdapError("no".to_string());
525 let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
526 let r = decide_fallback(
527 "example.test",
528 &rdap_err,
529 Err(whois_err),
530 DnsPresence::Unknown,
531 );
532 assert!(r.available);
533 assert_eq!(r.confidence, "medium");
534 }
535
536 #[test]
537 fn rdap_fail_whois_error_no_entries_marks_available_medium() {
538 let rdap_err = SeerError::RdapError("no".to_string());
539 let whois_err =
540 SeerError::WhoisError("No entries found for the selected source".to_string());
541 let r = decide_fallback(
542 "example.test",
543 &rdap_err,
544 Err(whois_err),
545 DnsPresence::Unknown,
546 );
547 assert!(r.available);
548 assert_eq!(r.confidence, "medium");
549 }
550
551 #[test]
552 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
553 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
554 let whois_err = SeerError::Timeout("whois timed out".to_string());
555 let r = decide_fallback(
556 "example.test",
557 &rdap_err,
558 Err(whois_err),
559 DnsPresence::Unknown,
560 );
561 assert!(
562 !r.available,
563 "inconclusive means NOT available (fail-safe default)"
564 );
565 assert_eq!(r.confidence, "none");
566 assert_eq!(r.method, "inconclusive");
567 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
568 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
569 }
570
571 #[test]
572 fn rdap_fail_whois_transport_error_with_phrase_not_available() {
573 let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
579 let whois_err =
580 SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
581 let r = decide_fallback(
582 "example.test",
583 &rdap_err,
584 Err(whois_err),
585 DnsPresence::Present,
586 );
587 assert!(
588 !r.available,
589 "transport error text must not infer availability"
590 );
591 }
592
593 #[test]
594 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
595 let rdap_err = SeerError::RdapError("connection refused".to_string());
596 let whois_err = SeerError::WhoisError(
597 "failed to connect to whois.example: connection refused".to_string(),
598 );
599 let r = decide_fallback(
600 "example.test",
601 &rdap_err,
602 Err(whois_err),
603 DnsPresence::Unknown,
604 );
605 assert!(!r.available);
606 assert_eq!(r.confidence, "none");
607 assert_eq!(r.method, "inconclusive");
608 }
609
610 #[test]
611 fn rdap_fail_whois_error_case_insensitive_not_found() {
612 let rdap_err = SeerError::RdapError("500".to_string());
615 let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
616 let r = decide_fallback(
617 "example.test",
618 &rdap_err,
619 Err(whois_err),
620 DnsPresence::Unknown,
621 );
622 assert!(r.available, "'NOT FOUND' should classify as available");
623 assert_eq!(r.confidence, "medium");
624 }
625
626 #[test]
629 fn rdap_404_with_blocked_whois_marks_available() {
630 let whois = whois_with("Requests of this client are not permitted.\n", None);
635 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
636 let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
637 assert!(
638 r.available,
639 "RDAP 404 must mark available even with blocked WHOIS"
640 );
641 assert_eq!(r.confidence, "high");
642 assert_eq!(r.method, "rdap");
643 }
644
645 #[test]
646 fn rdap_404_with_whois_error_marks_available() {
647 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
649 let whois_err = SeerError::WhoisError("connection refused".to_string());
650 let r = decide_fallback(
651 "example.test",
652 &rdap_err,
653 Err(whois_err),
654 DnsPresence::Unknown,
655 );
656 assert!(r.available);
657 assert_eq!(r.confidence, "high");
658 assert_eq!(r.method, "rdap");
659 }
660
661 #[test]
662 fn rdap_404_but_whois_has_full_registration_marks_registered() {
663 let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
667 whois.creation_date = Some(chrono::Utc::now());
668 whois.nameservers = vec!["ns1.example.net".to_string()];
669 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
670 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
671 assert!(
672 !r.available,
673 "concrete WHOIS registration must win over RDAP 404"
674 );
675 assert_eq!(r.confidence, "high");
676 assert_eq!(r.method, "whois");
677 }
678
679 #[test]
682 fn thin_whois_non404_dns_absent_marks_likely_available() {
683 let whois = whois_with(
687 "Conditions of use for the whois service via port 43\n",
688 None,
689 );
690 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
691 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
692 assert!(r.available);
693 assert_eq!(r.confidence, "medium");
694 assert_eq!(r.method, "dns_nxdomain");
695 }
696
697 #[test]
698 fn thin_whois_non404_dns_present_stays_unavailable() {
699 let whois = whois_with(
702 "Conditions of use for the whois service via port 43\n",
703 None,
704 );
705 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
706 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
707 assert!(!r.available);
708 assert_ne!(r.method, "dns_nxdomain");
709 }
710
711 #[test]
712 fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
713 let whois = whois_with(
717 "Conditions of use for the whois service via port 43\n",
718 None,
719 );
720 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
721 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
722 assert!(!r.available);
723 }
724
725 #[test]
726 fn both_legs_failed_dns_absent_marks_likely_available() {
727 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
730 let whois_err = SeerError::WhoisError("connection refused".to_string());
731 let r = decide_fallback(
732 "example.test",
733 &rdap_err,
734 Err(whois_err),
735 DnsPresence::Absent,
736 );
737 assert!(r.available);
738 assert_eq!(r.confidence, "medium");
739 assert_eq!(r.method, "dns_nxdomain");
740 }
741}