use std::fmt;
use std::net::IpAddr;
pub(crate) fn normalize_public_hostname(hostname: &str) -> String {
hostname.trim_end_matches('.').to_ascii_lowercase()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PublicHostnameError {
Empty,
TooLong,
EmptyLabel,
LabelTooLong,
InvalidCharacter,
LeadingOrTrailingHyphen,
Wildcard,
IpLiteral,
RawUnicode,
}
impl fmt::Display for PublicHostnameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("hostname must not be empty"),
Self::TooLong => formatter.write_str("hostname must be 253 bytes or shorter"),
Self::EmptyLabel => formatter.write_str("hostname labels must not be empty"),
Self::LabelTooLong => {
formatter.write_str("hostname labels must be 63 bytes or shorter")
}
Self::InvalidCharacter => formatter.write_str(
"hostname must contain only lowercase ASCII letters, digits, dots, and hyphens",
),
Self::LeadingOrTrailingHyphen => {
formatter.write_str("hostname labels must not start or end with a hyphen")
}
Self::Wildcard => formatter.write_str("wildcard hostnames are not supported"),
Self::IpLiteral => formatter.write_str("IP literals are not supported"),
Self::RawUnicode => formatter.write_str("raw Unicode hostnames are not supported"),
}
}
}
pub(crate) fn validate_public_hostname(hostname: &str) -> Result<String, PublicHostnameError> {
let normalized = normalize_public_hostname(hostname);
if normalized.is_empty() {
return Err(PublicHostnameError::Empty);
}
if !normalized.is_ascii() {
return Err(PublicHostnameError::RawUnicode);
}
if normalized.contains('*') {
return Err(PublicHostnameError::Wildcard);
}
if normalized.parse::<IpAddr>().is_ok() {
return Err(PublicHostnameError::IpLiteral);
}
if normalized.len() > 253 {
return Err(PublicHostnameError::TooLong);
}
for label in normalized.split('.') {
if label.is_empty() {
return Err(PublicHostnameError::EmptyLabel);
}
if label.len() > 63 {
return Err(PublicHostnameError::LabelTooLong);
}
if label.starts_with('-') || label.ends_with('-') {
return Err(PublicHostnameError::LeadingOrTrailingHyphen);
}
if !label
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err(PublicHostnameError::InvalidCharacter);
}
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::{PublicHostnameError, normalize_public_hostname, validate_public_hostname};
#[test]
fn lowercases_public_hostnames() {
assert_eq!(
normalize_public_hostname("App.Example.Test"),
"app.example.test"
);
}
#[test]
fn strips_a_trailing_dot_from_public_hostnames() {
assert_eq!(
normalize_public_hostname("app.example.test."),
"app.example.test"
);
}
#[test]
fn strips_all_trailing_dots_from_public_hostnames() {
assert_eq!(
normalize_public_hostname("app.example.test..."),
"app.example.test"
);
assert_eq!(normalize_public_hostname(".."), "");
}
#[test]
fn accepts_punycode_a_labels() {
assert_eq!(
validate_public_hostname("XN--BCHER-KVA.example").unwrap(),
"xn--bcher-kva.example"
);
}
#[test]
fn rejects_raw_unicode_hostnames() {
assert_eq!(
validate_public_hostname("bücher.example").unwrap_err(),
PublicHostnameError::RawUnicode
);
}
#[test]
fn rejects_wildcard_hostnames() {
assert_eq!(
validate_public_hostname("*.example.test").unwrap_err(),
PublicHostnameError::Wildcard
);
}
#[test]
fn rejects_ip_literals() {
assert_eq!(
validate_public_hostname("127.0.0.1").unwrap_err(),
PublicHostnameError::IpLiteral
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(32))]
#[test]
fn normalization_is_idempotent(hostname in ".*") {
let normalized = normalize_public_hostname(&hostname);
prop_assert_eq!(normalize_public_hostname(&normalized), normalized);
}
#[test]
fn validated_hostnames_remain_in_canonical_form(hostname in ".*") {
if let Ok(validated) = validate_public_hostname(&hostname) {
prop_assert_eq!(normalize_public_hostname(&validated), validated.clone());
prop_assert_eq!(validate_public_hostname(&validated), Ok(validated.clone()));
}
}
}
}