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::RequestTarget;
8
9/// HTTPS endpoint validation or target-composition error.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum EndpointError {
12    /// The endpoint is not a valid absolute URL.
13    InvalidUrl,
14    /// Public endpoints must use HTTPS.
15    HttpsRequired,
16    /// A host is required.
17    MissingHost,
18    /// URL user information is forbidden.
19    CredentialsForbidden,
20    /// Base endpoint queries are forbidden.
21    QueryForbidden,
22    /// Base endpoint fragments are forbidden.
23    FragmentForbidden,
24    /// Non-root endpoint paths must not end in `/`.
25    TrailingSlash,
26    /// Target percent encoding is malformed or encodes structural path bytes.
27    InvalidTargetEncoding,
28    /// URL parsing changed the exact configured base plus target bytes.
29    TargetNormalized,
30    /// Allocation failed during target composition.
31    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/// Validated HTTPS API endpoint, optionally including a fixed base path.
48#[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    /// Explicitly trusts and validates a custom HTTPS credential destination.
62    ///
63    /// The transport sends its bearer token to this origin. `value` must come
64    /// from trusted operator configuration and must never be controlled by a
65    /// tenant, request payload, or other untrusted input.
66    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}