cloud_sdk_reqwest/shared/
endpoint.rs1use core::fmt;
2use reqwest::Url;
3use std::net::Ipv4Addr;
4use std::str::FromStr;
5use std::string::String;
6#[cfg(test)]
7use std::string::ToString;
8
9use cloud_sdk::transport::{
10 AcknowledgedCustomEndpoint, CustomEndpointAcknowledgement, EndpointIdentity,
11 EndpointIdentityError, EndpointPolicy, EndpointScheme, MAX_ENDPOINT_BASE_PATH_BYTES,
12 MAX_ENDPOINT_HOST_BYTES, RequestTarget,
13};
14
15pub const MAX_CONFIGURED_ENDPOINT_BYTES: usize =
20 "https://".len() + MAX_ENDPOINT_HOST_BYTES + 1 + 5 + MAX_ENDPOINT_BASE_PATH_BYTES;
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum EndpointError {
25 InputTooLong,
27 InvalidUrl,
29 HttpsRequired,
31 MissingHost,
33 CredentialsForbidden,
35 AmbiguousAuthority,
37 QueryForbidden,
39 FragmentForbidden,
41 TrailingSlash,
43 TargetNormalized,
45 AllocationFailed,
47 IdentityRejected,
49 PolicyRejected,
51}
52
53impl_static_error!(EndpointError,
54 Self::InputTooLong => "endpoint input exceeds the length limit",
55 Self::InvalidUrl => "endpoint URL is invalid",
56 Self::HttpsRequired => "endpoint must use HTTPS",
57 Self::MissingHost => "endpoint host is missing",
58 Self::CredentialsForbidden => "endpoint credentials are forbidden",
59 Self::AmbiguousAuthority => "endpoint authority is ambiguous or non-canonical",
60 Self::QueryForbidden => "endpoint query is forbidden",
61 Self::FragmentForbidden => "endpoint fragment is forbidden",
62 Self::TrailingSlash => "endpoint path has a forbidden trailing slash",
63 Self::TargetNormalized => "request target was normalized or changed origin",
64 Self::AllocationFailed => "request-target allocation failed",
65 Self::IdentityRejected => "endpoint identity is invalid",
66 Self::PolicyRejected => "endpoint is not admitted by provider policy",
67);
68
69#[derive(Clone)]
71pub struct HttpsEndpoint {
72 base: Url,
73 prefix: String,
74}
75
76#[derive(Clone)]
82pub struct LinkLocalHttpEndpoint {
83 inner: HttpsEndpoint,
84}
85
86impl fmt::Debug for LinkLocalHttpEndpoint {
87 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88 formatter.write_str("LinkLocalHttpEndpoint([redacted])")
89 }
90}
91
92impl LinkLocalHttpEndpoint {
93 pub fn new_with_policy(value: &str, policy: EndpointPolicy<'_>) -> Result<Self, EndpointError> {
95 let inner = HttpsEndpoint::new_inner(value, false)?;
96 let identity = inner
97 .identity()
98 .map_err(|_| EndpointError::IdentityRejected)?;
99 let address =
100 Ipv4Addr::from_str(identity.host()).map_err(|_| EndpointError::PolicyRejected)?;
101 if identity.scheme() != EndpointScheme::Http
102 || identity.effective_port() != 80
103 || !address.is_link_local()
104 {
105 return Err(EndpointError::PolicyRejected);
106 }
107 policy
108 .verify(identity)
109 .map_err(|_| EndpointError::PolicyRejected)?;
110 Ok(Self { inner })
111 }
112
113 pub fn identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError> {
115 self.inner.identity()
116 }
117
118 pub(crate) fn into_inner(self) -> HttpsEndpoint {
119 self.inner
120 }
121}
122
123impl fmt::Debug for HttpsEndpoint {
124 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125 formatter.write_str("HttpsEndpoint([redacted])")
126 }
127}
128
129impl HttpsEndpoint {
130 pub fn new_with_policy(value: &str, policy: EndpointPolicy<'_>) -> Result<Self, EndpointError> {
135 let endpoint = Self::new_inner(value, true)?;
136 policy
137 .verify(
138 endpoint
139 .identity()
140 .map_err(|_| EndpointError::IdentityRejected)?,
141 )
142 .map_err(|_| EndpointError::PolicyRejected)?;
143 Ok(endpoint)
144 }
145
146 pub fn new_custom(
158 value: &str,
159 acknowledgement: CustomEndpointAcknowledgement,
160 ) -> Result<Self, EndpointError> {
161 let endpoint = Self::new_inner(value, true)?;
162 let identity = endpoint
163 .identity()
164 .map_err(|_| EndpointError::IdentityRejected)?;
165 EndpointPolicy::acknowledged_custom(AcknowledgedCustomEndpoint::new(
166 identity,
167 acknowledgement,
168 ))
169 .verify(identity)
170 .map_err(|_| EndpointError::PolicyRejected)?;
171 Ok(endpoint)
172 }
173
174 fn new_inner(value: &str, require_https: bool) -> Result<Self, EndpointError> {
175 if value.len() > MAX_CONFIGURED_ENDPOINT_BYTES {
176 return Err(EndpointError::InputTooLong);
177 }
178 validate_raw_authority(value)?;
179 let configured_path = validate_configured_base_path(value)?;
180 let base = Url::parse(value).map_err(|_| EndpointError::InvalidUrl)?;
181 if base.path() != configured_path {
182 return Err(EndpointError::TargetNormalized);
183 }
184 if require_https && base.scheme() != "https" {
185 return Err(EndpointError::HttpsRequired);
186 }
187 if base.host_str().is_none() {
188 return Err(EndpointError::MissingHost);
189 }
190 if !base.username().is_empty() || base.password().is_some() {
191 return Err(EndpointError::CredentialsForbidden);
192 }
193 if base.query().is_some() {
194 return Err(EndpointError::QueryForbidden);
195 }
196 if base.fragment().is_some() {
197 return Err(EndpointError::FragmentForbidden);
198 }
199 if base.path() != "/" && base.path().ends_with('/') {
200 return Err(EndpointError::TrailingSlash);
201 }
202
203 let mut prefix = String::from(base.as_str());
204 if base.path() == "/" {
205 prefix.pop();
206 }
207 let endpoint = Self { base, prefix };
208 endpoint
209 .identity()
210 .map_err(|_| EndpointError::IdentityRejected)?;
211 Ok(endpoint)
212 }
213
214 pub fn identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError> {
216 let scheme = match self.base.scheme() {
217 "https" => EndpointScheme::Https,
218 "http" => EndpointScheme::Http,
219 _ => return Err(EndpointIdentityError::InvalidBasePath),
220 };
221 let host = self
222 .base
223 .host_str()
224 .ok_or(EndpointIdentityError::EmptyHost)?;
225 let port = self
226 .base
227 .port_or_known_default()
228 .ok_or(EndpointIdentityError::InvalidPort)?;
229 EndpointIdentity::new(scheme, host, port, self.base.path())
230 }
231
232 pub(crate) fn compose(&self, target: RequestTarget<'_>) -> Result<Url, EndpointError> {
233 let mut absolute = self.prefix.clone();
234 absolute
235 .try_reserve_exact(target.as_str().len())
236 .map_err(|_| EndpointError::AllocationFailed)?;
237 absolute.push_str(target.as_str());
238 let url = Url::parse(&absolute).map_err(|_| EndpointError::InvalidUrl)?;
239 if url.as_str() != absolute {
240 return Err(EndpointError::TargetNormalized);
241 }
242 self.verify_origin(&url)?;
243 Ok(url)
244 }
245
246 pub(crate) fn verify_origin(&self, url: &Url) -> Result<(), EndpointError> {
247 if url.scheme() != self.base.scheme()
248 || url.host_str() != self.base.host_str()
249 || url.port_or_known_default() != self.base.port_or_known_default()
250 || !url.username().is_empty()
251 || url.password().is_some()
252 {
253 return Err(EndpointError::TargetNormalized);
254 }
255 Ok(())
256 }
257
258 #[cfg(test)]
259 pub(crate) fn local_http(value: &str) -> Result<Self, EndpointError> {
260 let endpoint = Self::new_inner(value, false)?;
261 if endpoint.base.scheme() != "http"
262 || !endpoint.base.host().is_some_and(|host| {
263 let text = host.to_string();
264 text.strip_prefix('[')
265 .and_then(|value| value.strip_suffix(']'))
266 .unwrap_or(&text)
267 .parse::<std::net::IpAddr>()
268 .is_ok_and(|ip| ip.is_loopback())
269 })
270 {
271 return Err(EndpointError::HttpsRequired);
272 }
273 Ok(endpoint)
274 }
275}
276
277fn validate_raw_authority(value: &str) -> Result<(), EndpointError> {
278 let (scheme, remainder) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
279 if scheme.is_empty()
280 || !scheme.is_ascii()
281 || scheme.bytes().any(|byte| byte.is_ascii_uppercase())
282 {
283 return Err(EndpointError::AmbiguousAuthority);
284 }
285 let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
286 let authority = remainder
287 .get(..authority_end)
288 .ok_or(EndpointError::AmbiguousAuthority)?;
289 if authority.is_empty() {
290 return Err(EndpointError::MissingHost);
291 }
292 if authority.contains('@') {
293 return Err(EndpointError::CredentialsForbidden);
294 }
295 if !authority.is_ascii() || authority.contains(['%', '\\']) {
296 return Err(EndpointError::AmbiguousAuthority);
297 }
298
299 let (host, port) = split_host_port(authority)?;
300 if host.bytes().any(|byte| byte.is_ascii_uppercase()) {
301 return Err(EndpointError::AmbiguousAuthority);
302 }
303 if let Some(port) = port
304 && (port.is_empty()
305 || !port.bytes().all(|byte| byte.is_ascii_digit())
306 || port.len() > 1 && port.starts_with('0')
307 || port
308 .parse::<u16>()
309 .ok()
310 .filter(|value| *value != 0)
311 .is_none())
312 {
313 return Err(EndpointError::AmbiguousAuthority);
314 }
315 EndpointIdentity::new(EndpointScheme::Https, host, 1, "/")
316 .map(|_| ())
317 .map_err(|_| EndpointError::AmbiguousAuthority)
318}
319
320fn split_host_port(authority: &str) -> Result<(&str, Option<&str>), EndpointError> {
321 if authority.starts_with('[') {
322 let close = authority
323 .find(']')
324 .ok_or(EndpointError::AmbiguousAuthority)?;
325 let host_end = close
326 .checked_add(1)
327 .ok_or(EndpointError::AmbiguousAuthority)?;
328 let host = authority
329 .get(..host_end)
330 .ok_or(EndpointError::AmbiguousAuthority)?;
331 let suffix = authority
332 .get(host_end..)
333 .ok_or(EndpointError::AmbiguousAuthority)?;
334 if suffix.is_empty() {
335 return Ok((host, None));
336 }
337 let port = suffix
338 .strip_prefix(':')
339 .ok_or(EndpointError::AmbiguousAuthority)?;
340 return Ok((host, Some(port)));
341 }
342 if authority.matches(':').count() > 1 {
343 return Err(EndpointError::AmbiguousAuthority);
344 }
345 Ok(match authority.rsplit_once(':') {
346 Some((host, port)) => (host, Some(port)),
347 None => (authority, None),
348 })
349}
350
351fn validate_configured_base_path(value: &str) -> Result<&str, EndpointError> {
352 let (_, authority_and_path) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
353 let suffix_start = authority_and_path
354 .find(['/', '?', '#'])
355 .unwrap_or(authority_and_path.len());
356 let suffix = authority_and_path
357 .get(suffix_start..)
358 .ok_or(EndpointError::IdentityRejected)?;
359 let path = if suffix.starts_with('/') {
360 let path_end = suffix.find(['?', '#']).unwrap_or(suffix.len());
361 suffix
362 .get(..path_end)
363 .ok_or(EndpointError::IdentityRejected)?
364 } else {
365 "/"
366 };
367 if path.len() > MAX_ENDPOINT_BASE_PATH_BYTES
368 || path.contains("//")
369 || path.split('/').any(|part| matches!(part, "." | ".."))
370 || !path
371 .bytes()
372 .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'%' | b'?' | b'#'))
373 {
374 return Err(EndpointError::IdentityRejected);
375 }
376 Ok(path)
377}
378
379#[cfg(test)]
380mod link_local_tests {
381 use cloud_sdk::transport::{EndpointIdentity, EndpointPolicy, EndpointScheme};
382
383 use super::{EndpointError, LinkLocalHttpEndpoint};
384
385 #[test]
386 fn link_local_http_requires_exact_provider_policy() {
387 let identity = EndpointIdentity::new(EndpointScheme::Http, "169.254.169.254", 80, "/")
388 .unwrap_or_else(|_| unreachable!("valid fixed endpoint"));
389 let policy = EndpointPolicy::fixed(identity);
390 let endpoint = LinkLocalHttpEndpoint::new_with_policy("http://169.254.169.254", policy);
391 let Ok(endpoint) = endpoint else {
392 unreachable!("fixed link-local endpoint was rejected");
393 };
394 assert_eq!(endpoint.identity(), Ok(identity));
395
396 for value in [
397 "https://169.254.169.254",
398 "http://10.0.0.1",
399 "http://169.254.169.253",
400 "http://169.254.169.254:81",
401 "http://169.254.169.254/2009-04-04/meta-data",
402 ] {
403 assert!(matches!(
404 LinkLocalHttpEndpoint::new_with_policy(value, policy),
405 Err(EndpointError::PolicyRejected)
406 ));
407 }
408 }
409}