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, 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>,
}
#[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>,
}
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,
}
}
}
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,
}
}
}
#[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,
})
.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();
}
}