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