monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! The [`RegistryProvider`] abstraction: where registry definitions come from.

use std::fmt;
use std::sync::Arc;

use crate::domain::{DomainName, Tld};
use crate::error::{Error, Result};
use crate::registry::Registry;

/// A source of registry definitions.
///
/// Implementors answer one question — "which registry serves this suffix?" — and
/// inherit suffix resolution from [`RegistryProvider::resolve`]. That split is
/// what lets the bundled data, a local override file, a runtime IANA download
/// and a naming convention all be the same kind of thing, and be stacked in any
/// order by [`LayeredRegistry`](crate::registry::LayeredRegistry).
pub trait RegistryProvider: fmt::Debug + Send + Sync {
    /// The registry serving exactly this suffix, or `None`.
    ///
    /// Implementations must not do partial matching here — no walking up from
    /// `co.uk` to `uk`. Choosing between candidate suffixes is
    /// [`resolve`](RegistryProvider::resolve)'s job, and doing it in both places
    /// makes the outcome depend on which provider answered first.
    fn get(&self, tld: &Tld) -> Option<Arc<Registry>>;

    /// Every suffix this provider can serve.
    ///
    /// Providers that synthesise definitions on demand, such as
    /// [`ConventionRegistry`](crate::registry::ConventionRegistry), return an
    /// empty list: they serve anything and can enumerate nothing.
    fn tlds(&self) -> Vec<Tld>;

    /// A short description for diagnostics.
    fn describe(&self) -> String {
        format!("{self:?}")
    }

    /// Split a name at its registry suffix, preferring the longest match.
    ///
    /// This is the whole reason `co.uk` and `co.com` behave differently: the
    /// answer is looked up rather than guessed from the label count.
    ///
    /// # Errors
    ///
    /// [`Error::UnsupportedTld`] when no candidate suffix is served. The suffix
    /// named in the error is the shortest candidate — the true top level — since
    /// that is the one a caller would want to go and add support for.
    fn resolve(&self, name: &DomainName) -> Result<Resolution> {
        for split in name.suffix_candidates() {
            let tld = Tld::from_ascii_unchecked(split.suffix);
            if let Some(registry) = self.get(&tld) {
                let registrable = name.registrable_under(&tld).unwrap_or_else(|| name.clone());
                return Ok(Resolution {
                    queried: name.clone(),
                    registrable,
                    tld,
                    registry,
                });
            }
        }

        let root = name
            .suffix_candidates()
            .last()
            .map(|split| Tld::from_ascii_unchecked(split.suffix))
            .unwrap_or_else(|| Tld::from_ascii_unchecked(name.as_ascii()));

        Err(Error::UnsupportedTld { tld: root })
    }

    /// Whether any candidate suffix of this name is served.
    fn can_resolve(&self, name: &DomainName) -> bool {
        self.resolve(name).is_ok()
    }
}

/// A name matched against a registry.
///
/// Carries both the name the caller asked about and the registrable name that
/// will actually be queried, because those differ whenever the input had a
/// subdomain and the difference matters when reporting results.
#[derive(Debug, Clone)]
pub struct Resolution {
    /// The name as the caller supplied it, after normalisation.
    pub queried: DomainName,
    /// The registrable name: `example.co.uk` for `www.example.co.uk`.
    pub registrable: DomainName,
    /// The suffix that matched.
    pub tld: Tld,
    /// The registry serving that suffix.
    pub registry: Arc<Registry>,
}

impl Resolution {
    /// The second-level label: `example` for `example.co.uk`.
    pub fn sld(&self) -> &str {
        self.registrable
            .as_ascii()
            .strip_suffix(self.tld.ascii())
            .map(|head| head.trim_end_matches('.'))
            .unwrap_or_else(|| self.registrable.as_ascii())
    }

    /// Whether the caller asked about a subdomain rather than the registrable
    /// name itself.
    pub fn is_subdomain(&self) -> bool {
        self.queried != self.registrable
    }

    /// The string to send to this registry, in the form it wants.
    pub fn wire_form(&self) -> String {
        self.registry.wire_form(&self.registrable)
    }
}

// A boxed or shared provider is itself a provider, so callers can hold
// `Arc<dyn RegistryProvider>` without a wrapper type.
impl<T: RegistryProvider + ?Sized> RegistryProvider for Arc<T> {
    fn get(&self, tld: &Tld) -> Option<Arc<Registry>> {
        (**self).get(tld)
    }

    fn tlds(&self) -> Vec<Tld> {
        (**self).tlds()
    }

    fn describe(&self) -> String {
        (**self).describe()
    }
}

impl<T: RegistryProvider + ?Sized> RegistryProvider for Box<T> {
    fn get(&self, tld: &Tld) -> Option<Arc<Registry>> {
        (**self).get(tld)
    }

    fn tlds(&self) -> Vec<Tld> {
        (**self).tlds()
    }

    fn describe(&self) -> String {
        (**self).describe()
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::*;
    use crate::registry::{Endpoint, Registry};

    #[derive(Debug)]
    struct Fixed(HashMap<Tld, Arc<Registry>>);

    impl Fixed {
        fn new(tlds: &[&str]) -> Self {
            let map = tlds
                .iter()
                .map(|raw| {
                    let tld = Tld::parse(raw).unwrap();
                    let registry = Registry::builder([tld.clone()])
                        .endpoint(Endpoint::whois(format!("whois.nic.{raw}")))
                        .build_shared();
                    (tld, registry)
                })
                .collect();
            Fixed(map)
        }
    }

    impl RegistryProvider for Fixed {
        fn get(&self, tld: &Tld) -> Option<Arc<Registry>> {
            self.0.get(tld).cloned()
        }

        fn tlds(&self) -> Vec<Tld> {
            self.0.keys().cloned().collect()
        }
    }

    #[test]
    fn resolve_prefers_the_longest_suffix() {
        let provider = Fixed::new(&["uk", "co.uk"]);
        let name = DomainName::parse("example.co.uk").unwrap();

        let resolved = provider.resolve(&name).unwrap();
        assert_eq!(resolved.tld.ascii(), "co.uk");
        assert_eq!(resolved.sld(), "example");
        assert!(!resolved.is_subdomain());
    }

    #[test]
    fn resolve_falls_back_to_the_shorter_suffix() {
        // `co.com` is an ordinary registration, not a registry suffix.
        let provider = Fixed::new(&["com"]);
        let resolved = provider
            .resolve(&DomainName::parse("co.com").unwrap())
            .unwrap();
        assert_eq!(resolved.tld.ascii(), "com");
        assert_eq!(resolved.sld(), "co");
    }

    #[test]
    fn resolve_trims_subdomains_to_the_registrable_name() {
        let provider = Fixed::new(&["co.uk"]);
        let name = DomainName::parse("www.mail.example.co.uk").unwrap();

        let resolved = provider.resolve(&name).unwrap();
        assert_eq!(resolved.registrable.as_ascii(), "example.co.uk");
        assert_eq!(resolved.queried.as_ascii(), "www.mail.example.co.uk");
        assert!(resolved.is_subdomain());
        assert_eq!(resolved.sld(), "example");
    }

    #[test]
    fn unsupported_tld_names_the_root_label() {
        let provider = Fixed::new(&["com"]);
        let error = provider
            .resolve(&DomainName::parse("example.co.uk").unwrap())
            .unwrap_err();

        match error {
            Error::UnsupportedTld { tld } => assert_eq!(tld.ascii(), "uk"),
            other => panic!("expected UnsupportedTld, got {other:?}"),
        }
    }

    #[test]
    fn idn_suffix_resolves_through_either_spelling() {
        let provider = Fixed::new(&["xn--p1ai"]);
        for input in ["example.рф", "example.xn--p1ai"] {
            let name = DomainName::parse(input).unwrap();
            let resolved = provider.resolve(&name).unwrap();
            assert_eq!(resolved.tld.ascii(), "xn--p1ai");
        }
    }
}