use crate::courierust_http::uri::Url;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
const MAX_COOKIES: usize = 3_000;
const MAX_COOKIES_PER_HOST: usize = 180;
const MAX_COOKIE_BYTES: usize = 4_096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cookie {
pub name: String,
pub value: String,
pub host: String,
pub domain: Option<String>,
pub path: String,
pub secure: bool,
pub expires_at: Option<i64>,
created: u64,
}
impl Cookie {
fn effective_domain(&self) -> &str {
self.domain.as_deref().unwrap_or(&self.host)
}
}
#[derive(Debug, Clone)]
pub struct CookieJar {
cookies: Vec<Cookie>,
next_created: u64,
max_cookies: usize,
max_per_host: usize,
}
impl Default for CookieJar {
fn default() -> Self {
Self::new()
}
}
impl CookieJar {
pub fn new() -> Self {
Self::with_limits(MAX_COOKIES, MAX_COOKIES_PER_HOST)
}
pub fn with_limits(max_cookies: usize, max_per_host: usize) -> Self {
Self {
cookies: Vec::new(),
next_created: 0,
max_cookies: max_cookies.max(1),
max_per_host: max_per_host.max(1),
}
}
pub fn len(&self) -> usize {
self.cookies.len()
}
pub fn is_empty(&self) -> bool {
self.cookies.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Cookie> {
self.cookies.iter()
}
pub fn clear(&mut self) {
self.cookies.clear();
}
pub fn store(&mut self, url: &Url, set_cookie: &str) {
self.store_at(url, set_cookie, super::unix_now());
}
pub fn store_at(&mut self, url: &Url, set_cookie: &str, now: i64) {
let Some(parsed) = parse_set_cookie(set_cookie) else {
return;
};
if let Some(domain) = &parsed.domain {
if !domain_match(&url.host, domain) {
return;
}
}
let path = match &parsed.path {
Some(p) if p.starts_with('/') => p.clone(),
_ => default_path(url.path_and_query.as_str()),
};
if parsed.name.len() + parsed.value.len() > MAX_COOKIE_BYTES {
return;
}
let domain = parsed.domain.clone();
let effective = domain.as_deref().unwrap_or(&url.host);
let dead = parsed.deletion_at(now);
let existing = self.cookies.iter().position(|c| {
c.name == parsed.name && c.effective_domain() == effective && c.path == path
});
if now >= dead {
if let Some(index) = existing {
self.cookies.remove(index);
}
return;
}
let created = match existing {
Some(index) => self.cookies[index].created,
None => {
self.next_created += 1;
self.next_created
}
};
let cookie = Cookie {
name: parsed.name,
value: parsed.value,
host: url.host.clone(),
domain,
path,
secure: parsed.secure,
expires_at: match dead {
i64::MAX => None,
at => Some(at),
},
created,
};
match existing {
Some(index) => self.cookies[index] = cookie,
None => self.cookies.push(cookie),
}
self.enforce_limits();
}
pub fn header_value(&self, url: &Url) -> Option<String> {
let secure_scheme = url.scheme == "https";
let path = url.path_and_query.as_str();
let mut matched: Vec<&Cookie> = self
.cookies
.iter()
.filter(|c| host_matches(url, c) && path_match(path, &c.path))
.filter(|c| !c.secure || secure_scheme)
.collect();
if matched.is_empty() {
return None;
}
matched.sort_by(|a, b| {
b.path
.len()
.cmp(&a.path.len())
.then(a.created.cmp(&b.created))
});
let mut out = String::new();
for cookie in matched {
if !out.is_empty() {
out.push_str("; ");
}
out.push_str(&cookie.name);
out.push('=');
out.push_str(&cookie.value);
}
Some(out)
}
fn enforce_limits(&mut self) {
while self.cookies.len() > self.max_cookies {
self.drop_oldest(|_| true);
}
let mut hosts: Vec<String> = self.cookies.iter().map(|c| c.host.clone()).collect();
hosts.sort();
hosts.dedup();
for host in hosts {
loop {
let count = self.cookies.iter().filter(|c| c.host == host).count();
if count <= self.max_per_host {
break;
}
self.drop_oldest(|c| c.host == host);
}
}
}
fn drop_oldest(&mut self, mut keep: impl FnMut(&Cookie) -> bool) {
let oldest = self
.cookies
.iter()
.enumerate()
.filter(|(_, c)| keep(c))
.min_by_key(|(_, c)| c.created)
.map(|(index, _)| index);
if let Some(index) = oldest {
self.cookies.remove(index);
}
}
}
fn host_matches(url: &Url, cookie: &Cookie) -> bool {
match &cookie.domain {
None => url.host == cookie.host,
Some(domain) => domain_match(&url.host, domain),
}
}
fn domain_match(host: &str, domain: &str) -> bool {
if host == domain {
return true;
}
if is_ip_literal(host) || is_ip_literal(domain) {
return false;
}
host.len() > domain.len()
&& host.ends_with(domain)
&& host.as_bytes()[host.len() - domain.len() - 1] == b'.'
}
fn is_ip_literal(host: &str) -> bool {
host.parse::<std::net::IpAddr>().is_ok()
}
fn path_match(request_path: &str, cookie_path: &str) -> bool {
if request_path == cookie_path {
return true;
}
if !request_path.starts_with(cookie_path) {
return false;
}
cookie_path.ends_with('/') || request_path.as_bytes()[cookie_path.len()] == b'/'
}
fn default_path(request_path: &str) -> String {
match request_path.rfind('/') {
Some(0) | None => "/".to_string(),
Some(index) => request_path[..index].to_string(),
}
}
struct ParsedCookie {
name: String,
value: String,
domain: Option<String>,
path: Option<String>,
secure: bool,
max_age: Option<i64>,
expires: Option<i64>,
}
impl ParsedCookie {
fn deletion_at(&self, now: i64) -> i64 {
match self.max_age {
Some(age) if age <= 0 => i64::MIN,
Some(age) => now.saturating_add(age),
None => self.expires.unwrap_or(i64::MAX),
}
}
}
fn parse_set_cookie(header: &str) -> Option<ParsedCookie> {
let mut segments = header.split(';');
let (name, value) = {
let (name, value) = segments.next()?.split_once('=')?;
let name = name.trim();
if !is_token(name) {
return None;
}
(name.to_string(), unquote(value.trim()))
};
let mut parsed = ParsedCookie {
name,
value,
domain: None,
path: None,
secure: false,
max_age: None,
expires: None,
};
for attribute in segments {
let (key, value) = match attribute.split_once('=') {
Some((k, v)) => (k.trim(), v.trim()),
None => (attribute.trim(), ""),
};
if key.eq_ignore_ascii_case("domain") {
let domain = value.trim_start_matches('.').to_ascii_lowercase();
if !domain.is_empty() {
parsed.domain = Some(domain);
}
} else if key.eq_ignore_ascii_case("path") {
parsed.path = Some(value.to_string());
} else if key.eq_ignore_ascii_case("secure") {
parsed.secure = true;
} else if key.eq_ignore_ascii_case("max-age") {
if let Ok(age) = value.parse::<i64>() {
parsed.max_age = Some(age);
}
} else if key.eq_ignore_ascii_case("expires") {
parsed.expires = parse_cookie_date(value);
}
}
Some(parsed)
}
fn unquote(value: &str) -> String {
let bytes = value.as_bytes();
if bytes.len() >= 2 && bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"' {
return value[1..value.len() - 1].to_string();
}
value.to_string()
}
fn is_token(name: &str) -> bool {
!name.is_empty()
&& name.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
}
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
fn parse_cookie_date(value: &str) -> Option<i64> {
let tokens: Vec<&str> = value
.split(|c: char| c == ',' || c.is_whitespace() || c == '-')
.filter(|t| !t.is_empty())
.collect();
let mut day = None;
let mut month = None;
let mut year = None;
let mut clock = None;
for (index, token) in tokens.iter().enumerate() {
if token.len() == 3 {
if let Some(position) = MONTHS.iter().position(|m| m.eq_ignore_ascii_case(token)) {
day = index
.checked_sub(1)
.and_then(|i| tokens[i].parse::<u32>().ok());
month = Some(position as u32 + 1);
}
}
if token.contains(':') {
clock = parse_clock(token);
}
if token.len() == 4 && token.bytes().all(|b| b.is_ascii_digit()) {
year = token.parse::<i32>().ok();
}
}
let (day, month, year, (hour, minute, second)) = (day?, month?, year?, clock?);
let year = if year < 100 {
if year >= 70 {
1900 + year
} else {
2000 + year
}
} else {
year
};
Some(
days_from_civil(year, month, day) * 86_400
+ i64::from(hour) * 3_600
+ i64::from(minute) * 60
+ i64::from(second),
)
}
fn parse_clock(token: &str) -> Option<(u32, u32, u32)> {
let mut parts = token.split(':');
let hour = parts.next()?.parse::<u32>().ok()?;
let minute = parts.next()?.parse::<u32>().ok()?;
let second = parts.next()?.parse::<u32>().ok()?;
(hour < 24 && minute < 60 && second < 60).then_some((hour, minute, second))
}
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
let shift = i32::from(month <= 2);
let y = i64::from(year - shift);
let m = i64::from(month);
let d = i64::from(day);
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> Url {
Url::parse(s).expect("test url")
}
#[test]
fn the_dot_matters_in_domain_matching() {
assert!(!domain_match("evil-example.com", "example.com"));
assert!(domain_match("a.example.com", "example.com"));
assert!(domain_match("example.com", "example.com"));
assert!(!domain_match("example.com", "a.example.com"));
assert!(domain_match("127.0.0.1", "127.0.0.1"));
assert!(!domain_match("127.0.0.1", "0.0.1"));
}
#[test]
fn a_cookie_for_another_host_is_refused_outright() {
let mut jar = CookieJar::new();
jar.store_at(&url("https://evil.com/"), "sid=1; Domain=example.com", 0);
assert!(jar.is_empty(), "a foreign Domain must be ignored");
jar.store_at(
&url("https://a.example.com/"),
"sid=2; Domain=.example.com",
0,
);
assert_eq!(jar.len(), 1, "a leading dot is the same domain");
}
#[test]
fn domain_and_path_matching_decide_where_a_cookie_is_sent() {
let mut jar = CookieJar::new();
jar.store_at(&url("https://example.com/a/b"), "p=1; Path=/a", 0);
jar.store_at(&url("https://example.com/a/b"), "q=2; Path=/a/b", 0);
jar.store_at(&url("https://example.com/a/b"), "r=3", 0);
jar.store_at(
&url("https://example.com/a/b"),
"s=4; Domain=example.com; Path=/",
0,
);
assert_eq!(
jar.header_value(&url("https://example.com/a/b/c")).unwrap(),
"q=2; p=1; r=3; s=4"
);
assert_eq!(
jar.header_value(&url("https://example.com/a/bc")).unwrap(),
"p=1; r=3; s=4",
"`/a` does match `/a/bc` — the next character is a slash"
);
assert_eq!(
jar.header_value(&url("https://example.com/abc")).unwrap(),
"s=4",
"`/a` must not match `/abc`: the next character is not a slash"
);
assert_eq!(
jar.header_value(&url("https://example.com/")).unwrap(),
"s=4"
);
assert_eq!(jar.header_value(&url("https://other.com/a/b/c")), None);
}
#[test]
fn a_host_only_cookie_never_leaves_its_host() {
let mut jar = CookieJar::new();
jar.store_at(&url("https://a.example.com/"), "sid=1", 0);
assert!(
jar.header_value(&url("https://b.example.com/")).is_none(),
"without a Domain attribute there is no sibling access"
);
assert!(jar.header_value(&url("https://a.example.com/")).is_some());
}
#[test]
fn a_secure_cookie_never_travels_in_clear() {
let mut jar = CookieJar::new();
jar.store_at(&url("https://example.com/"), "sid=1; Secure", 0);
assert_eq!(
jar.header_value(&url("https://example.com/")).unwrap(),
"sid=1"
);
assert_eq!(jar.header_value(&url("http://example.com/")), None);
}
#[test]
fn expiry_replacement_and_deletion() {
let mut jar = CookieJar::new();
let u = url("https://example.com/");
jar.store_at(&u, "sid=old", 1_000);
jar.store_at(&u, "sid=new", 1_000);
assert_eq!(jar.len(), 1, "the same key replaces, never duplicates");
assert_eq!(jar.header_value(&u).unwrap(), "sid=new");
jar.store_at(
&u,
"sid=x; Max-Age=100; Expires=Thu, 01 Jan 2099 00:00:00 GMT",
1_000,
);
assert_eq!(jar.iter().next().unwrap().expires_at, Some(1_100));
jar.store_at(&u, "sid=x; Max-Age=0", 1_100);
assert!(jar.is_empty(), "a zero Max-Age deletes");
jar.store_at(&u, "sid=x; Max-Age=0", 1_100);
assert!(jar.is_empty());
jar.store_at(&u, "sid=y", 1_000);
assert_eq!(jar.len(), 1);
jar.store_at(&u, "sid=y; Expires=Thu, 01 Jan 1970 00:00:00 GMT", 1_000);
assert!(jar.is_empty());
}
#[test]
fn dates_are_read_in_the_formats_servers_actually_send() {
assert_eq!(
parse_cookie_date("Thu, 14 Jan 2027 00:00:00 GMT"),
Some(1_799_884_800)
);
assert_eq!(
parse_cookie_date("Thu, 14-Jan-2027 00:00:00 GMT"),
Some(1_799_884_800)
);
assert_eq!(
parse_cookie_date("Sun, 06 Nov 1994 08:49:37 GMT"),
Some(784_111_777)
);
assert_eq!(parse_cookie_date("nonsense"), None);
assert_eq!(parse_cookie_date("Thu, 14 Jan 2027 25:00:00 GMT"), None);
}
#[test]
fn the_civil_calendar_maths_matches_an_independent_implementation() {
let cases = [
((1969, 12, 31), -86_400_i64),
((1970, 1, 1), 0),
((1970, 3, 1), 5_097_600),
((1999, 12, 31), 946_598_400),
((2000, 2, 29), 951_782_400),
((2024, 3, 1), 1_709_251_200),
((2027, 1, 14), 1_799_884_800),
((2038, 1, 19), 2_147_472_000),
];
for ((year, month, day), expected) in cases {
assert_eq!(
days_from_civil(year, month, day) * 86_400,
expected,
"{year}-{month}-{day}"
);
assert_eq!(
civil_from_days(expected.div_euclid(86_400)),
(i64::from(year), month, day),
"inverse of {year}-{month}-{day}"
);
}
assert_eq!(civil_from_days(-1), (1969, 12, 31));
}
#[test]
fn malformed_headers_are_ignored_not_fatal() {
let mut jar = CookieJar::new();
let u = url("https://example.com/");
for bad in ["", "no-equals", "=value", "bad name=1", ";;;"] {
jar.store_at(&u, bad, 0);
}
assert!(jar.is_empty(), "a peer must not break the jar: {jar:?}");
jar.store_at(&u, "ok=1; HttpOnly; SameSite=Lax; Future=x; Path", 0);
assert_eq!(jar.header_value(&u).unwrap(), "ok=1");
jar.store_at(&u, "q=\"a b\"", 0);
assert_eq!(jar.iter().find(|c| c.name == "q").unwrap().value, "a b");
}
#[test]
fn storage_is_bounded_per_host() {
let mut jar = CookieJar::with_limits(100, 3);
let u = url("https://example.com/");
for i in 0..10 {
jar.store_at(&u, &format!("c{i}=1; Path=/p{i}"), 0);
}
assert_eq!(jar.len(), 3, "the per-host cap holds");
let names: Vec<&str> = jar.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["c7", "c8", "c9"], "the oldest go first");
}
#[test]
fn storage_is_bounded_overall() {
let mut jar = CookieJar::with_limits(4, 100);
for i in 0..10 {
let u = url(&format!("https://h{i}.example/"));
jar.store_at(&u, "c=1", 0);
}
assert_eq!(
jar.len(),
4,
"a fleet of hosts cannot grow the jar without bound"
);
}
#[test]
fn an_oversized_cookie_is_dropped_not_truncated() {
let mut jar = CookieJar::new();
let u = url("https://example.com/");
jar.store_at(&u, &format!("big={}; Path=/", "x".repeat(5_000)), 0);
assert!(
jar.is_empty(),
"over the size ceiling, dropped not truncated"
);
}
}