use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use http::HeaderMap;
use http::header::{COOKIE, SET_COOKIE};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
#[derive(Clone, Debug)]
pub struct Cookie {
name: String,
value: String,
domain: Option<String>,
path: Option<String>,
secure: bool,
http_only: bool,
same_site: Option<SameSite>,
expired: bool,
host_only: bool,
}
impl Cookie {
pub fn name(&self) -> &str {
&self.name
}
pub fn value(&self) -> &str {
&self.value
}
pub fn domain(&self) -> Option<&str> {
self.domain.as_deref()
}
pub fn path(&self) -> Option<&str> {
self.path.as_deref()
}
pub fn secure(&self) -> bool {
self.secure
}
pub fn http_only(&self) -> bool {
self.http_only
}
pub fn same_site(&self) -> Option<&SameSite> {
self.same_site.as_ref()
}
}
#[derive(Clone, Default)]
pub struct CookieJar {
inner: Arc<Mutex<HashMap<String, Vec<Cookie>>>>,
}
impl std::fmt::Debug for CookieJar {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CookieJar").finish()
}
}
impl CookieJar {
pub fn new() -> Self {
Self::default()
}
pub fn store_from_response(&self, domain: &str, headers: &HeaderMap) {
let mut jar = self.inner.lock().unwrap();
let cookies = jar.entry(domain.to_owned()).or_default();
for value in headers.get_all(SET_COOKIE) {
if let Ok(s) = value.to_str()
&& let Some(cookie) = parse_set_cookie(s, domain)
{
if cookie.expired {
cookies.retain(|c| c.name != cookie.name);
} else if let Some(existing) = cookies.iter_mut().find(|c| c.name == cookie.name) {
*existing = cookie;
} else {
cookies.push(cookie);
}
}
}
}
pub fn apply_to_request(
&self,
domain: &str,
is_secure: bool,
request_path: &str,
headers: &mut HeaderMap,
) {
let jar = self.inner.lock().unwrap();
let mut matching_cookies = Vec::new();
for (stored_domain, cookies) in jar.iter() {
for c in cookies {
let cookie_domain = c.domain.as_deref().unwrap_or(stored_domain);
if c.host_only {
if domain != cookie_domain {
continue;
}
} else if !domain_matches(domain, cookie_domain) {
continue;
}
if c.secure && !is_secure {
continue;
}
if let Some(p) = &c.path
&& !request_path.starts_with(p.as_str())
{
continue;
}
matching_cookies.push(c);
}
}
if matching_cookies.is_empty() {
return;
}
let cookie_header: String = matching_cookies
.iter()
.map(|c| format!("{}={}", c.name, c.value))
.collect::<Vec<_>>()
.join("; ");
if let Ok(value) = cookie_header.parse() {
headers.insert(COOKIE, value);
}
}
pub fn clear(&self) {
self.inner.lock().unwrap().clear();
}
pub fn cookies(&self) -> Vec<Cookie> {
let jar = self.inner.lock().unwrap();
jar.values().flatten().cloned().collect()
}
}
fn parse_set_cookie(header: &str, request_domain: &str) -> Option<Cookie> {
let mut parts = header.split(';');
let name_value = parts.next()?.trim();
let (name, value) = name_value.split_once('=')?;
let name = name.trim().to_owned();
let value = value.trim().to_owned();
if name.is_empty() {
return None;
}
let mut domain = None;
let mut path = None;
let mut secure = false;
let mut http_only = false;
let mut same_site = None;
let mut expired = false;
for attr in parts {
let attr = attr.trim();
let lower = attr.to_lowercase();
if lower == "secure" {
secure = true;
} else if lower == "httponly" {
http_only = true;
} else if let Some(val) = lower.strip_prefix("domain=") {
domain = Some(val.trim_start_matches('.').to_owned());
} else if let Some(val) = lower.strip_prefix("path=") {
path = Some(val.to_owned());
} else if let Some(val) = lower.strip_prefix("samesite=") {
same_site = match val.trim() {
"strict" => Some(SameSite::Strict),
"lax" => Some(SameSite::Lax),
"none" => Some(SameSite::None),
_ => None,
};
} else if let Some(val) = lower.strip_prefix("max-age=")
&& let Ok(seconds) = val.trim().parse::<i64>()
&& seconds <= 0
{
expired = true;
} else if let Some(val) = attr
.strip_prefix("Expires=")
.or_else(|| attr.strip_prefix("expires="))
&& let Some(expires_time) = parse_http_date(val.trim())
&& expires_time < SystemTime::now()
{
expired = true;
}
}
let host_only = domain.is_none();
if domain.is_none() {
domain = Some(request_domain.to_owned());
}
if name.starts_with("__Host-") {
if !secure
|| !host_only
|| domain.as_deref() != Some(request_domain)
|| path.as_deref() != Some("/")
{
return None;
}
} else if name.starts_with("__Secure-") && !secure {
return None;
}
Some(Cookie {
name,
value,
domain,
path,
secure,
http_only,
same_site,
expired,
host_only,
})
}
fn parse_http_date(s: &str) -> Option<SystemTime> {
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() != 6 || parts[5] != "GMT" {
return None;
}
let day: u64 = parts[1].parse().ok()?;
let month = match parts[2] {
"Jan" => 1u64,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => return None,
};
let year: u64 = parts[3].parse().ok()?;
let time_parts: Vec<&str> = parts[4].split(':').collect();
if time_parts.len() != 3 {
return None;
}
let hour: u64 = time_parts[0].parse().ok()?;
let min: u64 = time_parts[1].parse().ok()?;
let sec: u64 = time_parts[2].parse().ok()?;
let days_before_month = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let m = (month - 1) as usize;
if m >= 12 {
return None;
}
let mut days = (year - 1970) * 365;
if year > 1970 {
days += (year - 1) / 4 - 1969 / 4;
days -= (year - 1) / 100 - 1969 / 100;
days += (year - 1) / 400 - 1969 / 400;
}
days += days_before_month[m];
if month > 2
&& (year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)))
{
days += 1;
}
days += day - 1;
let unix_secs = days * 86400 + hour * 3600 + min * 60 + sec;
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(unix_secs))
}
fn domain_matches(request_domain: &str, cookie_domain: &str) -> bool {
if request_domain == cookie_domain {
return true;
}
request_domain.ends_with(cookie_domain)
&& request_domain.as_bytes()[request_domain.len() - cookie_domain.len() - 1] == b'.'
}
#[cfg(test)]
mod tests {
use super::*;
use http::header::HeaderValue;
fn headers_with_cookies(cookies: &[&str]) -> HeaderMap {
let mut headers = HeaderMap::new();
for c in cookies {
headers.append(SET_COOKIE, HeaderValue::from_str(c).unwrap());
}
headers
}
#[test]
fn store_and_apply_roundtrip() {
let jar = CookieJar::new();
let headers = headers_with_cookies(&["foo=bar"]);
jar.store_from_response("example.com", &headers);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
assert_eq!(req_headers.get(COOKIE).unwrap(), "foo=bar");
}
#[test]
fn multiple_cookies_joined() {
let jar = CookieJar::new();
let headers = headers_with_cookies(&["a=1", "b=2"]);
jar.store_from_response("example.com", &headers);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
let cookie_str = req_headers.get(COOKIE).unwrap().to_str().unwrap();
assert!(cookie_str.contains("a=1"));
assert!(cookie_str.contains("b=2"));
assert!(cookie_str.contains("; "));
}
#[test]
fn cookie_update_overwrites_existing() {
let jar = CookieJar::new();
jar.store_from_response("example.com", &headers_with_cookies(&["k=old"]));
jar.store_from_response("example.com", &headers_with_cookies(&["k=new"]));
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
assert_eq!(req_headers.get(COOKIE).unwrap(), "k=new");
}
#[test]
fn secure_cookie_excluded_on_insecure() {
let jar = CookieJar::new();
let headers = headers_with_cookies(&["s=secret; Secure"]);
jar.store_from_response("example.com", &headers);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
assert!(req_headers.get(COOKIE).is_none());
}
#[test]
fn secure_cookie_included_on_secure() {
let jar = CookieJar::new();
let headers = headers_with_cookies(&["s=secret; Secure"]);
jar.store_from_response("example.com", &headers);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", true, "/", &mut req_headers);
assert_eq!(req_headers.get(COOKIE).unwrap(), "s=secret");
}
#[test]
fn clear_empties_jar() {
let jar = CookieJar::new();
jar.store_from_response("example.com", &headers_with_cookies(&["x=y"]));
jar.clear();
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
assert!(req_headers.get(COOKIE).is_none());
}
#[test]
fn empty_name_ignored() {
let jar = CookieJar::new();
let headers = headers_with_cookies(&["=value"]);
jar.store_from_response("example.com", &headers);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
assert!(req_headers.get(COOKIE).is_none());
}
#[test]
fn domain_attribute_with_leading_dot_stripped() {
let cookie = parse_set_cookie("a=b; Domain=.foo.com", "bar.com");
let c = cookie.unwrap();
assert_eq!(c.domain.as_deref(), Some("foo.com"));
}
#[test]
fn domain_defaults_to_request_domain() {
let cookie = parse_set_cookie("a=b", "request.com");
let c = cookie.unwrap();
assert_eq!(c.domain.as_deref(), Some("request.com"));
}
#[test]
fn host_only_cookie_not_sent_to_subdomain() {
let jar = CookieJar::new();
jar.store_from_response("example.com", &headers_with_cookies(&["hostonly=1"]));
let mut req_headers = HeaderMap::new();
jar.apply_to_request("sub.example.com", false, "/", &mut req_headers);
assert!(
req_headers.get(COOKIE).is_none(),
"cookie without Domain must be scoped to the exact origin host"
);
}
#[test]
fn domain_attribute_cookie_sent_to_subdomain() {
let jar = CookieJar::new();
jar.store_from_response(
"example.com",
&headers_with_cookies(&["domain=1; Domain=example.com"]),
);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("sub.example.com", false, "/", &mut req_headers);
assert_eq!(req_headers.get(COOKIE).unwrap(), "domain=1");
}
#[test]
fn httponly_attribute_parsed() {
let cookie = parse_set_cookie("a=b; HttpOnly", "example.com");
assert!(cookie.unwrap().http_only);
}
#[test]
fn path_attribute_parsed() {
let cookie = parse_set_cookie("a=b; Path=/api", "example.com");
assert_eq!(cookie.unwrap().path.as_deref(), Some("/api"));
}
#[test]
fn no_equals_returns_none() {
assert!(parse_set_cookie("invalid", "example.com").is_none());
}
#[test]
fn different_domain_does_not_apply() {
let jar = CookieJar::new();
jar.store_from_response("a.com", &headers_with_cookies(&["x=1"]));
let mut req_headers = HeaderMap::new();
jar.apply_to_request("b.com", false, "/", &mut req_headers);
assert!(req_headers.get(COOKIE).is_none());
}
#[test]
fn clone_shares_state() {
let jar = CookieJar::new();
let jar2 = jar.clone();
jar.store_from_response("example.com", &headers_with_cookies(&["x=1"]));
let mut req_headers = HeaderMap::new();
jar2.apply_to_request("example.com", false, "/", &mut req_headers);
assert_eq!(req_headers.get(COOKIE).unwrap(), "x=1");
}
#[test]
fn max_age_zero_removes_cookie() {
let jar = CookieJar::new();
jar.store_from_response("example.com", &headers_with_cookies(&["k=v"]));
jar.store_from_response("example.com", &headers_with_cookies(&["k=v; Max-Age=0"]));
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
assert!(req_headers.get(COOKIE).is_none());
}
#[test]
fn max_age_zero_not_stored() {
let jar = CookieJar::new();
jar.store_from_response("example.com", &headers_with_cookies(&["k=v; Max-Age=0"]));
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
assert!(req_headers.get(COOKIE).is_none());
}
#[test]
fn path_scoping() {
let jar = CookieJar::new();
jar.store_from_response("example.com", &headers_with_cookies(&["k=v; Path=/api"]));
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
assert!(req_headers.get(COOKIE).is_none());
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/api", &mut req_headers);
assert_eq!(req_headers.get(COOKIE).unwrap(), "k=v");
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/api/sub", &mut req_headers);
assert_eq!(req_headers.get(COOKIE).unwrap(), "k=v");
}
#[test]
fn domain_matching_exact() {
assert!(domain_matches("example.com", "example.com"));
}
#[test]
fn domain_matching_subdomain() {
assert!(domain_matches("sub.example.com", "example.com"));
}
#[test]
fn domain_matching_no_partial() {
assert!(!domain_matches("notexample.com", "example.com"));
}
#[test]
fn domain_matching_different() {
assert!(!domain_matches("other.com", "example.com"));
}
#[test]
fn subdomain_cookie_applied() {
let jar = CookieJar::new();
jar.store_from_response(
"example.com",
&headers_with_cookies(&["k=v; Domain=example.com"]),
);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("sub.example.com", false, "/", &mut req_headers);
assert_eq!(req_headers.get(COOKIE).unwrap(), "k=v");
}
#[test]
fn samesite_strict_parsed() {
let cookie = parse_set_cookie("a=b; SameSite=Strict", "example.com");
assert_eq!(cookie.unwrap().same_site, Some(SameSite::Strict));
}
#[test]
fn samesite_lax_parsed() {
let cookie = parse_set_cookie("a=b; SameSite=Lax", "example.com");
assert_eq!(cookie.unwrap().same_site, Some(SameSite::Lax));
}
#[test]
fn samesite_none_parsed() {
let cookie = parse_set_cookie("a=b; SameSite=None; Secure", "example.com");
let c = cookie.unwrap();
assert_eq!(c.same_site, Some(SameSite::None));
assert!(c.secure);
}
#[test]
fn samesite_unknown_value_ignored() {
let cookie = parse_set_cookie("a=b; SameSite=Invalid", "example.com");
assert_eq!(cookie.unwrap().same_site, None);
}
#[test]
fn host_prefix_valid() {
let cookie = parse_set_cookie("__Host-id=123; Secure; Path=/", "example.com");
let c = cookie.unwrap();
assert_eq!(c.name, "__Host-id");
assert!(c.secure);
assert_eq!(c.path.as_deref(), Some("/"));
}
#[test]
fn host_prefix_rejected_without_secure() {
let cookie = parse_set_cookie("__Host-id=123; Path=/", "example.com");
assert!(cookie.is_none());
}
#[test]
fn host_prefix_rejected_without_root_path() {
let cookie = parse_set_cookie("__Host-id=123; Secure; Path=/api", "example.com");
assert!(cookie.is_none());
}
#[test]
fn host_prefix_rejected_with_domain() {
let cookie = parse_set_cookie(
"__Host-id=123; Secure; Path=/; Domain=other.com",
"example.com",
);
assert!(cookie.is_none());
}
#[test]
fn secure_prefix_valid() {
let cookie = parse_set_cookie("__Secure-token=abc; Secure", "example.com");
assert!(cookie.is_some());
}
#[test]
fn secure_prefix_rejected_without_secure() {
let cookie = parse_set_cookie("__Secure-token=abc", "example.com");
assert!(cookie.is_none());
}
#[test]
fn expires_past_marks_expired() {
let cookie = parse_set_cookie(
"sid=abc; Expires=Wed, 01 Jan 2020 00:00:00 GMT",
"example.com",
);
assert!(cookie.is_some());
assert!(cookie.unwrap().expired);
}
#[test]
fn expires_future_not_expired() {
let cookie = parse_set_cookie(
"sid=abc; Expires=Wed, 01 Jan 2099 00:00:00 GMT",
"example.com",
);
assert!(cookie.is_some());
assert!(!cookie.unwrap().expired);
}
#[test]
fn max_age_positive_not_expired() {
let cookie = parse_set_cookie("sid=abc; Max-Age=3600", "example.com");
assert!(cookie.is_some());
assert!(!cookie.unwrap().expired);
}
#[test]
fn max_age_negative_expired() {
let cookie = parse_set_cookie("sid=abc; Max-Age=-1", "example.com");
assert!(cookie.is_some());
assert!(cookie.unwrap().expired);
}
#[test]
fn cookie_jar_debug() {
let jar = CookieJar::new();
let dbg = format!("{jar:?}");
assert!(dbg.contains("CookieJar"));
}
#[test]
fn cookie_jar_cookies_returns_all() {
let jar = CookieJar::new();
let mut headers = HeaderMap::new();
headers.append(SET_COOKIE, "a=1".parse().unwrap());
headers.append(SET_COOKIE, "b=2".parse().unwrap());
jar.store_from_response("example.com", &headers);
let cookies = jar.cookies();
assert_eq!(cookies.len(), 2);
}
#[test]
fn apply_to_request_adds_cookie_header() {
let jar = CookieJar::new();
let mut headers = HeaderMap::new();
headers.append(SET_COOKIE, "token=xyz".parse().unwrap());
jar.store_from_response("example.com", &headers);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
let cookie = req_headers.get("cookie").unwrap().to_str().unwrap();
assert!(cookie.contains("token=xyz"));
}
#[test]
fn apply_to_request_no_match_no_header() {
let jar = CookieJar::new();
let mut headers = HeaderMap::new();
headers.append(SET_COOKIE, "token=xyz".parse().unwrap());
jar.store_from_response("example.com", &headers);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("other.com", false, "/", &mut req_headers);
assert!(req_headers.get("cookie").is_none());
}
#[test]
fn secure_cookie_not_sent_on_http() {
let jar = CookieJar::new();
let mut headers = HeaderMap::new();
headers.append(SET_COOKIE, "s=1; Secure".parse().unwrap());
jar.store_from_response("example.com", &headers);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", false, "/", &mut req_headers);
assert!(req_headers.get("cookie").is_none());
}
#[test]
fn secure_cookie_sent_on_https() {
let jar = CookieJar::new();
let mut headers = HeaderMap::new();
headers.append(SET_COOKIE, "s=1; Secure".parse().unwrap());
jar.store_from_response("example.com", &headers);
let mut req_headers = HeaderMap::new();
jar.apply_to_request("example.com", true, "/", &mut req_headers);
assert!(req_headers.get("cookie").is_some());
}
#[test]
fn cookie_accessors() {
let cookie = parse_set_cookie(
"name=value; Domain=example.com; Path=/api; Secure; HttpOnly; SameSite=Strict",
"example.com",
)
.unwrap();
assert_eq!(cookie.name(), "name");
assert_eq!(cookie.value(), "value");
assert_eq!(cookie.domain(), Some("example.com"));
assert_eq!(cookie.path(), Some("/api"));
assert!(cookie.secure());
assert!(cookie.http_only());
assert_eq!(cookie.same_site(), Some(&SameSite::Strict));
}
#[test]
fn expires_november() {
let cookie = parse_set_cookie(
"sid=abc; Expires=Sun, 06 Nov 2099 08:49:37 GMT",
"example.com",
);
assert!(cookie.is_some());
assert!(!cookie.unwrap().expired);
}
#[test]
fn expires_december() {
let cookie = parse_set_cookie(
"sid=abc; Expires=Mon, 25 Dec 2099 12:00:00 GMT",
"example.com",
);
assert!(cookie.is_some());
assert!(!cookie.unwrap().expired);
}
#[test]
fn expires_invalid_month() {
let cookie = parse_set_cookie(
"sid=abc; Expires=Wed, 21 Xyz 2025 07:28:00 GMT",
"example.com",
);
let c = cookie.unwrap();
assert!(!c.expired);
}
#[test]
fn expires_bad_time_format() {
let cookie = parse_set_cookie("sid=abc; Expires=Wed, 21 Oct 2025 07:28 GMT", "example.com");
let c = cookie.unwrap();
assert!(!c.expired);
}
#[test]
fn expires_leap_year_after_february() {
let cookie = parse_set_cookie(
"sid=abc; Expires=Fri, 01 Mar 2096 00:00:00 GMT",
"example.com",
);
assert!(cookie.is_some());
assert!(!cookie.unwrap().expired);
}
#[test]
fn expired_cookie_removes_existing() {
let jar = CookieJar::new();
jar.store_from_response("example.com", &headers_with_cookies(&["k=v"]));
let cookies = jar.cookies();
assert_eq!(cookies.len(), 1);
jar.store_from_response(
"example.com",
&headers_with_cookies(&["k=v; Expires=Wed, 01 Jan 2020 00:00:00 GMT"]),
);
let cookies = jar.cookies();
assert_eq!(cookies.len(), 0);
}
#[test]
fn parse_http_date_all_months() {
let months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
for m in &months {
let date = format!("Mon, 15 {m} 2099 10:30:00 GMT");
let cookie = parse_set_cookie(&format!("sid=abc; Expires={date}"), "example.com");
assert!(cookie.is_some(), "cookie with month {m} should parse");
assert!(
!cookie.unwrap().expired,
"future date with month {m} should not be expired"
);
}
}
}