monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! [`ConventionRegistry`]: WHOIS hosts derived from the ICANN naming convention.

use std::collections::HashSet;
use std::sync::Arc;

use crate::domain::Tld;
use crate::registry::{BootstrapRegistry, Endpoint, Registry, RegistryProvider};

/// The convention every gTLD registry agreement requires.
pub const DEFAULT_TEMPLATE: &str = "whois.nic.{tld}";

/// Synthesises a WHOIS host per suffix from a name template.
///
/// ICANN registry agreements require new gTLDs to run WHOIS at
/// `whois.nic.<tld>`, so for the long tail of gTLDs the host can be derived
/// rather than looked up. That is a guess, though — the convention does not bind
/// ccTLDs, and a registry that moved its service will not follow it — so this
/// provider is **not** part of the default stack. It belongs at the bottom of a
/// [`LayeredRegistry`](crate::registry::LayeredRegistry), below real data:
///
/// ```
/// use monovm_whois::registry::{
///     ConventionRegistry, JsonRegistry, LayerStrategy, LayeredRegistry, RegistryProvider,
/// };
/// use monovm_whois::Tld;
///
/// // `Override`, not `Union`: a guessed host must not be *tried* ahead of a known-good
/// // one, and `Union` keeps both with the lower layer first.
/// let registry = LayeredRegistry::new(LayerStrategy::Override)
///     .layer(ConventionRegistry::from_iana())
///     .layer(JsonRegistry::bundled());
///
/// // Curated data replaces the guess where it exists.
/// let com = registry.get(&Tld::parse("com").unwrap()).unwrap();
/// assert_eq!(com.endpoints()[0].address(), "whois.verisign-grs.com");
///
/// // And the guess still covers a suffix the curated list has never heard of.
/// let guessed = registry.get(&Tld::parse("bar").unwrap()).unwrap();
/// assert!(guessed.endpoints().iter().any(|e| e.address() == "whois.nic.bar"));
/// ```
///
/// Suffixes are answered only if they appear in the allow-list the provider was
/// built with. Answering for anything at all would break suffix resolution:
/// `resolve` prefers the longest candidate, so a provider that claims every
/// suffix would match `example.co.uk` before `co.uk` and split the name wrongly.
#[derive(Debug, Clone)]
pub struct ConventionRegistry {
    known: HashSet<Tld>,
    template: String,
}

impl ConventionRegistry {
    /// Build with an explicit allow-list of suffixes.
    pub fn new(known: impl IntoIterator<Item = Tld>) -> Self {
        ConventionRegistry {
            known: known.into_iter().collect(),
            template: DEFAULT_TEMPLATE.to_string(),
        }
    }

    /// Build with the suffixes IANA's bundled bootstrap snapshot lists.
    ///
    /// That list is the closest thing to "every TLD that exists", which makes it
    /// the right allow-list: the convention is a guess about *where* a registry's
    /// WHOIS lives, and should not also be a guess about whether the TLD is real.
    pub fn from_iana() -> Self {
        ConventionRegistry::new(BootstrapRegistry::bundled().tlds())
    }

    /// Use a different host template. `{tld}` is replaced by the suffix's last
    /// label, so `whois.{tld}.example` becomes `whois.uk.example` for `co.uk`.
    pub fn with_template(mut self, template: impl Into<String>) -> Self {
        self.template = template.into();
        self
    }

    /// The template in use.
    pub fn template(&self) -> &str {
        &self.template
    }

    /// How many suffixes the allow-list holds.
    pub fn len(&self) -> usize {
        self.known.len()
    }

    /// Whether the allow-list is empty, in which case nothing is ever answered.
    pub fn is_empty(&self) -> bool {
        self.known.is_empty()
    }

    fn host_for(&self, tld: &Tld) -> String {
        self.template.replace("{tld}", tld.root_label())
    }
}

impl RegistryProvider for ConventionRegistry {
    fn get(&self, tld: &Tld) -> Option<Arc<Registry>> {
        if !self.known.contains(tld) {
            return None;
        }

        Some(
            Registry::builder([tld.clone()])
                .endpoint(Endpoint::whois(self.host_for(tld)))
                .note(format!(
                    "host guessed from the {} convention",
                    self.template
                ))
                .build_shared(),
        )
    }

    fn tlds(&self) -> Vec<Tld> {
        let mut tlds: Vec<Tld> = self.known.iter().cloned().collect();
        tlds.sort();
        tlds
    }

    fn describe(&self) -> String {
        format!("convention {} ({} tlds)", self.template, self.known.len())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn derives_the_conventional_host() {
        let registry = ConventionRegistry::new([Tld::parse("example").unwrap()]);
        let entry = registry.get(&Tld::parse("example").unwrap()).unwrap();
        assert_eq!(entry.endpoints()[0].address(), "whois.nic.example");
        assert!(entry.note().unwrap().contains("guessed"));
    }

    #[test]
    fn answers_nothing_outside_the_allow_list() {
        let registry = ConventionRegistry::new([Tld::parse("example").unwrap()]);
        assert!(registry.get(&Tld::parse("other").unwrap()).is_none());
        assert!(ConventionRegistry::new([])
            .get(&Tld::parse("com").unwrap())
            .is_none());
    }

    #[test]
    fn multi_label_suffixes_use_the_root_label() {
        let registry = ConventionRegistry::new([Tld::parse("co.uk").unwrap()]);
        let entry = registry.get(&Tld::parse("co.uk").unwrap()).unwrap();
        assert_eq!(entry.endpoints()[0].address(), "whois.nic.uk");
    }

    #[test]
    fn honours_a_custom_template() {
        let registry = ConventionRegistry::new([Tld::parse("example").unwrap()])
            .with_template("whois.{tld}.test");
        let entry = registry.get(&Tld::parse("example").unwrap()).unwrap();
        assert_eq!(entry.endpoints()[0].address(), "whois.example.test");
    }

    #[test]
    fn the_iana_allow_list_is_the_full_tld_set() {
        let registry = ConventionRegistry::from_iana();
        assert!(registry.len() > 1000, "got {}", registry.len());
        assert!(registry.get(&Tld::parse("com").unwrap()).is_some());
        // Not a TLD, so no host is invented for it.
        assert!(registry.get(&Tld::parse("example.com").unwrap()).is_none());
    }
}