Skip to main content

aimdb_core/session/
endpoint.rs

1//! `host:port` endpoint grammar, shared by every transport that speaks it.
2//!
3//! It lives here rather than in a connector because more than one crate needs
4//! it and they do not all depend on each other: `aimdb-client` resolves
5//! `scheme://` URLs whether or not the matching connector is compiled in.
6//! One grammar, one implementation, one set of tests.
7//!
8//! Callers layer their own policy on top: [`split_host_port_opt`] reports
9//! whether a port was written at all, so one caller can default it while
10//! another rejects an endpoint that omits it.
11
12use alloc::string::{String, ToString};
13
14/// Why an endpoint is not a `host:port`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EndpointError {
17    /// The endpoint names no host.
18    EmptyHost,
19    /// A bracketed IPv6 literal with no closing `]`.
20    UnclosedBracket,
21    /// A port was written, and is not one.
22    BadPort,
23}
24
25impl core::fmt::Display for EndpointError {
26    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
27        f.write_str(match self {
28            Self::EmptyHost => "endpoint names no host",
29            Self::UnclosedBracket => "missing closing bracket for IPv6 host",
30            Self::BadPort => "port is not a number in 0..=65535",
31        })
32    }
33}
34
35/// Split a `host:port` endpoint, leaving the port `None` when none was written.
36///
37/// A bracketed IPv6 literal carries colons of its own, so only a colon *after*
38/// the closing bracket separates the port; the brackets are stripped, because
39/// that is the form the adapters resolve. An **unbracketed** IPv6 literal
40/// cannot carry a port at all — brackets are what add one — so every colon in
41/// it belongs to the address.
42///
43/// A port that is written but is not one is an error, never a silent fallback:
44/// dialing a different service is worse than not dialing.
45pub fn split_host_port_opt(endpoint: &str) -> Result<(String, Option<u16>), EndpointError> {
46    if let Some(rest) = endpoint.strip_prefix('[') {
47        let Some((host, tail)) = rest.split_once(']') else {
48            return Err(EndpointError::UnclosedBracket);
49        };
50        if host.is_empty() {
51            return Err(EndpointError::EmptyHost);
52        }
53        let port = if tail.is_empty() {
54            None
55        } else {
56            // Anything but `:port` after `]` is junk, including junk with a port
57            // behind it — `[::1]oops:7003` names no reachable service.
58            Some(
59                tail.strip_prefix(':')
60                    .ok_or(EndpointError::BadPort)?
61                    .parse()
62                    .map_err(|_| EndpointError::BadPort)?,
63            )
64        };
65        return Ok((host.to_string(), port));
66    }
67    match endpoint.rsplit_once(':') {
68        // Another colon before the last one: an unbracketed IPv6 literal, whose
69        // trailing group is part of the address rather than a port.
70        Some((head, _)) if head.contains(':') => Ok((endpoint.to_string(), None)),
71        Some(("", _)) => Err(EndpointError::EmptyHost),
72        Some((host, port)) => Ok((
73            host.to_string(),
74            Some(port.parse().map_err(|_| EndpointError::BadPort)?),
75        )),
76        None if endpoint.is_empty() => Err(EndpointError::EmptyHost),
77        None => Ok((endpoint.to_string(), None)),
78    }
79}
80
81/// As [`split_host_port_opt`], substituting `default_port` when the endpoint
82/// names only a host.
83pub fn split_host_port(endpoint: &str, default_port: u16) -> Result<(String, u16), EndpointError> {
84    let (host, port) = split_host_port_opt(endpoint)?;
85    Ok((host, port.unwrap_or(default_port)))
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    const DEFAULT: u16 = 7001;
93
94    fn split(endpoint: &str) -> Result<(String, u16), EndpointError> {
95        split_host_port(endpoint, DEFAULT)
96    }
97
98    #[test]
99    fn splits_host_and_port() {
100        assert_eq!(split("127.0.0.1:7002"), Ok(("127.0.0.1".into(), 7002)));
101    }
102
103    #[test]
104    fn a_bare_host_takes_the_default_port() {
105        assert_eq!(split("example.test"), Ok(("example.test".into(), DEFAULT)));
106        assert_eq!(
107            split_host_port_opt("example.test"),
108            Ok(("example.test".into(), None)),
109            "the caller can tell a defaulted port from a written one"
110        );
111    }
112
113    /// A written port that is not one names no service, so it is an error
114    /// rather than a silent fallback to the default — that would dial a
115    /// different, possibly live, server.
116    #[test]
117    fn an_unparsable_port_is_rejected() {
118        assert_eq!(split("host:not-a-port"), Err(EndpointError::BadPort));
119        assert_eq!(split("10.0.0.5:8080x"), Err(EndpointError::BadPort));
120        assert_eq!(split("host:99999"), Err(EndpointError::BadPort));
121        assert_eq!(split("host:"), Err(EndpointError::BadPort));
122    }
123
124    /// A bracketed IPv6 literal is full of colons; only the one after `]`
125    /// separates the port, and the brackets are not part of the address.
126    #[test]
127    fn brackets_are_stripped_from_an_ipv6_literal() {
128        assert_eq!(split("[::1]:7003"), Ok(("::1".into(), 7003)));
129    }
130
131    #[test]
132    fn a_bracketed_ipv6_host_without_a_port_is_not_mangled() {
133        assert_eq!(split("[::1]"), Ok(("::1".into(), DEFAULT)));
134    }
135
136    /// Brackets are what let an IPv6 literal carry a port, so without them
137    /// every colon belongs to the address and no port was written.
138    #[test]
139    fn an_unbracketed_ipv6_literal_keeps_all_its_colons() {
140        assert_eq!(split("::1"), Ok(("::1".into(), DEFAULT)));
141        assert_eq!(split("fe80::1"), Ok(("fe80::1".into(), DEFAULT)));
142        assert_eq!(
143            split("2001:db8::dead:beef"),
144            Ok(("2001:db8::dead:beef".into(), DEFAULT)),
145            "the trailing group is address, not a port"
146        );
147    }
148
149    #[test]
150    fn a_malformed_endpoint_is_rejected() {
151        assert_eq!(split(":99999"), Err(EndpointError::EmptyHost));
152        assert_eq!(split(""), Err(EndpointError::EmptyHost));
153        assert_eq!(split("[]:7001"), Err(EndpointError::EmptyHost));
154        assert_eq!(split("[::1"), Err(EndpointError::UnclosedBracket));
155        assert_eq!(
156            split("[::1]oops:7003"),
157            Err(EndpointError::BadPort),
158            "an explicit port behind junk is not silently honoured"
159        );
160    }
161}