use crate::server::error::AppError;
const MAX_LEN: usize = 253;
pub fn normalize_domain_name(input: &str) -> Result<String, AppError> {
if input.is_empty() {
return Err(AppError::Validation("domain name must not be empty".into()));
}
if input.len() > MAX_LEN {
return Err(AppError::Validation(format!(
"domain name exceeds {MAX_LEN} chars"
)));
}
if input.trim() != input {
return Err(AppError::Validation(
"domain name must not have leading or trailing whitespace".into(),
));
}
if input.starts_with('/') || input.ends_with('/') {
return Err(AppError::Validation(
"domain name must not start or end with '/'".into(),
));
}
let (host_in, path_in) = match input.split_once('/') {
Some((h, p)) => (h, Some(p)),
None => (input, None),
};
let host = normalize_host(host_in)?;
let canonical = match path_in {
None => host,
Some(p) => {
let path = normalize_path(p)?;
format!("{host}/{path}")
}
};
if canonical != input {
return Err(AppError::Validation(format!(
"domain name not in canonical form — use '{canonical}'"
)));
}
Ok(canonical)
}
fn normalize_host(host: &str) -> Result<String, AppError> {
let (host_part, port) = split_host_port(host)?;
let parsed = url::Host::parse(host_part)
.map_err(|e| AppError::Validation(format!("malformed host '{host_part}': {e}")))?;
let canonical_host = match parsed {
url::Host::Domain(s) => {
if !s
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.')
{
return Err(AppError::Validation(format!(
"host '{s}' contains characters outside LDH + '.'"
)));
}
s
}
url::Host::Ipv4(_) | url::Host::Ipv6(_) => {
return Err(AppError::Validation(format!(
"host '{host_part}' is an IP literal — domain names must be DNS hostnames"
)));
}
};
Ok(match port {
Some(p) => format!("{canonical_host}:{p}"),
None => canonical_host,
})
}
fn split_host_port(host: &str) -> Result<(&str, Option<u16>), AppError> {
let Some((h, p)) = host.rsplit_once(':') else {
return Ok((host, None));
};
if h.is_empty() || p.is_empty() {
return Err(AppError::Validation(format!(
"malformed host '{host}': empty host or port around ':'"
)));
}
let n: u16 = p.parse().map_err(|_| {
AppError::Validation(format!(
"host port '{p}' must be a number between 1 and 65535"
))
})?;
if n == 0 {
return Err(AppError::Validation("host port must not be 0".into()));
}
Ok((h, Some(n)))
}
fn normalize_path(path: &str) -> Result<String, AppError> {
if path.is_empty() {
return Err(AppError::Validation(
"path-prefix after '/' must not be empty".into(),
));
}
if path.starts_with('/') || path.ends_with('/') {
return Err(AppError::Validation(
"path-prefix must not have leading or trailing '/'".into(),
));
}
if path.contains("//") {
return Err(AppError::Validation(
"path-prefix must not contain empty segments ('//')".into(),
));
}
for c in path.chars() {
let ok = c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| c == '-'
|| c == '_'
|| c == '.'
|| c == '/';
if !ok {
return Err(AppError::Validation(format!(
"path-prefix contains invalid character {c:?} — \
allowed: lowercase alphanumeric, '-', '_', '.', '/'"
)));
}
}
Ok(path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_canonical_msg(err: &AppError, expected_form: &str) {
let s = format!("{err}");
assert!(
s.contains(&format!("use '{expected_form}'")),
"error message should quote canonical form '{expected_form}'; got: {s}"
);
}
#[test]
fn accepts_canonical_lowercase() {
let out = normalize_domain_name("example.com").unwrap();
assert_eq!(out, "example.com");
}
#[test]
fn accepts_subdomain() {
let out = normalize_domain_name("tenant-a.example.com").unwrap();
assert_eq!(out, "tenant-a.example.com");
}
#[test]
fn rejects_uppercase_with_canonical_in_error() {
let err = normalize_domain_name("Example.com").expect_err("must reject");
assert_canonical_msg(&err, "example.com");
}
#[test]
fn rejects_all_caps_with_canonical_in_error() {
let err = normalize_domain_name("EXAMPLE.COM").expect_err("must reject");
assert_canonical_msg(&err, "example.com");
}
#[test]
fn rejects_leading_or_trailing_whitespace() {
assert!(normalize_domain_name(" example.com").is_err());
assert!(normalize_domain_name("example.com ").is_err());
assert!(normalize_domain_name("\texample.com").is_err());
}
#[test]
fn rejects_empty() {
assert!(normalize_domain_name("").is_err());
}
#[test]
fn rejects_overlong() {
let huge = "a".repeat(MAX_LEN + 1);
assert!(normalize_domain_name(&huge).is_err());
}
#[test]
fn rejects_ipv4_literal() {
let err = normalize_domain_name("127.0.0.1").expect_err("must reject");
assert!(err.to_string().contains("IP literal"));
}
#[test]
fn rejects_ipv6_literal() {
let err = normalize_domain_name("[::1]").expect_err("must reject");
let _ = err.to_string();
}
#[test]
fn idna_normalises_internationalised() {
let err = normalize_domain_name("müller.example").expect_err("must reject pre-punycode");
assert_canonical_msg(&err, "xn--mller-kva.example");
assert_eq!(
normalize_domain_name("xn--mller-kva.example").unwrap(),
"xn--mller-kva.example"
);
}
#[test]
fn accepts_path_prefix_canonical() {
let out = normalize_domain_name("example.com/webvh-a").unwrap();
assert_eq!(out, "example.com/webvh-a");
}
#[test]
fn accepts_path_prefix_with_segments() {
let out = normalize_domain_name("example.com/tenant-a/instance-1").unwrap();
assert_eq!(out, "example.com/tenant-a/instance-1");
}
#[test]
fn rejects_uppercase_path_with_canonical_in_error() {
let err =
normalize_domain_name("example.com/Tenant-A").expect_err("uppercase path rejects");
let s = err.to_string();
assert!(s.contains("invalid character"), "unexpected error: {s}");
}
#[test]
fn rejects_leading_slash_in_input() {
assert!(normalize_domain_name("/example.com").is_err());
}
#[test]
fn rejects_trailing_slash_in_input() {
assert!(normalize_domain_name("example.com/").is_err());
}
#[test]
fn rejects_double_slash_in_path() {
assert!(normalize_domain_name("example.com/a//b").is_err());
}
#[test]
fn rejects_special_chars_in_path() {
for bad in [
"example.com/foo bar",
"example.com/foo?bar",
"example.com/foo#bar",
"example.com/foo%20bar",
] {
let err = normalize_domain_name(bad).expect_err(&format!("{bad} must reject"));
let s = err.to_string();
let _ = s;
assert!(matches!(err, AppError::Validation(_)));
}
}
#[test]
fn rejects_path_with_uppercase_host() {
let err = normalize_domain_name("Example.com/webvh-a").expect_err("uppercase host rejects");
assert_canonical_msg(&err, "example.com/webvh-a");
}
#[test]
fn round_trips_under_re_normalisation() {
for input in [
"example.com",
"tenant-a.example.com",
"example.com/webvh-a",
"example.com/tenant-a/instance-1",
"xn--mller-kva.example",
"localhost:8534",
"example.com:8443/webvh-a",
] {
let canonical = normalize_domain_name(input).expect(input);
assert_eq!(
canonical,
normalize_domain_name(&canonical).unwrap(),
"non-idempotent on {input}"
);
}
}
#[test]
fn accepts_host_with_port() {
assert_eq!(
normalize_domain_name("localhost:8534").unwrap(),
"localhost:8534"
);
assert_eq!(
normalize_domain_name("example.com:8443").unwrap(),
"example.com:8443"
);
}
#[test]
fn accepts_host_with_port_and_path() {
assert_eq!(
normalize_domain_name("example.com:8443/webvh-a").unwrap(),
"example.com:8443/webvh-a"
);
}
#[test]
fn host_with_port_normalises_case_and_leading_zero() {
let err =
normalize_domain_name("Example.com:08443").expect_err("non-canonical must reject");
assert_canonical_msg(&err, "example.com:8443");
}
#[test]
fn rejects_zero_port() {
let err = normalize_domain_name("example.com:0").expect_err("port 0 must reject");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn rejects_overflow_port() {
let err = normalize_domain_name("example.com:65536").expect_err("overflow must reject");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn rejects_non_numeric_port() {
let err = normalize_domain_name("example.com:http").expect_err("non-numeric must reject");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn rejects_empty_port_after_colon() {
let err = normalize_domain_name("example.com:").expect_err("empty port must reject");
assert!(matches!(err, AppError::Validation(_)));
}
}