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 UnboundTransport,
16 EmptyHost,
18 HostTooLong,
20 InvalidHost,
23 InvalidPort,
25 InvalidBasePath,
27 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
43pub enum EndpointScheme {
44 Http,
46 Https,
48}
49
50#[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 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 #[must_use]
85 pub const fn scheme(self) -> EndpointScheme {
86 self.scheme
87 }
88
89 #[must_use]
91 pub const fn host(self) -> &'a str {
92 self.host
93 }
94
95 #[must_use]
97 pub const fn effective_port(self) -> u16 {
98 self.effective_port
99 }
100
101 #[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
120pub trait BoundTransport {
122 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}