use serde::{Deserialize, Serialize};
use tracing::{debug, instrument};
use crate::dns::{DnsPresence, DnsResolver};
use crate::error::{Result, SeerError};
use crate::rdap::{rdap_error_is_404, RdapClient};
use crate::whois::{WhoisClient, WhoisResponse};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailabilityResult {
pub domain: String,
pub available: bool,
pub confidence: String,
pub method: String,
pub details: Option<String>,
}
impl AvailabilityResult {
pub(crate) fn new(domain: &str, available: bool, confidence: &str, method: &str) -> Self {
Self {
domain: domain.to_string(),
available,
confidence: confidence.to_string(),
method: method.to_string(),
details: None,
}
}
pub(crate) fn with_details(mut self, details: impl Into<String>) -> Self {
self.details = Some(details.into());
self
}
pub fn verdict(&self) -> &'static str {
match (self.available, self.confidence.as_str()) {
(true, "high") => "available",
(true, "medium") => "likely_available",
(false, "high") => "registered",
(false, "medium") => "likely_registered",
_ => "unknown",
}
}
}
pub(crate) enum PriorRdap {
Response(Box<crate::rdap::RdapResponse>),
Failed(SeerError),
Missing,
}
pub(crate) enum PriorWhois {
Failed(SeerError),
Missing,
}
#[derive(Debug, Clone, Default)]
pub struct AvailabilityChecker {
rdap_client: RdapClient,
whois_client: WhoisClient,
dns_resolver: DnsResolver,
}
impl AvailabilityChecker {
pub fn new() -> Self {
Self::default()
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self {
rdap_client: RdapClient::from_config(config),
whois_client: WhoisClient::from_config(config),
dns_resolver: DnsResolver::from_config(config),
}
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
let domain = crate::validation::normalize_domain(domain)?;
debug!(domain = %domain, "Checking domain availability");
let result = self.check_registry(&domain).await;
Ok(self.guard_subdomain_claim(result).await)
}
async fn check_registry(&self, domain: &str) -> AvailabilityResult {
match self.rdap_client.lookup_domain(domain).await {
Ok(response) => decide_from_rdap(domain, response),
Err(rdap_err) => {
debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS + DNS");
let (whois_result, dns_presence) = tokio::join!(
self.whois_client.lookup(domain),
self.dns_resolver.presence(domain),
);
decide_fallback(domain, &rdap_err, whois_result, dns_presence)
}
}
}
pub(crate) async fn guard_subdomain_claim(
&self,
result: AvailabilityResult,
) -> AvailabilityResult {
if !result.available {
return result;
}
let Some(parent) = crate::psl::registrable_parent(&result.domain) else {
return result;
};
debug!(
domain = %result.domain,
parent = %parent,
"No registry object for a subdomain; checking its registrable parent"
);
let parent_result = self.check_registry(parent).await;
subdomain_verdict(&result.domain, &parent_result)
}
#[instrument(skip(self, prior_rdap, prior_whois), fields(domain = %domain))]
pub(crate) async fn check_with_prior(
&self,
domain: &str,
prior_rdap: PriorRdap,
prior_whois: PriorWhois,
) -> Result<AvailabilityResult> {
let domain = crate::validation::normalize_domain(domain)?;
debug!(domain = %domain, "Checking availability (reusing prior protocol outcomes)");
let rdap = match prior_rdap {
PriorRdap::Response(r) => Ok(*r),
PriorRdap::Failed(e) => Err(e),
PriorRdap::Missing => self.rdap_client.lookup_domain(&domain).await,
};
let result = match rdap {
Ok(response) => decide_from_rdap(&domain, response),
Err(rdap_err) => {
let (whois_result, dns_presence) = match prior_whois {
PriorWhois::Failed(e) => (Err(e), self.dns_resolver.presence(&domain).await),
PriorWhois::Missing => tokio::join!(
self.whois_client.lookup(&domain),
self.dns_resolver.presence(&domain),
),
};
decide_fallback(&domain, &rdap_err, whois_result, dns_presence)
}
};
Ok(self.guard_subdomain_claim(result).await)
}
}
pub(crate) const REFUSED_DETAILS: &str =
"Registry refused or throttled the query; availability is inconclusive";
pub(crate) const THIN_NXDOMAIN_DETAILS: &str =
"No registry data available; domain has no DNS presence (NXDOMAIN)";
pub(crate) fn subdomain_verdict(domain: &str, parent: &AvailabilityResult) -> AvailabilityResult {
let p = &parent.domain;
let (confidence, status) = match parent.verdict() {
"registered" | "likely_registered" => {
(parent.confidence.clone(), format!("{p} is registered"))
}
"available" | "likely_available" => (
"none".to_string(),
format!("{p} appears available; register {p} to obtain {domain}"),
),
_ => (
"none".to_string(),
format!("the registration status of {p} could not be determined"),
),
};
AvailabilityResult::new(domain, false, &confidence, "registrable_parent").with_details(format!(
"{domain} is not itself registrable: it is a name under the registrable domain {p}, \
and registries hold no object for subdomains. {status}."
))
}
fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
let statuses: Vec<String> = response.status.clone();
let is_redemption = statuses.iter().any(|s| {
let norm: String = s
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_lowercase();
matches!(norm.as_str(), "redemptionperiod" | "pendingdelete")
});
if is_redemption {
return AvailabilityResult::new(domain, false, "medium", "rdap")
.with_details("Domain is in redemption/pending delete period");
}
AvailabilityResult::new(domain, false, "high", "rdap").with_details(format!(
"Domain is registered (status: {})",
statuses.join(", ")
))
}
const RDAP_404_DETAILS: &str = "Registry RDAP reports no such domain (HTTP 404)";
pub(crate) enum Fallback {
Registered,
Verdict(AvailabilityResult),
NeedsDns(DnsTieBreak),
}
pub(crate) struct DnsTieBreak {
domain: String,
cause: SilentRegistry,
}
enum SilentRegistry {
ThinWhois { no_service: bool },
BothFailed { rdap: String, whois: String },
}
pub(crate) fn classify_fallback(
domain: &str,
rdap_err: Option<&SeerError>,
whois: std::result::Result<&WhoisResponse, &SeerError>,
) -> Fallback {
let rdap_404 = rdap_err.is_some_and(rdap_error_is_404);
let rdap_404_verdict = || {
Fallback::Verdict(
AvailabilityResult::new(domain, true, "high", "rdap").with_details(RDAP_404_DETAILS),
)
};
let cause = match whois {
Ok(w) if w.is_available() => {
return Fallback::Verdict(
AvailabilityResult::new(domain, true, "high", "whois")
.with_details("WHOIS indicates domain is not registered"),
)
}
Ok(w) if !w.is_thin() => return Fallback::Registered,
Ok(_) if rdap_404 => return rdap_404_verdict(),
Ok(w) if w.indicates_registry_refusal() => {
return Fallback::Verdict(
AvailabilityResult::new(domain, false, "none", "inconclusive")
.with_details(REFUSED_DETAILS),
)
}
Ok(w) => SilentRegistry::ThinWhois {
no_service: w.registry_unavailable(),
},
Err(_) if rdap_404 => return rdap_404_verdict(),
Err(whois_err) => SilentRegistry::BothFailed {
rdap: rdap_err.map_or_else(|| "no response".to_string(), SeerError::sanitized_message),
whois: whois_err.sanitized_message(),
},
};
Fallback::NeedsDns(DnsTieBreak {
domain: domain.to_string(),
cause,
})
}
impl DnsTieBreak {
pub(crate) fn decide(self, dns: DnsPresence) -> AvailabilityResult {
let d = self.domain.as_str();
match (dns, self.cause) {
(DnsPresence::Absent, SilentRegistry::ThinWhois { .. }) => {
AvailabilityResult::new(d, true, "medium", "dns_nxdomain")
.with_details(THIN_NXDOMAIN_DETAILS)
}
(DnsPresence::Absent, SilentRegistry::BothFailed { .. }) => {
AvailabilityResult::new(d, true, "medium", "dns_nxdomain")
.with_details("Registry lookups failed; domain has no DNS presence (NXDOMAIN)")
}
(DnsPresence::Present, SilentRegistry::ThinWhois { no_service: true }) => {
AvailabilityResult::new(d, false, "medium", "dns_present").with_details(
"The apex is delegated in DNS, so the domain is almost certainly \
registered. This TLD's registry provides no port-43 WHOIS data and \
RDAP was unavailable (rate-limited or unreachable); retry shortly for \
full RDAP detail.",
)
}
(DnsPresence::Present, SilentRegistry::ThinWhois { no_service: false }) => {
AvailabilityResult::new(d, false, "medium", "dns_present").with_details(
"The apex is delegated in DNS, so the domain is almost certainly \
registered. Registry detail was unavailable (RDAP rate-limited or \
unreachable and WHOIS returned no data); retry shortly for full detail.",
)
}
(DnsPresence::Present, SilentRegistry::BothFailed { .. }) => {
AvailabilityResult::new(d, false, "medium", "dns_present").with_details(
"Registry lookups failed, but the apex is delegated in DNS \
(NS records present) — the domain is almost certainly registered",
)
}
(DnsPresence::Unknown, SilentRegistry::ThinWhois { .. }) => {
AvailabilityResult::new(d, false, "none", "inconclusive").with_details(
"Could not determine availability: WHOIS returned no registration data, \
RDAP failed and the DNS presence probe failed",
)
}
(DnsPresence::Unknown, SilentRegistry::BothFailed { rdap, whois }) => {
AvailabilityResult::new(d, false, "none", "inconclusive").with_details(format!(
"Could not determine availability. RDAP: {rdap}. WHOIS: {whois}"
))
}
}
}
}
fn decide_fallback(
domain: &str,
rdap_err: &SeerError,
whois_result: Result<WhoisResponse>,
dns_presence: DnsPresence,
) -> AvailabilityResult {
match classify_fallback(domain, Some(rdap_err), whois_result.as_ref()) {
Fallback::Registered => AvailabilityResult {
details: whois_result
.ok()
.and_then(|w| w.registrar)
.map(|r| format!("Registered with {}", r)),
..AvailabilityResult::new(domain, false, "high", "whois")
},
Fallback::Verdict(verdict) => verdict,
Fallback::NeedsDns(tie_break) => tie_break.decide(dns_presence),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::SeerError;
use crate::rdap::RdapResponse;
use crate::whois::WhoisResponse;
#[test]
fn verdict_matrix() {
let make = |available, confidence: &str| {
AvailabilityResult::new("example.test", available, confidence, "whois")
};
assert_eq!(make(true, "high").verdict(), "available");
assert_eq!(make(true, "medium").verdict(), "likely_available");
assert_eq!(make(false, "high").verdict(), "registered");
assert_eq!(make(false, "medium").verdict(), "likely_registered");
assert_eq!(make(false, "none").verdict(), "unknown");
assert_eq!(make(true, "low").verdict(), "unknown");
}
#[test]
fn test_availability_result_serialization() {
let result = AvailabilityResult::new("example.com", false, "high", "rdap")
.with_details("Domain is registered");
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"available\":false"));
assert!(json.contains("\"confidence\":\"high\""));
}
fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
WhoisResponse {
domain: "example.test".to_string(),
registrar: registrar.map(str::to_string),
whois_server: "whois.test".to_string(),
raw_response: raw.to_string(),
..Default::default()
}
}
fn rdap_with(statuses: &[&str]) -> RdapResponse {
RdapResponse {
status: statuses.iter().map(|s| s.to_string()).collect(),
ldh_name: Some("example.test".to_string()),
..Default::default()
}
}
#[test]
fn rdap_success_registered_marks_taken_high_confidence() {
let rdap = rdap_with(&["active"]);
let r = decide_from_rdap("example.test", rdap);
assert!(!r.available, "registered domain must be marked taken");
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "rdap");
assert!(
r.details.as_deref().unwrap().contains("active"),
"details should include status list"
);
}
#[test]
fn rdap_success_empty_status_marks_taken_high_confidence() {
let rdap = rdap_with(&[]);
let r = decide_from_rdap("example.test", rdap);
assert!(!r.available);
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "rdap");
}
#[test]
fn rdap_success_redemption_period_marks_taken_medium_confidence() {
let rdap = rdap_with(&["redemption period"]);
let r = decide_from_rdap("example.test", rdap);
assert!(!r.available, "redemption period still means taken");
assert_eq!(r.confidence, "medium", "redemption drops confidence");
assert_eq!(r.method, "rdap");
assert!(r.details.as_deref().unwrap().contains("redemption"));
}
#[test]
fn rdap_success_pending_delete_marks_taken_medium_confidence() {
let rdap = rdap_with(&["pending delete"]);
let r = decide_from_rdap("example.test", rdap);
assert!(!r.available);
assert_eq!(r.confidence, "medium");
assert!(r.details.as_deref().unwrap().contains("redemption"));
}
#[test]
fn rdap_status_substring_redemption_not_misclassified() {
let rdap = rdap_with(&["clientHold (no redemption requested)"]);
let r = decide_from_rdap("example.test", rdap);
assert!(!r.available, "still registered");
assert_eq!(
r.confidence, "high",
"verbose status must not be downgraded to redemption/medium"
);
}
#[test]
fn rdap_status_redemption_detected_case_insensitively() {
let rdap = rdap_with(&["Redemption Period"]);
let r = decide_from_rdap("example.test", rdap);
assert_eq!(
r.confidence, "medium",
"standard token detected regardless of case"
);
}
#[test]
fn rdap_fail_whois_says_available_high_confidence() {
let whois = whois_with("No match for \"example.test\".\n", None);
let rdap_err = SeerError::RdapError("404 not found".to_string());
let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
assert!(r.available, "WHOIS 'no match' must mark available");
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "whois");
}
#[test]
fn rdap_fail_whois_says_registered_high_confidence() {
let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
let rdap_err = SeerError::RdapError("404 not found".to_string());
let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
assert!(!r.available);
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "whois");
assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
}
#[test]
fn rdap_fail_whois_without_registration_data_is_not_called_registered() {
let whois = whois_with("Domain Name: example.test\n", None);
let rdap_err = SeerError::RdapError("404".to_string());
let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
assert!(!r.available);
assert_eq!(r.confidence, "none");
assert_eq!(r.method, "inconclusive");
}
#[test]
fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
let rdap_err = SeerError::Timeout("rdap timed out".to_string());
let whois_err = SeerError::Timeout("whois timed out".to_string());
let r = decide_fallback(
"example.test",
&rdap_err,
Err(whois_err),
DnsPresence::Unknown,
);
assert!(
!r.available,
"inconclusive means NOT available (fail-safe default)"
);
assert_eq!(r.confidence, "none");
assert_eq!(r.method, "inconclusive");
assert!(r.details.as_deref().unwrap().contains("RDAP:"));
assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
}
#[test]
fn rdap_fail_whois_transport_error_with_phrase_not_available() {
let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
let whois_err =
SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
let r = decide_fallback(
"example.test",
&rdap_err,
Err(whois_err),
DnsPresence::Present,
);
assert!(
!r.available,
"transport error text must not infer availability"
);
}
#[test]
fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
let rdap_err = SeerError::RdapError("connection refused".to_string());
let whois_err = SeerError::WhoisError(
"failed to connect to whois.example: connection refused".to_string(),
);
let r = decide_fallback(
"example.test",
&rdap_err,
Err(whois_err),
DnsPresence::Unknown,
);
assert!(!r.available);
assert_eq!(r.confidence, "none");
assert_eq!(r.method, "inconclusive");
}
#[test]
fn rdap_404_with_blocked_whois_marks_available() {
let whois = whois_with("Requests of this client are not permitted.\n", None);
let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
assert!(
r.available,
"RDAP 404 must mark available even with blocked WHOIS"
);
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "rdap");
}
#[test]
fn rdap_404_with_whois_error_marks_available() {
let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
let whois_err = SeerError::WhoisError("connection refused".to_string());
let r = decide_fallback(
"example.test",
&rdap_err,
Err(whois_err),
DnsPresence::Unknown,
);
assert!(r.available);
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "rdap");
}
#[test]
fn rdap_404_but_whois_has_full_registration_marks_registered() {
let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
whois.creation_date = Some(chrono::Utc::now());
whois.nameservers = vec!["ns1.example.net".to_string()];
let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
assert!(
!r.available,
"concrete WHOIS registration must win over RDAP 404"
);
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "whois");
}
#[test]
fn thin_whois_non404_dns_absent_marks_likely_available() {
let whois = whois_with(
"Conditions of use for the whois service via port 43\n",
None,
);
let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
assert!(r.available);
assert_eq!(r.confidence, "medium");
assert_eq!(r.method, "dns_nxdomain");
}
#[test]
fn thin_whois_non404_dns_present_stays_unavailable() {
let whois = whois_with(
"Conditions of use for the whois service via port 43\n",
None,
);
let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
assert!(!r.available);
assert_ne!(r.method, "dns_nxdomain");
}
#[test]
fn thin_whois_non404_dns_unknown_is_inconclusive() {
let whois = whois_with(
"Conditions of use for the whois service via port 43\n",
None,
);
let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
assert!(!r.available);
assert_eq!(r.confidence, "none");
assert_eq!(r.method, "inconclusive");
assert!(r.details.is_some());
}
#[derive(Debug, PartialEq)]
enum Expect {
Registered,
Verdict(bool, &'static str, &'static str),
}
fn read(
rdap: Option<&SeerError>,
whois: std::result::Result<&WhoisResponse, &SeerError>,
dns: DnsPresence,
) -> Expect {
let v = match classify_fallback("example.test", rdap, whois) {
Fallback::Registered => return Expect::Registered,
Fallback::Verdict(v) => v,
Fallback::NeedsDns(t) => t.decide(dns),
};
assert!(v.details.is_some(), "every verdict explains itself: {v:?}");
Expect::Verdict(
v.available,
match v.confidence.as_str() {
"high" => "high",
"medium" => "medium",
"none" => "none",
other => panic!("unexpected confidence {other}"),
},
match v.method.as_str() {
"whois" => "whois",
"rdap" => "rdap",
"inconclusive" => "inconclusive",
"dns_nxdomain" => "dns_nxdomain",
"dns_present" => "dns_present",
other => panic!("unexpected method {other}"),
},
)
}
#[test]
fn fallback_ladder_covers_every_input_combination() {
use Expect::*;
let r404 = SeerError::RdapError("query failed with status 404 Not Found".to_string());
let r503 = SeerError::RdapError("query failed with status 503".to_string());
let no_match = whois_with("No match for \"EXAMPLE.TEST\".\n", None);
let mut registered = whois_with("Domain Name: example.test\n", Some("Registrar Inc."));
registered.creation_date = Some(chrono::Utc::now());
let thin = whois_with(
"Conditions of use for the whois service via port 43\n",
None,
);
let no_service = whois_with("TLD is not supported.\n", None);
let refusal = whois_with("Access rate limited; please try again later.\n", None);
let failed = SeerError::Timeout("whois timed out".to_string());
assert!(no_service.registry_unavailable() && !no_service.indicates_registry_refusal());
assert!(!thin.registry_unavailable() && !thin.indicates_registry_refusal());
let all_dns = [
DnsPresence::Absent,
DnsPresence::Present,
DnsPresence::Unknown,
];
for rdap in [Some(&r404), Some(&r503), None] {
let is_404 = matches!(rdap, Some(e) if rdap_error_is_404(e));
for dns in all_dns {
assert_eq!(
read(rdap, Ok(&no_match), dns),
Verdict(true, "high", "whois")
);
assert_eq!(read(rdap, Ok(®istered), dns), Registered);
if is_404 {
for w in [Ok(&thin), Ok(&no_service), Ok(&refusal), Err(&failed)] {
assert_eq!(read(rdap, w, dns), Verdict(true, "high", "rdap"));
}
continue;
}
assert_eq!(
read(rdap, Ok(&refusal), dns),
Verdict(false, "none", "inconclusive"),
"refusal is inconclusive whatever DNS says (#45)"
);
let expected = match dns {
DnsPresence::Absent => Verdict(true, "medium", "dns_nxdomain"),
DnsPresence::Present => Verdict(false, "medium", "dns_present"),
DnsPresence::Unknown => Verdict(false, "none", "inconclusive"),
};
for w in [Ok(&thin), Ok(&no_service), Err(&failed)] {
assert_eq!(read(rdap, w, dns), expected, "{rdap:?} {w:?} {dns:?}");
}
}
}
}
#[test]
fn both_legs_failed_dns_absent_marks_likely_available() {
let rdap_err = SeerError::Timeout("rdap timed out".to_string());
let whois_err = SeerError::WhoisError("connection refused".to_string());
let r = decide_fallback(
"example.test",
&rdap_err,
Err(whois_err),
DnsPresence::Absent,
);
assert!(r.available);
assert_eq!(r.confidence, "medium");
assert_eq!(r.method, "dns_nxdomain");
}
#[test]
fn both_legs_failed_dns_present_marks_likely_registered() {
let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.ru".to_string());
let whois_err = SeerError::Timeout(
"Operation failed after 3 attempts: Operation timed out".to_string(),
);
let r = decide_fallback(
"example.ru",
&rdap_err,
Err(whois_err),
DnsPresence::Present,
);
assert!(
!r.available,
"a delegated apex must never read as available"
);
assert_eq!(r.confidence, "medium");
assert_eq!(r.method, "dns_present");
assert_eq!(r.verdict(), "likely_registered");
assert!(
r.details.as_deref().unwrap().contains("delegated"),
"details should explain the DNS-delegation evidence"
);
}
#[test]
fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
let whois = whois_with(
"Access rate limited; no data found for unauthenticated clients\n",
None,
);
let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
assert!(
!r.available,
"refusal/throttle must not be called available"
);
assert_eq!(r.confidence, "none");
assert_eq!(r.method, "inconclusive");
}
#[test]
fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
let whois = whois_with(
"This domain name is not available for registration.\n",
None,
);
let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
assert!(!r.available);
assert_eq!(r.confidence, "none");
assert_eq!(r.method, "inconclusive");
}
#[test]
fn rdap_404_with_refusal_whois_still_marks_available() {
let whois = whois_with("Access rate limited; please try again later.\n", None);
let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
assert!(r.available, "RDAP 404 is authoritative over a refusal body");
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "rdap");
}
#[tokio::test]
async fn check_with_prior_reuses_rdap_response_without_network() {
let checker = AvailabilityChecker::new();
let rdap = rdap_with(&["active"]);
let r = checker
.check_with_prior(
"example.test",
PriorRdap::Response(Box::new(rdap)),
PriorWhois::Missing,
)
.await
.expect("prior-response path must not error");
assert!(!r.available, "an existing RDAP object means registered");
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "rdap");
}
#[tokio::test]
async fn check_with_prior_reuses_redemption_status_verdict() {
let checker = AvailabilityChecker::new();
let rdap = rdap_with(&["redemption period"]);
let r = checker
.check_with_prior(
"example.test",
PriorRdap::Response(Box::new(rdap)),
PriorWhois::Missing,
)
.await
.expect("prior-response path must not error");
assert!(!r.available);
assert_eq!(r.confidence, "medium");
assert_eq!(r.method, "rdap");
}
fn denic_whois() -> WhoisResponse {
let mut w = whois_with(
"Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n",
None,
);
w.nameservers = vec!["ns1.example.net".to_string()];
w.status = vec!["active".to_string()];
w
}
#[test]
fn whois_with_nameservers_is_not_thin() {
assert!(whois_with("", None).is_thin());
assert!(!denic_whois().is_thin());
}
#[test]
fn denic_style_whois_without_registrar_or_dates_is_registered() {
let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
let r = decide_fallback(
"example.de",
&rdap_err,
Ok(denic_whois()),
DnsPresence::Absent,
);
assert!(!r.available, "a delegated DENIC record is registered");
assert_eq!(r.confidence, "high");
assert_eq!(r.method, "whois");
}
fn avail(domain: &str, available: bool, confidence: &str) -> AvailabilityResult {
AvailabilityResult::new(domain, available, confidence, "rdap")
}
#[test]
fn subdomain_under_registered_parent_is_registered_not_available() {
let r = subdomain_verdict("mail.google.com", &avail("google.com", false, "high"));
assert!(!r.available);
assert_eq!(r.domain, "mail.google.com");
assert_eq!(r.verdict(), "registered");
assert_eq!(r.method, "registrable_parent");
let details = r.details.unwrap();
assert!(details.contains("google.com is registered"), "{details}");
}
#[test]
fn subdomain_under_available_parent_is_never_claimed_available() {
let r = subdomain_verdict("mail.freebrand.com", &avail("freebrand.com", true, "high"));
assert!(!r.available, "a subdomain itself is never registrable");
assert_eq!(r.verdict(), "unknown");
assert!(r.details.unwrap().contains("register freebrand.com"));
let r = subdomain_verdict("a.b.co.uk", &avail("b.co.uk", false, "none"));
assert!(!r.available);
assert_eq!(r.verdict(), "unknown");
assert!(r.details.unwrap().contains("could not be determined"));
}
#[tokio::test]
async fn subdomain_guard_leaves_registrable_names_and_negative_results_alone() {
let checker = AvailabilityChecker::new();
let r = checker
.guard_subdomain_claim(avail("example.co.uk", true, "high"))
.await;
assert!(r.available);
assert_eq!(r.method, "rdap");
let r = checker
.guard_subdomain_claim(avail("mail.example.com", false, "high"))
.await;
assert!(!r.available);
assert_eq!(r.method, "rdap");
}
}