diff --git a/src/cookie.rs b/src/cookie.rs index 0e8c9cab..17311a37 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -14,13 +14,17 @@ use std::{convert::TryInto, fmt, sync::Arc, time::SystemTime}; use cookie::{Cookie as RawCookie, Expiration, SameSite}; use http::{Uri, Version}; +use url::Host; pub use self::jar::Jar; use crate::{error::Error, header::HeaderValue}; -/// A parsed HTTP cookie. +/// A parsed HTTP cookie with optional [`Jar`] storage metadata. #[derive(Debug, Clone)] -pub struct Cookie<'a>(RawCookie<'a>); +pub struct Cookie<'a> { + inner: RawCookie<'a>, + host: Option>>, +} impl<'a> Cookie<'a> { pub(crate) fn parse(value: &'a HeaderValue) -> crate::Result> { @@ -28,13 +32,22 @@ impl<'a> Cookie<'a> { .map_err(cookie::ParseError::from) .and_then(cookie::Cookie::parse) .map_err(Error::decode) - .map(Cookie) + .map(|cookie| Cookie { + inner: cookie, + host: None, + }) + } + + #[inline] + fn with_host(mut self, host: Host>) -> Self { + self.host = Some(host); + self } /// Returns the name of `self`. #[inline] pub fn name(&self) -> &str { - self.0.name() + self.inner.name() } /// Returns the value of `self`. @@ -42,37 +55,37 @@ impl<'a> Cookie<'a> { /// Does not strip surrounding quotes. #[inline] pub fn value(&self) -> &str { - self.0.value() + self.inner.value() } /// Returns whether this cookie was marked `HttpOnly` or not. #[inline] pub fn http_only(&self) -> bool { - self.0.http_only().unwrap_or(false) + self.inner.http_only().unwrap_or(false) } /// Returns whether this cookie was marked `Secure` or not. #[inline] pub fn secure(&self) -> bool { - self.0.secure().unwrap_or(false) + self.inner.secure().unwrap_or(false) } /// Returns whether the `SameSite` attribute of this cookie is `Lax`. #[inline] pub fn same_site_lax(&self) -> bool { - self.0.same_site() == Some(SameSite::Lax) + self.inner.same_site() == Some(SameSite::Lax) } /// Returns whether the `SameSite` attribute of this cookie is `Strict`. #[inline] pub fn same_site_strict(&self) -> bool { - self.0.same_site() == Some(SameSite::Strict) + self.inner.same_site() == Some(SameSite::Strict) } /// Returns the `Path` of the cookie if one was specified. #[inline] pub fn path(&self) -> Option<&str> { - self.0.path() + self.inner.path() } /// Returns the `Domain` of the cookie if one was specified. @@ -82,13 +95,25 @@ impl<'a> Cookie<'a> { /// stripped. #[inline] pub fn domain(&self) -> Option<&str> { - self.0.domain() + self.inner.domain() + } + + /// Returns the canonical host used to store this cookie. + /// + /// Cookies returned by [`Jar`] query methods include this value. + #[inline] + pub fn host(&self) -> Option> { + self.host.as_ref().map(|host| match host { + Host::Domain(domain) => Host::Domain(domain.as_ref()), + Host::Ipv4(address) => Host::Ipv4(*address), + Host::Ipv6(address) => Host::Ipv6(*address), + }) } /// Returns the specified max-age of the cookie if it is non-negative and representable. #[inline] pub fn max_age(&self) -> Option { - self.0.max_age().and_then(|d| d.try_into().ok()) + self.inner.max_age().and_then(|d| d.try_into().ok()) } /// Returns the expiration date-time of the cookie if one was specified. @@ -96,7 +121,7 @@ impl<'a> Cookie<'a> { /// Session cookies return `None`. #[inline] pub fn expires(&self) -> Option { - match self.0.expires() { + match self.inner.expires() { Some(Expiration::DateTime(offset)) => Some(SystemTime::from(offset)), None | Some(Expiration::Session) => None, } @@ -105,28 +130,34 @@ impl<'a> Cookie<'a> { /// Converts `self` into a `Cookie` with a static lifetime with as few allocations as possible. #[inline] pub fn into_owned(self) -> Cookie<'static> { - Cookie(self.0.into_owned()) + Cookie { + inner: self.inner.into_owned(), + host: self.host, + } } } impl fmt::Display for Cookie<'_> { #[inline] fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - self.0.fmt(f) + self.inner.fmt(f) } } impl<'c> From> for Cookie<'c> { #[inline] fn from(cookie: RawCookie<'c>) -> Cookie<'c> { - Cookie(cookie) + Cookie { + inner: cookie, + host: None, + } } } impl<'c> From> for RawCookie<'c> { #[inline] fn from(cookie: Cookie<'c>) -> RawCookie<'c> { - cookie.0 + cookie.inner } } diff --git a/src/cookie/jar.rs b/src/cookie/jar.rs index d79c97a0..f47e0eab 100644 --- a/src/cookie/jar.rs +++ b/src/cookie/jar.rs @@ -49,7 +49,7 @@ impl Jar { let host = canonical_host(uri.host()?)?; let now = OffsetDateTime::now_utc(); let store = self.0.read(); - let cookie = store + let entry = store .cookies .get(&host)? .get(uri.path())? @@ -57,7 +57,7 @@ impl Jar { .filter(|entry| !cookie_is_expired(&entry.cookie, now)) .min_by_key(|entry| entry.creation_index)?; - Some(Cookie::from(cookie.cookie.clone())) + Some(Cookie::from(entry.cookie.clone()).with_host(host)) } /// Returns whether an unexpired cookie exists for an exact URI scope. @@ -70,6 +70,7 @@ impl Jar { let Some(host) = uri.host().and_then(canonical_host) else { return false; }; + let now = OffsetDateTime::now_utc(); self.0 @@ -84,20 +85,26 @@ impl Jar { }) } - /// Returns all unexpired cookies in the jar. + /// Returns all unexpired cookies in creation order. /// - /// The returned cookies are owned snapshots with their effective stored `Path`. Host-only - /// cookies keep their `Domain` attribute absent, so importing a snapshot into another jar does - /// not broaden its domain scope. Snapshots are returned in creation order. + /// Each snapshot preserves its effective `Path` and canonical storage host. Host-only cookies + /// keep `Domain` absent; use [`Cookie::host`] when restoring them. /// /// # Example /// ``` /// use wreq::cookie::Jar; - /// let jar = Jar::default(); - /// jar.add("foo=bar; Domain=example.com", "http://example.com"); - /// for cookie in jar.get_all() { - /// println!("{}={}", cookie.name(), cookie.value()); + /// let source = Jar::default(); + /// source.add("session=abc; Secure", "https://example.com"); + /// + /// let target = Jar::default(); + /// for cookie in source.get_all() { + /// if let Some(host) = cookie.host() { + /// let scheme = if cookie.secure() { "https" } else { "http" }; + /// let uri = format!("{scheme}://{host}/"); + /// target.add(cookie, uri); + /// } /// } + /// assert_eq!(target.get_all().count(), 1); /// ``` pub fn get_all(&self) -> impl Iterator> { let now = OffsetDateTime::now_utc(); @@ -105,15 +112,16 @@ impl Jar { .0 .read() .cookies - .values() - .flat_map(|path_map| { - path_map.values().flat_map(|cookie_map| { - cookie_map.values().filter_map(|entry| { + .iter() + .flat_map(|(host, path_map)| { + path_map.values().flat_map(move |cookie_map| { + cookie_map.values().filter_map(move |entry| { if cookie_is_expired(&entry.cookie, now) { return None; } - Some((entry.creation_index, Cookie::from(entry.cookie.clone()))) + let cookie = Cookie::from(entry.cookie.clone()).with_host(host.clone()); + Some((entry.creation_index, cookie)) }) }) }) @@ -187,7 +195,7 @@ impl Jar { let store = self.0.read(); store .matching_cookies(&uri, &host, now) - .map(|(_, _, entry)| Cookie::from(entry.cookie.clone())) + .map(|(host, _, entry)| Cookie::from(entry.cookie.clone()).with_host(host.clone())) .collect::>() .into_iter() } @@ -434,9 +442,14 @@ impl CookieStore for Jar { #[cfg(test)] mod tests { - use std::{thread, time::Duration as StdDuration}; + use std::{ + net::{Ipv4Addr, Ipv6Addr}, + thread, + time::Duration as StdDuration, + }; use http::{Uri, Version}; + use url::Host; use super::{CookieStore, Cookies, Jar}; @@ -602,30 +615,60 @@ mod tests { } #[test] - fn jar_get_all_export_import_keeps_host_only_scope_and_effective_path() { + fn jar_get_all_preserves_storage_scope() { let source = Jar::default(); source.add("session=abc", "http://example.com/foo/bar"); + source.add("pref=dark; Domain=example.com", "http://www.example.com/"); + source.add("secure=1; Secure", "https://secure.example/"); + source.add("v6=1", "http://[::1]:8080/"); + source.add("v4=1", "http://127.0.0.1:8080/"); let exported = source.get_all().collect::>(); - assert_eq!(exported.len(), 1); - assert_eq!(exported[0].domain(), None); - assert_eq!(exported[0].path(), Some("/foo")); + assert_eq!(exported.len(), 5); + + let session = &exported[0]; + assert_eq!(session.host(), Some(Host::Domain("example.com"))); + assert_eq!(session.domain(), None); + assert_eq!(session.path(), Some("/foo")); + + let pref = &exported[1]; + assert_eq!(pref.host(), Some(Host::Domain("example.com"))); + assert_eq!(pref.domain(), Some("example.com")); + assert_eq!(exported[2].host(), Some(Host::Domain("secure.example"))); + assert_eq!(exported[3].host(), Some(Host::Ipv6(Ipv6Addr::LOCALHOST))); + assert_eq!(exported[4].host(), Some(Host::Ipv4(Ipv4Addr::LOCALHOST))); + + let exact = source.get("session", "http://example.com/foo").unwrap(); + assert_eq!(exact.host(), Some(Host::Domain("example.com"))); + + let matched = source + .matches("http://example.com/foo/bar") + .find(|cookie| cookie.name() == "session") + .unwrap(); + assert_eq!(matched.host(), Some(Host::Domain("example.com"))); let target = Jar::default(); for cookie in exported { - target.add(cookie, "http://example.com/another/deeper"); + let scheme = if cookie.secure() { "https" } else { "http" }; + let uri = format!("{scheme}://{}/", cookie.host().unwrap()); + target.add(cookie, uri); } - let imported = target.get_all().collect::>(); - assert_eq!(imported.len(), 1); - assert_eq!(imported[0].domain(), None); - assert_eq!(imported[0].path(), Some("/foo")); - - let subdomain = Uri::from_static("http://api.example.com/foo/resource"); - assert!(matches!( - target.cookies(&subdomain, Version::HTTP_11), - Cookies::Empty - )); + for uri in [ + "http://example.com/foo/bar", + "http://api.example.com/", + "https://secure.example/", + "http://secure.example/", + "http://[::1]:9999/", + "http://127.0.0.1:9999/", + ] { + let uri = uri.parse::().unwrap(); + assert_eq!( + format!("{:?}", target.cookies(&uri, Version::HTTP_11)), + format!("{:?}", source.cookies(&uri, Version::HTTP_11)), + "restored jar should select the same cookies for {uri}" + ); + } } #[test]