#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub secure: bool,
pub http_only: bool,
pub expires: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub same_site: Option<SameSite>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SameSite {
Strict,
Lax,
None,
}
impl SameSite {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Strict => "Strict",
Self::Lax => "Lax",
Self::None => "None",
}
}
}
impl std::str::FromStr for SameSite {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Strict" => Ok(Self::Strict),
"Lax" => Ok(Self::Lax),
"None" => Ok(Self::None),
_ => Err(()),
}
}
}
const MAX_COOKIE_EXPIRES_SECONDS: f64 = 253_402_300_799.0;
fn is_local_hostname(hostname: &str) -> bool {
hostname == "localhost" || hostname.ends_with(".localhost")
}
fn cookie_domain_matches(hostname: &str, domain: &str) -> bool {
if hostname == domain {
return true;
}
if !domain.starts_with('.') {
return false;
}
format!(".{hostname}").ends_with(domain)
}
fn cookie_path_matches(request_path: &str, cookie_path: &str) -> bool {
if request_path == cookie_path {
return true;
}
let mut value = request_path.to_string();
if !value.ends_with('/') {
value.push('/');
}
let mut path = cookie_path.to_string();
if !path.ends_with('/') {
path.push('/');
}
value.starts_with(&path)
}
#[must_use]
pub fn cookie_matches_url(cookie: &Cookie, url: &reqwest::Url) -> bool {
let hostname = url.host_str().unwrap_or("");
if cookie.secure && url.scheme() != "https" && !is_local_hostname(hostname) {
return false;
}
if !cookie_domain_matches(hostname, &cookie.domain) {
return false;
}
if !cookie_path_matches(url.path(), &cookie.path) {
return false;
}
if let Some(expires) = cookie.expires
&& expires >= 0.0
&& expires
< std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0.0, |d| d.as_secs_f64())
{
return false;
}
true
}
fn parse_raw_set_cookie(header: &str) -> Option<Cookie> {
let mut pairs = header.split(';').filter(|s| !s.trim().is_empty()).map(|p| {
p.split_once('=').map_or_else(
|| (p.trim().to_string(), String::new()),
|(k, v)| (k.trim().to_string(), v.trim().to_string()),
)
});
let (name, value) = pairs.next()?;
let mut cookie = Cookie {
name,
value,
domain: String::new(),
path: String::new(),
secure: false,
http_only: false,
expires: None,
same_site: Some(SameSite::Lax),
url: None,
};
for (attr, attr_value) in pairs {
match attr.to_ascii_lowercase().as_str() {
"expires" => {
if let Ok(when) = httpdate::parse_http_date(&attr_value) {
let secs = when
.duration_since(std::time::UNIX_EPOCH)
.map_or(0.0, |d| d.as_secs_f64());
cookie.expires = Some(secs.min(MAX_COOKIE_EXPIRES_SECONDS));
}
},
"max-age" => {
if let Ok(delta) = attr_value.parse::<i64>() {
if delta <= 0 {
cookie.expires = Some(0.0);
} else {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0.0, |d| d.as_secs_f64());
let delta = f64::from(u32::try_from(delta).unwrap_or(u32::MAX));
cookie.expires = Some((now + delta).min(MAX_COOKIE_EXPIRES_SECONDS));
}
}
},
"domain" => {
let mut domain = attr_value.to_ascii_lowercase();
if !domain.is_empty() && !domain.starts_with('.') && domain.contains('.') {
domain.insert(0, '.');
}
cookie.domain = domain;
},
"path" => cookie.path = attr_value,
"secure" => cookie.secure = true,
"httponly" => cookie.http_only = true,
"samesite" => {
cookie.same_site = match attr_value.to_ascii_lowercase().as_str() {
"none" => Some(SameSite::None),
"strict" => Some(SameSite::Strict),
"lax" => Some(SameSite::Lax),
_ => cookie.same_site,
};
},
_ => {},
}
}
Some(cookie)
}
pub fn parse_set_cookie_headers(response_url: &reqwest::Url, headers: &reqwest::header::HeaderMap) -> Vec<Cookie> {
let hostname = response_url.host_str().unwrap_or("");
let path = response_url.path();
let default_path = {
let trimmed = path.strip_prefix('/').unwrap_or(path);
let segments: Vec<&str> = trimmed.split('/').collect();
format!("/{}", segments[..segments.len().saturating_sub(1)].join("/"))
};
let mut cookies = Vec::new();
for value in headers.get_all(reqwest::header::SET_COOKIE) {
let Ok(raw) = value.to_str() else { continue };
let Some(mut cookie) = parse_raw_set_cookie(raw) else {
continue;
};
if cookie.domain.is_empty() {
cookie.domain = hostname.to_string();
}
if !cookie_domain_matches(hostname, &cookie.domain) {
continue;
}
if cookie.path.is_empty() || !cookie.path.starts_with('/') {
cookie.path.clone_from(&default_path);
}
cookies.push(cookie);
}
cookies
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(header: &str, url: &str) -> Option<Cookie> {
let url = reqwest::Url::parse(url).unwrap();
let mut headers = reqwest::header::HeaderMap::new();
headers.append(reqwest::header::SET_COOKIE, header.parse().unwrap());
parse_set_cookie_headers(&url, &headers).into_iter().next()
}
#[test]
fn host_only_defaults_from_response_url() {
let c = parse("sid=abc", "http://example.com/a/b/c").unwrap();
assert_eq!(c.name, "sid");
assert_eq!(c.value, "abc");
assert_eq!(c.domain, "example.com");
assert_eq!(c.path, "/a/b");
assert_eq!(c.expires, None);
assert!(matches!(c.same_site, Some(SameSite::Lax)));
}
#[test]
fn declared_domain_gets_dot_prefixed() {
let c = parse("sid=1; Domain=example.com; Path=/", "http://example.com/").unwrap();
assert_eq!(c.domain, ".example.com");
assert_eq!(c.path, "/");
}
#[test]
fn foreign_domain_is_dropped() {
assert!(parse("sid=1; Domain=evil.com", "http://example.com/").is_none());
}
#[test]
fn attributes_parse() {
let c = parse(
"a=b; Secure; HttpOnly; SameSite=Strict; Max-Age=3600; Path=/x",
"https://example.com/",
)
.unwrap();
assert!(c.secure);
assert!(c.http_only);
assert!(matches!(c.same_site, Some(SameSite::Strict)));
assert_eq!(c.path, "/x");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let exp = c.expires.unwrap();
assert!(exp > now + 3500.0 && exp < now + 3700.0);
}
#[test]
fn non_positive_max_age_expires_immediately() {
let c = parse("a=b; Max-Age=0; Path=/", "http://example.com/").unwrap();
assert_eq!(c.expires, Some(0.0));
let c = parse("a=b; Max-Age=-5; Path=/", "http://example.com/").unwrap();
assert_eq!(c.expires, Some(0.0));
}
#[test]
fn expires_attribute_parses_http_date() {
let c = parse(
"a=b; Expires=Wed, 01 Jan 2031 00:00:00 GMT; Path=/",
"http://example.com/",
)
.unwrap();
let exp = c.expires.unwrap();
assert!((1_924_991_940.0..1_924_993_000.0).contains(&exp), "got {exp}");
}
#[test]
fn matcher_domain_and_path() {
let mk = |domain: &str, path: &str, secure: bool| Cookie {
name: "n".into(),
value: "v".into(),
domain: domain.into(),
path: path.into(),
secure,
http_only: false,
expires: None,
same_site: None,
url: None,
};
let url = |u: &str| reqwest::Url::parse(u).unwrap();
assert!(cookie_matches_url(
&mk("example.com", "/", false),
&url("http://example.com/")
));
assert!(!cookie_matches_url(
&mk("example.com", "/", false),
&url("http://sub.example.com/")
));
assert!(cookie_matches_url(
&mk(".example.com", "/", false),
&url("http://sub.example.com/")
));
assert!(cookie_matches_url(
&mk(".example.com", "/", false),
&url("http://example.com/")
));
assert!(cookie_matches_url(&mk("e.com", "/a", false), &url("http://e.com/a/b")));
assert!(!cookie_matches_url(&mk("e.com", "/a", false), &url("http://e.com/ab")));
assert!(!cookie_matches_url(&mk("e.com", "/", true), &url("http://e.com/")));
assert!(cookie_matches_url(&mk("e.com", "/", true), &url("https://e.com/")));
assert!(cookie_matches_url(
&mk("localhost", "/", true),
&url("http://localhost/")
));
}
#[test]
fn expired_cookie_is_not_sent() {
let c = Cookie {
name: "n".into(),
value: "v".into(),
domain: "e.com".into(),
path: "/".into(),
secure: false,
http_only: false,
expires: Some(1.0),
same_site: None,
url: None,
};
assert!(!cookie_matches_url(&c, &reqwest::Url::parse("http://e.com/").unwrap()));
let session = Cookie {
expires: Some(-1.0),
..c
};
assert!(cookie_matches_url(
&session,
&reqwest::Url::parse("http://e.com/").unwrap()
));
}
}