Skip to main content

cloud_sdk_reqwest/shared/
endpoint.rs

1use 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
13/// Maximum endpoint input accepted before URL parsing or allocation.
14///
15/// This covers `https://`, a maximum-length host, `:65535`, and a
16/// maximum-length base path.
17pub const MAX_CONFIGURED_ENDPOINT_BYTES: usize =
18    "https://".len() + MAX_ENDPOINT_HOST_BYTES + 1 + 5 + MAX_ENDPOINT_BASE_PATH_BYTES;
19
20/// HTTPS endpoint validation or target-composition error.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum EndpointError {
23    /// The raw endpoint exceeds [`MAX_CONFIGURED_ENDPOINT_BYTES`].
24    InputTooLong,
25    /// The endpoint is not a valid absolute URL.
26    InvalidUrl,
27    /// Public endpoints must use HTTPS.
28    HttpsRequired,
29    /// A host is required.
30    MissingHost,
31    /// URL user information is forbidden.
32    CredentialsForbidden,
33    /// The configured authority uses a non-canonical or ambiguous host form.
34    AmbiguousAuthority,
35    /// Base endpoint queries are forbidden.
36    QueryForbidden,
37    /// Base endpoint fragments are forbidden.
38    FragmentForbidden,
39    /// Non-root endpoint paths must not end in `/`.
40    TrailingSlash,
41    /// Target percent encoding is malformed or encodes structural path bytes.
42    InvalidTargetEncoding,
43    /// URL parsing changed the exact configured base plus target bytes.
44    TargetNormalized,
45    /// Allocation failed during target composition.
46    AllocationFailed,
47    /// The normalized endpoint could not be represented by the shared identity contract.
48    IdentityRejected,
49    /// The endpoint is not admitted by the supplied provider policy.
50    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/// Validated HTTPS API endpoint, optionally including a fixed base path.
71#[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    /// Validates an endpoint and requires admission by a provider-owned policy.
85    ///
86    /// This is the preferred constructor for official and region-derived
87    /// provider destinations.
88    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    /// Explicitly trusts and validates a custom HTTPS credential destination.
101    ///
102    /// The transport sends its bearer token to this origin. `value` must come
103    /// from trusted operator configuration and must never be controlled by a
104    /// tenant, request payload, or other untrusted input.
105    ///
106    /// ```compile_fail
107    /// use cloud_sdk_reqwest::blocking::HttpsEndpoint;
108    ///
109    /// let endpoint = HttpsEndpoint::new_custom("https://api.example.test/v1");
110    /// ```
111    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    /// Returns the normalized scheme, host, effective port, and base path.
169    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}