use core::fmt;
use core::str::FromStr;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "zeroize")]
use zeroize::Zeroize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub enum DomainNameError {
Empty,
TooLong(usize),
LabelTooLong {
label: usize,
len: usize,
},
InvalidLabelStart(char),
InvalidLabelEnd(char),
InvalidChar(char),
EmptyLabel,
}
impl fmt::Display for DomainNameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "domain name cannot be empty"),
Self::TooLong(len) => write!(
f,
"domain name exceeds maximum length of 253 characters (got {len})"
),
Self::LabelTooLong { label, len } => {
write!(
f,
"label {label} exceeds maximum length of 63 characters (got {len})"
)
}
Self::InvalidLabelStart(c) => write!(f, "label cannot start with '{c}'"),
Self::InvalidLabelEnd(c) => write!(f, "label cannot end with '{c}'"),
Self::InvalidChar(c) => write!(f, "invalid character '{c}' in domain name"),
Self::EmptyLabel => write!(f, "domain name cannot contain empty labels"),
}
}
}
#[cfg(feature = "std")]
impl std::error::Error for DomainNameError {}
#[repr(transparent)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "zeroize", derive(Zeroize))]
pub struct DomainName(heapless::String<253>);
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for DomainName {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
const DIGITS: &[u8] = b"0123456789";
let label_count = 1 + (u8::arbitrary(u)? % 4);
let mut inner = heapless::String::<253>::new();
for label_idx in 0..label_count {
let label_len = 1 + (u8::arbitrary(u)? % 20).min(19);
let first_byte = u8::arbitrary(u)?;
let first = match first_byte % 2 {
0 => ALPHABET[((first_byte >> 1) % 26) as usize] as char,
_ => DIGITS[((first_byte >> 1) % 10) as usize] as char,
};
inner
.push(first)
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
for _ in 1..label_len.saturating_sub(1) {
let byte = u8::arbitrary(u)?;
let c = match byte % 4 {
0 => ALPHABET[((byte >> 2) % 26) as usize] as char,
1 => DIGITS[((byte >> 2) % 10) as usize] as char,
_ => '-',
};
inner
.push(c)
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
}
if label_len > 1 {
let last_byte = u8::arbitrary(u)?;
let last = match last_byte % 2 {
0 => ALPHABET[((last_byte >> 1) % 26) as usize] as char,
_ => DIGITS[((last_byte >> 1) % 10) as usize] as char,
};
inner
.push(last)
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
}
if label_idx < label_count - 1 {
inner
.push('.')
.map_err(|_| arbitrary::Error::IncorrectFormat)?;
}
}
Ok(Self(inner))
}
}
impl DomainName {
#[allow(clippy::missing_panics_doc)]
pub fn new(s: &str) -> Result<Self, DomainNameError> {
if s.is_empty() {
return Err(DomainNameError::Empty);
}
if s.len() > 253 {
return Err(DomainNameError::TooLong(s.len()));
}
let mut inner = heapless::String::<253>::new();
let mut label_index = 0;
let mut label_len = 0;
let mut first_char: Option<char> = None;
let mut last_char: char = '\0';
for c in s.chars() {
if c == '.' {
if label_len == 0 {
return Err(DomainNameError::EmptyLabel);
}
if label_len > 63 {
return Err(DomainNameError::LabelTooLong {
label: label_index,
len: label_len,
});
}
let first = first_char.expect("label_len > 0 guarantees first_char is Some");
if !first.is_ascii_alphanumeric() {
return Err(DomainNameError::InvalidLabelStart(first));
}
if !last_char.is_ascii_alphanumeric() {
return Err(DomainNameError::InvalidLabelEnd(last_char));
}
inner.push('.').map_err(|_| DomainNameError::TooLong(253))?;
label_index += 1;
label_len = 0;
first_char = None;
} else {
if !c.is_ascii() {
return Err(DomainNameError::InvalidChar(c));
}
if !c.is_ascii_alphanumeric() && c != '-' {
return Err(DomainNameError::InvalidChar(c));
}
if label_len == 0 {
first_char = Some(c);
}
last_char = c;
label_len += 1;
inner
.push(c.to_ascii_lowercase())
.map_err(|_| DomainNameError::TooLong(253))?;
}
}
if label_len == 0 {
return Err(DomainNameError::EmptyLabel);
}
if label_len > 63 {
return Err(DomainNameError::LabelTooLong {
label: label_index,
len: label_len,
});
}
let first = first_char.expect("label_len > 0 guarantees first_char is Some");
if !first.is_ascii_alphanumeric() {
return Err(DomainNameError::InvalidLabelStart(first));
}
if !last_char.is_ascii_alphanumeric() {
return Err(DomainNameError::InvalidLabelEnd(last_char));
}
Ok(Self(inner))
}
#[must_use]
#[inline]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
#[inline]
pub const fn as_inner(&self) -> &heapless::String<253> {
&self.0
}
#[must_use]
#[inline]
pub fn into_inner(self) -> heapless::String<253> {
self.0
}
#[must_use]
#[inline]
pub fn depth(&self) -> usize {
self.as_str().chars().filter(|&c| c == '.').count() + 1
}
#[must_use]
#[inline]
pub fn is_subdomain_of(&self, other: &Self) -> bool {
if self.depth() <= other.depth() {
return false;
}
let self_str = self.as_str();
let other_str = other.as_str();
self_str.len() > other_str.len() + 1 && self_str.ends_with(&format!(".{other_str}"))
}
#[must_use]
#[inline]
pub fn is_tld(&self) -> bool {
self.depth() == 1
}
pub fn labels(&self) -> impl Iterator<Item = &str> {
self.as_str().split('.')
}
}
impl TryFrom<&str> for DomainName {
type Error = DomainNameError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl From<DomainName> for heapless::String<253> {
fn from(domain: DomainName) -> Self {
domain.0
}
}
impl FromStr for DomainName {
type Err = DomainNameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl fmt::Display for DomainName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_valid_domain_name() {
assert!(DomainName::new("example.com").is_ok());
assert!(DomainName::new("www.example.com").is_ok());
assert!(DomainName::new("a").is_ok());
}
#[test]
fn test_empty_domain_name() {
assert_eq!(DomainName::new(""), Err(DomainNameError::Empty));
}
#[test]
fn test_too_long_domain_name() {
let long = "a".repeat(254);
assert_eq!(DomainName::new(&long), Err(DomainNameError::TooLong(254)));
}
#[test]
fn test_label_too_long() {
let long_label = "a".repeat(64);
assert_eq!(
DomainName::new(&long_label),
Err(DomainNameError::LabelTooLong { label: 0, len: 64 })
);
}
#[test]
fn test_invalid_label_start() {
assert_eq!(
DomainName::new("-example.com"),
Err(DomainNameError::InvalidLabelStart('-'))
);
}
#[test]
fn test_invalid_label_end() {
assert_eq!(
DomainName::new("example-.com"),
Err(DomainNameError::InvalidLabelEnd('-'))
);
}
#[test]
fn test_invalid_char() {
assert_eq!(
DomainName::new("example_com"),
Err(DomainNameError::InvalidChar('_'))
);
}
#[test]
fn test_empty_label() {
assert_eq!(
DomainName::new("example..com"),
Err(DomainNameError::EmptyLabel)
);
assert_eq!(
DomainName::new(".example.com"),
Err(DomainNameError::EmptyLabel)
);
assert_eq!(
DomainName::new("example.com."),
Err(DomainNameError::EmptyLabel)
);
}
#[test]
fn test_as_str() {
let domain = DomainName::new("example.com").unwrap();
assert_eq!(domain.as_str(), "example.com");
}
#[test]
fn test_into_inner() {
let domain = DomainName::new("example.com").unwrap();
let inner = domain.into_inner();
assert_eq!(inner.as_str(), "example.com");
}
#[test]
fn test_depth() {
let domain = DomainName::new("com").unwrap();
assert_eq!(domain.depth(), 1);
let domain = DomainName::new("example.com").unwrap();
assert_eq!(domain.depth(), 2);
let domain = DomainName::new("www.example.com").unwrap();
assert_eq!(domain.depth(), 3);
}
#[test]
fn test_is_subdomain_of() {
let parent = DomainName::new("example.com").unwrap();
let child = DomainName::new("www.example.com").unwrap();
let grandchild = DomainName::new("sub.www.example.com").unwrap();
assert!(child.is_subdomain_of(&parent));
assert!(grandchild.is_subdomain_of(&parent));
assert!(grandchild.is_subdomain_of(&child));
assert!(!parent.is_subdomain_of(&child));
assert!(!parent.is_subdomain_of(&parent));
assert!(!child.is_subdomain_of(&child));
}
#[test]
fn test_is_tld() {
let tld = DomainName::new("com").unwrap();
assert!(tld.is_tld());
let domain = DomainName::new("example.com").unwrap();
assert!(!domain.is_tld());
}
#[test]
fn test_labels() {
let domain = DomainName::new("www.example.com").unwrap();
let labels: Vec<&str> = domain.labels().collect();
assert_eq!(labels, vec!["www", "example", "com"]);
}
#[test]
fn test_labels_single() {
let domain = DomainName::new("com").unwrap();
let labels: Vec<&str> = domain.labels().collect();
assert_eq!(labels, vec!["com"]);
}
#[test]
fn test_try_from_str() {
let domain = DomainName::try_from("example.com").unwrap();
assert_eq!(domain.as_str(), "example.com");
}
#[test]
fn test_from_domain_name_to_string() {
let domain = DomainName::new("example.com").unwrap();
let inner: heapless::String<253> = domain.into();
assert_eq!(inner.as_str(), "example.com");
}
#[test]
fn test_from_str() {
let domain: DomainName = "example.com".parse().unwrap();
assert_eq!(domain.as_str(), "example.com");
}
#[test]
fn test_from_str_invalid() {
assert!("".parse::<DomainName>().is_err());
assert!("-example.com".parse::<DomainName>().is_err());
assert!("example..com".parse::<DomainName>().is_err());
}
#[test]
fn test_display() {
let domain = DomainName::new("example.com").unwrap();
assert_eq!(format!("{domain}"), "example.com");
}
#[test]
fn test_equality() {
let domain1 = DomainName::new("example.com").unwrap();
let domain2 = DomainName::new("example.com").unwrap();
let domain3 = DomainName::new("www.example.com").unwrap();
assert_eq!(domain1, domain2);
assert_ne!(domain1, domain3);
}
#[test]
fn test_ordering() {
let domain1 = DomainName::new("a.example.com").unwrap();
let domain2 = DomainName::new("b.example.com").unwrap();
assert!(domain1 < domain2);
}
#[test]
fn test_clone() {
let domain = DomainName::new("example.com").unwrap();
let domain2 = domain.clone();
assert_eq!(domain, domain2);
}
#[test]
fn test_valid_characters() {
assert!(DomainName::new("a-b.example.com").is_ok());
assert!(DomainName::new("a1.example.com").is_ok());
assert!(DomainName::new("example-123.com").is_ok());
}
#[test]
fn test_maximum_length() {
let domain = format!(
"{}.{}.{}.{}",
"a".repeat(63),
"b".repeat(63),
"c".repeat(63),
"d".repeat(61)
);
assert_eq!(domain.len(), 253);
assert!(DomainName::new(&domain).is_ok());
}
#[test]
fn test_error_display() {
assert_eq!(
format!("{}", DomainNameError::Empty),
"domain name cannot be empty"
);
assert_eq!(
format!("{}", DomainNameError::TooLong(300)),
"domain name exceeds maximum length of 253 characters (got 300)"
);
assert_eq!(
format!("{}", DomainNameError::LabelTooLong { label: 0, len: 70 }),
"label 0 exceeds maximum length of 63 characters (got 70)"
);
assert_eq!(
format!("{}", DomainNameError::InvalidLabelStart('-')),
"label cannot start with '-'"
);
assert_eq!(
format!("{}", DomainNameError::InvalidLabelEnd('-')),
"label cannot end with '-'"
);
assert_eq!(
format!("{}", DomainNameError::InvalidChar('_')),
"invalid character '_' in domain name"
);
assert_eq!(
format!("{}", DomainNameError::EmptyLabel),
"domain name cannot contain empty labels"
);
}
#[test]
fn test_case_insensitive() {
let domain1 = DomainName::new("Example.COM").unwrap();
let domain2 = DomainName::new("example.com").unwrap();
assert_eq!(domain1, domain2);
assert_eq!(domain1.as_str(), "example.com");
}
#[test]
fn test_digit_start_labels() {
assert!(DomainName::new("123.example.com").is_ok());
assert!(DomainName::new("50-name.example.com").is_ok());
assert!(DomainName::new("235235").is_ok());
assert!(DomainName::new("0a.example.com").is_ok());
assert!(DomainName::new("9z.example.com").is_ok());
}
#[test]
fn test_digit_start_labels_valid() {
let domain = DomainName::new("123.example.com").unwrap();
assert_eq!(domain.as_str(), "123.example.com");
assert_eq!(domain.depth(), 3);
let labels: Vec<&str> = domain.labels().collect();
assert_eq!(labels, vec!["123", "example", "com"]);
}
#[test]
fn test_hyphen_not_at_boundaries() {
assert!(DomainName::new("a-b.example.com").is_ok());
assert!(DomainName::new("a-b-c.example.com").is_ok());
assert!(DomainName::new("example.a-b.com").is_ok());
}
#[test]
fn test_multiple_labels() {
let domain = DomainName::new("a.b.c.d.e.f.g").unwrap();
assert_eq!(domain.depth(), 7);
let labels: Vec<&str> = domain.labels().collect();
assert_eq!(labels, vec!["a", "b", "c", "d", "e", "f", "g"]);
}
#[test]
fn test_subdomain_edge_cases() {
let parent = DomainName::new("example.com").unwrap();
let child = DomainName::new("example.com").unwrap();
assert!(!child.is_subdomain_of(&parent));
let tld = DomainName::new("com").unwrap();
assert!(!tld.is_subdomain_of(&parent));
}
#[test]
fn test_single_label_domain() {
let domain = DomainName::new("localhost").unwrap();
assert_eq!(domain.depth(), 1);
assert!(domain.is_tld());
let labels: Vec<&str> = domain.labels().collect();
assert_eq!(labels, vec!["localhost"]);
}
#[test]
fn test_numeric_only_label() {
assert!(DomainName::new("123").is_ok());
assert!(DomainName::new("123.456").is_ok());
assert!(DomainName::new("123.456.789").is_ok());
}
#[test]
fn test_mixed_alphanumeric_labels() {
assert!(DomainName::new("a1b2c3.example.com").is_ok());
assert!(DomainName::new("123abc.example.com").is_ok());
assert!(DomainName::new("abc123.example.com").is_ok());
}
#[test]
fn test_maximum_label_length() {
let label = "a".repeat(63);
{
let domain = DomainName::new(&label).unwrap();
assert_eq!(domain.depth(), 1);
}
let domain = format!("{}.{}", "a".repeat(63), "b".repeat(63));
assert_eq!(domain.len(), 127);
assert!(DomainName::new(&domain).is_ok());
}
#[test]
fn test_maximum_total_length() {
let domain = format!(
"{}.{}.{}.{}",
"a".repeat(63),
"b".repeat(63),
"c".repeat(63),
"d".repeat(61)
);
assert_eq!(domain.len(), 253);
let domain = DomainName::new(&domain).unwrap();
assert_eq!(domain.depth(), 4);
assert_eq!(domain.as_str().len(), 253);
}
#[test]
fn test_maximum_total_length_plus_one() {
let domain = format!(
"{}.{}.{}.{}",
"a".repeat(63),
"b".repeat(63),
"c".repeat(63),
"d".repeat(62)
);
assert_eq!(domain.len(), 254);
assert_eq!(DomainName::new(&domain), Err(DomainNameError::TooLong(254)));
}
#[test]
fn test_unicode_rejected() {
assert!(DomainName::new("exรคmple.com").is_err());
assert!(DomainName::new("ไพใ.com").is_err());
assert!(DomainName::new("ไพใ.ใในใ").is_err());
}
#[test]
fn test_special_characters_rejected() {
assert!(DomainName::new("example_com").is_err());
assert!(DomainName::new("example.com/test").is_err());
assert!(DomainName::new("example.com?").is_err());
assert!(DomainName::new("example.com#").is_err());
assert!(DomainName::new("example.com@").is_err());
assert!(DomainName::new("example.com!").is_err());
}
#[test]
fn test_whitespace_rejected() {
assert!(DomainName::new("example .com").is_err());
assert!(DomainName::new("example. com").is_err());
assert!(DomainName::new("example . com").is_err());
assert!(DomainName::new("example\t.com").is_err());
assert!(DomainName::new("example\n.com").is_err());
}
#[test]
fn test_empty_labels_rejected() {
assert!(DomainName::new(".example.com").is_err());
assert!(DomainName::new("example..com").is_err());
assert!(DomainName::new("example.com.").is_err());
assert!(DomainName::new("..").is_err());
assert!(DomainName::new(".").is_err());
}
#[test]
fn test_hyphen_at_start_rejected() {
assert!(DomainName::new("-example.com").is_err());
assert!(DomainName::new("example.-com").is_err());
assert!(DomainName::new("-.example.com").is_err());
}
#[test]
fn test_hyphen_at_end_rejected() {
assert!(DomainName::new("example-.com").is_err());
assert!(DomainName::new("example.com-").is_err());
assert!(DomainName::new("example.-").is_err());
}
#[test]
fn test_consecutive_hyphens_allowed() {
assert!(DomainName::new("a--b.example.com").is_ok());
assert!(DomainName::new("a---b.example.com").is_ok());
}
#[test]
fn test_hash() {
use core::hash::Hash;
use core::hash::Hasher;
#[derive(Default)]
struct SimpleHasher(u64);
impl Hasher for SimpleHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for byte in bytes {
self.0 = self.0.wrapping_mul(31).wrapping_add(*byte as u64);
}
}
}
let domain1 = DomainName::new("example.com").unwrap();
let domain2 = DomainName::new("example.com").unwrap();
let domain3 = DomainName::new("www.example.com").unwrap();
let mut hasher1 = SimpleHasher::default();
let mut hasher2 = SimpleHasher::default();
let mut hasher3 = SimpleHasher::default();
domain1.hash(&mut hasher1);
domain2.hash(&mut hasher2);
domain3.hash(&mut hasher3);
assert_eq!(hasher1.finish(), hasher2.finish());
assert_ne!(hasher1.finish(), hasher3.finish());
}
#[test]
fn test_ordering_lexicographic() {
let domain1 = DomainName::new("a.example.com").unwrap();
let domain2 = DomainName::new("b.example.com").unwrap();
let domain3 = DomainName::new("a.example.com").unwrap();
assert!(domain1 < domain2);
assert!(domain2 > domain1);
assert_eq!(domain1, domain3);
}
#[test]
fn test_ordering_different_lengths() {
let domain1 = DomainName::new("a.com").unwrap();
let domain2 = DomainName::new("a.example.com").unwrap();
assert!(domain1 < domain2);
}
#[test]
fn test_debug() {
let domain = DomainName::new("example.com").unwrap();
assert_eq!(format!("{:?}", domain), "DomainName(\"example.com\")");
}
#[test]
fn test_as_inner() {
let domain = DomainName::new("example.com").unwrap();
let inner = domain.as_inner();
assert_eq!(inner.as_str(), "example.com");
}
#[test]
fn test_from_into_inner_roundtrip() {
let domain = DomainName::new("example.com").unwrap();
let inner: heapless::String<253> = domain.into();
let domain2 = DomainName::new(inner.as_str()).unwrap();
assert_eq!(domain2.as_str(), "example.com");
}
}