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 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 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 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--"));
}
}