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