use std::fs::File;
use std::io::{BufRead, BufReader};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SameSite {
#[default]
Unspecified,
Lax,
Strict,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub expires: Option<u64>,
pub secure: bool,
pub http_only: bool,
pub host_only: bool,
pub same_site: SameSite,
pub partitioned: bool,
pub partition_key: Option<String>,
}
impl Cookie {
fn key(&self) -> CookieKey<'_> {
(&self.name, &self.domain, &self.path)
}
}
type CookieKey<'a> = (&'a str, &'a str, &'a str);
fn same_key(a: CookieKey<'_>, b: CookieKey<'_>) -> bool {
a.0 == b.0 && a.1.eq_ignore_ascii_case(b.1) && a.2 == b.2
}
#[derive(Debug, Clone, Default)]
pub struct CookieJar {
cookies: Vec<Cookie>,
explicit: Vec<(String, String, String)>,
}
impl CookieJar {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.cookies.len()
}
pub fn is_empty(&self) -> bool {
self.cookies.is_empty()
}
pub fn ingest_response(&mut self, request_url: &crate::Url, headers: &[(String, String)]) {
let now = unix_now();
for (k, v) in headers {
if k.eq_ignore_ascii_case("set-cookie") {
self.add_set_cookie(request_url, v, now);
}
}
}
pub fn add_set_cookie(&mut self, request_url: &crate::Url, line: &str, now: u64) {
let Some(parsed) = parse_set_cookie(line, request_url, now) else {
return;
};
if !is_secure_context(request_url) && self.shadows_secure_cookie(&parsed) {
return;
}
if parsed.expires.is_some_and(|exp| exp <= now) {
self.remove_matching(&parsed);
return;
}
self.remove_matching(&parsed);
self.cookies.push(parsed);
}
fn remove_matching(&mut self, c: &Cookie) {
self.cookies.retain(|x| !same_key(x.key(), c.key()));
self.explicit
.retain(|(n, d, p)| !same_key((n, d, p), c.key()));
}
fn shadows_secure_cookie(&self, new: &Cookie) -> bool {
self.cookies.iter().any(|x| {
x.secure
&& x.name == new.name
&& (domain_match(&x.domain, &new.domain, false)
|| domain_match(&new.domain, &x.domain, false))
&& path_match(&new.path, &x.path)
})
}
pub fn purge_expired(&mut self) {
let now = unix_now();
self.cookies
.retain(|c| c.expires.is_none_or(|exp| exp > now));
}
pub fn cookie_header(&self, url: &crate::Url) -> Option<String> {
let now = unix_now();
let mut matches: Vec<&Cookie> = self
.cookies
.iter()
.filter(|c| matches_request(c, url, now))
.filter(|c| !has_forbidden_cookie_char(&c.name) && !has_forbidden_cookie_char(&c.value))
.collect();
if matches.is_empty() {
return None;
}
matches.sort_by_key(|c| std::cmp::Reverse(c.path.len()));
let mut header = String::new();
for (i, c) in matches.iter().enumerate() {
if i > 0 {
header.push_str("; ");
}
header.push_str(&c.name);
header.push('=');
header.push_str(&c.value);
}
Some(header)
}
pub fn load_netscape(path: &str) -> Result<Self> {
let f = File::open(path).map_err(Error::Io)?;
let mut jar = CookieJar::new();
for line in BufReader::new(f).lines() {
let line = line.map_err(Error::Io)?;
match parse_netscape_line(&line) {
LineOutcome::Cookie(cookie) => jar.cookies.push(*cookie),
LineOutcome::Skip => {}
LineOutcome::Malformed => {}
}
}
Ok(jar)
}
pub fn load_netscape_or_empty(path: &str) -> Result<Self> {
match Self::load_netscape(path) {
Ok(j) => Ok(j),
Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(CookieJar::new()),
Err(e) => Err(e),
}
}
pub fn save_netscape(&self, path: &str) -> Result<()> {
use std::fmt::Write as _;
let mut body = String::new();
body.push_str(
"# Netscape HTTP Cookie File\n\
# https://curl.se/docs/http-cookies.html\n\
# This file was generated by rsurl. Edit at your own risk.\n\n",
);
for c in &self.cookies {
if self.is_explicit(c) {
continue;
}
let exp = c.expires.unwrap_or(0);
if has_jar_separator(&c.name)
|| has_jar_separator(&c.value)
|| has_jar_separator(&c.domain)
|| has_jar_separator(&c.path)
{
continue;
}
let prefix = if c.http_only { "#HttpOnly_" } else { "" };
let dflag = if c.host_only { "FALSE" } else { "TRUE" };
let sflag = if c.secure { "TRUE" } else { "FALSE" };
let _ = writeln!(
body,
"{prefix}{}\t{dflag}\t{}\t{sflag}\t{exp}\t{}\t{}",
c.domain, c.path, c.name, c.value
);
}
write_private(path, body.as_bytes())
}
pub fn add_explicit(&mut self, name: &str, value: &str, request_url: &crate::Url) {
let cookie = Cookie {
name: name.to_string(),
value: value.to_string(),
domain: request_url.host.to_ascii_lowercase(),
path: "/".into(),
expires: None,
secure: false,
http_only: false,
host_only: true,
same_site: SameSite::Unspecified,
partitioned: false,
partition_key: None,
};
self.remove_matching(&cookie);
self.explicit.push((
cookie.name.clone(),
cookie.domain.clone(),
cookie.path.clone(),
));
self.cookies.push(cookie);
}
fn is_explicit(&self, c: &Cookie) -> bool {
self.explicit
.iter()
.any(|(n, d, p)| same_key((n, d, p), c.key()))
}
pub fn iter(&self) -> impl Iterator<Item = &Cookie> {
self.cookies.iter()
}
pub fn cookies(&self) -> &[Cookie] {
&self.cookies
}
pub fn insert(&mut self, cookie: Cookie) {
self.remove_matching(&cookie);
self.cookies.push(cookie);
}
pub fn remove(&mut self, name: &str, domain: &str, path: &str) -> bool {
let before = self.cookies.len();
let key = (name, domain, path);
self.cookies.retain(|c| !same_key(c.key(), key));
self.explicit.retain(|(n, d, p)| !same_key((n, d, p), key));
self.cookies.len() != before
}
pub fn clear(&mut self) {
self.cookies.clear();
self.explicit.clear();
}
}
fn matches_request(c: &Cookie, url: &crate::Url, now: u64) -> bool {
if c.expires.is_some_and(|exp| exp <= now) {
return false;
}
if c.secure && !is_secure_context(url) {
return false;
}
if !domain_match(&url.host, &c.domain, c.host_only) {
return false;
}
path_match(request_path(&url.path), &c.path)
}
fn ends_with_ignore_ascii_case(s: &str, suffix: &str) -> bool {
let (s, suffix) = (s.as_bytes(), suffix.as_bytes());
s.len() >= suffix.len() && s[s.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
}
fn starts_with_ignore_ascii_case(s: &str, prefix: &str) -> bool {
let (s, prefix) = (s.as_bytes(), prefix.as_bytes());
s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix)
}
fn domain_match(request_host: &str, cookie_domain: &str, host_only: bool) -> bool {
if request_host.eq_ignore_ascii_case(cookie_domain) {
return true;
}
if host_only {
return false;
}
if is_ip_literal(request_host) {
return false;
}
let (rh, cd) = (request_host.as_bytes(), cookie_domain.as_bytes());
rh.len() > cd.len()
&& rh[rh.len() - cd.len() - 1] == b'.'
&& ends_with_ignore_ascii_case(request_host, cookie_domain)
}
fn request_path(path: &str) -> &str {
match path.find(['?', '#']) {
Some(i) => &path[..i],
None => path,
}
}
fn is_secure_context(url: &crate::Url) -> bool {
if url.is_tls() {
return true;
}
let host = url.host.trim_start_matches('[').trim_end_matches(']');
host.eq_ignore_ascii_case("localhost")
|| ends_with_ignore_ascii_case(host, ".localhost")
|| host == "::1"
|| host
.parse::<std::net::Ipv4Addr>()
.is_ok_and(|ip| ip.is_loopback())
}
fn path_match(request_path: &str, cookie_path: &str) -> bool {
if request_path == cookie_path {
return true;
}
if let Some(rest) = request_path.strip_prefix(cookie_path) {
if cookie_path.ends_with('/') {
return true;
}
return rest.starts_with('/');
}
false
}
fn parse_set_cookie(line: &str, request_url: &crate::Url, now: u64) -> Option<Cookie> {
let mut parts = line.split(';');
let nv = parts.next()?.trim();
let (name, value) = nv.split_once('=')?;
let name = name.trim();
if name.is_empty() {
return None;
}
let value = value.trim();
if has_forbidden_cookie_char(name) || has_forbidden_cookie_char(value) {
return None;
}
let request_host = request_url.host.to_ascii_lowercase();
let mut domain: Option<String> = None;
let mut host_only = true;
let mut path = default_path(request_path(&request_url.path));
let mut has_domain_attr = false;
let mut expires: Option<u64> = None;
let mut max_age: Option<i64> = None;
let mut secure = false;
let mut http_only = false;
let mut same_site = SameSite::Unspecified;
let mut partitioned = false;
for attr in parts {
let attr = attr.trim();
if attr.is_empty() {
continue;
}
let (k, v) = match attr.split_once('=') {
Some((k, v)) => (k.trim(), Some(v.trim())),
None => (attr, None),
};
if k.eq_ignore_ascii_case("domain") {
if let Some(v) = v {
let v = v.strip_prefix('.').unwrap_or(v).to_ascii_lowercase();
if v.is_empty() {
continue;
}
has_domain_attr = true;
if is_ip_literal(&request_host) {
if v != request_host {
continue;
}
continue;
}
if !is_scopable_cookie_domain(&v) {
if v == request_host {
domain = Some(v);
host_only = true;
continue;
}
return None;
}
if !domain_match(&request_host, &v, false) {
return None;
}
domain = Some(v);
host_only = false;
}
} else if k.eq_ignore_ascii_case("path") {
if let Some(v) = v {
if has_forbidden_cookie_char(v) {
return None;
}
if v.starts_with('/') {
path = v.to_string();
}
}
} else if k.eq_ignore_ascii_case("expires") {
if let Some(v) = v {
expires = parse_http_date(v);
}
} else if k.eq_ignore_ascii_case("max-age") {
if let Some(v) = v {
if let Ok(n) = v.parse::<i64>() {
max_age = Some(n);
}
}
} else if k.eq_ignore_ascii_case("secure") {
secure = true;
} else if k.eq_ignore_ascii_case("httponly") {
http_only = true;
} else if k.eq_ignore_ascii_case("samesite") {
same_site = match v.map(|s| s.trim()) {
Some(s) if s.eq_ignore_ascii_case("lax") => SameSite::Lax,
Some(s) if s.eq_ignore_ascii_case("strict") => SameSite::Strict,
Some(s) if s.eq_ignore_ascii_case("none") => SameSite::None,
_ => SameSite::Unspecified,
};
} else if k.eq_ignore_ascii_case("partitioned") {
partitioned = true;
}
}
if secure && !is_secure_context(request_url) {
return None;
}
if starts_with_ignore_ascii_case(name, "__secure-") && !secure {
return None;
}
if starts_with_ignore_ascii_case(name, "__host-") && (!secure || has_domain_attr || path != "/")
{
return None;
}
if let Some(ma) = max_age {
expires = if ma <= 0 {
Some(0) } else {
Some(now.saturating_add(ma as u64))
};
}
Some(Cookie {
name: name.to_string(),
value: value.to_string(),
domain: domain.unwrap_or(request_host),
path,
expires,
secure,
http_only,
host_only,
same_site,
partitioned,
partition_key: None,
})
}
fn default_path(uri_path: &str) -> String {
if !uri_path.starts_with('/') {
return "/".into();
}
match uri_path.rfind('/') {
Some(idx) if idx > 0 => uri_path[..idx].to_string(),
_ => "/".into(),
}
}
fn parse_http_date(s: &str) -> Option<u64> {
let is_delim = |b: &u8| {
*b == 0x09
|| (0x20..=0x2f).contains(b)
|| (0x3b..=0x40).contains(b)
|| (0x5b..=0x60).contains(b)
|| (0x7b..=0x7e).contains(b)
};
let mut time: Option<(u32, u32, u32)> = None;
let mut day: Option<u32> = None;
let mut month: Option<u32> = None;
let mut year: Option<i32> = None;
for token in s.as_bytes().split(is_delim).filter(|t| !t.is_empty()) {
if time.is_none() {
if let Some(t) = cookie_date_time(token) {
time = Some(t);
continue;
}
}
if day.is_none() {
if let Some(d) = leading_digits(token, 1, 2) {
day = Some(d);
continue;
}
}
if month.is_none() {
if let Some(m) = std::str::from_utf8(token).ok().and_then(parse_month) {
month = Some(m);
continue;
}
}
if year.is_none() {
if let Some(y) = leading_digits(token, 2, 4) {
year = Some(y as i32);
continue;
}
}
}
let (hh, mm, ss) = time?;
let (day, month, mut year) = (day?, month?, year?);
if (70..=99).contains(&year) {
year += 1900;
} else if (0..=69).contains(&year) {
year += 2000;
}
if year < 1601 || hh > 23 || mm > 59 || ss > 59 {
return None;
}
epoch_from_ymd_hms(year, month, day, hh, mm, ss)
}
fn leading_digits(token: &[u8], min: usize, max: usize) -> Option<u32> {
let n = token.iter().take_while(|b| b.is_ascii_digit()).count();
if n < min || n > max {
return None;
}
std::str::from_utf8(&token[..n]).ok()?.parse().ok()
}
fn cookie_date_time(token: &[u8]) -> Option<(u32, u32, u32)> {
let mut rest = token;
let mut parts = [0u32; 3];
for (i, part) in parts.iter_mut().enumerate() {
let n = rest.iter().take_while(|b| b.is_ascii_digit()).count();
if !(1..=2).contains(&n) {
return None;
}
*part = std::str::from_utf8(&rest[..n]).ok()?.parse().ok()?;
rest = &rest[n..];
if i < 2 {
rest = rest.strip_prefix(b":")?;
}
}
Some((parts[0], parts[1], parts[2]))
}
fn parse_month(s: &str) -> Option<u32> {
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
let prefix = s.get(..3)?;
MONTHS
.iter()
.position(|m| m.eq_ignore_ascii_case(prefix))
.map(|i| i as u32 + 1)
}
fn epoch_from_ymd_hms(y: i32, m: u32, d: u32, hh: u32, mm: u32, ss: u32) -> Option<u64> {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 {
return None;
}
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let month_days = match m {
2 if leap => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
};
if d > month_days {
return None;
}
let year = if m <= 2 { y - 1 } else { y };
let era = if year >= 0 { year } else { year - 399 } / 400;
let yoe = (year - era * 400) as u32; let mp = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days_since_civil_epoch = (era as i64) * 146097 + (doe as i64) - 719468; if days_since_civil_epoch < 0 {
return Some(0);
}
let secs = (days_since_civil_epoch as u64) * 86400
+ (hh as u64) * 3600
+ (mm as u64) * 60
+ (ss as u64);
Some(secs)
}
fn has_forbidden_cookie_char(s: &str) -> bool {
s.bytes().any(|b| b.is_ascii_control() || b == 0x7f)
}
fn has_jar_separator(s: &str) -> bool {
s.bytes().any(|b| b == b'\t' || b == b'\r' || b == b'\n')
}
fn write_private(path: &str, contents: &[u8]) -> Result<()> {
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt;
let tmp = format!("{path}.tmp.{}", std::process::id());
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)
.map_err(Error::Io)?;
let res = f.write_all(contents).and_then(|()| f.flush());
if let Err(e) = res {
let _ = std::fs::remove_file(&tmp);
return Err(Error::Io(e));
}
drop(f);
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(Error::Io(e));
}
Ok(())
}
#[cfg(not(unix))]
{
use std::io::Write as _;
let mut f = File::create(path).map_err(Error::Io)?;
f.write_all(contents).map_err(Error::Io)?;
Ok(())
}
}
fn is_scopable_cookie_domain(domain: &str) -> bool {
let d = domain.trim_matches('.');
if d.is_empty() {
return false;
}
let lowered = d.to_ascii_lowercase();
psl2::lookup(&lowered).is_some_and(|dom| !dom.is_public_suffix())
}
fn is_ip_literal(host: &str) -> bool {
if host.starts_with('[') || host.contains(':') {
return true;
}
let mut parts = 0usize;
for part in host.split('.') {
if part.parse::<u8>().is_err() {
return false;
}
parts += 1;
}
parts == 4
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
enum LineOutcome {
Cookie(Box<Cookie>),
Skip,
Malformed,
}
fn parse_netscape_line(line: &str) -> LineOutcome {
let mut http_only = false;
let body = if let Some(rest) = line.strip_prefix("#HttpOnly_") {
http_only = true;
rest
} else if line.starts_with('#') || line.trim().is_empty() {
return LineOutcome::Skip;
} else {
line
};
let fields: Vec<&str> = body.split('\t').collect();
if fields.len() != 7 && fields.len() != 6 {
return LineOutcome::Malformed;
}
let domain = fields[0];
if domain.is_empty() {
return LineOutcome::Malformed;
}
let host_only = !fields[1].eq_ignore_ascii_case("TRUE");
let path = fields[2].to_string();
let secure = fields[3].eq_ignore_ascii_case("TRUE");
let Ok(expires_raw) = fields[4].parse::<i64>() else {
return LineOutcome::Malformed;
};
let expires = if expires_raw <= 0 {
None
} else {
Some(expires_raw as u64)
};
let name = fields[5].to_string();
let value = fields.get(6).copied().unwrap_or("").to_string();
if name.is_empty() {
return LineOutcome::Malformed;
}
if has_forbidden_cookie_char(&name) || has_forbidden_cookie_char(&value) {
return LineOutcome::Skip;
}
let domain = domain.trim_start_matches('.').to_string();
let host_only = host_only || is_ip_literal(&domain);
if !host_only && !is_scopable_cookie_domain(&domain) {
return LineOutcome::Skip;
}
LineOutcome::Cookie(Box::new(Cookie {
name,
value,
domain,
path,
expires,
secure,
http_only,
host_only,
same_site: SameSite::Unspecified,
partitioned: false,
partition_key: None,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Url;
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn parses_samesite_and_partitioned() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "a=1; Path=/; SameSite=Lax", 0);
j.add_set_cookie(
&url("https://example.com/"),
"b=2; Path=/; Secure; SameSite=None; Partitioned",
0,
);
j.add_set_cookie(&url("https://example.com/"), "c=3; Path=/", 0);
let by = |name: &str| j.iter().find(|c| c.name == name).unwrap().clone();
assert_eq!(by("a").same_site, SameSite::Lax);
assert!(!by("a").partitioned);
assert_eq!(by("b").same_site, SameSite::None);
assert!(by("b").partitioned);
assert_eq!(by("c").same_site, SameSite::Unspecified);
}
#[test]
fn path_with_control_byte_is_dropped() {
for bad in ["/x\ry", "/x\ny", "/x\0y", "/x\ty"] {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), &format!("a=1; Path={bad}"), 0);
assert!(j.is_empty(), "Path={bad:?} should drop the cookie");
}
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "a=1; Path=/ok", 0);
assert_eq!(j.iter().count(), 1);
}
#[test]
fn jar_enumeration_and_mutation() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "a=1; Path=/", 0);
assert_eq!(j.iter().count(), 1);
assert_eq!(j.cookies().len(), 1);
let mut c = j.cookies()[0].clone();
c.name = "p".into();
c.partition_key = Some("https://top.example".into());
j.insert(c);
let p = j.iter().find(|c| c.name == "p").unwrap();
assert_eq!(p.partition_key.as_deref(), Some("https://top.example"));
let mut dup = p.clone();
dup.value = "v2".into();
j.insert(dup);
assert_eq!(j.iter().filter(|c| c.name == "p").count(), 1);
assert_eq!(j.iter().find(|c| c.name == "p").unwrap().value, "v2");
assert!(j.remove("a", "example.com", "/"));
assert!(!j.remove("missing", "example.com", "/"));
j.clear();
assert!(j.is_empty());
}
#[cfg(unix)]
#[test]
fn saved_jar_is_owner_only_0600() {
use std::os::unix::fs::PermissionsExt;
let mut j = CookieJar::new();
j.add_set_cookie(
&url("https://example.com/"),
"sid=secret; Path=/; Expires=Wed, 09 Jun 2100 10:18:14 GMT",
0,
);
let path = std::env::temp_dir().join(format!(
"rsurl-jar-test-{}-{:?}.txt",
std::process::id(),
std::thread::current().id()
));
let path_str = path.to_str().unwrap();
j.save_netscape(path_str).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
let _ = std::fs::remove_file(&path);
assert_eq!(
mode & 0o777,
0o600,
"cookie jar must be owner-only, got {:o}",
mode & 0o777
);
}
#[test]
fn parses_simple_set_cookie() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "sid=abc; Path=/; Secure", 0);
assert_eq!(j.cookies.len(), 1);
let c = &j.cookies[0];
assert_eq!(c.name, "sid");
assert_eq!(c.value, "abc");
assert_eq!(c.domain, "example.com");
assert!(c.host_only);
assert!(c.secure);
assert_eq!(c.path, "/");
}
#[test]
fn domain_attribute_enables_subdomain_match() {
let mut j = CookieJar::new();
j.add_set_cookie(
&url("https://www.example.com/"),
"id=1; Domain=example.com; Path=/",
0,
);
assert_eq!(
j.cookie_header(&url("https://www.example.com/")).as_deref(),
Some("id=1")
);
assert_eq!(
j.cookie_header(&url("https://api.example.com/")).as_deref(),
Some("id=1")
);
assert!(j.cookie_header(&url("https://other.com/")).is_none());
}
#[test]
fn rejects_cookie_for_unrelated_domain() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "evil=1; Domain=other.com", 0);
assert!(j.is_empty());
}
#[test]
fn secure_only_on_https() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "s=1; Secure", 0);
assert!(j.cookie_header(&url("https://example.com/")).is_some());
assert!(j.cookie_header(&url("http://example.com/")).is_none());
}
#[test]
fn max_age_overrides_expires() {
let mut j = CookieJar::new();
j.add_set_cookie(
&url("https://example.com/"),
"a=1; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=3600",
1_000_000,
);
let c = &j.cookies[0];
assert_eq!(c.expires, Some(1_003_600));
}
#[test]
fn negative_max_age_deletes() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "a=1", 100);
j.add_set_cookie(&url("https://example.com/"), "a=1; Max-Age=-1", 100);
assert!(j.is_empty());
}
#[test]
fn cookie_header_sorts_longer_path_first() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/foo/bar"), "deep=1", 0);
j.add_set_cookie(&url("https://example.com/"), "root=1", 0);
let h = j
.cookie_header(&url("https://example.com/foo/bar"))
.unwrap();
assert!(h.starts_with("deep=1"), "got {h:?}");
assert!(h.ends_with("root=1"), "got {h:?}");
}
#[test]
fn path_match_respects_segment_boundary() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/foo"), "f=1; Path=/foo", 0);
assert!(j
.cookie_header(&url("https://example.com/foobar"))
.is_none());
assert!(j.cookie_header(&url("https://example.com/foo/x")).is_some());
}
#[test]
fn netscape_round_trip() {
let mut j = CookieJar::new();
j.cookies.push(Cookie {
name: "sid".into(),
value: "abc".into(),
domain: "example.com".into(),
path: "/".into(),
expires: Some(2_000_000_000),
secure: true,
http_only: true,
host_only: false,
same_site: SameSite::Unspecified,
partitioned: false,
partition_key: None,
});
let tmp = std::env::temp_dir().join("rsurl_cookie_test.txt");
let path = tmp.to_str().unwrap();
j.save_netscape(path).unwrap();
let back = CookieJar::load_netscape(path).unwrap();
let _ = std::fs::remove_file(path);
assert_eq!(back.cookies.len(), 1);
let c = &back.cookies[0];
assert_eq!(c.name, "sid");
assert_eq!(c.value, "abc");
assert!(c.secure);
assert!(c.http_only);
assert!(!c.host_only);
assert_eq!(c.expires, Some(2_000_000_000));
}
#[test]
fn netscape_load_missing_treated_as_empty_with_helper() {
let j = CookieJar::load_netscape_or_empty("/nonexistent/path/cookies.txt").unwrap();
assert!(j.is_empty());
}
#[test]
fn ingest_response_picks_up_multiple_set_cookies() {
let mut j = CookieJar::new();
let u = url("https://example.com/");
let h = vec![
("Set-Cookie".into(), "a=1".into()),
("X-Other".into(), "skip".into()),
("Set-Cookie".into(), "b=2".into()),
];
j.ingest_response(&u, &h);
assert_eq!(j.len(), 2);
}
#[test]
fn default_path_strips_filename() {
assert_eq!(default_path("/"), "/");
assert_eq!(default_path("/foo"), "/");
assert_eq!(default_path("/foo/bar"), "/foo");
assert_eq!(default_path("/foo/bar/"), "/foo/bar");
}
#[test]
fn parses_imf_fixdate() {
let t = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
assert_eq!(t, 784_111_777);
}
#[test]
fn parses_rfc850() {
let t = parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").unwrap();
assert_eq!(t, 784_111_777);
}
#[test]
fn parses_asctime() {
let t = parse_http_date("Sun Nov 6 08:49:37 1994").unwrap();
assert_eq!(t, 784_111_777);
}
#[test]
fn rejects_set_cookie_with_tab_in_value() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "sid=ab\tcd", 0);
assert!(j.is_empty(), "cookie with TAB in value must be dropped");
}
#[test]
fn rejects_set_cookie_with_newline_in_name() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "a\r\nb=1", 0);
assert!(j.is_empty(), "cookie with CRLF in name must be dropped");
}
#[test]
fn rejects_set_cookie_with_control_char_in_value() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "k=va\x00lue", 0);
assert!(j.is_empty(), "cookie with NUL in value must be dropped");
}
#[test]
fn rejects_bare_tld_domain_attribute() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "evil=1; Domain=com", 0);
assert!(j.is_empty(), "bare-TLD Domain= must be rejected");
}
#[test]
fn accepts_registrable_domain_attribute() {
let mut j = CookieJar::new();
j.add_set_cookie(
&url("https://www.example.com/"),
"id=1; Domain=example.com",
0,
);
assert_eq!(j.len(), 1);
}
#[test]
fn rejects_multi_label_public_suffix_domain() {
let mut j = CookieJar::new();
j.add_set_cookie(
&url("https://www.example.co.uk/"),
"evil=1; Domain=co.uk",
0,
);
assert!(
j.is_empty(),
"Domain=co.uk (public suffix) must be rejected"
);
}
#[test]
fn accepts_registrable_domain_under_multi_label_suffix() {
let mut j = CookieJar::new();
j.add_set_cookie(
&url("https://www.example.co.uk/"),
"id=1; Domain=example.co.uk",
0,
);
assert_eq!(j.len(), 1, "Domain=example.co.uk (eTLD+1) must be accepted");
}
#[test]
fn rejects_private_section_public_suffix_domain() {
let mut j = CookieJar::new();
j.add_set_cookie(
&url("https://user.github.io/"),
"evil=1; Domain=github.io",
0,
);
assert!(
j.is_empty(),
"Domain=github.io (PSL private suffix) must be rejected"
);
let mut j2 = CookieJar::new();
j2.add_set_cookie(
&url("https://x.user.github.io/"),
"id=1; Domain=user.github.io",
0,
);
assert_eq!(
j2.len(),
1,
"Domain=user.github.io (eTLD+1) must be accepted"
);
}
#[test]
fn save_netscape_skips_record_with_embedded_separator() {
let mut j = CookieJar::new();
j.cookies.push(Cookie {
name: "sid".into(),
value: "abc\tevil.com\tFALSE\t/\tTRUE\t9999999999\tforged".into(),
domain: "example.com".into(),
path: "/".into(),
expires: Some(2_000_000_000),
secure: false,
http_only: false,
host_only: true,
same_site: SameSite::Unspecified,
partitioned: false,
partition_key: None,
});
let tmp = std::env::temp_dir().join("rsurl_cookie_sep_test.txt");
let path = tmp.to_str().unwrap();
j.save_netscape(path).unwrap();
let body = std::fs::read_to_string(path).unwrap();
let _ = std::fs::remove_file(path);
assert!(
!body.contains("forged"),
"record with embedded TAB must be skipped, got: {body}"
);
}
#[test]
fn load_netscape_drops_entry_with_control_char() {
let tmp = std::env::temp_dir().join("rsurl_cookie_hostile_load.txt");
let path = tmp.to_str().unwrap();
std::fs::write(
&tmp,
"example.com\tTRUE\t/\tFALSE\t2000000000\tsid\tab\rcd\n",
)
.unwrap();
let j = CookieJar::load_netscape(path).unwrap();
let _ = std::fs::remove_file(path);
assert!(j.is_empty(), "entry with CR in value must be dropped");
}
#[test]
fn load_netscape_drops_bare_tld_domain() {
let tmp = std::env::temp_dir().join("rsurl_cookie_bare_tld_load.txt");
let path = tmp.to_str().unwrap();
std::fs::write(&tmp, ".com\tTRUE\t/\tFALSE\t2000000000\tsid\tabc\n").unwrap();
let j = CookieJar::load_netscape(path).unwrap();
let _ = std::fs::remove_file(path);
assert!(j.is_empty(), "bare-TLD jar entry must be dropped");
}
#[test]
fn load_netscape_keeps_host_only_single_label() {
let tmp = std::env::temp_dir().join("rsurl_cookie_localhost_load.txt");
let path = tmp.to_str().unwrap();
std::fs::write(&tmp, "localhost\tFALSE\t/\tFALSE\t2000000000\tsid\tabc\n").unwrap();
let j = CookieJar::load_netscape(path).unwrap();
let _ = std::fs::remove_file(path);
assert_eq!(j.len(), 1, "host-only localhost cookie must load");
}
#[test]
fn ip_literal_host_rejects_suffix_domain_attribute() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("http://127.0.0.1/"), "evil=1; Domain=0.0.1", 0);
assert_eq!(j.len(), 1, "cookie should be kept but as host-only");
let c = &j.cookies[0];
assert!(c.host_only, "IP-literal host must force host-only");
assert_eq!(c.domain, "127.0.0.1");
assert!(j.cookie_header(&url("http://10.0.0.1/")).is_none());
assert!(j.cookie_header(&url("http://127.0.0.1/")).is_some());
}
#[test]
fn ip_literal_host_exact_domain_is_host_only() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("http://127.0.0.1/"), "id=1; Domain=127.0.0.1", 0);
assert_eq!(j.len(), 1);
assert!(j.cookies[0].host_only, "exact IP Domain= stays host-only");
}
#[test]
fn is_ip_literal_classifies_hosts() {
assert!(is_ip_literal("127.0.0.1"));
assert!(is_ip_literal("10.0.0.1"));
assert!(is_ip_literal("[::1]"));
assert!(is_ip_literal("::1"));
assert!(is_ip_literal("fe80::1"));
assert!(!is_ip_literal("example.com"));
assert!(!is_ip_literal("0.0.1")); assert!(!is_ip_literal("999.0.0.1")); assert!(!is_ip_literal("localhost"));
}
#[test]
fn netscape_ip_suffix_domain_not_sent_to_unrelated_ip_host() {
let line = "0.0.1\tTRUE\t/\tFALSE\t9999999999\tsid\tsecret";
let c = match parse_netscape_line(line) {
LineOutcome::Cookie(c) => *c,
other => panic!("expected Cookie outcome, got {}", other_label(&other)),
};
assert!(
!domain_match("10.0.0.1", &c.domain, c.host_only),
"IP-suffix cookie must not match an unrelated IP host"
);
assert!(
!matches_request(&c, &url("http://10.0.0.1/"), 0),
"IP-suffix cookie must not be selected for an unrelated IP host"
);
}
#[test]
fn netscape_ip_literal_domain_coerced_to_host_only() {
let line = "10.0.0.1\tTRUE\t/\tFALSE\t9999999999\tsid\tsecret";
match parse_netscape_line(line) {
LineOutcome::Cookie(c) => assert!(
c.host_only,
"full IP-literal domain must be coerced to host-only on load"
),
other => panic!("expected Cookie outcome, got {}", other_label(&other)),
}
}
fn other_label(o: &LineOutcome) -> &'static str {
match o {
LineOutcome::Cookie(_) => "Cookie",
LineOutcome::Skip => "Skip",
LineOutcome::Malformed => "Malformed",
}
}
#[test]
fn parse_month_rejects_short_and_multibyte_without_panic() {
assert_eq!(parse_month("ja"), None); assert_eq!(parse_month(""), None);
assert_eq!(parse_month("é"), None); assert_eq!(parse_month("aé"), None); assert_eq!(parse_month("zzz"), None); assert_eq!(parse_month("Jan"), Some(1)); }
#[test]
fn crafted_multibyte_expires_does_not_panic_and_is_session() {
let mut j = CookieJar::new();
j.add_set_cookie(
&url("https://example.com/"),
"a=1; Expires=Sun, 06 é\u{00e9}é 1994 08:49:37 GMT",
1000,
);
assert_eq!(j.len(), 1);
assert_eq!(j.cookies[0].expires, None, "bad Expires → session cookie");
}
#[test]
fn secure_cookie_withheld_over_non_tls_scheme() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "s=1; Secure", 0);
assert!(j.cookie_header(&url("https://example.com/")).is_some());
assert!(j.cookie_header(&url("http://example.com/")).is_none());
assert!(j.cookie_header(&url("wss://example.com/")).is_some());
}
#[test]
fn add_explicit_creates_session_cookie() {
let mut j = CookieJar::new();
j.add_explicit("session", "xyz", &url("https://example.com/"));
let h = j.cookie_header(&url("https://example.com/")).unwrap();
assert_eq!(h, "session=xyz");
let tmp = std::env::temp_dir().join("rsurl_explicit_cookie_test.txt");
let path = tmp.to_str().unwrap();
j.save_netscape(path).unwrap();
let body = std::fs::read_to_string(path).unwrap();
let _ = std::fs::remove_file(path);
assert!(
!body.contains("session"),
"session cookies must not be saved, got: {body}"
);
}
#[test]
fn rfc850_style_with_four_digit_year() {
assert_eq!(parse_http_date("Thu, 01-Jan-1970 00:00:01 GMT"), Some(1));
assert_eq!(
parse_http_date("Sun, 21-Oct-2035 07:28:00 GMT"),
Some(2_076_564_480)
);
}
#[test]
fn two_digit_years_pivot_at_70_in_every_format() {
assert_eq!(
parse_http_date("Sun, 06 Nov 94 08:49:37 GMT"),
Some(784_111_777)
);
assert_eq!(
parse_http_date("Thu, 01-Jan-69 00:00:00 GMT"),
parse_http_date("Thu, 01 Jan 2069 00:00:00 GMT")
);
assert_eq!(parse_http_date("Thu, 01-Jan-70 00:00:10 GMT"), Some(10));
}
#[test]
fn pre_epoch_date_is_expired_not_session() {
assert_eq!(parse_http_date("Fri, 31 Dec 1965 23:59:59 GMT"), Some(0));
assert_eq!(parse_http_date("Sat, 01 Jan 1600 00:00:00 GMT"), None);
}
#[test]
fn invalid_calendar_dates_rejected() {
assert_eq!(parse_http_date("Sat, 31 Feb 2024 00:00:00 GMT"), None);
assert_eq!(parse_http_date("Sat, 01 Jan 2024 24:00:00 GMT"), None);
assert!(parse_http_date("Thu, 29 Feb 2024 00:00:00 GMT").is_some());
}
#[test]
fn past_expires_in_any_format_deletes_cookie() {
for date in [
"Thu, 01-Jan-1970 00:00:01 GMT",
"Thu, 01 Jan 1970 00:00:01 GMT",
"Wed, 31-Dec-1969 23:59:59 GMT",
"Thu Jan 1 00:00:01 1970",
] {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "sid=abc", 1000);
j.add_set_cookie(
&url("https://example.com/"),
&format!("sid=deleted; Expires={date}"),
1000,
);
assert!(j.is_empty(), "Expires={date} must delete the cookie");
}
}
#[test]
fn secure_cookie_cannot_be_set_from_http() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("http://example.com/"), "s=1; Secure", 0);
assert!(j.is_empty());
j.add_set_cookie(&url("http://localhost/"), "s=1; Secure", 0);
j.add_set_cookie(&url("http://127.0.0.1/"), "t=1; Secure", 0);
assert_eq!(j.len(), 2);
assert_eq!(
j.cookie_header(&url("http://localhost/")).as_deref(),
Some("s=1")
);
}
#[test]
fn http_cannot_overwrite_or_delete_secure_cookie() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "tok=good; Secure", 0);
j.add_set_cookie(&url("http://example.com/"), "tok=evil", 0);
j.add_set_cookie(&url("http://example.com/"), "tok=x; Max-Age=0", 0);
assert_eq!(
j.cookie_header(&url("https://example.com/")).as_deref(),
Some("tok=good")
);
j.add_set_cookie(&url("https://example.com/"), "tok=new; Secure", 0);
assert_eq!(
j.cookie_header(&url("https://example.com/")).as_deref(),
Some("tok=new")
);
}
#[test]
fn cookie_name_prefixes_enforced() {
let https = url("https://example.com/a/b");
let mut j = CookieJar::new();
j.add_set_cookie(&https, "__Secure-a=1", 0); j.add_set_cookie(&https, "__Host-b=1; Secure", 0); j.add_set_cookie(&https, "__Host-c=1; Secure; Path=/; Domain=example.com", 0);
j.add_set_cookie(&https, "__host-d=1; Path=/", 0); assert!(j.is_empty(), "got {:?}", j.cookies());
j.add_set_cookie(&https, "__Secure-ok=1; Secure; Domain=example.com", 0);
j.add_set_cookie(&https, "__Host-ok=1; Secure; Path=/", 0);
assert_eq!(j.len(), 2);
}
#[test]
fn query_string_does_not_break_path_matching() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/dir/"), "a=1; Path=/dir", 0);
assert_eq!(
j.cookie_header(&url("https://example.com/dir?x=1"))
.as_deref(),
Some("a=1")
);
j.add_set_cookie(&url("https://example.com/p?r=/a/b"), "b=1", 0);
let b = j.cookies().iter().find(|c| c.name == "b").unwrap();
assert_eq!(b.path, "/");
}
#[test]
fn public_suffix_domain_equal_to_host_is_host_only() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("http://localhost/"), "a=1; Domain=localhost", 0);
assert_eq!(j.len(), 1);
assert!(j.cookies()[0].host_only);
j.add_set_cookie(&url("https://foo.co.uk/"), "b=1; Domain=co.uk", 0);
assert_eq!(j.len(), 1);
}
#[test]
fn session_cookies_round_trip_through_jar_with_zero_expiry() {
let mut j = CookieJar::new();
j.add_set_cookie(&url("https://example.com/"), "sess=1", 0);
let tmp = std::env::temp_dir().join(format!(
"rsurl_session_cookie_test_{}.txt",
std::process::id()
));
let path = tmp.to_str().unwrap();
j.save_netscape(path).unwrap();
let body = std::fs::read_to_string(path).unwrap();
assert!(body.contains("\t0\tsess\t1"), "got: {body}");
let back = CookieJar::load_netscape(path).unwrap();
let _ = std::fs::remove_file(path);
assert_eq!(back.len(), 1);
assert_eq!(back.cookies()[0].expires, None);
}
#[test]
fn malformed_line_skipped_and_six_field_line_accepted() {
let tmp = std::env::temp_dir().join(format!(
"rsurl_malformed_cookie_test_{}.txt",
std::process::id()
));
let path = tmp.to_str().unwrap();
std::fs::write(
path,
"# Netscape HTTP Cookie File\n\
garbage line without tabs\n\
example.com\tFALSE\t/\tFALSE\tnot-a-number\tx\t1\n\
example.com\tFALSE\t/\tFALSE\t0\tempty\n\
example.com\tFALSE\t/\tFALSE\t0\tgood\tyes\n",
)
.unwrap();
let j = CookieJar::load_netscape(path).unwrap();
let _ = std::fs::remove_file(path);
let names: Vec<&str> = j.cookies().iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["empty", "good"]);
assert_eq!(j.cookies()[0].value, "");
}
#[test]
fn explicit_cookie_replaced_by_server_is_persisted() {
let mut j = CookieJar::new();
j.add_explicit("sid", "cli", &url("https://example.com/"));
j.add_set_cookie(&url("https://example.com/"), "sid=srv; Path=/", 0);
let tmp = std::env::temp_dir().join(format!(
"rsurl_explicit_replaced_test_{}.txt",
std::process::id()
));
let path = tmp.to_str().unwrap();
j.save_netscape(path).unwrap();
let body = std::fs::read_to_string(path).unwrap();
let _ = std::fs::remove_file(path);
assert!(body.contains("\tsid\tsrv"), "got: {body}");
}
}