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 TargetNormalized,
43 AllocationFailed,
45 IdentityRejected,
47 PolicyRejected,
49}
50
51impl_static_error!(EndpointError,
52 Self::InputTooLong => "endpoint input exceeds the length limit",
53 Self::InvalidUrl => "endpoint URL is invalid",
54 Self::HttpsRequired => "endpoint must use HTTPS",
55 Self::MissingHost => "endpoint host is missing",
56 Self::CredentialsForbidden => "endpoint credentials are forbidden",
57 Self::AmbiguousAuthority => "endpoint authority is ambiguous or non-canonical",
58 Self::QueryForbidden => "endpoint query is forbidden",
59 Self::FragmentForbidden => "endpoint fragment is forbidden",
60 Self::TrailingSlash => "endpoint path has a forbidden trailing slash",
61 Self::TargetNormalized => "request target was normalized or changed origin",
62 Self::AllocationFailed => "request-target allocation failed",
63 Self::IdentityRejected => "endpoint identity is invalid",
64 Self::PolicyRejected => "endpoint is not admitted by provider policy",
65);
66
67#[derive(Clone)]
69pub struct HttpsEndpoint {
70 base: Url,
71 prefix: String,
72}
73
74impl fmt::Debug for HttpsEndpoint {
75 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76 formatter.write_str("HttpsEndpoint([redacted])")
77 }
78}
79
80impl HttpsEndpoint {
81 pub fn new_with_policy(value: &str, policy: EndpointPolicy<'_>) -> Result<Self, EndpointError> {
86 let endpoint = Self::new_inner(value, true)?;
87 policy
88 .verify(
89 endpoint
90 .identity()
91 .map_err(|_| EndpointError::IdentityRejected)?,
92 )
93 .map_err(|_| EndpointError::PolicyRejected)?;
94 Ok(endpoint)
95 }
96
97 pub fn new_custom(
109 value: &str,
110 acknowledgement: CustomEndpointAcknowledgement,
111 ) -> Result<Self, EndpointError> {
112 let endpoint = Self::new_inner(value, true)?;
113 let identity = endpoint
114 .identity()
115 .map_err(|_| EndpointError::IdentityRejected)?;
116 EndpointPolicy::acknowledged_custom(AcknowledgedCustomEndpoint::new(
117 identity,
118 acknowledgement,
119 ))
120 .verify(identity)
121 .map_err(|_| EndpointError::PolicyRejected)?;
122 Ok(endpoint)
123 }
124
125 fn new_inner(value: &str, require_https: bool) -> Result<Self, EndpointError> {
126 if value.len() > MAX_CONFIGURED_ENDPOINT_BYTES {
127 return Err(EndpointError::InputTooLong);
128 }
129 validate_raw_authority(value)?;
130 let configured_path = validate_configured_base_path(value)?;
131 let base = Url::parse(value).map_err(|_| EndpointError::InvalidUrl)?;
132 if base.path() != configured_path {
133 return Err(EndpointError::TargetNormalized);
134 }
135 if require_https && base.scheme() != "https" {
136 return Err(EndpointError::HttpsRequired);
137 }
138 if base.host_str().is_none() {
139 return Err(EndpointError::MissingHost);
140 }
141 if !base.username().is_empty() || base.password().is_some() {
142 return Err(EndpointError::CredentialsForbidden);
143 }
144 if base.query().is_some() {
145 return Err(EndpointError::QueryForbidden);
146 }
147 if base.fragment().is_some() {
148 return Err(EndpointError::FragmentForbidden);
149 }
150 if base.path() != "/" && base.path().ends_with('/') {
151 return Err(EndpointError::TrailingSlash);
152 }
153
154 let mut prefix = String::from(base.as_str());
155 if base.path() == "/" {
156 prefix.pop();
157 }
158 let endpoint = Self { base, prefix };
159 endpoint
160 .identity()
161 .map_err(|_| EndpointError::IdentityRejected)?;
162 Ok(endpoint)
163 }
164
165 pub fn identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError> {
167 let scheme = match self.base.scheme() {
168 "https" => EndpointScheme::Https,
169 "http" => EndpointScheme::Http,
170 _ => return Err(EndpointIdentityError::InvalidBasePath),
171 };
172 let host = self
173 .base
174 .host_str()
175 .ok_or(EndpointIdentityError::EmptyHost)?;
176 let port = self
177 .base
178 .port_or_known_default()
179 .ok_or(EndpointIdentityError::InvalidPort)?;
180 EndpointIdentity::new(scheme, host, port, self.base.path())
181 }
182
183 pub(crate) fn compose(&self, target: RequestTarget<'_>) -> Result<Url, EndpointError> {
184 let mut absolute = self.prefix.clone();
185 absolute
186 .try_reserve_exact(target.as_str().len())
187 .map_err(|_| EndpointError::AllocationFailed)?;
188 absolute.push_str(target.as_str());
189 let url = Url::parse(&absolute).map_err(|_| EndpointError::InvalidUrl)?;
190 if url.as_str() != absolute {
191 return Err(EndpointError::TargetNormalized);
192 }
193 self.verify_origin(&url)?;
194 Ok(url)
195 }
196
197 pub(crate) fn verify_origin(&self, url: &Url) -> Result<(), EndpointError> {
198 if url.scheme() != self.base.scheme()
199 || url.host_str() != self.base.host_str()
200 || url.port_or_known_default() != self.base.port_or_known_default()
201 || !url.username().is_empty()
202 || url.password().is_some()
203 {
204 return Err(EndpointError::TargetNormalized);
205 }
206 Ok(())
207 }
208
209 #[cfg(test)]
210 pub(crate) fn local_http(value: &str) -> Result<Self, EndpointError> {
211 let endpoint = Self::new_inner(value, false)?;
212 if endpoint.base.scheme() != "http"
213 || !endpoint.base.host().is_some_and(|host| {
214 let text = host.to_string();
215 text.strip_prefix('[')
216 .and_then(|value| value.strip_suffix(']'))
217 .unwrap_or(&text)
218 .parse::<std::net::IpAddr>()
219 .is_ok_and(|ip| ip.is_loopback())
220 })
221 {
222 return Err(EndpointError::HttpsRequired);
223 }
224 Ok(endpoint)
225 }
226}
227
228fn validate_raw_authority(value: &str) -> Result<(), EndpointError> {
229 let (scheme, remainder) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
230 if scheme.is_empty()
231 || !scheme.is_ascii()
232 || scheme.bytes().any(|byte| byte.is_ascii_uppercase())
233 {
234 return Err(EndpointError::AmbiguousAuthority);
235 }
236 let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
237 let authority = remainder
238 .get(..authority_end)
239 .ok_or(EndpointError::AmbiguousAuthority)?;
240 if authority.is_empty() {
241 return Err(EndpointError::MissingHost);
242 }
243 if authority.contains('@') {
244 return Err(EndpointError::CredentialsForbidden);
245 }
246 if !authority.is_ascii() || authority.contains(['%', '\\']) {
247 return Err(EndpointError::AmbiguousAuthority);
248 }
249
250 let (host, port) = split_host_port(authority)?;
251 if host.bytes().any(|byte| byte.is_ascii_uppercase()) {
252 return Err(EndpointError::AmbiguousAuthority);
253 }
254 if let Some(port) = port
255 && (port.is_empty()
256 || !port.bytes().all(|byte| byte.is_ascii_digit())
257 || port.len() > 1 && port.starts_with('0')
258 || port
259 .parse::<u16>()
260 .ok()
261 .filter(|value| *value != 0)
262 .is_none())
263 {
264 return Err(EndpointError::AmbiguousAuthority);
265 }
266 EndpointIdentity::new(EndpointScheme::Https, host, 1, "/")
267 .map(|_| ())
268 .map_err(|_| EndpointError::AmbiguousAuthority)
269}
270
271fn split_host_port(authority: &str) -> Result<(&str, Option<&str>), EndpointError> {
272 if authority.starts_with('[') {
273 let close = authority
274 .find(']')
275 .ok_or(EndpointError::AmbiguousAuthority)?;
276 let host_end = close
277 .checked_add(1)
278 .ok_or(EndpointError::AmbiguousAuthority)?;
279 let host = authority
280 .get(..host_end)
281 .ok_or(EndpointError::AmbiguousAuthority)?;
282 let suffix = authority
283 .get(host_end..)
284 .ok_or(EndpointError::AmbiguousAuthority)?;
285 if suffix.is_empty() {
286 return Ok((host, None));
287 }
288 let port = suffix
289 .strip_prefix(':')
290 .ok_or(EndpointError::AmbiguousAuthority)?;
291 return Ok((host, Some(port)));
292 }
293 if authority.matches(':').count() > 1 {
294 return Err(EndpointError::AmbiguousAuthority);
295 }
296 Ok(match authority.rsplit_once(':') {
297 Some((host, port)) => (host, Some(port)),
298 None => (authority, None),
299 })
300}
301
302fn validate_configured_base_path(value: &str) -> Result<&str, EndpointError> {
303 let (_, authority_and_path) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
304 let suffix_start = authority_and_path
305 .find(['/', '?', '#'])
306 .unwrap_or(authority_and_path.len());
307 let suffix = authority_and_path
308 .get(suffix_start..)
309 .ok_or(EndpointError::IdentityRejected)?;
310 let path = if suffix.starts_with('/') {
311 let path_end = suffix.find(['?', '#']).unwrap_or(suffix.len());
312 suffix
313 .get(..path_end)
314 .ok_or(EndpointError::IdentityRejected)?
315 } else {
316 "/"
317 };
318 if path.len() > MAX_ENDPOINT_BASE_PATH_BYTES
319 || path.contains("//")
320 || path.split('/').any(|part| matches!(part, "." | ".."))
321 || !path
322 .bytes()
323 .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'%' | b'?' | b'#'))
324 {
325 return Err(EndpointError::IdentityRejected);
326 }
327 Ok(path)
328}