Skip to main content

cloud_sdk/transport/
endpoint.rs

1//! Immutable endpoint identity for credential-bound transports.
2
3use core::fmt;
4
5/// Maximum normalized endpoint host length admitted by the core contract.
6pub const MAX_ENDPOINT_HOST_BYTES: usize = 253;
7
8/// Maximum normalized endpoint base-path length admitted by the core contract.
9pub const MAX_ENDPOINT_BASE_PATH_BYTES: usize = 1024;
10
11/// Endpoint identity validation error.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum EndpointIdentityError {
14    /// Endpoint hosts must not be empty.
15    EmptyHost,
16    /// Endpoint hosts exceed [`MAX_ENDPOINT_HOST_BYTES`].
17    HostTooLong,
18    /// Endpoint hosts must already be normalized, visible ASCII without URL
19    /// authority delimiters.
20    InvalidHost,
21    /// Effective endpoint ports must be nonzero.
22    InvalidPort,
23    /// Endpoint base paths must use normalized absolute-path form.
24    InvalidBasePath,
25    /// Endpoint base paths exceed [`MAX_ENDPOINT_BASE_PATH_BYTES`].
26    BasePathTooLong,
27}
28
29impl_static_error!(EndpointIdentityError,
30    Self::EmptyHost => "endpoint identity host is empty",
31    Self::HostTooLong => "endpoint identity host exceeds the length limit",
32    Self::InvalidHost => "endpoint identity host is not normalized",
33    Self::InvalidPort => "endpoint identity port is invalid",
34    Self::InvalidBasePath => "endpoint identity base path is not normalized",
35    Self::BasePathTooLong => "endpoint identity base path exceeds the length limit",
36);
37
38/// Network scheme in an immutable endpoint identity.
39#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
40pub enum EndpointScheme {
41    /// Plain HTTP, intended only for explicitly admitted local test transports.
42    Http,
43    /// HTTPS.
44    Https,
45}
46
47/// Borrowed normalized identity of a credential-bound transport endpoint.
48///
49/// The identity contains no credential material. Provider facades can compare
50/// all four fields with their official endpoint constants before execution.
51#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
52pub struct EndpointIdentity<'a> {
53    scheme: EndpointScheme,
54    host: &'a str,
55    effective_port: u16,
56    base_path: &'a str,
57}
58
59impl<'a> EndpointIdentity<'a> {
60    /// Creates an identity from already normalized endpoint components.
61    pub fn new(
62        scheme: EndpointScheme,
63        host: &'a str,
64        effective_port: u16,
65        base_path: &'a str,
66    ) -> Result<Self, EndpointIdentityError> {
67        validate_host(host)?;
68        if effective_port == 0 {
69            return Err(EndpointIdentityError::InvalidPort);
70        }
71        validate_base_path(base_path)?;
72        Ok(Self {
73            scheme,
74            host,
75            effective_port,
76            base_path,
77        })
78    }
79
80    /// Returns the normalized network scheme.
81    #[must_use]
82    pub const fn scheme(self) -> EndpointScheme {
83        self.scheme
84    }
85
86    /// Returns the normalized host without credentials or a port.
87    #[must_use]
88    pub const fn host(self) -> &'a str {
89        self.host
90    }
91
92    /// Returns the effective port, including the scheme default when omitted.
93    #[must_use]
94    pub const fn effective_port(self) -> u16 {
95        self.effective_port
96    }
97
98    /// Returns the normalized absolute base path.
99    #[must_use]
100    pub const fn base_path(self) -> &'a str {
101        self.base_path
102    }
103}
104
105impl fmt::Debug for EndpointIdentity<'_> {
106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107        formatter
108            .debug_struct("EndpointIdentity")
109            .field("scheme", &self.scheme)
110            .field("host", &"[redacted]")
111            .field("effective_port", &self.effective_port)
112            .field("base_path", &"[redacted]")
113            .finish()
114    }
115}
116
117/// Transport whose credentials are permanently bound to one endpoint.
118pub trait BoundTransport {
119    /// Returns the immutable normalized endpoint identity.
120    fn endpoint_identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError>;
121}
122
123fn validate_host(host: &str) -> Result<(), EndpointIdentityError> {
124    if host.is_empty() {
125        return Err(EndpointIdentityError::EmptyHost);
126    }
127    if host.len() > MAX_ENDPOINT_HOST_BYTES {
128        return Err(EndpointIdentityError::HostTooLong);
129    }
130    if !host.bytes().all(|byte| {
131        byte.is_ascii_graphic()
132            && !byte.is_ascii_uppercase()
133            && !matches!(byte, b'/' | b'\\' | b'@' | b'?' | b'#' | b'%')
134    }) {
135        return Err(EndpointIdentityError::InvalidHost);
136    }
137    Ok(())
138}
139
140fn validate_base_path(base_path: &str) -> Result<(), EndpointIdentityError> {
141    if base_path.len() > MAX_ENDPOINT_BASE_PATH_BYTES {
142        return Err(EndpointIdentityError::BasePathTooLong);
143    }
144    if !base_path.starts_with('/')
145        || (base_path != "/" && base_path.ends_with('/'))
146        || base_path.contains("//")
147        || base_path.split('/').any(|part| matches!(part, "." | ".."))
148        || !base_path
149            .bytes()
150            .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'?' | b'#' | b'%'))
151    {
152        return Err(EndpointIdentityError::InvalidBasePath);
153    }
154    Ok(())
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{EndpointIdentity, EndpointIdentityError, EndpointScheme};
160
161    #[test]
162    fn endpoint_identity_requires_normalized_complete_components() {
163        let official =
164            EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, "/v1");
165        assert!(official.is_ok());
166        if let Ok(official) = official {
167            assert_eq!(official.scheme(), EndpointScheme::Https);
168            assert_eq!(official.host(), "api.hetzner.cloud");
169            assert_eq!(official.effective_port(), 443);
170            assert_eq!(official.base_path(), "/v1");
171        }
172        assert_eq!(
173            EndpointIdentity::new(EndpointScheme::Https, "", 443, "/v1"),
174            Err(EndpointIdentityError::EmptyHost)
175        );
176        assert_eq!(
177            EndpointIdentity::new(EndpointScheme::Https, "API.Hetzner.Cloud", 443, "/v1"),
178            Err(EndpointIdentityError::InvalidHost)
179        );
180        assert_eq!(
181            EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 0, "/v1"),
182            Err(EndpointIdentityError::InvalidPort)
183        );
184        for path in ["v1", "/v1/", "/v1//admin", "/v1/../admin", "/v1?x=1"] {
185            assert_eq!(
186                EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, path),
187                Err(EndpointIdentityError::InvalidBasePath)
188            );
189        }
190    }
191
192    #[test]
193    fn endpoint_identity_comparison_detects_every_destination_change() {
194        let official =
195            EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, "/v1");
196        let Ok(official) = official else { return };
197        for candidate in [
198            EndpointIdentity::new(EndpointScheme::Http, "api.hetzner.cloud", 443, "/v1"),
199            EndpointIdentity::new(EndpointScheme::Https, "evil.example", 443, "/v1"),
200            EndpointIdentity::new(EndpointScheme::Https, "sub.api.hetzner.cloud", 443, "/v1"),
201            EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 8443, "/v1"),
202            EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, "/v2"),
203        ] {
204            assert!(candidate.is_ok());
205            if let Ok(candidate) = candidate {
206                assert_ne!(candidate, official);
207            }
208        }
209    }
210}