Skip to main content

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}