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 host.to_string()
215 .parse::<std::net::IpAddr>()
216 .is_ok_and(|ip| ip.is_loopback())
217 })
218 {
219 return Err(EndpointError::HttpsRequired);
220 }
221 Ok(endpoint)
222 }
223}
224
225fn validate_raw_authority(value: &str) -> Result<(), EndpointError> {
226 let (scheme, remainder) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
227 if scheme.is_empty()
228 || !scheme.is_ascii()
229 || scheme.bytes().any(|byte| byte.is_ascii_uppercase())
230 {
231 return Err(EndpointError::AmbiguousAuthority);
232 }
233 let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
234 let authority = remainder
235 .get(..authority_end)
236 .ok_or(EndpointError::AmbiguousAuthority)?;
237 if authority.is_empty() {
238 return Err(EndpointError::MissingHost);
239 }
240 if authority.contains('@') {
241 return Err(EndpointError::CredentialsForbidden);
242 }
243 if !authority.is_ascii() || authority.contains(['%', '\\']) {
244 return Err(EndpointError::AmbiguousAuthority);
245 }
246
247 let (host, port) = split_host_port(authority)?;
248 if host.bytes().any(|byte| byte.is_ascii_uppercase()) {
249 return Err(EndpointError::AmbiguousAuthority);
250 }
251 if let Some(port) = port
252 && (port.is_empty()
253 || !port.bytes().all(|byte| byte.is_ascii_digit())
254 || port.len() > 1 && port.starts_with('0')
255 || port
256 .parse::<u16>()
257 .ok()
258 .filter(|value| *value != 0)
259 .is_none())
260 {
261 return Err(EndpointError::AmbiguousAuthority);
262 }
263 EndpointIdentity::new(EndpointScheme::Https, host, 1, "/")
264 .map(|_| ())
265 .map_err(|_| EndpointError::AmbiguousAuthority)
266}
267
268fn split_host_port(authority: &str) -> Result<(&str, Option<&str>), EndpointError> {
269 if authority.starts_with('[') {
270 let close = authority
271 .find(']')
272 .ok_or(EndpointError::AmbiguousAuthority)?;
273 let host_end = close
274 .checked_add(1)
275 .ok_or(EndpointError::AmbiguousAuthority)?;
276 let host = authority
277 .get(..host_end)
278 .ok_or(EndpointError::AmbiguousAuthority)?;
279 let suffix = authority
280 .get(host_end..)
281 .ok_or(EndpointError::AmbiguousAuthority)?;
282 if suffix.is_empty() {
283 return Ok((host, None));
284 }
285 let port = suffix
286 .strip_prefix(':')
287 .ok_or(EndpointError::AmbiguousAuthority)?;
288 return Ok((host, Some(port)));
289 }
290 if authority.matches(':').count() > 1 {
291 return Err(EndpointError::AmbiguousAuthority);
292 }
293 Ok(match authority.rsplit_once(':') {
294 Some((host, port)) => (host, Some(port)),
295 None => (authority, None),
296 })
297}
298
299fn validate_configured_base_path(value: &str) -> Result<&str, EndpointError> {
300 let (_, authority_and_path) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
301 let suffix_start = authority_and_path
302 .find(['/', '?', '#'])
303 .unwrap_or(authority_and_path.len());
304 let suffix = authority_and_path
305 .get(suffix_start..)
306 .ok_or(EndpointError::IdentityRejected)?;
307 let path = if suffix.starts_with('/') {
308 let path_end = suffix.find(['?', '#']).unwrap_or(suffix.len());
309 suffix
310 .get(..path_end)
311 .ok_or(EndpointError::IdentityRejected)?
312 } else {
313 "/"
314 };
315 if path.len() > MAX_ENDPOINT_BASE_PATH_BYTES
316 || path.contains("//")
317 || path.split('/').any(|part| matches!(part, "." | ".."))
318 || !path
319 .bytes()
320 .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'%' | b'?' | b'#'))
321 {
322 return Err(EndpointError::IdentityRejected);
323 }
324 Ok(path)
325}