monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! Turning a response into a [`WhoisRecord`].
//!
//! Most WHOIS libraries return the server's text and stop there, which leaves
//! every caller writing the same fragile string search for an expiry date.
//! This module does that once, badly-behaved registries included.
//!
//! # Strategy, not a switch
//!
//! [`RecordParser`] is a trait with one implementation per response format, and
//! [`CompositeParser`] picks whichever one claims the response. Adding support for
//! a registry that publishes something unusual means writing a parser and pushing
//! it onto the composite — not editing a match arm in the middle of the pipeline.
//!
//! ```
//! use monovm_whois::parser::{CompositeParser, RecordParser};
//! use monovm_whois::registry::Endpoint;
//! use monovm_whois::transport::{RawResponse, ResponseKind};
//! use std::time::Duration;
//!
//! let parser = CompositeParser::standard();
//!
//! // Whichever protocol answered, the record has the same shape.
//! let text = RawResponse::new(
//!     Endpoint::whois("whois.example"),
//!     ResponseKind::WhoisText,
//!     "Domain Name: EXAMPLE.COM\nRegistrar: Example LLC\n",
//!     Duration::ZERO,
//! );
//! let json = RawResponse::new(
//!     Endpoint::rdap("https://rdap.example/"),
//!     ResponseKind::RdapJson,
//!     r#"{"objectClassName":"domain","ldhName":"example.com"}"#,
//!     Duration::ZERO,
//! );
//!
//! assert_eq!(
//!     parser.parse(&text).unwrap().domain,
//!     parser.parse(&json).unwrap().domain,
//! );
//! ```

use std::fmt;

use crate::error::{Error, Result};
use crate::transport::RawResponse;

mod dates;
mod keyvalue;
mod record;

#[cfg(feature = "rdap")]
mod rdap;

pub use dates::parse_datetime;
pub use keyvalue::KeyValueParser;
pub use record::{Contact, WhoisRecord};

#[cfg(feature = "rdap")]
pub use rdap::RdapParser;

/// Reads one response format into a [`WhoisRecord`].
pub trait RecordParser: fmt::Debug + Send + Sync {
    /// A stable identifier, for diagnostics.
    fn name(&self) -> &'static str;

    /// Whether this parser handles that response.
    ///
    /// Checked before [`parse`](RecordParser::parse), so a composite can dispatch
    /// without relying on parse failures to route.
    fn can_parse(&self, response: &RawResponse) -> bool;

    /// Read the response.
    ///
    /// A response with no registration in it — a "no match", an RDAP 404 — is not
    /// an error: it parses to an empty [`WhoisRecord`]. Only a response this parser
    /// cannot read at all should fail.
    fn parse(&self, response: &RawResponse) -> Result<WhoisRecord>;
}

impl<T: RecordParser + ?Sized> RecordParser for std::sync::Arc<T> {
    fn name(&self) -> &'static str {
        (**self).name()
    }

    fn can_parse(&self, response: &RawResponse) -> bool {
        (**self).can_parse(response)
    }

    fn parse(&self, response: &RawResponse) -> Result<WhoisRecord> {
        (**self).parse(response)
    }
}

/// Delegates to the first parser that claims a response.
#[derive(Debug, Default)]
pub struct CompositeParser {
    parsers: Vec<Box<dyn RecordParser>>,
}

impl CompositeParser {
    /// An empty composite, which claims nothing.
    pub fn new() -> Self {
        CompositeParser {
            parsers: Vec::new(),
        }
    }

    /// The parsers this crate ships: RDAP where compiled in, then key/value.
    pub fn standard() -> Self {
        let composite = CompositeParser::new();

        #[cfg(feature = "rdap")]
        let composite = composite.parser(RdapParser::new());

        composite.parser(KeyValueParser::new())
    }

    /// Append a parser. Earlier ones win where both could claim a response.
    pub fn parser(mut self, parser: impl RecordParser + 'static) -> Self {
        self.parsers.push(Box::new(parser));
        self
    }

    /// Append a boxed parser.
    pub fn boxed_parser(mut self, parser: Box<dyn RecordParser>) -> Self {
        self.parsers.push(parser);
        self
    }

    /// How many parsers are registered.
    pub fn len(&self) -> usize {
        self.parsers.len()
    }

    /// Whether no parser is registered.
    pub fn is_empty(&self) -> bool {
        self.parsers.is_empty()
    }

    /// The parser names, in order.
    pub fn parser_names(&self) -> Vec<&'static str> {
        self.parsers.iter().map(|parser| parser.name()).collect()
    }

    /// Merge the records from several responses, earlier ones taking precedence.
    ///
    /// This is what makes a thin registry usable. Verisign answers a `.com` query
    /// with the registrar's name and nothing else; the registrar's own server has
    /// the contacts and the dates. Neither record is complete and together they are,
    /// so a referral chain is parsed into one.
    ///
    /// Precedence runs earliest-first because the registry is authoritative for the
    /// fields it does publish — a registrar's copy of an expiry date can be stale,
    /// the registry's cannot.
    pub fn parse_all(&self, responses: &[RawResponse]) -> Result<WhoisRecord> {
        let mut merged = WhoisRecord::new();
        let mut parsed_any = false;
        let mut last_error = None;

        for response in responses {
            match self.parse(response) {
                Ok(record) => {
                    parsed_any = true;
                    merge_into(&mut merged, record);
                }
                Err(error) => last_error = Some(error),
            }
        }

        match (parsed_any, last_error) {
            (true, _) => Ok(merged),
            (false, Some(error)) => Err(error),
            (false, None) => Ok(merged),
        }
    }
}

impl RecordParser for CompositeParser {
    fn name(&self) -> &'static str {
        "composite"
    }

    fn can_parse(&self, response: &RawResponse) -> bool {
        self.parsers.iter().any(|parser| parser.can_parse(response))
    }

    fn parse(&self, response: &RawResponse) -> Result<WhoisRecord> {
        for parser in &self.parsers {
            if parser.can_parse(response) {
                return parser.parse(response);
            }
        }

        Err(Error::Definitions(format!(
            "no parser handles a {:?} response from {}",
            response.kind(),
            response.endpoint().address()
        )))
    }
}

/// Fill the gaps in `target` from `source`, without overwriting.
fn merge_into(target: &mut WhoisRecord, source: WhoisRecord) {
    fn fill<T>(target: &mut Option<T>, source: Option<T>) {
        if target.is_none() {
            *target = source;
        }
    }

    fill(&mut target.domain, source.domain);
    fill(&mut target.registry_id, source.registry_id);
    fill(&mut target.registrar, source.registrar);
    fill(&mut target.registrar_iana_id, source.registrar_iana_id);
    fill(
        &mut target.registrar_whois_server,
        source.registrar_whois_server,
    );
    fill(&mut target.registrar_url, source.registrar_url);
    fill(&mut target.abuse_contact_email, source.abuse_contact_email);
    fill(&mut target.abuse_contact_phone, source.abuse_contact_phone);
    fill(&mut target.created, source.created);
    fill(&mut target.updated, source.updated);
    fill(&mut target.expires, source.expires);
    fill(&mut target.dnssec, source.dnssec);
    fill(&mut target.registrant, source.registrant);
    fill(&mut target.admin, source.admin);
    fill(&mut target.tech, source.tech);
    fill(&mut target.billing, source.billing);

    for status in source.statuses {
        if !target.statuses.contains(&status) {
            target.statuses.push(status);
        }
    }
    for server in source.name_servers {
        if !target.name_servers.contains(&server) {
            target.name_servers.push(server);
        }
    }
    for (key, values) in source.extra {
        target.extra.entry(key).or_insert(values);
    }
}

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

    use super::*;
    use crate::registry::Endpoint;
    use crate::transport::ResponseKind;

    fn whois(text: &str) -> RawResponse {
        RawResponse::new(
            Endpoint::whois("whois.example"),
            ResponseKind::WhoisText,
            text,
            Duration::ZERO,
        )
    }

    #[cfg(feature = "rdap")]
    fn rdap(json: &str) -> RawResponse {
        RawResponse::new(
            Endpoint::rdap("https://rdap.example/"),
            ResponseKind::RdapJson,
            json,
            Duration::ZERO,
        )
    }

    #[test]
    fn the_standard_composite_handles_whois_text() {
        let parser = CompositeParser::standard();
        let response = whois("Domain Name: example.com\nRegistrar: Example LLC\n");

        assert!(parser.can_parse(&response));
        let record = parser.parse(&response).unwrap();
        assert_eq!(record.registrar.as_deref(), Some("Example LLC"));
    }

    #[cfg(feature = "rdap")]
    #[test]
    fn the_standard_composite_handles_rdap_json() {
        let parser = CompositeParser::standard();
        let response = rdap(r#"{"objectClassName":"domain","ldhName":"example.com"}"#);

        assert!(parser.can_parse(&response));
        assert_eq!(
            parser.parse(&response).unwrap().domain.unwrap().as_ascii(),
            "example.com"
        );
        assert_eq!(parser.parser_names(), ["rdap", "key-value"]);
    }

    #[test]
    fn an_empty_composite_claims_nothing() {
        let parser = CompositeParser::new();
        let response = whois("Domain Name: example.com\n");

        assert!(parser.is_empty());
        assert!(!parser.can_parse(&response));
        assert!(parser.parse(&response).is_err());
    }

    #[test]
    fn a_thin_registry_answer_is_completed_by_the_referral() {
        // Verisign publishes the registrar and the authoritative dates; the
        // registrar publishes the contacts. Neither alone is a usable record.
        let registry = whois(
            "\
Domain Name: EXAMPLE.COM
Registrar: Example Registrar, LLC
Registrar WHOIS Server: whois.example-registrar.com
Registry Expiry Date: 2027-08-13T04:00:00Z
Name Server: NS1.EXAMPLE.COM
",
        );
        let registrar = whois(
            "\
Domain Name: EXAMPLE.COM
Registrant Name: Ada Lovelace
Registrant Country: GB
Expiry Date: 2020-01-01T00:00:00Z
Name Server: NS2.EXAMPLE.COM
",
        );

        let merged = CompositeParser::standard()
            .parse_all(&[registry, registrar])
            .unwrap();

        assert_eq!(merged.registrar.as_deref(), Some("Example Registrar, LLC"));
        assert_eq!(
            merged.registrant.as_ref().unwrap().name.as_deref(),
            Some("Ada Lovelace")
        );
        assert_eq!(
            merged.expires.unwrap().format("%Y").to_string(),
            "2027",
            "the registry's date is authoritative and must not be overwritten"
        );
        assert_eq!(
            merged.name_servers,
            ["ns1.example.com", "ns2.example.com"],
            "multi-valued fields accumulate"
        );
    }

    #[test]
    fn parse_all_of_nothing_is_an_empty_record() {
        let merged = CompositeParser::standard().parse_all(&[]).unwrap();
        assert!(merged.is_empty());
    }

    #[test]
    fn parse_all_reports_a_failure_only_when_everything_failed() {
        let parser = CompositeParser::new().parser(KeyValueParser::new());

        // A response no registered parser claims.
        let unparseable = RawResponse::new(
            Endpoint::rdap("https://rdap.example/"),
            ResponseKind::RdapJson,
            "{}",
            Duration::ZERO,
        );
        assert!(parser.parse_all(&[unparseable]).is_err());

        let unparseable = RawResponse::new(
            Endpoint::rdap("https://rdap.example/"),
            ResponseKind::RdapJson,
            "{}",
            Duration::ZERO,
        );
        let good = whois("Domain Name: example.com\n");
        let merged = parser.parse_all(&[unparseable, good]).unwrap();
        assert!(merged.domain.is_some());
    }

    #[test]
    fn unrecognised_extra_fields_merge_without_clobbering() {
        let first = whois("Eligibility Type: Company\n");
        let second = whois("Eligibility Type: Other\nEligibility Name: Example Pty\n");

        let merged = CompositeParser::standard()
            .parse_all(&[first, second])
            .unwrap();

        assert_eq!(merged.extra_field("eligibility type"), Some("Company"));
        assert_eq!(merged.extra_field("eligibility name"), Some("Example Pty"));
    }
}