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 EndpointIdentity, EndpointIdentityError, EndpointScheme, RequestTarget,
9};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum EndpointError {
14 InvalidUrl,
16 HttpsRequired,
18 MissingHost,
20 CredentialsForbidden,
22 QueryForbidden,
24 FragmentForbidden,
26 TrailingSlash,
28 InvalidTargetEncoding,
30 TargetNormalized,
32 AllocationFailed,
34 IdentityRejected,
36}
37
38impl_static_error!(EndpointError,
39 Self::InvalidUrl => "endpoint URL is invalid",
40 Self::HttpsRequired => "endpoint must use HTTPS",
41 Self::MissingHost => "endpoint host is missing",
42 Self::CredentialsForbidden => "endpoint credentials are forbidden",
43 Self::QueryForbidden => "endpoint query is forbidden",
44 Self::FragmentForbidden => "endpoint fragment is forbidden",
45 Self::TrailingSlash => "endpoint path has a forbidden trailing slash",
46 Self::InvalidTargetEncoding => "request target encoding is invalid",
47 Self::TargetNormalized => "request target was normalized or changed origin",
48 Self::AllocationFailed => "request-target allocation failed",
49 Self::IdentityRejected => "endpoint identity is invalid",
50);
51
52#[derive(Clone)]
54pub struct HttpsEndpoint {
55 base: Url,
56 prefix: String,
57}
58
59impl fmt::Debug for HttpsEndpoint {
60 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61 formatter.write_str("HttpsEndpoint([redacted])")
62 }
63}
64
65impl HttpsEndpoint {
66 pub fn new_custom(value: &str) -> Result<Self, EndpointError> {
72 Self::new_inner(value, true)
73 }
74
75 fn new_inner(value: &str, require_https: bool) -> Result<Self, EndpointError> {
76 validate_configured_base_path(value)?;
77 let base = Url::parse(value).map_err(|_| EndpointError::InvalidUrl)?;
78 if require_https && base.scheme() != "https" {
79 return Err(EndpointError::HttpsRequired);
80 }
81 if base.host_str().is_none() {
82 return Err(EndpointError::MissingHost);
83 }
84 if !base.username().is_empty() || base.password().is_some() {
85 return Err(EndpointError::CredentialsForbidden);
86 }
87 if base.query().is_some() {
88 return Err(EndpointError::QueryForbidden);
89 }
90 if base.fragment().is_some() {
91 return Err(EndpointError::FragmentForbidden);
92 }
93 if base.path() != "/" && base.path().ends_with('/') {
94 return Err(EndpointError::TrailingSlash);
95 }
96
97 let mut prefix = String::from(base.as_str());
98 if base.path() == "/" {
99 prefix.pop();
100 }
101 let endpoint = Self { base, prefix };
102 endpoint
103 .identity()
104 .map_err(|_| EndpointError::IdentityRejected)?;
105 Ok(endpoint)
106 }
107
108 pub fn identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError> {
110 let scheme = match self.base.scheme() {
111 "https" => EndpointScheme::Https,
112 "http" => EndpointScheme::Http,
113 _ => return Err(EndpointIdentityError::InvalidBasePath),
114 };
115 let host = self
116 .base
117 .host_str()
118 .ok_or(EndpointIdentityError::EmptyHost)?;
119 let port = self
120 .base
121 .port_or_known_default()
122 .ok_or(EndpointIdentityError::InvalidPort)?;
123 EndpointIdentity::new(scheme, host, port, self.base.path())
124 }
125
126 pub(crate) fn compose(&self, target: RequestTarget<'_>) -> Result<Url, EndpointError> {
127 validate_target_encoding(target.as_str())?;
128 let mut absolute = self.prefix.clone();
129 absolute
130 .try_reserve_exact(target.as_str().len())
131 .map_err(|_| EndpointError::AllocationFailed)?;
132 absolute.push_str(target.as_str());
133 let url = Url::parse(&absolute).map_err(|_| EndpointError::InvalidUrl)?;
134 if url.as_str() != absolute {
135 return Err(EndpointError::TargetNormalized);
136 }
137 self.verify_origin(&url)?;
138 Ok(url)
139 }
140
141 pub(crate) fn verify_origin(&self, url: &Url) -> Result<(), EndpointError> {
142 if url.scheme() != self.base.scheme()
143 || url.host_str() != self.base.host_str()
144 || url.port_or_known_default() != self.base.port_or_known_default()
145 || !url.username().is_empty()
146 || url.password().is_some()
147 {
148 return Err(EndpointError::TargetNormalized);
149 }
150 Ok(())
151 }
152
153 #[cfg(test)]
154 pub(crate) fn local_http(value: &str) -> Result<Self, EndpointError> {
155 let endpoint = Self::new_inner(value, false)?;
156 if endpoint.base.scheme() != "http"
157 || !endpoint.base.host().is_some_and(|host| {
158 host.to_string()
159 .parse::<std::net::IpAddr>()
160 .is_ok_and(|ip| ip.is_loopback())
161 })
162 {
163 return Err(EndpointError::HttpsRequired);
164 }
165 Ok(endpoint)
166 }
167}
168
169fn validate_configured_base_path(value: &str) -> Result<(), EndpointError> {
170 let Some((_, authority_and_path)) = value.split_once("://") else {
171 return Ok(());
172 };
173 let path = authority_and_path
174 .find('/')
175 .and_then(|position| authority_and_path.get(position..))
176 .unwrap_or("/");
177 let path = path.split(['?', '#']).next().unwrap_or(path);
178 if path.contains('%')
179 || path.contains("//")
180 || path.split('/').any(|part| matches!(part, "." | ".."))
181 {
182 return Err(EndpointError::IdentityRejected);
183 }
184 Ok(())
185}
186
187fn validate_target_encoding(target: &str) -> Result<(), EndpointError> {
188 let path_end = target.find('?').unwrap_or(target.len());
189 let bytes = target.as_bytes();
190 let mut index = 0_usize;
191 while index < bytes.len() {
192 if bytes.get(index) != Some(&b'%') {
193 index = index
194 .checked_add(1)
195 .ok_or(EndpointError::InvalidTargetEncoding)?;
196 continue;
197 }
198 let high = bytes
199 .get(
200 index
201 .checked_add(1)
202 .ok_or(EndpointError::InvalidTargetEncoding)?,
203 )
204 .and_then(|byte| decode_hex(*byte));
205 let low = bytes
206 .get(
207 index
208 .checked_add(2)
209 .ok_or(EndpointError::InvalidTargetEncoding)?,
210 )
211 .and_then(|byte| decode_hex(*byte));
212 let (Some(high), Some(low)) = (high, low) else {
213 return Err(EndpointError::InvalidTargetEncoding);
214 };
215 let decoded = high
216 .checked_mul(16)
217 .and_then(|value| value.checked_add(low))
218 .ok_or(EndpointError::InvalidTargetEncoding)?;
219 if index < path_end
220 && (decoded <= b' '
221 || decoded >= 0x7f
222 || matches!(decoded, b'.' | b'/' | b'\\' | b'?' | b'#' | b'%'))
223 {
224 return Err(EndpointError::InvalidTargetEncoding);
225 }
226 index = index
227 .checked_add(3)
228 .ok_or(EndpointError::InvalidTargetEncoding)?;
229 }
230 Ok(())
231}
232
233const fn decode_hex(byte: u8) -> Option<u8> {
234 match byte {
235 b'0'..=b'9' => byte.checked_sub(b'0'),
236 b'a'..=b'f' => match byte.checked_sub(b'a') {
237 Some(value) => value.checked_add(10),
238 None => None,
239 },
240 b'A'..=b'F' => match byte.checked_sub(b'A') {
241 Some(value) => value.checked_add(10),
242 None => None,
243 },
244 _ => None,
245 }
246}