use std::borrow::Cow;
use idna::uts46::{AsciiDenyList, DnsLength, Hyphens, Uts46};
use crate::prelude::*;
const ID_TAG_MIN_LEN: usize = 5;
const ID_TAG_MAX_LEN: usize = 62;
fn uts46() -> Uts46 {
Uts46::new()
}
const DENY: AsciiDenyList = AsciiDenyList::STD3;
const HYPHENS: Hyphens = Hyphens::Allow;
pub fn canonicalize_id_tag(id_tag: &str) -> ClResult<Cow<'_, str>> {
let unicode = canonicalize_dns_host(id_tag)?;
let ascii_len =
if unicode.is_ascii() { unicode.len() } else { id_tag_to_ascii(&unicode)?.len() };
if !(ID_TAG_MIN_LEN..=ID_TAG_MAX_LEN).contains(&ascii_len) {
return Err(Error::ValidationError(format!("invalid id_tag length: {id_tag}")));
}
Ok(unicode)
}
pub fn canonicalize_dns_host(id_tag: &str) -> ClResult<Cow<'_, str>> {
let trimmed = id_tag.trim();
if is_canonical_ascii_host(trimmed) {
return Ok(Cow::Borrowed(trimmed));
}
canonicalize_dns_host_uncached(trimmed)
}
fn canonicalize_dns_host_uncached(id_tag: &str) -> ClResult<Cow<'_, str>> {
let trimmed = id_tag.trim();
uts46()
.to_ascii(trimmed.as_bytes(), DENY, HYPHENS, DnsLength::Verify)
.map_err(|_| Error::ValidationError(format!("invalid id_tag: {id_tag}")))?;
let (unicode, res) = uts46().to_unicode(trimmed.as_bytes(), DENY, HYPHENS);
res.map_err(|_| Error::ValidationError(format!("invalid id_tag: {id_tag}")))?;
Ok(unicode)
}
fn is_canonical_ascii_host(s: &str) -> bool {
if s.is_empty() || s.len() > 253 {
return false;
}
s.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with("xn--")
&& label.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
})
}
pub fn dns_host_to_unicode_lossy(host: &str) -> Cow<'_, str> {
canonicalize_dns_host(host).unwrap_or(Cow::Borrowed(host))
}
pub fn id_tag_to_ascii(id_tag: &str) -> ClResult<Cow<'_, str>> {
uts46()
.to_ascii(id_tag.trim().as_bytes(), DENY, HYPHENS, DnsLength::Verify)
.map_err(|_| Error::ValidationError(format!("invalid id_tag: {id_tag}")))
}
pub fn id_tag_to_ascii_lossy(id_tag: &str) -> Cow<'_, str> {
id_tag_to_ascii(id_tag).unwrap_or(Cow::Borrowed(id_tag))
}
pub fn validate_id_tag(id_tag: &str) -> bool {
canonicalize_id_tag(id_tag).is_ok_and(|canonical| canonical == id_tag)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_id_tag() {
assert!(validate_id_tag("alice"));
assert!(validate_id_tag("bob-123"));
assert!(validate_id_tag("user-name-123"));
assert!(validate_id_tag("home.w9.hu"));
assert!(!validate_id_tag("Al")); assert!(!validate_id_tag("Alice")); assert!(!validate_id_tag("alice_123")); }
#[test]
fn test_validate_id_tag_rejects_url_injection() {
assert!(!validate_id_tag("alice/../../etc"));
assert!(!validate_id_tag("alice/admin"));
assert!(!validate_id_tag("alice@evil.com"));
assert!(!validate_id_tag("alice:8080"));
assert!(!validate_id_tag("alice evil"));
assert!(!validate_id_tag("alice?x=1"));
assert!(!validate_id_tag("alice#frag"));
assert!(!validate_id_tag("alice\\evil"));
assert!(!validate_id_tag("")); }
#[test]
fn test_canonicalize_id_tag_case_folds_to_unicode() {
assert_eq!(canonicalize_id_tag("MÜNCHEN.example.com").unwrap(), "münchen.example.com");
assert_eq!(
canonicalize_id_tag("xn--mnchen-3ya.example.com").unwrap(),
"münchen.example.com"
);
}
#[test]
fn test_canonicalize_id_tag_normalises_to_nfc() {
assert_eq!(canonicalize_id_tag("cafe\u{0301}.example.com").unwrap(), "café.example.com");
}
#[test]
fn test_canonicalize_id_tag_borrows_when_canonical() {
assert!(matches!(canonicalize_id_tag("alice.example.com"), Ok(Cow::Borrowed(_))));
}
#[test]
fn test_canonicalize_id_tag_is_idempotent() {
for input in ["alice.example.com", "MÜNCHEN.example.com", "xn--mnchen-3ya.example.com"] {
let once = canonicalize_id_tag(input).unwrap().into_owned();
let twice = canonicalize_id_tag(&once).unwrap().into_owned();
assert_eq!(once, twice);
}
}
#[test]
fn test_id_tag_to_ascii() {
assert_eq!(id_tag_to_ascii("münchen.example.com").unwrap(), "xn--mnchen-3ya.example.com");
assert_eq!(id_tag_to_ascii("alice.example.com").unwrap(), "alice.example.com");
assert_eq!(
id_tag_to_ascii("xn--mnchen-3ya.example.com").unwrap(),
"xn--mnchen-3ya.example.com"
);
}
#[test]
fn test_validate_id_tag_accepts_u_labels_only() {
assert!(validate_id_tag("münchen.example.com"));
assert!(!validate_id_tag("xn--mnchen-3ya.example.com"));
}
#[test]
fn canonicalize_dns_host_ignores_the_id_tag_length_policy() {
assert_eq!(canonicalize_dns_host("dev").expect("valid"), "dev");
assert!(canonicalize_id_tag("dev").is_err());
for bad in ["a_b", "a b", "a/b", "a..b", ".a.b", "a.b.", ""] {
assert!(canonicalize_dns_host(bad).is_err(), "expected reject for {bad:?}");
}
}
#[test]
fn dns_host_to_unicode_lossy_decodes_and_falls_back() {
assert_eq!(dns_host_to_unicode_lossy("xn--mnchen-3ya.example.com"), "münchen.example.com");
assert_eq!(dns_host_to_unicode_lossy("ALICE.example.com"), "alice.example.com");
assert_eq!(dns_host_to_unicode_lossy("a_b"), "a_b");
}
#[test]
fn an_sni_name_decodes_to_the_stored_form() {
let stored = canonicalize_id_tag("MÜNCHEN.example.com").expect("valid").into_owned();
let on_the_wire = id_tag_to_ascii(&stored).expect("encodable").into_owned();
assert_eq!(on_the_wire, "xn--mnchen-3ya.example.com");
assert_eq!(dns_host_to_unicode_lossy(&on_the_wire), stored);
assert_eq!(
dns_host_to_unicode_lossy(&format!("cl-o.{on_the_wire}")),
format!("cl-o.{stored}")
);
}
#[test]
fn the_ascii_fast_path_agrees_with_the_full_canonicalisation() {
for input in [
"alice.example.com",
"a.bc",
"dev",
"a",
"user-name-123",
"-leading-hyphen.example.com",
"trailing-hyphen-.example.com",
"123.456.example.com",
" alice.example.com ",
"Alice.Example.COM",
"münchen.example.com",
"MÜNCHEN.example.com",
"xn--mnchen-3ya.example.com",
"cafe\u{0301}.example.com",
"alice..example.com",
".alice.example.com",
"alice.example.com.",
"alice_123.example.com",
"alice/evil.example.com",
"alice evil.example.com",
"",
" ",
] {
let fast = canonicalize_dns_host(input);
let slow = canonicalize_dns_host_uncached(input.trim());
match (fast, slow) {
(Ok(a), Ok(b)) => assert_eq!(a, b, "disagreement on {input:?}"),
(Err(_), Err(_)) => {}
(a, b) => panic!("fast/slow disagree on {input:?}: {a:?} vs {b:?}"),
}
}
}
}