use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
use crate::net::waf::is_challenge_cookie;
pub const DEFAULT_COOKIE_FILE_NAME: &str = "cookies.json";
pub const DEFAULT_COOKIE_FILE_MODE: u32 = 0o600;
pub const DEFAULT_CHROME_FULL_VERSION: &str = "131.0.6778.86";
pub const CHROME_HEADER_ORDER: [&str; 16] = [
":method",
":authority",
":scheme",
":path",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"upgrade-insecure-requests",
"user-agent",
"accept",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"accept-encoding",
"accept-language",
];
pub const DEFAULT_NAVIGATION_ACCEPT: &str = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7";
pub const DEFAULT_ACCEPT_ENCODING: &str = "gzip, deflate, br, zstd";
pub const DEFAULT_ACCEPT_LANGUAGE: &str = "en-US,en;q=0.9";
#[must_use]
pub fn cookie_file_name() -> String {
crate::config::tuning_string_or("net.session.cookie_file", DEFAULT_COOKIE_FILE_NAME)
}
#[must_use]
pub fn cookie_file_mode() -> u32 {
crate::config::tuning_u32_in_range(
"net.session.cookie_file_mode",
DEFAULT_COOKIE_FILE_MODE,
0o400,
0o777,
)
}
#[must_use]
pub fn chrome_full_version() -> String {
crate::config::tuning_string_or(
"net.session.chrome_full_version",
DEFAULT_CHROME_FULL_VERSION,
)
}
#[must_use]
pub fn chrome_header_order() -> Vec<String> {
crate::config::tuning_str_list_or("net.session.header_order", &CHROME_HEADER_ORDER)
}
#[must_use]
pub fn navigation_accept() -> String {
crate::config::tuning_string_or("net.session.accept_navigation", DEFAULT_NAVIGATION_ACCEPT)
}
#[must_use]
pub fn accept_encoding() -> String {
crate::config::tuning_string_or("net.session.accept_encoding", DEFAULT_ACCEPT_ENCODING)
}
#[must_use]
pub fn accept_language() -> String {
crate::config::tuning_string_or("net.session.accept_language", DEFAULT_ACCEPT_LANGUAGE)
}
pub fn chrome_client(timeout: std::time::Duration) -> AppResult<reqwest::Client> {
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
let user_agent = crate::config::tuning_string("user_agent")
.unwrap_or_else(|| ChromeFingerprint::default().user_agent().to_owned());
let fingerprint = ChromeFingerprint::from_user_agent(&user_agent);
let mut headers = HeaderMap::new();
for name in chrome_header_order() {
let value = match name.as_str() {
"sec-ch-ua" => fingerprint.as_ref().map(ChromeFingerprint::sec_ch_ua),
"sec-ch-ua-mobile" => fingerprint
.as_ref()
.map(|fp| fp.sec_ch_ua_mobile().to_owned()),
"sec-ch-ua-platform" => fingerprint
.as_ref()
.map(ChromeFingerprint::sec_ch_ua_platform),
"upgrade-insecure-requests" => Some("1".to_owned()),
"accept" => Some(navigation_accept()),
"accept-encoding" => Some(accept_encoding()),
"accept-language" => Some(accept_language()),
_ => None,
};
let Some(value) = value else { continue };
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(&value),
) {
headers.insert(name, value);
}
}
reqwest::Client::builder()
.timeout(timeout)
.user_agent(user_agent)
.default_headers(headers)
.build()
.map_err(AppError::Http)
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredCookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub expires: Option<DateTime<Utc>>,
pub secure: bool,
pub http_only: bool,
pub same_site: Option<String>,
}
impl fmt::Debug for StoredCookie {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StoredCookie")
.field("name", &self.name)
.field("value", &"[REDACTED]")
.field("domain", &self.domain)
.field("path", &self.path)
.field("expires", &self.expires)
.field("secure", &self.secure)
.field("http_only", &self.http_only)
.field("same_site", &self.same_site)
.finish()
}
}
impl StoredCookie {
#[must_use]
pub fn new(
name: impl Into<String>,
value: impl Into<String>,
domain: impl Into<String>,
) -> Self {
Self {
name: name.into(),
value: value.into(),
domain: domain.into(),
path: "/".to_owned(),
expires: None,
secure: true,
http_only: false,
same_site: None,
}
}
#[must_use]
pub fn with_expiry(mut self, expires: DateTime<Utc>) -> Self {
self.expires = Some(expires);
self
}
#[must_use]
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = path.into();
self
}
#[must_use]
pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
self.expires.is_some_and(|exp| exp <= now)
}
#[must_use]
pub fn is_challenge(&self) -> bool {
is_challenge_cookie(&self.name)
}
#[must_use]
pub fn key(&self) -> String {
format!(
"{}\u{1f}{}\u{1f}{}",
self.domain.trim_start_matches('.').to_ascii_lowercase(),
self.path,
self.name
)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CookieJar {
cookies: BTreeMap<String, StoredCookie>,
}
impl CookieJar {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, cookie: StoredCookie) {
self.cookies.insert(cookie.key(), cookie);
}
#[must_use]
pub fn get(&self, domain: &str, path: &str, name: &str) -> Option<&StoredCookie> {
let probe = StoredCookie {
name: name.to_owned(),
value: String::new(),
domain: domain.to_owned(),
path: path.to_owned(),
expires: None,
secure: false,
http_only: false,
same_site: None,
};
self.cookies.get(&probe.key())
}
pub fn iter(&self) -> impl Iterator<Item = &StoredCookie> {
self.cookies.values()
}
#[must_use]
pub fn len(&self) -> usize {
self.cookies.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cookies.is_empty()
}
#[must_use]
pub fn cookie_names(&self) -> Vec<String> {
self.cookies.values().map(|c| c.name.clone()).collect()
}
pub fn prune_expired(&mut self, now: DateTime<Utc>) -> usize {
let before = self.cookies.len();
self.cookies.retain(|_, cookie| !cookie.is_expired_at(now));
before - self.cookies.len()
}
pub fn clear_except_challenges(&mut self) {
self.cookies.retain(|_, cookie| cookie.is_challenge());
}
pub fn clear(&mut self) {
self.cookies.clear();
}
#[must_use]
pub fn challenge_is_valid(&self, domain: &str, now: DateTime<Utc>) -> bool {
let domain = domain.trim_start_matches('.').to_ascii_lowercase();
let mut seen = false;
for cookie in self.cookies.values().filter(|c| c.is_challenge()) {
let cookie_domain = cookie.domain.trim_start_matches('.').to_ascii_lowercase();
if domain == cookie_domain || domain.ends_with(&format!(".{cookie_domain}")) {
seen = true;
if cookie.is_expired_at(now) {
return false;
}
}
}
seen
}
pub fn default_path() -> AppResult<PathBuf> {
let dirs = crate::config::project_dirs().ok_or_else(|| {
AppError::Config("cannot resolve the platform data directory".to_owned())
})?;
Ok(dirs.data_dir().join(cookie_file_name()))
}
pub fn load(path: &Path) -> AppResult<Self> {
match fs::read(path) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new()),
Err(e) => Err(AppError::Io(e)),
}
}
pub fn save(&self, path: &Path) -> AppResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_vec_pretty(self)?;
let tmp = path.with_extension("json.tmp");
{
let mut file = fs::File::create(&tmp)?;
restrict_permissions(&file)?;
file.write_all(&json)?;
file.sync_all()?;
}
fs::rename(&tmp, path)?;
Ok(())
}
pub fn load_default() -> AppResult<Self> {
let path = Self::default_path()?;
Self::load(&path)
}
pub fn save_default(&self) -> AppResult<()> {
let path = Self::default_path()?;
self.save(&path)
}
}
#[cfg(unix)]
fn restrict_permissions(file: &fs::File) -> AppResult<()> {
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(cookie_file_mode());
file.set_permissions(perms)?;
Ok(())
}
#[cfg(not(unix))]
fn restrict_permissions(_file: &fs::File) -> AppResult<()> {
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChromePlatform {
Windows,
MacOs,
Linux,
}
impl ChromePlatform {
#[must_use]
pub const fn ch_ua_platform(self) -> &'static str {
match self {
Self::Windows => "Windows",
Self::MacOs => "macOS",
Self::Linux => "Linux",
}
}
#[must_use]
pub const fn ua_platform_token(self) -> &'static str {
match self {
Self::Windows => "Windows NT 10.0; Win64; x64",
Self::MacOs => "Macintosh; Intel Mac OS X 10_15_7",
Self::Linux => "X11; Linux x86_64",
}
}
#[must_use]
pub const fn host() -> Self {
if cfg!(target_os = "windows") {
Self::Windows
} else if cfg!(target_os = "macos") {
Self::MacOs
} else {
Self::Linux
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchMetadata {
pub site: String,
pub mode: String,
pub user: Option<String>,
pub dest: String,
}
impl FetchMetadata {
#[must_use]
pub fn top_level_navigation() -> Self {
Self {
site: "none".to_owned(),
mode: "navigate".to_owned(),
user: Some("?1".to_owned()),
dest: "document".to_owned(),
}
}
#[must_use]
pub fn same_origin_xhr() -> Self {
Self {
site: "same-origin".to_owned(),
mode: "cors".to_owned(),
user: None,
dest: "empty".to_owned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestContext {
pub method: String,
pub scheme: String,
pub authority: String,
pub path: String,
pub accept: String,
pub accept_language: String,
pub fetch: FetchMetadata,
}
impl RequestContext {
#[must_use]
pub fn navigation(authority: impl Into<String>, path: impl Into<String>) -> Self {
Self {
method: "GET".to_owned(),
scheme: "https".to_owned(),
authority: authority.into(),
path: path.into(),
accept: navigation_accept(),
accept_language: accept_language(),
fetch: FetchMetadata::top_level_navigation(),
}
}
#[must_use]
pub fn with_accept_language(mut self, value: impl Into<String>) -> Self {
self.accept_language = value.into();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChromeFingerprint {
full_version: String,
major_version: u32,
platform: ChromePlatform,
user_agent: String,
}
impl Default for ChromeFingerprint {
fn default() -> Self {
Self::new(ChromePlatform::host(), &chrome_full_version())
.unwrap_or_else(|| Self::fallback(ChromePlatform::host()))
}
}
impl ChromeFingerprint {
#[must_use]
pub fn new(platform: ChromePlatform, full_version: &str) -> Option<Self> {
let major = full_version.split('.').next()?.parse::<u32>().ok()?;
let user_agent = format!(
"Mozilla/5.0 ({}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36",
platform.ua_platform_token(),
full_version
);
Some(Self {
full_version: full_version.to_owned(),
major_version: major,
platform,
user_agent,
})
}
fn fallback(platform: ChromePlatform) -> Self {
let full_version = "131.0.0.0";
let user_agent = format!(
"Mozilla/5.0 ({}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36",
platform.ua_platform_token(),
full_version
);
Self {
full_version: full_version.to_owned(),
major_version: 131,
platform,
user_agent,
}
}
#[must_use]
pub fn from_user_agent(user_agent: &str) -> Option<Self> {
let after = user_agent.split("Chrome/").nth(1)?;
let full_version: String = after
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
let major = full_version.split('.').next()?.parse::<u32>().ok()?;
let platform = if user_agent.contains("Windows NT") {
ChromePlatform::Windows
} else if user_agent.contains("Mac OS X") || user_agent.contains("Macintosh") {
ChromePlatform::MacOs
} else {
ChromePlatform::Linux
};
Some(Self {
full_version,
major_version: major,
platform,
user_agent: user_agent.to_owned(),
})
}
#[must_use]
pub fn full_version(&self) -> &str {
&self.full_version
}
#[must_use]
pub const fn major_version(&self) -> u32 {
self.major_version
}
#[must_use]
pub const fn platform(&self) -> ChromePlatform {
self.platform
}
#[must_use]
pub fn user_agent(&self) -> &str {
&self.user_agent
}
#[must_use]
pub fn sec_ch_ua(&self) -> String {
let major = self.major_version;
format!(
"\"Chromium\";v=\"{major}\", \"{}\";v=\"{}\", \"Google Chrome\";v=\"{major}\"",
grease_brand(major),
grease_version(major)
)
}
#[must_use]
pub const fn sec_ch_ua_mobile(&self) -> &'static str {
"?0"
}
#[must_use]
pub fn sec_ch_ua_platform(&self) -> String {
format!("\"{}\"", self.platform.ch_ua_platform())
}
#[must_use]
pub fn is_coherent(&self) -> bool {
let hint_names_major = self
.sec_ch_ua()
.contains(&format!("\"{}\"", self.major_version));
let ua_names_major = self
.user_agent
.contains(&format!("Chrome/{}", self.major_version));
hint_names_major && ua_names_major
}
#[must_use]
pub fn headers(&self, ctx: &RequestContext) -> Vec<(String, String)> {
let order = chrome_header_order();
let encoding = accept_encoding();
let mut out: Vec<(String, String)> = Vec::with_capacity(order.len());
for name in &order {
let value = match name.as_str() {
":method" => Some(ctx.method.clone()),
":authority" => Some(ctx.authority.clone()),
":scheme" => Some(ctx.scheme.clone()),
":path" => Some(ctx.path.clone()),
"sec-ch-ua" => Some(self.sec_ch_ua()),
"sec-ch-ua-mobile" => Some(self.sec_ch_ua_mobile().to_owned()),
"sec-ch-ua-platform" => Some(self.sec_ch_ua_platform()),
"upgrade-insecure-requests" => Some("1".to_owned()),
"user-agent" => Some(self.user_agent.clone()),
"accept" => Some(ctx.accept.clone()),
"sec-fetch-site" => Some(ctx.fetch.site.clone()),
"sec-fetch-mode" => Some(ctx.fetch.mode.clone()),
"sec-fetch-user" => ctx.fetch.user.clone(),
"sec-fetch-dest" => Some(ctx.fetch.dest.clone()),
"accept-encoding" => Some(encoding.clone()),
"accept-language" => Some(ctx.accept_language.clone()),
_ => None,
};
if let Some(value) = value {
out.push((name.clone(), value));
}
}
out
}
#[must_use]
pub fn transmittable_headers(&self, ctx: &RequestContext) -> Vec<(String, String)> {
self.headers(ctx)
.into_iter()
.filter(|(name, _)| !name.starts_with(':'))
.collect()
}
}
fn grease_brand(major: u32) -> String {
const SEPARATORS: [&str; 4] = ["Not_A Brand", "Not-A.Brand", "Not.A/Brand", "Not?A_Brand"];
let idx = (major as usize) % SEPARATORS.len();
SEPARATORS[idx].to_owned()
}
fn grease_version(major: u32) -> u32 {
const CANDIDATES: [u32; 4] = [8, 24, 99, 128];
CANDIDATES[(major as usize) % CANDIDATES.len()]
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn linux_fp() -> ChromeFingerprint {
ChromeFingerprint::new(ChromePlatform::Linux, "131.0.6778.86")
.expect("a well-formed version must parse")
}
#[test]
fn headers_are_emitted_in_chrome_order() {
let fp = linux_fp();
let ctx = RequestContext::navigation("example.com", "/watch");
let headers = fp.headers(&ctx);
let names: Vec<&str> = headers.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names.len(), CHROME_HEADER_ORDER.len());
for (idx, expected) in CHROME_HEADER_ORDER.iter().enumerate() {
assert_eq!(names[idx], *expected, "field {idx} out of order");
}
}
#[test]
fn pseudo_headers_come_first_and_in_chrome_sequence() {
let fp = linux_fp();
let ctx = RequestContext::navigation("example.com", "/x");
let headers = fp.headers(&ctx);
let pseudo: Vec<&str> = headers
.iter()
.take_while(|(n, _)| n.starts_with(':'))
.map(|(n, _)| n.as_str())
.collect();
assert_eq!(pseudo, [":method", ":authority", ":scheme", ":path"]);
}
#[test]
fn sec_fetch_user_is_omitted_rather_than_sent_as_false() {
let fp = linux_fp();
let mut ctx = RequestContext::navigation("example.com", "/api");
ctx.fetch = FetchMetadata::same_origin_xhr();
let headers = fp.headers(&ctx);
assert!(
!headers.iter().any(|(n, _)| n == "sec-fetch-user"),
"Chrome omits sec-fetch-user; it never sends ?0"
);
assert_eq!(headers.len(), CHROME_HEADER_ORDER.len() - 1);
}
#[test]
fn transmittable_headers_drop_the_pseudo_headers() {
let fp = linux_fp();
let ctx = RequestContext::navigation("example.com", "/x");
let headers = fp.transmittable_headers(&ctx);
assert!(!headers.iter().any(|(n, _)| n.starts_with(':')));
assert_eq!(headers.len(), CHROME_HEADER_ORDER.len() - 4);
assert_eq!(headers[0].0, "sec-ch-ua");
}
#[test]
fn client_hints_agree_with_the_user_agent() {
for version in ["120.0.6099.109", "131.0.6778.86", "142.0.7444.12"] {
let fp = ChromeFingerprint::new(ChromePlatform::Windows, version)
.expect("version must parse");
let major = version
.split('.')
.next()
.and_then(|m| m.parse::<u32>().ok())
.expect("major must parse");
assert_eq!(fp.major_version(), major);
assert!(
fp.sec_ch_ua()
.contains(&format!("\"Chromium\";v=\"{major}\"")),
"sec-ch-ua must name Chromium {major}"
);
assert!(
fp.sec_ch_ua()
.contains(&format!("\"Google Chrome\";v=\"{major}\"")),
"sec-ch-ua must name Google Chrome {major}"
);
assert!(fp.user_agent().contains(&format!("Chrome/{version}")));
assert!(fp.is_coherent());
}
}
#[test]
fn hints_derived_from_a_user_agent_cannot_drift_from_it() {
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/128.0.6613.120 Safari/537.36";
let fp = ChromeFingerprint::from_user_agent(ua).expect("must parse");
assert_eq!(fp.major_version(), 128);
assert_eq!(fp.full_version(), "128.0.6613.120");
assert_eq!(fp.platform(), ChromePlatform::MacOs);
assert_eq!(fp.sec_ch_ua_platform(), "\"macOS\"");
assert!(fp.sec_ch_ua().contains("\"128\""));
assert!(fp.is_coherent());
}
#[test]
fn platform_hint_matches_the_user_agent_token() {
for (platform, hint, token) in [
(ChromePlatform::Windows, "\"Windows\"", "Windows NT"),
(ChromePlatform::MacOs, "\"macOS\"", "Mac OS X"),
(ChromePlatform::Linux, "\"Linux\"", "X11; Linux"),
] {
let fp = ChromeFingerprint::new(platform, "131.0.6778.86").expect("must parse");
assert_eq!(fp.sec_ch_ua_platform(), hint);
assert!(
fp.user_agent().contains(token),
"{hint} must pair with the {token} user-agent token"
);
}
}
#[test]
fn grease_brand_is_stable_for_a_given_version() {
let a = ChromeFingerprint::new(ChromePlatform::Linux, "131.0.6778.86")
.expect("must parse")
.sec_ch_ua();
let b = ChromeFingerprint::new(ChromePlatform::Linux, "131.0.6778.86")
.expect("must parse")
.sec_ch_ua();
assert_eq!(a, b, "the brand list must not change between requests");
}
#[test]
fn a_non_chrome_user_agent_is_rejected() {
assert!(ChromeFingerprint::from_user_agent(
"Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0"
)
.is_none());
assert!(ChromeFingerprint::new(ChromePlatform::Linux, "not-a-version").is_none());
}
#[test]
fn the_default_fingerprint_is_coherent() {
let fp = ChromeFingerprint::default();
assert!(fp.is_coherent());
assert_eq!(fp.full_version(), chrome_full_version());
}
#[test]
fn expiry_is_read_from_the_cookie_not_from_a_timer() {
let now = Utc::now();
let mut jar = CookieJar::new();
jar.insert(
StoredCookie::new("fresh", "v", "example.com").with_expiry(now + Duration::hours(2)),
);
jar.insert(
StoredCookie::new("stale", "v", "example.com").with_expiry(now - Duration::seconds(1)),
);
jar.insert(StoredCookie::new("session", "v", "example.com"));
assert_eq!(jar.prune_expired(now), 1);
assert_eq!(jar.len(), 2);
assert!(jar.get("example.com", "/", "fresh").is_some());
assert!(jar.get("example.com", "/", "session").is_some());
assert!(jar.get("example.com", "/", "stale").is_none());
}
#[test]
fn challenge_cookies_survive_a_reset() {
let mut jar = CookieJar::new();
jar.insert(StoredCookie::new("cf_clearance", "proof", "example.com"));
jar.insert(StoredCookie::new("datadome", "proof", "example.com"));
jar.insert(StoredCookie::new("session_id", "s", "example.com"));
jar.insert(StoredCookie::new("ab_test", "b", "example.com"));
jar.clear_except_challenges();
assert_eq!(jar.len(), 2);
assert!(jar.iter().all(StoredCookie::is_challenge));
}
#[test]
fn challenge_validity_reflects_real_expiry() {
let now = Utc::now();
let mut jar = CookieJar::new();
assert!(
!jar.challenge_is_valid("example.com", now),
"no challenge cookie at all means no valid challenge"
);
jar.insert(
StoredCookie::new("cf_clearance", "proof", "example.com")
.with_expiry(now + Duration::minutes(30)),
);
assert!(jar.challenge_is_valid("example.com", now));
assert!(jar.challenge_is_valid("www.example.com", now));
jar.insert(
StoredCookie::new("cf_clearance", "proof", "example.com")
.with_expiry(now - Duration::minutes(1)),
);
assert!(!jar.challenge_is_valid("example.com", now));
}
#[test]
fn cookie_identity_is_domain_path_name() {
let mut jar = CookieJar::new();
jar.insert(StoredCookie::new("a", "1", "example.com"));
jar.insert(StoredCookie::new("a", "2", "example.com"));
assert_eq!(jar.len(), 1, "same identity must replace");
assert_eq!(
jar.get("example.com", "/", "a").map(|c| c.value.as_str()),
Some("2")
);
jar.insert(StoredCookie::new("a", "3", "example.com").with_path("/sub"));
assert_eq!(jar.len(), 2, "a different path is a different cookie");
jar.insert(StoredCookie::new("a", "4", "other.com"));
assert_eq!(jar.len(), 3, "a different domain is a different cookie");
}
#[test]
fn cookie_value_is_not_printed_by_debug() {
let cookie = StoredCookie::new("cf_clearance", "topsecretvalue", "example.com");
let rendered = format!("{cookie:?}");
assert!(
!rendered.contains("topsecretvalue"),
"Debug leaked a cookie value: {rendered}"
);
assert!(rendered.contains("[REDACTED]"));
assert!(rendered.contains("cf_clearance"), "the name is diagnostic");
}
#[test]
fn jar_round_trips_through_disk_with_owner_only_permissions() {
let dir = std::env::temp_dir().join(format!(
"ylc-net-session-{}-{}",
std::process::id(),
Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
let path = dir.join("nested").join(cookie_file_name());
let mut jar = CookieJar::new();
jar.insert(StoredCookie::new("cf_clearance", "proof", "example.com"));
jar.save(&path).expect("save must succeed");
let loaded = CookieJar::load(&path).expect("load must succeed");
assert_eq!(loaded.len(), 1);
assert_eq!(
loaded
.get("example.com", "/", "cf_clearance")
.map(|c| c.value.as_str()),
Some("proof")
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path)
.expect("metadata must be readable")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, cookie_file_mode(), "jar must be owner-only");
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_jar_file_is_an_empty_jar_not_an_error() {
let path = std::env::temp_dir().join("ylc-net-session-does-not-exist.json");
let _ = fs::remove_file(&path);
let jar = CookieJar::load(&path).expect("a missing file must not be an error");
assert!(jar.is_empty());
}
fn production_prefix(source: &str) -> &str {
match source.find("#[cfg(test)]") {
Some(idx) => &source[..idx],
None => source,
}
}
fn rust_sources(root: &Path) -> Vec<PathBuf> {
let mut stack = vec![root.to_path_buf()];
let mut found = Vec::new();
while let Some(dir) = stack.pop() {
let entries = fs::read_dir(&dir).expect("src/ must be readable");
for entry in entries {
let path = entry.expect("a directory entry must be readable").path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "rs") {
found.push(path);
}
}
}
found
}
#[test]
fn every_production_http_client_comes_from_the_factory() {
const FORBIDDEN: [&str; 2] = ["reqwest::Client::builder", "reqwest::Client::new"];
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let sources = rust_sources(&root);
let mut files_scanned = 0_usize;
let mut occurrences_seen = 0_usize;
let mut offenders: Vec<String> = Vec::new();
for path in &sources {
if path.ends_with("net/session.rs") {
continue;
}
let source = fs::read_to_string(path).expect("a source file must be readable");
files_scanned += 1;
let production = production_prefix(&source);
for (number, line) in source.lines().enumerate() {
for needle in FORBIDDEN {
if !line.contains(needle) {
continue;
}
occurrences_seen += 1;
let offset = line.as_ptr() as usize - source.as_ptr() as usize;
if offset < production.len() {
offenders.push(format!(
"{}:{}: {needle} outside net::session::chrome_client",
path.display(),
number + 1
));
}
}
}
}
assert!(
files_scanned >= 40,
"the gate scanned only {files_scanned} files; the walker is broken"
);
assert!(
occurrences_seen >= 1,
"the gate matched no `reqwest::Client::` at all; the needles are stale"
);
assert!(
offenders.is_empty(),
"production HTTP clients built outside the factory:\n{}",
offenders.join("\n")
);
}
#[test]
fn cookie_names_feed_the_waf_classifier() {
let mut jar = CookieJar::new();
jar.insert(StoredCookie::new("datadome", "x", "example.com"));
let names = jar.cookie_names();
let hit = crate::net::waf::detect(&[], &names).expect("must classify");
assert_eq!(hit.vendor, crate::net::waf::WafVendor::DataDome);
}
}