use std::fs;
use std::path::Path;
use std::sync::RwLock;
use std::time::{Duration, SystemTime};
use crate::error::{Aria2Error, Result};
use crate::http::cookie::Cookie;
use serde::{Deserialize, Serialize};
pub struct CookieStorage {
cookies: RwLock<Vec<Cookie>>,
}
impl CookieStorage {
pub fn new() -> Self {
Self {
cookies: RwLock::new(Vec::new()),
}
}
pub fn add(&self, cookie: Cookie) {
let mut cookies = self.cookies.write().unwrap_or_else(|e| e.into_inner());
if let Some(pos) = cookies.iter().position(|c| c == &cookie) {
cookies[pos] = cookie;
} else {
cookies.push(cookie);
}
}
pub fn find_cookies(&self, host: &str, path: &str, secure: bool) -> Vec<Cookie> {
let cookies = self.cookies.read().unwrap_or_else(|e| e.into_inner());
let date = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
cookies
.iter()
.filter(|c| c.match_request(host, path, date, secure))
.cloned()
.collect()
}
pub fn find_cookies_for_url(&self, url: &reqwest::Url) -> Vec<Cookie> {
let host = url.host_str().unwrap_or("");
let path = url.path();
let scheme = url.scheme();
let secure = scheme == "https";
self.find_cookies(host, path, secure)
}
pub fn expire_cookies(&self, base_time: i64) -> usize {
let mut cookies = self.cookies.write().unwrap_or_else(|e| e.into_inner());
let before = cookies.len();
cookies.retain(|c| !c.is_expired(base_time));
before - cookies.len()
}
pub fn count(&self) -> usize {
self.cookies.read().unwrap_or_else(|e| e.into_inner()).len()
}
pub fn load_file(&self, path: &Path) -> Result<usize> {
let data = fs::read_to_string(path).map_err(|e| Aria2Error::Io(e.to_string()))?;
let parsed = crate::http::ns_cookie_parser::NsCookieParser::parse_str(&data)?;
let n = parsed.len();
let mut cookies = self.cookies.write().unwrap_or_else(|e| e.into_inner());
for cookie in parsed {
if let Some(pos) = cookies.iter().position(|c| *c == cookie) {
cookies[pos] = cookie;
} else {
cookies.push(cookie);
}
}
Ok(n)
}
pub fn save_file(&self, path: &Path) -> Result<()> {
let cookies = self.cookies.read().unwrap_or_else(|e| e.into_inner());
let mut lines = Vec::with_capacity(cookies.len() + 3);
lines.push("# Netscape HTTP Cookie File".to_string());
lines.push("# This file is generated by aria2-rust".to_string());
for c in cookies.iter() {
lines.push(c.to_netscape_line());
}
fs::write(path, lines.join("\n")).map_err(|e| Aria2Error::Io(e.to_string()))?;
Ok(())
}
pub fn clear(&self) {
self.cookies
.write()
.unwrap_or_else(|e| e.into_inner())
.clear();
}
pub fn to_header_string(&self, host: &str, path: &str, secure: bool) -> String {
let cookies = self.find_cookies(host, path, secure);
if cookies.is_empty() {
return String::new();
}
cookies
.iter()
.map(|c| format!("{}={}", c.name, c.value))
.collect::<Vec<_>>()
.join("; ")
}
pub fn is_empty(&self) -> bool {
self.cookies
.read()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
}
}
impl Default for CookieStorage {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JarCookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub expires: Option<SystemTime>,
pub secure: bool,
pub http_only: bool,
pub creation_time: SystemTime,
}
impl JarCookie {
pub fn new(name: &str, value: &str, domain: &str) -> Self {
Self {
name: name.to_string(),
value: value.to_string(),
domain: domain.to_string(),
path: "/".to_string(),
expires: None,
secure: false,
http_only: false,
creation_time: SystemTime::now(),
}
}
pub fn matches_url(&self, url: &str, is_secure: bool) -> bool {
if self.secure && !is_secure {
return false;
}
let url_lower = url.to_lowercase();
let domain_lower = self.domain.to_lowercase();
if !url_lower.contains(&domain_lower) && !domain_lower.is_empty() {
return false;
}
if self.path != "/" {
let path_clean = self.path.trim_start_matches('/');
if !url_lower.contains(&path_clean.to_lowercase()) {
if let Some(after_domain) = url_lower.find(&domain_lower) {
let rest = &url_lower[after_domain + domain_lower.len()..];
if !rest.starts_with(&format!("/{}", path_clean.to_lowercase()))
&& !rest.starts_with(&format!("/{}", self.path.to_lowercase()))
{
return false;
}
}
}
}
if let Some(expires) = self.expires
&& SystemTime::now() > expires
{
return false;
}
true
}
pub fn to_header_value(&self) -> String {
let mut s = format!("{}={}", self.name, self.value);
if !self.domain.is_empty() {
s.push_str(&format!("; Domain={}", self.domain));
}
s.push_str(&format!("; Path={}", self.path));
if let Some(_expires) = self.expires {
if let Ok(_dur) = _expires.duration_since(SystemTime::UNIX_EPOCH) {
s.push_str(&format!(
"; Expires={}",
format_systemtime_as_http_date(_expires)
));
}
}
if self.secure {
s.push_str("; Secure");
}
if self.http_only {
s.push_str("; HttpOnly");
}
s
}
pub fn parse_set_cookie(header_value: &str) -> Option<Self> {
let header = header_value.trim();
if header.is_empty() {
return None;
}
let parts: Vec<&str> = header.split(';').collect();
if parts.is_empty() {
return None;
}
let nv: Vec<&str> = parts[0].trim().splitn(2, '=').collect();
if nv.len() != 2 {
return None;
}
let name = nv[0].trim();
let value = nv[1].trim();
if name.is_empty() {
return None;
}
let mut cookie = Self::new(name, value, "");
for attr in &parts[1..] {
let attr = attr.trim();
if attr.is_empty() {
continue;
}
let kv: Vec<&str> = attr.splitn(2, '=').collect();
match kv[0].trim().to_lowercase().as_str() {
"domain" if kv.len() > 1 => {
cookie.domain = kv[1].trim().to_string();
}
"path" if kv.len() > 1 => {
cookie.path = kv[1].trim().to_string();
}
"max-age" if kv.len() > 1 => {
if let Ok(secs) = kv[1].trim().parse::<u64>() {
cookie.expires = Some(SystemTime::now() + Duration::from_secs(secs));
}
}
"expires" if kv.len() > 1 => {
if cookie.expires.is_none()
&& let Ok(dt) = parse_http_date(kv[1].trim())
{
cookie.expires = Some(dt);
}
}
"secure" => {
cookie.secure = true;
}
"httponly" => {
cookie.http_only = true;
}
_ => {} }
}
Some(cookie)
}
}
impl PartialEq for JarCookie {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.domain == other.domain && self.path == other.path
}
}
pub struct CookieJar {
pub cookies: Vec<JarCookie>,
}
impl CookieJar {
pub fn new() -> Self {
Self {
cookies: Vec::new(),
}
}
pub fn store(&mut self, cookie: JarCookie) {
self.cookies
.retain(|c| !(c.name == cookie.name && c.domain == cookie.domain));
self.cookies.push(cookie);
}
pub fn get_cookies_for_url(&self, url: &str, is_secure: bool) -> Vec<JarCookie> {
self.cookies
.iter()
.filter(|c| c.matches_url(url, is_secure))
.cloned()
.collect()
}
pub fn cookie_header_for_url(&self, url: &str, is_secure: bool) -> Option<String> {
let matching = self.get_cookies_for_url(url, is_secure);
if matching.is_empty() {
return None;
}
let header: String = matching
.iter()
.map(|c| format!("{}={}", c.name, c.value))
.collect::<Vec<_>>()
.join("; ");
Some(header)
}
pub fn cleanup_expired(&mut self) -> usize {
let before = self.cookies.len();
self.cookies.retain(|c| {
if let Some(exp) = c.expires {
SystemTime::now() <= exp
} else {
true }
});
before - self.cookies.len()
}
pub fn load_netscape_file(&mut self, path: &Path) -> Result<usize> {
let content = fs::read_to_string(path).map_err(|e| Aria2Error::Io(e.to_string()))?;
let mut loaded = 0;
for line in content.lines() {
if line.starts_with('#') || line.starts_with('\n') || line.is_empty() {
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() >= 7 {
let domain = fields[0].trim();
let path = fields[2].trim();
let secure = fields[3] == "TRUE";
let expires = fields[4].trim().parse::<i64>().ok().and_then(|ts| {
if ts > 0 {
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(ts as u64))
} else {
None }
});
let name = fields[5].trim().to_string();
let value = if fields.len() > 7 {
fields[6..].join("\t")
} else {
fields[6].trim().to_string()
};
let cookie = JarCookie {
name,
value,
domain: domain.to_string(),
path: path.to_string(),
expires,
secure,
http_only: false, creation_time: SystemTime::now(),
};
self.cookies.push(cookie);
loaded += 1;
}
}
Ok(loaded)
}
pub fn len(&self) -> usize {
self.cookies.len()
}
pub fn is_empty(&self) -> bool {
self.cookies.is_empty()
}
pub fn clear(&mut self) {
self.cookies.clear();
}
}
impl Default for CookieJar {
fn default() -> Self {
Self::new()
}
}
fn format_systemtime_as_http_date(time: SystemTime) -> String {
const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let dur = time
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let epoch = dur.as_secs();
let days_since_epoch = (epoch / 86400) as u32;
let mut year = 1970u32;
let mut remaining = days_since_epoch;
loop {
let leap =
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
let days_in_year = if leap { 366 } else { 365 };
if remaining < days_in_year {
break;
}
remaining -= days_in_year;
year += 1;
}
let mdays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut month = 0u32;
while month < 12 {
let dim = if month == 1
&& ((year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400))
{
29
} else {
mdays[month as usize]
};
if remaining < dim {
break;
}
remaining -= dim;
month += 1;
}
let day = remaining + 1;
let secs = epoch % 86400;
let hour = (secs / 3600) as u32;
let min = ((secs % 3600) / 60) as u32;
let sec = (secs % 60) as u32;
let dow: usize =
((year + (year / 4) - (year / 100) + (year / 400) + (13 * month + 1) / 5 + day + 308) % 7)
as usize;
format!(
"{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
DAYS[dow], day, MONTHS[month as usize], year, hour, min, sec
)
}
fn parse_http_date(s: &str) -> Result<SystemTime> {
let s = s.trim();
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() >= 5 {
let (day_str, mon_str, year_str, time_str) = if parts[0].ends_with(',') {
if parts.len() >= 6 {
(
parts[1].trim(),
parts[2].trim(),
parts[3].trim(),
parts[4].trim(),
)
} else if parts.len() >= 5 {
(
parts[1].split('-').next().unwrap_or("1"),
parts[1].split('-').nth(1).unwrap_or("Jan"),
parts[2].trim(),
parts[3].trim(),
)
} else {
return Err(Aria2Error::Parse("Invalid date format".to_string()));
}
} else {
(
parts[2].trim(),
parts[1].trim(),
parts[4].trim(),
parts[3].trim(),
)
};
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let day: u32 = day_str.parse().unwrap_or(1);
let month_idx = MONTHS
.iter()
.position(|&m| m.eq_ignore_ascii_case(mon_str))
.unwrap_or(0);
let year: i32 = year_str.parse().unwrap_or({
let y: u32 = year_str.parse().unwrap_or(70);
if y < 100 { (1900 + y) as i32 } else { y as i32 }
});
let time_parts: Vec<u32> = time_str.split(':').filter_map(|x| x.parse().ok()).collect();
let hour = time_parts.first().copied().unwrap_or(0);
let min = time_parts.get(1).copied().unwrap_or(0);
let sec = time_parts.get(2).copied().unwrap_or(0);
let total_days = calculate_days_since_epoch(year, month_idx as u32, day);
let timestamp =
total_days as u64 * 86400 + hour as u64 * 3600 + min as u64 * 60 + sec as u64;
return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp));
}
Ok(SystemTime::now() + Duration::from_secs(86400 * 365))
}
fn calculate_days_since_epoch(year: i32, month: u32, day: u32) -> i64 {
let mut days: i64 = 0;
for y in 1970..year {
days += if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
366
} else {
365
};
}
let mdays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let is_leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
for m in 0..month {
let d = if m == 1 && is_leap {
29
} else {
mdays[m as usize]
};
days += d as i64;
}
days += day as i64 - 1;
days
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_creation_and_count() {
let store = CookieStorage::new();
assert_eq!(store.count(), 0);
assert!(store.is_empty());
}
#[test]
fn test_add_cookie() {
let store = CookieStorage::new();
store.add(Cookie::new("sid", "v1", "example.com"));
assert_eq!(store.count(), 1);
assert!(!store.is_empty());
}
#[test]
fn test_add_updates_existing() {
let store = CookieStorage::new();
store.add(Cookie::new("sid", "old", "example.com"));
store.add(Cookie::new("sid", "new", "example.com"));
assert_eq!(store.count(), 1);
let found = store.find_cookies("example.com", "/", false);
assert_eq!(found.len(), 1);
assert_eq!(found[0].value, "new");
}
#[test]
fn test_find_cookies_filters() {
let store = CookieStorage::new();
store.add(Cookie::new("a", "1", "example.com"));
store.add(Cookie::new("b", "2", "other.com"));
let found = store.find_cookies("example.com", "/", false);
assert_eq!(found.len(), 1);
assert_eq!(found[0].name, "a");
}
#[test]
fn test_find_cookies_for_url() {
let store = CookieStorage::new();
let mut c = Cookie::new("lang", "en", "example.com");
c.path = "/api".to_string();
store.add(c);
let url = reqwest::Url::parse("http://example.com/api/data").unwrap();
let found = store.find_cookies_for_url(&url);
assert_eq!(found.len(), 1);
let url2 = reqwest::Url::parse("http://example.com/home").unwrap();
let found2 = store.find_cookies_for_url(&url2);
assert!(found2.is_empty());
}
#[test]
fn test_expire_cookies() {
let store = CookieStorage::new();
let mut expired = Cookie::new("old", "v", "x.com");
expired.persistent = true;
expired.expiry_time = 1;
store.add(expired);
let mut fresh = Cookie::new("fresh", "v", "x.com");
fresh.persistent = true;
fresh.expiry_time = i64::MAX;
store.add(fresh);
assert_eq!(store.count(), 2);
let removed = store.expire_cookies(2);
assert_eq!(removed, 1);
assert_eq!(store.count(), 1);
}
#[test]
fn test_clear() {
let store = CookieStorage::new();
store.add(Cookie::new("a", "1", "b.com"));
store.add(Cookie::new("c", "2", "d.com"));
store.clear();
assert_eq!(store.count(), 0);
}
#[test]
fn test_to_header_string() {
let store = CookieStorage::new();
store.add(Cookie::new("a", "1", "example.com"));
store.add(Cookie::new("b", "2", "example.com"));
let hdr = store.to_header_string("example.com", "/", false);
assert!(hdr.contains("a=1"));
assert!(hdr.contains("b=2"));
}
#[test]
fn test_to_header_string_empty_for_no_match() {
let store = CookieStorage::new();
store.add(Cookie::new("a", "1", "example.com"));
let hdr = store.to_header_string("other.com", "/", false);
assert!(hdr.is_empty());
}
#[test]
#[ignore]
fn test_load_save_roundtrip() {
let dir = std::env::temp_dir().join("aria2_test_cookie");
fs::create_dir_all(&dir).ok();
let path = dir.join("cookies.txt");
let store = CookieStorage::new();
store.add(Cookie::new("sid", "abc", "example.com"));
store.save_file(&path).expect("save should succeed");
let store2 = CookieStorage::new();
let n = store2.load_file(&path).expect("load should succeed");
assert_eq!(n, 1);
let found = store2.find_cookies("example.com", "/", false);
assert_eq!(found.len(), 1);
assert_eq!(found[0].value, "abc");
fs::remove_dir_all(dir).ok();
}
#[test]
fn test_multiple_domains_independent() {
let store = CookieStorage::new();
store.add(Cookie::new("a", "1", "alpha.com"));
store.add(Cookie::new("b", "2", "beta.com"));
store.add(Cookie::new("c", "3", "alpha.com"));
assert_eq!(store.count(), 3);
assert_eq!(store.find_cookies("alpha.com", "/", false).len(), 2);
assert_eq!(store.find_cookies("beta.com", "/", false).len(), 1);
}
#[test]
fn test_cookie_parse_set_cookie() {
let cookie = JarCookie::parse_set_cookie(
"session_id=abc123; Domain=example.com; Path=/login; Secure; HttpOnly",
)
.expect("Should parse valid Set-Cookie header");
assert_eq!(cookie.name, "session_id");
assert_eq!(cookie.value, "abc123");
assert_eq!(cookie.domain, "example.com");
assert_eq!(cookie.path, "/login");
assert!(cookie.secure, "Secure flag should be set");
assert!(cookie.http_only, "HttpOnly flag should be set");
}
#[test]
fn test_cookie_parse_minimal() {
let cookie = JarCookie::parse_set_cookie("SID=31d4d96e407aad42")
.expect("Should parse minimal cookie");
assert_eq!(cookie.name, "SID");
assert_eq!(cookie.value, "31d4d96e407aad42");
assert_eq!(cookie.domain, ""); assert_eq!(cookie.path, "/");
assert!(!cookie.secure);
assert!(!cookie.http_only);
}
#[test]
fn test_cookie_parse_max_age() {
let cookie =
JarCookie::parse_set_cookie("token=xyz; Max-Age=3600").expect("Should parse Max-Age");
assert_eq!(cookie.name, "token");
assert!(cookie.expires.is_some(), "Max-Age should set expiration");
}
#[test]
fn test_cookie_parse_invalid_returns_none() {
assert!(JarCookie::parse_set_cookie("").is_none());
assert!(JarCookie::parse_set_cookie("noequal").is_none());
assert!(JarCookie::parse_set_cookie("=").is_none());
assert!(JarCookie::parse_set_cookie(";").is_none());
}
#[test]
fn test_cookie_matches_url_secure_flag() {
let mut cookie = JarCookie::new("auth_token", "secret123", "secure.example.com");
cookie.secure = true;
assert!(
cookie.matches_url("https://secure.example.com/api", true),
"Secure cookie should match HTTPS URL"
);
assert!(
!cookie.matches_url("http://secure.example.com/api", false),
"Secure cookie must NOT match HTTP URL"
);
}
#[test]
fn test_cookie_matches_url_non_secure_both_schemes() {
let cookie = JarCookie::new("lang", "en", "example.com");
assert!(
cookie.matches_url("http://example.com/", false),
"Non-secure cookie should match HTTP"
);
assert!(
cookie.matches_url("https://example.com/", true),
"Non-secure cookie should also match HTTPS"
);
}
#[test]
fn test_cookie_matches_url_expired() {
let mut cookie = JarCookie::new("old_session", "val", "example.com");
cookie.expires = Some(SystemTime::now() - Duration::from_secs(1));
assert!(
!cookie.matches_url("https://example.com/", true),
"Expired cookie should not match any URL"
);
}
#[test]
fn test_cookie_jar_get_for_url() {
let mut jar = CookieJar::new();
jar.store(JarCookie::new("session", "abc123", "example.com"));
jar.store(JarCookie::new("theme", "dark", "example.com"));
jar.store(JarCookie::new("tracker", "xyz999", "other.com"));
let example_cookies = jar.get_cookies_for_url("http://example.com/page", false);
assert_eq!(
example_cookies.len(),
2,
"Should get exactly 2 cookies for example.com"
);
let names: Vec<&str> = example_cookies.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"session"));
assert!(names.contains(&"theme"));
let other_cookies = jar.get_cookies_for_url("http://other.com/", false);
assert_eq!(other_cookies.len(), 1);
assert_eq!(other_cookies[0].name, "tracker");
}
#[test]
fn test_cookie_jar_header_format() {
let mut jar = CookieJar::new();
jar.store(JarCookie::new("a", "1", "example.com"));
jar.store(JarCookie::new("b", "2", "example.com"));
let header = jar.cookie_header_for_url("http://example.com/", false);
assert!(header.is_some());
let hdr = header.unwrap();
assert!(hdr.contains("a=1"), "Header should contain a=1");
assert!(hdr.contains("b=2"), "Header should contain b=2");
assert!(
hdr == "a=1; b=2" || hdr == "b=2; a=1",
"Unexpected header format: {}",
hdr
);
}
#[test]
fn test_cookie_jar_no_match_returns_none() {
let mut jar = CookieJar::new();
jar.store(JarCookie::new("x", "y", "example.com"));
let header = jar.cookie_header_for_url("http://other.com/", false);
assert!(header.is_none(), "No match should return None");
}
#[test]
fn test_netscape_cookie_file_load() {
let dir = std::env::temp_dir().join("aria2_netscape_test");
fs::create_dir_all(&dir).ok();
let path = dir.join("cookies.txt");
let content = "# Netscape HTTP Cookie File\n\
\n\
.example.com\tTRUE\t/\tFALSE\t0\tsession_id\tabc123\n\
.api.example.com\tTRUE\t/api\tTRUE\t1700000000\ttoken\tsecret\n\
localhost\tFALSE\t/\tFALSE\t0\tlocal_key\tlocal_val\n";
fs::write(&path, content).expect("Failed to write test cookie file");
let mut jar = CookieJar::new();
let result = jar.load_netscape_file(&path);
assert!(
result.is_ok(),
"Loading netscape file should succeed: {:?}",
result.err()
);
let count = result.unwrap();
assert_eq!(count, 3, "Should have loaded 3 cookies");
assert_eq!(jar.len(), 3);
let session_cookie = jar
.cookies
.iter()
.find(|c| c.name == "session_id")
.expect("Should find session_id cookie");
assert_eq!(session_cookie.domain, ".example.com");
assert_eq!(session_cookie.value, "abc123");
assert!(!session_cookie.secure);
assert!(
session_cookie.expires.is_none(),
"Timestamp 0 means session cookie (no expiry)"
);
let token_cookie = jar
.cookies
.iter()
.find(|c| c.name == "token")
.expect("Should find token cookie");
assert_eq!(token_cookie.domain, ".api.example.com");
assert_eq!(token_cookie.path, "/api");
assert!(token_cookie.secure, "Token cookie should be secure");
assert!(
token_cookie.expires.is_some(),
"Token cookie should have expiry time"
);
fs::remove_dir_all(dir).ok();
}
#[test]
fn test_netscape_load_nonexistent_file() {
let mut jar = CookieJar::new();
let result = jar.load_netscape_file(Path::new("/nonexistent/path/cookies.txt"));
assert!(result.is_err(), "Loading nonexistent file should fail");
}
#[test]
fn test_netscape_load_empty_file() {
let dir = std::env::temp_dir().join("aria2_netscape_empty");
fs::create_dir_all(&dir).ok();
let path = dir.join("empty.txt");
fs::write(&path, "").expect("Failed to write empty file");
let mut jar = CookieJar::new();
let result = jar.load_netscape_file(&path).expect("Load should succeed");
assert_eq!(result, 0, "Empty file should yield 0 cookies");
assert!(jar.is_empty());
fs::remove_dir_all(dir).ok();
}
#[test]
fn test_jar_cookie_creation() {
let c = JarCookie::new("test", "value", "example.com");
assert_eq!(c.name, "test");
assert_eq!(c.value, "value");
assert_eq!(c.domain, "example.com");
assert_eq!(c.path, "/");
assert!(!c.secure);
assert!(!c.http_only);
assert!(c.expires.is_none()); }
#[test]
fn test_jar_cookie_to_header_value() {
let mut c = JarCookie::new("sid", "abc", "example.com");
c.secure = true;
c.http_only = true;
let hdr = c.to_header_value();
assert!(hdr.starts_with("sid=abc"));
assert!(hdr.contains("Domain=example.com"));
assert!(hdr.contains("Secure"));
assert!(hdr.contains("HttpOnly"));
}
#[test]
fn test_jar_cookie_equality() {
let a = JarCookie::new("x", "1", "a.com");
let b = JarCookie::new("x", "2", "a.com"); assert_eq!(a, b, "Cookies with same name/domain/path should be equal");
let c = JarCookie::new("y", "1", "a.com");
assert_ne!(a, c, "Different names should not be equal");
}
#[test]
fn test_cookie_jar_store_updates_existing() {
let mut jar = CookieJar::new();
jar.store(JarCookie::new("sid", "old", "example.com"));
jar.store(JarCookie::new("sid", "new", "example.com"));
assert_eq!(jar.len(), 1, "Store should update, not duplicate");
let cookies = jar.get_cookies_for_url("http://example.com/", false);
assert_eq!(cookies[0].value, "new");
}
#[test]
fn test_cookie_jar_cleanup_expired() {
let mut jar = CookieJar::new();
let mut expired = JarCookie::new("old", "val", "x.com");
expired.expires = Some(SystemTime::now() - Duration::from_secs(60));
jar.store(expired);
let mut fresh = JarCookie::new("fresh", "val", "x.com");
fresh.expires = Some(SystemTime::now() + Duration::from_secs(86400 * 365));
jar.store(fresh);
jar.store(JarCookie::new("session", "val", "x.com"));
assert_eq!(jar.len(), 3);
let removed = jar.cleanup_expired();
assert_eq!(removed, 1, "Should remove exactly 1 expired cookie");
assert_eq!(jar.len(), 2, "Fresh + session cookies remain");
}
#[test]
fn test_cookie_jar_clear() {
let mut jar = CookieJar::new();
jar.store(JarCookie::new("a", "1", "x.com"));
jar.store(JarCookie::new("b", "2", "y.com"));
jar.clear();
assert!(jar.is_empty());
}
#[test]
fn test_format_systemtime_as_http_date() {
let time = SystemTime::UNIX_EPOCH + Duration::from_secs(784111777); let formatted = format_systemtime_as_http_date(time);
assert!(formatted.contains("GMT"), "HTTP date should end with GMT");
assert!(
formatted.contains(','),
"IMF-fixdate should have comma after weekday"
);
}
#[test]
fn test_parse_http_date_imf_fixdate() {
let result = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT");
assert!(result.is_ok(), "Should parse IMF-fixdate format");
let time = result.unwrap();
let dur = time
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
assert!(
dur.as_secs() > 784000000,
"Timestamp should be roughly correct, got {}",
dur.as_secs()
);
}
#[test]
fn test_parse_http_date_fallback() {
let result = parse_http_date("totally-invalid-date-string");
assert!(result.is_ok(), "Should return fallback, not error");
let time = result.unwrap();
assert!(time > SystemTime::now(), "Fallback should be in the future");
}
}