aws_smithy_http_client/client/pool/
origin.rs1use 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#[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 pub fn from_uri(uri: &Uri) -> Result<Self, InvalidOrigin> {
40 OriginLookup::from_uri(uri).map(OriginLookup::into_owned)
41 }
42
43 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 pub fn scheme(&self) -> &Scheme {
79 self.scheme.as_scheme()
80 }
81
82 pub fn host(&self) -> &str {
84 &self.host
85 }
86
87 pub fn port(&self) -> Option<u16> {
89 self.port.map(NonZeroU16::get)
90 }
91
92 pub(super) fn shared_host(&self) -> Arc<str> {
94 self.host.clone()
95 }
96}
97
98#[derive(Debug)]
100pub struct InvalidOrigin {
101 message: String,
102 source: Option<Box<dyn Error + Send + Sync + 'static>>,
103}
104
105impl InvalidOrigin {
106 fn new(message: impl Into<String>) -> Self {
108 Self {
109 message: message.into(),
110 source: None,
111 }
112 }
113
114 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
139pub(super) enum SchemeKey {
140 Http,
141 Https,
142}
143
144impl SchemeKey {
145 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 fn as_scheme(self) -> &'static Scheme {
161 match self {
162 Self::Http => &Scheme::HTTP,
163 Self::Https => &Scheme::HTTPS,
164 }
165 }
166}
167
168pub(super) struct OriginLookup<'a> {
174 scheme: SchemeKey,
175 host: Cow<'a, str>,
176 port: Option<NonZeroU16>,
177}
178
179impl<'a> OriginLookup<'a> {
180 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 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 pub(super) fn scheme(&self) -> SchemeKey {
215 self.scheme
216 }
217
218 pub(super) fn host(&self) -> &str {
220 &self.host
221 }
222
223 pub(super) fn port(&self) -> Option<NonZeroU16> {
225 self.port
226 }
227
228 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 pub(super) fn owned_origin_key_materializations_for_test() -> usize {
242 OWNED_ORIGIN_KEY_MATERIALIZATIONS.with(Cell::get)
243 }
244}
245
246fn 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
258fn 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
272fn 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
290fn 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
326fn 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}