use std::hash::{Hash, Hasher}; use std::{fmt::Display, fmt::Formatter}; use url::Url; use uv_redacted::DisplaySafeUrl; use uv_small_str::SmallString; /// Used to determine if authentication information should be retained on a new URL. /// Based on the specification defined in RFC 7235 and 7230. /// /// /// // // The "scheme" and "authority" components must match to retain authentication // The "authority", is composed of the host and port. // // The scheme must always be an exact match. // Note some clients such as Python's `requests` library allow an upgrade // from `http` to `https` but this is not spec-compliant. // // // The host must always be an exact match. // // The port is only allowed to differ if it matches the "default port" for the scheme. // However, `url` (and therefore `reqwest`) sets the `port` to `None` if it matches the default port // so we do not need any special handling here. #[derive(Debug, Clone)] pub struct Realm { scheme: SmallString, host: Option, port: Option, } impl From<&DisplaySafeUrl> for Realm { fn from(url: &DisplaySafeUrl) -> Self { Self::from(&**url) } } impl From<&Url> for Realm { fn from(url: &Url) -> Self { Self { scheme: SmallString::from(url.scheme()), host: url.host_str().map(SmallString::from), port: url.port(), } } } impl Display for Realm { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if let Some(port) = self.port { write!( f, "{}://{}:{port}", self.scheme, self.host.as_deref().unwrap_or_default() ) } else { write!( f, "{}://{}", self.scheme, self.host.as_deref().unwrap_or_default() ) } } } impl PartialEq for Realm { fn eq(&self, other: &Self) -> bool { RealmRef::from(self) == RealmRef::from(other) } } impl Eq for Realm {} impl Hash for Realm { fn hash(&self, state: &mut H) { RealmRef::from(self).hash(state); } } /// A reference to a [`Realm`] that can be used for zero-allocation comparisons. #[derive(Debug, Copy, Clone)] pub struct RealmRef<'a> { scheme: &'a str, host: Option<&'a str>, port: Option, } impl RealmRef<'_> { /// Returns true if this realm is a subdomain of the other realm. pub(crate) fn is_subdomain_of(&self, other: Self) -> bool { other.scheme == self.scheme && other.port == self.port && other.host.is_some_and(|other_host| { self.host.is_some_and(|self_host| { self_host .strip_suffix(other_host) .is_some_and(|prefix| prefix.ends_with('.')) }) }) } } impl<'a> From<&'a Url> for RealmRef<'a> { fn from(url: &'a Url) -> Self { Self { scheme: url.scheme(), host: url.host_str(), port: url.port(), } } } impl PartialEq for RealmRef<'_> { fn eq(&self, other: &Self) -> bool { self.scheme == other.scheme && self.host == other.host && self.port == other.port } } impl Eq for RealmRef<'_> {} impl Hash for RealmRef<'_> { fn hash(&self, state: &mut H) { self.scheme.hash(state); self.host.hash(state); self.port.hash(state); } } impl<'a> PartialEq> for Realm { fn eq(&self, rhs: &RealmRef<'a>) -> bool { RealmRef::from(self) == *rhs } } impl PartialEq for RealmRef<'_> { fn eq(&self, rhs: &Realm) -> bool { *self == RealmRef::from(rhs) } } impl<'a> From<&'a Realm> for RealmRef<'a> { fn from(realm: &'a Realm) -> Self { Self { scheme: &realm.scheme, host: realm.host.as_deref(), port: realm.port, } } } #[cfg(test)] mod tests { use url::{ParseError, Url}; use crate::Realm; #[test] fn test_should_retain_auth() -> Result<(), ParseError> { // Exact match (https) assert_eq!( Realm::from(&Url::parse("https://example.com")?), Realm::from(&Url::parse("https://example.com")?) ); // Exact match (with port) assert_eq!( Realm::from(&Url::parse("https://example.com:1234")?), Realm::from(&Url::parse("https://example.com:1234")?) ); // Exact match (http) assert_eq!( Realm::from(&Url::parse("http://example.com")?), Realm::from(&Url::parse("http://example.com")?) ); // Okay, path differs assert_eq!( Realm::from(&Url::parse("http://example.com/foo")?), Realm::from(&Url::parse("http://example.com/bar")?) ); // Okay, default port differs (https) assert_eq!( Realm::from(&Url::parse("https://example.com:443")?), Realm::from(&Url::parse("https://example.com")?) ); // Okay, default port differs (http) assert_eq!( Realm::from(&Url::parse("http://example.com:80")?), Realm::from(&Url::parse("http://example.com")?) ); // Mismatched scheme assert_ne!( Realm::from(&Url::parse("https://example.com")?), Realm::from(&Url::parse("http://example.com")?) ); // Mismatched scheme, we explicitly do not allow upgrade to https assert_ne!( Realm::from(&Url::parse("http://example.com")?), Realm::from(&Url::parse("https://example.com")?) ); // Mismatched host assert_ne!( Realm::from(&Url::parse("https://foo.com")?), Realm::from(&Url::parse("https://bar.com")?) ); // Mismatched port assert_ne!( Realm::from(&Url::parse("https://example.com:1234")?), Realm::from(&Url::parse("https://example.com:5678")?) ); // Mismatched port, with one as default for scheme assert_ne!( Realm::from(&Url::parse("https://example.com:443")?), Realm::from(&Url::parse("https://example.com:5678")?) ); assert_ne!( Realm::from(&Url::parse("https://example.com:1234")?), Realm::from(&Url::parse("https://example.com:443")?) ); // Mismatched port, with default for a different scheme assert_ne!( Realm::from(&Url::parse("https://example.com:80")?), Realm::from(&Url::parse("https://example.com")?) ); Ok(()) } #[test] fn test_is_subdomain_of() -> Result<(), ParseError> { use crate::realm::RealmRef; // Subdomain relationship: sub.example.com is a subdomain of example.com let subdomain_url = Url::parse("https://sub.example.com")?; let domain_url = Url::parse("https://example.com")?; let subdomain = RealmRef::from(&subdomain_url); let domain = RealmRef::from(&domain_url); assert!(subdomain.is_subdomain_of(domain)); // Deeper subdomain: foo.bar.example.com is a subdomain of example.com let deep_subdomain_url = Url::parse("https://foo.bar.example.com")?; let deep_subdomain = RealmRef::from(&deep_subdomain_url); assert!(deep_subdomain.is_subdomain_of(domain)); // Deeper subdomain: foo.bar.example.com is also a subdomain of bar.example.com let parent_subdomain_url = Url::parse("https://bar.example.com")?; let parent_subdomain = RealmRef::from(&parent_subdomain_url); assert!(deep_subdomain.is_subdomain_of(parent_subdomain)); // Not a subdomain: example.com is not a subdomain of sub.example.com assert!(!domain.is_subdomain_of(subdomain)); // Same domain is not a subdomain of itself assert!(!domain.is_subdomain_of(domain)); // Different TLD: example.org is not a subdomain of example.com let different_tld_url = Url::parse("https://example.org")?; let different_tld = RealmRef::from(&different_tld_url); assert!(!different_tld.is_subdomain_of(domain)); // Partial match but not a subdomain: notexample.com is not a subdomain of example.com let partial_match_url = Url::parse("https://notexample.com")?; let partial_match = RealmRef::from(&partial_match_url); assert!(!partial_match.is_subdomain_of(domain)); // Different scheme: http subdomain is not a subdomain of https domain let http_subdomain_url = Url::parse("http://sub.example.com")?; let https_domain_url = Url::parse("https://example.com")?; let http_subdomain = RealmRef::from(&http_subdomain_url); let https_domain = RealmRef::from(&https_domain_url); assert!(!http_subdomain.is_subdomain_of(https_domain)); // Different port: same subdomain with different port is not a subdomain let subdomain_port_8080_url = Url::parse("https://sub.example.com:8080")?; let domain_port_9090_url = Url::parse("https://example.com:9090")?; let subdomain_port_8080 = RealmRef::from(&subdomain_port_8080_url); let domain_port_9090 = RealmRef::from(&domain_port_9090_url); assert!(!subdomain_port_8080.is_subdomain_of(domain_port_9090)); // Same port: subdomain with same explicit port is a subdomain let subdomain_with_port_url = Url::parse("https://sub.example.com:8080")?; let domain_with_port_url = Url::parse("https://example.com:8080")?; let subdomain_with_port = RealmRef::from(&subdomain_with_port_url); let domain_with_port = RealmRef::from(&domain_with_port_url); assert!(subdomain_with_port.is_subdomain_of(domain_with_port)); // Default port handling: subdomain with implicit port is a subdomain let subdomain_default_url = Url::parse("https://sub.example.com")?; let domain_explicit_443_url = Url::parse("https://example.com:443")?; let subdomain_default = RealmRef::from(&subdomain_default_url); let domain_explicit_443 = RealmRef::from(&domain_explicit_443_url); assert!(subdomain_default.is_subdomain_of(domain_explicit_443)); // Edge case: empty host (shouldn't happen with valid URLs but testing defensive code) let file_url = Url::parse("file:///path/to/file")?; let https_url = Url::parse("https://example.com")?; let file_realm = RealmRef::from(&file_url); let https_realm = RealmRef::from(&https_url); assert!(!file_realm.is_subdomain_of(https_realm)); assert!(!https_realm.is_subdomain_of(file_realm)); // Subdomain with path (path should be ignored) let subdomain_with_path_url = Url::parse("https://sub.example.com/path")?; let domain_with_path_url = Url::parse("https://example.com/other")?; let subdomain_with_path = RealmRef::from(&subdomain_with_path_url); let domain_with_path = RealmRef::from(&domain_with_path_url); assert!(subdomain_with_path.is_subdomain_of(domain_with_path)); Ok(()) } }