use std::fmt;
use std::str::FromStr;
use crate::domain::idn;
use crate::domain::Tld;
use crate::error::{DomainError, Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct DomainName {
ascii: String,
unicode: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SuffixSplit<'a> {
pub sld: &'a str,
pub suffix: &'a str,
pub extra_labels: usize,
}
impl DomainName {
pub fn parse(input: &str) -> Result<Self> {
let host = strip_to_host(input);
let reject = |reason: DomainError| Error::InvalidDomain {
input: input.to_string(),
reason,
};
if host.is_empty() {
return Err(reject(DomainError::Empty));
}
if idn::is_ip_literal(host) {
return Err(reject(DomainError::IpAddress));
}
let (ascii, unicode) = idn::both_forms(host).map_err(reject)?;
if ascii.len() > idn::MAX_NAME_LEN {
return Err(reject(DomainError::TooLong));
}
if !ascii.contains('.') {
return Err(reject(DomainError::NoTld));
}
for label in ascii.split('.') {
idn::validate_label(label).map_err(reject)?;
}
Ok(DomainName { ascii, unicode })
}
pub fn as_ascii(&self) -> &str {
&self.ascii
}
pub fn as_unicode(&self) -> &str {
&self.unicode
}
pub fn is_idn(&self) -> bool {
self.ascii != self.unicode
}
pub fn labels(&self) -> impl Iterator<Item = &str> + '_ {
self.ascii.split('.')
}
pub fn label_count(&self) -> usize {
self.ascii.split('.').count()
}
pub fn suffix_candidates(&self) -> impl Iterator<Item = SuffixSplit<'_>> + '_ {
let mut labels: Vec<(usize, &str)> = Vec::with_capacity(4);
let mut offset = 0;
for label in self.ascii.split('.') {
labels.push((offset, label));
offset += label.len() + 1; }
(1..labels.len()).map(move |index| SuffixSplit {
sld: labels[index - 1].1,
suffix: &self.ascii[labels[index].0..],
extra_labels: index - 1,
})
}
pub fn registrable_under(&self, tld: &Tld) -> Option<DomainName> {
let split = self
.suffix_candidates()
.find(|split| split.suffix == tld.ascii())?;
if split.extra_labels == 0 {
return Some(self.clone());
}
let ascii = format!("{}.{}", split.sld, split.suffix);
let unicode = idn::to_unicode_lossy(&ascii);
Some(DomainName { ascii, unicode })
}
pub fn parent(&self) -> Option<DomainName> {
let (_, rest) = self.ascii.split_once('.')?;
if !rest.contains('.') {
return None;
}
Some(DomainName {
unicode: idn::to_unicode_lossy(rest),
ascii: rest.to_string(),
})
}
}
fn strip_to_host(input: &str) -> &str {
let mut text = input.trim();
if let Some((_, rest)) = text.split_once("://") {
text = rest;
}
if let Some((_, rest)) = text.rsplit_once('@') {
text = rest;
}
for separator in ['/', '?', '#'] {
if let Some((head, _)) = text.split_once(separator) {
text = head;
}
}
text = strip_port(text);
text.trim().trim_matches('.')
}
fn strip_port(text: &str) -> &str {
if text.starts_with('[') {
return text;
}
match text.rsplit_once(':') {
Some((head, port))
if !head.is_empty()
&& !head.contains(':')
&& !port.is_empty()
&& port.bytes().all(|byte| byte.is_ascii_digit()) =>
{
head
}
_ => text,
}
}
impl fmt::Display for DomainName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.unicode)
}
}
impl AsRef<str> for DomainName {
fn as_ref(&self) -> &str {
&self.ascii
}
}
impl FromStr for DomainName {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
DomainName::parse(s)
}
}
impl TryFrom<&str> for DomainName {
type Error = Error;
fn try_from(value: &str) -> Result<Self> {
DomainName::parse(value)
}
}
impl TryFrom<String> for DomainName {
type Error = Error;
fn try_from(value: String) -> Result<Self> {
DomainName::parse(&value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_url_furniture() {
let name = DomainName::parse(" HTTPS://user:pw@WWW.Example.COM:8443/a/b?c=1#d ").unwrap();
assert_eq!(name.as_ascii(), "www.example.com");
}
#[test]
fn strips_root_dot() {
assert_eq!(
DomainName::parse("example.com.").unwrap().as_ascii(),
"example.com"
);
}
#[test]
fn keeps_both_forms() {
let name = DomainName::parse("MÜNCHEN.de").unwrap();
assert_eq!(name.as_ascii(), "xn--mnchen-3ya.de");
assert_eq!(name.as_unicode(), "münchen.de");
assert!(name.is_idn());
assert_eq!(name.to_string(), "münchen.de");
}
#[test]
fn punycode_input_is_accepted_too() {
let from_ace = DomainName::parse("xn--mnchen-3ya.de").unwrap();
let from_unicode = DomainName::parse("münchen.de").unwrap();
assert_eq!(from_ace, from_unicode);
}
#[test]
fn rejects_unqueryable_input() {
use DomainError::*;
let reason = |input: &str| match DomainName::parse(input) {
Err(Error::InvalidDomain { reason, .. }) => reason,
other => panic!("expected InvalidDomain for {input:?}, got {other:?}"),
};
assert_eq!(reason(""), Empty);
assert_eq!(reason(" "), Empty);
assert_eq!(reason("."), Empty);
assert_eq!(reason("https://"), Empty);
assert_eq!(reason("localhost"), NoTld);
assert_eq!(reason("192.0.2.1"), IpAddress);
assert_eq!(reason("::1"), IpAddress);
assert_eq!(reason(&format!("{}.com", "a".repeat(250))), TooLong);
assert!(matches!(reason("-bad.com"), InvalidLabel { .. }));
assert!(matches!(reason("a..b.com"), InvalidLabel { .. }));
}
#[test]
fn suffix_candidates_are_longest_first() {
let name = DomainName::parse("www.example.co.uk").unwrap();
let splits: Vec<_> = name.suffix_candidates().collect();
assert_eq!(splits.len(), 3);
assert_eq!(
splits[0],
SuffixSplit {
sld: "www",
suffix: "example.co.uk",
extra_labels: 0
}
);
assert_eq!(
splits[1],
SuffixSplit {
sld: "example",
suffix: "co.uk",
extra_labels: 1
}
);
assert_eq!(
splits[2],
SuffixSplit {
sld: "co",
suffix: "uk",
extra_labels: 2
}
);
}
#[test]
fn suffix_candidates_of_a_two_label_name() {
let name = DomainName::parse("example.com").unwrap();
let splits: Vec<_> = name.suffix_candidates().collect();
assert_eq!(
splits,
[SuffixSplit {
sld: "example",
suffix: "com",
extra_labels: 0
}]
);
}
#[test]
fn registrable_under_trims_subdomains() {
let name = DomainName::parse("a.b.example.co.uk").unwrap();
let tld = Tld::parse("co.uk").unwrap();
assert_eq!(
name.registrable_under(&tld).unwrap().as_ascii(),
"example.co.uk"
);
let bare = DomainName::parse("example.co.uk").unwrap();
assert_eq!(
bare.registrable_under(&tld).unwrap().as_ascii(),
"example.co.uk"
);
assert!(name.registrable_under(&Tld::parse("de").unwrap()).is_none());
}
#[test]
fn parent_stops_at_two_labels() {
let name = DomainName::parse("a.b.example.com").unwrap();
assert_eq!(name.parent().unwrap().as_ascii(), "b.example.com");
assert_eq!(
name.parent().unwrap().parent().unwrap().as_ascii(),
"example.com"
);
assert!(DomainName::parse("example.com").unwrap().parent().is_none());
}
}