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