Skip to main content

reserve_core/tld/
extension.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::Error;
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
9#[serde(transparent)]
10pub struct Suffix(String);
11
12impl<'de> Deserialize<'de> for Suffix {
13    /// @docgen Without this a suffix loaded from the catalog skips every rule the parser enforces on typed input.
14    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
15    where
16        D: serde::Deserializer<'de>,
17    {
18        let raw = String::deserialize(deserializer)?;
19        Self::parse(&raw).map_err(serde::de::Error::custom)
20    }
21}
22
23impl Suffix {
24    pub fn parse(value: &str) -> Result<Self, Error> {
25        let trimmed = value.trim().trim_start_matches('.').trim_end_matches('.');
26        if trimmed.is_empty() {
27            return Err(Error::ExtensionInvalid {
28                extension: value.to_owned(),
29            });
30        }
31
32        let lowered = trimmed.to_lowercase();
33        let ascii = idna::domain_to_ascii(&lowered).map_err(|_| Error::ExtensionInvalid {
34            extension: value.to_owned(),
35        })?;
36
37        if ascii.len() > 253 {
38            return Err(Error::ExtensionInvalid {
39                extension: value.to_owned(),
40            });
41        }
42
43        for label in ascii.split('.') {
44            if check_label(label).is_err() || label.bytes().all(|b| b.is_ascii_digit()) {
45                return Err(Error::ExtensionInvalid {
46                    extension: value.to_owned(),
47                });
48            }
49        }
50
51        Ok(Self(ascii))
52    }
53
54    /// @docgen Reporting an unrecognized name needs a suffix that cannot fail to build, since it is only ever displayed.
55    pub(crate) fn from_raw(value: &str) -> Self {
56        Self(value.to_lowercase())
57    }
58
59    #[must_use]
60    pub fn as_str(&self) -> &str {
61        &self.0
62    }
63
64    #[must_use]
65    pub fn label_count(&self) -> usize {
66        self.0.split('.').count()
67    }
68
69    /// @docgen ICANN delegates the final label, so a two-letter test sees `co.uk` as a country code.
70    #[must_use]
71    pub fn delegated_label(&self) -> &str {
72        self.0.rsplit('.').next().unwrap_or(&self.0)
73    }
74
75    #[must_use]
76    pub fn is_country_code(&self) -> bool {
77        let root = self.delegated_label();
78        root.len() == 2 && root.bytes().all(|b| b.is_ascii_alphabetic())
79    }
80
81    /// @docgen Registry tables are matched longest-first, so `co.uk` wins over `uk`.
82    #[must_use]
83    pub fn ancestors(&self) -> Vec<String> {
84        let mut chain = Vec::new();
85        let mut rest: &str = &self.0;
86        loop {
87            chain.push(rest.to_owned());
88            match rest.split_once('.') {
89                Some((_, tail)) if !tail.is_empty() => rest = tail,
90                _ => break,
91            }
92        }
93        chain
94    }
95}
96
97fn check_label(label: &str) -> Result<(), &'static str> {
98    if label.is_empty() {
99        return Err("it has an empty label");
100    }
101    if label.len() > 63 {
102        return Err("a label is longer than 63 characters");
103    }
104    if label.starts_with('-') || label.ends_with('-') {
105        return Err("a label starts or ends with a hyphen");
106    }
107    if !label
108        .bytes()
109        .all(|b| b.is_ascii_alphanumeric() || b == b'-')
110    {
111        return Err("it has a character that is not a letter, digit, or hyphen");
112    }
113    Ok(())
114}
115
116/// @docgen A name reaches a raw port-43 request line, so an unchecked control byte injects a second query.
117pub fn parse_name(value: &str) -> Result<String, Error> {
118    let trimmed = value.trim();
119    let refuse = |reason: &str| Error::NameInvalid {
120        name: value.to_owned(),
121        reason: reason.to_owned(),
122    };
123
124    if trimmed.is_empty() {
125        return Err(refuse("it is empty"));
126    }
127
128    let ascii = idna::domain_to_ascii(&trimmed.to_lowercase())
129        .map_err(|_| refuse("it is not a usable domain name"))?;
130
131    if ascii.len() > 253 {
132        return Err(refuse("it is longer than 253 characters"));
133    }
134    for label in ascii.split('.') {
135        check_label(label).map_err(refuse)?;
136    }
137
138    Ok(ascii)
139}
140
141impl fmt::Display for Suffix {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        f.write_str(&self.0)
144    }
145}
146
147impl FromStr for Suffix {
148    type Err = Error;
149
150    fn from_str(s: &str) -> Result<Self, Self::Err> {
151        Self::parse(s)
152    }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
156#[serde(rename_all = "kebab-case")]
157pub enum ExtensionKind {
158    Generic,
159    Country,
160    Sponsored,
161}
162
163impl ExtensionKind {
164    #[must_use]
165    pub const fn label(self) -> &'static str {
166        match self {
167            Self::Generic => "generic",
168            Self::Country => "country",
169            Self::Sponsored => "sponsored",
170        }
171    }
172}
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct Extension {
176    pub suffix: Suffix,
177    pub kind: ExtensionKind,
178    /// @docgen None means the zone is too small or too private to rank, so absence is not missing data.
179    #[serde(default)]
180    pub rank: Option<u32>,
181    #[serde(default)]
182    pub industries: Vec<String>,
183    #[serde(default)]
184    pub region: Option<String>,
185    #[serde(default)]
186    pub country: Option<String>,
187    #[serde(default = "default_registrable")]
188    pub registrable: bool,
189    #[serde(default)]
190    pub repurposed: bool,
191}
192
193const fn default_registrable() -> bool {
194    true
195}
196
197impl Extension {
198    #[must_use]
199    pub fn is_in_industry(&self, key: &str) -> bool {
200        self.industries.iter().any(|i| i == key)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn a_leading_dot_is_accepted_and_stripped() {
210        assert_eq!(Suffix::parse(".com").unwrap().as_str(), "com");
211        assert_eq!(Suffix::parse("com").unwrap().as_str(), "com");
212        assert_eq!(Suffix::parse("  .COM  ").unwrap().as_str(), "com");
213    }
214
215    #[test]
216    fn rubbish_is_refused_rather_than_guessed() {
217        for bad in ["", ".", "..", "-com", "com-", "a..b", "9", "co m", "*"] {
218            assert!(Suffix::parse(bad).is_err(), "{bad} should be refused");
219        }
220    }
221
222    #[test]
223    fn a_label_of_sixty_three_characters_is_the_longest_one_allowed() {
224        let longest = "a".repeat(63);
225        assert_eq!(Suffix::parse(&longest).unwrap().as_str(), longest);
226        assert!(Suffix::parse(&"a".repeat(64)).is_err());
227    }
228
229    #[test]
230    fn an_extension_of_two_hundred_and_fifty_three_characters_is_the_longest_one_allowed() {
231        let label = "a".repeat(63);
232        let at_the_cap = [
233            label.as_str(),
234            label.as_str(),
235            label.as_str(),
236            &"b".repeat(61),
237        ]
238        .join(".");
239        assert_eq!(at_the_cap.len(), 253);
240        assert!(Suffix::parse(&at_the_cap).is_ok());
241
242        let over_the_cap = format!("{at_the_cap}b");
243        assert_eq!(over_the_cap.len(), 254);
244        assert!(Suffix::parse(&over_the_cap).is_err());
245    }
246
247    #[test]
248    fn a_suffix_read_from_json_goes_through_the_same_parser_as_a_typed_one() {
249        let parsed: Suffix = serde_json::from_str("\".CO.UK\"").unwrap();
250        assert_eq!(parsed.as_str(), "co.uk");
251        assert_eq!(parsed, Suffix::parse(".CO.UK").unwrap());
252    }
253
254    #[test]
255    fn an_unusable_suffix_in_json_is_refused_rather_than_loaded_unchecked() {
256        for bad in [
257            "\"\"", "\".\"", "\"-com\"", "\"com-\"", "\"a..b\"", "\"9\"", "\"co m\"",
258        ] {
259            assert!(
260                serde_json::from_str::<Suffix>(bad).is_err(),
261                "{bad} should be refused"
262            );
263        }
264    }
265
266    #[test]
267    fn a_suffix_survives_a_round_trip_through_json() {
268        let suffix = Suffix::parse("com.bd").unwrap();
269        let text = serde_json::to_string(&suffix).unwrap();
270        assert_eq!(text, "\"com.bd\"");
271        assert_eq!(serde_json::from_str::<Suffix>(&text).unwrap(), suffix);
272    }
273
274    #[test]
275    fn label_count_separates_second_level_from_third() {
276        assert_eq!(Suffix::parse("com").unwrap().label_count(), 1);
277        assert_eq!(Suffix::parse("co.uk").unwrap().label_count(), 2);
278    }
279
280    #[test]
281    fn the_country_test_reads_the_delegated_label_not_the_whole_string() {
282        assert!(Suffix::parse("uk").unwrap().is_country_code());
283        assert!(Suffix::parse("co.uk").unwrap().is_country_code());
284        assert!(Suffix::parse("bd").unwrap().is_country_code());
285        assert!(!Suffix::parse("com").unwrap().is_country_code());
286        assert!(!Suffix::parse("dev").unwrap().is_country_code());
287    }
288
289    #[test]
290    fn the_parent_chain_runs_longest_first() {
291        let suffix = Suffix::parse("com.bd").unwrap();
292        assert_eq!(
293            suffix.ancestors(),
294            vec!["com.bd".to_owned(), "bd".to_owned()]
295        );
296        let plain = Suffix::parse("dev").unwrap();
297        assert_eq!(plain.ancestors(), vec!["dev".to_owned()]);
298    }
299
300    #[test]
301    fn a_control_byte_in_a_name_is_refused() {
302        for bad in [
303            "x\rdomain google.com",
304            "x\ndomain google.com",
305            "x\r\ndomain google.com",
306            "x\0y",
307            "x y",
308            "x\ty",
309            "x\u{1b}[2Ky",
310        ] {
311            assert!(
312                parse_name(bad).is_err(),
313                "{bad:?} must never reach a request line"
314            );
315        }
316    }
317
318    #[test]
319    fn a_usable_name_survives_validation() {
320        assert_eq!(parse_name("example").unwrap(), "example");
321        assert_eq!(parse_name("  Example  ").unwrap(), "example");
322        assert_eq!(parse_name("shop.example").unwrap(), "shop.example");
323        assert_eq!(parse_name("123").unwrap(), "123");
324        assert_eq!(parse_name("a-b").unwrap(), "a-b");
325    }
326
327    #[test]
328    fn a_unicode_name_is_normalized_before_it_reaches_the_wire() {
329        assert_eq!(parse_name("münchen").unwrap(), "xn--mnchen-3ya");
330    }
331
332    #[test]
333    fn a_malformed_name_is_refused() {
334        for bad in ["", "  ", "-lead", "trail-", "a..b", &"x".repeat(64)] {
335            assert!(parse_name(bad).is_err(), "{bad:?} should be refused");
336        }
337    }
338
339    #[test]
340    fn a_unicode_extension_is_normalized_to_its_ascii_form() {
341        let suffix = Suffix::parse("বাংলা").unwrap();
342        assert!(suffix.as_str().starts_with("xn--"));
343    }
344}