hclient_core/host.rs
1//! Where a URI's authority stops being URI syntax.
2
3/// The host a URI names, with an IPv6 literal's brackets removed.
4///
5/// `[2001:db8::1]` becomes `2001:db8::1`; every other host — a name, an
6/// IPv4 literal, an already-bare v6 address — comes back untouched.
7///
8/// # Why this exists at all, and why in this crate
9///
10/// `http::Uri::host()` returns an IPv6 literal **with its brackets**,
11/// because that is what the URI says: RFC 3986 §3.2.2 puts `IP-literal =
12/// "[" ( IPv6address / IPvFuture ) "]"` in the *authority*'s grammar, not
13/// in the host's. Nothing outside a URI wants them, and everything outside
14/// a URI is where this workspace kept meeting the same failure:
15///
16/// * `str::parse::<IpAddr>()` rejects `[::1]`, so a resolver's literal
17/// shortcut falls through and asks DNS about a string no zone contains.
18/// * `rustls_pki_types::ServerName::try_from` rejects `[::1]` as **both** a
19/// DNS name and an address, so a TLS or QUIC handshake fails with
20/// `invalid dns name` before a byte of the exchange happens.
21///
22/// The duty is the **caller's**, not the backend's — see
23/// `hclient_tls::TlsRequest::server_name`, whose doc says so at the seam
24/// where it matters. A backend that stripped defensively would be the
25/// second place normalising, and two places normalising is how they drift;
26/// worse, it would have to guess, since a backend cannot tell a host that
27/// came from a URI from one a caller built by hand.
28///
29/// This crate is the home because it is the only one every consumer
30/// already has. `hclient-native` and `hclient-h3` both hold a `Uri` and
31/// both feed a TLS seam; `hclient-dns` and `hclient-dns-doh` both parse
32/// literals; `hclient-tls`, whose doc has to name the duty, depends on
33/// this crate and not on any of them. Putting it in `hclient-dns` would
34/// make a TLS server name reach through a resolver crate for a fact about
35/// URI syntax, and putting it in `hclient-tls` would do the mirror image
36/// to a resolver.
37///
38/// # What it does not do
39///
40/// It is not a validator and not a parser. `[` alone is not a bracketed
41/// host and comes back as `[`; `[]` is a bracketed *empty* host and comes
42/// back as the empty string, which every consumer downstream then refuses
43/// — `ServerName::try_from("")` and `"".parse::<IpAddr>()` both fail —
44/// rather than being quietly patched up here. Percent-encoding, ports and
45/// userinfo are `http::Uri`'s business and have already been removed by
46/// the time `Uri::host()` has answered.
47///
48/// # What must NOT be stripped
49///
50/// The `Host` header and HTTP/2's `:authority` are authority syntax, so
51/// they keep their brackets (RFC 9110 §7.2 — `Host = uri-host [ ":" port
52/// ]`). Only the step out of URI-land takes them off.
53#[must_use]
54pub fn bare_host(host: &str) -> &str {
55 // Both ends, or neither: `strip_prefix` alone would turn the malformed
56 // `[::1` into `::1` and hand a plausible-looking address to a caller
57 // that was given a broken URI.
58 host.strip_prefix('[')
59 .and_then(|inner| inner.strip_suffix(']'))
60 .unwrap_or(host)
61}
62
63#[cfg(test)]
64mod tests {
65 use super::bare_host;
66
67 /// The case the function exists for, and the four ways of getting it
68 /// wrong that a mutation reaches: a strip that fires on every host, a
69 /// strip that takes only the prefix, one that trims repeatedly, and one
70 /// that slices without checking.
71 #[test]
72 fn the_brackets_come_off_a_bracketed_host_and_nothing_else() {
73 assert_eq!(bare_host("[2001:db8::1]"), "2001:db8::1");
74 assert_eq!(bare_host("[::1]"), "::1");
75
76 // Not bracketed: untouched, character for character. `example.com`
77 // becoming `xample.co` is the mutation this row is here for.
78 assert_eq!(bare_host("example.com"), "example.com");
79 assert_eq!(bare_host("127.0.0.1"), "127.0.0.1");
80 assert_eq!(bare_host("::1"), "::1");
81 assert_eq!(bare_host(""), "");
82
83 // One pair, not as many as there are. A host is bracketed once.
84 assert_eq!(bare_host("[[::1]]"), "[::1]");
85 }
86
87 /// Half a bracket is not a bracketed host. Both of these are what a
88 /// slice-without-checking implementation panics on, and what a
89 /// `trim_matches` one silently empties.
90 #[test]
91 fn a_lone_bracket_is_a_host_like_any_other() {
92 assert_eq!(bare_host("["), "[");
93 assert_eq!(bare_host("]"), "]");
94 assert_eq!(bare_host("[::1"), "[::1");
95 assert_eq!(bare_host("::1]"), "::1]");
96 }
97
98 /// A bracketed empty host is an empty host, and stays empty rather
99 /// than being turned back into `[]` to look harmless. Whoever receives
100 /// it refuses it: neither `ServerName::try_from` nor
101 /// `str::parse::<IpAddr>` accepts `""`.
102 #[test]
103 fn an_empty_bracketed_host_is_empty() {
104 assert_eq!(bare_host("[]"), "");
105 assert!("".parse::<std::net::IpAddr>().is_err());
106 }
107}