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    /// URL parsing changed the exact configured base plus target bytes.
42    TargetNormalized,
43    /// Allocation failed during target composition.
44    AllocationFailed,
45    /// The normalized endpoint could not be represented by the shared identity contract.
46    IdentityRejected,
47    /// The endpoint is not admitted by the supplied provider policy.
48    PolicyRejected,
49}
50
51impl_static_error!(EndpointError,
52    Self::InputTooLong => "endpoint input exceeds the length limit",
53    Self::InvalidUrl => "endpoint URL is invalid",
54    Self::HttpsRequired => "endpoint must use HTTPS",
55    Self::MissingHost => "endpoint host is missing",
56    Self::CredentialsForbidden => "endpoint credentials are forbidden",
57    Self::AmbiguousAuthority => "endpoint authority is ambiguous or non-canonical",
58    Self::QueryForbidden => "endpoint query is forbidden",
59    Self::FragmentForbidden => "endpoint fragment is forbidden",
60    Self::TrailingSlash => "endpoint path has a forbidden trailing slash",
61    Self::TargetNormalized => "request target was normalized or changed origin",
62    Self::AllocationFailed => "request-target allocation failed",
63    Self::IdentityRejected => "endpoint identity is invalid",
64    Self::PolicyRejected => "endpoint is not admitted by provider policy",
65);
66
67/// Validated HTTPS API endpoint, optionally including a fixed base path.
68#[derive(Clone)]
69pub struct HttpsEndpoint {
70    base: Url,
71    prefix: String,
72}
73
74impl fmt::Debug for HttpsEndpoint {
75    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76        formatter.write_str("HttpsEndpoint([redacted])")
77    }
78}
79
80impl HttpsEndpoint {
81    /// Validates an endpoint and requires admission by a provider-owned policy.
82    ///
83    /// This is the preferred constructor for official and region-derived
84    /// provider destinations.
85    pub fn new_with_policy(value: &str, policy: EndpointPolicy<'_>) -> Result<Self, EndpointError> {
86        let endpoint = Self::new_inner(value, true)?;
87        policy
88            .verify(
89                endpoint
90                    .identity()
91                    .map_err(|_| EndpointError::IdentityRejected)?,
92            )
93            .map_err(|_| EndpointError::PolicyRejected)?;
94        Ok(endpoint)
95    }
96
97    /// Explicitly trusts and validates a custom HTTPS credential destination.
98    ///
99    /// The transport sends its bearer token to this origin. `value` must come
100    /// from trusted operator configuration and must never be controlled by a
101    /// tenant, request payload, or other untrusted input.
102    ///
103    /// ```compile_fail
104    /// use cloud_sdk_reqwest::blocking::HttpsEndpoint;
105    ///
106    /// let endpoint = HttpsEndpoint::new_custom("https://api.example.test/v1");
107    /// ```
108    pub fn new_custom(
109        value: &str,
110        acknowledgement: CustomEndpointAcknowledgement,
111    ) -> Result<Self, EndpointError> {
112        let endpoint = Self::new_inner(value, true)?;
113        let identity = endpoint
114            .identity()
115            .map_err(|_| EndpointError::IdentityRejected)?;
116        EndpointPolicy::acknowledged_custom(AcknowledgedCustomEndpoint::new(
117            identity,
118            acknowledgement,
119        ))
120        .verify(identity)
121        .map_err(|_| EndpointError::PolicyRejected)?;
122        Ok(endpoint)
123    }
124
125    fn new_inner(value: &str, require_https: bool) -> Result<Self, EndpointError> {
126        if value.len() > MAX_CONFIGURED_ENDPOINT_BYTES {
127            return Err(EndpointError::InputTooLong);
128        }
129        validate_raw_authority(value)?;
130        let configured_path = validate_configured_base_path(value)?;
131        let base = Url::parse(value).map_err(|_| EndpointError::InvalidUrl)?;
132        if base.path() != configured_path {
133            return Err(EndpointError::TargetNormalized);
134        }
135        if require_https && base.scheme() != "https" {
136            return Err(EndpointError::HttpsRequired);
137        }
138        if base.host_str().is_none() {
139            return Err(EndpointError::MissingHost);
140        }
141        if !base.username().is_empty() || base.password().is_some() {
142            return Err(EndpointError::CredentialsForbidden);
143        }
144        if base.query().is_some() {
145            return Err(EndpointError::QueryForbidden);
146        }
147        if base.fragment().is_some() {
148            return Err(EndpointError::FragmentForbidden);
149        }
150        if base.path() != "/" && base.path().ends_with('/') {
151            return Err(EndpointError::TrailingSlash);
152        }
153
154        let mut prefix = String::from(base.as_str());
155        if base.path() == "/" {
156            prefix.pop();
157        }
158        let endpoint = Self { base, prefix };
159        endpoint
160            .identity()
161            .map_err(|_| EndpointError::IdentityRejected)?;
162        Ok(endpoint)
163    }
164
165    /// Returns the normalized scheme, host, effective port, and base path.
166    pub fn identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError> {
167        let scheme = match self.base.scheme() {
168            "https" => EndpointScheme::Https,
169            "http" => EndpointScheme::Http,
170            _ => return Err(EndpointIdentityError::InvalidBasePath),
171        };
172        let host = self
173            .base
174            .host_str()
175            .ok_or(EndpointIdentityError::EmptyHost)?;
176        let port = self
177            .base
178            .port_or_known_default()
179            .ok_or(EndpointIdentityError::InvalidPort)?;
180        EndpointIdentity::new(scheme, host, port, self.base.path())
181    }
182
183    pub(crate) fn compose(&self, target: RequestTarget<'_>) -> Result<Url, EndpointError> {
184        let mut absolute = self.prefix.clone();
185        absolute
186            .try_reserve_exact(target.as_str().len())
187            .map_err(|_| EndpointError::AllocationFailed)?;
188        absolute.push_str(target.as_str());
189        let url = Url::parse(&absolute).map_err(|_| EndpointError::InvalidUrl)?;
190        if url.as_str() != absolute {
191            return Err(EndpointError::TargetNormalized);
192        }
193        self.verify_origin(&url)?;
194        Ok(url)
195    }
196
197    pub(crate) fn verify_origin(&self, url: &Url) -> Result<(), EndpointError> {
198        if url.scheme() != self.base.scheme()
199            || url.host_str() != self.base.host_str()
200            || url.port_or_known_default() != self.base.port_or_known_default()
201            || !url.username().is_empty()
202            || url.password().is_some()
203        {
204            return Err(EndpointError::TargetNormalized);
205        }
206        Ok(())
207    }
208
209    #[cfg(test)]
210    pub(crate) fn local_http(value: &str) -> Result<Self, EndpointError> {
211        let endpoint = Self::new_inner(value, false)?;
212        if endpoint.base.scheme() != "http"
213            || !endpoint.base.host().is_some_and(|host| {
214                host.to_string()
215                    .parse::<std::net::IpAddr>()
216                    .is_ok_and(|ip| ip.is_loopback())
217            })
218        {
219            return Err(EndpointError::HttpsRequired);
220        }
221        Ok(endpoint)
222    }
223}
224
225fn validate_raw_authority(value: &str) -> Result<(), EndpointError> {
226    let (scheme, remainder) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
227    if scheme.is_empty()
228        || !scheme.is_ascii()
229        || scheme.bytes().any(|byte| byte.is_ascii_uppercase())
230    {
231        return Err(EndpointError::AmbiguousAuthority);
232    }
233    let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
234    let authority = remainder
235        .get(..authority_end)
236        .ok_or(EndpointError::AmbiguousAuthority)?;
237    if authority.is_empty() {
238        return Err(EndpointError::MissingHost);
239    }
240    if authority.contains('@') {
241        return Err(EndpointError::CredentialsForbidden);
242    }
243    if !authority.is_ascii() || authority.contains(['%', '\\']) {
244        return Err(EndpointError::AmbiguousAuthority);
245    }
246
247    let (host, port) = split_host_port(authority)?;
248    if host.bytes().any(|byte| byte.is_ascii_uppercase()) {
249        return Err(EndpointError::AmbiguousAuthority);
250    }
251    if let Some(port) = port
252        && (port.is_empty()
253            || !port.bytes().all(|byte| byte.is_ascii_digit())
254            || port.len() > 1 && port.starts_with('0')
255            || port
256                .parse::<u16>()
257                .ok()
258                .filter(|value| *value != 0)
259                .is_none())
260    {
261        return Err(EndpointError::AmbiguousAuthority);
262    }
263    EndpointIdentity::new(EndpointScheme::Https, host, 1, "/")
264        .map(|_| ())
265        .map_err(|_| EndpointError::AmbiguousAuthority)
266}
267
268fn split_host_port(authority: &str) -> Result<(&str, Option<&str>), EndpointError> {
269    if authority.starts_with('[') {
270        let close = authority
271            .find(']')
272            .ok_or(EndpointError::AmbiguousAuthority)?;
273        let host_end = close
274            .checked_add(1)
275            .ok_or(EndpointError::AmbiguousAuthority)?;
276        let host = authority
277            .get(..host_end)
278            .ok_or(EndpointError::AmbiguousAuthority)?;
279        let suffix = authority
280            .get(host_end..)
281            .ok_or(EndpointError::AmbiguousAuthority)?;
282        if suffix.is_empty() {
283            return Ok((host, None));
284        }
285        let port = suffix
286            .strip_prefix(':')
287            .ok_or(EndpointError::AmbiguousAuthority)?;
288        return Ok((host, Some(port)));
289    }
290    if authority.matches(':').count() > 1 {
291        return Err(EndpointError::AmbiguousAuthority);
292    }
293    Ok(match authority.rsplit_once(':') {
294        Some((host, port)) => (host, Some(port)),
295        None => (authority, None),
296    })
297}
298
299fn validate_configured_base_path(value: &str) -> Result<&str, EndpointError> {
300    let (_, authority_and_path) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
301    let suffix_start = authority_and_path
302        .find(['/', '?', '#'])
303        .unwrap_or(authority_and_path.len());
304    let suffix = authority_and_path
305        .get(suffix_start..)
306        .ok_or(EndpointError::IdentityRejected)?;
307    let path = if suffix.starts_with('/') {
308        let path_end = suffix.find(['?', '#']).unwrap_or(suffix.len());
309        suffix
310            .get(..path_end)
311            .ok_or(EndpointError::IdentityRejected)?
312    } else {
313        "/"
314    };
315    if path.len() > MAX_ENDPOINT_BASE_PATH_BYTES
316        || path.contains("//")
317        || path.split('/').any(|part| matches!(part, "." | ".."))
318        || !path
319            .bytes()
320            .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'%' | b'?' | b'#'))
321    {
322        return Err(EndpointError::IdentityRejected);
323    }
324    Ok(path)
325}