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 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 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]
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 #[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 #[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
130pub const MAX_INPUT_BYTES: usize = 1024;
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct NormalizedName {
136 pub name: String,
137 pub rewritten: bool,
138}
139
140pub 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 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 let candidate = if trimmed.is_ascii() && !starts_with_ace(trimmed) {
161 slug(trimmed)
162 } else {
163 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 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
185fn 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
217fn 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 .filter(|label| !label.is_empty())
239 .collect::<Vec<_>>()
240 .join(".")
241}
242
243pub 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 #[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 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 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 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 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}