monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! [`BootstrapRegistry`]: RDAP endpoints from IANA's bootstrap registry.
//!
//! IANA publishes the authoritative map of TLD to RDAP service at
//! <https://data.iana.org/rdap/dns.json>, in the format RFC 9224 specifies. A
//! snapshot ships with this crate — that alone covers roughly 1200 suffixes,
//! against the 872 in the hand-curated list — and the live file can be loaded at
//! runtime instead.
//!
//! Only RDAP endpoints come from here. The file says nothing about port 43 hosts
//! or about how a registry words "no match", so a bootstrap-only provider is
//! best stacked *under* the curated definitions with
//! [`LayerStrategy::Union`](crate::registry::LayerStrategy::Union).

use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, OnceLock};

use serde::Deserialize;

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

/// The IANA snapshot compiled into the crate.
const BUNDLED_JSON: &str = include_str!("../../data/rdap-bootstrap.json");

/// The canonical location of the live file.
pub const IANA_BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json";

/// RDAP service endpoints, keyed by suffix, from an RFC 9224 bootstrap file.
#[derive(Debug, Clone)]
pub struct BootstrapRegistry {
    by_tld: HashMap<Tld, Arc<Registry>>,
    publication: Option<String>,
    origin: String,
}

impl BootstrapRegistry {
    /// Parse a bootstrap document.
    pub fn from_json(json: &str, origin: impl Into<String>) -> Result<Self> {
        let origin = origin.into();
        let file: BootstrapFile = serde_json::from_str(json)
            .map_err(|error| Error::Definitions(format!("{origin}: {error}")))?;

        let mut by_tld: HashMap<Tld, Arc<Registry>> = HashMap::new();

        for (index, service) in file.services.iter().enumerate() {
            let (raw_tlds, urls) = match service.as_slice() {
                [tlds, urls] => (tlds, urls),
                _ => {
                    return Err(Error::Definitions(format!(
                        "{origin}: services[{index}] is not a [tlds, urls] pair"
                    )))
                }
            };

            let mut tlds = Vec::with_capacity(raw_tlds.len());
            for raw in raw_tlds {
                // A malformed suffix in IANA's file should cost us that one entry,
                // not the whole document — the other 1200 are still good.
                if let Ok(tld) = Tld::parse(raw) {
                    tlds.push(tld);
                }
            }
            if tlds.is_empty() {
                continue;
            }

            let endpoints: Vec<Endpoint> = urls
                .iter()
                .filter(|url| !url.trim().is_empty())
                .map(|url| Endpoint::rdap(url.trim()))
                .collect();
            if endpoints.is_empty() {
                continue;
            }

            let registry = Registry::builder(tlds.clone())
                .endpoints(endpoints)
                .note(format!("RDAP endpoint from {origin}"))
                .build_shared();

            for tld in tlds {
                by_tld.insert(tld, Arc::clone(&registry));
            }
        }

        if by_tld.is_empty() {
            return Err(Error::Definitions(format!(
                "{origin}: no usable RDAP services found"
            )));
        }

        Ok(BootstrapRegistry {
            by_tld,
            publication: file.publication,
            origin,
        })
    }

    /// Read and parse a bootstrap file from disk.
    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
        let path = path.as_ref();
        let json = std::fs::read_to_string(path)
            .map_err(|error| Error::Definitions(format!("{}: {error}", path.display())))?;
        BootstrapRegistry::from_json(&json, path.display().to_string())
    }

    /// The snapshot bundled with the crate, parsed once and shared.
    ///
    /// # Panics
    ///
    /// Only if the compiled-in snapshot is corrupt, which this crate's tests rule
    /// out. Use [`try_bundled`](BootstrapRegistry::try_bundled) to handle that as
    /// an error instead.
    pub fn bundled() -> Arc<BootstrapRegistry> {
        static BUNDLED: OnceLock<Arc<BootstrapRegistry>> = OnceLock::new();
        Arc::clone(BUNDLED.get_or_init(|| {
            Arc::new(
                BootstrapRegistry::try_bundled()
                    .expect("bundled data/rdap-bootstrap.json is not a valid RFC 9224 file"),
            )
        }))
    }

    /// The bundled snapshot, as a result rather than a panic.
    pub fn try_bundled() -> Result<Self> {
        BootstrapRegistry::from_json(BUNDLED_JSON, "bundled IANA RDAP bootstrap")
    }

    /// Download and parse the live file from IANA.
    ///
    /// Requires the `iana-bootstrap` feature. The bundled snapshot ages: registries
    /// are added and RDAP URLs move, so a long-running service is better off
    /// refreshing this periodically than shipping a year-old copy.
    #[cfg(feature = "iana-bootstrap")]
    pub fn fetch() -> Result<Self> {
        BootstrapRegistry::fetch_from(IANA_BOOTSTRAP_URL)
    }

    /// Download and parse a bootstrap file from an arbitrary URL.
    #[cfg(feature = "iana-bootstrap")]
    pub fn fetch_from(url: &str) -> Result<Self> {
        let response = reqwest::blocking::get(url).map_err(|error| Error::Connect {
            server: url.to_string(),
            source: std::io::Error::other(error),
        })?;

        let status = response.status();
        if !status.is_success() {
            return Err(Error::Http {
                url: url.to_string(),
                status: status.as_u16(),
            });
        }

        let body = response.text().map_err(|error| Error::Io {
            server: url.to_string(),
            source: std::io::Error::other(error),
        })?;

        BootstrapRegistry::from_json(&body, url)
    }

    /// Download and parse the live file, asynchronously.
    #[cfg(all(feature = "iana-bootstrap", feature = "async"))]
    pub async fn fetch_async() -> Result<Self> {
        BootstrapRegistry::fetch_from_async(IANA_BOOTSTRAP_URL).await
    }

    /// Download and parse a bootstrap file from an arbitrary URL, asynchronously.
    #[cfg(all(feature = "iana-bootstrap", feature = "async"))]
    pub async fn fetch_from_async(url: &str) -> Result<Self> {
        let response = reqwest::get(url).await.map_err(|error| Error::Connect {
            server: url.to_string(),
            source: std::io::Error::other(error),
        })?;

        let status = response.status();
        if !status.is_success() {
            return Err(Error::Http {
                url: url.to_string(),
                status: status.as_u16(),
            });
        }

        let body = response.text().await.map_err(|error| Error::Io {
            server: url.to_string(),
            source: std::io::Error::other(error),
        })?;

        BootstrapRegistry::from_json(&body, url)
    }

    /// The `publication` timestamp the file declared, if it had one.
    ///
    /// Worth surfacing: it is the only way to tell how stale the data is.
    pub fn publication(&self) -> Option<&str> {
        self.publication.as_deref()
    }

    /// Where these endpoints came from.
    pub fn origin(&self) -> &str {
        &self.origin
    }

    /// How many suffixes are covered.
    pub fn len(&self) -> usize {
        self.by_tld.len()
    }

    /// Whether no suffix is covered.
    pub fn is_empty(&self) -> bool {
        self.by_tld.is_empty()
    }
}

impl RegistryProvider for BootstrapRegistry {
    fn get(&self, tld: &Tld) -> Option<Arc<Registry>> {
        self.by_tld.get(tld).map(Arc::clone)
    }

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

    fn describe(&self) -> String {
        match &self.publication {
            Some(published) => format!(
                "{} ({} tlds, published {published})",
                self.origin,
                self.by_tld.len()
            ),
            None => format!("{} ({} tlds)", self.origin, self.by_tld.len()),
        }
    }
}

/// The RFC 9224 wire format: a list of `[[tld, ...], [url, ...]]` pairs.
#[derive(Debug, Deserialize)]
struct BootstrapFile {
    #[serde(default)]
    publication: Option<String>,
    #[serde(default)]
    services: Vec<Vec<Vec<String>>>,
}

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

    #[test]
    fn bundled_snapshot_covers_far_more_than_the_curated_list() {
        let registry = BootstrapRegistry::bundled();
        assert!(
            registry.len() > 1000,
            "expected the full IANA list, got {}",
            registry.len()
        );
        assert!(registry.publication().is_some());
    }

    #[test]
    fn bundled_snapshot_yields_rdap_only() {
        let registry = BootstrapRegistry::bundled();
        let com = registry.get(&Tld::parse("com").unwrap()).unwrap();
        assert!(com.endpoints().iter().all(Endpoint::is_rdap));
        assert!(!com.endpoints().is_empty());
    }

    #[test]
    fn bundled_snapshot_query_urls_are_well_formed() {
        let registry = BootstrapRegistry::bundled();
        let com = registry.get(&Tld::parse("com").unwrap()).unwrap();

        let Endpoint::Rdap(endpoint) = &com.endpoints()[0] else {
            panic!("expected an RDAP endpoint");
        };
        let url = endpoint.query_url("example.com");
        assert!(url.contains("/domain/example.com"), "{url}");
        assert!(url.starts_with("http"), "{url}");
    }

    #[test]
    fn covers_internationalised_suffixes() {
        let registry = BootstrapRegistry::bundled();

        // IANA lists these as ACE labels, and both spellings must resolve, since `Tld`
        // compares on the punycode form. This is the only place that property is
        // exercised against real data rather than a fixture.
        //
        // `.онлайн` rather than a more obvious choice: plenty of internationalised
        // suffixes are absent from the RDAP bootstrap file because their registries
        // run no RDAP service, so the test has to name one that is actually there.
        assert!(
            registry.get(&Tld::parse("xn--80asehdb").unwrap()).is_some(),
            "the bundled snapshot lists no .онлайн"
        );
        assert!(registry.get(&Tld::parse("онлайн").unwrap()).is_some());

        let idn_count = registry.tlds().iter().filter(|tld| tld.is_idn()).count();
        assert!(
            idn_count > 50,
            "expected the internationalised suffixes, got {idn_count}"
        );
    }

    #[test]
    fn parses_a_minimal_document() {
        let json = r#"{
            "publication": "2026-01-01T00:00:00Z",
            "services": [[["example", "test"], ["https://rdap.example/"]]]
        }"#;
        let registry = BootstrapRegistry::from_json(json, "test").unwrap();

        assert_eq!(registry.len(), 2);
        assert_eq!(registry.publication(), Some("2026-01-01T00:00:00Z"));

        let entry = registry.get(&Tld::parse("test").unwrap()).unwrap();
        assert_eq!(entry.endpoints(), [Endpoint::rdap("https://rdap.example/")]);
    }

    #[test]
    fn skips_unusable_services_but_keeps_the_rest() {
        let json = r#"{"services":[
            [["-bad"], ["https://rdap.example/"]],
            [["nourls"], []],
            [["good"], ["https://rdap.example/"]]
        ]}"#;
        let registry = BootstrapRegistry::from_json(json, "test").unwrap();

        assert_eq!(registry.len(), 1);
        assert!(registry.get(&Tld::parse("good").unwrap()).is_some());
    }

    #[test]
    fn rejects_documents_with_nothing_usable() {
        assert!(BootstrapRegistry::from_json(r#"{"services":[]}"#, "test").is_err());
        assert!(BootstrapRegistry::from_json("{", "test").is_err());
        assert!(
            BootstrapRegistry::from_json(r#"{"services":[[["a"],["u"],["extra"]]]}"#, "test")
                .is_err()
        );
    }
}