use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::errors::Error;
use crate::helpers::sync::Lock;
use crate::models::{Limits, URL};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
impl SameSite {
pub fn as_str(&self) -> &'static str {
match self {
Self::Strict => "Strict",
Self::Lax => "Lax",
Self::None => "None",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"strict" => Some(Self::Strict),
"lax" => Some(Self::Lax),
"none" => Some(Self::None),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Cookie {
pub pairs: Vec<(String, String)>,
}
impl Cookie {
pub fn is_separator(byte: u8) -> bool {
byte <= 0x20 || byte == 0x7f || b"()<>@,;:\\\"/[]?={}".contains(&byte)
}
pub fn new() -> Self {
Self { pairs: Vec::new() }
}
pub fn get(&self, name: &str) -> Option<&str> {
self.pairs.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str())
}
pub fn parse(value: &str) -> Self {
let mut cookie = Self::new();
for part in value.split(';') {
let Some((name, raw)) = part.split_once('=') else {
continue;
};
let name = name.trim();
if name.is_empty() || cookie.pairs.iter().any(|(key, _)| key == name) {
continue;
}
cookie.pairs.push((name.to_owned(), raw.trim().trim_matches('"').to_owned()));
}
cookie
}
pub fn build(&self) -> String {
self.pairs.iter().map(|(name, value)| format!("{name}={value}")).collect::<Vec<_>>().join("; ")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetCookie {
pub name: String,
pub value: String,
pub expires: Option<String>,
pub max_age: Option<i64>,
pub domain: Option<String>,
pub path: Option<String>,
pub secure: bool,
pub httponly: bool,
pub samesite: Option<SameSite>,
}
impl SetCookie {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
expires: None,
max_age: None,
domain: None,
path: None,
secure: false,
httponly: false,
samesite: None,
}
}
pub fn parse(value: &str) -> Result<Self, Error> {
let mut parts = value.split(';');
let pair = parts.next().unwrap_or_default();
let (name, value) = pair
.split_once('=')
.ok_or_else(|| Error::Protocol("Set-Cookie has no name=value pair".into()))?;
let mut cookie = Self::new(name.trim().to_owned(), value.trim().to_owned());
for attribute in parts {
let (key, raw) = match attribute.split_once('=') {
Some((key, raw)) => (key.trim().to_ascii_lowercase(), raw.trim()),
None => (attribute.trim().to_ascii_lowercase(), ""),
};
match key.as_str() {
"expires" => cookie.expires = Some(raw.to_owned()),
"max-age" => cookie.max_age = Self::age(raw),
"domain" => cookie.domain = Some(raw.to_owned()),
"path" => cookie.path = Some(raw.to_owned()),
"secure" => cookie.secure = true,
"httponly" => cookie.httponly = true,
"samesite" => cookie.samesite = SameSite::parse(raw),
_ => {}
}
}
Ok(cookie)
}
pub fn age(text: &str) -> Option<i64> {
let (sign, digits) = match text.strip_prefix('-') {
Some(rest) => (-1, rest),
None => (1, text),
};
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
Some(sign * digits.parse::<i64>().unwrap_or(i64::MAX))
}
pub fn build(&self) -> Result<String, Error> {
if self.name.is_empty() || self.name.bytes().any(Cookie::is_separator) {
return Err(Error::Protocol(format!("cookie name {:?} is not a token", self.name)));
}
if self.value.bytes().any(|byte| !(0x21..=0x7e).contains(&byte) || b" \",;\\".contains(&byte)) {
return Err(Error::Protocol(format!("cookie value of {:?} carries a forbidden octet", self.name)));
}
let mut out = format!("{}={}", self.name, self.value);
if let Some(expires) = &self.expires {
out.push_str(&format!("; Expires={expires}"));
}
if let Some(max_age) = self.max_age {
out.push_str(&format!("; Max-Age={max_age}"));
}
if let Some(domain) = &self.domain {
out.push_str(&format!("; Domain={domain}"));
}
if let Some(path) = &self.path {
out.push_str(&format!("; Path={path}"));
}
if self.secure {
out.push_str("; Secure");
}
if self.httponly {
out.push_str("; HttpOnly");
}
if let Some(samesite) = self.samesite {
out.push_str(&format!("; SameSite={}", samesite.as_str()));
}
Ok(out)
}
}
#[derive(Debug, Clone)]
pub struct StoredCookie {
pub name: String,
pub value: String,
pub domain: String,
pub host_only: bool,
pub path: String,
pub secure: bool,
pub expires: Option<Instant>,
}
impl StoredCookie {
pub fn path_matches(target: &str, cookie_path: &str) -> bool {
let request_path = target.split(['?', '#']).next().unwrap_or("/");
if request_path == cookie_path {
return true;
}
if let Some(rest) = request_path.strip_prefix(cookie_path) {
return cookie_path.ends_with('/') || rest.starts_with('/');
}
false
}
pub fn domain_matches(host: &str, domain: &str) -> bool {
if host == domain {
return true;
}
if host.parse::<std::net::IpAddr>().is_ok() {
return false;
}
!domain.is_empty() && host.len() > domain.len() && host.ends_with(domain) && host.as_bytes()[host.len() - domain.len() - 1] == b'.'
}
pub fn too_broad(domain: &str) -> bool {
!domain.contains('.')
}
pub fn scope(host: &str, attribute: Option<&str>) -> Option<(String, bool)> {
let domain = attribute.map(|domain| domain.trim().trim_start_matches('.').to_ascii_lowercase()).filter(|domain| !domain.is_empty());
let Some(domain) = domain else {
return Some((host.to_owned(), true));
};
if domain == host {
return Some((domain, false));
}
if Self::too_broad(&domain) || !Self::domain_matches(host, &domain) {
return None;
}
Some((domain, false))
}
pub fn default_path(target: &str) -> String {
let path = target.split(['?', '#']).next().unwrap_or("/");
match path.rfind('/') {
Some(0) | None => "/".to_owned(),
Some(index) => path[..index].to_owned(),
}
}
pub fn matches(&self, url: &URL, now: Instant) -> bool {
if self.expires.is_some_and(|expiry| expiry <= now) {
return false;
}
if self.secure && !url.secure() {
return false;
}
let host = url.host.to_ascii_lowercase();
let domain_ok = match self.host_only {
true => host == self.domain,
false => Self::domain_matches(&host, &self.domain),
};
domain_ok && Self::path_matches(&url.target, &self.path)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CookieLimits {
pub max_cookies: u32,
pub max_cookies_per_domain: u16,
}
impl Default for CookieLimits {
fn default() -> Self {
Self { max_cookies: 3000, max_cookies_per_domain: 50 }
}
}
impl From<Limits> for CookieLimits {
fn from(limits: Limits) -> Self {
Self { max_cookies: limits.max_cookies, max_cookies_per_domain: limits.max_cookies_per_domain }
}
}
#[derive(Default)]
pub struct CookieJar {
pub entries: Mutex<Vec<StoredCookie>>,
pub limits: CookieLimits,
}
impl CookieJar {
pub fn evict(entries: &mut Vec<StoredCookie>, domain: &str, limits: &CookieLimits) {
if entries.iter().filter(|stored| stored.domain == domain).count() >= limits.max_cookies_per_domain as usize
&& let Some(oldest) = entries.iter().position(|stored| stored.domain == domain)
{
entries.remove(oldest);
}
while !entries.is_empty() && entries.len() >= limits.max_cookies as usize {
entries.remove(0);
}
}
pub fn holds(limits: &CookieLimits) -> bool {
limits.max_cookies > 0
}
pub fn new() -> Self {
Self { entries: Mutex::new(Vec::new()), limits: CookieLimits::default() }
}
pub fn with_limits(mut self, limits: impl Into<CookieLimits>) -> Self {
self.limits = limits.into();
self
}
pub fn learn(&self, url: &URL, values: &[&str], now: Instant) {
let host = url.host.to_ascii_lowercase();
let mut entries = Lock::on(&self.entries);
for value in values {
let Ok(cookie) = SetCookie::parse(value) else {
continue;
};
let Some((domain, host_only)) = StoredCookie::scope(&host, cookie.domain.as_deref()) else {
continue;
};
let path = cookie.path.clone().unwrap_or_else(|| StoredCookie::default_path(&url.target));
let expires = cookie
.max_age
.and_then(|seconds| now.checked_add(Duration::from_secs(seconds.max(0) as u64)));
let expired = cookie.max_age.is_some_and(|seconds| seconds <= 0);
entries.retain(|stored| !(stored.name == cookie.name && stored.domain == domain && stored.path == path));
if expired {
continue;
}
if !Self::holds(&self.limits) {
continue;
}
entries.retain(|stored| !stored.expires.is_some_and(|expiry| expiry <= now));
Self::evict(&mut entries, &domain, &self.limits);
entries.push(StoredCookie {
name: cookie.name,
value: cookie.value,
domain,
host_only,
path,
secure: cookie.secure,
expires,
});
}
}
pub fn cookie(&self, url: &URL, now: Instant) -> Option<String> {
let entries = Lock::on(&self.entries);
let pairs: Vec<String> = entries
.iter()
.filter(|cookie| cookie.matches(url, now))
.map(|cookie| format!("{}={}", cookie.name, cookie.value))
.collect();
(!pairs.is_empty()).then(|| pairs.join("; "))
}
pub fn prune(&self, now: Instant) {
Lock::on(&self.entries).retain(|cookie| !cookie.expires.is_some_and(|expiry| expiry <= now));
}
}