monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! [`Lookup`]: everything one query established.

use std::fmt;

use crate::detect::{DetectionReport, Verdict};
use crate::domain::{Availability, DomainName, Tld};
use crate::registry::Endpoint;
use crate::transport::RawResponse;

#[cfg(feature = "parser")]
use crate::parser::WhoisRecord;

/// The result of looking one domain up.
///
/// Carries the verdict, why it was reached, every response that contributed, and —
/// with the `parser` feature — the parsed record. The raw responses are kept
/// deliberately: a caller who disagrees with the verdict needs to see what the
/// server actually said, and a support ticket about a wrong answer is unanswerable
/// without it.
#[derive(Debug, Clone)]
pub struct Lookup {
    /// The name as the caller asked about it, normalised.
    pub queried: DomainName,
    /// The registrable name that was actually looked up.
    pub domain: DomainName,
    /// The suffix that decided which registry to ask.
    pub tld: Tld,
    /// What the response said, and why the engine thinks so.
    pub verdict: Verdict,
    /// Every response, registry first and referrals after.
    pub responses: Vec<RawResponse>,
    /// The parsed record, merged across every response.
    #[cfg(feature = "parser")]
    pub record: Option<WhoisRecord>,
}

impl Lookup {
    /// What the registry said about the name.
    pub fn availability(&self) -> Availability {
        self.verdict.availability
    }

    /// Whether the domain can be registered at the ordinary price.
    pub fn is_available(&self) -> bool {
        self.verdict.availability.is_available()
    }

    /// Whether the domain is held by a registrant.
    pub fn is_registered(&self) -> bool {
        self.verdict.availability.is_registered()
    }

    /// The response that produced the verdict — the first one received.
    pub fn primary_response(&self) -> Option<&RawResponse> {
        self.responses.first()
    }

    /// The last response, which for a followed referral is the registrar's.
    pub fn final_response(&self) -> Option<&RawResponse> {
        self.responses.last()
    }

    /// The raw text of every response, concatenated with a separator naming each
    /// server.
    pub fn raw_text(&self) -> String {
        if self.responses.len() == 1 {
            return self.responses[0].text().to_string();
        }

        self.responses
            .iter()
            .map(|response| {
                format!(
                    ">>> {} <<<\n{}",
                    response.endpoint().address(),
                    response.text()
                )
            })
            .collect::<Vec<_>>()
            .join("\n\n")
    }

    /// The endpoints that were consulted, in order.
    pub fn consulted(&self) -> Vec<Endpoint> {
        self.responses
            .iter()
            .map(|response| response.endpoint().clone())
            .collect()
    }

    /// Whether a referral was followed.
    pub fn followed_referral(&self) -> bool {
        self.responses.len() > 1
    }

    /// Whether every response came from a cache.
    pub fn was_cached(&self) -> bool {
        !self.responses.is_empty() && self.responses.iter().all(RawResponse::is_cached)
    }

    /// The parsed record, or an empty one when nothing parsed.
    #[cfg(feature = "parser")]
    pub fn record_or_empty(&self) -> WhoisRecord {
        self.record.clone().unwrap_or_default()
    }
}

impl fmt::Display for Lookup {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} {}", self.domain, self.verdict.availability)
    }
}

/// A [`Lookup`] plus the full detection breakdown.
///
/// Produced by [`WhoisClient::explain`](crate::WhoisClient::explain) and worth
/// reaching for when a verdict looks wrong: it shows every rule's opinion, not just
/// the winning one.
#[derive(Debug, Clone)]
pub struct Explanation {
    /// The lookup, as usual.
    pub lookup: Lookup,
    /// What each rule made of the deciding response.
    pub report: DetectionReport,
}

impl fmt::Display for Explanation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "{}{}", self.lookup.domain, self.lookup.verdict)?;
        writeln!(
            f,
            "consulted: {}",
            self.lookup
                .consulted()
                .iter()
                .map(Endpoint::address)
                .collect::<Vec<_>>()
                .join(", ")
        )?;
        write!(f, "{}", self.report)
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use super::*;
    use crate::detect::Confidence;
    use crate::transport::ResponseKind;

    fn response(host: &str, text: &str) -> RawResponse {
        RawResponse::new(
            Endpoint::whois(host),
            ResponseKind::WhoisText,
            text,
            Duration::from_millis(5),
        )
    }

    fn lookup(responses: Vec<RawResponse>) -> Lookup {
        Lookup {
            queried: DomainName::parse("www.example.com").unwrap(),
            domain: DomainName::parse("example.com").unwrap(),
            tld: Tld::parse("com").unwrap(),
            verdict: Verdict {
                availability: Availability::Registered,
                confidence: Confidence::High,
                rule: "registered",
                because: "status field".into(),
            },
            responses,
            #[cfg(feature = "parser")]
            record: None,
        }
    }

    #[test]
    fn exposes_the_verdict_directly() {
        let result = lookup(vec![response("whois.example", "record")]);

        assert_eq!(result.availability(), Availability::Registered);
        assert!(result.is_registered());
        assert!(!result.is_available());
        assert_eq!(result.to_string(), "example.com registered");
    }

    #[test]
    fn a_single_response_is_returned_verbatim() {
        let result = lookup(vec![response("whois.example", "Domain Name: EXAMPLE.COM")]);
        assert_eq!(result.raw_text(), "Domain Name: EXAMPLE.COM");
        assert!(!result.followed_referral());
    }

    #[test]
    fn several_responses_are_labelled_by_server() {
        let result = lookup(vec![
            response("whois.verisign-grs.com", "thin record"),
            response("whois.registrar.example", "thick record"),
        ]);

        let text = result.raw_text();
        assert!(text.contains(">>> whois.verisign-grs.com <<<"), "{text}");
        assert!(text.contains(">>> whois.registrar.example <<<"), "{text}");
        assert!(text.contains("thin record") && text.contains("thick record"));
        assert!(result.followed_referral());
    }

    #[test]
    fn primary_and_final_responses_are_the_ends_of_the_chain() {
        let result = lookup(vec![
            response("registry.example", "first"),
            response("registrar.example", "second"),
        ]);

        assert_eq!(result.primary_response().unwrap().text(), "first");
        assert_eq!(result.final_response().unwrap().text(), "second");
        assert_eq!(
            result
                .consulted()
                .iter()
                .map(Endpoint::address)
                .collect::<Vec<_>>(),
            ["registry.example", "registrar.example"]
        );
    }

    #[test]
    fn an_empty_chain_is_handled_without_panicking() {
        let result = lookup(Vec::new());

        assert!(result.primary_response().is_none());
        assert!(result.final_response().is_none());
        assert_eq!(result.raw_text(), "");
        assert!(!result.was_cached());
        assert!(result.consulted().is_empty());
    }

    #[test]
    fn cached_is_only_true_when_every_response_was() {
        let fresh = response("a.example", "x");
        let cached = response("b.example", "y").mark_cached();

        assert!(!lookup(vec![fresh.clone(), cached.clone()]).was_cached());
        assert!(lookup(vec![cached]).was_cached());
        assert!(!lookup(vec![fresh]).was_cached());
    }

    #[test]
    fn the_queried_name_and_the_registrable_name_are_both_kept() {
        let result = lookup(vec![response("a.example", "x")]);
        assert_eq!(result.queried.as_ascii(), "www.example.com");
        assert_eq!(result.domain.as_ascii(), "example.com");
    }
}