mur_common/
host_pattern.rs1use std::net::IpAddr;
5
6#[cfg(test)]
7mod tests;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct NormalizedHost {
11 pub value: String,
12 pub notes: Vec<Normalization>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Normalization {
17 Lowercased { from: String },
18 Punycode { from: String },
19 LegacyDotWildcard,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum HostPatternError {
24 Empty,
25 Whitespace,
26 UrlOrPath,
27 HasPort,
28 NoDot,
29 BadChar(char),
30 BadLabel(String),
31 TooLong,
32 BadWildcard,
33}
34
35impl std::fmt::Display for HostPatternError {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::Empty => write!(f, "empty host"),
39 Self::Whitespace => write!(f, "contains a space — one host per argument"),
40 Self::UrlOrPath => write!(f, "contains a URL scheme or path — pass the bare host"),
41 Self::HasPort => write!(f, "contains a port — pass the bare host"),
42 Self::NoDot => write!(f, "has no dot — a host looks like `example.com`"),
43 Self::BadChar(c) => write!(f, "contains `{c}`, which no hostname can contain"),
44 Self::BadLabel(l) => write!(f, "label `{l}` is not a valid DNS label"),
45 Self::TooLong => write!(f, "longer than 253 characters"),
46 Self::BadWildcard => write!(
47 f,
48 "a wildcard is only allowed as the first label, e.g. `*.example.com`"
49 ),
50 }
51 }
52}
53
54const SLASHES: [char; 3] = ['/', '/', '\\'];
55
56pub fn normalize_host_pattern(raw: &str) -> Result<NormalizedHost, HostPatternError> {
57 let s = raw.trim();
58 if s.is_empty() {
59 return Err(HostPatternError::Empty);
60 }
61 if s.chars().any(char::is_whitespace) {
62 return Err(HostPatternError::Whitespace);
63 }
64 if s.contains("://") || s.contains(SLASHES) {
65 return Err(HostPatternError::UrlOrPath);
66 }
67 let unbracketed = s
69 .strip_prefix('[')
70 .and_then(|r| r.strip_suffix(']'))
71 .unwrap_or(s);
72 if let Ok(ip) = unbracketed.parse::<IpAddr>() {
73 let value = match ip {
77 IpAddr::V4(v4) => v4.to_string(),
78 IpAddr::V6(v6) => format!("[{v6}]"),
79 };
80 return Ok(NormalizedHost {
81 value,
82 notes: vec![],
83 });
84 }
85 if s.contains(':') {
86 return Err(HostPatternError::HasPort);
87 }
88 if s.eq_ignore_ascii_case("localhost") {
89 return Ok(NormalizedHost {
90 value: "localhost".into(),
91 notes: lower_note(s),
92 });
93 }
94 let mut notes = Vec::new();
95 let (prefix, name) = if let Some(rest) = s.strip_prefix("*.") {
96 ("*.", rest)
97 } else if let Some(rest) = s.strip_prefix('.') {
98 notes.push(Normalization::LegacyDotWildcard);
99 (".", rest)
100 } else {
101 ("", s)
102 };
103 if name.contains('*') {
104 return Err(HostPatternError::BadWildcard);
105 }
106 let ascii = if name.is_ascii() {
107 name.to_ascii_lowercase()
108 } else {
109 if let Some(c) = name
111 .chars()
112 .find(|c| !c.is_alphanumeric() && *c != '.' && *c != '-')
113 {
114 return Err(HostPatternError::BadChar(c));
115 }
116 let a = idna::domain_to_ascii_strict(name)
117 .map_err(|_| HostPatternError::BadLabel(name.into()))?;
118 notes.push(Normalization::Punycode { from: s.into() });
119 a
120 };
121 if name.is_ascii() && ascii != name {
122 notes.push(Normalization::Lowercased { from: s.into() });
123 }
124 if ascii.len() > 253 {
125 return Err(HostPatternError::TooLong);
126 }
127 let labels: Vec<&str> = ascii.split('.').collect();
128 if labels.len() < 2 {
129 return Err(HostPatternError::NoDot);
130 }
131 for l in &labels {
132 let ok = (1..=63).contains(&l.len())
133 && l.bytes()
134 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
135 && !l.starts_with('-')
136 && !l.ends_with('-');
137 if !ok {
138 return Err(HostPatternError::BadLabel((*l).into()));
139 }
140 }
141 Ok(NormalizedHost {
142 value: format!("{prefix}{ascii}"),
143 notes,
144 })
145}
146
147fn lower_note(s: &str) -> Vec<Normalization> {
148 if s.bytes().any(|b| b.is_ascii_uppercase()) {
149 vec![Normalization::Lowercased { from: s.into() }]
150 } else {
151 vec![]
152 }
153}