cloud_sdk_reqwest/shared/
endpoint.rs1use core::fmt;
2use reqwest::Url;
3use std::string::String;
4#[cfg(test)]
5use std::string::ToString;
6
7use cloud_sdk::transport::{
8 AcknowledgedCustomEndpoint, CustomEndpointAcknowledgement, EndpointIdentity,
9 EndpointIdentityError, EndpointPolicy, EndpointScheme, MAX_ENDPOINT_BASE_PATH_BYTES,
10 MAX_ENDPOINT_HOST_BYTES, RequestTarget,
11};
12
13pub const MAX_CONFIGURED_ENDPOINT_BYTES: usize =
18 "https://".len() + MAX_ENDPOINT_HOST_BYTES + 1 + 5 + MAX_ENDPOINT_BASE_PATH_BYTES;
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum EndpointError {
23 InputTooLong,
25 InvalidUrl,
27 HttpsRequired,
29 MissingHost,
31 CredentialsForbidden,
33 AmbiguousAuthority,
35 QueryForbidden,
37 FragmentForbidden,
39 TrailingSlash,
41 InvalidTargetEncoding,
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::InvalidTargetEncoding => "request target encoding is invalid",
64 Self::TargetNormalized => "request target was normalized or changed origin",
65 Self::AllocationFailed => "request-target allocation failed",
66 Self::IdentityRejected => "endpoint identity is invalid",
67 Self::PolicyRejected => "endpoint is not admitted by provider policy",
68);
69
70#[derive(Clone)]
72pub struct HttpsEndpoint {
73 base: Url,
74 prefix: String,
75}
76
77impl fmt::Debug for HttpsEndpoint {
78 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79 formatter.write_str("HttpsEndpoint([redacted])")
80 }
81}
82
83impl HttpsEndpoint {
84 pub fn new_with_policy(value: &str, policy: EndpointPolicy<'_>) -> Result<Self, EndpointError> {
89 let endpoint = Self::new_inner(value, true)?;
90 policy
91 .verify(
92 endpoint
93 .identity()
94 .map_err(|_| EndpointError::IdentityRejected)?,
95 )
96 .map_err(|_| EndpointError::PolicyRejected)?;
97 Ok(endpoint)
98 }
99
100 pub fn new_custom(
112 value: &str,
113 acknowledgement: CustomEndpointAcknowledgement,
114 ) -> Result<Self, EndpointError> {
115 let endpoint = Self::new_inner(value, true)?;
116 let identity = endpoint
117 .identity()
118 .map_err(|_| EndpointError::IdentityRejected)?;
119 EndpointPolicy::acknowledged_custom(AcknowledgedCustomEndpoint::new(
120 identity,
121 acknowledgement,
122 ))
123 .verify(identity)
124 .map_err(|_| EndpointError::PolicyRejected)?;
125 Ok(endpoint)
126 }
127
128 fn new_inner(value: &str, require_https: bool) -> Result<Self, EndpointError> {
129 if value.len() > MAX_CONFIGURED_ENDPOINT_BYTES {
130 return Err(EndpointError::InputTooLong);
131 }
132 validate_raw_authority(value)?;
133 let configured_path = validate_configured_base_path(value)?;
134 let base = Url::parse(value).map_err(|_| EndpointError::InvalidUrl)?;
135 if base.path() != configured_path {
136 return Err(EndpointError::TargetNormalized);
137 }
138 if require_https && base.scheme() != "https" {
139 return Err(EndpointError::HttpsRequired);
140 }
141 if base.host_str().is_none() {
142 return Err(EndpointError::MissingHost);
143 }
144 if !base.username().is_empty() || base.password().is_some() {
145 return Err(EndpointError::CredentialsForbidden);
146 }
147 if base.query().is_some() {
148 return Err(EndpointError::QueryForbidden);
149 }
150 if base.fragment().is_some() {
151 return Err(EndpointError::FragmentForbidden);
152 }
153 if base.path() != "/" && base.path().ends_with('/') {
154 return Err(EndpointError::TrailingSlash);
155 }
156
157 let mut prefix = String::from(base.as_str());
158 if base.path() == "/" {
159 prefix.pop();
160 }
161 let endpoint = Self { base, prefix };
162 endpoint
163 .identity()
164 .map_err(|_| EndpointError::IdentityRejected)?;
165 Ok(endpoint)
166 }
167
168 pub fn identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError> {
170 let scheme = match self.base.scheme() {
171 "https" => EndpointScheme::Https,
172 "http" => EndpointScheme::Http,
173 _ => return Err(EndpointIdentityError::InvalidBasePath),
174 };
175 let host = self
176 .base
177 .host_str()
178 .ok_or(EndpointIdentityError::EmptyHost)?;
179 let port = self
180 .base
181 .port_or_known_default()
182 .ok_or(EndpointIdentityError::InvalidPort)?;
183 EndpointIdentity::new(scheme, host, port, self.base.path())
184 }
185
186 pub(crate) fn compose(&self, target: RequestTarget<'_>) -> Result<Url, EndpointError> {
187 validate_target_encoding(target.as_str())?;
188 let mut absolute = self.prefix.clone();
189 absolute
190 .try_reserve_exact(target.as_str().len())
191 .map_err(|_| EndpointError::AllocationFailed)?;
192 absolute.push_str(target.as_str());
193 let url = Url::parse(&absolute).map_err(|_| EndpointError::InvalidUrl)?;
194 if url.as_str() != absolute {
195 return Err(EndpointError::TargetNormalized);
196 }
197 self.verify_origin(&url)?;
198 Ok(url)
199 }
200
201 pub(crate) fn verify_origin(&self, url: &Url) -> Result<(), EndpointError> {
202 if url.scheme() != self.base.scheme()
203 || url.host_str() != self.base.host_str()
204 || url.port_or_known_default() != self.base.port_or_known_default()
205 || !url.username().is_empty()
206 || url.password().is_some()
207 {
208 return Err(EndpointError::TargetNormalized);
209 }
210 Ok(())
211 }
212
213 #[cfg(test)]
214 pub(crate) fn local_http(value: &str) -> Result<Self, EndpointError> {
215 let endpoint = Self::new_inner(value, false)?;
216 if endpoint.base.scheme() != "http"
217 || !endpoint.base.host().is_some_and(|host| {
218 host.to_string()
219 .parse::<std::net::IpAddr>()
220 .is_ok_and(|ip| ip.is_loopback())
221 })
222 {
223 return Err(EndpointError::HttpsRequired);
224 }
225 Ok(endpoint)
226 }
227}
228
229fn validate_raw_authority(value: &str) -> Result<(), EndpointError> {
230 let (scheme, remainder) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
231 if scheme.is_empty()
232 || !scheme.is_ascii()
233 || scheme.bytes().any(|byte| byte.is_ascii_uppercase())
234 {
235 return Err(EndpointError::AmbiguousAuthority);
236 }
237 let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
238 let authority = remainder
239 .get(..authority_end)
240 .ok_or(EndpointError::AmbiguousAuthority)?;
241 if authority.is_empty() {
242 return Err(EndpointError::MissingHost);
243 }
244 if authority.contains('@') {
245 return Err(EndpointError::CredentialsForbidden);
246 }
247 if !authority.is_ascii() || authority.contains(['%', '\\']) {
248 return Err(EndpointError::AmbiguousAuthority);
249 }
250
251 let (host, port) = split_host_port(authority)?;
252 if host.bytes().any(|byte| byte.is_ascii_uppercase()) {
253 return Err(EndpointError::AmbiguousAuthority);
254 }
255 if let Some(port) = port
256 && (port.is_empty()
257 || !port.bytes().all(|byte| byte.is_ascii_digit())
258 || port.len() > 1 && port.starts_with('0')
259 || port
260 .parse::<u16>()
261 .ok()
262 .filter(|value| *value != 0)
263 .is_none())
264 {
265 return Err(EndpointError::AmbiguousAuthority);
266 }
267 EndpointIdentity::new(EndpointScheme::Https, host, 1, "/")
268 .map(|_| ())
269 .map_err(|_| EndpointError::AmbiguousAuthority)
270}
271
272fn split_host_port(authority: &str) -> Result<(&str, Option<&str>), EndpointError> {
273 if authority.starts_with('[') {
274 let close = authority
275 .find(']')
276 .ok_or(EndpointError::AmbiguousAuthority)?;
277 let host_end = close
278 .checked_add(1)
279 .ok_or(EndpointError::AmbiguousAuthority)?;
280 let host = authority
281 .get(..host_end)
282 .ok_or(EndpointError::AmbiguousAuthority)?;
283 let suffix = authority
284 .get(host_end..)
285 .ok_or(EndpointError::AmbiguousAuthority)?;
286 if suffix.is_empty() {
287 return Ok((host, None));
288 }
289 let port = suffix
290 .strip_prefix(':')
291 .ok_or(EndpointError::AmbiguousAuthority)?;
292 return Ok((host, Some(port)));
293 }
294 if authority.matches(':').count() > 1 {
295 return Err(EndpointError::AmbiguousAuthority);
296 }
297 Ok(match authority.rsplit_once(':') {
298 Some((host, port)) => (host, Some(port)),
299 None => (authority, None),
300 })
301}
302
303fn validate_configured_base_path(value: &str) -> Result<&str, EndpointError> {
304 let (_, authority_and_path) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
305 let suffix_start = authority_and_path
306 .find(['/', '?', '#'])
307 .unwrap_or(authority_and_path.len());
308 let suffix = authority_and_path
309 .get(suffix_start..)
310 .ok_or(EndpointError::IdentityRejected)?;
311 let path = if suffix.starts_with('/') {
312 let path_end = suffix.find(['?', '#']).unwrap_or(suffix.len());
313 suffix
314 .get(..path_end)
315 .ok_or(EndpointError::IdentityRejected)?
316 } else {
317 "/"
318 };
319 if path.len() > MAX_ENDPOINT_BASE_PATH_BYTES
320 || path.contains("//")
321 || path.split('/').any(|part| matches!(part, "." | ".."))
322 || !path
323 .bytes()
324 .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'%' | b'?' | b'#'))
325 {
326 return Err(EndpointError::IdentityRejected);
327 }
328 Ok(path)
329}
330
331fn validate_target_encoding(target: &str) -> Result<(), EndpointError> {
332 let path_end = target.find('?').unwrap_or(target.len());
333 let bytes = target.as_bytes();
334 let mut index = 0_usize;
335 while index < bytes.len() {
336 if bytes.get(index) != Some(&b'%') {
337 index = index
338 .checked_add(1)
339 .ok_or(EndpointError::InvalidTargetEncoding)?;
340 continue;
341 }
342 let high = bytes
343 .get(
344 index
345 .checked_add(1)
346 .ok_or(EndpointError::InvalidTargetEncoding)?,
347 )
348 .and_then(|byte| decode_hex(*byte));
349 let low = bytes
350 .get(
351 index
352 .checked_add(2)
353 .ok_or(EndpointError::InvalidTargetEncoding)?,
354 )
355 .and_then(|byte| decode_hex(*byte));
356 let (Some(high), Some(low)) = (high, low) else {
357 return Err(EndpointError::InvalidTargetEncoding);
358 };
359 let decoded = high
360 .checked_mul(16)
361 .and_then(|value| value.checked_add(low))
362 .ok_or(EndpointError::InvalidTargetEncoding)?;
363 if index < path_end
364 && (decoded <= b' '
365 || decoded >= 0x7f
366 || matches!(decoded, b'.' | b'/' | b'\\' | b'?' | b'#' | b'%'))
367 {
368 return Err(EndpointError::InvalidTargetEncoding);
369 }
370 index = index
371 .checked_add(3)
372 .ok_or(EndpointError::InvalidTargetEncoding)?;
373 }
374 Ok(())
375}
376
377const fn decode_hex(byte: u8) -> Option<u8> {
378 match byte {
379 b'0'..=b'9' => byte.checked_sub(b'0'),
380 b'a'..=b'f' => match byte.checked_sub(b'a') {
381 Some(value) => value.checked_add(10),
382 None => None,
383 },
384 b'A'..=b'F' => match byte.checked_sub(b'A') {
385 Some(value) => value.checked_add(10),
386 None => None,
387 },
388 _ => None,
389 }
390}