use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct CapturedCookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub expires: Option<i64>,
pub secure: bool,
pub http_only: bool,
pub same_site: Option<String>,
}
impl CapturedCookie {
pub fn is_expired_now(&self) -> bool {
let Some(exp) = self.expires else {
return false;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
exp <= now
}
pub fn keep_persistent_alive(input: &[CapturedCookie]) -> Vec<CapturedCookie> {
input
.iter()
.filter(|c| c.expires.is_some() && !c.is_expired_now())
.cloned()
.collect()
}
}
pub async fn capture_from_page(page: &chromiumoxide::Page) -> anyhow::Result<Vec<CapturedCookie>> {
use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams;
let resp = page.execute(GetCookiesParams::default()).await?;
let mut out = Vec::with_capacity(resp.cookies.len());
for c in &resp.cookies {
out.push(CapturedCookie {
name: c.name.clone(),
value: c.value.clone(),
domain: c.domain.clone(),
path: c.path.clone(),
expires: cdp_expiry_to_epoch(c.expires),
secure: c.secure,
http_only: c.http_only,
same_site: c.same_site.as_ref().map(|s| {
format!("{s:?}").to_lowercase()
}),
});
}
Ok(out)
}
pub async fn apply_to_page(
page: &chromiumoxide::Page,
cookies: &[CapturedCookie],
) -> anyhow::Result<usize> {
use chromiumoxide::cdp::browser_protocol::network::{
CookieParam, SetCookiesParams, TimeSinceEpoch,
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut to_install = Vec::with_capacity(cookies.len());
for c in cookies {
if let Some(exp) = c.expires {
if exp <= now {
continue; }
}
let mut builder = CookieParam::builder()
.name(c.name.clone())
.value(c.value.clone())
.domain(c.domain.clone())
.path(c.path.clone())
.secure(c.secure)
.http_only(c.http_only);
if let Some(exp) = c.expires {
builder = builder.expires(TimeSinceEpoch::new(exp as f64));
}
let cookie = builder
.build()
.map_err(|e| anyhow::anyhow!("cookie builder: {e}"))?;
to_install.push(cookie);
}
let count = to_install.len();
if !to_install.is_empty() {
page.execute(SetCookiesParams::new(to_install)).await?;
}
Ok(count)
}
pub fn vendor_cookies(input: &[CapturedCookie], vendor: &str) -> Vec<CapturedCookie> {
let names: &[&str] = match vendor.to_lowercase().as_str() {
"cloudflare" | "cf" => &["__cf_bm", "cf_chl_2", "cf_clearance"],
"akamai" => &["_abck", "bm_sz", "ak_bmsc"],
"datadome" => &["datadome", "_dd_s"],
"perimeterx" | "human" => &["_px2", "_pxhd", "_px3", "_pxvid"],
"incapsula" | "imperva" => &["visid_incap", "incap_ses"],
"kasada" => &["KP_UIDz", "x-kpsdk-cd", "x-kpsdk-ct"],
"fastly" => &["_fastly_ngwaf"],
"sucuri" => &["sucuri_cloudproxy_uuid"],
"anubis" => &["anubis-auth"],
_ => return Vec::new(),
};
input
.iter()
.filter(|c| {
names
.iter()
.any(|n| c.name.starts_with(*n) || c.name == *n)
})
.cloned()
.collect()
}
#[cfg(test)]
mod vendor_cookie_tests {
use super::*;
fn ck(name: &str) -> CapturedCookie {
CapturedCookie {
name: name.into(),
value: "test".into(),
domain: ".example.com".into(),
path: "/".into(),
expires: None,
secure: false,
http_only: false,
same_site: None,
}
}
#[test]
fn vendor_cookies_filters_cloudflare_set() {
let all = vec![
ck("__cf_bm"),
ck("cf_clearance"),
ck("session_id"),
ck("_ga"),
];
let filtered = vendor_cookies(&all, "cloudflare");
let names: Vec<&str> = filtered.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"__cf_bm"));
assert!(names.contains(&"cf_clearance"));
assert!(!names.contains(&"_ga"));
}
#[test]
fn vendor_cookies_matches_prefixed_cookie_names() {
let all = vec![ck("visid_incap_12345"), ck("incap_ses_99_99")];
let filtered = vendor_cookies(&all, "imperva");
assert_eq!(filtered.len(), 2);
}
#[test]
fn vendor_cookies_unknown_vendor_returns_empty() {
let all = vec![ck("__cf_bm")];
let filtered = vendor_cookies(&all, "totally-not-a-vendor");
assert!(filtered.is_empty());
}
#[test]
fn vendor_cookies_is_case_insensitive_on_vendor() {
let all = vec![ck("__cf_bm")];
assert_eq!(vendor_cookies(&all, "Cloudflare").len(), 1);
assert_eq!(vendor_cookies(&all, "CLOUDFLARE").len(), 1);
assert_eq!(vendor_cookies(&all, "cf").len(), 1);
}
}
fn cdp_expiry_to_epoch(raw: f64) -> Option<i64> {
if raw <= 0.0 {
None
} else {
Some(raw as i64)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cookie(name: &str, expires: Option<i64>) -> CapturedCookie {
CapturedCookie {
name: name.into(),
value: "v".into(),
domain: ".example.com".into(),
path: "/".into(),
expires,
secure: true,
http_only: false,
same_site: None,
}
}
#[test]
fn is_expired_now_true_for_past_expires() {
let c = cookie("c", Some(1)); assert!(c.is_expired_now());
}
#[test]
fn is_expired_now_false_for_far_future_expires() {
let c = cookie("c", Some(i64::MAX));
assert!(!c.is_expired_now());
}
#[test]
fn is_expired_now_false_for_session_cookie() {
let c = cookie("c", None);
assert!(!c.is_expired_now());
}
#[test]
fn keep_persistent_alive_drops_session_cookies() {
let cookies = vec![
cookie("session", None),
cookie("persistent", Some(i64::MAX)),
];
let kept = CapturedCookie::keep_persistent_alive(&cookies);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].name, "persistent");
}
#[test]
fn keep_persistent_alive_drops_expired_cookies() {
let cookies = vec![cookie("expired", Some(1)), cookie("alive", Some(i64::MAX))];
let kept = CapturedCookie::keep_persistent_alive(&cookies);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].name, "alive");
}
#[test]
fn cdp_expiry_negative_one_becomes_none() {
assert_eq!(cdp_expiry_to_epoch(-1.0), None);
}
#[test]
fn cdp_expiry_zero_becomes_none() {
assert_eq!(cdp_expiry_to_epoch(0.0), None);
}
#[test]
fn cdp_expiry_positive_becomes_some_truncated() {
assert_eq!(cdp_expiry_to_epoch(1234567890.7), Some(1234567890));
}
#[test]
fn captured_cookie_serde_roundtrip() {
let c = cookie("cf_clearance", Some(1234567890));
let json = serde_json::to_string(&c).unwrap();
let back: CapturedCookie = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
}