Skip to main content

cloud_sdk_reqwest/shared/
endpoint.rs

1use core::fmt;
2use reqwest::Url;
3use std::net::Ipv4Addr;
4use std::str::FromStr;
5use std::string::String;
6#[cfg(test)]
7use std::string::ToString;
8
9use cloud_sdk::transport::{
10    AcknowledgedCustomEndpoint, CustomEndpointAcknowledgement, EndpointIdentity,
11    EndpointIdentityError, EndpointPolicy, EndpointScheme, MAX_ENDPOINT_BASE_PATH_BYTES,
12    MAX_ENDPOINT_HOST_BYTES, RequestTarget,
13};
14
15/// Maximum endpoint input accepted before URL parsing or allocation.
16///
17/// This covers `https://`, a maximum-length host, `:65535`, and a
18/// maximum-length base path.
19pub const MAX_CONFIGURED_ENDPOINT_BYTES: usize =
20    "https://".len() + MAX_ENDPOINT_HOST_BYTES + 1 + 5 + MAX_ENDPOINT_BASE_PATH_BYTES;
21
22/// HTTPS endpoint validation or target-composition error.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum EndpointError {
25    /// The raw endpoint exceeds [`MAX_CONFIGURED_ENDPOINT_BYTES`].
26    InputTooLong,
27    /// The endpoint is not a valid absolute URL.
28    InvalidUrl,
29    /// Public endpoints must use HTTPS.
30    HttpsRequired,
31    /// A host is required.
32    MissingHost,
33    /// URL user information is forbidden.
34    CredentialsForbidden,
35    /// The configured authority uses a non-canonical or ambiguous host form.
36    AmbiguousAuthority,
37    /// Base endpoint queries are forbidden.
38    QueryForbidden,
39    /// Base endpoint fragments are forbidden.
40    FragmentForbidden,
41    /// Non-root endpoint paths must not end in `/`.
42    TrailingSlash,
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::TargetNormalized => "request target was normalized or changed origin",
64    Self::AllocationFailed => "request-target allocation failed",
65    Self::IdentityRejected => "endpoint identity is invalid",
66    Self::PolicyRejected => "endpoint is not admitted by provider policy",
67);
68
69/// Validated HTTPS API endpoint, optionally including a fixed base path.
70#[derive(Clone)]
71pub struct HttpsEndpoint {
72    base: Url,
73    prefix: String,
74}
75
76/// Provider-policy-bound IPv4 link-local HTTP endpoint for metadata services.
77///
78/// This type cannot carry credentials and is accepted only by explicit raw
79/// client constructors. It rejects DNS names, non-link-local addresses,
80/// non-default ports, and destinations outside the supplied fixed policy.
81#[derive(Clone)]
82pub struct LinkLocalHttpEndpoint {
83    inner: HttpsEndpoint,
84}
85
86impl fmt::Debug for LinkLocalHttpEndpoint {
87    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88        formatter.write_str("LinkLocalHttpEndpoint([redacted])")
89    }
90}
91
92impl LinkLocalHttpEndpoint {
93    /// Validates one direct HTTP metadata destination against provider policy.
94    pub fn new_with_policy(value: &str, policy: EndpointPolicy<'_>) -> Result<Self, EndpointError> {
95        let inner = HttpsEndpoint::new_inner(value, false)?;
96        let identity = inner
97            .identity()
98            .map_err(|_| EndpointError::IdentityRejected)?;
99        let address =
100            Ipv4Addr::from_str(identity.host()).map_err(|_| EndpointError::PolicyRejected)?;
101        if identity.scheme() != EndpointScheme::Http
102            || identity.effective_port() != 80
103            || !address.is_link_local()
104        {
105            return Err(EndpointError::PolicyRejected);
106        }
107        policy
108            .verify(identity)
109            .map_err(|_| EndpointError::PolicyRejected)?;
110        Ok(Self { inner })
111    }
112
113    /// Returns the normalized endpoint identity.
114    pub fn identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError> {
115        self.inner.identity()
116    }
117
118    pub(crate) fn into_inner(self) -> HttpsEndpoint {
119        self.inner
120    }
121}
122
123impl fmt::Debug for HttpsEndpoint {
124    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125        formatter.write_str("HttpsEndpoint([redacted])")
126    }
127}
128
129impl HttpsEndpoint {
130    /// Validates an endpoint and requires admission by a provider-owned policy.
131    ///
132    /// This is the preferred constructor for official and region-derived
133    /// provider destinations.
134    pub fn new_with_policy(value: &str, policy: EndpointPolicy<'_>) -> Result<Self, EndpointError> {
135        let endpoint = Self::new_inner(value, true)?;
136        policy
137            .verify(
138                endpoint
139                    .identity()
140                    .map_err(|_| EndpointError::IdentityRejected)?,
141            )
142            .map_err(|_| EndpointError::PolicyRejected)?;
143        Ok(endpoint)
144    }
145
146    /// Explicitly trusts and validates a custom HTTPS credential destination.
147    ///
148    /// The transport sends its bearer token to this origin. `value` must come
149    /// from trusted operator configuration and must never be controlled by a
150    /// tenant, request payload, or other untrusted input.
151    ///
152    /// ```compile_fail
153    /// use cloud_sdk_reqwest::blocking::HttpsEndpoint;
154    ///
155    /// let endpoint = HttpsEndpoint::new_custom("https://api.example.test/v1");
156    /// ```
157    pub fn new_custom(
158        value: &str,
159        acknowledgement: CustomEndpointAcknowledgement,
160    ) -> Result<Self, EndpointError> {
161        let endpoint = Self::new_inner(value, true)?;
162        let identity = endpoint
163            .identity()
164            .map_err(|_| EndpointError::IdentityRejected)?;
165        EndpointPolicy::acknowledged_custom(AcknowledgedCustomEndpoint::new(
166            identity,
167            acknowledgement,
168        ))
169        .verify(identity)
170        .map_err(|_| EndpointError::PolicyRejected)?;
171        Ok(endpoint)
172    }
173
174    fn new_inner(value: &str, require_https: bool) -> Result<Self, EndpointError> {
175        if value.len() > MAX_CONFIGURED_ENDPOINT_BYTES {
176            return Err(EndpointError::InputTooLong);
177        }
178        validate_raw_authority(value)?;
179        let configured_path = validate_configured_base_path(value)?;
180        let base = Url::parse(value).map_err(|_| EndpointError::InvalidUrl)?;
181        if base.path() != configured_path {
182            return Err(EndpointError::TargetNormalized);
183        }
184        if require_https && base.scheme() != "https" {
185            return Err(EndpointError::HttpsRequired);
186        }
187        if base.host_str().is_none() {
188            return Err(EndpointError::MissingHost);
189        }
190        if !base.username().is_empty() || base.password().is_some() {
191            return Err(EndpointError::CredentialsForbidden);
192        }
193        if base.query().is_some() {
194            return Err(EndpointError::QueryForbidden);
195        }
196        if base.fragment().is_some() {
197            return Err(EndpointError::FragmentForbidden);
198        }
199        if base.path() != "/" && base.path().ends_with('/') {
200            return Err(EndpointError::TrailingSlash);
201        }
202
203        let mut prefix = String::from(base.as_str());
204        if base.path() == "/" {
205            prefix.pop();
206        }
207        let endpoint = Self { base, prefix };
208        endpoint
209            .identity()
210            .map_err(|_| EndpointError::IdentityRejected)?;
211        Ok(endpoint)
212    }
213
214    /// Returns the normalized scheme, host, effective port, and base path.
215    pub fn identity(&self) -> Result<EndpointIdentity<'_>, EndpointIdentityError> {
216        let scheme = match self.base.scheme() {
217            "https" => EndpointScheme::Https,
218            "http" => EndpointScheme::Http,
219            _ => return Err(EndpointIdentityError::InvalidBasePath),
220        };
221        let host = self
222            .base
223            .host_str()
224            .ok_or(EndpointIdentityError::EmptyHost)?;
225        let port = self
226            .base
227            .port_or_known_default()
228            .ok_or(EndpointIdentityError::InvalidPort)?;
229        EndpointIdentity::new(scheme, host, port, self.base.path())
230    }
231
232    pub(crate) fn compose(&self, target: RequestTarget<'_>) -> Result<Url, EndpointError> {
233        let mut absolute = self.prefix.clone();
234        absolute
235            .try_reserve_exact(target.as_str().len())
236            .map_err(|_| EndpointError::AllocationFailed)?;
237        absolute.push_str(target.as_str());
238        let url = Url::parse(&absolute).map_err(|_| EndpointError::InvalidUrl)?;
239        if url.as_str() != absolute {
240            return Err(EndpointError::TargetNormalized);
241        }
242        self.verify_origin(&url)?;
243        Ok(url)
244    }
245
246    pub(crate) fn verify_origin(&self, url: &Url) -> Result<(), EndpointError> {
247        if url.scheme() != self.base.scheme()
248            || url.host_str() != self.base.host_str()
249            || url.port_or_known_default() != self.base.port_or_known_default()
250            || !url.username().is_empty()
251            || url.password().is_some()
252        {
253            return Err(EndpointError::TargetNormalized);
254        }
255        Ok(())
256    }
257
258    #[cfg(test)]
259    pub(crate) fn local_http(value: &str) -> Result<Self, EndpointError> {
260        let endpoint = Self::new_inner(value, false)?;
261        if endpoint.base.scheme() != "http"
262            || !endpoint.base.host().is_some_and(|host| {
263                let text = host.to_string();
264                text.strip_prefix('[')
265                    .and_then(|value| value.strip_suffix(']'))
266                    .unwrap_or(&text)
267                    .parse::<std::net::IpAddr>()
268                    .is_ok_and(|ip| ip.is_loopback())
269            })
270        {
271            return Err(EndpointError::HttpsRequired);
272        }
273        Ok(endpoint)
274    }
275}
276
277fn validate_raw_authority(value: &str) -> Result<(), EndpointError> {
278    let (scheme, remainder) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
279    if scheme.is_empty()
280        || !scheme.is_ascii()
281        || scheme.bytes().any(|byte| byte.is_ascii_uppercase())
282    {
283        return Err(EndpointError::AmbiguousAuthority);
284    }
285    let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
286    let authority = remainder
287        .get(..authority_end)
288        .ok_or(EndpointError::AmbiguousAuthority)?;
289    if authority.is_empty() {
290        return Err(EndpointError::MissingHost);
291    }
292    if authority.contains('@') {
293        return Err(EndpointError::CredentialsForbidden);
294    }
295    if !authority.is_ascii() || authority.contains(['%', '\\']) {
296        return Err(EndpointError::AmbiguousAuthority);
297    }
298
299    let (host, port) = split_host_port(authority)?;
300    if host.bytes().any(|byte| byte.is_ascii_uppercase()) {
301        return Err(EndpointError::AmbiguousAuthority);
302    }
303    if let Some(port) = port
304        && (port.is_empty()
305            || !port.bytes().all(|byte| byte.is_ascii_digit())
306            || port.len() > 1 && port.starts_with('0')
307            || port
308                .parse::<u16>()
309                .ok()
310                .filter(|value| *value != 0)
311                .is_none())
312    {
313        return Err(EndpointError::AmbiguousAuthority);
314    }
315    EndpointIdentity::new(EndpointScheme::Https, host, 1, "/")
316        .map(|_| ())
317        .map_err(|_| EndpointError::AmbiguousAuthority)
318}
319
320fn split_host_port(authority: &str) -> Result<(&str, Option<&str>), EndpointError> {
321    if authority.starts_with('[') {
322        let close = authority
323            .find(']')
324            .ok_or(EndpointError::AmbiguousAuthority)?;
325        let host_end = close
326            .checked_add(1)
327            .ok_or(EndpointError::AmbiguousAuthority)?;
328        let host = authority
329            .get(..host_end)
330            .ok_or(EndpointError::AmbiguousAuthority)?;
331        let suffix = authority
332            .get(host_end..)
333            .ok_or(EndpointError::AmbiguousAuthority)?;
334        if suffix.is_empty() {
335            return Ok((host, None));
336        }
337        let port = suffix
338            .strip_prefix(':')
339            .ok_or(EndpointError::AmbiguousAuthority)?;
340        return Ok((host, Some(port)));
341    }
342    if authority.matches(':').count() > 1 {
343        return Err(EndpointError::AmbiguousAuthority);
344    }
345    Ok(match authority.rsplit_once(':') {
346        Some((host, port)) => (host, Some(port)),
347        None => (authority, None),
348    })
349}
350
351fn validate_configured_base_path(value: &str) -> Result<&str, EndpointError> {
352    let (_, authority_and_path) = value.split_once("://").ok_or(EndpointError::InvalidUrl)?;
353    let suffix_start = authority_and_path
354        .find(['/', '?', '#'])
355        .unwrap_or(authority_and_path.len());
356    let suffix = authority_and_path
357        .get(suffix_start..)
358        .ok_or(EndpointError::IdentityRejected)?;
359    let path = if suffix.starts_with('/') {
360        let path_end = suffix.find(['?', '#']).unwrap_or(suffix.len());
361        suffix
362            .get(..path_end)
363            .ok_or(EndpointError::IdentityRejected)?
364    } else {
365        "/"
366    };
367    if path.len() > MAX_ENDPOINT_BASE_PATH_BYTES
368        || path.contains("//")
369        || path.split('/').any(|part| matches!(part, "." | ".."))
370        || !path
371            .bytes()
372            .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'%' | b'?' | b'#'))
373    {
374        return Err(EndpointError::IdentityRejected);
375    }
376    Ok(path)
377}
378
379#[cfg(test)]
380mod link_local_tests {
381    use cloud_sdk::transport::{EndpointIdentity, EndpointPolicy, EndpointScheme};
382
383    use super::{EndpointError, LinkLocalHttpEndpoint};
384
385    #[test]
386    fn link_local_http_requires_exact_provider_policy() {
387        let identity = EndpointIdentity::new(EndpointScheme::Http, "169.254.169.254", 80, "/")
388            .unwrap_or_else(|_| unreachable!("valid fixed endpoint"));
389        let policy = EndpointPolicy::fixed(identity);
390        let endpoint = LinkLocalHttpEndpoint::new_with_policy("http://169.254.169.254", policy);
391        let Ok(endpoint) = endpoint else {
392            unreachable!("fixed link-local endpoint was rejected");
393        };
394        assert_eq!(endpoint.identity(), Ok(identity));
395
396        for value in [
397            "https://169.254.169.254",
398            "http://10.0.0.1",
399            "http://169.254.169.253",
400            "http://169.254.169.254:81",
401            "http://169.254.169.254/2009-04-04/meta-data",
402        ] {
403            assert!(matches!(
404                LinkLocalHttpEndpoint::new_with_policy(value, policy),
405                Err(EndpointError::PolicyRejected)
406            ));
407        }
408    }
409}