use std::fmt;
use std::str::FromStr;
use hickory_proto::ProtoError;
use hickory_proto::rr::Name;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct DomainName(String);
#[derive(Debug, Clone, thiserror::Error)]
pub enum DomainNameError {
#[error("domain name is empty")]
Empty,
#[error("invalid domain name: {0}")]
Invalid(#[from] ProtoError),
}
impl DomainName {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for DomainName {
type Err = DomainNameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let trimmed = s.trim_start_matches('.').trim_end_matches('.');
if trimmed.is_empty() {
return Err(DomainNameError::Empty);
}
let _name: Name = trimmed.parse()?;
Ok(Self(trimmed.to_ascii_lowercase()))
}
}
impl TryFrom<String> for DomainName {
type Error = DomainNameError;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl TryFrom<&str> for DomainName {
type Error = DomainNameError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
impl From<DomainName> for String {
fn from(name: DomainName) -> Self {
name.0
}
}
impl fmt::Display for DomainName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_lowercase_name() {
let name: DomainName = "pypi.org".parse().unwrap();
assert_eq!(name.as_str(), "pypi.org");
}
#[test]
fn canonicalizes_case_and_trailing_dot() {
let name: DomainName = "PyPI.Org.".parse().unwrap();
assert_eq!(name.as_str(), "pypi.org");
}
#[test]
fn strips_leading_dot_for_suffix_ergonomics() {
let name: DomainName = ".pythonhosted.org".parse().unwrap();
assert_eq!(name.as_str(), "pythonhosted.org");
}
#[test]
fn canonical_form_is_idempotent() {
let once: DomainName = "Example.COM.".parse().unwrap();
let twice: DomainName = once.as_str().parse().unwrap();
assert_eq!(once, twice);
}
#[test]
fn accepts_underscore_labels() {
let name: DomainName = "_http._tcp.example.com".parse().unwrap();
assert_eq!(name.as_str(), "_http._tcp.example.com");
}
#[test]
fn rejects_empty_input() {
assert!(matches!(
"".parse::<DomainName>(),
Err(DomainNameError::Empty)
));
assert!(matches!(
"...".parse::<DomainName>(),
Err(DomainNameError::Empty)
));
}
#[test]
fn rejects_whitespace_in_labels() {
assert!("foo bar.example".parse::<DomainName>().is_err());
}
#[test]
fn serde_round_trip_preserves_canonical_form() {
let name: DomainName = ".PyPI.Org.".parse().unwrap();
let json = serde_json::to_string(&name).unwrap();
assert_eq!(json, r#""pypi.org""#);
let back: DomainName = serde_json::from_str(&json).unwrap();
assert_eq!(back, name);
}
#[test]
fn serde_deserialize_validates() {
assert!(serde_json::from_str::<DomainName>(r#""foo bar.example""#).is_err());
assert!(serde_json::from_str::<DomainName>(r#""""#).is_err());
}
#[test]
fn rejects_url_decorations() {
for bad in [
"bar.example:443", "user@example.com", "user:pass@example.com", "https://example.com", "example.com/path", "example.com?q=1", "example.com#frag", ] {
assert!(
bad.parse::<DomainName>().is_err(),
"expected `{bad}` to be rejected"
);
let json = serde_json::to_string(bad).unwrap();
assert!(
serde_json::from_str::<DomainName>(&json).is_err(),
"expected serde to reject `{bad}`"
);
}
}
}