use axum::http::{HeaderName, HeaderValue, Request, Response};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
const FOREVER_EXPIRE_SECONDS: i64 = 315360000;
const DELETE_EXPIRE_OFFSET_SECONDS: i64 = -3600;
#[derive(Debug, Clone)]
pub struct CookieOptions {
pub expire: i64,
pub path: String,
pub domain: String,
pub secure: bool,
pub httponly: bool,
pub samesite: String,
}
impl Default for CookieOptions {
fn default() -> Self {
Self {
expire: 0,
path: "/".to_string(),
domain: String::new(),
secure: false,
httponly: false,
samesite: String::new(),
}
}
}
impl CookieOptions {
pub fn with_expire(expire: i64) -> Self {
Self {
expire,
..Default::default()
}
}
}
#[derive(Debug, Clone)]
pub struct CookieEntry {
pub name: String,
pub value: String,
pub expire: i64,
pub options: CookieOptions,
}
impl CookieEntry {
pub fn to_header_string(&self) -> String {
let mut parts = vec![format!("{}={}", self.name, self.value)];
if self.expire > 0 {
let expire_dt = DateTime::<Utc>::from_timestamp(self.expire, 0)
.unwrap_or_else(|| Utc::now());
parts.push(format!(
"Expires={}",
expire_dt.format("%a, %d %b %Y %H:%M:%S GMT")
));
}
if !self.options.path.is_empty() {
parts.push(format!("Path={}", self.options.path));
}
if !self.options.domain.is_empty() {
parts.push(format!("Domain={}", self.options.domain));
}
if self.options.secure {
parts.push("Secure".to_string());
}
if self.options.httponly {
parts.push("HttpOnly".to_string());
}
if !self.options.samesite.is_empty() {
parts.push(format!("SameSite={}", self.options.samesite));
}
parts.join("; ")
}
}
#[derive(Debug, Clone)]
pub struct CookieJar {
request_cookies: HashMap<String, String>,
response_cookies: Vec<CookieEntry>,
config: CookieOptions,
}
impl Default for CookieJar {
fn default() -> Self {
Self {
request_cookies: HashMap::new(),
response_cookies: Vec::new(),
config: CookieOptions::default(),
}
}
}
impl CookieJar {
pub fn new() -> Self {
Self::default()
}
pub fn with_config(config: CookieOptions) -> Self {
Self {
request_cookies: HashMap::new(),
response_cookies: Vec::new(),
config,
}
}
pub fn from_request<B>(req: &Request<B>) -> Self {
let mut jar = Self::default();
if let Some(cookie_header) = req.headers().get(axum::http::header::COOKIE) {
if let Ok(header_str) = cookie_header.to_str() {
jar.request_cookies = parse_cookie_header(header_str);
}
}
jar
}
pub fn get(&self, name: &str) -> Option<String> {
self.request_cookies.get(name).cloned()
}
pub fn get_with_default(&self, name: &str, default: &str) -> String {
self.request_cookies
.get(name)
.cloned()
.unwrap_or_else(|| default.to_string())
}
pub fn has(&self, name: &str) -> bool {
self.request_cookies.contains_key(name)
}
pub fn set(mut self, name: &str, value: &str, options: CookieOptions) -> Self {
let expire = if options.expire > 0 {
Utc::now().timestamp() + options.expire
} else {
0
};
self.response_cookies.push(CookieEntry {
name: name.to_string(),
value: value.to_string(),
expire,
options,
});
self
}
pub fn forever(self, name: &str, value: &str, mut options: CookieOptions) -> Self {
options.expire = FOREVER_EXPIRE_SECONDS;
self.set(name, value, options)
}
pub fn delete(mut self, name: &str, options: CookieOptions) -> Self {
let expire = Utc::now().timestamp() + DELETE_EXPIRE_OFFSET_SECONDS;
self.response_cookies.push(CookieEntry {
name: name.to_string(),
value: String::new(),
expire,
options,
});
self
}
pub fn apply_to_response<B>(self, resp: &mut Response<B>) {
if self.response_cookies.is_empty() {
return;
}
let headers = resp.headers_mut();
for entry in &self.response_cookies {
if let Ok(value) = HeaderValue::from_str(&entry.to_header_string()) {
headers.append(HeaderName::from_static("set-cookie"), value);
}
}
}
pub fn get_response_cookies(&self) -> &[CookieEntry] {
&self.response_cookies
}
pub fn config(&self) -> &CookieOptions {
&self.config
}
}
fn parse_cookie_header(header: &str) -> HashMap<String, String> {
let mut cookies = HashMap::new();
for pair in header.split(';') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
if let Some(eq_pos) = pair.find('=') {
let name = pair[..eq_pos].trim().to_string();
let value = pair[eq_pos + 1..].trim().to_string();
if !name.is_empty() {
cookies.insert(name, value);
}
}
}
cookies
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, Response};
#[test]
fn test_cookie_options_default() {
let opts = CookieOptions::default();
assert_eq!(opts.expire, 0);
assert_eq!(opts.path, "/");
assert_eq!(opts.domain, "");
assert!(!opts.secure);
assert!(!opts.httponly);
assert_eq!(opts.samesite, "");
}
#[test]
fn test_cookie_options_with_expire() {
let opts = CookieOptions::with_expire(3600);
assert_eq!(opts.expire, 3600);
assert_eq!(opts.path, "/"); }
#[test]
fn test_cookie_entry_minimal_header() {
let entry = CookieEntry {
name: "token".to_string(),
value: "abc123".to_string(),
expire: 0,
options: CookieOptions {
path: String::new(), domain: String::new(),
samesite: String::new(),
..Default::default()
},
};
let header = entry.to_header_string();
assert_eq!(header, "token=abc123");
}
#[test]
fn test_cookie_entry_with_path() {
let entry = CookieEntry {
name: "token".to_string(),
value: "abc".to_string(),
expire: 0,
options: CookieOptions {
path: "/api".to_string(),
..Default::default()
},
};
let header = entry.to_header_string();
assert!(header.contains("token=abc"));
assert!(header.contains("Path=/api"));
}
#[test]
fn test_cookie_entry_with_all_attributes() {
let entry = CookieEntry {
name: "session".to_string(),
value: "xyz".to_string(),
expire: 1893456000, options: CookieOptions {
path: "/".to_string(),
domain: "example.com".to_string(),
secure: true,
httponly: true,
samesite: "Lax".to_string(),
..Default::default()
},
};
let header = entry.to_header_string();
assert!(header.contains("session=xyz"));
assert!(header.contains("Expires="));
assert!(header.contains("Path=/"));
assert!(header.contains("Domain=example.com"));
assert!(header.contains("Secure"));
assert!(header.contains("HttpOnly"));
assert!(header.contains("SameSite=Lax"));
}
#[test]
fn test_cookie_entry_expire_zero_no_expires_header() {
let entry = CookieEntry {
name: "session".to_string(),
value: "v".to_string(),
expire: 0,
options: CookieOptions::default(),
};
let header = entry.to_header_string();
assert!(!header.contains("Expires="));
}
#[test]
fn test_cookie_jar_default_empty() {
let jar = CookieJar::new();
assert!(jar.get("any").is_none());
assert!(!jar.has("any"));
assert!(jar.get_response_cookies().is_empty());
}
#[test]
fn test_cookie_jar_with_config() {
let config = CookieOptions {
path: "/app".to_string(),
..Default::default()
};
let jar = CookieJar::with_config(config);
assert_eq!(jar.config().path, "/app");
}
#[test]
fn test_cookie_jar_set_adds_to_response_cookies() {
let jar = CookieJar::new().set("token", "abc", CookieOptions::default());
assert_eq!(jar.get_response_cookies().len(), 1);
assert_eq!(jar.get_response_cookies()[0].name, "token");
assert_eq!(jar.get_response_cookies()[0].value, "abc");
}
#[test]
fn test_cookie_jar_set_chain() {
let jar = CookieJar::new()
.set("a", "1", CookieOptions::default())
.set("b", "2", CookieOptions::default())
.set("c", "3", CookieOptions::default());
assert_eq!(jar.get_response_cookies().len(), 3);
}
#[test]
fn test_cookie_jar_set_with_expire_calculates_timestamp() {
let before = Utc::now().timestamp();
let jar = CookieJar::new().set(
"token",
"abc",
CookieOptions::with_expire(3600),
);
let after = Utc::now().timestamp();
let entry = &jar.get_response_cookies()[0];
assert!(entry.expire >= before + 3600);
assert!(entry.expire <= after + 3600);
}
#[test]
fn test_cookie_jar_set_expire_zero_keeps_zero() {
let jar = CookieJar::new().set("token", "abc", CookieOptions::default());
let entry = &jar.get_response_cookies()[0];
assert_eq!(entry.expire, 0);
}
#[test]
fn test_cookie_jar_forever_sets_10_year_expire() {
let before = Utc::now().timestamp();
let jar = CookieJar::new().forever("token", "abc", CookieOptions::default());
let entry = &jar.get_response_cookies()[0];
let expected_min = before + FOREVER_EXPIRE_SECONDS;
assert!(entry.expire >= expected_min);
}
#[test]
fn test_cookie_jar_delete_sets_past_expire() {
let before = Utc::now().timestamp();
let jar = CookieJar::new().delete("token", CookieOptions::default());
let entry = &jar.get_response_cookies()[0];
assert_eq!(entry.value, ""); assert!(entry.expire < before);
}
#[test]
fn test_from_request_no_cookie_header() {
let req = Request::<Body>::default();
let jar = CookieJar::from_request(&req);
assert!(jar.get("any").is_none());
}
#[test]
fn test_from_request_single_cookie() {
let mut req = Request::<Body>::default();
req.headers_mut().insert(
axum::http::header::COOKIE,
HeaderValue::from_static("token=abc123"),
);
let jar = CookieJar::from_request(&req);
assert_eq!(jar.get("token"), Some("abc123".to_string()));
assert!(jar.has("token"));
}
#[test]
fn test_from_request_multiple_cookies() {
let mut req = Request::<Body>::default();
req.headers_mut().insert(
axum::http::header::COOKIE,
HeaderValue::from_static("token=abc; user=42; theme=dark"),
);
let jar = CookieJar::from_request(&req);
assert_eq!(jar.get("token"), Some("abc".to_string()));
assert_eq!(jar.get("user"), Some("42".to_string()));
assert_eq!(jar.get("theme"), Some("dark".to_string()));
}
#[test]
fn test_from_request_cookie_with_whitespace() {
let mut req = Request::<Body>::default();
req.headers_mut().insert(
axum::http::header::COOKIE,
HeaderValue::from_static(" token = abc ; user = 42 "),
);
let jar = CookieJar::from_request(&req);
assert_eq!(jar.get("token"), Some("abc".to_string()));
assert_eq!(jar.get("user"), Some("42".to_string()));
}
#[test]
fn test_from_request_cookie_value_with_equals() {
let mut req = Request::<Body>::default();
req.headers_mut().insert(
axum::http::header::COOKIE,
HeaderValue::from_static("data=a=b=c"),
);
let jar = CookieJar::from_request(&req);
assert_eq!(jar.get("data"), Some("a=b=c".to_string()));
}
#[test]
fn test_from_request_empty_cookie_header() {
let mut req = Request::<Body>::default();
req.headers_mut().insert(
axum::http::header::COOKIE,
HeaderValue::from_static(""),
);
let jar = CookieJar::from_request(&req);
assert!(jar.get("any").is_none());
}
#[test]
fn test_from_request_malformed_pairs_ignored() {
let mut req = Request::<Body>::default();
req.headers_mut().insert(
axum::http::header::COOKIE,
HeaderValue::from_static("token=abc; malformed; =empty_name; valid=ok"),
);
let jar = CookieJar::from_request(&req);
assert_eq!(jar.get("token"), Some("abc".to_string()));
assert!(jar.get("malformed").is_none()); assert!(jar.get("").is_none()); assert_eq!(jar.get("valid"), Some("ok".to_string()));
}
#[test]
fn test_get_with_default_returns_value_when_exists() {
let mut req = Request::<Body>::default();
req.headers_mut().insert(
axum::http::header::COOKIE,
HeaderValue::from_static("name=alice"),
);
let jar = CookieJar::from_request(&req);
assert_eq!(jar.get_with_default("name", "guest"), "alice");
}
#[test]
fn test_get_with_default_returns_default_when_missing() {
let req = Request::<Body>::default();
let jar = CookieJar::from_request(&req);
assert_eq!(jar.get_with_default("name", "guest"), "guest");
}
#[test]
fn test_apply_to_response_no_cookies() {
let jar = CookieJar::new();
let mut resp = Response::new(Body::empty());
jar.apply_to_response(&mut resp);
assert!(resp.headers().get("set-cookie").is_none());
}
#[test]
fn test_apply_to_response_single_cookie() {
let jar = CookieJar::new().set("token", "abc", CookieOptions::default());
let mut resp = Response::new(Body::empty());
jar.apply_to_response(&mut resp);
let set_cookies: Vec<_> = resp.headers().get_all("set-cookie").iter().collect();
assert_eq!(set_cookies.len(), 1);
assert_eq!(
set_cookies[0].to_str().unwrap(),
"token=abc; Path=/"
);
}
#[test]
fn test_apply_to_response_multiple_cookies() {
let jar = CookieJar::new()
.set("a", "1", CookieOptions::default())
.set("b", "2", CookieOptions::default())
.set("c", "3", CookieOptions::default());
let mut resp = Response::new(Body::empty());
jar.apply_to_response(&mut resp);
let set_cookies: Vec<_> = resp
.headers()
.get_all("set-cookie")
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect();
assert_eq!(set_cookies.len(), 3);
assert!(set_cookies.contains(&"a=1; Path=/".to_string()));
assert!(set_cookies.contains(&"b=2; Path=/".to_string()));
assert!(set_cookies.contains(&"c=3; Path=/".to_string()));
}
#[test]
fn test_apply_to_response_with_all_attributes() {
let jar = CookieJar::new().set(
"session",
"xyz",
CookieOptions {
expire: 1893456000, path: "/".to_string(),
domain: "example.com".to_string(),
secure: true,
httponly: true,
samesite: "Strict".to_string(),
},
);
let mut resp = Response::new(Body::empty());
jar.apply_to_response(&mut resp);
let header = resp
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(header.contains("session=xyz"));
assert!(header.contains("Expires="));
assert!(header.contains("Path=/"));
assert!(header.contains("Domain=example.com"));
assert!(header.contains("Secure"));
assert!(header.contains("HttpOnly"));
assert!(header.contains("SameSite=Strict"));
}
#[test]
fn test_parse_empty_header() {
let cookies = parse_cookie_header("");
assert!(cookies.is_empty());
}
#[test]
fn test_parse_single_pair() {
let cookies = parse_cookie_header("name=value");
assert_eq!(cookies.get("name"), Some(&"value".to_string()));
}
#[test]
fn test_parse_multiple_pairs() {
let cookies = parse_cookie_header("a=1; b=2; c=3");
assert_eq!(cookies.len(), 3);
assert_eq!(cookies.get("a"), Some(&"1".to_string()));
assert_eq!(cookies.get("b"), Some(&"2".to_string()));
assert_eq!(cookies.get("c"), Some(&"3".to_string()));
}
#[test]
fn test_parse_trims_whitespace() {
let cookies = parse_cookie_header(" a = 1 ; b = 2 ");
assert_eq!(cookies.get("a"), Some(&"1".to_string()));
assert_eq!(cookies.get("b"), Some(&"2".to_string()));
}
#[test]
fn test_parse_skips_empty_pairs() {
let cookies = parse_cookie_header("a=1;; ;b=2");
assert_eq!(cookies.len(), 2);
assert_eq!(cookies.get("a"), Some(&"1".to_string()));
assert_eq!(cookies.get("b"), Some(&"2".to_string()));
}
#[test]
fn test_parse_skips_no_equals() {
let cookies = parse_cookie_header("a=1; invalid; b=2");
assert_eq!(cookies.len(), 2);
assert!(cookies.get("invalid").is_none());
}
#[test]
fn test_parse_skips_empty_name() {
let cookies = parse_cookie_header("a=1; =empty; b=2");
assert_eq!(cookies.len(), 2);
assert!(cookies.get("").is_none());
}
#[test]
fn test_php_consistency_set_and_save_flow() {
let req = Request::<Body>::default();
let jar = CookieJar::from_request(&req)
.set("token", "abc123", CookieOptions::with_expire(3600));
let mut resp = Response::new(Body::empty());
jar.apply_to_response(&mut resp);
let header = resp
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap();
assert!(header.starts_with("token=abc123"));
assert!(header.contains("Expires="));
assert!(header.contains("Path=/"));
}
#[test]
fn test_php_consistency_delete_flow() {
let req = Request::<Body>::default();
let jar = CookieJar::from_request(&req).delete("token", CookieOptions::default());
let mut resp = Response::new(Body::empty());
jar.apply_to_response(&mut resp);
let header = resp
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap();
assert!(header.starts_with("token="));
assert!(header.contains("Expires="));
}
#[test]
fn test_php_consistency_forever_flow() {
let req = Request::<Body>::default();
let jar = CookieJar::from_request(&req).forever("pref", "dark", CookieOptions::default());
let mut resp = Response::new(Body::empty());
jar.apply_to_response(&mut resp);
let header = resp
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap();
assert!(header.contains("pref=dark"));
assert!(header.contains("Expires="));
}
#[test]
fn test_php_consistency_request_response_isolation() {
let mut req = Request::<Body>::default();
req.headers_mut().insert(
axum::http::header::COOKIE,
HeaderValue::from_static("old=value"),
);
let jar = CookieJar::from_request(&req)
.set("new", "value", CookieOptions::default());
assert_eq!(jar.get("old"), Some("value".to_string()));
assert!(jar.get("new").is_none());
assert_eq!(jar.get_response_cookies().len(), 1);
assert_eq!(jar.get_response_cookies()[0].name, "new");
}
}