pub mod persistence;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use zendriver_transport::Connection;
use crate::error::{Result, ZendriverError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SameSite {
Strict,
Lax,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CookiePriority {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CookieSourceScheme {
Unset,
NonSecure,
Secure,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires: Option<f64>,
#[serde(default)]
pub http_only: bool,
#[serde(default)]
pub secure: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub same_site: Option<SameSite>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<CookiePriority>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub same_party: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_scheme: Option<CookieSourceScheme>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_port: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partition_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CdpCookie {
name: String,
value: String,
domain: String,
path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expires: Option<f64>,
#[serde(default)]
http_only: bool,
#[serde(default)]
secure: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
same_site: Option<SameSite>,
#[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
priority: Option<CookiePriority>,
#[serde(default, skip_serializing_if = "Option::is_none")]
same_party: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source_scheme: Option<CookieSourceScheme>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source_port: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
partition_key: Option<String>,
}
impl From<Cookie> for CdpCookie {
fn from(c: Cookie) -> Self {
Self {
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
expires: c.expires,
http_only: c.http_only,
secure: c.secure,
same_site: c.same_site,
url: c.url,
priority: c.priority,
same_party: c.same_party,
source_scheme: c.source_scheme,
source_port: c.source_port,
partition_key: c.partition_key,
}
}
}
impl From<CdpCookie> for Cookie {
fn from(c: CdpCookie) -> Self {
Self {
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
expires: c.expires,
http_only: c.http_only,
secure: c.secure,
same_site: c.same_site,
url: c.url,
priority: c.priority,
same_party: c.same_party,
source_scheme: c.source_scheme,
source_port: c.source_port,
partition_key: c.partition_key,
}
}
}
#[derive(Clone, Debug)]
pub struct CookieJar {
inner: Arc<CookieJarInner>,
}
#[derive(Debug)]
struct CookieJarInner {
conn: Connection,
}
impl CookieJar {
#[must_use]
pub fn new(conn: Connection) -> Self {
Self {
inner: Arc::new(CookieJarInner { conn }),
}
}
pub async fn all(&self) -> Result<Vec<Cookie>> {
let resp = self
.inner
.conn
.call_raw("Storage.getCookies", json!({}), None)
.await?;
parse_cookies(&resp)
}
pub async fn for_url(&self, url: &url::Url) -> Result<Vec<Cookie>> {
let resp = self
.inner
.conn
.call_raw(
"Network.getCookies",
json!({ "urls": [url.as_str()] }),
None,
)
.await?;
parse_cookies(&resp)
}
pub async fn set(&self, cookie: Cookie) -> Result<()> {
let cdp: CdpCookie = cookie.into();
let cdp_json = serde_json::to_value(&cdp).map_err(ZendriverError::Serde)?;
self.inner
.conn
.call_raw("Storage.setCookies", json!({ "cookies": [cdp_json] }), None)
.await?;
Ok(())
}
pub async fn set_many(&self, cookies: Vec<Cookie>) -> Result<()> {
let cdp: Vec<CdpCookie> = cookies.into_iter().map(CdpCookie::from).collect();
self.inner
.conn
.call_raw("Storage.setCookies", json!({ "cookies": cdp }), None)
.await?;
Ok(())
}
pub async fn delete(&self, name: &str, domain: Option<&str>, path: Option<&str>) -> Result<()> {
let mut params = json!({ "name": name });
if let Some(d) = domain {
params["domain"] = json!(d);
}
if let Some(p) = path {
params["path"] = json!(p);
}
self.inner
.conn
.call_raw("Network.deleteCookies", params, None)
.await?;
Ok(())
}
pub async fn clear(&self) -> Result<()> {
self.inner
.conn
.call_raw("Storage.clearCookies", json!({}), None)
.await?;
Ok(())
}
}
#[allow(clippy::result_large_err)] fn parse_cookies(resp: &serde_json::Value) -> Result<Vec<Cookie>> {
let arr = resp
.get("cookies")
.and_then(|v| v.as_array())
.ok_or_else(|| ZendriverError::Cookie("response missing `cookies` array".into()))?;
let mut out = Vec::with_capacity(arr.len());
for v in arr {
let cdp: CdpCookie = serde_json::from_value(v.clone()).map_err(ZendriverError::Serde)?;
out.push(cdp.into());
}
Ok(out)
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn all_parses_get_all_cookies_response() {
let (mut mock, conn) = MockConnection::pair();
let jar = CookieJar::new(conn.clone());
let call = tokio::spawn({
let j = jar.clone();
async move { j.all().await }
});
let id = mock.expect_cmd("Storage.getCookies").await;
mock.reply(
id,
json!({
"cookies": [
{
"name": "sid",
"value": "abc",
"domain": ".example.com",
"path": "/",
"expires": 1_700_000_000.5,
"httpOnly": true,
"secure": true,
"sameSite": "Lax",
},
{
"name": "theme",
"value": "dark",
"domain": "example.com",
"path": "/",
"httpOnly": false,
"secure": false,
},
]
}),
)
.await;
let cookies = call.await.unwrap().unwrap();
assert_eq!(cookies.len(), 2);
assert_eq!(cookies[0].name, "sid");
assert_eq!(cookies[0].value, "abc");
assert_eq!(cookies[0].domain, ".example.com");
assert_eq!(cookies[0].path, "/");
assert!((cookies[0].expires.unwrap() - 1_700_000_000.5).abs() < 1e-6);
assert!(cookies[0].http_only);
assert!(cookies[0].secure);
assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
assert_eq!(cookies[1].name, "theme");
assert!(!cookies[1].http_only);
assert_eq!(cookies[1].expires, None);
assert_eq!(cookies[1].same_site, None);
conn.shutdown();
}
#[tokio::test]
async fn set_dispatches_network_set_cookies_with_camel_case_payload() {
let (mut mock, conn) = MockConnection::pair();
let jar = CookieJar::new(conn.clone());
let call = tokio::spawn({
let j = jar.clone();
async move {
j.set(Cookie {
name: "sid".into(),
value: "xyz".into(),
domain: ".example.com".into(),
path: "/".into(),
expires: None,
http_only: true,
secure: true,
same_site: Some(SameSite::Strict),
url: None,
..Default::default()
})
.await
}
});
let id = mock.expect_cmd("Storage.setCookies").await;
let params = &mock.last_sent()["params"];
let arr = params["cookies"]
.as_array()
.expect("setCookies payload must carry a cookies array");
assert_eq!(arr.len(), 1);
let c = &arr[0];
assert_eq!(c["name"], "sid");
assert_eq!(c["value"], "xyz");
assert_eq!(c["domain"], ".example.com");
assert_eq!(c["path"], "/");
assert_eq!(c["httpOnly"], true);
assert_eq!(c["secure"], true);
assert_eq!(c["sameSite"], "Strict");
assert!(c.get("http_only").is_none());
assert!(c.get("same_site").is_none());
mock.reply(id, json!({})).await;
call.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn delete_dispatches_network_delete_cookies_with_filters() {
let (mut mock, conn) = MockConnection::pair();
let jar = CookieJar::new(conn.clone());
let call_a = tokio::spawn({
let j = jar.clone();
async move { j.delete("sid", None, None).await }
});
let id_a = mock.expect_cmd("Network.deleteCookies").await;
let params_a = &mock.last_sent()["params"];
assert_eq!(params_a["name"], "sid");
assert!(params_a.get("domain").is_none());
assert!(params_a.get("path").is_none());
mock.reply(id_a, json!({})).await;
call_a.await.unwrap().unwrap();
let call_b = tokio::spawn({
let j = jar.clone();
async move { j.delete("sid", Some(".example.com"), Some("/api")).await }
});
let id_b = mock.expect_cmd("Network.deleteCookies").await;
let params_b = &mock.last_sent()["params"];
assert_eq!(params_b["name"], "sid");
assert_eq!(params_b["domain"], ".example.com");
assert_eq!(params_b["path"], "/api");
mock.reply(id_b, json!({})).await;
call_b.await.unwrap().unwrap();
conn.shutdown();
}
#[tokio::test]
async fn set_cookie_with_priority_and_partition_key_on_wire() {
let (mut mock, conn) = MockConnection::pair();
let jar = CookieJar::new(conn.clone());
let call = tokio::spawn({
let j = jar.clone();
async move {
j.set(Cookie {
name: "sid".into(),
value: "xyz".into(),
domain: ".example.com".into(),
path: "/".into(),
priority: Some(CookiePriority::High),
same_party: Some(true),
source_scheme: Some(CookieSourceScheme::Secure),
source_port: Some(443),
partition_key: Some("https://top".into()),
..Default::default()
})
.await
}
});
let id = mock.expect_cmd("Storage.setCookies").await;
let params = &mock.last_sent()["params"];
let c = ¶ms["cookies"]
.as_array()
.expect("setCookies payload must carry a cookies array")[0];
assert_eq!(c["priority"], "High");
assert_eq!(c["sameParty"], true);
assert_eq!(c["sourceScheme"], "Secure");
assert_eq!(c["sourcePort"], 443);
assert_eq!(c["partitionKey"], "https://top");
assert!(c.get("same_party").is_none());
assert!(c.get("source_scheme").is_none());
assert!(c.get("partition_key").is_none());
mock.reply(id, json!({})).await;
call.await.unwrap().unwrap();
conn.shutdown();
}
#[test]
fn cookie_json_roundtrip_preserves_new_fields() {
let cookie = Cookie {
name: "sid".into(),
value: "xyz".into(),
domain: ".example.com".into(),
path: "/".into(),
priority: Some(CookiePriority::Low),
same_party: Some(false),
source_scheme: Some(CookieSourceScheme::NonSecure),
source_port: Some(80),
partition_key: Some("https://top.example".into()),
..Default::default()
};
let json = serde_json::to_string(&cookie).unwrap();
assert!(json.contains("\"source_scheme\""));
assert!(json.contains("\"partition_key\""));
let back: Cookie = serde_json::from_str(&json).unwrap();
assert_eq!(back, cookie);
assert_eq!(back.priority, Some(CookiePriority::Low));
assert_eq!(back.same_party, Some(false));
assert_eq!(back.source_scheme, Some(CookieSourceScheme::NonSecure));
assert_eq!(back.source_port, Some(80));
assert_eq!(back.partition_key.as_deref(), Some("https://top.example"));
}
#[test]
fn cookie_read_missing_new_fields_is_none() {
let cdp: CdpCookie = serde_json::from_value(json!({
"name": "sid",
"value": "xyz",
"domain": ".example.com",
"path": "/",
"httpOnly": true,
"secure": true,
}))
.expect("a CDP cookie lacking the new fields must still parse");
let cookie: Cookie = cdp.into();
assert_eq!(cookie.priority, None);
assert_eq!(cookie.same_party, None);
assert_eq!(cookie.source_scheme, None);
assert_eq!(cookie.source_port, None);
assert_eq!(cookie.partition_key, None);
}
#[test]
fn cookie_read_ignores_unknown_future_field() {
let cdp: CdpCookie = serde_json::from_value(json!({
"name": "sid", "value": "xyz", "domain": ".example.com", "path": "/",
"someChrome147Field": { "nested": true }, "anotherNewThing": 42
}))
.expect("unknown fields must be ignored, not rejected");
assert_eq!(cdp.name, "sid");
}
}