use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: Option<String>,
pub path: Option<String>,
pub expires: Option<DateTime<Utc>>,
pub max_age: Option<i64>,
pub secure: bool,
pub http_only: bool,
pub same_site: Option<SameSite>,
pub size: usize,
pub created_at: DateTime<Utc>,
pub extensions: HashMap<String, String>,
pub is_third_party: bool,
pub source_url: Option<String>,
pub category: Option<CookieCategory>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum CookieCategory {
StrictlyNecessary,
Functional,
Performance,
Targeting,
SocialMedia,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieAttribute {
pub name: String,
pub value: Option<String>,
}
impl Cookie {
#[must_use]
pub fn new(name: String, value: String) -> Self {
Self {
name,
value,
domain: None,
path: None,
expires: None,
max_age: None,
secure: false,
http_only: false,
same_site: None,
size: 0,
created_at: Utc::now(),
extensions: HashMap::new(),
is_third_party: false,
source_url: None,
category: None,
}
}
#[must_use]
pub fn is_expired(&self) -> bool {
if let Some(expires) = self.expires {
return Utc::now() > expires;
}
false
}
#[must_use]
pub fn is_session(&self) -> bool {
self.expires.is_none() && self.max_age.is_none()
}
#[must_use]
pub fn is_persistent(&self) -> bool {
!self.is_session()
}
#[must_use]
pub fn lifetime_seconds(&self) -> Option<i64> {
if let Some(max_age) = self.max_age {
return Some(max_age);
}
if let Some(expires) = self.expires {
let duration = expires.signed_duration_since(self.created_at);
return Some(duration.num_seconds());
}
None
}
#[must_use]
pub fn has_secure_flags(&self) -> bool {
self.secure && self.http_only
}
#[must_use]
pub fn is_likely_auth_cookie(&self) -> bool {
let name_lower = self.name.to_lowercase();
let auth_patterns = [
"session",
"sess",
"auth",
"token",
"jwt",
"login",
"user",
"id",
"credentials",
];
auth_patterns
.iter()
.any(|pattern| name_lower.contains(pattern))
}
#[must_use]
pub fn is_localhost(&self) -> bool {
if let Some(ref domain) = self.domain {
let domain_lower = domain.to_lowercase();
return domain_lower == "localhost"
|| domain_lower == "127.0.0.1"
|| domain_lower == "::1"
|| std::path::Path::new(&domain_lower)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("local"))
|| domain_lower.ends_with(".localhost");
}
if let Some(ref url) = self.source_url {
let url_lower = url.to_lowercase();
return url_lower.contains("localhost")
|| url_lower.contains("127.0.0.1")
|| url_lower.contains("::1");
}
false
}
#[must_use]
pub fn effective_domain(&self) -> Option<&str> {
self.domain.as_deref()
}
#[must_use]
pub fn effective_path(&self) -> &str {
self.path.as_deref().unwrap_or("/")
}
#[must_use]
pub fn has_secure_prefix(&self) -> bool {
self.name.starts_with("__Secure-")
}
#[must_use]
pub fn has_host_prefix(&self) -> bool {
self.name.starts_with("__Host-")
}
pub fn validate_prefix(&self) -> Result<(), String> {
if self.has_secure_prefix() && !self.secure {
return Err("__Secure- prefix requires Secure flag".to_string());
}
if self.has_host_prefix() {
if !self.secure {
return Err("__Host- prefix requires Secure flag".to_string());
}
if self.path != Some("/".to_string()) {
return Err("__Host- prefix requires Path=/".to_string());
}
if self.domain.is_some() {
return Err("__Host- prefix forbids Domain attribute".to_string());
}
}
Ok(())
}
}
impl std::fmt::Display for Cookie {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}={}", self.name, self.value)?;
if let Some(domain) = &self.domain {
write!(f, "; Domain={domain}")?;
}
if let Some(path) = &self.path {
write!(f, "; Path={path}")?;
}
if self.secure {
write!(f, "; Secure")?;
}
if self.http_only {
write!(f, "; HttpOnly")?;
}
if let Some(same_site) = self.same_site {
write!(f, "; SameSite={same_site:?}")?;
}
Ok(())
}
}
impl Default for Cookie {
fn default() -> Self {
Self::new(String::new(), String::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cookie_creation() {
let cookie = Cookie::new("test".to_string(), "value".to_string());
assert_eq!(cookie.name, "test");
assert_eq!(cookie.value, "value");
assert!(!cookie.secure);
assert!(!cookie.http_only);
}
#[test]
fn test_is_session_cookie() {
let cookie = Cookie::new("session".to_string(), "abc123".to_string());
assert!(cookie.is_session());
assert!(!cookie.is_persistent());
}
#[test]
fn test_is_likely_auth_cookie() {
let cookie = Cookie::new("session_id".to_string(), "abc123".to_string());
assert!(cookie.is_likely_auth_cookie());
let cookie2 = Cookie::new("preferences".to_string(), "dark_mode".to_string());
assert!(!cookie2.is_likely_auth_cookie());
}
#[test]
fn test_is_localhost() {
let mut cookie = Cookie::new("test".to_string(), "value".to_string());
cookie.domain = Some("localhost".to_string());
assert!(cookie.is_localhost());
cookie.domain = Some("127.0.0.1".to_string());
assert!(cookie.is_localhost());
cookie.domain = Some("myapp.local".to_string());
assert!(cookie.is_localhost());
cookie.domain = Some("example.com".to_string());
assert!(!cookie.is_localhost());
cookie.domain = None;
cookie.source_url = Some("http://localhost:8080".to_string());
assert!(cookie.is_localhost());
}
#[test]
fn test_secure_prefix_validation() {
let mut cookie = Cookie::new("__Secure-token".to_string(), "value".to_string());
assert!(cookie.validate_prefix().is_err());
cookie.secure = true;
assert!(cookie.validate_prefix().is_ok());
}
#[test]
fn test_host_prefix_validation() {
let mut cookie = Cookie::new("__Host-token".to_string(), "value".to_string());
cookie.secure = true;
cookie.path = Some("/".to_string());
assert!(cookie.validate_prefix().is_ok());
cookie.domain = Some("example.com".to_string());
assert!(cookie.validate_prefix().is_err());
}
}