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