use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::Error;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct Suffix(String);
impl<'de> Deserialize<'de> for Suffix {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::parse(&raw).map_err(serde::de::Error::custom)
}
}
impl Suffix {
pub fn parse(value: &str) -> Result<Self, Error> {
let trimmed = value.trim().trim_start_matches('.').trim_end_matches('.');
if trimmed.is_empty() {
return Err(Error::ExtensionInvalid {
extension: value.to_owned(),
});
}
let lowered = trimmed.to_lowercase();
let ascii = idna::domain_to_ascii(&lowered).map_err(|_| Error::ExtensionInvalid {
extension: value.to_owned(),
})?;
if ascii.len() > 253 {
return Err(Error::ExtensionInvalid {
extension: value.to_owned(),
});
}
for label in ascii.split('.') {
if check_label(label).is_err() || label.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error::ExtensionInvalid {
extension: value.to_owned(),
});
}
}
Ok(Self(ascii))
}
pub(crate) fn from_raw(value: &str) -> Self {
Self(value.to_lowercase())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn human(&self) -> String {
if !self.0.split('.').any(|label| label.starts_with("xn--")) {
return self.0.clone();
}
let (decoded, outcome) = idna::domain_to_unicode(&self.0);
if outcome.is_ok() {
decoded
} else {
self.0.clone()
}
}
#[must_use]
pub fn label_count(&self) -> usize {
self.0.split('.').count()
}
#[must_use]
pub fn delegated_label(&self) -> &str {
self.0.rsplit('.').next().unwrap_or(&self.0)
}
#[must_use]
pub fn is_country_code(&self) -> bool {
let root = self.delegated_label();
root.len() == 2 && root.bytes().all(|b| b.is_ascii_alphabetic())
}
#[must_use]
pub fn ancestors(&self) -> Vec<String> {
let mut chain = Vec::new();
let mut rest: &str = &self.0;
loop {
chain.push(rest.to_owned());
match rest.split_once('.') {
Some((_, tail)) if !tail.is_empty() => rest = tail,
_ => break,
}
}
chain
}
}
fn check_label(label: &str) -> Result<(), &'static str> {
if label.is_empty() {
return Err("it has an empty label");
}
if label.len() > 63 {
return Err("a label is longer than 63 characters");
}
if label.starts_with('-') || label.ends_with('-') {
return Err("a label starts or ends with a hyphen");
}
if !label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return Err("it has a character that is not a letter, digit, or hyphen");
}
Ok(())
}
pub const MAX_INPUT_BYTES: usize = 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NormalizedName {
pub name: String,
pub rewritten: bool,
}
pub fn normalize_name(value: &str) -> Result<NormalizedName, Error> {
let refuse = |reason: &str| Error::NameInvalid {
name: value.chars().take(60).collect(),
reason: reason.to_owned(),
};
if value.len() > MAX_INPUT_BYTES {
return Err(refuse(
"it is far longer than any domain name can be; paste just the name",
));
}
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(refuse("it is empty"));
}
let candidate = if trimmed.is_ascii() && !starts_with_ace(trimmed) {
slug(trimmed)
} else {
join_words(trimmed)
};
if candidate.is_empty() || candidate.chars().all(|c| c == '.') {
return Err(refuse("it has no letters or digits to check"));
}
for label in candidate.split('.') {
if is_reserved_shape(label) {
return Err(refuse(
"a part of it has two hyphens in the third and fourth places, which is reserved",
));
}
}
let name = parse_name(&candidate)?;
let rewritten = name != trimmed.to_lowercase();
Ok(NormalizedName { name, rewritten })
}
fn join_words(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut pending_gap = false;
for ch in value.chars() {
if ch.is_whitespace() {
pending_gap = true;
} else {
if pending_gap && !out.is_empty() && !out.ends_with('.') && ch != '.' {
out.push('-');
}
pending_gap = false;
out.push(ch);
}
}
out
}
fn starts_with_ace(value: &str) -> bool {
value
.split('.')
.any(|label| label.len() >= 4 && label[..4].eq_ignore_ascii_case("xn--"))
}
fn is_reserved_shape(label: &str) -> bool {
let bytes = label.as_bytes();
bytes.len() >= 4
&& bytes.get(2) == Some(&b'-')
&& bytes.get(3) == Some(&b'-')
&& !label[..4].eq_ignore_ascii_case("xn--")
}
fn slug(value: &str) -> String {
value
.split('.')
.map(|label| {
let mut out = String::with_capacity(label.len());
let mut pending_gap = false;
for ch in label.chars() {
if ch.is_ascii_alphanumeric() {
if pending_gap && !out.is_empty() {
out.push('-');
}
pending_gap = false;
out.push(ch.to_ascii_lowercase());
} else {
pending_gap = true;
}
}
out
})
.filter(|label| !label.is_empty())
.collect::<Vec<_>>()
.join(".")
}
pub fn parse_name(value: &str) -> Result<String, Error> {
let trimmed = value.trim();
let refuse = |reason: &str| Error::NameInvalid {
name: value.to_owned(),
reason: reason.to_owned(),
};
if trimmed.is_empty() {
return Err(refuse("it is empty"));
}
let ascii = idna::domain_to_ascii(&trimmed.to_lowercase())
.map_err(|_| refuse("it is not a usable domain name"))?;
if ascii.len() > 253 {
return Err(refuse("it is longer than 253 characters"));
}
for label in ascii.split('.') {
check_label(label).map_err(refuse)?;
}
Ok(ascii)
}
impl fmt::Display for Suffix {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for Suffix {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExtensionKind {
Generic,
Country,
Sponsored,
}
impl ExtensionKind {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Generic => "generic",
Self::Country => "country",
Self::Sponsored => "sponsored",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Extension {
pub suffix: Suffix,
pub kind: ExtensionKind,
#[serde(default)]
pub rank: Option<u32>,
#[serde(default)]
pub industries: Vec<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub country: Option<String>,
#[serde(default = "default_registrable")]
pub registrable: bool,
#[serde(default)]
pub repurposed: bool,
}
const fn default_registrable() -> bool {
true
}
impl Extension {
#[must_use]
pub fn is_in_industry(&self, key: &str) -> bool {
self.industries.iter().any(|i| i == key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_internationalised_zone_reads_in_its_own_script_while_staying_punycode_underneath() {
let bengali = Suffix::parse("বাংলা").expect("the Bengali ccTLD parses");
assert_eq!(
bengali.as_str(),
"xn--54b7fta0cc",
"the protocol form stays ASCII"
);
assert_eq!(bengali.human(), "বাংলা", "the reader sees their own script");
let typed_as_punycode = Suffix::parse("xn--54b7fta0cc").expect("the A-label parses");
assert_eq!(typed_as_punycode, bengali, "both spellings reach one value");
let plain = Suffix::parse("com.bd").expect("an ASCII suffix parses");
assert_eq!(plain.human(), "com.bd", "an ASCII zone is left alone");
}
#[test]
fn a_typed_phrase_becomes_a_name_a_registry_can_be_asked_about() {
for (typed, expected) in [
("hello world", "hello-world"),
(" My Cool Startup! ", "my-cool-startup"),
("foo___bar", "foo-bar"),
("a lot of space", "a-lot-of-space"),
("--leading and trailing--", "leading-and-trailing"),
("Mixed CASE", "mixed-case"),
("My Site.com", "my-site.com"),
] {
let out = normalize_name(typed).unwrap_or_else(|e| panic!("{typed}: {e}"));
assert_eq!(out.name, expected, "typed {typed}");
assert!(out.rewritten, "{typed} was reshaped and should say so");
}
}
#[test]
fn a_name_that_needed_no_reshaping_does_not_claim_it_was_reshaped() {
let out = normalize_name("example").expect("plain name");
assert_eq!(out.name, "example");
assert!(!out.rewritten);
}
#[test]
fn a_non_ascii_name_written_as_two_words_is_joined_rather_than_refused() {
let two_words = normalize_name("বাংলা দেশ").expect("two Bengali words are usable");
let joined = idna::domain_to_ascii("বাংলা-দেশ").expect("reference encoding");
assert_eq!(two_words.name, joined, "the words are joined, not stripped");
assert!(two_words.rewritten);
}
#[test]
fn a_non_ascii_name_is_never_stripped_into_a_different_one() {
let bengali = normalize_name("বাংলা").expect("a Bengali name is usable");
assert_eq!(bengali.name, "xn--54b7fta0cc");
let german = normalize_name("münchen").expect("a German name is usable");
assert_eq!(german.name, "xn--mnchen-3ya");
let conjunct = normalize_name("পরীক্ষা").expect("a Bengali conjunct survives");
assert_eq!(
conjunct.name,
idna::domain_to_ascii("পরীক্ষা").expect("reference encoding"),
"the name checked must be the name typed"
);
}
#[test]
fn input_far_larger_than_any_domain_is_refused_rather_than_processed() {
let pasted = "a".repeat(MAX_INPUT_BYTES + 1);
let refused = normalize_name(&pasted).expect_err("a pasted document is not a name");
assert!(refused.to_string().contains("longer than any domain"));
}
#[test]
fn a_slug_collapses_separators_so_it_cannot_invent_the_reserved_shape() {
for typed in ["ab cd", "ab--cd", "ab..--..cd", "ab___cd"] {
let out = normalize_name(typed).unwrap_or_else(|e| panic!("{typed}: {e}"));
assert!(
!out.name.split('.').any(is_reserved_shape),
"{typed} produced the reserved shape {}",
out.name
);
}
}
#[test]
fn a_reserved_shape_arriving_unslugged_is_refused() {
let refused = normalize_name("ab--cd.münchen").expect_err("a reserved label is refused");
assert!(refused.to_string().contains("reserved"), "{refused}");
assert!(normalize_name("xn--54b7fta0cc").is_ok());
}
#[test]
fn a_string_with_nothing_to_check_is_refused() {
for empty in [" ", "!!!", "...", "---"] {
assert!(normalize_name(empty).is_err(), "{empty} should be refused");
}
}
#[test]
fn a_leading_dot_is_accepted_and_stripped() {
assert_eq!(Suffix::parse(".com").unwrap().as_str(), "com");
assert_eq!(Suffix::parse("com").unwrap().as_str(), "com");
assert_eq!(Suffix::parse(" .COM ").unwrap().as_str(), "com");
}
#[test]
fn rubbish_is_refused_rather_than_guessed() {
for bad in ["", ".", "..", "-com", "com-", "a..b", "9", "co m", "*"] {
assert!(Suffix::parse(bad).is_err(), "{bad} should be refused");
}
}
#[test]
fn a_label_of_sixty_three_characters_is_the_longest_one_allowed() {
let longest = "a".repeat(63);
assert_eq!(Suffix::parse(&longest).unwrap().as_str(), longest);
assert!(Suffix::parse(&"a".repeat(64)).is_err());
}
#[test]
fn an_extension_of_two_hundred_and_fifty_three_characters_is_the_longest_one_allowed() {
let label = "a".repeat(63);
let at_the_cap = [
label.as_str(),
label.as_str(),
label.as_str(),
&"b".repeat(61),
]
.join(".");
assert_eq!(at_the_cap.len(), 253);
assert!(Suffix::parse(&at_the_cap).is_ok());
let over_the_cap = format!("{at_the_cap}b");
assert_eq!(over_the_cap.len(), 254);
assert!(Suffix::parse(&over_the_cap).is_err());
}
#[test]
fn a_suffix_read_from_json_goes_through_the_same_parser_as_a_typed_one() {
let parsed: Suffix = serde_json::from_str("\".CO.UK\"").unwrap();
assert_eq!(parsed.as_str(), "co.uk");
assert_eq!(parsed, Suffix::parse(".CO.UK").unwrap());
}
#[test]
fn an_unusable_suffix_in_json_is_refused_rather_than_loaded_unchecked() {
for bad in [
"\"\"", "\".\"", "\"-com\"", "\"com-\"", "\"a..b\"", "\"9\"", "\"co m\"",
] {
assert!(
serde_json::from_str::<Suffix>(bad).is_err(),
"{bad} should be refused"
);
}
}
#[test]
fn a_suffix_survives_a_round_trip_through_json() {
let suffix = Suffix::parse("com.bd").unwrap();
let text = serde_json::to_string(&suffix).unwrap();
assert_eq!(text, "\"com.bd\"");
assert_eq!(serde_json::from_str::<Suffix>(&text).unwrap(), suffix);
}
#[test]
fn label_count_separates_second_level_from_third() {
assert_eq!(Suffix::parse("com").unwrap().label_count(), 1);
assert_eq!(Suffix::parse("co.uk").unwrap().label_count(), 2);
}
#[test]
fn the_country_test_reads_the_delegated_label_not_the_whole_string() {
assert!(Suffix::parse("uk").unwrap().is_country_code());
assert!(Suffix::parse("co.uk").unwrap().is_country_code());
assert!(Suffix::parse("bd").unwrap().is_country_code());
assert!(!Suffix::parse("com").unwrap().is_country_code());
assert!(!Suffix::parse("dev").unwrap().is_country_code());
}
#[test]
fn the_parent_chain_runs_longest_first() {
let suffix = Suffix::parse("com.bd").unwrap();
assert_eq!(
suffix.ancestors(),
vec!["com.bd".to_owned(), "bd".to_owned()]
);
let plain = Suffix::parse("dev").unwrap();
assert_eq!(plain.ancestors(), vec!["dev".to_owned()]);
}
#[test]
fn a_control_byte_in_a_name_is_refused() {
for bad in [
"x\rdomain google.com",
"x\ndomain google.com",
"x\r\ndomain google.com",
"x\0y",
"x y",
"x\ty",
"x\u{1b}[2Ky",
] {
assert!(
parse_name(bad).is_err(),
"{bad:?} must never reach a request line"
);
}
}
#[test]
fn a_usable_name_survives_validation() {
assert_eq!(parse_name("example").unwrap(), "example");
assert_eq!(parse_name(" Example ").unwrap(), "example");
assert_eq!(parse_name("shop.example").unwrap(), "shop.example");
assert_eq!(parse_name("123").unwrap(), "123");
assert_eq!(parse_name("a-b").unwrap(), "a-b");
}
#[test]
fn a_unicode_name_is_normalized_before_it_reaches_the_wire() {
assert_eq!(parse_name("münchen").unwrap(), "xn--mnchen-3ya");
}
#[test]
fn a_malformed_name_is_refused() {
for bad in ["", " ", "-lead", "trail-", "a..b", &"x".repeat(64)] {
assert!(parse_name(bad).is_err(), "{bad:?} should be refused");
}
}
#[test]
fn a_unicode_extension_is_normalized_to_its_ascii_form() {
let suffix = Suffix::parse("বাংলা").unwrap();
assert!(suffix.as_str().starts_with("xn--"));
}
}