Skip to main content

cloud_sdk/transport/endpoint/
identity.rs

1//! Canonical endpoint identity components.
2
3use core::cmp::Ordering;
4use core::fmt;
5use core::hash::{Hash, Hasher};
6use core::net::Ipv6Addr;
7use core::str::FromStr;
8
9/// Maximum normalized endpoint host length admitted by the core contract.
10pub const MAX_ENDPOINT_HOST_BYTES: usize = 253;
11
12/// Maximum normalized endpoint base-path length admitted by the core contract.
13pub const MAX_ENDPOINT_BASE_PATH_BYTES: usize = 1024;
14
15/// Endpoint identity validation error.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum EndpointIdentityError {
18    /// The transport has not been bound to an endpoint identity.
19    UnboundTransport,
20    /// Endpoint hosts must not be empty.
21    EmptyHost,
22    /// Endpoint hosts exceed [`MAX_ENDPOINT_HOST_BYTES`].
23    HostTooLong,
24    /// The host is not canonical lowercase ASCII DNS, IPv4, or bracketed IPv6.
25    InvalidHost,
26    /// Effective endpoint ports must be nonzero.
27    InvalidPort,
28    /// Endpoint base paths must use normalized absolute-path form.
29    InvalidBasePath,
30    /// Endpoint base paths exceed [`MAX_ENDPOINT_BASE_PATH_BYTES`].
31    BasePathTooLong,
32}
33
34impl_static_error!(EndpointIdentityError,
35    Self::UnboundTransport => "transport endpoint identity is unbound",
36    Self::EmptyHost => "endpoint identity host is empty",
37    Self::HostTooLong => "endpoint identity host exceeds the length limit",
38    Self::InvalidHost => "endpoint identity host is not canonical",
39    Self::InvalidPort => "endpoint identity port is invalid",
40    Self::InvalidBasePath => "endpoint identity base path is not normalized",
41    Self::BasePathTooLong => "endpoint identity base path exceeds the length limit",
42);
43
44/// Network scheme in an immutable endpoint identity.
45#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub enum EndpointScheme {
47    /// Plain HTTP, intended only for explicitly admitted local test transports.
48    Http,
49    /// HTTPS.
50    Https,
51}
52
53#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub(crate) enum CanonicalHost<'a> {
55    Dns(&'a str),
56    Ipv4([u8; 4]),
57    Ipv6([u8; 16]),
58}
59
60/// Borrowed normalized identity of a credential-bound transport endpoint.
61///
62/// DNS input must already be canonical lowercase ASCII. Internationalized
63/// names must use their lowercase ASCII A-label form. IPv6 input must be
64/// bracketed; comparison uses its parsed 128-bit address, so equivalent text
65/// forms have one identity. Zone identifiers are never admitted.
66#[derive(Clone, Copy)]
67pub struct EndpointIdentity<'a> {
68    scheme: EndpointScheme,
69    host: &'a str,
70    canonical_host: CanonicalHost<'a>,
71    effective_port: u16,
72    base_path: &'a str,
73}
74
75impl<'a> EndpointIdentity<'a> {
76    /// Creates an identity from normalized endpoint components.
77    pub fn new(
78        scheme: EndpointScheme,
79        host: &'a str,
80        effective_port: u16,
81        base_path: &'a str,
82    ) -> Result<Self, EndpointIdentityError> {
83        let canonical_host = validate_host(host)?;
84        if effective_port == 0 {
85            return Err(EndpointIdentityError::InvalidPort);
86        }
87        validate_base_path(base_path)?;
88        Ok(Self {
89            scheme,
90            host,
91            canonical_host,
92            effective_port,
93            base_path,
94        })
95    }
96
97    /// Returns the normalized network scheme.
98    #[must_use]
99    pub const fn scheme(self) -> EndpointScheme {
100        self.scheme
101    }
102
103    /// Returns the admitted host text without credentials or a port.
104    #[must_use]
105    pub const fn host(self) -> &'a str {
106        self.host
107    }
108
109    /// Returns the parsed identity used by equality, hashing, and signing.
110    #[must_use]
111    pub(crate) const fn canonical_host(self) -> CanonicalHost<'a> {
112        self.canonical_host
113    }
114
115    /// Returns the effective port, including the scheme default when omitted.
116    #[must_use]
117    pub const fn effective_port(self) -> u16 {
118        self.effective_port
119    }
120
121    /// Returns the normalized absolute base path.
122    #[must_use]
123    pub const fn base_path(self) -> &'a str {
124        self.base_path
125    }
126
127    fn comparison_key(self) -> (EndpointScheme, CanonicalHost<'a>, u16, &'a str) {
128        (
129            self.scheme,
130            self.canonical_host,
131            self.effective_port,
132            self.base_path,
133        )
134    }
135}
136
137impl PartialEq for EndpointIdentity<'_> {
138    fn eq(&self, other: &Self) -> bool {
139        self.comparison_key() == other.comparison_key()
140    }
141}
142
143impl Eq for EndpointIdentity<'_> {}
144
145impl PartialOrd for EndpointIdentity<'_> {
146    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
147        Some(self.cmp(other))
148    }
149}
150
151impl Ord for EndpointIdentity<'_> {
152    fn cmp(&self, other: &Self) -> Ordering {
153        self.comparison_key().cmp(&other.comparison_key())
154    }
155}
156
157impl Hash for EndpointIdentity<'_> {
158    fn hash<H: Hasher>(&self, state: &mut H) {
159        self.comparison_key().hash(state);
160    }
161}
162
163impl fmt::Debug for EndpointIdentity<'_> {
164    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
165        formatter
166            .debug_struct("EndpointIdentity")
167            .field("scheme", &self.scheme)
168            .field("host", &"[redacted]")
169            .field("effective_port", &self.effective_port)
170            .field("base_path", &"[redacted]")
171            .finish()
172    }
173}
174
175/// Transport whose credentials are permanently bound to one endpoint.
176pub trait BoundTransport {
177    /// Returns the immutable normalized endpoint identity.
178    fn endpoint_identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError>;
179}
180
181fn validate_host(host: &str) -> Result<CanonicalHost<'_>, EndpointIdentityError> {
182    if host.is_empty() {
183        return Err(EndpointIdentityError::EmptyHost);
184    }
185    if host.len() > MAX_ENDPOINT_HOST_BYTES {
186        return Err(EndpointIdentityError::HostTooLong);
187    }
188    if !host.is_ascii() || host.contains(['%', '@', '/', '\\', '?', '#']) {
189        return Err(EndpointIdentityError::InvalidHost);
190    }
191    if host.starts_with('[') || host.ends_with(']') {
192        return parse_ipv6(host);
193    }
194    if host.contains(':') {
195        return Err(EndpointIdentityError::InvalidHost);
196    }
197    if host
198        .bytes()
199        .all(|byte| byte.is_ascii_digit() || byte == b'.')
200    {
201        return parse_ipv4(host);
202    }
203    validate_dns(host)?;
204    Ok(CanonicalHost::Dns(host))
205}
206
207fn parse_ipv6(host: &str) -> Result<CanonicalHost<'_>, EndpointIdentityError> {
208    let address = host
209        .strip_prefix('[')
210        .and_then(|value| value.strip_suffix(']'))
211        .ok_or(EndpointIdentityError::InvalidHost)?;
212    if address.is_empty() || address.contains('%') {
213        return Err(EndpointIdentityError::InvalidHost);
214    }
215    Ipv6Addr::from_str(address)
216        .map(|value| CanonicalHost::Ipv6(value.octets()))
217        .map_err(|_| EndpointIdentityError::InvalidHost)
218}
219
220fn parse_ipv4(host: &str) -> Result<CanonicalHost<'_>, EndpointIdentityError> {
221    let mut octets = [0_u8; 4];
222    let mut count = 0_usize;
223    for part in host.split('.') {
224        if count >= octets.len() || part.is_empty() || part.len() > 1 && part.starts_with('0') {
225            return Err(EndpointIdentityError::InvalidHost);
226        }
227        let value = part
228            .parse::<u8>()
229            .map_err(|_| EndpointIdentityError::InvalidHost)?;
230        let slot = octets
231            .get_mut(count)
232            .ok_or(EndpointIdentityError::InvalidHost)?;
233        *slot = value;
234        count = count
235            .checked_add(1)
236            .ok_or(EndpointIdentityError::InvalidHost)?;
237    }
238    if count != octets.len() {
239        return Err(EndpointIdentityError::InvalidHost);
240    }
241    Ok(CanonicalHost::Ipv4(octets))
242}
243
244fn validate_dns(host: &str) -> Result<(), EndpointIdentityError> {
245    if host.ends_with('.') {
246        return Err(EndpointIdentityError::InvalidHost);
247    }
248    for label in host.split('.') {
249        let reserved_hyphens = label
250            .as_bytes()
251            .get(2..4)
252            .is_some_and(|bytes| bytes == b"--");
253        if label.is_empty()
254            || label.len() > 63
255            || label.starts_with('-')
256            || label.ends_with('-')
257            || reserved_hyphens && !label.starts_with("xn--")
258            || label == "xn--"
259            || !label
260                .bytes()
261                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
262        {
263            return Err(EndpointIdentityError::InvalidHost);
264        }
265    }
266    Ok(())
267}
268
269fn validate_base_path(base_path: &str) -> Result<(), EndpointIdentityError> {
270    if base_path.len() > MAX_ENDPOINT_BASE_PATH_BYTES {
271        return Err(EndpointIdentityError::BasePathTooLong);
272    }
273    if !base_path.starts_with('/')
274        || (base_path != "/" && base_path.ends_with('/'))
275        || base_path.contains("//")
276        || base_path.split('/').any(|part| matches!(part, "." | ".."))
277        || !base_path
278            .bytes()
279            .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'?' | b'#' | b'%'))
280    {
281        return Err(EndpointIdentityError::InvalidBasePath);
282    }
283    Ok(())
284}
285
286#[cfg(test)]
287mod tests {
288    use super::{EndpointIdentity, EndpointIdentityError, EndpointScheme};
289
290    #[test]
291    fn identity_requires_complete_canonical_components() {
292        let official =
293            EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, "/v1");
294        assert!(official.is_ok());
295        if let Ok(official) = official {
296            assert_eq!(official.host(), "api.hetzner.cloud");
297            assert_eq!(official.effective_port(), 443);
298        }
299        for host in [
300            "API.Hetzner.Cloud",
301            "api.hetzner.cloud.",
302            "api..example",
303            "-api.example",
304            "api_.example",
305            "t\u{00e4}st.example",
306            "api%2eexample",
307            "user@api.example",
308            "127.1",
309            "127.00.0.1",
310            "::1",
311            "[fe80::1%25eth0]",
312        ] {
313            assert_eq!(
314                EndpointIdentity::new(EndpointScheme::Https, host, 443, "/v1"),
315                Err(EndpointIdentityError::InvalidHost),
316                "{host}"
317            );
318        }
319        assert!(
320            EndpointIdentity::new(EndpointScheme::Https, "xn--tst-qla.example", 443, "/v1").is_ok()
321        );
322        assert!(EndpointIdentity::new(EndpointScheme::Https, "127.0.0.1", 443, "/v1").is_ok());
323        for host in ["127.0.0.1.2", "256.0.0.1"] {
324            assert_eq!(
325                EndpointIdentity::new(EndpointScheme::Https, host, 443, "/v1"),
326                Err(EndpointIdentityError::InvalidHost),
327                "{host}"
328            );
329        }
330    }
331
332    #[test]
333    fn bracketed_ipv6_comparison_uses_canonical_address_bits() {
334        let compressed = EndpointIdentity::new(EndpointScheme::Https, "[2001:db8::1]", 443, "/v1");
335        let expanded =
336            EndpointIdentity::new(EndpointScheme::Https, "[2001:0db8:0:0:0:0:0:1]", 443, "/v1");
337        assert!(compressed.is_ok() && expanded.is_ok());
338        if let (Ok(compressed), Ok(expanded)) = (compressed, expanded) {
339            assert_eq!(compressed, expanded);
340        }
341    }
342
343    #[test]
344    fn comparison_detects_every_destination_change() {
345        let official =
346            EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, "/v1");
347        let Ok(official) = official else { return };
348        for candidate in [
349            EndpointIdentity::new(EndpointScheme::Http, "api.hetzner.cloud", 443, "/v1"),
350            EndpointIdentity::new(EndpointScheme::Https, "evil.example", 443, "/v1"),
351            EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 8443, "/v1"),
352            EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, "/v2"),
353        ] {
354            assert!(candidate.is_ok());
355            if let Ok(candidate) = candidate {
356                assert_ne!(candidate, official);
357            }
358        }
359    }
360}