dnspls-domain 0.1.0

Canonical domain identity primitives for DNSpls
Documentation
//! Canonical domain identity primitives.
//!
//! This crate knows how to turn user-facing names into stable DNS comparison
//! keys. It deliberately knows nothing about providers, availability, HTTP, or
//! MCP. Public-suffix data provides a parsing boundary only; it never implies
//! that a name is available or supported by a registrar.

use std::{error::Error, fmt};

use serde::{Deserialize, Serialize};

const MAX_DOMAIN_OCTETS: usize = 253;
const MAX_LABEL_OCTETS: usize = 63;

/// A syntactically valid, canonical DNS name.
///
/// Equality, ordering, hashing, and serialization use the lowercase ASCII
/// A-label form. Construction is only possible through [`DomainName::parse`].
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct DomainName(Box<str>);

impl DomainName {
    /// Parses a domain using strict IDNA processing and DNS host-label rules.
    ///
    /// One terminal root dot is accepted and removed. Leading/trailing
    /// whitespace is rejected rather than silently changing user input.
    ///
    /// # Errors
    ///
    /// Returns a stable [`DomainNameError`] when IDNA processing or DNS label
    /// validation rejects the input.
    pub fn parse(input: &str) -> Result<Self, DomainNameError> {
        if input.is_empty() {
            return Err(DomainNameError::new(DomainErrorCode::EmptyName));
        }
        if input.trim() != input {
            return Err(DomainNameError::new(DomainErrorCode::Whitespace));
        }

        let without_root = input.strip_suffix('.').unwrap_or(input);
        if without_root.is_empty() {
            return Err(DomainNameError::new(DomainErrorCode::EmptyName));
        }

        let ascii = idna::domain_to_ascii_strict(without_root)
            .map_err(|_| DomainNameError::new(DomainErrorCode::IdnaRejected))?
            .to_ascii_lowercase();

        validate_ascii_name(&ascii)?;
        Ok(Self(ascii.into_boxed_str()))
    }

    /// Returns the canonical lowercase ASCII A-label form.
    pub fn as_ascii(&self) -> &str {
        &self.0
    }

    /// Returns the normalized Unicode display form.
    pub fn to_unicode(&self) -> String {
        let (unicode, result) = idna::domain_to_unicode(self.as_ascii());
        debug_assert!(result.is_ok(), "a validated A-label must decode");
        unicode
    }

    /// Returns the final ASCII root-zone label without a leading dot.
    pub fn tld(&self) -> &str {
        self.as_ascii()
            .rsplit_once('.')
            .map_or(self.as_ascii(), |(_, tld)| tld)
    }

    /// Resolves the embedded Public Suffix List boundary.
    ///
    /// A missing boundary means policy coverage is unknown, not that the name
    /// is invalid or available.
    pub fn identity(&self) -> DomainIdentity {
        let bytes = self.as_ascii().as_bytes();
        let public_suffix = psl::suffix(bytes)
            .filter(psl::Suffix::is_known)
            .map(|suffix| {
                PublicSuffix(copy_ascii(
                    suffix.as_bytes(),
                    "PSL suffix is a slice of canonical ASCII input",
                ))
            });
        let registrable_domain = psl::domain(bytes)
            .filter(|domain| domain.suffix().is_known())
            .map(|domain| {
                RegistrableDomain(copy_ascii(
                    domain.as_bytes(),
                    "PSL domain is a slice of canonical ASCII input",
                ))
            });

        DomainIdentity {
            name: self.clone(),
            public_suffix,
            registrable_domain,
        }
    }
}

impl fmt::Display for DomainName {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_ascii())
    }
}

impl<'de> Deserialize<'de> for DomainName {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let input = String::deserialize(deserializer)?;
        Self::parse(&input).map_err(serde::de::Error::custom)
    }
}

/// A canonical name plus its embedded-PSL parsing interpretation.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct DomainIdentity {
    name: DomainName,
    public_suffix: Option<PublicSuffix>,
    registrable_domain: Option<RegistrableDomain>,
}

impl DomainIdentity {
    pub fn name(&self) -> &DomainName {
        &self.name
    }

    pub fn public_suffix(&self) -> Option<&PublicSuffix> {
        self.public_suffix.as_ref()
    }

    pub fn registrable_domain(&self) -> Option<&RegistrableDomain> {
        self.registrable_domain.as_ref()
    }

    pub const fn boundary_status(&self) -> BoundaryStatus {
        if self.public_suffix.is_some() && self.registrable_domain.is_some() {
            BoundaryStatus::Known
        } else {
            BoundaryStatus::PolicyUnknown
        }
    }
}

/// Whether the pinned parsing data can identify a registrable boundary.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoundaryStatus {
    Known,
    PolicyUnknown,
}

/// A suffix found in the embedded Public Suffix List.
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PublicSuffix(Box<str>);

impl PublicSuffix {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// The eTLD+1 boundary derived from the embedded Public Suffix List.
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RegistrableDomain(Box<str>);

impl RegistrableDomain {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Stable, non-provider-specific domain parsing failures.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DomainErrorCode {
    EmptyName,
    Whitespace,
    IdnaRejected,
    EmptyLabel,
    LabelTooLong,
    NameTooLong,
    InvalidAsciiLabel,
}

/// A domain parse error suitable for transport-safe mapping.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct DomainNameError {
    code: DomainErrorCode,
}

impl DomainNameError {
    const fn new(code: DomainErrorCode) -> Self {
        Self { code }
    }

    pub const fn code(&self) -> DomainErrorCode {
        self.code
    }
}

impl fmt::Display for DomainNameError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let message = match self.code {
            DomainErrorCode::EmptyName => "domain name is empty",
            DomainErrorCode::Whitespace => "domain name contains surrounding whitespace",
            DomainErrorCode::IdnaRejected => "domain name was rejected by strict IDNA processing",
            DomainErrorCode::EmptyLabel => "domain name contains an empty label",
            DomainErrorCode::LabelTooLong => "domain name contains a label longer than 63 octets",
            DomainErrorCode::NameTooLong => "domain name is longer than 253 octets",
            DomainErrorCode::InvalidAsciiLabel => "domain name contains an invalid host label",
        };
        formatter.write_str(message)
    }
}

impl Error for DomainNameError {}

fn validate_ascii_name(ascii: &str) -> Result<(), DomainNameError> {
    if ascii.len() > MAX_DOMAIN_OCTETS {
        return Err(DomainNameError::new(DomainErrorCode::NameTooLong));
    }

    for label in ascii.split('.') {
        if label.is_empty() {
            return Err(DomainNameError::new(DomainErrorCode::EmptyLabel));
        }
        if label.len() > MAX_LABEL_OCTETS {
            return Err(DomainNameError::new(DomainErrorCode::LabelTooLong));
        }
        if label.starts_with('-')
            || label.ends_with('-')
            || !label
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
        {
            return Err(DomainNameError::new(DomainErrorCode::InvalidAsciiLabel));
        }
    }
    Ok(())
}

fn copy_ascii(bytes: &[u8], invariant: &str) -> Box<str> {
    std::str::from_utf8(bytes)
        .expect(invariant)
        .to_owned()
        .into_boxed_str()
}

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

    use super::*;

    #[test]
    fn canonicalizes_case_and_root_dot() {
        let domain = DomainName::parse("ExAmPlE.COM.").unwrap();
        assert_eq!(domain.as_ascii(), "example.com");
        assert_eq!(domain.to_unicode(), "example.com");
        assert_eq!(domain.tld(), "com");
    }

    #[test]
    fn canonicalizes_an_idn_without_mixing_labels() {
        let domain = DomainName::parse("BÜCHER.de").unwrap();
        assert_eq!(domain.as_ascii(), "xn--bcher-kva.de");
        assert_eq!(domain.to_unicode(), "bücher.de");
    }

    #[test]
    fn resolves_multilabel_public_suffixes() {
        let identity = DomainName::parse("www.example.co.uk").unwrap().identity();
        assert_eq!(identity.boundary_status(), BoundaryStatus::Known);
        assert_eq!(identity.public_suffix().unwrap().as_str(), "co.uk");
        assert_eq!(
            identity.registrable_domain().unwrap().as_str(),
            "example.co.uk"
        );
    }

    #[test]
    fn unknown_suffix_is_policy_unknown() {
        let identity = DomainName::parse("example.definitely-not-a-real-tld")
            .unwrap()
            .identity();
        assert_eq!(identity.boundary_status(), BoundaryStatus::PolicyUnknown);
        assert!(identity.public_suffix().is_none());
        assert!(identity.registrable_domain().is_none());
    }

    #[test]
    fn rejects_ambiguous_or_invalid_input() {
        for input in [
            "",
            ".",
            " example.com",
            "example.com ",
            "a..com",
            "-a.com",
            "a_.com",
        ] {
            assert!(DomainName::parse(input).is_err(), "accepted {input:?}");
        }
    }

    #[test]
    fn deserialization_revalidates_the_invariant() {
        assert!(serde_json::from_str::<DomainName>(r#""bad_.com""#).is_err());
    }

    proptest! {
        #[test]
        fn parser_never_panics_and_success_is_canonical(input in any::<String>()) {
            if let Ok(domain) = DomainName::parse(&input) {
                prop_assert!(!domain.as_ascii().is_empty());
                prop_assert!(domain.as_ascii().len() <= MAX_DOMAIN_OCTETS);
                prop_assert_eq!(domain.as_ascii(), domain.as_ascii().to_ascii_lowercase());
                prop_assert!(!domain.as_ascii().ends_with('.'));
                prop_assert!(domain.as_ascii().split('.').all(|label| !label.is_empty() && label.len() <= MAX_LABEL_OCTETS));
                prop_assert_eq!(DomainName::parse(domain.as_ascii()).unwrap(), domain);
            }
        }
    }
}