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)]
54enum 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 const fn effective_port(self) -> u16 {
112 self.effective_port
113 }
114
115 #[must_use]
117 pub const fn base_path(self) -> &'a str {
118 self.base_path
119 }
120
121 fn comparison_key(self) -> (EndpointScheme, CanonicalHost<'a>, u16, &'a str) {
122 (
123 self.scheme,
124 self.canonical_host,
125 self.effective_port,
126 self.base_path,
127 )
128 }
129}
130
131impl PartialEq for EndpointIdentity<'_> {
132 fn eq(&self, other: &Self) -> bool {
133 self.comparison_key() == other.comparison_key()
134 }
135}
136
137impl Eq for EndpointIdentity<'_> {}
138
139impl PartialOrd for EndpointIdentity<'_> {
140 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
141 Some(self.cmp(other))
142 }
143}
144
145impl Ord for EndpointIdentity<'_> {
146 fn cmp(&self, other: &Self) -> Ordering {
147 self.comparison_key().cmp(&other.comparison_key())
148 }
149}
150
151impl Hash for EndpointIdentity<'_> {
152 fn hash<H: Hasher>(&self, state: &mut H) {
153 self.comparison_key().hash(state);
154 }
155}
156
157impl fmt::Debug for EndpointIdentity<'_> {
158 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
159 formatter
160 .debug_struct("EndpointIdentity")
161 .field("scheme", &self.scheme)
162 .field("host", &"[redacted]")
163 .field("effective_port", &self.effective_port)
164 .field("base_path", &"[redacted]")
165 .finish()
166 }
167}
168
169pub trait BoundTransport {
171 fn endpoint_identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError>;
173}
174
175fn validate_host(host: &str) -> Result<CanonicalHost<'_>, EndpointIdentityError> {
176 if host.is_empty() {
177 return Err(EndpointIdentityError::EmptyHost);
178 }
179 if host.len() > MAX_ENDPOINT_HOST_BYTES {
180 return Err(EndpointIdentityError::HostTooLong);
181 }
182 if !host.is_ascii() || host.contains(['%', '@', '/', '\\', '?', '#']) {
183 return Err(EndpointIdentityError::InvalidHost);
184 }
185 if host.starts_with('[') || host.ends_with(']') {
186 return parse_ipv6(host);
187 }
188 if host.contains(':') {
189 return Err(EndpointIdentityError::InvalidHost);
190 }
191 if host
192 .bytes()
193 .all(|byte| byte.is_ascii_digit() || byte == b'.')
194 {
195 return parse_ipv4(host);
196 }
197 validate_dns(host)?;
198 Ok(CanonicalHost::Dns(host))
199}
200
201fn parse_ipv6(host: &str) -> Result<CanonicalHost<'_>, EndpointIdentityError> {
202 let address = host
203 .strip_prefix('[')
204 .and_then(|value| value.strip_suffix(']'))
205 .ok_or(EndpointIdentityError::InvalidHost)?;
206 if address.is_empty() || address.contains('%') {
207 return Err(EndpointIdentityError::InvalidHost);
208 }
209 Ipv6Addr::from_str(address)
210 .map(|value| CanonicalHost::Ipv6(value.octets()))
211 .map_err(|_| EndpointIdentityError::InvalidHost)
212}
213
214fn parse_ipv4(host: &str) -> Result<CanonicalHost<'_>, EndpointIdentityError> {
215 let mut octets = [0_u8; 4];
216 let mut count = 0_usize;
217 for part in host.split('.') {
218 if count >= octets.len() || part.is_empty() || part.len() > 1 && part.starts_with('0') {
219 return Err(EndpointIdentityError::InvalidHost);
220 }
221 let value = part
222 .parse::<u8>()
223 .map_err(|_| EndpointIdentityError::InvalidHost)?;
224 let slot = octets
225 .get_mut(count)
226 .ok_or(EndpointIdentityError::InvalidHost)?;
227 *slot = value;
228 count = count
229 .checked_add(1)
230 .ok_or(EndpointIdentityError::InvalidHost)?;
231 }
232 if count != octets.len() {
233 return Err(EndpointIdentityError::InvalidHost);
234 }
235 Ok(CanonicalHost::Ipv4(octets))
236}
237
238fn validate_dns(host: &str) -> Result<(), EndpointIdentityError> {
239 if host.ends_with('.') {
240 return Err(EndpointIdentityError::InvalidHost);
241 }
242 for label in host.split('.') {
243 let reserved_hyphens = label
244 .as_bytes()
245 .get(2..4)
246 .is_some_and(|bytes| bytes == b"--");
247 if label.is_empty()
248 || label.len() > 63
249 || label.starts_with('-')
250 || label.ends_with('-')
251 || reserved_hyphens && !label.starts_with("xn--")
252 || label == "xn--"
253 || !label
254 .bytes()
255 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
256 {
257 return Err(EndpointIdentityError::InvalidHost);
258 }
259 }
260 Ok(())
261}
262
263fn validate_base_path(base_path: &str) -> Result<(), EndpointIdentityError> {
264 if base_path.len() > MAX_ENDPOINT_BASE_PATH_BYTES {
265 return Err(EndpointIdentityError::BasePathTooLong);
266 }
267 if !base_path.starts_with('/')
268 || (base_path != "/" && base_path.ends_with('/'))
269 || base_path.contains("//")
270 || base_path.split('/').any(|part| matches!(part, "." | ".."))
271 || !base_path
272 .bytes()
273 .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'?' | b'#' | b'%'))
274 {
275 return Err(EndpointIdentityError::InvalidBasePath);
276 }
277 Ok(())
278}
279
280#[cfg(test)]
281mod tests {
282 use super::{EndpointIdentity, EndpointIdentityError, EndpointScheme};
283
284 #[test]
285 fn identity_requires_complete_canonical_components() {
286 let official =
287 EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, "/v1");
288 assert!(official.is_ok());
289 if let Ok(official) = official {
290 assert_eq!(official.host(), "api.hetzner.cloud");
291 assert_eq!(official.effective_port(), 443);
292 }
293 for host in [
294 "API.Hetzner.Cloud",
295 "api.hetzner.cloud.",
296 "api..example",
297 "-api.example",
298 "api_.example",
299 "t\u{00e4}st.example",
300 "api%2eexample",
301 "user@api.example",
302 "127.1",
303 "127.00.0.1",
304 "::1",
305 "[fe80::1%25eth0]",
306 ] {
307 assert_eq!(
308 EndpointIdentity::new(EndpointScheme::Https, host, 443, "/v1"),
309 Err(EndpointIdentityError::InvalidHost),
310 "{host}"
311 );
312 }
313 assert!(
314 EndpointIdentity::new(EndpointScheme::Https, "xn--tst-qla.example", 443, "/v1").is_ok()
315 );
316 assert!(EndpointIdentity::new(EndpointScheme::Https, "127.0.0.1", 443, "/v1").is_ok());
317 for host in ["127.0.0.1.2", "256.0.0.1"] {
318 assert_eq!(
319 EndpointIdentity::new(EndpointScheme::Https, host, 443, "/v1"),
320 Err(EndpointIdentityError::InvalidHost),
321 "{host}"
322 );
323 }
324 }
325
326 #[test]
327 fn bracketed_ipv6_comparison_uses_canonical_address_bits() {
328 let compressed = EndpointIdentity::new(EndpointScheme::Https, "[2001:db8::1]", 443, "/v1");
329 let expanded =
330 EndpointIdentity::new(EndpointScheme::Https, "[2001:0db8:0:0:0:0:0:1]", 443, "/v1");
331 assert!(compressed.is_ok() && expanded.is_ok());
332 if let (Ok(compressed), Ok(expanded)) = (compressed, expanded) {
333 assert_eq!(compressed, expanded);
334 }
335 }
336
337 #[test]
338 fn comparison_detects_every_destination_change() {
339 let official =
340 EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, "/v1");
341 let Ok(official) = official else { return };
342 for candidate in [
343 EndpointIdentity::new(EndpointScheme::Http, "api.hetzner.cloud", 443, "/v1"),
344 EndpointIdentity::new(EndpointScheme::Https, "evil.example", 443, "/v1"),
345 EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 8443, "/v1"),
346 EndpointIdentity::new(EndpointScheme::Https, "api.hetzner.cloud", 443, "/v2"),
347 ] {
348 assert!(candidate.is_ok());
349 if let Ok(candidate) = candidate {
350 assert_ne!(candidate, official);
351 }
352 }
353 }
354}