Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 48 additions & 17 deletions src/cookie.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,65 +14,78 @@ 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<Host<Box<str>>>,
}

impl<'a> Cookie<'a> {
pub(crate) fn parse(value: &'a HeaderValue) -> crate::Result<Cookie<'a>> {
std::str::from_utf8(value.as_bytes())
.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<Box<str>>) -> 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`.
///
/// 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.
Expand All @@ -82,21 +95,33 @@ 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<Host<&str>> {
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<std::time::Duration> {
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.
///
/// Session cookies return `None`.
#[inline]
pub fn expires(&self) -> Option<SystemTime> {
match self.0.expires() {
match self.inner.expires() {
Some(Expiration::DateTime(offset)) => Some(SystemTime::from(offset)),
None | Some(Expiration::Session) => None,
}
Expand All @@ -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<RawCookie<'c>> for Cookie<'c> {
#[inline]
fn from(cookie: RawCookie<'c>) -> Cookie<'c> {
Cookie(cookie)
Cookie {
inner: cookie,
host: None,
}
}
}

impl<'c> From<Cookie<'c>> for RawCookie<'c> {
#[inline]
fn from(cookie: Cookie<'c>) -> RawCookie<'c> {
cookie.0
cookie.inner
}
}

Expand Down
107 changes: 75 additions & 32 deletions src/cookie/jar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ 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())?
.entries(name)
.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.
Expand All @@ -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
Expand All @@ -84,36 +85,43 @@ 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<Item = Cookie<'static>> {
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(|(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))
})
})
})
Expand Down Expand Up @@ -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::<Vec<_>>()
.into_iter()
}
Expand Down Expand Up @@ -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};

Expand Down Expand Up @@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Uri>().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]
Expand Down
Loading