1use core::fmt;
4
5pub const MAX_ENDPOINT_HOST_BYTES: usize = 253;
7
8pub const MAX_ENDPOINT_BASE_PATH_BYTES: usize = 1024;
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum EndpointIdentityError {
14 EmptyHost,
16 HostTooLong,
18 InvalidHost,
21 InvalidPort,
23 InvalidBasePath,
25 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
40pub enum EndpointScheme {
41 Http,
43 Https,
45}
46
47#[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 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 #[must_use]
82 pub const fn scheme(self) -> EndpointScheme {
83 self.scheme
84 }
85
86 #[must_use]
88 pub const fn host(self) -> &'a str {
89 self.host
90 }
91
92 #[must_use]
94 pub const fn effective_port(self) -> u16 {
95 self.effective_port
96 }
97
98 #[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
117pub trait BoundTransport {
119 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}