Skip to main content

aws_smithy_http_client/client/pool/
origin.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Canonical HTTP origin identity.
7//!
8//! [`OriginLookup`] borrows canonical URI host text when possible. A miss
9//! materializes an owned [`OriginKey`] for retained pool state; a canonical
10//! lookup hit does not allocate host storage.
11
12use http_1x::uri::{Authority, Scheme};
13use http_1x::Uri;
14use std::borrow::Cow;
15#[cfg(test)]
16use std::cell::Cell;
17use std::error::Error;
18use std::fmt;
19use std::net::Ipv6Addr;
20use std::num::NonZeroU16;
21use std::str::FromStr;
22use std::sync::Arc;
23
24#[cfg(test)]
25std::thread_local! {
26    static OWNED_ORIGIN_KEY_MATERIALIZATIONS: Cell<usize> = const { Cell::new(0) };
27}
28
29/// An owned, canonical HTTP or HTTPS origin.
30#[derive(Clone, Debug, Eq, Hash, PartialEq)]
31pub struct OriginKey {
32    scheme: SchemeKey,
33    host: Arc<str>,
34    port: Option<NonZeroU16>,
35}
36
37impl OriginKey {
38    /// Creates an origin from an absolute HTTP or HTTPS URI.
39    pub fn from_uri(uri: &Uri) -> Result<Self, InvalidOrigin> {
40        OriginLookup::from_uri(uri).map(OriginLookup::into_owned)
41    }
42
43    /// Creates an origin from structured scheme, host, and port parts.
44    ///
45    /// The `host` must not contain user information or a port. IPv6 literals
46    /// use the bracketed authority form, for example `[::1]`.
47    pub fn from_parts(
48        scheme: Scheme,
49        host: impl AsRef<str>,
50        port: Option<u16>,
51    ) -> Result<Self, InvalidOrigin> {
52        let scheme = SchemeKey::from_scheme(&scheme)?;
53        let host = host.as_ref();
54        let authority = Authority::from_str(host).map_err(|source| {
55            InvalidOrigin::with_source(format!("invalid origin host {host:?}"), source)
56        })?;
57
58        if authority.as_str().contains('@') || authority_port_text(&authority).is_some() {
59            return Err(InvalidOrigin::new(
60                "origin host must not contain user information or a port",
61            ));
62        }
63
64        let host = authority.host();
65        if host.is_empty() {
66            return Err(InvalidOrigin::new("origin host must not be empty"));
67        }
68
69        Ok(OriginLookup {
70            scheme,
71            host: canonical_host(host),
72            port: canonical_port(scheme, port)?,
73        }
74        .into_owned())
75    }
76
77    /// Returns this origin's HTTP or HTTPS scheme.
78    pub fn scheme(&self) -> &Scheme {
79        self.scheme.as_scheme()
80    }
81
82    /// Returns this origin's canonical host.
83    pub fn host(&self) -> &str {
84        &self.host
85    }
86
87    /// Returns the non-default port, if one was specified.
88    pub fn port(&self) -> Option<u16> {
89        self.port.map(NonZeroU16::get)
90    }
91
92    /// Clones the shared host pointer for use as an index key.
93    pub(super) fn shared_host(&self) -> Arc<str> {
94        self.host.clone()
95    }
96}
97
98/// Error returned when a URI or set of parts does not name an HTTP origin.
99#[derive(Debug)]
100pub struct InvalidOrigin {
101    message: String,
102    source: Option<Box<dyn Error + Send + Sync + 'static>>,
103}
104
105impl InvalidOrigin {
106    /// Creates an origin error with no lower-level cause.
107    fn new(message: impl Into<String>) -> Self {
108        Self {
109            message: message.into(),
110            source: None,
111        }
112    }
113
114    /// Creates an origin error that preserves the parsing failure as its source.
115    fn with_source(message: impl Into<String>, source: impl Error + Send + Sync + 'static) -> Self {
116        Self {
117            message: message.into(),
118            source: Some(Box::new(source)),
119        }
120    }
121}
122
123impl fmt::Display for InvalidOrigin {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.write_str(&self.message)
126    }
127}
128
129impl Error for InvalidOrigin {
130    fn source(&self) -> Option<&(dyn Error + 'static)> {
131        self.source
132            .as_deref()
133            .map(|source| source as &(dyn Error + 'static))
134    }
135}
136
137/// Compact HTTP or HTTPS scheme identity.
138#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
139pub(super) enum SchemeKey {
140    Http,
141    Https,
142}
143
144impl SchemeKey {
145    /// Accepts HTTP or HTTPS case-insensitively and rejects every other scheme.
146    fn from_scheme(scheme: &Scheme) -> Result<Self, InvalidOrigin> {
147        if scheme.as_str().eq_ignore_ascii_case("http") {
148            Ok(Self::Http)
149        } else if scheme.as_str().eq_ignore_ascii_case("https") {
150            Ok(Self::Https)
151        } else {
152            Err(InvalidOrigin::new(format!(
153                "unsupported origin scheme {:?}; expected http or https",
154                scheme.as_str()
155            )))
156        }
157    }
158
159    /// Returns the canonical static `http` or `https` scheme.
160    fn as_scheme(self) -> &'static Scheme {
161        match self {
162            Self::Http => &Scheme::HTTP,
163            Self::Https => &Scheme::HTTPS,
164        }
165    }
166}
167
168/// Borrowed canonical origin used to probe retained cells.
169///
170/// A lowercase URI host remains borrowed on a lookup hit. After a cell miss,
171/// converting it into an [`OriginKey`] allocates the host retained by the new
172/// cell.
173pub(super) struct OriginLookup<'a> {
174    scheme: SchemeKey,
175    host: Cow<'a, str>,
176    port: Option<NonZeroU16>,
177}
178
179impl<'a> OriginLookup<'a> {
180    /// Borrows every identity component from an already-owned origin.
181    pub(super) fn from_origin(origin: &'a OriginKey) -> Self {
182        Self {
183            scheme: origin.scheme,
184            host: Cow::Borrowed(&origin.host),
185            port: origin.port,
186        }
187    }
188
189    /// Canonicalizes an absolute HTTP or HTTPS URI for retained-state lookup.
190    ///
191    /// Host storage remains borrowed when the URI already uses its canonical
192    /// spelling.
193    pub(super) fn from_uri(uri: &'a Uri) -> Result<Self, InvalidOrigin> {
194        let scheme = uri
195            .scheme()
196            .ok_or_else(|| InvalidOrigin::new("origin URI is missing a scheme"))
197            .and_then(SchemeKey::from_scheme)?;
198        let authority = uri
199            .authority()
200            .ok_or_else(|| InvalidOrigin::new("origin URI is missing an authority"))?;
201        let host = authority.host();
202        if host.is_empty() {
203            return Err(InvalidOrigin::new("origin URI is missing a host"));
204        }
205
206        Ok(Self {
207            scheme,
208            host: canonical_host(host),
209            port: canonical_port(scheme, parse_authority_port(authority)?)?,
210        })
211    }
212
213    /// Returns the compact canonical scheme used by the first-level index.
214    pub(super) fn scheme(&self) -> SchemeKey {
215        self.scheme
216    }
217
218    /// Returns the canonical host without materializing owned storage.
219    pub(super) fn host(&self) -> &str {
220        &self.host
221    }
222
223    /// Returns the canonical non-default port used by the first-level index.
224    pub(super) fn port(&self) -> Option<NonZeroU16> {
225        self.port
226    }
227
228    /// Materializes the stable identity retained after a lookup miss.
229    pub(super) fn into_owned(self) -> OriginKey {
230        #[cfg(test)]
231        OWNED_ORIGIN_KEY_MATERIALIZATIONS.with(|count| count.set(count.get() + 1));
232        OriginKey {
233            scheme: self.scheme,
234            host: Arc::from(self.host),
235            port: self.port,
236        }
237    }
238
239    #[cfg(test)]
240    /// Returns the number of lookup values converted into retained identities.
241    pub(super) fn owned_origin_key_materializations_for_test() -> usize {
242        OWNED_ORIGIN_KEY_MATERIALIZATIONS.with(Cell::get)
243    }
244}
245
246/// Returns a non-default port, omitting scheme defaults and rejecting zero.
247fn canonical_port(
248    scheme: SchemeKey,
249    port: Option<u16>,
250) -> Result<Option<NonZeroU16>, InvalidOrigin> {
251    match (scheme, port) {
252        (_, Some(0)) => Err(InvalidOrigin::new("origin port must not be zero")),
253        (SchemeKey::Http, Some(80)) | (SchemeKey::Https, Some(443)) | (_, None) => Ok(None),
254        (_, Some(port)) => Ok(NonZeroU16::new(port)),
255    }
256}
257
258/// Parses an explicitly written authority port.
259///
260/// Invalid and absent ports remain distinct so malformed origins cannot alias
261/// an origin with the scheme's default port.
262fn parse_authority_port(authority: &Authority) -> Result<Option<u16>, InvalidOrigin> {
263    let Some(port) = authority_port_text(authority) else {
264        return Ok(None);
265    };
266
267    port.parse().map(Some).map_err(|source| {
268        InvalidOrigin::with_source(format!("invalid origin port {port:?}"), source)
269    })
270}
271
272/// Returns the port text following an authority host, if one was written.
273///
274/// This reads the authority source because `Authority::port_u16` returns
275/// `None` for both an absent port and a malformed or out-of-range port.
276fn authority_port_text(authority: &Authority) -> Option<&str> {
277    let host_and_port = authority
278        .as_str()
279        .rsplit_once('@')
280        .map_or(authority.as_str(), |(_, host_and_port)| host_and_port);
281
282    if host_and_port.starts_with('[') {
283        let close = host_and_port.find(']')?;
284        host_and_port[close + 1..].strip_prefix(':')
285    } else {
286        host_and_port.rsplit_once(':').map(|(_, port)| port)
287    }
288}
289
290/// Canonicalizes DNS names and recognized IPv6 literals, borrowing when unchanged.
291///
292/// DNS names are ASCII-lowercased. IPv6 addresses use the standard compressed
293/// spelling while retaining the zone identifier's case. Unknown IP-literal
294/// forms remain byte-distinct because their case semantics are not known.
295fn canonical_host(host: &str) -> Cow<'_, str> {
296    if let Some(inner) = host
297        .strip_prefix('[')
298        .and_then(|host| host.strip_suffix(']'))
299    {
300        let (address, zone) = inner
301            .split_once("%25")
302            .map_or((inner, None), |(address, zone)| (address, Some(zone)));
303        if let Ok(address) = address.parse::<Ipv6Addr>() {
304            return if canonical_ipv6_matches(host, address, zone) {
305                Cow::Borrowed(host)
306            } else {
307                Cow::Owned(match zone {
308                    Some(zone) => format!("[{address}%25{zone}]"),
309                    None => format!("[{address}]"),
310                })
311            };
312        }
313
314        return Cow::Borrowed(host);
315    }
316
317    if host.as_bytes().iter().any(u8::is_ascii_uppercase) {
318        let mut canonical = host.to_owned();
319        canonical.make_ascii_lowercase();
320        Cow::Owned(canonical)
321    } else {
322        Cow::Borrowed(host)
323    }
324}
325
326/// Compares an IPv6 literal with its canonical formatting without allocating.
327fn canonical_ipv6_matches(host: &str, address: Ipv6Addr, zone: Option<&str>) -> bool {
328    struct CompareWriter<'a> {
329        expected: &'a [u8],
330        offset: usize,
331        matches: bool,
332    }
333
334    impl fmt::Write for CompareWriter<'_> {
335        fn write_str(&mut self, value: &str) -> fmt::Result {
336            let end = self.offset.saturating_add(value.len());
337            if self.expected.get(self.offset..end) != Some(value.as_bytes()) {
338                self.matches = false;
339            }
340            self.offset = end;
341            Ok(())
342        }
343    }
344
345    let mut writer = CompareWriter {
346        expected: host.as_bytes(),
347        offset: 0,
348        matches: true,
349    };
350    let result = match zone {
351        Some(zone) => fmt::write(&mut writer, format_args!("[{address}%25{zone}]")),
352        None => fmt::write(&mut writer, format_args!("[{address}]")),
353    };
354    result.is_ok() && writer.matches && writer.offset == writer.expected.len()
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn canonical_host_borrows_common_case() {
363        assert!(matches!(canonical_host("example.com"), Cow::Borrowed(_)));
364        assert!(matches!(
365            canonical_host("EXAMPLE.com"),
366            Cow::Owned(host) if host == "example.com"
367        ));
368    }
369
370    #[test]
371    fn canonicalizes_host_and_default_port() {
372        let a = OriginKey::from_uri(&"https://EXAMPLE.com:443/a".parse().unwrap()).unwrap();
373        let b = OriginKey::from_uri(&"https://example.com/b".parse().unwrap()).unwrap();
374
375        assert_eq!(a, b);
376        assert_eq!("example.com", a.host());
377        assert_eq!(None, a.port());
378    }
379
380    #[test]
381    fn non_default_port_and_scheme_remain_identity() {
382        let base = OriginKey::from_uri(&"https://example.com/".parse().unwrap()).unwrap();
383        let port = OriginKey::from_uri(&"https://example.com:8443/".parse().unwrap()).unwrap();
384        let clear = OriginKey::from_uri(&"http://example.com/".parse().unwrap()).unwrap();
385
386        assert_ne!(base, port);
387        assert_ne!(base, clear);
388        assert_eq!(Some(8443), port.port());
389    }
390
391    #[test]
392    fn rejects_invalid_and_zero_ports() {
393        for uri in [
394            "https://example.com:/",
395            "https://example.com:abc/",
396            "https://example.com:99999/",
397            "https://example.com:0/",
398        ] {
399            let uri: Uri = uri.parse().unwrap();
400            assert!(OriginKey::from_uri(&uri).is_err(), "{uri}");
401        }
402
403        assert!(OriginKey::from_parts(Scheme::HTTPS, "example.com", Some(0)).is_err());
404        assert!(OriginKey::from_parts(Scheme::HTTPS, "example.com:abc", None).is_err());
405        assert!(OriginKey::from_parts(Scheme::HTTPS, "example.com:99999", None).is_err());
406    }
407
408    #[test]
409    fn trailing_dot_remains_distinct() {
410        let plain = OriginKey::from_uri(&"https://example.com/".parse().unwrap()).unwrap();
411        let dotted = OriginKey::from_uri(&"https://example.com./".parse().unwrap()).unwrap();
412        assert_ne!(plain, dotted);
413    }
414
415    #[test]
416    fn parts_and_uri_canonicalize_identically() {
417        let from_parts = OriginKey::from_parts(Scheme::HTTPS, "EXAMPLE.com", Some(443)).unwrap();
418        let from_uri = OriginKey::from_uri(&"https://example.com/".parse().unwrap()).unwrap();
419        assert_eq!(from_parts, from_uri);
420    }
421
422    #[test]
423    fn parts_accept_mixed_case_http_scheme() {
424        let scheme = Scheme::from_str("HTTPS").unwrap();
425        let origin = OriginKey::from_parts(scheme, "example.com", None).unwrap();
426        assert_eq!(&Scheme::HTTPS, origin.scheme());
427    }
428
429    #[test]
430    fn uri_userinfo_is_not_part_of_the_origin() {
431        let with_userinfo =
432            OriginKey::from_uri(&"https://user:password@example.com/".parse().unwrap()).unwrap();
433        let plain = OriginKey::from_uri(&"https://example.com/".parse().unwrap()).unwrap();
434        assert_eq!(plain, with_userinfo);
435    }
436
437    #[test]
438    fn ipv6_parts_and_uri_canonicalize_identically() {
439        let from_parts =
440            OriginKey::from_parts(Scheme::HTTPS, "[2001:0db8:0:0:0:0:0:1]", Some(443)).unwrap();
441        let from_uri = OriginKey::from_uri(&"https://[2001:db8::1]:443/".parse().unwrap()).unwrap();
442
443        assert_eq!(from_parts, from_uri);
444        assert_eq!("[2001:db8::1]", from_parts.host());
445        assert_eq!(None, from_parts.port());
446    }
447
448    #[test]
449    fn canonical_ipv6_lookup_remains_borrowed() {
450        assert!(matches!(canonical_host("[2001:db8::1]"), Cow::Borrowed(_)));
451        assert!(matches!(
452            canonical_host("[fe80::1%25ETH0]"),
453            Cow::Borrowed(_)
454        ));
455    }
456
457    #[test]
458    fn ipv6_zone_identifier_case_remains_distinct() {
459        let upper =
460            OriginKey::from_uri(&"https://[fe80:0:0:0::1%25ETH0]/".parse().unwrap()).unwrap();
461        let lower = OriginKey::from_uri(&"https://[fe80::1%25eth0]/".parse().unwrap()).unwrap();
462
463        assert_eq!("[fe80::1%25ETH0]", upper.host());
464        assert_ne!(upper, lower);
465    }
466
467    #[test]
468    fn invalid_origin_preserves_diagnostic_source() {
469        let error = OriginKey::from_parts(Scheme::HTTPS, "not a host", None).unwrap_err();
470        assert!(error.to_string().contains("invalid origin host"));
471        assert!(error.source().is_some());
472
473        let uri: Uri = "https://example.com:abc/".parse().unwrap();
474        let error = OriginKey::from_uri(&uri).unwrap_err();
475        assert_eq!("invalid origin port \"abc\"", error.to_string());
476        assert!(error.source().is_some());
477    }
478
479    #[test]
480    fn rejects_non_origins() {
481        let relative: Uri = "/path".parse().unwrap();
482        assert_eq!(
483            "origin URI is missing a scheme",
484            OriginKey::from_uri(&relative).unwrap_err().to_string()
485        );
486
487        let ftp: Uri = "ftp://example.com/".parse().unwrap();
488        assert!(OriginKey::from_uri(&ftp)
489            .unwrap_err()
490            .to_string()
491            .contains("unsupported origin scheme"));
492
493        assert_eq!(
494            "origin host must not contain user information or a port",
495            OriginKey::from_parts(Scheme::HTTPS, "example.com:443", None)
496                .unwrap_err()
497                .to_string()
498        );
499    }
500}