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    /// @docgen A Bengali or Arabic zone reads as `xn--` gibberish otherwise, and this value is ours rather than a remote answer.
65    #[must_use]
66    pub fn human(&self) -> String {
67        if !self.0.split('.').any(|label| label.starts_with("xn--")) {
68            return self.0.clone();
69        }
70        let (decoded, outcome) = idna::domain_to_unicode(&self.0);
71        if outcome.is_ok() {
72            decoded
73        } else {
74            self.0.clone()
75        }
76    }
77
78    #[must_use]
79    pub fn label_count(&self) -> usize {
80        self.0.split('.').count()
81    }
82
83    /// @docgen ICANN delegates the final label, so a two-letter test sees `co.uk` as a country code.
84    #[must_use]
85    pub fn delegated_label(&self) -> &str {
86        self.0.rsplit('.').next().unwrap_or(&self.0)
87    }
88
89    #[must_use]
90    pub fn is_country_code(&self) -> bool {
91        let root = self.delegated_label();
92        root.len() == 2 && root.bytes().all(|b| b.is_ascii_alphabetic())
93    }
94
95    /// @docgen Registry tables are matched longest-first, so `co.uk` wins over `uk`.
96    #[must_use]
97    pub fn ancestors(&self) -> Vec<String> {
98        let mut chain = Vec::new();
99        let mut rest: &str = &self.0;
100        loop {
101            chain.push(rest.to_owned());
102            match rest.split_once('.') {
103                Some((_, tail)) if !tail.is_empty() => rest = tail,
104                _ => break,
105            }
106        }
107        chain
108    }
109}
110
111fn check_label(label: &str) -> Result<(), &'static str> {
112    if label.is_empty() {
113        return Err("it has an empty label");
114    }
115    if label.len() > 63 {
116        return Err("a label is longer than 63 characters");
117    }
118    if label.starts_with('-') || label.ends_with('-') {
119        return Err("a label starts or ends with a hyphen");
120    }
121    if !label
122        .bytes()
123        .all(|b| b.is_ascii_alphanumeric() || b == b'-')
124    {
125        return Err("it has a character that is not a letter, digit, or hyphen");
126    }
127    Ok(())
128}
129
130/// @docgen A domain is at most 253 characters and a character at most four bytes, so nothing longer can ever become one.
131pub const MAX_INPUT_BYTES: usize = 1024;
132
133/// @docgen What the user typed and what was checked, kept apart so a rewrite can be shown rather than done behind their back.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct NormalizedName {
136    pub name: String,
137    pub rewritten: bool,
138}
139
140/// @docgen Turns what a person types into a name a registry can be asked about, without quietly checking a different one.
141pub fn normalize_name(value: &str) -> Result<NormalizedName, Error> {
142    let refuse = |reason: &str| Error::NameInvalid {
143        name: value.chars().take(60).collect(),
144        reason: reason.to_owned(),
145    };
146
147    // @docgen Bounded before any Unicode work, so a pasted document costs one length check rather than a full mapping pass.
148    if value.len() > MAX_INPUT_BYTES {
149        return Err(refuse(
150            "it is far longer than any domain name can be; paste just the name",
151        ));
152    }
153
154    let trimmed = value.trim();
155    if trimmed.is_empty() {
156        return Err(refuse("it is empty"));
157    }
158
159    // @docgen Only ASCII is reshaped. Stripping punctuation from Bengali or Arabic deletes marks that carry meaning and checks a different name.
160    let candidate = if trimmed.is_ascii() && !starts_with_ace(trimmed) {
161        slug(trimmed)
162    } else {
163        // @docgen Whitespace is the one thing no script allows inside a label, so joining words is safe where stripping punctuation would not be.
164        join_words(trimmed)
165    };
166
167    if candidate.is_empty() || candidate.chars().all(|c| c == '.') {
168        return Err(refuse("it has no letters or digits to check"));
169    }
170
171    for label in candidate.split('.') {
172        // @docgen RFC 5891 reserves a hyphen pair in the third and fourth places for the punycode prefix, so a slug must never invent one.
173        if is_reserved_shape(label) {
174            return Err(refuse(
175                "a part of it has two hyphens in the third and fourth places, which is reserved",
176            ));
177        }
178    }
179
180    let name = parse_name(&candidate)?;
181    let rewritten = name != trimmed.to_lowercase();
182    Ok(NormalizedName { name, rewritten })
183}
184
185/// @docgen Runs of spaces become one hyphen and nothing else is touched, so every mark the writer typed survives.
186fn join_words(value: &str) -> String {
187    let mut out = String::with_capacity(value.len());
188    let mut pending_gap = false;
189    for ch in value.chars() {
190        if ch.is_whitespace() {
191            pending_gap = true;
192        } else {
193            if pending_gap && !out.is_empty() && !out.ends_with('.') && ch != '.' {
194                out.push('-');
195            }
196            pending_gap = false;
197            out.push(ch);
198        }
199    }
200    out
201}
202
203fn starts_with_ace(value: &str) -> bool {
204    value
205        .split('.')
206        .any(|label| label.len() >= 4 && label[..4].eq_ignore_ascii_case("xn--"))
207}
208
209fn is_reserved_shape(label: &str) -> bool {
210    let bytes = label.as_bytes();
211    bytes.len() >= 4
212        && bytes.get(2) == Some(&b'-')
213        && bytes.get(3) == Some(&b'-')
214        && !label[..4].eq_ignore_ascii_case("xn--")
215}
216
217/// @docgen Dots are kept so a full domain survives, and each part is reshaped on its own.
218fn slug(value: &str) -> String {
219    value
220        .split('.')
221        .map(|label| {
222            let mut out = String::with_capacity(label.len());
223            let mut pending_gap = false;
224            for ch in label.chars() {
225                if ch.is_ascii_alphanumeric() {
226                    if pending_gap && !out.is_empty() {
227                        out.push('-');
228                    }
229                    pending_gap = false;
230                    out.push(ch.to_ascii_lowercase());
231                } else {
232                    pending_gap = true;
233                }
234            }
235            out
236        })
237        // @docgen A doubled dot is a typo rather than an empty label, so it is healed instead of refused.
238        .filter(|label| !label.is_empty())
239        .collect::<Vec<_>>()
240        .join(".")
241}
242
243/// @docgen A name reaches a raw port-43 request line, so an unchecked control byte injects a second query.
244pub fn parse_name(value: &str) -> Result<String, Error> {
245    let trimmed = value.trim();
246    let refuse = |reason: &str| Error::NameInvalid {
247        name: value.to_owned(),
248        reason: reason.to_owned(),
249    };
250
251    if trimmed.is_empty() {
252        return Err(refuse("it is empty"));
253    }
254
255    let ascii = idna::domain_to_ascii(&trimmed.to_lowercase())
256        .map_err(|_| refuse("it is not a usable domain name"))?;
257
258    if ascii.len() > 253 {
259        return Err(refuse("it is longer than 253 characters"));
260    }
261    for label in ascii.split('.') {
262        check_label(label).map_err(refuse)?;
263    }
264
265    Ok(ascii)
266}
267
268impl fmt::Display for Suffix {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        f.write_str(&self.0)
271    }
272}
273
274impl FromStr for Suffix {
275    type Err = Error;
276
277    fn from_str(s: &str) -> Result<Self, Self::Err> {
278        Self::parse(s)
279    }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
283#[serde(rename_all = "kebab-case")]
284pub enum ExtensionKind {
285    Generic,
286    Country,
287    Sponsored,
288}
289
290impl ExtensionKind {
291    #[must_use]
292    pub const fn label(self) -> &'static str {
293        match self {
294            Self::Generic => "generic",
295            Self::Country => "country",
296            Self::Sponsored => "sponsored",
297        }
298    }
299}
300
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct Extension {
303    pub suffix: Suffix,
304    pub kind: ExtensionKind,
305    /// @docgen None means the zone is too small or too private to rank, so absence is not missing data.
306    #[serde(default)]
307    pub rank: Option<u32>,
308    #[serde(default)]
309    pub industries: Vec<String>,
310    #[serde(default)]
311    pub region: Option<String>,
312    #[serde(default)]
313    pub country: Option<String>,
314    #[serde(default = "default_registrable")]
315    pub registrable: bool,
316    #[serde(default)]
317    pub repurposed: bool,
318}
319
320const fn default_registrable() -> bool {
321    true
322}
323
324impl Extension {
325    #[must_use]
326    pub fn is_in_industry(&self, key: &str) -> bool {
327        self.industries.iter().any(|i| i == key)
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn an_internationalised_zone_reads_in_its_own_script_while_staying_punycode_underneath() {
337        let bengali = Suffix::parse("বাংলা").expect("the Bengali ccTLD parses");
338        assert_eq!(
339            bengali.as_str(),
340            "xn--54b7fta0cc",
341            "the protocol form stays ASCII"
342        );
343        assert_eq!(bengali.human(), "বাংলা", "the reader sees their own script");
344
345        let typed_as_punycode = Suffix::parse("xn--54b7fta0cc").expect("the A-label parses");
346        assert_eq!(typed_as_punycode, bengali, "both spellings reach one value");
347
348        let plain = Suffix::parse("com.bd").expect("an ASCII suffix parses");
349        assert_eq!(plain.human(), "com.bd", "an ASCII zone is left alone");
350    }
351
352    #[test]
353    fn a_typed_phrase_becomes_a_name_a_registry_can_be_asked_about() {
354        for (typed, expected) in [
355            ("hello world", "hello-world"),
356            ("  My Cool Startup!  ", "my-cool-startup"),
357            ("foo___bar", "foo-bar"),
358            ("a  lot   of   space", "a-lot-of-space"),
359            ("--leading and trailing--", "leading-and-trailing"),
360            ("Mixed CASE", "mixed-case"),
361            ("My Site.com", "my-site.com"),
362        ] {
363            let out = normalize_name(typed).unwrap_or_else(|e| panic!("{typed}: {e}"));
364            assert_eq!(out.name, expected, "typed {typed}");
365            assert!(out.rewritten, "{typed} was reshaped and should say so");
366        }
367    }
368
369    #[test]
370    fn a_name_that_needed_no_reshaping_does_not_claim_it_was_reshaped() {
371        let out = normalize_name("example").expect("plain name");
372        assert_eq!(out.name, "example");
373        assert!(!out.rewritten);
374    }
375
376    #[test]
377    fn a_non_ascii_name_written_as_two_words_is_joined_rather_than_refused() {
378        let two_words = normalize_name("বাংলা দেশ").expect("two Bengali words are usable");
379        let joined = idna::domain_to_ascii("বাংলা-দেশ").expect("reference encoding");
380        assert_eq!(two_words.name, joined, "the words are joined, not stripped");
381        assert!(two_words.rewritten);
382    }
383
384    #[test]
385    fn a_non_ascii_name_is_never_stripped_into_a_different_one() {
386        // The Bengali conjunct carries a virama that is not alphanumeric; a slug
387        // step would delete it and quietly check a different name.
388        let bengali = normalize_name("বাংলা").expect("a Bengali name is usable");
389        assert_eq!(bengali.name, "xn--54b7fta0cc");
390
391        let german = normalize_name("münchen").expect("a German name is usable");
392        assert_eq!(german.name, "xn--mnchen-3ya");
393
394        let conjunct = normalize_name("পরীক্ষা").expect("a Bengali conjunct survives");
395        assert_eq!(
396            conjunct.name,
397            idna::domain_to_ascii("পরীক্ষা").expect("reference encoding"),
398            "the name checked must be the name typed"
399        );
400    }
401
402    #[test]
403    fn input_far_larger_than_any_domain_is_refused_rather_than_processed() {
404        let pasted = "a".repeat(MAX_INPUT_BYTES + 1);
405        let refused = normalize_name(&pasted).expect_err("a pasted document is not a name");
406        assert!(refused.to_string().contains("longer than any domain"));
407    }
408
409    #[test]
410    fn a_slug_collapses_separators_so_it_cannot_invent_the_reserved_shape() {
411        // Runs of separators become one hyphen, so no ASCII input can produce a
412        // hyphen pair in the third and fourth places.
413        for typed in ["ab  cd", "ab--cd", "ab..--..cd", "ab___cd"] {
414            let out = normalize_name(typed).unwrap_or_else(|e| panic!("{typed}: {e}"));
415            assert!(
416                !out.name.split('.').any(is_reserved_shape),
417                "{typed} produced the reserved shape {}",
418                out.name
419            );
420        }
421    }
422
423    #[test]
424    fn a_reserved_shape_arriving_unslugged_is_refused() {
425        // A name carrying non-ASCII skips the slug, so the guard is what stops
426        // a reserved label reaching the registry.
427        let refused = normalize_name("ab--cd.münchen").expect_err("a reserved label is refused");
428        assert!(refused.to_string().contains("reserved"), "{refused}");
429
430        // The punycode prefix is the one permitted form of that shape.
431        assert!(normalize_name("xn--54b7fta0cc").is_ok());
432    }
433
434    #[test]
435    fn a_string_with_nothing_to_check_is_refused() {
436        for empty in ["   ", "!!!", "...", "---"] {
437            assert!(normalize_name(empty).is_err(), "{empty} should be refused");
438        }
439    }
440
441    #[test]
442    fn a_leading_dot_is_accepted_and_stripped() {
443        assert_eq!(Suffix::parse(".com").unwrap().as_str(), "com");
444        assert_eq!(Suffix::parse("com").unwrap().as_str(), "com");
445        assert_eq!(Suffix::parse("  .COM  ").unwrap().as_str(), "com");
446    }
447
448    #[test]
449    fn rubbish_is_refused_rather_than_guessed() {
450        for bad in ["", ".", "..", "-com", "com-", "a..b", "9", "co m", "*"] {
451            assert!(Suffix::parse(bad).is_err(), "{bad} should be refused");
452        }
453    }
454
455    #[test]
456    fn a_label_of_sixty_three_characters_is_the_longest_one_allowed() {
457        let longest = "a".repeat(63);
458        assert_eq!(Suffix::parse(&longest).unwrap().as_str(), longest);
459        assert!(Suffix::parse(&"a".repeat(64)).is_err());
460    }
461
462    #[test]
463    fn an_extension_of_two_hundred_and_fifty_three_characters_is_the_longest_one_allowed() {
464        let label = "a".repeat(63);
465        let at_the_cap = [
466            label.as_str(),
467            label.as_str(),
468            label.as_str(),
469            &"b".repeat(61),
470        ]
471        .join(".");
472        assert_eq!(at_the_cap.len(), 253);
473        assert!(Suffix::parse(&at_the_cap).is_ok());
474
475        let over_the_cap = format!("{at_the_cap}b");
476        assert_eq!(over_the_cap.len(), 254);
477        assert!(Suffix::parse(&over_the_cap).is_err());
478    }
479
480    #[test]
481    fn a_suffix_read_from_json_goes_through_the_same_parser_as_a_typed_one() {
482        let parsed: Suffix = serde_json::from_str("\".CO.UK\"").unwrap();
483        assert_eq!(parsed.as_str(), "co.uk");
484        assert_eq!(parsed, Suffix::parse(".CO.UK").unwrap());
485    }
486
487    #[test]
488    fn an_unusable_suffix_in_json_is_refused_rather_than_loaded_unchecked() {
489        for bad in [
490            "\"\"", "\".\"", "\"-com\"", "\"com-\"", "\"a..b\"", "\"9\"", "\"co m\"",
491        ] {
492            assert!(
493                serde_json::from_str::<Suffix>(bad).is_err(),
494                "{bad} should be refused"
495            );
496        }
497    }
498
499    #[test]
500    fn a_suffix_survives_a_round_trip_through_json() {
501        let suffix = Suffix::parse("com.bd").unwrap();
502        let text = serde_json::to_string(&suffix).unwrap();
503        assert_eq!(text, "\"com.bd\"");
504        assert_eq!(serde_json::from_str::<Suffix>(&text).unwrap(), suffix);
505    }
506
507    #[test]
508    fn label_count_separates_second_level_from_third() {
509        assert_eq!(Suffix::parse("com").unwrap().label_count(), 1);
510        assert_eq!(Suffix::parse("co.uk").unwrap().label_count(), 2);
511    }
512
513    #[test]
514    fn the_country_test_reads_the_delegated_label_not_the_whole_string() {
515        assert!(Suffix::parse("uk").unwrap().is_country_code());
516        assert!(Suffix::parse("co.uk").unwrap().is_country_code());
517        assert!(Suffix::parse("bd").unwrap().is_country_code());
518        assert!(!Suffix::parse("com").unwrap().is_country_code());
519        assert!(!Suffix::parse("dev").unwrap().is_country_code());
520    }
521
522    #[test]
523    fn the_parent_chain_runs_longest_first() {
524        let suffix = Suffix::parse("com.bd").unwrap();
525        assert_eq!(
526            suffix.ancestors(),
527            vec!["com.bd".to_owned(), "bd".to_owned()]
528        );
529        let plain = Suffix::parse("dev").unwrap();
530        assert_eq!(plain.ancestors(), vec!["dev".to_owned()]);
531    }
532
533    #[test]
534    fn a_control_byte_in_a_name_is_refused() {
535        for bad in [
536            "x\rdomain google.com",
537            "x\ndomain google.com",
538            "x\r\ndomain google.com",
539            "x\0y",
540            "x y",
541            "x\ty",
542            "x\u{1b}[2Ky",
543        ] {
544            assert!(
545                parse_name(bad).is_err(),
546                "{bad:?} must never reach a request line"
547            );
548        }
549    }
550
551    #[test]
552    fn a_usable_name_survives_validation() {
553        assert_eq!(parse_name("example").unwrap(), "example");
554        assert_eq!(parse_name("  Example  ").unwrap(), "example");
555        assert_eq!(parse_name("shop.example").unwrap(), "shop.example");
556        assert_eq!(parse_name("123").unwrap(), "123");
557        assert_eq!(parse_name("a-b").unwrap(), "a-b");
558    }
559
560    #[test]
561    fn a_unicode_name_is_normalized_before_it_reaches_the_wire() {
562        assert_eq!(parse_name("münchen").unwrap(), "xn--mnchen-3ya");
563    }
564
565    #[test]
566    fn a_malformed_name_is_refused() {
567        for bad in ["", "  ", "-lead", "trail-", "a..b", &"x".repeat(64)] {
568            assert!(parse_name(bad).is_err(), "{bad:?} should be refused");
569        }
570    }
571
572    #[test]
573    fn a_unicode_extension_is_normalized_to_its_ascii_form() {
574        let suffix = Suffix::parse("বাংলা").unwrap();
575        assert!(suffix.as_str().starts_with("xn--"));
576    }
577}