seer-core 0.48.0

Core library for Seer domain name utilities
Documentation
//! Registry-specific WHOIS response parsers.
//!
//! This module provides a parser registry system that allows for TLD-specific
//! parsing of WHOIS responses. Different registries use different formats,
//! field names, and date formats, so having specialized parsers improves
//! data extraction reliability.

mod denic;
mod educause;
mod eis;
mod eurid;
mod generic;
mod isoc_il;
mod jprs;
mod kisa;
mod nic_it;
mod nic_lv;
mod nominet;
mod sidn;

use once_cell::sync::Lazy;

use super::parser::WhoisResponse;
pub use denic::DenicParser;
pub use educause::EducauseParser;
pub use eis::EisParser;
pub use eurid::EuridParser;
pub use generic::GenericParser;
pub use isoc_il::IsocIlParser;
pub use jprs::JprsParser;
pub use kisa::KisaParser;
pub use nic_it::NicItParser;
pub use nic_lv::NicLvParser;
pub use nominet::NominetParser;
pub use sidn::SidnParser;

/// Maximum number of nameservers extracted from a single WHOIS response.
/// Shared with the generic parser. Real domains have ≤ 13 NS records (DNS
/// protocol limit); cap defensively so a hostile/malformed registry body of
/// distinct `nserver:` lines can't drive the O(n) `Vec::contains` dedup into
/// O(n²) CPU.
pub(crate) const MAX_NAMESERVERS: usize = 32;

/// Maximum number of domain-level status codes we extract. Shared with the
/// generic parser. EPP defines ~16 status values; a real domain rarely has
/// more than 5-6. Cap to bound CPU/memory against a hostile body.
pub(crate) const MAX_STATUSES: usize = 32;

/// Pushes `value` into `vec` when it is non-empty, below `cap`, and not already
/// present. The linear `Vec::contains` dedup is fine because `cap` bounds `n`,
/// so this stays O(cap) per call against adversarial input.
pub(crate) fn push_bounded(vec: &mut Vec<String>, value: String, cap: usize) {
    if !value.is_empty() && vec.len() < cap && !vec.contains(&value) {
        vec.push(value);
    }
}

/// Trait for registry-specific WHOIS parsers.
///
/// Implementors of this trait can provide specialized parsing logic for
/// specific TLDs that don't follow the standard WHOIS format.
pub trait RegistryParser: Send + Sync {
    /// Returns the TLDs this parser handles.
    fn supported_tlds(&self) -> &[&str];

    /// Parses a raw WHOIS response into a structured response.
    fn parse(&self, domain: &str, server: &str, raw: &str) -> WhoisResponse;
}

/// Registry of all available parsers.
///
/// The registry maintains a list of specialized parsers and falls back
/// to the generic parser when no specialized parser is available.
pub struct ParserRegistry {
    parsers: Vec<Box<dyn RegistryParser>>,
    fallback: GenericParser,
}

impl ParserRegistry {
    /// Creates a new parser registry with all known parsers.
    pub fn new() -> Self {
        Self {
            parsers: vec![
                Box::new(DenicParser::new()),    // .de
                Box::new(EducauseParser::new()), // .edu
                Box::new(EisParser::new()),      // .ee
                Box::new(EuridParser::new()),    // .eu, .ею, .ευ
                Box::new(IsocIlParser::new()),   // .il (co.il, org.il, …), .ישראל
                Box::new(JprsParser::new()),     // .jp
                Box::new(KisaParser::new()),     // .kr, .한국, .삼성
                Box::new(NicItParser::new()),    // .it
                Box::new(NicLvParser::new()),    // .lv
                Box::new(NominetParser::new()),  // .uk, .co.uk
                Box::new(SidnParser::new()),     // .nl
            ],
            fallback: GenericParser::new(),
        }
    }

    /// Parses a WHOIS response using the appropriate parser for the TLD.
    ///
    /// This method first checks if any specialized parser handles the TLD,
    /// and falls back to the generic parser if not.
    pub fn parse(&self, domain: &str, server: &str, raw: &str) -> WhoisResponse {
        // Extract TLD (and second-level TLD for ccSLDs like .co.uk)
        let tld = extract_tld(domain);
        let sld_tld = extract_second_level_tld(domain);

        // Try to find a specialized parser
        for parser in &self.parsers {
            let supported = parser.supported_tlds();
            // Check for exact second-level TLD match first (e.g., "co.uk")
            if let Some(sld) = &sld_tld {
                if supported.contains(&sld.as_str()) {
                    return parser.parse(domain, server, raw);
                }
            }
            // Then check for TLD match (e.g., "uk")
            if let Some(tld) = &tld {
                if supported.contains(&tld.as_str()) {
                    return parser.parse(domain, server, raw);
                }
            }
        }

        // Fall back to generic parser
        self.fallback.parse(domain, server, raw)
    }
}

impl Default for ParserRegistry {
    fn default() -> Self {
        Self::new()
    }
}

/// Global parser registry instance.
pub static PARSER_REGISTRY: Lazy<ParserRegistry> = Lazy::new(ParserRegistry::new);

/// Extracts the TLD from a domain name.
fn extract_tld(domain: &str) -> Option<String> {
    domain.rsplit('.').next().map(|s| s.to_lowercase())
}

/// Extracts the second-level TLD from a domain name (e.g., "co.uk" from "example.co.uk").
fn extract_second_level_tld(domain: &str) -> Option<String> {
    let parts: Vec<&str> = domain.rsplit('.').collect();
    if parts.len() >= 3 {
        Some(format!(
            "{}.{}",
            parts[1].to_lowercase(),
            parts[0].to_lowercase()
        ))
    } else {
        None
    }
}

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

    #[test]
    fn test_extract_tld() {
        assert_eq!(extract_tld("example.com"), Some("com".to_string()));
        assert_eq!(extract_tld("example.co.uk"), Some("uk".to_string()));
        assert_eq!(extract_tld("example.de"), Some("de".to_string()));
    }

    #[test]
    fn test_extract_second_level_tld() {
        assert_eq!(
            extract_second_level_tld("example.co.uk"),
            Some("co.uk".to_string())
        );
        assert_eq!(extract_second_level_tld("example.com"), None);
    }

    // Every parser sets `domain`, so the selection tests assert a field only
    // the expected parser produces from the given body.

    #[test]
    fn test_parser_registry_selects_denic_for_de() {
        let registry = ParserRegistry::new();
        let result = registry.parse(
            "example.de",
            "whois.denic.de",
            "Domain: example.de\nStatus: connect",
        );
        // DENIC maps its `connect` vocabulary to `active`; the generic
        // parser would keep `connect` verbatim.
        assert_eq!(result.status, vec!["active"]);
    }

    #[test]
    fn test_parser_registry_selects_nominet_for_uk() {
        let registry = ParserRegistry::new();
        let result = registry.parse(
            "example.co.uk",
            "whois.nic.uk",
            "Domain name:\n    example.co.uk\n\nRegistrar:\n    Example Registrar Ltd [Tag = EXAMPLE]\n",
        );
        // Nominet strips the `[Tag = …]` suffix; the generic parser keeps it.
        assert_eq!(result.registrar.as_deref(), Some("Example Registrar Ltd"));
    }

    #[test]
    fn test_parser_registry_uses_generic_for_unknown() {
        let registry = ParserRegistry::new();
        let result = registry.parse(
            "example.com",
            "whois.verisign-grs.com",
            "Domain Name: example.com\nRegistrar: Example Registrar, Inc.\nStatus: connect\n",
        );
        assert_eq!(result.registrar.as_deref(), Some("Example Registrar, Inc."));
        // Not routed through DENIC's status mapping.
        assert_eq!(result.status, vec!["connect"]);
    }

    /// whois.kr serves .한국 / .삼성 in the KISA format: those IDN TLDs (which
    /// reach the parser as A-labels) must use the KISA parser, whose
    /// `Authorized Agency` / `Host Name` fields the generic parser ignores.
    #[test]
    fn test_parser_registry_selects_kisa_for_korean_idn_tlds() {
        let raw = "# ENGLISH\n\
                   \n\
                   Domain Name                 : example.xn--3e0b707e\n\
                   Authorized Agency           : Whois Corp.(http://whois.co.kr)\n\
                   \n\
                   Primary Name Server\n\
                   \x20  Host Name                : ns1.example.kr\n";
        let registry = ParserRegistry::new();
        for domain in ["example.xn--3e0b707e", "example.xn--cg4bki"] {
            let result = registry.parse(domain, "whois.kr", raw);
            assert_eq!(
                result.registrar.as_deref(),
                Some("Whois Corp."),
                "{domain} must use the KISA parser"
            );
            assert_eq!(result.nameservers, vec!["ns1.example.kr"], "{domain}");
        }
    }

    /// whois.eu serves .ею / .ευ in the EURid format.
    #[test]
    fn test_parser_registry_selects_eurid_for_eu_idn_tlds() {
        let raw = "Domain: example.xn--e1a4c\n\
                   \n\
                   Name servers:\n\
                   \x20       ns1.example.eu (192.0.2.1)\n\
                   \x20       ns2.example.eu\n\
                   \n\
                   Keys:\n\
                   \x20       flags:KSK protocol:3 algorithm:RSA_SHA256 pubKey:AwEAAtest\n";
        let registry = ParserRegistry::new();
        for domain in ["example.xn--e1a4c", "example.xn--qxa6a"] {
            let result = registry.parse(domain, "whois.eu", raw);
            assert_eq!(
                result.nameservers,
                vec!["ns1.example.eu", "ns2.example.eu"],
                "{domain} must use the EURid parser"
            );
            assert_eq!(
                result.dnssec.as_deref(),
                Some("signedDelegation"),
                "{domain}"
            );
        }
    }
}