From f6bad28ddf696d2f17e1c4e7cca128ec93655fbe Mon Sep 17 00:00:00 2001 From: Oleksandr Herasymov Date: Sun, 30 Aug 2026 14:51:39 +0200 Subject: [PATCH 1/5] feat(cookie): add Jar::get_all_scoped to report stored cookie scope --- src/cookie.rs | 50 +++++++++++++++++++++ src/cookie/jar.rs | 112 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 156 insertions(+), 6 deletions(-) diff --git a/src/cookie.rs b/src/cookie.rs index 0e8c9cab..91a7bde3 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -130,6 +130,56 @@ impl<'c> From> for RawCookie<'c> { } } +/// A cookie stored in a [`Jar`], together with the domain and host-only scope it is kept under. +/// +/// Returned by [`Jar::get_all_scoped`]. +#[derive(Debug, Clone)] +pub struct ScopedCookie { + cookie: Cookie<'static>, + domain: String, + host_only: bool, +} + +impl ScopedCookie { + #[inline] + pub(super) fn new(cookie: Cookie<'static>, domain: String, host_only: bool) -> ScopedCookie { + ScopedCookie { + cookie, + domain, + host_only, + } + } + + /// Returns the cookie of `self`. + #[inline] + pub fn cookie(&self) -> &Cookie<'static> { + &self.cookie + } + + /// Returns the domain `self` is stored under. + /// + /// For a host-only cookie this is the request host it was received from, which its own + /// `Domain` attribute does not carry. Otherwise it is the canonicalized `Domain` attribute. + /// The domain is returned in URI authority form, so an IPv6 host keeps its brackets. + #[inline] + pub fn domain(&self) -> &str { + &self.domain + } + + /// Returns whether `self` is stored host-only, meaning it was received without a `Domain` + /// attribute and applies to its origin host alone. + #[inline] + pub fn host_only(&self) -> bool { + self.host_only + } + + /// Converts `self` into the stored cookie. + #[inline] + pub fn into_cookie(self) -> Cookie<'static> { + self.cookie + } +} + /// Serialized `Cookie` field values selected for an outgoing request. #[derive(Debug, Clone)] #[non_exhaustive] diff --git a/src/cookie/jar.rs b/src/cookie/jar.rs index d79c97a0..a9da84fb 100644 --- a/src/cookie/jar.rs +++ b/src/cookie/jar.rs @@ -6,7 +6,7 @@ use cookie::{ use http::{Uri, Version}; use super::{ - Cookie, CookieStore, Cookies, IntoCookie, + Cookie, CookieStore, Cookies, IntoCookie, ScopedCookie, store::{DEFAULT_PATH, Store, canonical_host, cookie_is_expired, domain_match, normalize_path}, }; use crate::{IntoUri, ext::UriExt, header::HeaderValue, sync::RwLock}; @@ -100,20 +100,49 @@ impl Jar { /// } /// ``` pub fn get_all(&self) -> impl Iterator> { + self.get_all_scoped().map(ScopedCookie::into_cookie) + } + + /// Returns all unexpired cookies in the jar together with the scope they are stored under. + /// + /// [`get_all`](Self::get_all) leaves the `Domain` attribute of a host-only cookie absent, which + /// is what re-importing it requires, but that also drops the origin host the jar keeps as part + /// of its stored identity. Use this when a snapshot has to outlive the jar, such as persisting + /// a session to disk, so that each cookie can later be restored into the scope it came from. + /// Snapshots are returned in creation order. + /// + /// # Example + /// ``` + /// use wreq::cookie::Jar; + /// let source = Jar::default(); + /// source.add("foo=bar", "http://example.com"); + /// + /// let target = Jar::default(); + /// for stored in source.get_all_scoped() { + /// let uri = format!("http://{}/", stored.domain()); + /// target.add(stored.into_cookie(), uri); + /// } + /// ``` + pub fn get_all_scoped(&self) -> impl Iterator { let now = OffsetDateTime::now_utc(); let mut cookies = self .0 .read() .cookies - .values() - .flat_map(|path_map| { - path_map.values().flat_map(|cookie_map| { - cookie_map.values().filter_map(|entry| { + .iter() + .flat_map(|(domain, 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 = entry.cookie.clone(); + let host_only = cookie.domain().is_none(); + Some(( + entry.creation_index, + ScopedCookie::new(Cookie::from(cookie), domain.to_string(), host_only), + )) }) }) }) @@ -628,6 +657,77 @@ mod tests { )); } + #[test] + fn jar_get_all_scoped_reports_the_host_only_origin() { + let source = Jar::default(); + source.add("session=abc", "http://example.com/foo/bar"); + source.add("pref=dark; Domain=example.com", "http://www.example.com/"); + + let scoped = source.get_all_scoped().collect::>(); + assert_eq!(scoped.len(), 2); + + let session = &scoped[0]; + assert!(session.host_only()); + assert_eq!(session.domain(), "example.com"); + assert_eq!(session.cookie().domain(), None); + assert_eq!(session.cookie().path(), Some("/foo")); + + let pref = &scoped[1]; + assert!(!pref.host_only()); + assert_eq!(pref.domain(), "example.com"); + assert_eq!(pref.cookie().domain(), Some("example.com")); + } + + #[test] + fn jar_get_all_scoped_reports_ip_hosts_in_uri_form() { + let jar = Jar::default(); + jar.add("v6=1", "http://[::1]:8080/"); + jar.add("v4=1", "http://127.0.0.1:8080/"); + + let scoped = jar.get_all_scoped().collect::>(); + assert_eq!(scoped[0].domain(), "[::1]"); + assert_eq!(scoped[1].domain(), "127.0.0.1"); + + // The reported form can be put straight back into a URI authority. + let target = Jar::default(); + for stored in scoped { + let uri = format!("http://{}/", stored.domain()); + target.add(stored.into_cookie(), uri); + } + assert_eq!(target.get_all().count(), 2); + assert!(target.contains("v6", "http://[::1]:9999/")); + assert!(target.contains("v4", "http://127.0.0.1:9999/")); + } + + #[test] + fn jar_get_all_scoped_restores_a_snapshot_without_a_known_origin() { + let source = Jar::default(); + source.add("session=abc", "http://example.com/foo/bar"); + source.add("other=xyz", "http://other.example/"); + source.add("pref=dark; Domain=example.com", "http://www.example.com/"); + + // The scope is the only thing the caller needs to put a snapshot back. + let target = Jar::default(); + for stored in source.get_all_scoped() { + let uri = format!("http://{}/", stored.domain()); + target.add(stored.into_cookie(), uri); + } + + for uri in [ + "http://example.com/foo/bar", + "http://other.example/", + "http://www.example.com/", + "http://api.example.com/", + ] { + 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] fn jar_get_all_export_import_preserves_absolute_expiration() { let source = Jar::default(); From f3e89121c8fcd70176c4ec58d15f2b7bf8c025ab Mon Sep 17 00:00:00 2001 From: gngpp Date: Mon, 31 Aug 2026 01:34:45 +0800 Subject: [PATCH 2/5] refactor(cookie): simplify hostname export --- src/cookie.rs | 105 +++++++++++++++++----------------------------- src/cookie/jar.rs | 81 +++++++++++++---------------------- 2 files changed, 69 insertions(+), 117 deletions(-) diff --git a/src/cookie.rs b/src/cookie.rs index 91a7bde3..be2d204d 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. #[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,16 @@ 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, + }) } /// Returns the name of `self`. #[inline] pub fn name(&self) -> &str { - self.0.name() + self.inner.name() } /// Returns the value of `self`. @@ -42,37 +49,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 +89,23 @@ impl<'a> Cookie<'a> { /// stripped. #[inline] pub fn domain(&self) -> Option<&str> { - self.0.domain() + self.inner.domain() + } + + /// Returns the host `self` is stored under. + /// + /// For a host-only cookie this is the request host it was received from, which its own + /// `Domain` attribute does not carry. Otherwise it is the canonicalized `Domain` attribute. + /// The domain is returned in URI authority form, so an IPv6 host keeps its brackets. + #[inline] + pub fn host(&self) -> Option<&Host>> { + self.host.as_ref() } /// 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 +113,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,78 +122,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 - } -} - -/// A cookie stored in a [`Jar`], together with the domain and host-only scope it is kept under. -/// -/// Returned by [`Jar::get_all_scoped`]. -#[derive(Debug, Clone)] -pub struct ScopedCookie { - cookie: Cookie<'static>, - domain: String, - host_only: bool, -} - -impl ScopedCookie { - #[inline] - pub(super) fn new(cookie: Cookie<'static>, domain: String, host_only: bool) -> ScopedCookie { - ScopedCookie { - cookie, - domain, - host_only, - } - } - - /// Returns the cookie of `self`. - #[inline] - pub fn cookie(&self) -> &Cookie<'static> { - &self.cookie - } - - /// Returns the domain `self` is stored under. - /// - /// For a host-only cookie this is the request host it was received from, which its own - /// `Domain` attribute does not carry. Otherwise it is the canonicalized `Domain` attribute. - /// The domain is returned in URI authority form, so an IPv6 host keeps its brackets. - #[inline] - pub fn domain(&self) -> &str { - &self.domain - } - - /// Returns whether `self` is stored host-only, meaning it was received without a `Domain` - /// attribute and applies to its origin host alone. - #[inline] - pub fn host_only(&self) -> bool { - self.host_only - } - - /// Converts `self` into the stored cookie. - #[inline] - pub fn into_cookie(self) -> Cookie<'static> { - self.cookie + cookie.inner } } diff --git a/src/cookie/jar.rs b/src/cookie/jar.rs index a9da84fb..8d429145 100644 --- a/src/cookie/jar.rs +++ b/src/cookie/jar.rs @@ -6,7 +6,7 @@ use cookie::{ use http::{Uri, Version}; use super::{ - Cookie, CookieStore, Cookies, IntoCookie, ScopedCookie, + Cookie, CookieStore, Cookies, IntoCookie, store::{DEFAULT_PATH, Store, canonical_host, cookie_is_expired, domain_match, normalize_path}, }; use crate::{IntoUri, ext::UriExt, header::HeaderValue, sync::RwLock}; @@ -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())) } /// 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 @@ -100,49 +101,22 @@ impl Jar { /// } /// ``` pub fn get_all(&self) -> impl Iterator> { - self.get_all_scoped().map(ScopedCookie::into_cookie) - } - - /// Returns all unexpired cookies in the jar together with the scope they are stored under. - /// - /// [`get_all`](Self::get_all) leaves the `Domain` attribute of a host-only cookie absent, which - /// is what re-importing it requires, but that also drops the origin host the jar keeps as part - /// of its stored identity. Use this when a snapshot has to outlive the jar, such as persisting - /// a session to disk, so that each cookie can later be restored into the scope it came from. - /// Snapshots are returned in creation order. - /// - /// # Example - /// ``` - /// use wreq::cookie::Jar; - /// let source = Jar::default(); - /// source.add("foo=bar", "http://example.com"); - /// - /// let target = Jar::default(); - /// for stored in source.get_all_scoped() { - /// let uri = format!("http://{}/", stored.domain()); - /// target.add(stored.into_cookie(), uri); - /// } - /// ``` - pub fn get_all_scoped(&self) -> impl Iterator { let now = OffsetDateTime::now_utc(); let mut cookies = self .0 .read() .cookies .iter() - .flat_map(|(domain, path_map)| { + .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; } - let cookie = entry.cookie.clone(); - let host_only = cookie.domain().is_none(); - Some(( - entry.creation_index, - ScopedCookie::new(Cookie::from(cookie), domain.to_string(), host_only), - )) + let mut cookie = Cookie::from(entry.cookie.clone()); + cookie.host = Some(host.clone()); + Some((entry.creation_index, cookie)) }) }) }) @@ -463,9 +437,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}; @@ -663,19 +642,19 @@ mod tests { source.add("session=abc", "http://example.com/foo/bar"); source.add("pref=dark; Domain=example.com", "http://www.example.com/"); - let scoped = source.get_all_scoped().collect::>(); + let scoped = source.get_all().collect::>(); assert_eq!(scoped.len(), 2); let session = &scoped[0]; - assert!(session.host_only()); - assert_eq!(session.domain(), "example.com"); - assert_eq!(session.cookie().domain(), None); - assert_eq!(session.cookie().path(), Some("/foo")); + assert!(session.domain().is_none()); + assert_eq!(session.host(), Some(&Host::Domain("example.com".into()))); + assert_eq!(session.domain(), None); + assert_eq!(session.path(), Some("/foo")); let pref = &scoped[1]; - assert!(!pref.host_only()); - assert_eq!(pref.domain(), "example.com"); - assert_eq!(pref.cookie().domain(), Some("example.com")); + assert!(!pref.domain().is_none()); + assert_eq!(pref.host(), Some(&Host::Domain("example.com".into()))); + assert_eq!(pref.domain(), Some("example.com")); } #[test] @@ -684,15 +663,15 @@ mod tests { jar.add("v6=1", "http://[::1]:8080/"); jar.add("v4=1", "http://127.0.0.1:8080/"); - let scoped = jar.get_all_scoped().collect::>(); - assert_eq!(scoped[0].domain(), "[::1]"); - assert_eq!(scoped[1].domain(), "127.0.0.1"); + let scoped = jar.get_all().collect::>(); + assert_eq!(scoped[0].host(), Some(&Host::Ipv6(Ipv6Addr::LOCALHOST))); + assert_eq!(scoped[1].host(), Some(&Host::Ipv4(Ipv4Addr::LOCALHOST))); // The reported form can be put straight back into a URI authority. let target = Jar::default(); for stored in scoped { - let uri = format!("http://{}/", stored.domain()); - target.add(stored.into_cookie(), uri); + let uri = format!("http://{}/", stored.host().unwrap()); + target.add(stored, uri); } assert_eq!(target.get_all().count(), 2); assert!(target.contains("v6", "http://[::1]:9999/")); @@ -708,9 +687,9 @@ mod tests { // The scope is the only thing the caller needs to put a snapshot back. let target = Jar::default(); - for stored in source.get_all_scoped() { - let uri = format!("http://{}/", stored.domain()); - target.add(stored.into_cookie(), uri); + for stored in source.get_all() { + let uri = format!("http://{}/", stored.host().unwrap()); + target.add(stored, uri); } for uri in [ From 9cbf43f33382c89ac43e908edbf75dae9a18af3c Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 1 Sep 2026 06:54:44 +0800 Subject: [PATCH 3/5] fix(cookie): expose storage host consistently --- src/cookie.rs | 14 ++++++++++++-- src/cookie/jar.rs | 27 +++++++++++++++++++-------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/cookie.rs b/src/cookie.rs index be2d204d..e08678bc 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -38,6 +38,12 @@ impl<'a> Cookie<'a> { }) } + #[inline] + fn with_storage_host(mut self, host: Host>) -> Self { + self.host = Some(host); + self + } + /// Returns the name of `self`. #[inline] pub fn name(&self) -> &str { @@ -98,8 +104,12 @@ impl<'a> Cookie<'a> { /// `Domain` attribute does not carry. Otherwise it is the canonicalized `Domain` attribute. /// The domain is returned in URI authority form, so an IPv6 host keeps its brackets. #[inline] - pub fn host(&self) -> Option<&Host>> { - self.host.as_ref() + 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. diff --git a/src/cookie/jar.rs b/src/cookie/jar.rs index 8d429145..64daa240 100644 --- a/src/cookie/jar.rs +++ b/src/cookie/jar.rs @@ -57,7 +57,7 @@ impl Jar { .filter(|entry| !cookie_is_expired(&entry.cookie, now)) .min_by_key(|entry| entry.creation_index)?; - Some(Cookie::from(entry.cookie.clone())) + Some(Cookie::from(entry.cookie.clone()).with_storage_host(host)) } /// Returns whether an unexpired cookie exists for an exact URI scope. @@ -114,8 +114,8 @@ impl Jar { return None; } - let mut cookie = Cookie::from(entry.cookie.clone()); - cookie.host = Some(host.clone()); + let cookie = + Cookie::from(entry.cookie.clone()).with_storage_host(host.clone()); Some((entry.creation_index, cookie)) }) }) @@ -190,7 +190,9 @@ 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_storage_host(host.clone()) + }) .collect::>() .into_iter() } @@ -647,14 +649,23 @@ mod tests { let session = &scoped[0]; assert!(session.domain().is_none()); - assert_eq!(session.host(), Some(&Host::Domain("example.com".into()))); + assert_eq!(session.host(), Some(Host::Domain("example.com"))); assert_eq!(session.domain(), None); assert_eq!(session.path(), Some("/foo")); let pref = &scoped[1]; assert!(!pref.domain().is_none()); - assert_eq!(pref.host(), Some(&Host::Domain("example.com".into()))); + assert_eq!(pref.host(), Some(Host::Domain("example.com"))); assert_eq!(pref.domain(), Some("example.com")); + + 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"))); } #[test] @@ -664,8 +675,8 @@ mod tests { jar.add("v4=1", "http://127.0.0.1:8080/"); let scoped = jar.get_all().collect::>(); - assert_eq!(scoped[0].host(), Some(&Host::Ipv6(Ipv6Addr::LOCALHOST))); - assert_eq!(scoped[1].host(), Some(&Host::Ipv4(Ipv4Addr::LOCALHOST))); + assert_eq!(scoped[0].host(), Some(Host::Ipv6(Ipv6Addr::LOCALHOST))); + assert_eq!(scoped[1].host(), Some(Host::Ipv4(Ipv4Addr::LOCALHOST))); // The reported form can be put straight back into a URI authority. let target = Jar::default(); From 9f5c20ea272125803a542b98dcf6e00b8190ea3e Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 1 Sep 2026 06:58:00 +0800 Subject: [PATCH 4/5] docs(cookie): clarify storage host snapshots --- src/cookie.rs | 8 ++-- src/cookie/jar.rs | 110 ++++++++++++++-------------------------------- 2 files changed, 36 insertions(+), 82 deletions(-) diff --git a/src/cookie.rs b/src/cookie.rs index e08678bc..7b65da3b 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -19,7 +19,7 @@ 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> { inner: RawCookie<'a>, @@ -98,11 +98,9 @@ impl<'a> Cookie<'a> { self.inner.domain() } - /// Returns the host `self` is stored under. + /// Returns the canonical host used to store this cookie. /// - /// For a host-only cookie this is the request host it was received from, which its own - /// `Domain` attribute does not carry. Otherwise it is the canonicalized `Domain` attribute. - /// The domain is returned in URI authority form, so an IPv6 host keeps its brackets. + /// Cookies returned by [`Jar`] query methods include this value. #[inline] pub fn host(&self) -> Option> { self.host.as_ref().map(|host| match host { diff --git a/src/cookie/jar.rs b/src/cookie/jar.rs index 64daa240..0484d02b 100644 --- a/src/cookie/jar.rs +++ b/src/cookie/jar.rs @@ -85,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(); @@ -612,51 +618,28 @@ mod tests { } #[test] - fn jar_get_all_export_import_keeps_host_only_scope_and_effective_path() { - let source = Jar::default(); - source.add("session=abc", "http://example.com/foo/bar"); - - let exported = source.get_all().collect::>(); - assert_eq!(exported.len(), 1); - assert_eq!(exported[0].domain(), None); - assert_eq!(exported[0].path(), Some("/foo")); - - let target = Jar::default(); - for cookie in exported { - target.add(cookie, "http://example.com/another/deeper"); - } - - 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 - )); - } - - #[test] - fn jar_get_all_scoped_reports_the_host_only_origin() { + 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 scoped = source.get_all().collect::>(); - assert_eq!(scoped.len(), 2); + let exported = source.get_all().collect::>(); + assert_eq!(exported.len(), 5); - let session = &scoped[0]; - assert!(session.domain().is_none()); + 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 = &scoped[1]; - assert!(!pref.domain().is_none()); + 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"))); @@ -666,48 +649,21 @@ mod tests { .find(|cookie| cookie.name() == "session") .unwrap(); assert_eq!(matched.host(), Some(Host::Domain("example.com"))); - } - - #[test] - fn jar_get_all_scoped_reports_ip_hosts_in_uri_form() { - let jar = Jar::default(); - jar.add("v6=1", "http://[::1]:8080/"); - jar.add("v4=1", "http://127.0.0.1:8080/"); - - let scoped = jar.get_all().collect::>(); - assert_eq!(scoped[0].host(), Some(Host::Ipv6(Ipv6Addr::LOCALHOST))); - assert_eq!(scoped[1].host(), Some(Host::Ipv4(Ipv4Addr::LOCALHOST))); - - // The reported form can be put straight back into a URI authority. - let target = Jar::default(); - for stored in scoped { - let uri = format!("http://{}/", stored.host().unwrap()); - target.add(stored, uri); - } - assert_eq!(target.get_all().count(), 2); - assert!(target.contains("v6", "http://[::1]:9999/")); - assert!(target.contains("v4", "http://127.0.0.1:9999/")); - } - #[test] - fn jar_get_all_scoped_restores_a_snapshot_without_a_known_origin() { - let source = Jar::default(); - source.add("session=abc", "http://example.com/foo/bar"); - source.add("other=xyz", "http://other.example/"); - source.add("pref=dark; Domain=example.com", "http://www.example.com/"); - - // The scope is the only thing the caller needs to put a snapshot back. let target = Jar::default(); - for stored in source.get_all() { - let uri = format!("http://{}/", stored.host().unwrap()); - target.add(stored, uri); + for cookie in exported { + let scheme = if cookie.secure() { "https" } else { "http" }; + let uri = format!("{scheme}://{}/", cookie.host().unwrap()); + target.add(cookie, uri); } for uri in [ "http://example.com/foo/bar", - "http://other.example/", - "http://www.example.com/", "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!( From 209cb2bdb09ce2d61b629e94e1459b9bbc87ef4e Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 1 Sep 2026 07:04:29 +0800 Subject: [PATCH 5/5] refactor(cookie): simplify host helper name --- src/cookie.rs | 2 +- src/cookie/jar.rs | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/cookie.rs b/src/cookie.rs index 7b65da3b..17311a37 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -39,7 +39,7 @@ impl<'a> Cookie<'a> { } #[inline] - fn with_storage_host(mut self, host: Host>) -> Self { + fn with_host(mut self, host: Host>) -> Self { self.host = Some(host); self } diff --git a/src/cookie/jar.rs b/src/cookie/jar.rs index 0484d02b..f47e0eab 100644 --- a/src/cookie/jar.rs +++ b/src/cookie/jar.rs @@ -57,7 +57,7 @@ impl Jar { .filter(|entry| !cookie_is_expired(&entry.cookie, now)) .min_by_key(|entry| entry.creation_index)?; - Some(Cookie::from(entry.cookie.clone()).with_storage_host(host)) + Some(Cookie::from(entry.cookie.clone()).with_host(host)) } /// Returns whether an unexpired cookie exists for an exact URI scope. @@ -120,8 +120,7 @@ impl Jar { return None; } - let cookie = - Cookie::from(entry.cookie.clone()).with_storage_host(host.clone()); + let cookie = Cookie::from(entry.cookie.clone()).with_host(host.clone()); Some((entry.creation_index, cookie)) }) }) @@ -196,9 +195,7 @@ impl Jar { let store = self.0.read(); store .matching_cookies(&uri, &host, now) - .map(|(host, _, entry)| { - Cookie::from(entry.cookie.clone()).with_storage_host(host.clone()) - }) + .map(|(host, _, entry)| Cookie::from(entry.cookie.clone()).with_host(host.clone())) .collect::>() .into_iter() }