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),
#[error(
"suffix `{raw}` is a single DNS label and would match every domain under that TLD; \
add at least one parent label to scope it (e.g. `myco.{raw}`)"
)]
SuffixTooBroad { raw: String },
}
impl DomainName {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn try_into_suffix(self) -> Result<Self, DomainNameError> {
if self.0.contains('.') {
Ok(self)
} else {
Err(DomainNameError::SuffixTooBroad {
raw: self.0.clone(),
})
}
}
}
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 try_into_suffix_accepts_multilabel_names() {
let name: DomainName = "example.com".parse().unwrap();
let suffix = name.try_into_suffix().unwrap();
assert_eq!(suffix.as_str(), "example.com");
let deep: DomainName = "api.staging.example.com".parse().unwrap();
let suffix = deep.try_into_suffix().unwrap();
assert_eq!(suffix.as_str(), "api.staging.example.com");
}
#[test]
fn try_into_suffix_rejects_single_label_tlds() {
for raw in ["com", "local", "internal", "intranet", "lan"] {
let name: DomainName = raw.parse().unwrap();
let err = name.try_into_suffix().unwrap_err();
assert!(
matches!(&err, DomainNameError::SuffixTooBroad { raw: r } if r == raw),
"expected SuffixTooBroad for `{raw}`, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("match every domain"),
"error message should explain blast radius, got: {msg}"
);
}
}
#[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}`"
);
}
}
}