use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
const MAX_DOMAIN_OCTETS: usize = 253;
const MAX_LABEL_OCTETS: usize = 63;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct DomainName(Box<str>);
impl DomainName {
pub fn parse(input: &str) -> Result<Self, DomainNameError> {
if input.is_empty() {
return Err(DomainNameError::new(DomainErrorCode::EmptyName));
}
if input.trim() != input {
return Err(DomainNameError::new(DomainErrorCode::Whitespace));
}
let without_root = input.strip_suffix('.').unwrap_or(input);
if without_root.is_empty() {
return Err(DomainNameError::new(DomainErrorCode::EmptyName));
}
let ascii = idna::domain_to_ascii_strict(without_root)
.map_err(|_| DomainNameError::new(DomainErrorCode::IdnaRejected))?
.to_ascii_lowercase();
validate_ascii_name(&ascii)?;
Ok(Self(ascii.into_boxed_str()))
}
pub fn as_ascii(&self) -> &str {
&self.0
}
pub fn to_unicode(&self) -> String {
let (unicode, result) = idna::domain_to_unicode(self.as_ascii());
debug_assert!(result.is_ok(), "a validated A-label must decode");
unicode
}
pub fn tld(&self) -> &str {
self.as_ascii()
.rsplit_once('.')
.map_or(self.as_ascii(), |(_, tld)| tld)
}
pub fn identity(&self) -> DomainIdentity {
let bytes = self.as_ascii().as_bytes();
let public_suffix = psl::suffix(bytes)
.filter(psl::Suffix::is_known)
.map(|suffix| {
PublicSuffix(copy_ascii(
suffix.as_bytes(),
"PSL suffix is a slice of canonical ASCII input",
))
});
let registrable_domain = psl::domain(bytes)
.filter(|domain| domain.suffix().is_known())
.map(|domain| {
RegistrableDomain(copy_ascii(
domain.as_bytes(),
"PSL domain is a slice of canonical ASCII input",
))
});
DomainIdentity {
name: self.clone(),
public_suffix,
registrable_domain,
}
}
}
impl fmt::Display for DomainName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_ascii())
}
}
impl<'de> Deserialize<'de> for DomainName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let input = String::deserialize(deserializer)?;
Self::parse(&input).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct DomainIdentity {
name: DomainName,
public_suffix: Option<PublicSuffix>,
registrable_domain: Option<RegistrableDomain>,
}
impl DomainIdentity {
pub fn name(&self) -> &DomainName {
&self.name
}
pub fn public_suffix(&self) -> Option<&PublicSuffix> {
self.public_suffix.as_ref()
}
pub fn registrable_domain(&self) -> Option<&RegistrableDomain> {
self.registrable_domain.as_ref()
}
pub const fn boundary_status(&self) -> BoundaryStatus {
if self.public_suffix.is_some() && self.registrable_domain.is_some() {
BoundaryStatus::Known
} else {
BoundaryStatus::PolicyUnknown
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoundaryStatus {
Known,
PolicyUnknown,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PublicSuffix(Box<str>);
impl PublicSuffix {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RegistrableDomain(Box<str>);
impl RegistrableDomain {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DomainErrorCode {
EmptyName,
Whitespace,
IdnaRejected,
EmptyLabel,
LabelTooLong,
NameTooLong,
InvalidAsciiLabel,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct DomainNameError {
code: DomainErrorCode,
}
impl DomainNameError {
const fn new(code: DomainErrorCode) -> Self {
Self { code }
}
pub const fn code(&self) -> DomainErrorCode {
self.code
}
}
impl fmt::Display for DomainNameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self.code {
DomainErrorCode::EmptyName => "domain name is empty",
DomainErrorCode::Whitespace => "domain name contains surrounding whitespace",
DomainErrorCode::IdnaRejected => "domain name was rejected by strict IDNA processing",
DomainErrorCode::EmptyLabel => "domain name contains an empty label",
DomainErrorCode::LabelTooLong => "domain name contains a label longer than 63 octets",
DomainErrorCode::NameTooLong => "domain name is longer than 253 octets",
DomainErrorCode::InvalidAsciiLabel => "domain name contains an invalid host label",
};
formatter.write_str(message)
}
}
impl Error for DomainNameError {}
fn validate_ascii_name(ascii: &str) -> Result<(), DomainNameError> {
if ascii.len() > MAX_DOMAIN_OCTETS {
return Err(DomainNameError::new(DomainErrorCode::NameTooLong));
}
for label in ascii.split('.') {
if label.is_empty() {
return Err(DomainNameError::new(DomainErrorCode::EmptyLabel));
}
if label.len() > MAX_LABEL_OCTETS {
return Err(DomainNameError::new(DomainErrorCode::LabelTooLong));
}
if label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(DomainNameError::new(DomainErrorCode::InvalidAsciiLabel));
}
}
Ok(())
}
fn copy_ascii(bytes: &[u8], invariant: &str) -> Box<str> {
std::str::from_utf8(bytes)
.expect(invariant)
.to_owned()
.into_boxed_str()
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
#[test]
fn canonicalizes_case_and_root_dot() {
let domain = DomainName::parse("ExAmPlE.COM.").unwrap();
assert_eq!(domain.as_ascii(), "example.com");
assert_eq!(domain.to_unicode(), "example.com");
assert_eq!(domain.tld(), "com");
}
#[test]
fn canonicalizes_an_idn_without_mixing_labels() {
let domain = DomainName::parse("BÜCHER.de").unwrap();
assert_eq!(domain.as_ascii(), "xn--bcher-kva.de");
assert_eq!(domain.to_unicode(), "bücher.de");
}
#[test]
fn resolves_multilabel_public_suffixes() {
let identity = DomainName::parse("www.example.co.uk").unwrap().identity();
assert_eq!(identity.boundary_status(), BoundaryStatus::Known);
assert_eq!(identity.public_suffix().unwrap().as_str(), "co.uk");
assert_eq!(
identity.registrable_domain().unwrap().as_str(),
"example.co.uk"
);
}
#[test]
fn unknown_suffix_is_policy_unknown() {
let identity = DomainName::parse("example.definitely-not-a-real-tld")
.unwrap()
.identity();
assert_eq!(identity.boundary_status(), BoundaryStatus::PolicyUnknown);
assert!(identity.public_suffix().is_none());
assert!(identity.registrable_domain().is_none());
}
#[test]
fn rejects_ambiguous_or_invalid_input() {
for input in [
"",
".",
" example.com",
"example.com ",
"a..com",
"-a.com",
"a_.com",
] {
assert!(DomainName::parse(input).is_err(), "accepted {input:?}");
}
}
#[test]
fn deserialization_revalidates_the_invariant() {
assert!(serde_json::from_str::<DomainName>(r#""bad_.com""#).is_err());
}
proptest! {
#[test]
fn parser_never_panics_and_success_is_canonical(input in any::<String>()) {
if let Ok(domain) = DomainName::parse(&input) {
prop_assert!(!domain.as_ascii().is_empty());
prop_assert!(domain.as_ascii().len() <= MAX_DOMAIN_OCTETS);
prop_assert_eq!(domain.as_ascii(), domain.as_ascii().to_ascii_lowercase());
prop_assert!(!domain.as_ascii().ends_with('.'));
prop_assert!(domain.as_ascii().split('.').all(|label| !label.is_empty() && label.len() <= MAX_LABEL_OCTETS));
prop_assert_eq!(DomainName::parse(domain.as_ascii()).unwrap(), domain);
}
}
}
}