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