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 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 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 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 #[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 #[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
121pub const MAX_INPUT_BYTES: usize = 1024;
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct NormalizedName {
127 pub name: String,
128 pub rewritten: bool,
129}
130
131pub 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 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 let candidate = if trimmed.is_ascii() && !starts_with_ace(trimmed) {
152 slug(trimmed)
153 } else {
154 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 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
176fn 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
208fn 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 .filter(|label| !label.is_empty())
230 .collect::<Vec<_>>()
231 .join(".")
232}
233
234pub fn parse_name(value: &str) -> Result<String, Error> {
236 let trimmed = value.trim();
237 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 #[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 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 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 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 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}