use crate::domain::Tld;
use crate::error::{Error, Result};
use crate::registry::{Endpoint, EndpointKind, Registry, Resolution};
use crate::transport::Query;
#[cfg(any(feature = "blocking", feature = "async"))]
use crate::detect::{DetectionEngine, Evidence, Verdict};
#[cfg(any(feature = "blocking", feature = "async"))]
use crate::registry::WhoisEndpoint;
#[cfg(any(feature = "blocking", feature = "async"))]
use crate::transport::{referral_host, RawResponse};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Preference {
#[default]
Whois,
Rdap,
RegistryOrder,
WhoisOnly,
RdapOnly,
}
impl Preference {
pub fn order(self, registry: &Registry) -> Vec<Endpoint> {
let whois = || registry.endpoints_matching(EndpointKind::Whois).cloned();
let rdap = || registry.endpoints_matching(EndpointKind::Rdap).cloned();
match self {
Preference::Whois => whois().chain(rdap()).collect(),
Preference::Rdap => rdap().chain(whois()).collect(),
Preference::RegistryOrder => registry.endpoints().to_vec(),
Preference::WhoisOnly => whois().collect(),
Preference::RdapOnly => rdap().collect(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReferralPolicy {
pub max_hops: u8,
pub always: bool,
}
impl ReferralPolicy {
pub const DEFAULT: ReferralPolicy = ReferralPolicy {
max_hops: 1,
always: false,
};
pub const NONE: ReferralPolicy = ReferralPolicy {
max_hops: 0,
always: false,
};
pub fn eager(hops: u8) -> Self {
ReferralPolicy {
max_hops: hops,
always: true,
}
}
pub fn is_enabled(self) -> bool {
self.max_hops > 0
}
}
impl Default for ReferralPolicy {
fn default() -> Self {
ReferralPolicy::DEFAULT
}
}
#[derive(Debug, Clone)]
pub struct Plan {
pub resolution: Resolution,
pub attempts: Vec<Endpoint>,
pub wire_name: String,
}
impl Plan {
pub fn build(resolution: Resolution, preference: Preference) -> Result<Self> {
let attempts = preference.order(&resolution.registry);
if attempts.is_empty() {
let available: Vec<String> = resolution
.registry
.endpoints()
.iter()
.map(Endpoint::to_string)
.collect();
return Err(Error::NoEndpoint {
tld: resolution.tld.clone(),
detail: if available.is_empty() {
"the registry definition lists no endpoint".to_string()
} else {
format!(
"{preference:?} excludes every endpoint the registry has: {}",
available.join(", ")
)
},
});
}
let wire_name = resolution.wire_form();
Ok(Plan {
resolution,
attempts,
wire_name,
})
}
pub fn tld(&self) -> &Tld {
&self.resolution.tld
}
pub fn query(&self, endpoint: &Endpoint) -> Query {
Query::new(
endpoint.clone(),
self.wire_name.clone(),
self.resolution.tld.clone(),
)
}
pub fn queries(&self) -> Vec<Query> {
self.attempts
.iter()
.map(|endpoint| self.query(endpoint))
.collect()
}
}
#[cfg(any(feature = "blocking", feature = "async"))]
pub fn next_referral(
response: &RawResponse,
registry: &Registry,
policy: ReferralPolicy,
visited: &[WhoisEndpoint],
) -> Option<WhoisEndpoint> {
if !policy.is_enabled() || visited.len() > policy.max_hops as usize {
return None;
}
if !policy.always && !registry.is_thin() {
return None;
}
let host = referral_host(response.text())?;
let endpoint = WhoisEndpoint::parse(&host).ok()?;
if visited
.iter()
.any(|seen| seen.host().eq_ignore_ascii_case(endpoint.host()))
{
return None;
}
Some(endpoint)
}
#[cfg(any(feature = "blocking", feature = "async"))]
pub fn interpret(
engine: &DetectionEngine,
response: &RawResponse,
resolution: &Resolution,
) -> Result<Verdict> {
let evidence = Evidence::from_response(
response,
&resolution.tld,
Some(resolution.registry.as_ref()),
);
engine.decide(&evidence)
}
#[cfg(any(feature = "blocking", feature = "async"))]
pub fn combined_failure(name: &str, mut failures: Vec<(Endpoint, Error)>) -> Error {
if let Some(index) = failures
.iter()
.position(|(_, error)| matches!(error, Error::Refused { .. }))
{
return failures.swap_remove(index).1;
}
let consulted = failures
.iter()
.map(|(endpoint, _)| endpoint.address())
.collect::<Vec<_>>()
.join(", ");
let detail = failures
.iter()
.map(|(endpoint, error)| format!("{}: {error}", endpoint.address()))
.collect::<Vec<_>>()
.join("; ");
Error::Inconclusive {
domain: name.to_string(),
consulted: if consulted.is_empty() {
"no endpoint".to_string()
} else {
consulted
},
detail,
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use super::*;
use crate::domain::DomainName;
use crate::registry::Registry;
use crate::transport::ResponseKind;
fn resolution(registry: Registry) -> Resolution {
let tld = Tld::parse("com").unwrap();
let name = DomainName::parse("example.com").unwrap();
Resolution {
queried: name.clone(),
registrable: name,
tld,
registry: Arc::new(registry),
}
}
fn both_protocols() -> Registry {
Registry::builder([Tld::parse("com").unwrap()])
.endpoint(Endpoint::whois("whois.verisign-grs.com"))
.endpoint(Endpoint::rdap("https://rdap.verisign.com/com/v1/"))
.build()
}
fn response(text: &str) -> RawResponse {
RawResponse::new(
Endpoint::whois("whois.verisign-grs.com"),
ResponseKind::WhoisText,
text,
Duration::ZERO,
)
}
#[test]
fn preference_orders_and_filters_endpoints() {
let registry = both_protocols();
let whois_first = Preference::Whois.order(®istry);
assert!(whois_first[0].is_whois());
assert!(whois_first[1].is_rdap());
let rdap_first = Preference::Rdap.order(®istry);
assert!(rdap_first[0].is_rdap());
assert!(rdap_first[1].is_whois());
assert_eq!(Preference::WhoisOnly.order(®istry).len(), 1);
assert!(Preference::WhoisOnly.order(®istry)[0].is_whois());
assert!(Preference::RdapOnly.order(®istry)[0].is_rdap());
assert_eq!(
Preference::RegistryOrder.order(®istry),
registry.endpoints()
);
}
#[test]
fn the_default_preference_is_whois_first() {
assert_eq!(Preference::default(), Preference::Whois);
}
#[test]
fn a_plan_lists_the_endpoints_to_try() {
let plan = Plan::build(resolution(both_protocols()), Preference::Whois).unwrap();
assert_eq!(plan.attempts.len(), 2);
assert_eq!(plan.wire_name, "example.com");
assert_eq!(plan.tld().ascii(), "com");
assert_eq!(plan.queries().len(), 2);
}
#[test]
fn a_plan_that_excludes_everything_says_so() {
let whois_only = Registry::builder([Tld::parse("com").unwrap()])
.endpoint(Endpoint::whois("whois.example"))
.build();
let error = Plan::build(resolution(whois_only), Preference::RdapOnly).unwrap_err();
match error {
Error::NoEndpoint { detail, .. } => {
assert!(detail.contains("RdapOnly"), "{detail}");
assert!(detail.contains("whois.example"), "{detail}");
}
other => panic!("got {other:?}"),
}
}
#[test]
fn a_registry_with_no_endpoint_says_so() {
let empty = Registry::builder([Tld::parse("com").unwrap()]).build();
let error = Plan::build(resolution(empty), Preference::Whois).unwrap_err();
match error {
Error::NoEndpoint { detail, .. } => assert!(detail.contains("no endpoint"), "{detail}"),
other => panic!("got {other:?}"),
}
}
#[test]
fn the_wire_name_follows_the_registrys_idn_preference() {
let tld = Tld::parse("de").unwrap();
let name = DomainName::parse("münchen.de").unwrap();
let registry = Registry::builder([tld.clone()])
.endpoint(Endpoint::whois("whois.denic.de"))
.idn_form(crate::registry::IdnForm::Unicode)
.build();
let plan = Plan::build(
Resolution {
queried: name.clone(),
registrable: name,
tld,
registry: Arc::new(registry),
},
Preference::Whois,
)
.unwrap();
assert_eq!(plan.wire_name, "münchen.de");
}
#[cfg(any(feature = "blocking", feature = "async"))]
mod client_driven {
use super::*;
const THIN_ANSWER: &str = "\
Domain Name: EXAMPLE.COM
Registrar: Example Registrar, LLC
Registrar WHOIS Server: whois.example-registrar.com
";
#[test]
fn a_thin_registry_referral_is_followed() {
let registry = Registry::builder([Tld::parse("com").unwrap()])
.thin(true)
.build();
let visited = vec![WhoisEndpoint::new("whois.verisign-grs.com", 43)];
let next = next_referral(
&response(THIN_ANSWER),
®istry,
ReferralPolicy::DEFAULT,
&visited,
);
assert_eq!(next.unwrap().host(), "whois.example-registrar.com");
}
#[test]
fn a_thick_registry_referral_is_not_followed_by_default() {
let thick = Registry::builder([Tld::parse("com").unwrap()]).build();
assert!(
next_referral(&response(THIN_ANSWER), &thick, ReferralPolicy::DEFAULT, &[])
.is_none()
);
assert!(next_referral(
&response(THIN_ANSWER),
&thick,
ReferralPolicy::eager(1),
&[]
)
.is_some());
}
#[test]
fn referral_chasing_can_be_switched_off() {
let thin = Registry::builder([Tld::parse("com").unwrap()])
.thin(true)
.build();
assert!(
next_referral(&response(THIN_ANSWER), &thin, ReferralPolicy::NONE, &[]).is_none()
);
assert!(!ReferralPolicy::NONE.is_enabled());
}
#[test]
fn the_hop_budget_is_respected() {
let thin = Registry::builder([Tld::parse("com").unwrap()])
.thin(true)
.build();
let visited = vec![
WhoisEndpoint::new("whois.verisign-grs.com", 43),
WhoisEndpoint::new("whois.first-registrar.com", 43),
];
assert!(next_referral(
&response(THIN_ANSWER),
&thin,
ReferralPolicy::DEFAULT,
&visited
)
.is_none());
}
#[test]
fn a_referral_loop_is_not_followed() {
let thin = Registry::builder([Tld::parse("com").unwrap()])
.thin(true)
.build();
let visited = vec![WhoisEndpoint::new("whois.example-registrar.com", 43)];
assert!(
next_referral(
&response(THIN_ANSWER),
&thin,
ReferralPolicy::DEFAULT,
&visited
)
.is_none(),
"a referral back to a server already asked must not be followed"
);
}
#[test]
fn a_record_with_no_referral_ends_the_chain() {
let thin = Registry::builder([Tld::parse("com").unwrap()])
.thin(true)
.build();
let complete = response("Domain Name: EXAMPLE.DE\nStatus: connect\n");
assert!(next_referral(&complete, &thin, ReferralPolicy::DEFAULT, &[]).is_none());
}
#[test]
fn a_refusal_outranks_a_connection_failure() {
let failures = vec![
(
Endpoint::whois("a.example"),
Error::Connect {
server: "a.example".into(),
source: std::io::Error::other("no route"),
},
),
(
Endpoint::rdap("https://b.example/"),
Error::Refused {
server: "b.example".into(),
reason: crate::error::Refusal::RateLimited,
},
),
];
let combined = combined_failure("example.com", failures);
assert!(
matches!(combined, Error::Refused { .. }),
"a definite refusal is more useful than a transport failure: got {combined:?}"
);
}
#[test]
fn transport_failures_are_reported_together() {
let failures = vec![
(
Endpoint::whois("a.example"),
Error::Timeout {
server: "a.example".into(),
elapsed: Duration::from_secs(5),
},
),
(
Endpoint::rdap("https://b.example/"),
Error::Http {
url: "https://b.example/".into(),
status: 500,
},
),
];
match combined_failure("example.com", failures) {
Error::Inconclusive {
consulted, detail, ..
} => {
assert!(consulted.contains("a.example"), "{consulted}");
assert!(consulted.contains("b.example"), "{consulted}");
assert!(detail.contains("500"), "{detail}");
}
other => panic!("got {other:?}"),
}
}
#[test]
fn no_failures_at_all_still_produces_a_usable_error() {
match combined_failure("example.com", Vec::new()) {
Error::Inconclusive { consulted, .. } => assert_eq!(consulted, "no endpoint"),
other => panic!("got {other:?}"),
}
}
}
}