gglib_core/access/host.rs
1use std::net::IpAddr;
2
3/// Whether `host` refers to the loopback interface.
4///
5/// Covers the literal `localhost` alongside any address that parses as an IP
6/// and is loopback, so `::1` and `127.0.0.2` are caught as well as `127.0.0.1`.
7///
8/// Accepts a bare host, with or without a port and with or without IPv6
9/// brackets — callers reach this both with a configured bind host and with a
10/// raw `Host` header, and the two are spelled differently.
11#[must_use]
12pub fn is_loopback_host(host: &str) -> bool {
13 let Some(host) = normalize_host(host) else {
14 return false;
15 };
16 host == "localhost" || host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
17}
18
19/// Whether `host` is a wildcard ("all interfaces") address.
20///
21/// True for both `0.0.0.0` and its IPv6 equivalent `::`, so the two are treated
22/// alike when deciding how to describe the bind and how to advertise it.
23#[must_use]
24pub fn is_wildcard_host(host: &str) -> bool {
25 let Some(host) = normalize_host(host) else {
26 return false;
27 };
28 host.parse::<IpAddr>().is_ok_and(|ip| ip.is_unspecified())
29}
30
31/// Reduce a host or authority to a bare, comparable host name.
32///
33/// Lowercases, strips a trailing `:port`, and unwraps IPv6 brackets, so the
34/// three spellings a single address arrives in — `[::1]:8080` from a browser,
35/// `::1` from a config file, `[::1]` from a URL — compare equal.
36///
37/// Returns `None` for anything that cannot be a host: an empty string, or a
38/// value carrying userinfo or a path. Those are rejected rather than
39/// sanitized, because a `Host` header containing them is malformed and the
40/// only safe reading of a malformed claim is no claim at all.
41#[must_use]
42pub fn normalize_host(host: &str) -> Option<String> {
43 let host = host.trim();
44 if host.is_empty() || host.contains('@') || host.contains('/') {
45 return None;
46 }
47
48 // Bracketed IPv6, with or without a port: `[::1]` / `[::1]:8080`.
49 let bare = if let Some(rest) = host.strip_prefix('[') {
50 let (inner, tail) = rest.split_once(']')?;
51 if !tail.is_empty() && !tail.starts_with(':') {
52 return None;
53 }
54 inner
55 } else {
56 // A single colon is a port separator. Several mean an unbracketed IPv6
57 // literal, which is malformed in a `Host` header but unambiguous as a
58 // configured value, so it is taken whole rather than truncated at the
59 // first colon.
60 match host.split_once(':') {
61 Some((h, port)) if !port.contains(':') => h,
62 _ => host,
63 }
64 };
65
66 if bare.is_empty() {
67 return None;
68 }
69 Some(bare.to_ascii_lowercase())
70}