Skip to main content

pharos_corefile/
key.rs

1use std::net::IpAddr;
2
3/// A normalized Server Block Key.
4#[derive(Clone, Debug, Eq, Hash, PartialEq)]
5pub struct ServerBlockKey {
6    /// Transport selected by the key.
7    pub transport: String,
8    /// Fully qualified, lowercased Domain.
9    pub domain: String,
10    /// Decimal port, or an empty string for the Unix transport's default.
11    pub port: String,
12}
13
14/// Normalize one Server Block Key, expanding CIDRs into reverse Domains.
15pub fn normalize_key(key: &str) -> Result<Vec<ServerBlockKey>, String> {
16    let (transport, host) = ["dns", "tls", "quic", "grpc", "https", "https3", "unix"]
17        .into_iter()
18        .find_map(|transport| {
19            key.strip_prefix(&format!("{transport}://"))
20                .map(|host| (transport, host))
21        })
22        .unwrap_or(("dns", key));
23
24    if host.is_empty() || host.ends_with(':') {
25        return Err(format!("expecting data after last colon: {host:?}"));
26    }
27    let (host, port) = host
28        .rsplit_once(':')
29        .and_then(|(host, port)| {
30            port.parse::<i64>()
31                .ok()
32                .map(|port| (host, port.to_string()))
33        })
34        .unwrap_or((host, String::new()));
35    if host.len() > 255 || !valid_domain(host) {
36        return Err(format!("invalid Domain: {host}"));
37    }
38
39    let domains = match parse_cidr(host) {
40        Some((ip, prefix)) => {
41            if host.starts_with(':') || (host.starts_with('0') && host.contains(':')) {
42                return Err(format!("invalid CIDR {host}"));
43            }
44            reverse_domains(ip, prefix)
45        }
46        None => vec![fqdn(host)],
47    };
48    let port = if port.is_empty() {
49        match transport {
50            "dns" => "53",
51            "tls" | "quic" => "853",
52            "grpc" | "https" | "https3" => "443",
53            _ => "",
54        }
55        .to_owned()
56    } else {
57        port
58    };
59    domains
60        .into_iter()
61        .map(|domain| {
62            if domain.len() > 255 || !valid_domain(&domain) {
63                return Err(format!("invalid Domain: {domain}"));
64            }
65            Ok(ServerBlockKey {
66                transport: transport.to_owned(),
67                domain: domain.chars().map(go_lowercase).collect(),
68                port: port.clone(),
69            })
70        })
71        .collect()
72}
73
74fn go_lowercase(c: char) -> char {
75    // Go's Unicode tables predate these case mappings in Rust's tables.
76    match c {
77        '\u{1c89}'
78        | '\u{a7cb}'
79        | '\u{a7cc}'
80        | '\u{a7ce}'
81        | '\u{a7d2}'
82        | '\u{a7d4}'
83        | '\u{a7da}'
84        | '\u{a7dc}'
85        | '\u{10d50}'..='\u{10d65}'
86        | '\u{16ea0}'..='\u{16eb8}' => c,
87        _ => c.to_lowercase().next().unwrap_or(c),
88    }
89}
90
91fn fqdn(name: &str) -> String {
92    let is_fqdn = name.strip_suffix('.').is_some_and(|prefix| {
93        if !prefix.ends_with('\\') {
94            return true;
95        }
96        // Match miekg/dns's byte-index calculation, including its Unicode edge case.
97        let last_non_escape = prefix.char_indices().rfind(|(_, c)| *c != '\\');
98        let distance = last_non_escape.map_or(prefix.len() + 1, |(index, _)| prefix.len() - index);
99        !distance.is_multiple_of(2)
100    });
101    if is_fqdn {
102        name.to_owned()
103    } else {
104        format!("{name}.")
105    }
106}
107
108fn valid_domain(name: &str) -> bool {
109    if name.is_empty() {
110        return false;
111    }
112    let qualified = fqdn(name);
113    let bytes = qualified.as_bytes();
114    let (mut i, mut begin, mut offset) = (0, 0, 0);
115    let (mut was_dot, mut escape) = (false, false);
116    while i < bytes.len() {
117        match bytes[i] {
118            b'\\' => {
119                escape = !escape;
120                if offset + 1 > 256 {
121                    return false;
122                }
123                let digits = bytes
124                    .get(i + 1..i + 4)
125                    .is_some_and(|digits| digits.iter().all(u8::is_ascii_digit));
126                let escaped = if digits { 3 } else { 1 };
127                i += escaped;
128                begin += escaped;
129                was_dot = false;
130            }
131            b'.' => {
132                escape = false;
133                if (i == 0 && bytes.len() > 1) || was_dot {
134                    return false;
135                }
136                let label_len = i - begin;
137                // RFC 1035 §2.3.4: DNS labels are limited to 63 octets.
138                if label_len >= 64 {
139                    return false;
140                }
141                offset += 1 + label_len;
142                if offset > 256 {
143                    return false;
144                }
145                begin = i + 1;
146                was_dot = true;
147            }
148            _ => {
149                escape = false;
150                was_dot = false;
151            }
152        }
153        i += 1;
154    }
155    !escape
156}
157
158fn parse_cidr(host: &str) -> Option<(IpAddr, u32)> {
159    let (address, prefix) = host.split_once('/')?;
160    if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_digit()) {
161        return None;
162    }
163    let ip: IpAddr = address.parse().ok()?;
164    let prefix: u32 = prefix.parse().ok()?;
165    let bits = if ip.is_ipv4() { 32 } else { 128 };
166    (prefix <= bits).then_some((ip, prefix))
167}
168
169fn reverse_domains(ip: IpAddr, prefix: u32) -> Vec<String> {
170    // RFC 1035 §3.5: IPv4 reverse names use reversed decimal octets under in-addr.arpa.
171    // RFC 3596 §2.5: IPv6 reverse names use reversed hex nibbles under ip6.arpa.
172    let (value, bits, step, suffix) = match ip {
173        IpAddr::V4(ip) => (u128::from(u32::from(ip)), 32, 8, "in-addr.arpa."),
174        IpAddr::V6(ip) => (u128::from(ip), 128, 4, "ip6.arpa."),
175    };
176    let rounded = prefix.div_ceil(step) * step;
177    let network = if prefix == 0 {
178        0
179    } else {
180        (value >> (bits - prefix)) << (bits - prefix)
181    };
182    let count = 1_u128 << (rounded - prefix);
183    (0..count)
184        .map(|index| {
185            let value = if rounded == 0 {
186                network
187            } else {
188                network | (index << (bits - rounded))
189            };
190            let mut domain = String::new();
191            for label in (0..rounded / step).rev() {
192                let digit = (value >> (bits - step * (label + 1))) & ((1 << step) - 1);
193                if step == 8 {
194                    domain.push_str(&format!("{digit}."));
195                } else {
196                    domain.push_str(&format!("{digit:x}."));
197                }
198            }
199            domain.push_str(suffix);
200            domain
201        })
202        .collect()
203}
204
205#[cfg(test)]
206mod tests {
207    use super::{ServerBlockKey, normalize_key};
208
209    fn key(transport: &str, domain: &str, port: &str) -> ServerBlockKey {
210        ServerBlockKey {
211            transport: transport.into(),
212            domain: domain.into(),
213            port: port.into(),
214        }
215    }
216
217    #[test]
218    fn transport_and_port_defaults() {
219        for (input, transport, port) in [
220            (".", "dns", "53"),
221            ("dns://Example.ORG", "dns", "53"),
222            ("tls://Example.ORG.", "tls", "853"),
223            ("quic://example.org", "quic", "853"),
224            ("grpc://example.org", "grpc", "443"),
225            ("https://example.org", "https", "443"),
226            ("https3://example.org", "https3", "443"),
227            ("unix://example.org", "unix", ""),
228        ] {
229            let domain = if input == "." { "." } else { "example.org." };
230            assert_eq!(
231                normalize_key(input).unwrap(),
232                [key(transport, domain, port)],
233                "{input}"
234            );
235        }
236    }
237
238    #[test]
239    fn numeric_ports_are_canonicalized_without_range_validation() {
240        for (input, domain, port) in [
241            ("Example.Org:0053", "example.org.", "53"),
242            ("example.org:+53", "example.org.", "53"),
243            ("example.org:-1", "example.org.", "-1"),
244            ("example.org:99999", "example.org.", "99999"),
245            ("example.org:http", "example.org:http.", "53"),
246            ("unknown://Example.org", "unknown://example.org.", "53"),
247            ("2001:db8::1", "2001:db8:.", "1"),
248        ] {
249            assert_eq!(
250                normalize_key(input).unwrap(),
251                [key("dns", domain, port)],
252                "{input}"
253            );
254        }
255    }
256
257    #[test]
258    fn expands_ipv4_networks_on_octet_boundaries() {
259        assert_eq!(
260            normalize_key("10.0.0.0/8:53").unwrap(),
261            [key("dns", "10.in-addr.arpa.", "53")]
262        );
263        assert_eq!(
264            normalize_key("tls://192.168.1.1/23:0054").unwrap(),
265            [
266                key("tls", "0.168.192.in-addr.arpa.", "54"),
267                key("tls", "1.168.192.in-addr.arpa.", "54"),
268            ]
269        );
270        let expanded = normalize_key("10.9.3.0/18").unwrap();
271        assert_eq!(expanded.len(), 64);
272        assert_eq!(expanded[0], key("dns", "0.9.10.in-addr.arpa.", "53"));
273        assert_eq!(expanded[63], key("dns", "63.9.10.in-addr.arpa.", "53"));
274        assert_eq!(
275            normalize_key("0.0.0.0/0").unwrap(),
276            [key("dns", "in-addr.arpa.", "53")]
277        );
278    }
279
280    #[test]
281    fn expands_ipv6_networks_on_nibble_boundaries() {
282        assert_eq!(
283            normalize_key("2001:db8::/31").unwrap(),
284            [
285                key("dns", "8.b.d.0.1.0.0.2.ip6.arpa.", "53"),
286                key("dns", "9.b.d.0.1.0.0.2.ip6.arpa.", "53"),
287            ]
288        );
289        let expanded = normalize_key("2001:db8::/29:853").unwrap();
290        assert_eq!(expanded.len(), 8);
291        assert_eq!(expanded[7], key("dns", "f.b.d.0.1.0.0.2.ip6.arpa.", "853"));
292        assert!(normalize_key("::1/128").is_err());
293        assert!(normalize_key("0:1::/32").is_err());
294        assert_eq!(
295            normalize_key("2001:db8::/0").unwrap(),
296            [key("dns", "ip6.arpa.", "53")]
297        );
298    }
299
300    #[test]
301    fn trailing_dot_and_invalid_cidr_remain_domains() {
302        for (input, domain) in [
303            ("10.0.0.0/8.", "10.0.0.0/8."),
304            ("2001:db8::/32.", "2001:db8::/32."),
305            ("10.0.0.0/99", "10.0.0.0/99."),
306            ("10.0.0/8", "10.0.0/8."),
307        ] {
308            assert_eq!(
309                normalize_key(input).unwrap(),
310                [key("dns", domain, "53")],
311                "{input}"
312            );
313        }
314    }
315
316    #[test]
317    fn validates_domain_names_like_coredns() {
318        for input in ["", "dns://", "example:", ".example", "a..b", "a\\", "ȶ\\."] {
319            assert!(normalize_key(input).is_err(), "{input:?}");
320        }
321        assert!(normalize_key(&format!("{}.example", "a".repeat(64))).is_err());
322        assert!(normalize_key(&"a".repeat(256)).is_err());
323        assert_eq!(
324            normalize_key("A\\.B.Example").unwrap(),
325            [key("dns", "a\\.b.example.", "53")]
326        );
327        assert_eq!(normalize_key("A\\.").unwrap(), [key("dns", "a\\..", "53")]);
328        assert_eq!(
329            normalize_key("İᲉ\u{10d50}\u{16ea0}").unwrap(),
330            [key("dns", "iᲉ\u{10d50}\u{16ea0}.", "53")]
331        );
332    }
333}