#[must_use]
pub fn normalize_host(target: &str) -> String {
let mut s = target;
if s.len() >= 7 && s.as_bytes()[..7].eq_ignore_ascii_case(b"http://") {
s = &s[7..];
} else if s.len() >= 8 && s.as_bytes()[..8].eq_ignore_ascii_case(b"https://") {
s = &s[8..];
}
if let Some(at) = s.find('@') {
s = &s[at + 1..];
}
if let Some(idx) = s.find('/') {
s = &s[..idx];
}
if let Some(idx) = s.rfind(':') {
if s.starts_with('[') && s.contains("]:") {
s = &s[..idx];
} else if s.matches(':').count() == 1
&& !s[idx + 1..].is_empty()
&& s[idx + 1..].chars().all(|c| c.is_ascii_digit())
{
s = &s[..idx];
}
}
let mut out = s.to_lowercase();
out = out.trim_end_matches('.').to_string();
let looks_ip = out.starts_with('[') || out.parse::<std::net::IpAddr>().is_ok();
if !looks_ip && out.split('.').any(|label| label.starts_with("xn--")) {
let (decoded, _maybe_err) = idna::domain_to_unicode(&out);
out = decoded.to_lowercase();
}
out
}
#[must_use]
pub fn registrable(host: &str) -> Option<String> {
let h = normalize_host(host);
if h.parse::<std::net::IpAddr>().is_ok() {
return None;
}
let dom = psl::domain(h.as_bytes())?;
std::str::from_utf8(dom.as_bytes()).ok().map(str::to_string)
}
#[must_use]
pub fn org_label(input: &str) -> String {
let mut host = normalize_host(input);
if let Some(stripped) = host.strip_prefix("*.") {
host = stripped.to_string();
}
if host.is_empty() {
return host;
}
if host.parse::<std::net::IpAddr>().is_ok() {
return host;
}
if let Some(reg) = psl::domain(host.as_bytes()) {
if let Ok(s) = std::str::from_utf8(reg.as_bytes()) {
if let Some(label) = s.split('.').next() {
if !label.is_empty() {
return label.to_string();
}
}
}
}
host.split('.').next().unwrap_or(&host).to_string()
}
#[must_use]
pub fn parent_domain(host: &str) -> String {
let h = normalize_host(host);
let labels: Vec<&str> = h.split('.').filter(|s| !s.is_empty()).collect();
if labels.len() < 2 {
return h;
}
labels[labels.len() - 2..].join(".")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_strips_scheme_userinfo_port_path_and_casefolds() {
assert_eq!(
normalize_host("https://user@Example.COM:443/a/b"),
"example.com"
);
assert_eq!(normalize_host("https://Example.COM/a/b"), "example.com");
assert_eq!(normalize_host("http://example.com."), "example.com");
assert_eq!(normalize_host("HTTP://Example.COM/path"), "example.com");
assert_eq!(normalize_host("Https://Example.COM:443/x"), "example.com");
assert_eq!(normalize_host("[::1]:8443"), "[::1]");
assert_eq!(normalize_host("1.2.3.4:80"), "1.2.3.4");
}
#[test]
fn normalize_decodes_leading_punycode() {
assert_eq!(normalize_host("xn--mnchen-3ya"), "münchen");
}
#[test]
fn normalize_decodes_punycode_in_any_label_position() {
assert_eq!(normalize_host("www.xn--mnchen-3ya.de"), "www.münchen.de");
assert_eq!(
normalize_host("https://API.xn--mnchen-3ya.de:443/x"),
"api.münchen.de"
);
assert_eq!(
normalize_host("www.xn--mnchen-3ya.de"),
normalize_host("www.münchen.de"),
"A-label and U-label of a non-leading IDN must cluster equal"
);
assert_eq!(
normalize_host("shop.xn--mnchen-3ya.xn--mnchen-3ya"),
"shop.münchen.münchen"
);
assert_eq!(normalize_host("api.example.com"), "api.example.com");
assert_eq!(normalize_host("1.2.3.4:80"), "1.2.3.4");
assert_eq!(normalize_host("[::1]:8443"), "[::1]");
assert_eq!(normalize_host("2001:db8::1"), "2001:db8::1");
}
#[test]
fn org_label_uses_registrable_first_label() {
assert_eq!(org_label("example.com"), "example");
assert_eq!(org_label("shop.example.co.uk"), "example");
assert_eq!(org_label("*.example.com"), "example");
assert_eq!(org_label("*.shop.example.co.uk"), "example");
assert_eq!(org_label("https://api.example.com.br:443/x"), "example");
assert_eq!(org_label("www.agency.gov.au"), "agency");
assert_eq!(org_label("localhost"), "localhost");
assert_eq!(org_label("192.0.2.10"), "192.0.2.10");
assert_eq!(org_label("cdn.example-site.com"), "example-site");
}
#[test]
fn registrable_handles_etld_and_rejects_ip() {
assert_eq!(
registrable("a.b.example.co.uk").as_deref(),
Some("example.co.uk")
);
assert_eq!(registrable("example.com").as_deref(), Some("example.com"));
assert_eq!(registrable("10.0.0.1"), None);
}
#[test]
fn parent_domain_is_last_two_labels() {
assert_eq!(parent_domain("a.b.example.com"), "example.com");
assert_eq!(parent_domain("https://x.example.com:443/p"), "example.com");
assert_eq!(parent_domain("localhost"), "localhost");
}
#[test]
fn normalize_does_not_corrupt_bare_ipv6() {
for v6 in ["2001:db8::1", "fe80::1", "::1", "2001:4860:4860::8888"] {
let n = normalize_host(v6);
assert_eq!(n, v6.to_lowercase(), "bare IPv6 {v6} was corrupted to {n}");
assert!(
n.parse::<std::net::Ipv6Addr>().is_ok(),
"normalized bare IPv6 {v6} -> {n} no longer parses as an address"
);
}
assert_eq!(normalize_host("2001:DB8::1"), "2001:db8::1");
}
#[test]
fn normalize_still_strips_real_single_colon_port() {
assert_eq!(normalize_host("example.com:443"), "example.com");
assert_eq!(normalize_host("1.2.3.4:8080"), "1.2.3.4");
assert_eq!(normalize_host("[::1]:8443"), "[::1]");
}
#[test]
fn normalize_preserves_trailing_colon_without_port() {
assert_eq!(normalize_host("example.com:"), "example.com:");
assert_eq!(normalize_host("host:"), "host:");
}
}