use std::{
collections::HashMap,
fmt,
time::{SystemTime, UNIX_EPOCH},
};
use chromiumoxide::cdp::browser_protocol::network::{Cookie, CookieSourceScheme};
use serde::Serialize;
use uuid::Uuid;
use crate::error::{Result, VoidCrawlError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeaseScope {
pub scheme: String,
pub host: String,
pub port: Option<u16>,
}
impl LeaseScope {
pub fn from_url(url: &str) -> Result<Self> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| VoidCrawlError::Other(format!("replay origin needs a scheme: {url}")))?;
let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
let (host, port_str) = if host.starts_with(']') {
(host, None)
} else if host.starts_with('[') {
match host.split_once(']') {
Some((h, rest)) => {
(&host[..=h.len()], rest.strip_prefix(':').filter(|p| !p.is_empty()))
}
None => (host, None),
}
} else {
match host.split_once(':') {
Some((h, p)) => (h, Some(p).filter(|p| !p.is_empty())),
None => (host, None),
}
};
if scheme.is_empty() || host.is_empty() {
return Err(VoidCrawlError::Other(format!("replay origin is not absolute: {url}")));
}
let port = match port_str {
Some(p) => Some(p.parse::<u16>().map_err(|_| {
VoidCrawlError::Other(format!("replay origin has an invalid port: {url}"))
})?),
None => None,
};
Ok(Self { scheme: scheme.to_ascii_lowercase(), host: host.to_ascii_lowercase(), port })
}
#[must_use]
pub fn origin(&self) -> String {
match self.port {
Some(p) => format!("{}://{}:{p}", self.scheme, self.host),
None => format!("{}://{}", self.scheme, self.host),
}
}
#[must_use]
pub fn domain_matches(&self, cookie_domain: &str) -> bool {
let domain = cookie_domain.trim_start_matches('.').to_ascii_lowercase();
if domain.is_empty() {
return false;
}
if self.host == domain {
return true;
}
if !cookie_domain.starts_with('.') {
return false;
}
self.host.ends_with(&domain)
&& self.host.len() > domain.len()
&& self.host.as_bytes()[self.host.len() - domain.len() - 1] == b'.'
}
}
#[allow(
clippy::struct_excessive_bools,
reason = "mirrors CDP's cookie attribute set; each flag is a distinct fact a \
downstream classifier needs, not a state machine to collapse"
)]
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CookieProvenance {
pub name: String,
pub domain: String,
pub path: String,
pub issuing_origin: String,
pub intended_replay_origin: String,
pub top_level_site: Option<String>,
pub partition_key_opaque: bool,
pub has_cross_site_ancestor: Option<bool>,
pub observed_in_top_level_url: Option<String>,
pub expires_unix: Option<i64>,
pub is_session_cookie: bool,
pub http_only: bool,
pub secure: bool,
pub same_site: Option<String>,
pub source_scheme: String,
pub source_port: i64,
pub has_value: bool,
pub session_id: String,
pub acquired_at_unix: u64,
}
#[allow(
clippy::cast_possible_truncation,
reason = "clamped to i64 range first; sub-second cookie expiry is not meaningful"
)]
fn expiry_seconds(cookie: &Cookie) -> Option<i64> {
if cookie.session || !cookie.expires.is_finite() || cookie.expires < 0.0 {
return None;
}
const MAX_EPOCH_SECS: f64 = 253_402_300_799.0;
Some(cookie.expires.trunc().clamp(0.0, MAX_EPOCH_SECS) as i64)
}
fn same_site_str(cookie: &Cookie) -> Option<String> {
cookie.same_site.as_ref().map(|s| format!("{s:?}").to_ascii_lowercase())
}
fn source_scheme_str(scheme: &CookieSourceScheme) -> String {
format!("{scheme:?}").to_ascii_lowercase()
}
pub struct CookieLease {
id: String,
session_id: String,
scope: LeaseScope,
created_at: SystemTime,
entries: Vec<(CookieProvenance, String)>,
revoked: bool,
revoked_for: Option<String>,
}
impl fmt::Debug for CookieLease {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CookieLease")
.field("id", &self.id)
.field("session_id", &self.session_id)
.field("scope", &self.scope.origin())
.field("cookies", &self.entries.len())
.field("revoked", &self.revoked)
.field("revoked_for", &self.revoked_for)
.finish_non_exhaustive()
}
}
impl CookieLease {
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
#[must_use]
pub fn scope(&self) -> &LeaseScope {
&self.scope
}
#[must_use]
pub fn created_at(&self) -> SystemTime {
self.created_at
}
#[must_use]
pub fn is_revoked(&self) -> bool {
self.revoked
}
#[must_use]
pub fn revoked_for(&self) -> Option<&str> {
self.revoked_for.as_deref()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn provenance(&self) -> Vec<CookieProvenance> {
self.entries.iter().map(|(p, _)| p.clone()).collect()
}
pub fn header_for(&self, names: &[&str]) -> Result<String> {
if self.revoked {
return Err(VoidCrawlError::Other(format!(
"cookie lease {} is revoked ({})",
self.id,
self.revoked_for.as_deref().unwrap_or("no reason recorded")
)));
}
let by_name: HashMap<&str, &str> =
self.entries.iter().map(|(p, v)| (p.name.as_str(), v.as_str())).collect();
let mut pairs = Vec::with_capacity(names.len());
for name in names {
let value = by_name.get(*name).ok_or_else(|| {
VoidCrawlError::Other(format!(
"cookie {name:?} is not in lease {} (scope {})",
self.id,
self.scope.origin()
))
})?;
pairs.push(format!("{name}={value}"));
}
Ok(pairs.join("; "))
}
pub fn revoke(&mut self, reason: impl Into<String>) {
for (_, value) in &mut self.entries {
let len = value.len();
value.clear();
value.push_str(&"\0".repeat(len));
value.clear();
value.shrink_to_fit();
}
self.entries.clear();
self.entries.shrink_to_fit();
if !self.revoked {
self.revoked_for = Some(reason.into());
}
self.revoked = true;
}
}
impl Drop for CookieLease {
fn drop(&mut self) {
if !self.revoked {
self.revoke("lease dropped");
}
}
}
#[must_use]
pub fn fork_scoped(
cookies: &[Cookie],
scope: LeaseScope,
session_id: &str,
observed_in_top_level_url: Option<&str>,
) -> CookieLease {
let acquired_at_unix =
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or_default();
let intended_replay_origin = scope.origin();
let entries = cookies
.iter()
.filter(|c| scope.domain_matches(&c.domain))
.map(|c| {
let source_scheme = source_scheme_str(&c.source_scheme);
let expires_unix = expiry_seconds(c);
let provenance = CookieProvenance {
name: c.name.clone(),
domain: c.domain.clone(),
path: c.path.clone(),
issuing_origin: format!(
"{}://{}",
if source_scheme == "secure" { "https" } else { "http" },
c.domain.trim_start_matches('.')
),
intended_replay_origin: intended_replay_origin.clone(),
top_level_site: c.partition_key.as_ref().map(|k| k.top_level_site.clone()),
partition_key_opaque: c.partition_key_opaque.unwrap_or(false),
has_cross_site_ancestor: c
.partition_key
.as_ref()
.map(|k| k.has_cross_site_ancestor),
observed_in_top_level_url: observed_in_top_level_url.map(str::to_string),
expires_unix,
is_session_cookie: c.session,
http_only: c.http_only,
secure: c.secure,
same_site: same_site_str(c),
source_scheme,
source_port: c.source_port,
has_value: !c.value.is_empty(),
session_id: session_id.to_string(),
acquired_at_unix,
};
(provenance, c.value.clone())
})
.collect::<Vec<_>>();
CookieLease {
id: Uuid::new_v4().to_string(),
session_id: session_id.to_string(),
scope,
created_at: SystemTime::now(),
entries,
revoked: false,
revoked_for: None,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, reason = "test harness")]
mod tests {
use chromiumoxide::cdp::browser_protocol::network::{
CookiePartitionKey, CookiePriority, CookieSameSite,
};
use super::*;
fn cookie(name: &str, domain: &str, value: &str) -> Cookie {
Cookie {
name: name.into(),
value: value.into(),
domain: domain.into(),
path: "/".into(),
expires: -1.0,
size: 0,
http_only: true,
secure: true,
session: true,
same_site: Some(CookieSameSite::Lax),
priority: CookiePriority::Medium,
source_scheme: CookieSourceScheme::Secure,
source_port: 443,
partition_key: None,
partition_key_opaque: None,
}
}
fn scope(url: &str) -> LeaseScope {
LeaseScope::from_url(url).expect("valid scope")
}
#[test]
fn scope_parsing_strips_port_path_and_userinfo() {
assert_eq!(scope("https://api.example.com:8443/v1?x=1").host, "api.example.com");
assert_eq!(scope("https://user:pw@api.example.com/v1").host, "api.example.com");
assert_eq!(scope("HTTPS://API.Example.COM").origin(), "https://api.example.com");
}
#[test]
fn a_non_default_port_round_trips_into_the_echoed_origin() {
assert_eq!(scope("http://127.0.0.1:8901/").origin(), "http://127.0.0.1:8901");
assert_eq!(scope("https://api.example.com/v1").origin(), "https://api.example.com");
}
#[test]
fn a_port_is_ignored_for_cookie_matching() {
let s = scope("http://127.0.0.1:8901/");
assert!(s.domain_matches("127.0.0.1"));
}
#[test]
fn an_invalid_port_is_rejected_rather_than_silently_dropped() {
assert!(LeaseScope::from_url("http://example.com:notaport/").is_err());
}
#[test]
fn scope_parsing_keeps_ipv6_literals_intact() {
assert_eq!(scope("http://[::1]:8080/x").host, "[::1]");
assert_eq!(scope("http://[::1]:8080/x").port, Some(8080));
assert_eq!(scope("http://[::1]/x").port, None);
assert_eq!(scope("http://[::1]:8080/x").origin(), "http://[::1]:8080");
}
#[test]
fn a_scope_cannot_be_created_without_scheme_and_host() {
for bad in ["example.com/v1", "https://", "://example.com", ""] {
assert!(LeaseScope::from_url(bad).is_err(), "{bad:?} should be rejected");
}
}
#[test]
fn host_only_cookies_require_an_exact_host() {
let s = scope("https://api.example.com");
assert!(s.domain_matches("api.example.com"));
assert!(!s.domain_matches("example.com"));
assert!(!s.domain_matches("other.example.com"));
}
#[test]
fn domain_cookies_match_subdomains_only_at_a_label_boundary() {
let s = scope("https://api.example.com");
assert!(s.domain_matches(".example.com"));
assert!(s.domain_matches(".api.example.com"));
assert!(!scope("https://notexample.com").domain_matches(".example.com"));
assert!(!scope("https://evil-example.com").domain_matches(".example.com"));
}
#[test]
fn an_empty_cookie_domain_matches_nothing() {
assert!(!scope("https://example.com").domain_matches(""));
assert!(!scope("https://example.com").domain_matches("."));
}
#[test]
fn forking_keeps_only_cookies_in_scope() {
let cookies = vec![
cookie("session", "api.example.com", "wanted"),
cookie("other", "unrelated.test", "not-wanted"),
];
let lease = fork_scoped(&cookies, scope("https://api.example.com"), "sess-1", None);
assert_eq!(lease.len(), 1);
assert_eq!(lease.provenance()[0].name, "session");
}
#[test]
fn provenance_records_both_origin_and_top_level_site() {
let mut c = cookie("session", "api.stripe.test", "v");
c.partition_key = Some(CookiePartitionKey {
top_level_site: "https://shop-a.test".into(),
has_cross_site_ancestor: true,
});
let lease = fork_scoped(
&[c],
scope("https://api.stripe.test"),
"sess-1",
Some("https://shop-a.test/checkout"),
);
let p = &lease.provenance()[0];
assert_eq!(p.issuing_origin, "https://api.stripe.test");
assert_eq!(p.top_level_site.as_deref(), Some("https://shop-a.test"));
assert_eq!(p.has_cross_site_ancestor, Some(true));
assert_eq!(p.observed_in_top_level_url.as_deref(), Some("https://shop-a.test/checkout"));
assert_eq!(p.intended_replay_origin, "https://api.stripe.test");
}
#[test]
fn unpartitioned_cookies_report_no_top_level_site() {
let lease = fork_scoped(
&[cookie("a", "example.com", "v")],
scope("https://example.com"),
"s",
None,
);
let p = &lease.provenance()[0];
assert_eq!(p.top_level_site, None);
assert_eq!(p.has_cross_site_ancestor, None);
assert!(!p.partition_key_opaque);
}
#[test]
fn session_and_unset_expiry_both_collapse_to_none() {
let mut expiring = cookie("a", "example.com", "v");
expiring.session = false;
expiring.expires = 1_800_000_000.0;
let mut unset = cookie("b", "example.com", "v");
unset.session = false;
unset.expires = -1.0;
let lease = fork_scoped(&[expiring, unset], scope("https://example.com"), "s", None);
let p = lease.provenance();
assert_eq!(p[0].expires_unix, Some(1_800_000_000));
assert_eq!(p[1].expires_unix, None, "CDP's -1 sentinel must not become a real date");
}
#[test]
fn attributes_needed_for_downstream_classification_survive_the_fork() {
let lease = fork_scoped(
&[cookie("a", "example.com", "v")],
scope("https://example.com"),
"s",
None,
);
let p = &lease.provenance()[0];
assert!(p.http_only, "HttpOnly must be exported, not dropped");
assert!(p.secure);
assert_eq!(p.same_site.as_deref(), Some("lax"));
assert_eq!(p.source_scheme, "secure");
assert_eq!(p.source_port, 443);
}
#[test]
fn serialized_provenance_contains_no_cookie_value() {
let lease = fork_scoped(
&[cookie("session", "example.com", "SUPER-SECRET-VALUE")],
scope("https://example.com"),
"sess-1",
None,
);
let secret = "SUPER-SECRET-VALUE";
let json = serde_json::to_string(&lease.provenance()).expect("serializes");
assert!(!json.contains(secret), "value leaked into provenance JSON: {json}");
assert!(json.contains("\"has_value\":true"));
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
let record = parsed[0].as_object().expect("object");
for (key, value) in record {
let lower = key.to_ascii_lowercase();
assert!(
!(lower.contains("len") || lower.contains("size")),
"provenance exposes a length-ish field {key:?}"
);
if let Some(n) = value.as_u64() {
assert_ne!(
usize::try_from(n).unwrap_or(usize::MAX),
secret.len(),
"field {key:?} equals the secret's length"
);
}
}
}
#[test]
fn debug_formatting_a_lease_never_prints_values() {
let lease = fork_scoped(
&[cookie("session", "example.com", "SUPER-SECRET-VALUE")],
scope("https://example.com"),
"sess-1",
None,
);
let rendered = format!("{lease:?}");
assert!(!rendered.contains("SUPER-SECRET-VALUE"), "value leaked via Debug: {rendered}");
}
#[test]
fn header_includes_exactly_the_named_cookies() {
let cookies =
vec![cookie("session", "example.com", "abc"), cookie("tracking", "example.com", "xyz")];
let lease = fork_scoped(&cookies, scope("https://example.com"), "s", None);
assert_eq!(lease.header_for(&["session"]).expect("header"), "session=abc");
assert_eq!(
lease.header_for(&["session", "tracking"]).expect("header"),
"session=abc; tracking=xyz"
);
}
#[test]
fn an_unknown_cookie_name_errors_rather_than_being_dropped() {
let lease = fork_scoped(
&[cookie("session", "example.com", "abc")],
scope("https://example.com"),
"s",
None,
);
let err = lease.header_for(&["session", "nope"]).expect_err("must error");
assert!(format!("{err}").contains("nope"));
}
#[test]
fn revoking_clears_values_and_fails_closed_with_a_reason() {
let mut lease = fork_scoped(
&[cookie("session", "example.com", "abc")],
scope("https://example.com"),
"s",
None,
);
assert!(lease.header_for(&["session"]).is_ok());
lease.revoke("session closed");
assert!(lease.is_revoked());
assert_eq!(lease.revoked_for(), Some("session closed"));
assert!(lease.is_empty(), "values must be dropped, not just flagged");
let err = lease.header_for(&["session"]).expect_err("revoked lease must fail closed");
let message = format!("{err}");
assert!(message.contains("revoked"), "reason should be machine-readable: {message}");
assert!(message.contains("session closed"));
}
#[test]
fn revocation_is_idempotent_and_keeps_the_first_reason() {
let mut lease = fork_scoped(
&[cookie("a", "example.com", "v")],
scope("https://example.com"),
"s",
None,
);
lease.revoke("auth failed");
lease.revoke("session closed");
assert_eq!(lease.revoked_for(), Some("auth failed"));
}
#[test]
fn provenance_survives_revocation_for_post_mortem_explanation() {
let mut lease = fork_scoped(
&[cookie("a", "example.com", "v")],
scope("https://example.com"),
"s",
None,
);
let before = lease.provenance();
lease.revoke("auth failed");
assert_eq!(before.len(), 1);
assert!(lease.provenance().is_empty());
}
}