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 HostnameError {
Empty,
TooLong(usize),
LabelTooLong {
label: usize,
len: usize,
},
InvalidLabelStart(char),
InvalidLabelEnd(char),
InvalidChar(char),
EmptyLabel,
}
impl fmt::Display for HostnameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "hostname cannot be empty"),
Self::TooLong(len) => write!(
f,
"hostname 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 hostname"),
Self::EmptyLabel => write!(f, "hostname cannot contain empty labels"),
}
}
}
#[cfg(feature = "std")]
impl std::error::Error for HostnameError {}
#[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 Hostname(heapless::String<253>);
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for Hostname {
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 = ALPHABET[(first_byte % 26) 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 % 3 {
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 = ALPHABET[(last_byte % 26) 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 Hostname {
#[allow(clippy::missing_panics_doc)]
pub fn new(s: &str) -> Result<Self, HostnameError> {
if s.is_empty() {
return Err(HostnameError::Empty);
}
if s.len() > 253 {
return Err(HostnameError::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(HostnameError::EmptyLabel);
}
if label_len > 63 {
return Err(HostnameError::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(HostnameError::InvalidLabelStart(first));
}
if !last_char.is_ascii_alphanumeric() {
return Err(HostnameError::InvalidLabelEnd(last_char));
}
inner.push('.').map_err(|_| HostnameError::TooLong(253))?;
label_index += 1;
label_len = 0;
first_char = None;
} else {
if !c.is_ascii() {
return Err(HostnameError::InvalidChar(c));
}
if !c.is_ascii_alphanumeric() && c != '-' {
return Err(HostnameError::InvalidChar(c));
}
if label_len == 0 {
first_char = Some(c);
}
last_char = c;
label_len += 1;
inner
.push(c.to_ascii_lowercase())
.map_err(|_| HostnameError::TooLong(253))?;
}
}
if label_len == 0 {
return Err(HostnameError::EmptyLabel);
}
if label_len > 63 {
return Err(HostnameError::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(HostnameError::InvalidLabelStart(first));
}
if !last_char.is_ascii_alphanumeric() {
return Err(HostnameError::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 is_localhost(&self) -> bool {
self.as_str() == "localhost"
}
pub fn labels(&self) -> impl Iterator<Item = &str> {
self.as_str().split('.')
}
}
impl TryFrom<&str> for Hostname {
type Error = HostnameError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl From<Hostname> for heapless::String<253> {
fn from(hostname: Hostname) -> Self {
hostname.0
}
}
impl FromStr for Hostname {
type Err = HostnameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl fmt::Display for Hostname {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_valid_hostname() {
assert!(Hostname::new("example.com").is_ok());
assert!(Hostname::new("www.example.com").is_ok());
assert!(Hostname::new("localhost").is_ok());
assert!(Hostname::new("a").is_ok());
}
#[test]
fn test_empty_hostname() {
assert_eq!(Hostname::new(""), Err(HostnameError::Empty));
}
#[test]
fn test_too_long_hostname() {
let long = "a".repeat(254);
assert_eq!(Hostname::new(&long), Err(HostnameError::TooLong(254)));
}
#[test]
fn test_label_too_long() {
let long_label = "a".repeat(64);
assert_eq!(
Hostname::new(&long_label),
Err(HostnameError::LabelTooLong { label: 0, len: 64 })
);
}
#[test]
fn test_invalid_label_start() {
assert_eq!(
Hostname::new("-example.com"),
Err(HostnameError::InvalidLabelStart('-'))
);
}
#[test]
fn test_invalid_label_end() {
assert_eq!(
Hostname::new("example-.com"),
Err(HostnameError::InvalidLabelEnd('-'))
);
}
#[test]
fn test_invalid_char() {
assert_eq!(
Hostname::new("example_com"),
Err(HostnameError::InvalidChar('_'))
);
}
#[test]
fn test_empty_label() {
assert_eq!(
Hostname::new("example..com"),
Err(HostnameError::EmptyLabel)
);
assert_eq!(
Hostname::new(".example.com"),
Err(HostnameError::EmptyLabel)
);
assert_eq!(
Hostname::new("example.com."),
Err(HostnameError::EmptyLabel)
);
}
#[test]
fn test_as_str() {
let hostname = Hostname::new("example.com").unwrap();
assert_eq!(hostname.as_str(), "example.com");
}
#[test]
fn test_into_inner() {
let hostname = Hostname::new("example.com").unwrap();
let inner = hostname.into_inner();
assert_eq!(inner.as_str(), "example.com");
}
#[test]
fn test_is_localhost() {
assert!(Hostname::new("localhost").unwrap().is_localhost());
assert!(!Hostname::new("example.com").unwrap().is_localhost());
}
#[test]
fn test_labels() {
let hostname = Hostname::new("www.example.com").unwrap();
let labels: Vec<&str> = hostname.labels().collect();
assert_eq!(labels, vec!["www", "example", "com"]);
}
#[test]
fn test_labels_single() {
let hostname = Hostname::new("localhost").unwrap();
let labels: Vec<&str> = hostname.labels().collect();
assert_eq!(labels, vec!["localhost"]);
}
#[test]
fn test_try_from_str() {
let hostname = Hostname::try_from("example.com").unwrap();
assert_eq!(hostname.as_str(), "example.com");
}
#[test]
fn test_from_hostname_to_string() {
let hostname = Hostname::new("example.com").unwrap();
let inner: heapless::String<253> = hostname.into();
assert_eq!(inner.as_str(), "example.com");
}
#[test]
fn test_from_str() {
let hostname: Hostname = "example.com".parse().unwrap();
assert_eq!(hostname.as_str(), "example.com");
}
#[test]
fn test_from_str_invalid() {
assert!("".parse::<Hostname>().is_err());
assert!("-example.com".parse::<Hostname>().is_err());
assert!("example..com".parse::<Hostname>().is_err());
}
#[test]
fn test_display() {
let hostname = Hostname::new("example.com").unwrap();
assert_eq!(format!("{hostname}"), "example.com");
}
#[test]
fn test_equality() {
let hostname1 = Hostname::new("example.com").unwrap();
let hostname2 = Hostname::new("example.com").unwrap();
let hostname3 = Hostname::new("www.example.com").unwrap();
assert_eq!(hostname1, hostname2);
assert_ne!(hostname1, hostname3);
}
#[test]
fn test_ordering() {
let hostname1 = Hostname::new("a.example.com").unwrap();
let hostname2 = Hostname::new("b.example.com").unwrap();
assert!(hostname1 < hostname2);
}
#[test]
fn test_clone() {
let hostname = Hostname::new("example.com").unwrap();
let hostname2 = hostname.clone();
assert_eq!(hostname, hostname2);
}
#[test]
fn test_valid_characters() {
assert!(Hostname::new("a-b.example.com").is_ok());
assert!(Hostname::new("a1.example.com").is_ok());
assert!(Hostname::new("example-123.com").is_ok());
}
#[test]
fn test_maximum_length() {
let hostname = format!(
"{}.{}.{}.{}",
"a".repeat(63),
"b".repeat(63),
"c".repeat(63),
"d".repeat(61)
);
assert_eq!(hostname.len(), 253);
assert!(Hostname::new(&hostname).is_ok());
}
#[test]
fn test_error_display() {
assert_eq!(
format!("{}", HostnameError::Empty),
"hostname cannot be empty"
);
assert_eq!(
format!("{}", HostnameError::TooLong(300)),
"hostname exceeds maximum length of 253 characters (got 300)"
);
assert_eq!(
format!("{}", HostnameError::LabelTooLong { label: 0, len: 70 }),
"label 0 exceeds maximum length of 63 characters (got 70)"
);
assert_eq!(
format!("{}", HostnameError::InvalidLabelStart('-')),
"label cannot start with '-'"
);
assert_eq!(
format!("{}", HostnameError::InvalidLabelEnd('-')),
"label cannot end with '-'"
);
assert_eq!(
format!("{}", HostnameError::InvalidChar('_')),
"invalid character '_' in hostname"
);
assert_eq!(
format!("{}", HostnameError::EmptyLabel),
"hostname cannot contain empty labels"
);
}
#[test]
fn test_case_insensitive() {
let hostname1 = Hostname::new("Example.COM").unwrap();
let hostname2 = Hostname::new("example.com").unwrap();
assert_eq!(hostname1, hostname2);
assert_eq!(hostname1.as_str(), "example.com");
}
}