monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! IDNA conversion and DNS label validation.
//!
//! Kept in one place because the rest of the crate should never have to think
//! about which form of a name it is holding: [`crate::DomainName`] and
//! [`crate::Tld`] carry both, and everything downstream asks for the one it
//! needs.

use crate::error::DomainError;

/// Longest legal DNS name, in octets, excluding the root label.
pub(crate) const MAX_NAME_LEN: usize = 253;
/// Longest legal DNS label, in octets.
pub(crate) const MAX_LABEL_LEN: usize = 63;
/// Prefix marking a punycode-encoded label.
pub(crate) const ACE_PREFIX: &str = "xn--";

/// Convert to punycode. ASCII input is lower-cased and otherwise untouched.
pub(crate) fn to_ascii(input: &str) -> std::result::Result<String, DomainError> {
    if input.is_ascii() {
        // Skip UTS-46 for the common case. It would only lower-case the string,
        // and its extra validation would reject names that a registry is
        // perfectly happy to answer for.
        return Ok(input.to_ascii_lowercase());
    }

    idna::domain_to_ascii(input).map_err(|error| DomainError::InvalidIdn {
        label: input.to_string(),
        detail: error.to_string(),
    })
}

/// Convert punycode labels back to Unicode.
///
/// Labels that are not punycode, or that do not decode, are passed through:
/// this is used for display, where showing the ACE form is better than failing.
pub(crate) fn to_unicode_lossy(input: &str) -> String {
    if !input.to_ascii_lowercase().contains(ACE_PREFIX) {
        return input.to_ascii_lowercase();
    }

    let (unicode, result) = idna::domain_to_unicode(input);
    if result.is_ok() {
        unicode
    } else {
        input.to_ascii_lowercase()
    }
}

/// Both forms of a name: `(punycode, unicode)`.
pub(crate) fn both_forms(input: &str) -> std::result::Result<(String, String), DomainError> {
    let ascii = to_ascii(input)?;
    let unicode = to_unicode_lossy(&ascii);
    Ok((ascii, unicode))
}

/// Check one label of the punycode form against the DNS rules.
///
/// Letters, digits and inner hyphens, 1 to 63 octets. Underscores and other
/// characters that appear in service records are rejected: they are legal in DNS
/// but never in a registrable domain, and accepting them only produces queries
/// no registry will answer.
pub(crate) fn validate_label(label: &str) -> std::result::Result<(), DomainError> {
    let invalid = || DomainError::InvalidLabel {
        label: label.to_string(),
    };

    if label.is_empty() || label.len() > MAX_LABEL_LEN {
        return Err(invalid());
    }
    if label.starts_with('-') || label.ends_with('-') {
        return Err(invalid());
    }
    if !label
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
    {
        return Err(invalid());
    }

    Ok(())
}

/// Whether the string is an IP address literal rather than a domain name.
///
/// Checked because `192.0.2.1` would otherwise pass label validation and be sent to a
/// registry as if it were a name. Two colons is taken as IPv6 even when it does not
/// parse — a truncated or malformed address is still not a domain — but a single colon
/// is not, so `example.com:oops` is reported as a bad label rather than as an address.
pub(crate) fn is_ip_literal(input: &str) -> bool {
    input.parse::<std::net::IpAddr>().is_ok()
        || (input.starts_with('[') && input.ends_with(']'))
        || input.matches(':').count() >= 2
}

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

    #[test]
    fn ascii_input_is_only_lowercased() {
        assert_eq!(to_ascii("Example.COM").unwrap(), "example.com");
    }

    #[test]
    fn unicode_round_trips() {
        let ascii = to_ascii("münchen.de").unwrap();
        assert_eq!(ascii, "xn--mnchen-3ya.de");
        assert_eq!(to_unicode_lossy(&ascii), "münchen.de");
    }

    #[test]
    fn undecodable_ace_is_passed_through() {
        assert_eq!(to_unicode_lossy("xn--"), "xn--");
        assert_eq!(to_unicode_lossy("XN--ZZZZZZZZZ"), "xn--zzzzzzzzz");
    }

    #[test]
    fn label_rules() {
        assert!(validate_label("example").is_ok());
        assert!(validate_label("xn--mnchen-3ya").is_ok());
        assert!(validate_label("a-b-c").is_ok());
        assert!(validate_label("").is_err());
        assert!(validate_label("-lead").is_err());
        assert!(validate_label("trail-").is_err());
        assert!(validate_label("under_score").is_err());
        assert!(validate_label(&"a".repeat(64)).is_err());
        assert!(validate_label(&"a".repeat(63)).is_ok());
    }

    #[test]
    fn detects_ip_literals() {
        assert!(is_ip_literal("192.0.2.1"));
        assert!(is_ip_literal("::1"));
        assert!(is_ip_literal("2001:db8::1"));
        assert!(is_ip_literal("[2001:db8::1]"));
        assert!(!is_ip_literal("example.com"));
        // One colon is a malformed name, not an address.
        assert!(!is_ip_literal("example.com:oops"));
    }
}