1use std::{error::Error, fmt};
9
10use serde::{Deserialize, Serialize};
11
12const MAX_DOMAIN_OCTETS: usize = 253;
13const MAX_LABEL_OCTETS: usize = 63;
14
15#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
20#[serde(transparent)]
21pub struct DomainName(Box<str>);
22
23impl DomainName {
24 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 pub fn as_ascii(&self) -> &str {
56 &self.0
57 }
58
59 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 pub fn tld(&self) -> &str {
68 self.as_ascii()
69 .rsplit_once('.')
70 .map_or(self.as_ascii(), |(_, tld)| tld)
71 }
72
73 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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum BoundaryStatus {
154 Known,
155 PolicyUnknown,
156}
157
158#[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#[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#[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#[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}