Skip to main content

dnspls_domain/
lib.rs

1//! Canonical domain identity primitives.
2//!
3//! This crate knows how to turn user-facing names into stable DNS comparison
4//! keys. It deliberately knows nothing about providers, availability, HTTP, or
5//! MCP. Public-suffix data provides a parsing boundary only; it never implies
6//! that a name is available or supported by a registrar.
7
8use std::{error::Error, fmt};
9
10use serde::{Deserialize, Serialize};
11
12const MAX_DOMAIN_OCTETS: usize = 253;
13const MAX_LABEL_OCTETS: usize = 63;
14
15/// A syntactically valid, canonical DNS name.
16///
17/// Equality, ordering, hashing, and serialization use the lowercase ASCII
18/// A-label form. Construction is only possible through [`DomainName::parse`].
19#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
20#[serde(transparent)]
21pub struct DomainName(Box<str>);
22
23impl DomainName {
24    /// Parses a domain using strict IDNA processing and DNS host-label rules.
25    ///
26    /// One terminal root dot is accepted and removed. Leading/trailing
27    /// whitespace is rejected rather than silently changing user input.
28    ///
29    /// # Errors
30    ///
31    /// Returns a stable [`DomainNameError`] when IDNA processing or DNS label
32    /// validation rejects the input.
33    pub fn parse(input: &str) -> Result<Self, DomainNameError> {
34        if input.is_empty() {
35            return Err(DomainNameError::new(DomainErrorCode::EmptyName));
36        }
37        if input.trim() != input {
38            return Err(DomainNameError::new(DomainErrorCode::Whitespace));
39        }
40
41        let without_root = input.strip_suffix('.').unwrap_or(input);
42        if without_root.is_empty() {
43            return Err(DomainNameError::new(DomainErrorCode::EmptyName));
44        }
45
46        let ascii = idna::domain_to_ascii_strict(without_root)
47            .map_err(|_| DomainNameError::new(DomainErrorCode::IdnaRejected))?
48            .to_ascii_lowercase();
49
50        validate_ascii_name(&ascii)?;
51        Ok(Self(ascii.into_boxed_str()))
52    }
53
54    /// Returns the canonical lowercase ASCII A-label form.
55    pub fn as_ascii(&self) -> &str {
56        &self.0
57    }
58
59    /// Returns the normalized Unicode display form.
60    pub fn to_unicode(&self) -> String {
61        let (unicode, result) = idna::domain_to_unicode(self.as_ascii());
62        debug_assert!(result.is_ok(), "a validated A-label must decode");
63        unicode
64    }
65
66    /// Returns the final ASCII root-zone label without a leading dot.
67    pub fn tld(&self) -> &str {
68        self.as_ascii()
69            .rsplit_once('.')
70            .map_or(self.as_ascii(), |(_, tld)| tld)
71    }
72
73    /// Resolves the embedded Public Suffix List boundary.
74    ///
75    /// A missing boundary means policy coverage is unknown, not that the name
76    /// is invalid or available.
77    pub fn identity(&self) -> DomainIdentity {
78        let bytes = self.as_ascii().as_bytes();
79        let public_suffix = psl::suffix(bytes)
80            .filter(psl::Suffix::is_known)
81            .map(|suffix| {
82                PublicSuffix(copy_ascii(
83                    suffix.as_bytes(),
84                    "PSL suffix is a slice of canonical ASCII input",
85                ))
86            });
87        let registrable_domain = psl::domain(bytes)
88            .filter(|domain| domain.suffix().is_known())
89            .map(|domain| {
90                RegistrableDomain(copy_ascii(
91                    domain.as_bytes(),
92                    "PSL domain is a slice of canonical ASCII input",
93                ))
94            });
95
96        DomainIdentity {
97            name: self.clone(),
98            public_suffix,
99            registrable_domain,
100        }
101    }
102}
103
104impl fmt::Display for DomainName {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        formatter.write_str(self.as_ascii())
107    }
108}
109
110impl<'de> Deserialize<'de> for DomainName {
111    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112    where
113        D: serde::Deserializer<'de>,
114    {
115        let input = String::deserialize(deserializer)?;
116        Self::parse(&input).map_err(serde::de::Error::custom)
117    }
118}
119
120/// A canonical name plus its embedded-PSL parsing interpretation.
121#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
122pub struct DomainIdentity {
123    name: DomainName,
124    public_suffix: Option<PublicSuffix>,
125    registrable_domain: Option<RegistrableDomain>,
126}
127
128impl DomainIdentity {
129    pub fn name(&self) -> &DomainName {
130        &self.name
131    }
132
133    pub fn public_suffix(&self) -> Option<&PublicSuffix> {
134        self.public_suffix.as_ref()
135    }
136
137    pub fn registrable_domain(&self) -> Option<&RegistrableDomain> {
138        self.registrable_domain.as_ref()
139    }
140
141    pub const fn boundary_status(&self) -> BoundaryStatus {
142        if self.public_suffix.is_some() && self.registrable_domain.is_some() {
143            BoundaryStatus::Known
144        } else {
145            BoundaryStatus::PolicyUnknown
146        }
147    }
148}
149
150/// Whether the pinned parsing data can identify a registrable boundary.
151#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum BoundaryStatus {
154    Known,
155    PolicyUnknown,
156}
157
158/// A suffix found in the embedded Public Suffix List.
159#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
160#[serde(transparent)]
161pub struct PublicSuffix(Box<str>);
162
163impl PublicSuffix {
164    pub fn as_str(&self) -> &str {
165        &self.0
166    }
167}
168
169/// The eTLD+1 boundary derived from the embedded Public Suffix List.
170#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
171#[serde(transparent)]
172pub struct RegistrableDomain(Box<str>);
173
174impl RegistrableDomain {
175    pub fn as_str(&self) -> &str {
176        &self.0
177    }
178}
179
180/// Stable, non-provider-specific domain parsing failures.
181#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum DomainErrorCode {
184    EmptyName,
185    Whitespace,
186    IdnaRejected,
187    EmptyLabel,
188    LabelTooLong,
189    NameTooLong,
190    InvalidAsciiLabel,
191}
192
193/// A domain parse error suitable for transport-safe mapping.
194#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
195pub struct DomainNameError {
196    code: DomainErrorCode,
197}
198
199impl DomainNameError {
200    const fn new(code: DomainErrorCode) -> Self {
201        Self { code }
202    }
203
204    pub const fn code(&self) -> DomainErrorCode {
205        self.code
206    }
207}
208
209impl fmt::Display for DomainNameError {
210    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
211        let message = match self.code {
212            DomainErrorCode::EmptyName => "domain name is empty",
213            DomainErrorCode::Whitespace => "domain name contains surrounding whitespace",
214            DomainErrorCode::IdnaRejected => "domain name was rejected by strict IDNA processing",
215            DomainErrorCode::EmptyLabel => "domain name contains an empty label",
216            DomainErrorCode::LabelTooLong => "domain name contains a label longer than 63 octets",
217            DomainErrorCode::NameTooLong => "domain name is longer than 253 octets",
218            DomainErrorCode::InvalidAsciiLabel => "domain name contains an invalid host label",
219        };
220        formatter.write_str(message)
221    }
222}
223
224impl Error for DomainNameError {}
225
226fn validate_ascii_name(ascii: &str) -> Result<(), DomainNameError> {
227    if ascii.len() > MAX_DOMAIN_OCTETS {
228        return Err(DomainNameError::new(DomainErrorCode::NameTooLong));
229    }
230
231    for label in ascii.split('.') {
232        if label.is_empty() {
233            return Err(DomainNameError::new(DomainErrorCode::EmptyLabel));
234        }
235        if label.len() > MAX_LABEL_OCTETS {
236            return Err(DomainNameError::new(DomainErrorCode::LabelTooLong));
237        }
238        if label.starts_with('-')
239            || label.ends_with('-')
240            || !label
241                .bytes()
242                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
243        {
244            return Err(DomainNameError::new(DomainErrorCode::InvalidAsciiLabel));
245        }
246    }
247    Ok(())
248}
249
250fn copy_ascii(bytes: &[u8], invariant: &str) -> Box<str> {
251    std::str::from_utf8(bytes)
252        .expect(invariant)
253        .to_owned()
254        .into_boxed_str()
255}
256
257#[cfg(test)]
258mod tests {
259    use proptest::prelude::*;
260
261    use super::*;
262
263    #[test]
264    fn canonicalizes_case_and_root_dot() {
265        let domain = DomainName::parse("ExAmPlE.COM.").unwrap();
266        assert_eq!(domain.as_ascii(), "example.com");
267        assert_eq!(domain.to_unicode(), "example.com");
268        assert_eq!(domain.tld(), "com");
269    }
270
271    #[test]
272    fn canonicalizes_an_idn_without_mixing_labels() {
273        let domain = DomainName::parse("BÜCHER.de").unwrap();
274        assert_eq!(domain.as_ascii(), "xn--bcher-kva.de");
275        assert_eq!(domain.to_unicode(), "bücher.de");
276    }
277
278    #[test]
279    fn resolves_multilabel_public_suffixes() {
280        let identity = DomainName::parse("www.example.co.uk").unwrap().identity();
281        assert_eq!(identity.boundary_status(), BoundaryStatus::Known);
282        assert_eq!(identity.public_suffix().unwrap().as_str(), "co.uk");
283        assert_eq!(
284            identity.registrable_domain().unwrap().as_str(),
285            "example.co.uk"
286        );
287    }
288
289    #[test]
290    fn unknown_suffix_is_policy_unknown() {
291        let identity = DomainName::parse("example.definitely-not-a-real-tld")
292            .unwrap()
293            .identity();
294        assert_eq!(identity.boundary_status(), BoundaryStatus::PolicyUnknown);
295        assert!(identity.public_suffix().is_none());
296        assert!(identity.registrable_domain().is_none());
297    }
298
299    #[test]
300    fn rejects_ambiguous_or_invalid_input() {
301        for input in [
302            "",
303            ".",
304            " example.com",
305            "example.com ",
306            "a..com",
307            "-a.com",
308            "a_.com",
309        ] {
310            assert!(DomainName::parse(input).is_err(), "accepted {input:?}");
311        }
312    }
313
314    #[test]
315    fn deserialization_revalidates_the_invariant() {
316        assert!(serde_json::from_str::<DomainName>(r#""bad_.com""#).is_err());
317    }
318
319    proptest! {
320        #[test]
321        fn parser_never_panics_and_success_is_canonical(input in any::<String>()) {
322            if let Ok(domain) = DomainName::parse(&input) {
323                prop_assert!(!domain.as_ascii().is_empty());
324                prop_assert!(domain.as_ascii().len() <= MAX_DOMAIN_OCTETS);
325                prop_assert_eq!(domain.as_ascii(), domain.as_ascii().to_ascii_lowercase());
326                prop_assert!(!domain.as_ascii().ends_with('.'));
327                prop_assert!(domain.as_ascii().split('.').all(|label| !label.is_empty() && label.len() <= MAX_LABEL_OCTETS));
328                prop_assert_eq!(DomainName::parse(domain.as_ascii()).unwrap(), domain);
329            }
330        }
331    }
332}