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