reserve-core 0.1.0

Core lookup, catalog, and rate-limiting engine behind the reserve domain finder
Documentation
use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};

use crate::error::Error;

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct Suffix(String);

impl<'de> Deserialize<'de> for Suffix {
    /// @docgen Without this a suffix loaded from the catalog skips every rule the parser enforces on typed input.
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let raw = String::deserialize(deserializer)?;
        Self::parse(&raw).map_err(serde::de::Error::custom)
    }
}

impl Suffix {
    pub fn parse(value: &str) -> Result<Self, Error> {
        let trimmed = value.trim().trim_start_matches('.').trim_end_matches('.');
        if trimmed.is_empty() {
            return Err(Error::ExtensionInvalid {
                extension: value.to_owned(),
            });
        }

        let lowered = trimmed.to_lowercase();
        let ascii = idna::domain_to_ascii(&lowered).map_err(|_| Error::ExtensionInvalid {
            extension: value.to_owned(),
        })?;

        if ascii.len() > 253 {
            return Err(Error::ExtensionInvalid {
                extension: value.to_owned(),
            });
        }

        for label in ascii.split('.') {
            if check_label(label).is_err() || label.bytes().all(|b| b.is_ascii_digit()) {
                return Err(Error::ExtensionInvalid {
                    extension: value.to_owned(),
                });
            }
        }

        Ok(Self(ascii))
    }

    /// @docgen Reporting an unrecognized name needs a suffix that cannot fail to build, since it is only ever displayed.
    pub(crate) fn from_raw(value: &str) -> Self {
        Self(value.to_lowercase())
    }

    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    #[must_use]
    pub fn label_count(&self) -> usize {
        self.0.split('.').count()
    }

    /// @docgen ICANN delegates the final label, so a two-letter test sees `co.uk` as a country code.
    #[must_use]
    pub fn delegated_label(&self) -> &str {
        self.0.rsplit('.').next().unwrap_or(&self.0)
    }

    #[must_use]
    pub fn is_country_code(&self) -> bool {
        let root = self.delegated_label();
        root.len() == 2 && root.bytes().all(|b| b.is_ascii_alphabetic())
    }

    /// @docgen Registry tables are matched longest-first, so `co.uk` wins over `uk`.
    #[must_use]
    pub fn ancestors(&self) -> Vec<String> {
        let mut chain = Vec::new();
        let mut rest: &str = &self.0;
        loop {
            chain.push(rest.to_owned());
            match rest.split_once('.') {
                Some((_, tail)) if !tail.is_empty() => rest = tail,
                _ => break,
            }
        }
        chain
    }
}

fn check_label(label: &str) -> Result<(), &'static str> {
    if label.is_empty() {
        return Err("it has an empty label");
    }
    if label.len() > 63 {
        return Err("a label is longer than 63 characters");
    }
    if label.starts_with('-') || label.ends_with('-') {
        return Err("a label starts or ends with a hyphen");
    }
    if !label
        .bytes()
        .all(|b| b.is_ascii_alphanumeric() || b == b'-')
    {
        return Err("it has a character that is not a letter, digit, or hyphen");
    }
    Ok(())
}

/// @docgen A name reaches a raw port-43 request line, so an unchecked control byte injects a second query.
pub fn parse_name(value: &str) -> Result<String, Error> {
    let trimmed = value.trim();
    let refuse = |reason: &str| Error::NameInvalid {
        name: value.to_owned(),
        reason: reason.to_owned(),
    };

    if trimmed.is_empty() {
        return Err(refuse("it is empty"));
    }

    let ascii = idna::domain_to_ascii(&trimmed.to_lowercase())
        .map_err(|_| refuse("it is not a usable domain name"))?;

    if ascii.len() > 253 {
        return Err(refuse("it is longer than 253 characters"));
    }
    for label in ascii.split('.') {
        check_label(label).map_err(refuse)?;
    }

    Ok(ascii)
}

impl fmt::Display for Suffix {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl FromStr for Suffix {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::parse(s)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExtensionKind {
    Generic,
    Country,
    Sponsored,
}

impl ExtensionKind {
    #[must_use]
    pub const fn label(self) -> &'static str {
        match self {
            Self::Generic => "generic",
            Self::Country => "country",
            Self::Sponsored => "sponsored",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Extension {
    pub suffix: Suffix,
    pub kind: ExtensionKind,
    /// @docgen None means the zone is too small or too private to rank, so absence is not missing data.
    #[serde(default)]
    pub rank: Option<u32>,
    #[serde(default)]
    pub industries: Vec<String>,
    #[serde(default)]
    pub region: Option<String>,
    #[serde(default)]
    pub country: Option<String>,
    #[serde(default = "default_registrable")]
    pub registrable: bool,
    #[serde(default)]
    pub repurposed: bool,
}

const fn default_registrable() -> bool {
    true
}

impl Extension {
    #[must_use]
    pub fn is_in_industry(&self, key: &str) -> bool {
        self.industries.iter().any(|i| i == key)
    }
}

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

    #[test]
    fn a_leading_dot_is_accepted_and_stripped() {
        assert_eq!(Suffix::parse(".com").unwrap().as_str(), "com");
        assert_eq!(Suffix::parse("com").unwrap().as_str(), "com");
        assert_eq!(Suffix::parse("  .COM  ").unwrap().as_str(), "com");
    }

    #[test]
    fn rubbish_is_refused_rather_than_guessed() {
        for bad in ["", ".", "..", "-com", "com-", "a..b", "9", "co m", "*"] {
            assert!(Suffix::parse(bad).is_err(), "{bad} should be refused");
        }
    }

    #[test]
    fn a_label_of_sixty_three_characters_is_the_longest_one_allowed() {
        let longest = "a".repeat(63);
        assert_eq!(Suffix::parse(&longest).unwrap().as_str(), longest);
        assert!(Suffix::parse(&"a".repeat(64)).is_err());
    }

    #[test]
    fn an_extension_of_two_hundred_and_fifty_three_characters_is_the_longest_one_allowed() {
        let label = "a".repeat(63);
        let at_the_cap = [
            label.as_str(),
            label.as_str(),
            label.as_str(),
            &"b".repeat(61),
        ]
        .join(".");
        assert_eq!(at_the_cap.len(), 253);
        assert!(Suffix::parse(&at_the_cap).is_ok());

        let over_the_cap = format!("{at_the_cap}b");
        assert_eq!(over_the_cap.len(), 254);
        assert!(Suffix::parse(&over_the_cap).is_err());
    }

    #[test]
    fn a_suffix_read_from_json_goes_through_the_same_parser_as_a_typed_one() {
        let parsed: Suffix = serde_json::from_str("\".CO.UK\"").unwrap();
        assert_eq!(parsed.as_str(), "co.uk");
        assert_eq!(parsed, Suffix::parse(".CO.UK").unwrap());
    }

    #[test]
    fn an_unusable_suffix_in_json_is_refused_rather_than_loaded_unchecked() {
        for bad in [
            "\"\"", "\".\"", "\"-com\"", "\"com-\"", "\"a..b\"", "\"9\"", "\"co m\"",
        ] {
            assert!(
                serde_json::from_str::<Suffix>(bad).is_err(),
                "{bad} should be refused"
            );
        }
    }

    #[test]
    fn a_suffix_survives_a_round_trip_through_json() {
        let suffix = Suffix::parse("com.bd").unwrap();
        let text = serde_json::to_string(&suffix).unwrap();
        assert_eq!(text, "\"com.bd\"");
        assert_eq!(serde_json::from_str::<Suffix>(&text).unwrap(), suffix);
    }

    #[test]
    fn label_count_separates_second_level_from_third() {
        assert_eq!(Suffix::parse("com").unwrap().label_count(), 1);
        assert_eq!(Suffix::parse("co.uk").unwrap().label_count(), 2);
    }

    #[test]
    fn the_country_test_reads_the_delegated_label_not_the_whole_string() {
        assert!(Suffix::parse("uk").unwrap().is_country_code());
        assert!(Suffix::parse("co.uk").unwrap().is_country_code());
        assert!(Suffix::parse("bd").unwrap().is_country_code());
        assert!(!Suffix::parse("com").unwrap().is_country_code());
        assert!(!Suffix::parse("dev").unwrap().is_country_code());
    }

    #[test]
    fn the_parent_chain_runs_longest_first() {
        let suffix = Suffix::parse("com.bd").unwrap();
        assert_eq!(
            suffix.ancestors(),
            vec!["com.bd".to_owned(), "bd".to_owned()]
        );
        let plain = Suffix::parse("dev").unwrap();
        assert_eq!(plain.ancestors(), vec!["dev".to_owned()]);
    }

    #[test]
    fn a_control_byte_in_a_name_is_refused() {
        for bad in [
            "x\rdomain google.com",
            "x\ndomain google.com",
            "x\r\ndomain google.com",
            "x\0y",
            "x y",
            "x\ty",
            "x\u{1b}[2Ky",
        ] {
            assert!(
                parse_name(bad).is_err(),
                "{bad:?} must never reach a request line"
            );
        }
    }

    #[test]
    fn a_usable_name_survives_validation() {
        assert_eq!(parse_name("example").unwrap(), "example");
        assert_eq!(parse_name("  Example  ").unwrap(), "example");
        assert_eq!(parse_name("shop.example").unwrap(), "shop.example");
        assert_eq!(parse_name("123").unwrap(), "123");
        assert_eq!(parse_name("a-b").unwrap(), "a-b");
    }

    #[test]
    fn a_unicode_name_is_normalized_before_it_reaches_the_wire() {
        assert_eq!(parse_name("münchen").unwrap(), "xn--mnchen-3ya");
    }

    #[test]
    fn a_malformed_name_is_refused() {
        for bad in ["", "  ", "-lead", "trail-", "a..b", &"x".repeat(64)] {
            assert!(parse_name(bad).is_err(), "{bad:?} should be refused");
        }
    }

    #[test]
    fn a_unicode_extension_is_normalized_to_its_ascii_form() {
        let suffix = Suffix::parse("বাংলা").unwrap();
        assert!(suffix.as_str().starts_with("xn--"));
    }
}