use std::collections::HashMap;
use rfc_6265::OffsetDateTime;
use rfc_6265::domain::{canonicalize, domain_matches};
use rfc_6265::path::{default_path, path_matches};
use crate::cookie::Cookie;
use crate::encoding::ValueEncoding;
use crate::jar::CookieJar;
use crate::report::Reported;
use crate::same_site::SameSite;
use crate::set_cookie::{CookieConstraint, KnownAttribute, SetCookie, SetCookieIssue};
const MAX_EXPIRY_TS: i64 = 253_402_300_799;
fn url_parts(url: &url::Url) -> Option<(&str, &str, bool)> {
Some((url.host_str()?, url.path(), is_secure_url(url)))
}
fn is_secure_url(url: &url::Url) -> bool {
match url.scheme() {
"https" | "wss" => true,
_ => match url.host() {
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
Some(url::Host::Domain(host)) => {
host.eq_ignore_ascii_case("localhost")
|| host.to_ascii_lowercase().ends_with(".localhost")
}
None => false,
},
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct StoreConfig {
pub max_cookies: usize,
pub max_cookies_per_domain: usize,
}
impl Default for StoreConfig {
fn default() -> Self {
Self {
max_cookies: 3000,
max_cookies_per_domain: 50,
}
}
}
#[must_use = "an unchecked insertion can hide a rejected or deleted cookie"]
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Insertion {
Stored,
Replaced,
Deleted,
Rejected(RejectionReason),
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum RejectionReason {
Malformed,
InvalidDomain,
DomainMismatch,
ConstraintViolation,
InsecureOrigin,
}
impl std::fmt::Display for RejectionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Malformed => "no usable name=value pair (or no origin host)",
Self::InvalidDomain => "the Domain attribute was refused and names a foreign host",
Self::DomainMismatch => "the Domain attribute does not cover the origin host",
Self::ConstraintViolation => "an unmet prefix or Partitioned/Secure requirement",
Self::InsecureOrigin => "a Secure cookie set over an insecure origin",
})
}
}
#[derive(Clone, Debug)]
struct StoredCookie {
name: String,
value: String,
domain: String,
host_only: bool,
path: String,
secure: bool,
http_only: bool,
partitioned: bool,
same_site: Option<SameSite>,
expires_at: Option<i64>,
created: u64,
}
impl StoredCookie {
fn expired(&self, now_ts: i64) -> bool {
self.expires_at.is_some_and(|at| at <= now_ts)
}
}
#[derive(Clone, Copy, Debug)]
pub struct StoredRef<'a>(&'a StoredCookie);
impl<'a> StoredRef<'a> {
#[must_use]
pub fn name(&self) -> &'a str {
&self.0.name
}
#[must_use]
pub fn value(&self) -> &'a str {
&self.0.value
}
#[must_use]
pub fn domain(&self) -> &'a str {
&self.0.domain
}
#[must_use]
pub fn host_only(&self) -> bool {
self.0.host_only
}
#[must_use]
pub fn path(&self) -> &'a str {
&self.0.path
}
#[must_use]
pub fn secure(&self) -> bool {
self.0.secure
}
#[must_use]
pub fn http_only(&self) -> bool {
self.0.http_only
}
#[must_use]
pub fn partitioned(&self) -> bool {
self.0.partitioned
}
#[must_use]
pub fn same_site(&self) -> Option<SameSite> {
self.0.same_site
}
#[must_use]
pub fn expires(&self) -> Option<OffsetDateTime> {
self.0
.expires_at
.and_then(|at| OffsetDateTime::from_unix_timestamp(at).ok())
}
}
#[derive(Clone, Debug, Default)]
pub struct CookieStore {
cookies: Vec<StoredCookie>,
next_created: u64,
config: StoreConfig,
}
impl CookieStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_config(config: StoreConfig) -> Self {
Self {
config,
..Self::default()
}
}
pub fn insert(
&mut self,
origin: &url::Url,
set_cookie: &str,
now: OffsetDateTime,
) -> Insertion {
let Some((origin_host, origin_path, origin_secure)) = url_parts(origin) else {
return Insertion::Rejected(RejectionReason::Malformed);
};
let Ok(Reported {
value: cookie,
issues,
}) = SetCookie::parse(set_cookie)
else {
return Insertion::Rejected(RejectionReason::Malformed);
};
let attrs = cookie.attributes();
let origin_host = canonicalize(origin_host);
let (host_only, domain) = match attrs.domain.map(|d| d.as_str()) {
Some(d) if !d.strip_prefix('.').unwrap_or(d).is_empty() => {
let d = canonicalize(d.strip_prefix('.').unwrap_or(d));
if !domain_matches(&origin_host, &d) {
return Insertion::Rejected(RejectionReason::DomainMismatch);
}
(false, d)
}
Some(_) => (true, origin_host),
None => {
if let Some(refused) = refused_domain(&issues) {
let refused = refused.strip_prefix('.').unwrap_or(refused);
if !refused.is_empty() && canonicalize(refused) != origin_host {
return Insertion::Rejected(RejectionReason::InvalidDomain);
}
}
(true, origin_host)
}
};
if attrs.secure && !origin_secure {
return Insertion::Rejected(RejectionReason::InsecureOrigin);
}
if issues.iter().any(|issue| {
matches!(
issue,
SetCookieIssue::ConstraintViolation { constraint }
if !matches!(constraint, CookieConstraint::NonCanonicalPrefixCase)
)
}) {
return Insertion::Rejected(RejectionReason::ConstraintViolation);
}
let path = match attrs.path.map(|p| p.as_str()) {
Some(p) if p.starts_with('/') => p.to_owned(),
_ => default_path(origin_path).to_owned(),
};
let now_ts = now.unix_timestamp();
let expires_at = if negative_max_age(&issues) {
Some(now_ts)
} else if let Some(seconds) = attrs.max_age {
Some(now_ts.saturating_add_unsigned(seconds).min(MAX_EXPIRY_TS))
} else {
attrs.expires.map(OffsetDateTime::unix_timestamp)
};
if expires_at.is_some_and(|at| at <= now_ts) {
self.remove_stored(cookie.name(), &domain, &path);
return Insertion::Deleted;
}
if let Some(existing) = self
.cookies
.iter_mut()
.find(|c| c.name == cookie.name() && c.domain == domain && c.path == path)
{
existing.value = cookie.value().to_owned();
existing.host_only = host_only;
existing.secure = attrs.secure;
existing.http_only = attrs.http_only;
existing.partitioned = attrs.partitioned;
existing.same_site = attrs.same_site;
existing.expires_at = expires_at;
return Insertion::Replaced;
}
self.cookies.push(StoredCookie {
name: cookie.name().to_owned(),
value: cookie.value().to_owned(),
domain,
host_only,
path,
secure: attrs.secure,
http_only: attrs.http_only,
partitioned: attrs.partitioned,
same_site: attrs.same_site,
expires_at,
created: self.next_created,
});
self.next_created += 1;
self.enforce_caps(now_ts);
Insertion::Stored
}
pub fn insert_all<'s>(
&mut self,
origin: &url::Url,
lines: impl IntoIterator<Item = &'s str>,
now: OffsetDateTime,
) {
for line in lines {
let _ = self.insert(origin, line, now);
}
}
pub fn insert_response(
&mut self,
origin: &url::Url,
headers: &http::HeaderMap,
now: OffsetDateTime,
) {
self.insert_all(
origin,
headers
.get_all(http::header::SET_COOKIE)
.into_iter()
.filter_map(|value| value.to_str().ok()),
now,
);
}
pub fn matches<'s>(
&'s self,
request: &url::Url,
now: OffsetDateTime,
) -> impl Iterator<Item = StoredRef<'s>> + 's {
let now_ts = now.unix_timestamp();
let mut hits: Vec<&StoredCookie> = match url_parts(request) {
Some((host, path, secure)) => {
let host = canonicalize(host);
self.cookies
.iter()
.filter(|c| {
!c.expired(now_ts)
&& (!c.secure || secure)
&& (if c.host_only {
host == c.domain
} else {
domain_matches(&host, &c.domain)
})
&& path_matches(path, &c.path)
})
.collect()
}
None => Vec::new(),
};
hits.sort_unstable_by(|a, b| {
b.path
.len()
.cmp(&a.path.len())
.then(a.created.cmp(&b.created))
});
hits.into_iter().map(StoredRef)
}
#[must_use]
pub fn cookie_header(
&self,
request: &url::Url,
now: OffsetDateTime,
) -> Option<http::HeaderValue> {
let mut jar = CookieJar::new();
for cookie in self.matches(request, now) {
jar.add(Cookie::new(cookie.name(), cookie.value()));
}
if jar.is_empty() {
return None;
}
Some(
jar.to_header_value(ValueEncoding::Percent)
.expect("stored names are tokens and Percent output is header-valid"),
)
}
pub fn iter(&self) -> impl Iterator<Item = StoredRef<'_>> {
self.cookies.iter().map(StoredRef)
}
pub fn get<'s>(&'s self, name: &'s str) -> impl Iterator<Item = StoredRef<'s>> + 's {
self.iter().filter(move |c| c.name() == name)
}
pub fn remove(&mut self, name: &str, domain: &str, path: &str) -> bool {
let domain = domain.strip_prefix('.').unwrap_or(domain);
self.remove_stored(name, &canonicalize(domain), path)
}
pub fn clear(&mut self) {
self.cookies.clear();
}
pub fn purge_expired(&mut self, now: OffsetDateTime) {
let now_ts = now.unix_timestamp();
self.cookies.retain(|c| !c.expired(now_ts));
}
#[must_use]
pub fn len(&self) -> usize {
self.cookies.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cookies.is_empty()
}
fn remove_stored(&mut self, name: &str, domain: &str, path: &str) -> bool {
let before = self.cookies.len();
self.cookies
.retain(|c| !(c.name == name && c.domain == domain && c.path == path));
before != self.cookies.len()
}
fn enforce_caps(&mut self, now_ts: i64) {
if self.cookies.len() <= self.config.max_cookies
&& self.cookies.len() <= self.config.max_cookies_per_domain
{
return;
}
self.cookies.retain(|c| !c.expired(now_ts));
let dropped = {
let mut order: Vec<usize> = (0..self.cookies.len()).collect();
order.sort_unstable_by_key(|&i| std::cmp::Reverse(self.cookies[i].created));
let mut per_domain: HashMap<&str, usize> = HashMap::new();
let mut kept_total = 0;
let mut dropped = vec![false; self.cookies.len()];
for &i in &order {
let of_domain = per_domain
.entry(self.cookies[i].domain.as_str())
.or_insert(0);
if *of_domain >= self.config.max_cookies_per_domain
|| kept_total >= self.config.max_cookies
{
dropped[i] = true;
} else {
*of_domain += 1;
kept_total += 1;
}
}
dropped
};
let mut index = 0;
self.cookies.retain(|_| {
let keep = !dropped[index];
index += 1;
keep
});
}
}
#[cfg(feature = "serde")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PersistedCookie {
pub name: String,
pub value: String,
pub domain: String,
pub host_only: bool,
pub path: String,
pub secure: bool,
pub http_only: bool,
pub partitioned: bool,
pub same_site: Option<String>,
pub expires_at: Option<i64>,
}
#[cfg(feature = "serde")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PersistedStore {
pub cookies: Vec<PersistedCookie>,
}
#[cfg(feature = "serde")]
impl CookieStore {
#[must_use]
pub fn export(&self) -> PersistedStore {
PersistedStore {
cookies: self
.cookies
.iter()
.map(|c| PersistedCookie {
name: c.name.clone(),
value: c.value.clone(),
domain: c.domain.clone(),
host_only: c.host_only,
path: c.path.clone(),
secure: c.secure,
http_only: c.http_only,
partitioned: c.partitioned,
same_site: c.same_site.map(|s| s.to_string()),
expires_at: c.expires_at,
})
.collect(),
}
}
#[must_use]
pub fn import(persisted: PersistedStore, config: StoreConfig, now: OffsetDateTime) -> Self {
let mut store = Self::with_config(config);
for (created, c) in persisted.cookies.into_iter().enumerate() {
store.cookies.push(StoredCookie {
name: c.name,
value: c.value,
domain: c.domain,
host_only: c.host_only,
path: c.path,
secure: c.secure,
http_only: c.http_only,
partitioned: c.partitioned,
same_site: c.same_site.as_deref().and_then(|s| s.parse().ok()),
expires_at: c.expires_at,
created: created as u64,
});
}
store.next_created = store.cookies.len() as u64;
store.purge_expired(now);
store.enforce_caps(now.unix_timestamp());
store
}
}
fn refused_domain<'a>(issues: &[SetCookieIssue<'a>]) -> Option<&'a str> {
issues.iter().rev().find_map(|issue| match issue {
SetCookieIssue::InvalidAttributeValue {
attribute: KnownAttribute::Domain,
value,
} => Some(*value),
_ => None,
})
}
fn negative_max_age(issues: &[SetCookieIssue<'_>]) -> bool {
issues.iter().any(|issue| {
matches!(
issue,
SetCookieIssue::InvalidAttributeValue {
attribute: KnownAttribute::MaxAge,
value,
} if value
.strip_prefix('-')
.is_some_and(|digits| !digits.is_empty()
&& digits.bytes().all(|b| b.is_ascii_digit()))
)
})
}
#[cfg(test)]
mod tests {
use time::macros::datetime;
use super::*;
fn now() -> OffsetDateTime {
datetime!(2026-07-11 12:00 UTC)
}
fn u(s: &str) -> url::Url {
url::Url::parse(s).expect("test url")
}
fn header(store: &CookieStore, url: &url::Url, at: OffsetDateTime) -> String {
store
.cookie_header(url, at)
.map(|h| {
h.to_str()
.expect("percent-encoded header is ASCII")
.to_owned()
})
.unwrap_or_default()
}
#[test]
fn host_only_cookie_matches_exactly_its_setting_host() {
let mut store = CookieStore::new();
assert_eq!(
store.insert(&u("https://a.example.test/"), "SID=x", now()),
Insertion::Stored
);
assert_eq!(
header(&store, &u("https://a.example.test/"), now()),
"SID=x"
);
assert_eq!(header(&store, &u("https://b.example.test/"), now()), "");
assert_eq!(header(&store, &u("https://sub.a.example.test/"), now()), "");
let stored = store.iter().next().unwrap();
assert!(stored.host_only());
assert_eq!(stored.domain(), "a.example.test");
}
#[test]
fn domain_cookie_covers_subdomains_not_siblings() {
let mut store = CookieStore::new();
assert_eq!(
store.insert(
&u("https://www.example.test/"),
"SID=x; Domain=.example.test",
now()
),
Insertion::Stored
);
for host in ["example.test", "www.example.test", "deep.sub.example.test"] {
assert_eq!(
header(&store, &u(&format!("https://{host}/")), now()),
"SID=x",
"{host}"
);
}
assert_eq!(header(&store, &u("https://other.test/"), now()), "");
assert_eq!(header(&store, &u("https://badexample.test/"), now()), "");
let stored = store.iter().next().unwrap();
assert!(!stored.host_only());
assert_eq!(stored.domain(), "example.test");
}
#[test]
fn foreign_domain_is_the_anti_planting_rejection() {
let mut store = CookieStore::new();
for wire in [
"SID=x; Domain=victim.test",
"SID=x; Domain=sub.example.test",
] {
assert_eq!(
store.insert(&u("https://example.test/"), wire, now()),
Insertion::Rejected(RejectionReason::DomainMismatch),
"{wire}"
);
}
assert!(store.is_empty());
}
#[test]
fn empty_domain_attribute_degrades_to_host_only() {
for wire in ["SID=x; Domain=", "SID=x; Domain=."] {
let mut store = CookieStore::new();
assert_eq!(
store.insert(&u("https://example.test/"), wire, now()),
Insertion::Stored,
"{wire}"
);
let stored = store.iter().next().unwrap();
assert!(stored.host_only(), "{wire}");
assert_eq!(header(&store, &u("https://sub.example.test/"), now()), "");
}
}
#[cfg(not(feature = "psl"))]
#[test]
fn without_psl_a_public_suffix_domain_is_the_bare_rfc_attach() {
let mut store = CookieStore::new();
assert_eq!(
store.insert(&u("https://foo.com/"), "SID=x; Domain=com", now()),
Insertion::Stored
);
assert_eq!(header(&store, &u("https://bar.com/"), now()), "SID=x");
}
#[cfg(feature = "psl")]
#[test]
fn with_psl_a_foreign_public_suffix_domain_rejects_the_cookie() {
let mut store = CookieStore::new();
assert_eq!(
store.insert(&u("https://foo.com/"), "SID=x; Domain=com", now()),
Insertion::Rejected(RejectionReason::InvalidDomain)
);
assert!(store.is_empty());
}
#[cfg(feature = "psl")]
#[test]
fn with_psl_the_origin_on_the_suffix_itself_degrades_to_host_only() {
let mut store = CookieStore::new();
assert_eq!(
store.insert(&u("https://github.io/"), "SID=x; Domain=github.io", now()),
Insertion::Stored
);
let stored = store.iter().next().unwrap();
assert!(stored.host_only());
assert_eq!(header(&store, &u("https://github.io/"), now()), "SID=x");
assert_eq!(header(&store, &u("https://user.github.io/"), now()), "");
}
#[test]
fn hosts_and_domains_compare_canonicalized() {
let mut store = CookieStore::new();
assert_eq!(
store.insert(
&u("https://WWW.Example.TEST/"),
"SID=x; Domain=EXAMPLE.test",
now()
),
Insertion::Stored
);
assert_eq!(store.iter().next().unwrap().domain(), "example.test");
assert_eq!(
header(&store, &u("https://www.example.test/"), now()),
"SID=x"
);
}
#[test]
fn path_attribute_scopes_and_default_path_applies() {
let mut store = CookieStore::new();
let origin = u("https://example.test/a/b/c");
assert_eq!(
store.insert(&origin, "scoped=1; Path=/app", now()),
Insertion::Stored
);
assert_eq!(store.insert(&origin, "def=1", now()), Insertion::Stored);
assert_eq!(
store.insert(&origin, "rel=1; Path=nope", now()),
Insertion::Stored
);
assert_eq!(
header(&store, &u("https://example.test/app/x"), now()),
"scoped=1"
);
assert_eq!(header(&store, &u("https://example.test/apple"), now()), "");
assert_eq!(
header(&store, &u("https://example.test/a/b/x"), now()),
"def=1; rel=1"
);
assert_eq!(header(&store, &u("https://example.test/a"), now()), "");
for c in store.iter().filter(|c| c.name() != "scoped") {
assert_eq!(c.path(), "/a/b");
}
}
#[test]
fn secure_cookie_needs_a_secure_origin_and_a_secure_request() {
let mut store = CookieStore::new();
assert_eq!(
store.insert(&u("http://example.test/"), "SID=x; Secure", now()),
Insertion::Rejected(RejectionReason::InsecureOrigin)
);
assert_eq!(
store.insert(&u("https://example.test/"), "SID=x; Secure", now()),
Insertion::Stored
);
assert_eq!(header(&store, &u("http://example.test/"), now()), "");
assert_eq!(header(&store, &u("https://example.test/"), now()), "SID=x");
assert_eq!(
store.insert(&u("http://example.test/"), "plain=1", now()),
Insertion::Stored
);
assert_eq!(header(&store, &u("http://example.test/"), now()), "plain=1");
}
#[test]
fn prefix_requirements_gate_storage() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
let rejected = Insertion::Rejected(RejectionReason::ConstraintViolation);
assert_eq!(store.insert(&origin, "__Host-a=1; Path=/", now()), rejected);
assert_eq!(store.insert(&origin, "__Host-a=1; Secure", now()), rejected);
assert_eq!(
store.insert(
&origin,
"__Host-a=1; Secure; Path=/; Domain=example.test",
now()
),
rejected
);
assert_eq!(store.insert(&origin, "__Secure-b=1", now()), rejected);
assert_eq!(
store.insert(&origin, "part=1; Partitioned", now()),
rejected
);
assert!(store.is_empty());
assert_eq!(
store.insert(&origin, "__Host-a=1; Secure; Path=/", now()),
Insertion::Stored
);
assert_eq!(
store.insert(&origin, "__Secure-b=1; Secure", now()),
Insertion::Stored
);
assert_eq!(
store.insert(&origin, "part=1; Partitioned; Secure", now()),
Insertion::Stored
);
let part = store.get("part").next().unwrap();
assert!(part.partitioned() && part.secure());
}
#[test]
fn a_case_variant_prefix_with_met_requirements_stores_verbatim() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(
store.insert(&origin, "__host-SID=x; Secure; Path=/", now()),
Insertion::Stored
);
assert_eq!(store.iter().next().unwrap().name(), "__host-SID");
assert_eq!(
store.insert(&origin, "__host-other=x", now()),
Insertion::Rejected(RejectionReason::ConstraintViolation)
);
}
#[test]
fn max_age_expires_and_wins_over_expires() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(
store.insert(&origin, "short=1; Max-Age=60", now()),
Insertion::Stored
);
assert_eq!(
store.insert(
&origin,
"mixed=1; Max-Age=60; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
now()
),
Insertion::Stored
);
assert_eq!(
store.insert(
&origin,
"gone=1; Expires=Fri, 31 Dec 9999 23:59:59 GMT; Max-Age=0",
now()
),
Insertion::Deleted
);
let at = |secs: i64| now() + time::Duration::seconds(secs);
assert_eq!(header(&store, &origin, at(30)), "short=1; mixed=1");
assert_eq!(header(&store, &origin, at(61)), "");
assert_eq!(
store.get("short").next().unwrap().expires(),
Some(now() + time::Duration::seconds(60))
);
}
#[test]
fn negative_max_age_is_the_delete_idiom() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(store.insert(&origin, "SID=x", now()), Insertion::Stored);
assert_eq!(
store.insert(&origin, "SID=; Max-Age=-1", now()),
Insertion::Deleted
);
assert!(store.is_empty());
assert_eq!(
store.insert(&origin, "SID=; Max-Age=-1", now()),
Insertion::Deleted
);
assert_eq!(
store.insert(&origin, "keep=1; Max-Age=banana", now()),
Insertion::Stored
);
assert_eq!(store.get("keep").next().unwrap().expires(), None);
}
#[test]
fn a_past_expires_deletes_the_identity_twin() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(
store.insert(&origin, "SID=x; Path=/", now()),
Insertion::Stored
);
assert_eq!(
store.insert(
&origin,
"SID=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
now()
),
Insertion::Deleted
);
assert!(store.is_empty());
}
#[test]
fn a_session_cookie_outlives_any_clock() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(store.insert(&origin, "SID=x", now()), Insertion::Stored);
let far = datetime!(9999-01-01 0:00 UTC);
assert_eq!(header(&store, &origin, far), "SID=x");
}
#[test]
fn replacement_keeps_the_creation_order() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(store.insert(&origin, "first=1", now()), Insertion::Stored);
assert_eq!(store.insert(&origin, "second=1", now()), Insertion::Stored);
assert_eq!(store.insert(&origin, "first=2", now()), Insertion::Replaced);
assert_eq!(store.len(), 2);
assert_eq!(header(&store, &origin, now()), "first=2; second=1");
}
#[test]
fn a_domain_variant_replaces_its_host_only_identity_twin() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(store.insert(&origin, "SID=a", now()), Insertion::Stored);
assert_eq!(
store.insert(&origin, "SID=b; Domain=example.test", now()),
Insertion::Replaced
);
let stored = store.iter().next().unwrap();
assert!(!stored.host_only());
assert_eq!(
header(&store, &u("https://sub.example.test/"), now()),
"SID=b"
);
}
#[test]
fn ordering_is_longest_path_then_creation() {
let mut store = CookieStore::new();
let origin = u("https://example.test/a/b");
assert_eq!(
store.insert(&origin, "root=1; Path=/", now()),
Insertion::Stored
);
assert_eq!(
store.insert(&origin, "deep=1; Path=/a/b", now()),
Insertion::Stored
);
assert_eq!(
store.insert(&origin, "mid=1; Path=/a", now()),
Insertion::Stored
);
assert_eq!(
store.insert(&origin, "deep2=1; Path=/a/b", now()),
Insertion::Stored
);
assert_eq!(
header(&store, &origin, now()),
"deep=1; deep2=1; mid=1; root=1"
);
}
#[test]
fn the_per_domain_cap_evicts_the_oldest_of_that_domain() {
let mut store = CookieStore::with_config(StoreConfig {
max_cookies: 100,
max_cookies_per_domain: 2,
});
let a = u("https://a.test/");
let b = u("https://b.test/");
assert_eq!(store.insert(&a, "a1=1", now()), Insertion::Stored);
assert_eq!(store.insert(&b, "b1=1", now()), Insertion::Stored);
assert_eq!(store.insert(&a, "a2=1", now()), Insertion::Stored);
assert_eq!(store.insert(&a, "a3=1", now()), Insertion::Stored);
assert_eq!(header(&store, &a, now()), "a2=1; a3=1");
assert_eq!(header(&store, &b, now()), "b1=1");
}
#[test]
fn the_global_cap_evicts_the_oldest_overall() {
let mut store = CookieStore::with_config(StoreConfig {
max_cookies: 2,
max_cookies_per_domain: 100,
});
assert_eq!(
store.insert(&u("https://a.test/"), "a=1", now()),
Insertion::Stored
);
assert_eq!(
store.insert(&u("https://b.test/"), "b=1", now()),
Insertion::Stored
);
assert_eq!(
store.insert(&u("https://c.test/"), "c=1", now()),
Insertion::Stored
);
assert_eq!(store.len(), 2);
assert_eq!(header(&store, &u("https://a.test/"), now()), "");
assert_eq!(header(&store, &u("https://c.test/"), now()), "c=1");
}
#[test]
fn eviction_prefers_expired_cookies_over_live_ones() {
let mut store = CookieStore::with_config(StoreConfig {
max_cookies: 2,
max_cookies_per_domain: 2,
});
let origin = u("https://example.test/");
assert_eq!(store.insert(&origin, "old=1", now()), Insertion::Stored);
assert_eq!(
store.insert(&origin, "brief=1; Max-Age=1", now()),
Insertion::Stored
);
let later = now() + time::Duration::seconds(2);
assert_eq!(store.insert(&origin, "new=1", later), Insertion::Stored);
assert_eq!(header(&store, &origin, later), "old=1; new=1");
}
#[test]
fn manage_remove_get_iter_clear_purge() {
let mut store = CookieStore::new();
let origin = u("https://example.test/a/b");
assert_eq!(
store.insert(&origin, "SID=x; Path=/", now()),
Insertion::Stored
);
assert_eq!(
store.insert(&origin, "SID=y; Domain=.Example.test; Path=/other", now()),
Insertion::Stored
);
assert_eq!(
store.insert(&origin, "tmp=1; Max-Age=10", now()),
Insertion::Stored
);
assert_eq!(store.get("SID").count(), 2);
assert_eq!(store.iter().count(), 3);
assert_eq!(store.len(), 3);
assert!(store.remove("SID", ".EXAMPLE.test", "/other"));
assert!(!store.remove("SID", "other.test", "/"));
assert_eq!(store.get("SID").count(), 1);
store.purge_expired(now() + time::Duration::seconds(11));
assert_eq!(store.len(), 1);
store.clear();
assert!(store.is_empty());
assert_eq!(store.cookie_header(&origin, now()), None);
}
#[test]
fn malformed_lines_and_a_hostless_origin_are_rejected() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
for wire in ["garbage", "=v", " ; "] {
assert_eq!(
store.insert(&origin, wire, now()),
Insertion::Rejected(RejectionReason::Malformed),
"{wire}"
);
}
assert_eq!(
store.insert(&u("mailto:x@example.test"), "SID=x", now()),
Insertion::Rejected(RejectionReason::Malformed)
);
assert!(store.is_empty());
assert_eq!(store.insert(&origin, "SID=x", now()), Insertion::Stored);
assert_eq!(
store.cookie_header(&u("mailto:x@example.test"), now()),
None
);
assert_eq!(store.cookie_header(&u("data:text/plain,hi"), now()), None);
}
#[test]
fn loopback_origins_count_as_secure() {
for origin in [
"http://127.0.0.1/",
"http://127.9.9.9/",
"http://[::1]/",
"http://localhost/",
"http://app.localhost/",
] {
let mut store = CookieStore::new();
assert_eq!(
store.insert(&u(origin), "SID=x; Secure", now()),
Insertion::Stored,
"{origin}"
);
assert_eq!(header(&store, &u(origin), now()), "SID=x", "{origin}");
}
let mut store = CookieStore::new();
assert_eq!(
store.insert(&u("http://192.168.0.1/"), "SID=x; Secure", now()),
Insertion::Rejected(RejectionReason::InsecureOrigin)
);
}
#[test]
fn wss_is_a_tls_scheme_and_ws_is_not() {
let mut store = CookieStore::new();
assert_eq!(
store.insert(&u("wss://example.test/"), "SID=x; Secure", now()),
Insertion::Stored
);
assert_eq!(header(&store, &u("wss://example.test/"), now()), "SID=x");
assert_eq!(header(&store, &u("ws://example.test/"), now()), "");
}
#[test]
fn a_huge_max_age_saturates_inside_the_datetime_range() {
assert!(OffsetDateTime::from_unix_timestamp(MAX_EXPIRY_TS).is_ok());
assert!(OffsetDateTime::from_unix_timestamp(MAX_EXPIRY_TS + 1).is_err());
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(
store.insert(&origin, "SID=x; Max-Age=18446744073709551615", now()),
Insertion::Stored
);
assert_eq!(
store.get("SID").next().unwrap().expires(),
Some(OffsetDateTime::from_unix_timestamp(MAX_EXPIRY_TS).unwrap())
);
}
#[test]
fn insert_all_and_insert_response_walk_every_line() {
let origin = u("https://example.test/");
let mut store = CookieStore::new();
store.insert_all(&origin, ["a=1", "b=2; Max-Age=-1", "garbage"], now());
assert_eq!(header(&store, &origin, now()), "a=1");
let mut headers = http::HeaderMap::new();
headers.append(http::header::SET_COOKIE, "c=3".parse().unwrap());
headers.append(http::header::SET_COOKIE, "d=4; Secure".parse().unwrap());
let mut store = CookieStore::new();
store.insert_response(&origin, &headers, now());
assert_eq!(header(&store, &origin, now()), "c=3; d=4");
}
#[test]
fn http_only_and_same_site_are_surfaced_not_enforced() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(
store.insert(&origin, "SID=x; HttpOnly; SameSite=Strict", now()),
Insertion::Stored
);
let stored = store.iter().next().unwrap();
assert!(stored.http_only());
assert_eq!(stored.same_site(), Some(SameSite::Strict));
assert_eq!(header(&store, &origin, now()), "SID=x");
}
#[test]
fn values_round_trip_decoded_and_render_canonically() {
let mut store = CookieStore::new();
let origin = u("https://example.test/");
assert_eq!(
store.insert(&origin, "pref=\"dark mode\"", now()),
Insertion::Stored
);
assert_eq!(store.get("pref").next().unwrap().value(), "dark mode");
assert_eq!(header(&store, &origin, now()), "pref=dark%20mode");
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
use time::macros::datetime;
use super::*;
fn now() -> OffsetDateTime {
datetime!(2026-07-11 12:00 UTC)
}
fn u(s: &str) -> url::Url {
url::Url::parse(s).expect("test url")
}
#[test]
fn export_import_round_trips_through_json() {
let origin = u("https://shop.example.test/");
let mut store = CookieStore::new();
store.insert_all(
&origin,
[
"SID=deadbeef; Secure; HttpOnly; SameSite=Strict; Path=/",
"part=1; Partitioned; Secure",
"theme=dark; Max-Age=3600",
"wide=1; Domain=example.test",
],
now(),
);
let json = serde_json::to_string(&store.export()).unwrap();
let revived = CookieStore::import(
serde_json::from_str(&json).unwrap(),
StoreConfig::default(),
now(),
);
let request = u("https://shop.example.test/x");
assert_eq!(
revived.cookie_header(&request, now()),
store.cookie_header(&request, now())
);
let sid = revived.get("SID").next().unwrap();
assert!(sid.http_only() && sid.secure());
assert_eq!(sid.same_site(), Some(SameSite::Strict));
assert!(revived.get("part").next().unwrap().partitioned());
assert_eq!(
revived.get("theme").next().unwrap().expires(),
store.get("theme").next().unwrap().expires()
);
assert_eq!(revived.export(), store.export());
}
#[test]
fn import_applies_now_and_the_caps() {
let origin = u("https://example.test/");
let mut store = CookieStore::new();
store.insert_all(&origin, ["a=1", "b=2; Max-Age=60", "c=3", "d=4"], now());
let persisted = store.export();
let later = now() + time::Duration::hours(1);
let revived = CookieStore::import(
persisted,
StoreConfig {
max_cookies: 2,
max_cookies_per_domain: 2,
},
later,
);
let names: Vec<_> = revived.iter().map(|c| c.name().to_owned()).collect();
assert_eq!(names, ["c", "d"]);
let mut odd = store.export();
odd.cookies.truncate(1);
odd.cookies[0].same_site = Some("Sideways".to_owned());
let revived = CookieStore::import(odd, StoreConfig::default(), now());
assert_eq!(revived.iter().next().unwrap().same_site(), None);
}
}