use serde::{Deserialize, Serialize};
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cookie {
pub domain: String,
pub name: String,
pub value: String,
pub path: String,
pub expires: i64,
pub secure: bool,
pub http_only: bool,
}
impl Cookie {
pub fn is_session(&self) -> bool {
self.expires == 0
}
fn include_subdomains(&self) -> bool {
self.domain.starts_with('.')
}
fn matches(&self, host: &str) -> bool {
let host = host.as_bytes();
let domain = self.domain.trim_start_matches('.').as_bytes();
if host.eq_ignore_ascii_case(domain) {
return true;
}
self.include_subdomains()
&& host.len() > domain.len()
&& host[host.len() - domain.len()..].eq_ignore_ascii_case(domain)
&& host[host.len() - domain.len() - 1] == b'.'
}
}
#[derive(Debug, Default, Clone)]
pub struct CookieJar {
cookies: Vec<Cookie>,
}
impl CookieJar {
pub(crate) fn new(mut cookies: Vec<Cookie>) -> Self {
cookies.sort_by(|a, b| {
a.domain
.trim_start_matches('.')
.cmp(b.domain.trim_start_matches('.'))
.then_with(|| a.domain.cmp(&b.domain))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.path.cmp(&b.path))
});
Self { cookies }
}
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 domain(&self, host: &str) -> Self {
self.domains(&[host])
}
pub fn domains(&self, hosts: &[&str]) -> Self {
let hosts: Vec<_> = hosts.iter().map(|host| host.trim_start_matches('.')).collect();
Self {
cookies: self
.cookies
.iter()
.filter(|cookie| hosts.iter().any(|host| cookie.matches(host)))
.cloned()
.collect(),
}
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(&self.cookies).expect("serializing cookies cannot fail")
}
pub fn to_netscape(&self) -> String {
let mut out = String::from("# Netscape HTTP Cookie File\n");
out.push_str("# https://curl.se/docs/http-cookies.html\n");
out.push_str("# This file was generated by unjar. Edit at your own risk.\n\n");
for c in &self.cookies {
let sub = if c.include_subdomains() { "TRUE" } else { "FALSE" };
let secure = if c.secure { "TRUE" } else { "FALSE" };
let http_only = if c.http_only { "#HttpOnly_" } else { "" };
out.push_str(&format!(
"{}{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
http_only, c.domain, sub, c.path, secure, c.expires, c.name, c.value
));
}
out
}
pub fn to_header(&self) -> String {
self.cookies.iter().map(|c| format!("{}={}", c.name, c.value)).collect::<Vec<_>>().join("; ")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cookie(domain: &str, name: &str, value: &str) -> Cookie {
Cookie {
domain: domain.into(),
name: name.into(),
value: value.into(),
path: "/".into(),
expires: 0,
secure: true,
http_only: false,
}
}
fn sample() -> CookieJar {
CookieJar::new(vec![
cookie(".x.com", "auth_token", "aaa"), cookie("x.com", "g_state", "ggg"), cookie("api.x.com", "ct0", "bbb"), cookie("example.com", "sid", "ccc"),
])
}
#[test]
fn domain_cookie_matches_host_and_subdomains() {
let jar = sample().domain("api.x.com");
let names: Vec<_> = jar.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["ct0", "auth_token"]);
}
#[test]
fn domain_matching_is_case_insensitive() {
let jar = sample().domain("API.X.COM");
let names: Vec<_> = jar.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["ct0", "auth_token"]);
}
#[test]
fn host_only_cookie_does_not_leak_across_hosts() {
let jar = sample().domain("x.com");
let names: Vec<_> = jar.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["auth_token", "g_state"]);
}
#[test]
fn multiple_domains_are_combined_without_duplicates() {
let jar = sample().domains(&["x.com", "api.x.com"]);
let names: Vec<_> = jar.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["ct0", "auth_token", "g_state"]);
}
#[test]
fn header_joins_name_value_pairs() {
let jar = sample().domain("x.com");
assert_eq!(jar.to_header(), "auth_token=aaa; g_state=ggg");
}
#[test]
fn netscape_has_header_and_tab_rows() {
let out = sample().domain("example.com").to_netscape();
assert!(out.starts_with("# Netscape HTTP Cookie File"));
assert!(out.contains("example.com\tFALSE\t/\tTRUE\t0\tsid\tccc"));
}
#[test]
fn netscape_preserves_http_only() {
let mut cookie = cookie(".example.com", "sid", "ccc");
cookie.http_only = true;
let out = CookieJar::new(vec![cookie]).to_netscape();
assert!(out.contains("#HttpOnly_.example.com\tTRUE\t/\tTRUE\t0\tsid\tccc"));
}
#[test]
fn json_round_trips() {
let jar = sample();
let parsed: Vec<Cookie> = serde_json::from_str(&jar.to_json()).unwrap();
assert_eq!(parsed.len(), 4);
}
}