use std::sync::Arc;
use std::time::Duration;
use crate::cache::{MemoryCache, NullCache, ResponseCache};
use crate::client::lookup::{Explanation, Lookup};
use crate::client::plan::{self, Plan, Preference, ReferralPolicy};
use crate::client::Parts;
use crate::detect::{DetectionEngine, Evidence};
use crate::domain::{Availability, DomainName, Tld};
use crate::error::{Error, Result};
use crate::registry::{Endpoint, RegistryProvider, WhoisEndpoint};
use crate::transport::{
CachingTransport, RawResponse, RetryPolicy, RetryTransport, Router, ThrottlePolicy,
ThrottleTransport, Transport, TransportConfig, Whois43Transport,
};
#[cfg(feature = "parser")]
use crate::parser::{CompositeParser, WhoisRecord};
#[derive(Debug, Clone)]
pub struct WhoisClient {
parts: Parts,
transport: Arc<dyn Transport>,
}
impl WhoisClient {
pub fn new() -> Result<Self> {
WhoisClient::builder().build()
}
pub fn builder() -> WhoisClientBuilder {
WhoisClientBuilder::new()
}
pub fn lookup(&self, domain: &str) -> Result<Lookup> {
self.lookup_name(&DomainName::parse(domain)?)
}
pub fn lookup_name(&self, name: &DomainName) -> Result<Lookup> {
let resolution = self.parts.registry.resolve(name)?;
let plan = Plan::build(resolution, self.parts.preference)?;
let (verdict, mut responses) = self.first_usable_answer(&plan)?;
if verdict.availability.is_registered() {
self.follow_referrals(&plan, &mut responses);
}
#[cfg(feature = "parser")]
let record = self
.parts
.parser
.parse_all(&responses)
.ok()
.filter(|record| !record.is_empty());
Ok(Lookup {
queried: plan.resolution.queried.clone(),
domain: plan.resolution.registrable.clone(),
tld: plan.resolution.tld.clone(),
verdict,
responses,
#[cfg(feature = "parser")]
record,
})
}
pub fn availability(&self, domain: &str) -> Result<Availability> {
Ok(self.lookup(domain)?.availability())
}
pub fn is_available(&self, domain: &str) -> Result<bool> {
Ok(self.lookup(domain)?.is_available())
}
pub fn explain(&self, domain: &str) -> Result<Explanation> {
let lookup = self.lookup(domain)?;
let registry = self.parts.registry.get(&lookup.tld);
let report = match lookup.primary_response() {
Some(response) => {
let evidence = Evidence::from_response(response, &lookup.tld, registry.as_deref());
self.parts.engine.report(&evidence)
}
None => {
let evidence = Evidence::new(
"",
crate::transport::ResponseKind::WhoisText,
&lookup.tld,
registry.as_deref(),
);
self.parts.engine.report(&evidence)
}
};
Ok(Explanation { lookup, report })
}
#[cfg(feature = "parser")]
pub fn record(&self, domain: &str) -> Result<WhoisRecord> {
let lookup = self.lookup(domain)?;
let consulted = || {
lookup
.consulted()
.iter()
.map(Endpoint::address)
.collect::<Vec<_>>()
.join(", ")
};
if !lookup.availability().is_registered() {
return Err(Error::Inconclusive {
domain: lookup.domain.as_ascii().to_string(),
consulted: consulted(),
detail: format!(
"no registration record: the domain is {}",
lookup.availability()
),
});
}
lookup.record.clone().ok_or_else(|| Error::Inconclusive {
domain: lookup.domain.as_ascii().to_string(),
consulted: consulted(),
detail: "the domain is registered but no field of its record could be parsed"
.to_string(),
})
}
pub fn registry(&self) -> &Arc<dyn RegistryProvider> {
&self.parts.registry
}
pub fn engine(&self) -> &Arc<DetectionEngine> {
&self.parts.engine
}
pub fn transport_name(&self) -> String {
self.transport.name()
}
pub fn supported_tlds(&self) -> Vec<Tld> {
self.parts.registry.tlds()
}
pub fn can_lookup(&self, domain: &str) -> bool {
DomainName::parse(domain)
.map(|name| self.parts.registry.can_resolve(&name))
.unwrap_or(false)
}
fn first_usable_answer(
&self,
plan: &Plan,
) -> Result<(crate::detect::Verdict, Vec<RawResponse>)> {
let mut failures: Vec<(Endpoint, Error)> = Vec::new();
for endpoint in &plan.attempts {
let query = plan.query(endpoint);
let response = match self.transport.fetch(&query) {
Ok(response) => response,
Err(error) => {
if error.is_endpoint_failure() {
failures.push((endpoint.clone(), error));
continue;
}
return Err(error);
}
};
match plan::interpret(&self.parts.engine, &response, &plan.resolution) {
Ok(verdict) => return Ok((verdict, vec![response])),
Err(error) => failures.push((endpoint.clone(), error)),
}
}
Err(plan::combined_failure(
plan.resolution.registrable.as_ascii(),
failures,
))
}
fn follow_referrals(&self, plan: &Plan, responses: &mut Vec<RawResponse>) {
let mut visited: Vec<WhoisEndpoint> = responses
.iter()
.filter_map(|response| match response.endpoint() {
Endpoint::Whois(endpoint) => Some(endpoint.clone()),
Endpoint::Rdap(_) => None,
})
.collect();
loop {
let Some(last) = responses.last() else { return };
let Some(next) = plan::next_referral(
last,
&plan.resolution.registry,
self.parts.referrals,
&visited,
) else {
return;
};
let endpoint = Endpoint::Whois(next.clone());
let query = plan.query(&endpoint);
match self.transport.fetch(&query) {
Ok(response) => {
visited.push(next);
responses.push(response);
}
Err(_) => return,
}
}
}
}
#[derive(Debug, Default)]
pub struct WhoisClientBuilder {
registry: Option<Arc<dyn RegistryProvider>>,
engine: Option<Arc<DetectionEngine>>,
#[cfg(feature = "parser")]
parser: Option<Arc<CompositeParser>>,
transport: Option<Arc<dyn Transport>>,
cache: Option<Arc<dyn ResponseCache>>,
config: TransportConfig,
retry: RetryPolicy,
throttle: ThrottlePolicy,
preference: Preference,
referrals: ReferralPolicy,
}
impl WhoisClientBuilder {
pub fn new() -> Self {
WhoisClientBuilder {
registry: None,
engine: None,
#[cfg(feature = "parser")]
parser: None,
transport: None,
cache: None,
config: TransportConfig::default(),
retry: RetryPolicy::default(),
throttle: ThrottlePolicy::default(),
preference: Preference::default(),
referrals: ReferralPolicy::default(),
}
}
pub fn registry(mut self, registry: impl RegistryProvider + 'static) -> Self {
self.registry = Some(Arc::new(registry));
self
}
pub fn shared_registry(mut self, registry: Arc<dyn RegistryProvider>) -> Self {
self.registry = Some(registry);
self
}
pub fn engine(mut self, engine: DetectionEngine) -> Self {
self.engine = Some(Arc::new(engine));
self
}
#[cfg(feature = "parser")]
pub fn parser(mut self, parser: CompositeParser) -> Self {
self.parser = Some(Arc::new(parser));
self
}
pub fn transport(mut self, transport: impl Transport + 'static) -> Self {
self.transport = Some(Arc::new(transport));
self
}
pub fn shared_transport(mut self, transport: Arc<dyn Transport>) -> Self {
self.transport = Some(transport);
self
}
pub fn timeouts(mut self, config: TransportConfig) -> Self {
self.config = config;
self
}
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.config.connect_timeout = timeout;
self
}
pub fn read_timeout(mut self, timeout: Duration) -> Self {
self.config.read_timeout = timeout;
self
}
pub fn retry(mut self, policy: RetryPolicy) -> Self {
self.retry = policy;
self
}
pub fn throttle(mut self, policy: ThrottlePolicy) -> Self {
self.throttle = policy;
self
}
pub fn throttle_per_host(mut self, gap: Duration) -> Self {
self.throttle = ThrottlePolicy::per_host(gap);
self
}
pub fn memory_cache(mut self, ttl: Duration) -> Self {
self.cache = Some(Arc::new(MemoryCache::with_ttl(ttl)));
self
}
pub fn cache(mut self, cache: impl ResponseCache + 'static) -> Self {
self.cache = Some(Arc::new(cache));
self
}
pub fn shared_cache(mut self, cache: Arc<dyn ResponseCache>) -> Self {
self.cache = Some(cache);
self
}
pub fn prefer(mut self, preference: Preference) -> Self {
self.preference = preference;
self
}
pub fn referrals(mut self, policy: ReferralPolicy) -> Self {
self.referrals = policy;
self
}
pub fn build(self) -> Result<WhoisClient> {
let transport = match &self.transport {
Some(transport) => Arc::clone(transport),
None => self.default_transport()?,
};
let parts = Parts::new(self.registry, self.engine, self.preference, self.referrals);
#[cfg(feature = "parser")]
let parts = parts.with_parser(self.parser);
Ok(WhoisClient { parts, transport })
}
fn default_transport(&self) -> Result<Arc<dyn Transport>> {
#[cfg(feature = "rdap")]
let router = Router::new()
.with(Whois43Transport::with_config(self.config))
.with(crate::transport::RdapTransport::with_config(self.config)?);
#[cfg(not(feature = "rdap"))]
let router = Router::new().with(Whois43Transport::with_config(self.config));
let throttled = ThrottleTransport::new(router, self.throttle.clone());
let retried = RetryTransport::new(throttled, self.retry);
Ok(match &self.cache {
Some(cache) => Arc::new(CachingTransport::new(retried, Arc::clone(cache))),
None => Arc::new(CachingTransport::new(retried, NullCache)),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::JsonRegistry;
use crate::transport::{MockTransport, Scripted};
fn registry() -> JsonRegistry {
JsonRegistry::from_json(
r#"{"registries":[
{"tlds":["test"],"whois":"registry.test","available":["No match for"],"thin":true},
{"tlds":["thick"],"whois":"registry.thick","available":["No match for"]},
{"tlds":["both"],"whois":"registry.both","rdap":["https://rdap.both/"]}
]}"#,
"test",
)
.unwrap()
}
fn client(transport: MockTransport) -> WhoisClient {
WhoisClient::builder()
.registry(registry())
.transport(transport)
.build()
.unwrap()
}
const REGISTERED: &str = "\
Domain Name: EXAMPLE.TEST
Registrar: Example Registrar, LLC
Registrar WHOIS Server: registrar.test
Domain Status: clientTransferProhibited
";
#[test]
fn an_available_domain_is_reported_available() {
let client = client(MockTransport::answering("No match for \"NOTHERE.TEST\""));
let lookup = client.lookup("nothere.test").unwrap();
assert_eq!(lookup.availability(), Availability::Available);
assert!(lookup.is_available());
assert_eq!(lookup.verdict.rule, "registry-marker");
}
#[test]
fn a_registered_domain_is_reported_registered() {
let client = client(MockTransport::routed([
("registry.test".to_string(), REGISTERED.to_string()),
(
"registrar.test".to_string(),
"Registrant Name: Ada\n".to_string(),
),
]));
let lookup = client.lookup("example.test").unwrap();
assert!(lookup.is_registered());
}
#[test]
fn a_thin_registrys_referral_is_followed() {
let transport = MockTransport::routed([
("registry.test".to_string(), REGISTERED.to_string()),
(
"registrar.test".to_string(),
"Registrant Name: Ada Lovelace\nRegistrant Country: GB\n".to_string(),
),
]);
let client = client(transport.clone());
let lookup = client.lookup("example.test").unwrap();
assert!(lookup.followed_referral());
assert_eq!(transport.contacted(), ["registry.test", "registrar.test"]);
#[cfg(feature = "parser")]
{
let record = lookup.record.as_ref().unwrap();
assert_eq!(record.registrar.as_deref(), Some("Example Registrar, LLC"));
assert_eq!(
record.registrant.as_ref().unwrap().name.as_deref(),
Some("Ada Lovelace"),
"the referral's contacts should be merged in"
);
}
}
#[test]
fn a_thick_registrys_referral_is_left_alone() {
let transport = MockTransport::routed([(
"registry.thick".to_string(),
REGISTERED.replace("EXAMPLE.TEST", "EXAMPLE.THICK"),
)]);
let client = client(transport.clone());
client.lookup("example.thick").unwrap();
assert_eq!(
transport.contacted(),
["registry.thick"],
"no referral expected"
);
}
#[test]
fn no_referral_is_chased_for_a_free_name() {
let transport = MockTransport::answering("No match for \"NOTHERE.TEST\"");
let client = client(transport.clone());
client.lookup("nothere.test").unwrap();
assert_eq!(
transport.call_count(),
1,
"a free name has no record to fetch"
);
}
#[test]
fn a_failed_referral_costs_only_the_extra_detail() {
let transport =
MockTransport::routed([("registry.test".to_string(), REGISTERED.to_string())]);
let client = client(transport);
let lookup = client.lookup("example.test").unwrap();
assert!(lookup.is_registered());
assert!(!lookup.followed_referral());
}
#[test]
fn a_refused_endpoint_falls_through_to_the_next() {
let transport = MockTransport::routed([
(
"registry.both".to_string(),
"%% queries limit exceeded".to_string(),
),
(
"https://rdap.both/".to_string(),
r#"{"errorCode":404,"title":"Not Found"}"#.to_string(),
),
]);
let client = client(transport.clone());
let lookup = client.lookup("nothere.both").unwrap();
assert_eq!(lookup.availability(), Availability::Available);
assert_eq!(lookup.verdict.rule, "rdap");
assert_eq!(transport.contacted().len(), 2);
}
#[test]
fn when_every_endpoint_fails_the_error_names_them_all() {
let transport = MockTransport::new(vec![
Scripted::Fail(Error::Timeout {
server: "registry.both".into(),
elapsed: Duration::ZERO,
}),
Scripted::Fail(Error::Http {
url: "https://rdap.both/".into(),
status: 500,
}),
]);
let client = client(transport);
match client.lookup("example.both").unwrap_err() {
Error::Inconclusive { consulted, .. } => {
assert!(consulted.contains("registry.both"), "{consulted}");
assert!(consulted.contains("rdap.both"), "{consulted}");
}
other => panic!("got {other:?}"),
}
}
#[test]
fn an_unsupported_suffix_is_rejected_before_any_query() {
let transport = MockTransport::answering("should not be reached");
let client = client(transport.clone());
assert!(matches!(
client.lookup("example.unknown").unwrap_err(),
Error::UnsupportedTld { .. }
));
assert_eq!(transport.call_count(), 0);
}
#[test]
fn invalid_input_is_rejected_before_any_query() {
let transport = MockTransport::answering("should not be reached");
let client = client(transport.clone());
for input in ["", "localhost", "192.0.2.1", "-bad.test"] {
assert!(
matches!(
client.lookup(input).unwrap_err(),
Error::InvalidDomain { .. }
),
"accepted {input:?}"
);
}
assert_eq!(transport.call_count(), 0);
}
#[test]
fn a_subdomain_is_trimmed_to_the_registrable_name() {
let transport = MockTransport::answering("No match for \"EXAMPLE.TEST\"");
let client = client(transport.clone());
let lookup = client.lookup("www.mail.example.test").unwrap();
assert_eq!(lookup.queried.as_ascii(), "www.mail.example.test");
assert_eq!(lookup.domain.as_ascii(), "example.test");
assert_eq!(transport.wire_names(), ["example.test"]);
}
#[test]
fn urls_are_accepted_as_input() {
let transport = MockTransport::answering("No match for \"EXAMPLE.TEST\"");
let client = client(transport.clone());
client.lookup("HTTPS://Example.TEST:443/path?q=1").unwrap();
assert_eq!(transport.wire_names(), ["example.test"]);
}
#[test]
fn preference_can_exclude_a_protocol() {
let transport = MockTransport::answering(r#"{"errorCode":404}"#);
let client = WhoisClient::builder()
.registry(registry())
.transport(transport.clone())
.prefer(Preference::RdapOnly)
.build()
.unwrap();
client.lookup("nothere.both").unwrap();
assert_eq!(transport.contacted(), ["https://rdap.both/"]);
assert!(matches!(
client.lookup("nothere.test").unwrap_err(),
Error::NoEndpoint { .. }
));
}
#[test]
fn explain_reports_every_rules_opinion() {
let client = client(MockTransport::answering(REGISTERED));
let explanation = client.explain("example.test").unwrap();
assert_eq!(explanation.report.judgements.len(), 9);
assert!(explanation.report.outcome.is_ok());
assert!(explanation.to_string().contains("example.test"));
}
#[test]
fn availability_helpers_agree_with_the_lookup() {
let client = client(MockTransport::answering("No match for \"X.TEST\""));
assert_eq!(
client.availability("x.test").unwrap(),
Availability::Available
);
assert!(client.is_available("x.test").unwrap());
}
#[test]
fn can_lookup_reflects_the_registry() {
let client = client(MockTransport::answering("x"));
assert!(client.can_lookup("example.test"));
assert!(!client.can_lookup("example.unknown"));
assert!(!client.can_lookup("not a domain"));
assert_eq!(client.supported_tlds().len(), 3);
}
#[cfg(feature = "parser")]
#[test]
fn record_returns_the_parsed_registration() {
let client = client(MockTransport::routed([(
"registry.test".to_string(),
REGISTERED.to_string(),
)]));
let record = client.record("example.test").unwrap();
assert_eq!(record.registrar.as_deref(), Some("Example Registrar, LLC"));
assert!(record.is_transfer_locked());
}
#[cfg(feature = "parser")]
#[test]
fn record_of_a_free_name_is_an_error_not_an_empty_struct() {
let client = client(MockTransport::answering("No match for \"NOTHERE.TEST\""));
match client.record("nothere.test").unwrap_err() {
Error::Inconclusive { detail, .. } => assert!(detail.contains("available"), "{detail}"),
other => panic!("got {other:?}"),
}
}
#[test]
fn the_default_stack_is_layered_as_documented() {
let client = WhoisClient::new().unwrap();
let name = client.transport_name();
assert!(name.starts_with("cached("), "{name}");
assert!(name.contains("retry("), "{name}");
assert!(name.contains("throttle("), "{name}");
assert!(name.contains("whois43"), "{name}");
}
#[test]
fn a_client_is_cheap_to_clone_and_shares_its_transport() {
let transport = MockTransport::answering("No match for \"X.TEST\"");
let client = client(transport.clone());
let clone = client.clone();
client.lookup("a.test").unwrap();
clone.lookup("b.test").unwrap();
assert_eq!(transport.call_count(), 2, "clones must share one transport");
}
#[test]
fn a_client_is_shareable_across_threads() {
let transport = MockTransport::answering("No match for \"X.TEST\"");
let client = Arc::new(client(transport.clone()));
let handles: Vec<_> = (0..4)
.map(|index| {
let client = Arc::clone(&client);
std::thread::spawn(move || client.lookup(&format!("d{index}.test")).is_ok())
})
.collect();
for handle in handles {
assert!(handle.join().unwrap());
}
assert_eq!(transport.call_count(), 4);
}
}