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
103 .iter()
104 .any(|s| s.contains("redemption") || s.contains("pending delete"));
105
106 if is_redemption {
107 return AvailabilityResult {
108 domain: domain.to_string(),
109 available: false,
110 confidence: "medium".to_string(),
111 method: "rdap".to_string(),
112 details: Some("Domain is in redemption/pending delete period".to_string()),
113 };
114 }
115
116 AvailabilityResult {
117 domain: domain.to_string(),
118 available: false,
119 confidence: "high".to_string(),
120 method: "rdap".to_string(),
121 details: Some(format!(
122 "Domain is registered (status: {})",
123 statuses.join(", ")
124 )),
125 }
126}
127
128fn decide_fallback(
135 domain: &str,
136 rdap_err: &crate::error::SeerError,
137 whois_result: Result<crate::whois::WhoisResponse>,
138 dns_presence: DnsPresence,
139) -> AvailabilityResult {
140 match whois_result {
141 Ok(whois_response) => {
142 let thin = whois_response.registrar.is_none()
146 && whois_response.creation_date.is_none()
147 && whois_response.expiration_date.is_none();
148
149 if whois_response.is_available() {
150 AvailabilityResult {
151 domain: domain.to_string(),
152 available: true,
153 confidence: "high".to_string(),
154 method: "whois".to_string(),
155 details: Some("WHOIS indicates domain is not registered".to_string()),
156 }
157 } else if !thin {
158 AvailabilityResult {
161 domain: domain.to_string(),
162 available: false,
163 confidence: "high".to_string(),
164 method: "whois".to_string(),
165 details: whois_response
166 .registrar
167 .map(|r| format!("Registered with {}", r)),
168 }
169 } else if rdap_error_is_404(rdap_err) {
170 AvailabilityResult {
173 domain: domain.to_string(),
174 available: true,
175 confidence: "high".to_string(),
176 method: "rdap".to_string(),
177 details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
178 }
179 } else if dns_presence == DnsPresence::Absent {
180 AvailabilityResult {
183 domain: domain.to_string(),
184 available: true,
185 confidence: "medium".to_string(),
186 method: "dns_nxdomain".to_string(),
187 details: Some(
188 "No registry data available; domain has no DNS presence (NXDOMAIN)"
189 .to_string(),
190 ),
191 }
192 } else {
193 AvailabilityResult {
196 domain: domain.to_string(),
197 available: false,
198 confidence: "high".to_string(),
199 method: "whois".to_string(),
200 details: None,
201 }
202 }
203 }
204 Err(whois_err) => {
205 if rdap_error_is_404(rdap_err) {
209 return AvailabilityResult {
210 domain: domain.to_string(),
211 available: true,
212 confidence: "high".to_string(),
213 method: "rdap".to_string(),
214 details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
215 };
216 }
217 let whois_msg = whois_err.to_string().to_lowercase();
219 let likely_available = whois_msg.contains("no match")
220 || whois_msg.contains("not found")
221 || whois_msg.contains("no data found")
222 || whois_msg.contains("no entries found");
223
224 if likely_available {
225 AvailabilityResult {
226 domain: domain.to_string(),
227 available: true,
228 confidence: "medium".to_string(),
229 method: "whois_error".to_string(),
230 details: Some("WHOIS server indicates no matching records".to_string()),
231 }
232 } else if dns_presence == DnsPresence::Absent {
233 AvailabilityResult {
236 domain: domain.to_string(),
237 available: true,
238 confidence: "medium".to_string(),
239 method: "dns_nxdomain".to_string(),
240 details: Some(
241 "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
242 .to_string(),
243 ),
244 }
245 } else {
246 AvailabilityResult {
252 domain: domain.to_string(),
253 available: false,
254 confidence: "none".to_string(),
255 method: "inconclusive".to_string(),
256 details: Some(format!(
261 "Could not determine availability. RDAP: {}. WHOIS: {}",
262 rdap_err.sanitized_message(),
263 whois_err.sanitized_message()
264 )),
265 }
266 }
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::error::SeerError;
275 use crate::rdap::RdapResponse;
276 use crate::whois::WhoisResponse;
277
278 #[test]
279 fn verdict_matrix() {
280 let make = |available, confidence: &str| AvailabilityResult {
281 domain: "example.test".to_string(),
282 available,
283 confidence: confidence.to_string(),
284 method: "whois".to_string(),
285 details: None,
286 };
287 assert_eq!(make(true, "high").verdict(), "available");
288 assert_eq!(make(true, "medium").verdict(), "likely_available");
289 assert_eq!(make(false, "high").verdict(), "registered");
290 assert_eq!(make(false, "medium").verdict(), "likely_registered");
291 assert_eq!(make(false, "none").verdict(), "unknown");
292 assert_eq!(make(true, "low").verdict(), "unknown");
293 }
294
295 #[test]
296 fn test_availability_result_serialization() {
297 let result = AvailabilityResult {
298 domain: "example.com".to_string(),
299 available: false,
300 confidence: "high".to_string(),
301 method: "rdap".to_string(),
302 details: Some("Domain is registered".to_string()),
303 };
304 let json = serde_json::to_string(&result).unwrap();
305 assert!(json.contains("\"available\":false"));
306 assert!(json.contains("\"confidence\":\"high\""));
307 }
308
309 fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
321 WhoisResponse {
322 domain: "example.test".to_string(),
323 registrar: registrar.map(str::to_string),
324 registrant: None,
325 organization: None,
326 registrant_email: None,
327 registrant_phone: None,
328 registrant_address: None,
329 registrant_country: None,
330 admin_name: None,
331 admin_organization: None,
332 admin_email: None,
333 admin_phone: None,
334 tech_name: None,
335 tech_organization: None,
336 tech_email: None,
337 tech_phone: None,
338 creation_date: None,
339 expiration_date: None,
340 updated_date: None,
341 nameservers: vec![],
342 status: vec![],
343 dnssec: None,
344 whois_server: "whois.test".to_string(),
345 raw_response: raw.to_string(),
346 }
347 }
348
349 fn rdap_with(statuses: &[&str]) -> RdapResponse {
350 RdapResponse {
351 status: statuses.iter().map(|s| s.to_string()).collect(),
352 ldh_name: Some("example.test".to_string()),
353 ..Default::default()
354 }
355 }
356
357 #[test]
360 fn rdap_success_registered_marks_taken_high_confidence() {
361 let rdap = rdap_with(&["active"]);
362 let r = decide_from_rdap("example.test", rdap);
363 assert!(!r.available, "registered domain must be marked taken");
364 assert_eq!(r.confidence, "high");
365 assert_eq!(r.method, "rdap");
366 assert!(
367 r.details.as_deref().unwrap().contains("active"),
368 "details should include status list"
369 );
370 }
371
372 #[test]
373 fn rdap_success_empty_status_marks_taken_high_confidence() {
374 let rdap = rdap_with(&[]);
377 let r = decide_from_rdap("example.test", rdap);
378 assert!(!r.available);
379 assert_eq!(r.confidence, "high");
380 assert_eq!(r.method, "rdap");
381 }
382
383 #[test]
384 fn rdap_success_redemption_period_marks_taken_medium_confidence() {
385 let rdap = rdap_with(&["redemption period"]);
386 let r = decide_from_rdap("example.test", rdap);
387 assert!(!r.available, "redemption period still means taken");
388 assert_eq!(r.confidence, "medium", "redemption drops confidence");
389 assert_eq!(r.method, "rdap");
390 assert!(r.details.as_deref().unwrap().contains("redemption"));
391 }
392
393 #[test]
394 fn rdap_success_pending_delete_marks_taken_medium_confidence() {
395 let rdap = rdap_with(&["pending delete"]);
396 let r = decide_from_rdap("example.test", rdap);
397 assert!(!r.available);
398 assert_eq!(r.confidence, "medium");
399 assert!(r.details.as_deref().unwrap().contains("redemption"));
400 }
401
402 #[test]
405 fn rdap_fail_whois_says_available_high_confidence() {
406 let whois = whois_with("No match for \"example.test\".\n", None);
409 let rdap_err = SeerError::RdapError("404 not found".to_string());
410 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
411 assert!(r.available, "WHOIS 'no match' must mark available");
412 assert_eq!(r.confidence, "high");
413 assert_eq!(r.method, "whois");
414 }
415
416 #[test]
417 fn rdap_fail_whois_says_registered_high_confidence() {
418 let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
419 let rdap_err = SeerError::RdapError("404 not found".to_string());
420 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
421 assert!(!r.available);
422 assert_eq!(r.confidence, "high");
423 assert_eq!(r.method, "whois");
424 assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
425 }
426
427 #[test]
428 fn rdap_fail_whois_registered_without_registrar_no_detail() {
429 let whois = whois_with("Domain Name: example.test\n", None);
432 let rdap_err = SeerError::RdapError("404".to_string());
433 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
434 assert!(!r.available);
435 assert_eq!(r.confidence, "high");
436 assert!(
437 r.details.is_none(),
438 "no registrar means no details string, got: {:?}",
439 r.details
440 );
441 }
442
443 #[test]
446 fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
447 let rdap_err = SeerError::RdapError("500".to_string());
448 let whois_err =
449 SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
450 let r = decide_fallback(
451 "example.test",
452 &rdap_err,
453 Err(whois_err),
454 DnsPresence::Unknown,
455 );
456 assert!(
457 r.available,
458 "whois error containing 'no match' is available"
459 );
460 assert_eq!(r.confidence, "medium");
461 assert_eq!(r.method, "whois_error");
462 }
463
464 #[test]
465 fn rdap_fail_whois_error_not_found_marks_available_medium() {
466 let rdap_err = SeerError::RdapError("500".to_string());
467 let whois_err = SeerError::WhoisError("Domain not found".to_string());
468 let r = decide_fallback(
469 "example.test",
470 &rdap_err,
471 Err(whois_err),
472 DnsPresence::Unknown,
473 );
474 assert!(r.available);
475 assert_eq!(r.confidence, "medium");
476 assert_eq!(r.method, "whois_error");
477 }
478
479 #[test]
480 fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
481 let rdap_err = SeerError::RdapError("no".to_string());
482 let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
483 let r = decide_fallback(
484 "example.test",
485 &rdap_err,
486 Err(whois_err),
487 DnsPresence::Unknown,
488 );
489 assert!(r.available);
490 assert_eq!(r.confidence, "medium");
491 }
492
493 #[test]
494 fn rdap_fail_whois_error_no_entries_marks_available_medium() {
495 let rdap_err = SeerError::RdapError("no".to_string());
496 let whois_err =
497 SeerError::WhoisError("No entries found for the selected source".to_string());
498 let r = decide_fallback(
499 "example.test",
500 &rdap_err,
501 Err(whois_err),
502 DnsPresence::Unknown,
503 );
504 assert!(r.available);
505 assert_eq!(r.confidence, "medium");
506 }
507
508 #[test]
509 fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
510 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
511 let whois_err = SeerError::Timeout("whois timed out".to_string());
512 let r = decide_fallback(
513 "example.test",
514 &rdap_err,
515 Err(whois_err),
516 DnsPresence::Unknown,
517 );
518 assert!(
519 !r.available,
520 "inconclusive means NOT available (fail-safe default)"
521 );
522 assert_eq!(r.confidence, "none");
523 assert_eq!(r.method, "inconclusive");
524 assert!(r.details.as_deref().unwrap().contains("RDAP:"));
525 assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
526 }
527
528 #[test]
529 fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
530 let rdap_err = SeerError::RdapError("connection refused".to_string());
531 let whois_err = SeerError::WhoisError(
532 "failed to connect to whois.example: connection refused".to_string(),
533 );
534 let r = decide_fallback(
535 "example.test",
536 &rdap_err,
537 Err(whois_err),
538 DnsPresence::Unknown,
539 );
540 assert!(!r.available);
541 assert_eq!(r.confidence, "none");
542 assert_eq!(r.method, "inconclusive");
543 }
544
545 #[test]
546 fn rdap_fail_whois_error_case_insensitive_not_found() {
547 let rdap_err = SeerError::RdapError("500".to_string());
550 let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
551 let r = decide_fallback(
552 "example.test",
553 &rdap_err,
554 Err(whois_err),
555 DnsPresence::Unknown,
556 );
557 assert!(r.available, "'NOT FOUND' should classify as available");
558 assert_eq!(r.confidence, "medium");
559 }
560
561 #[test]
564 fn rdap_404_with_blocked_whois_marks_available() {
565 let whois = whois_with("Requests of this client are not permitted.\n", None);
570 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
571 let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
572 assert!(
573 r.available,
574 "RDAP 404 must mark available even with blocked WHOIS"
575 );
576 assert_eq!(r.confidence, "high");
577 assert_eq!(r.method, "rdap");
578 }
579
580 #[test]
581 fn rdap_404_with_whois_error_marks_available() {
582 let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
584 let whois_err = SeerError::WhoisError("connection refused".to_string());
585 let r = decide_fallback(
586 "example.test",
587 &rdap_err,
588 Err(whois_err),
589 DnsPresence::Unknown,
590 );
591 assert!(r.available);
592 assert_eq!(r.confidence, "high");
593 assert_eq!(r.method, "rdap");
594 }
595
596 #[test]
597 fn rdap_404_but_whois_has_full_registration_marks_registered() {
598 let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
602 whois.creation_date = Some(chrono::Utc::now());
603 whois.nameservers = vec!["ns1.example.net".to_string()];
604 let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
605 let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
606 assert!(
607 !r.available,
608 "concrete WHOIS registration must win over RDAP 404"
609 );
610 assert_eq!(r.confidence, "high");
611 assert_eq!(r.method, "whois");
612 }
613
614 #[test]
617 fn thin_whois_non404_dns_absent_marks_likely_available() {
618 let whois = whois_with(
622 "Conditions of use for the whois service via port 43\n",
623 None,
624 );
625 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
626 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
627 assert!(r.available);
628 assert_eq!(r.confidence, "medium");
629 assert_eq!(r.method, "dns_nxdomain");
630 }
631
632 #[test]
633 fn thin_whois_non404_dns_present_stays_unavailable() {
634 let whois = whois_with(
637 "Conditions of use for the whois service via port 43\n",
638 None,
639 );
640 let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
641 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
642 assert!(!r.available);
643 assert_ne!(r.method, "dns_nxdomain");
644 }
645
646 #[test]
647 fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
648 let whois = whois_with(
652 "Conditions of use for the whois service via port 43\n",
653 None,
654 );
655 let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
656 let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
657 assert!(!r.available);
658 }
659
660 #[test]
661 fn both_legs_failed_dns_absent_marks_likely_available() {
662 let rdap_err = SeerError::Timeout("rdap timed out".to_string());
665 let whois_err = SeerError::WhoisError("connection refused".to_string());
666 let r = decide_fallback(
667 "example.test",
668 &rdap_err,
669 Err(whois_err),
670 DnsPresence::Absent,
671 );
672 assert!(r.available);
673 assert_eq!(r.confidence, "medium");
674 assert_eq!(r.method, "dns_nxdomain");
675 }
676}