cloud_sdk/transport/endpoint/
identity.rs1use core::cmp::Ordering;
4use core::fmt;
5use core::hash::{Hash, Hasher};
6use core::net::Ipv6Addr;
7use core::str::FromStr;
8
9pub const MAX_ENDPOINT_HOST_BYTES: usize = 253;
11
12pub const MAX_ENDPOINT_BASE_PATH_BYTES: usize = 1024;
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum EndpointIdentityError {
18 UnboundTransport,
20 EmptyHost,
22 HostTooLong,
24 InvalidHost,
26 InvalidPort,
28 InvalidBasePath,
30 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub enum EndpointScheme {
47 Http,
49 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#[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 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 #[must_use]
99 pub const fn scheme(self) -> EndpointScheme {
100 self.scheme
101 }
102
103 #[must_use]
105 pub const fn host(self) -> &'a str {
106 self.host
107 }
108
109 #[must_use]
111 pub(crate) const fn canonical_host(self) -> CanonicalHost<'a> {
112 self.canonical_host
113 }
114
115 #[must_use]
117 pub const fn effective_port(self) -> u16 {
118 self.effective_port
119 }
120
121 #[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
175pub trait BoundTransport {
177 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}