backport midjourney fixes from ArtCraft-X (#1953)

This commit is contained in:
Brandon Thomas
2026-09-14 12:45:24 -04:00
committed by GitHub
parent 5c7807add6
commit 584fdb7044
126 changed files with 5503 additions and 1566 deletions
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "browser_emulation"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "browser_emulation"
path = "src/lib.rs"
[dependencies]
# External
wreq.workspace = true
wreq-util.workspace = true
[dev-dependencies]
# None yet
@@ -0,0 +1,225 @@
use wreq::{Client, ClientBuilder};
use wreq_util::{Emulation, EmulationOS, EmulationOption};
/// A browser identity to emulate.
///
/// The named variants cover the browsers we routinely emulate, defaulting to a
/// macOS platform (which is what our desktop app and captured cookies use).
/// [`BrowserProfile::Custom`] is the escape hatch for any other `wreq_util`
/// emulation, a different OS, or a User-Agent override (useful when a site
/// advertises a browser version newer than `wreq_util` ships a fingerprint for).
#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
pub enum BrowserProfile {
#[default]
Firefox139,
Firefox147,
Chrome131,
Chrome145,
Safari18,
Custom(CustomBrowserProfile),
}
/// A fully-specified browser identity for [`BrowserProfile::Custom`].
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CustomBrowserProfile {
/// The `wreq_util` fingerprint to emulate.
pub emulation: Emulation,
/// The operating system to present (drives `sec-ch-ua-platform`, the UA, …).
pub os: EmulationOS,
/// Overrides the User-Agent the emulation would otherwise send. Leave `None`
/// to use the emulation's built-in UA.
pub maybe_user_agent: Option<String>,
}
impl BrowserProfile {
/// A macOS Safari profile with an explicit User-Agent override.
///
/// Use this when the UA must EXACTLY match another client's — e.g. an
/// embedded webview that captured the cookies, since Cloudflare's
/// `cf_clearance` is bound to the exact User-Agent string.
pub fn safari_macos_with_user_agent(user_agent: impl Into<String>) -> Self {
Self::Custom(CustomBrowserProfile { emulation: Emulation::Safari18, os: EmulationOS::MacOS, maybe_user_agent: Some(user_agent.into()) })
}
/// The profile that best matches a User-Agent captured from another client
/// (typically the login webview that earned the site's cookies), keeping
/// that exact UA string.
///
/// Use this whenever cookies are replayed: bot-protection clearances
/// (Cloudflare `cf_clearance`, DataDome) are bound to the UA and checked
/// against the TLS/HTTP fingerprint, so the replaying client should present
/// the same UA on the fingerprint of the same browser family. WebKit-based
/// UAs (Safari, WKWebView) map to Safari; Chrome/Chromium/Edge to Chrome;
/// Firefox to Firefox. Anything else falls back to Safari, since our
/// webviews are WebKit.
pub fn matching_user_agent(user_agent: impl Into<String>) -> Self {
let user_agent = user_agent.into();
let emulation = emulation_for_user_agent(&user_agent);
Self::Custom(CustomBrowserProfile { emulation, os: os_for_user_agent(&user_agent), maybe_user_agent: Some(user_agent) })
}
/// Build a ready-to-use client carrying this profile's full fingerprint and
/// identity headers. This is the usual entry point.
pub fn build_client(&self) -> Result<Client, wreq::Error> {
self.configure_client_builder(Client::builder()).build()
}
/// Apply this profile to an existing [`ClientBuilder`], so callers can layer
/// on their own settings (timeouts, proxies, …) before building.
pub fn configure_client_builder(&self, builder: ClientBuilder) -> ClientBuilder {
let builder = builder.emulation(self.emulation_option());
match self.maybe_user_agent_override() {
Some(user_agent) => builder.user_agent(user_agent),
None => builder,
}
}
/// The `wreq` emulation factory (fingerprint + coherent identity headers)
/// for this profile. Pass it to any `wreq` `emulation(...)` method — e.g. to
/// emulate at the request level (`RequestBuilder`) or on a websocket upgrade
/// (`WebSocketRequestBuilder`) instead of at the client level.
///
/// Note: this does NOT include the User-Agent override — that is applied by
/// [`Self::configure_client_builder`]. When emulating at the request level,
/// apply any override yourself with a `user-agent` header.
pub fn emulation_option(&self) -> EmulationOption {
EmulationOption::builder().emulation(self.emulation()).emulation_os(self.os()).build()
}
/// The underlying `wreq_util` emulation for this profile.
pub fn emulation(&self) -> Emulation {
match self {
Self::Firefox139 => Emulation::Firefox139,
Self::Firefox147 => Emulation::Firefox147,
Self::Chrome131 => Emulation::Chrome131,
Self::Chrome145 => Emulation::Chrome145,
Self::Safari18 => Emulation::Safari18,
Self::Custom(custom) => custom.emulation,
}
}
/// The operating system this profile presents. Named variants default to
/// macOS.
pub fn os(&self) -> EmulationOS {
match self {
Self::Custom(custom) => custom.os,
_ => EmulationOS::MacOS,
}
}
/// The User-Agent override, if this profile sets one. Named variants use the
/// emulation's built-in UA and return `None`.
pub fn maybe_user_agent_override(&self) -> Option<&str> {
match self {
Self::Custom(custom) => custom.maybe_user_agent.as_deref(),
_ => None,
}
}
/// A short human label for logs and debugging.
pub fn label(&self) -> String {
match self {
Self::Firefox139 => "firefox139".to_string(),
Self::Firefox147 => "firefox147".to_string(),
Self::Chrome131 => "chrome131".to_string(),
Self::Chrome145 => "chrome145".to_string(),
Self::Safari18 => "safari18".to_string(),
Self::Custom(custom) => {
format!("custom({:?} on {:?})", custom.emulation, custom.os)
},
}
}
}
/// The newest fingerprint we ship for the UA's browser family.
fn emulation_for_user_agent(user_agent: &str) -> Emulation {
let lower = user_agent.to_ascii_lowercase();
if lower.contains("firefox/") {
Emulation::Firefox147
} else if lower.contains("chrome/") || lower.contains("chromium/") || lower.contains("edg/") {
Emulation::Chrome145
} else {
// Safari proper, WKWebView UAs ("AppleWebKit/... Safari/..." without
// "Chrome/"), and anything unrecognized: our webviews are WebKit.
Emulation::Safari18
}
}
fn os_for_user_agent(user_agent: &str) -> EmulationOS {
let lower = user_agent.to_ascii_lowercase();
if lower.contains("windows") {
EmulationOS::Windows
} else if lower.contains("android") {
EmulationOS::Android
} else if lower.contains("iphone") || lower.contains("ipad") {
EmulationOS::IOS
} else if lower.contains("linux") || lower.contains("x11") {
EmulationOS::Linux
} else {
EmulationOS::MacOS
}
}
#[cfg(test)]
mod tests {
use super::*;
const WKWEBVIEW_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)";
const SAFARI_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15";
const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36";
const FIREFOX_WIN_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0";
#[test]
fn matching_user_agent_picks_family_and_keeps_exact_ua() {
let safari = BrowserProfile::matching_user_agent(SAFARI_MAC_UA);
assert_eq!(safari.emulation(), Emulation::Safari18);
assert_eq!(safari.os(), EmulationOS::MacOS);
assert_eq!(safari.maybe_user_agent_override(), Some(SAFARI_MAC_UA));
// WKWebView says "Safari/..."-less AppleWebKit; still WebKit.
assert_eq!(BrowserProfile::matching_user_agent(WKWEBVIEW_MAC_UA).emulation(), Emulation::Safari18);
// Chrome also says "Safari/537.36" — "Chrome/" must win.
let chrome = BrowserProfile::matching_user_agent(CHROME_MAC_UA);
assert_eq!(chrome.emulation(), Emulation::Chrome145);
assert_eq!(chrome.os(), EmulationOS::MacOS);
let firefox = BrowserProfile::matching_user_agent(FIREFOX_WIN_UA);
assert_eq!(firefox.emulation(), Emulation::Firefox147);
assert_eq!(firefox.os(), EmulationOS::Windows);
}
#[test]
fn matching_user_agent_builds_a_client() {
BrowserProfile::matching_user_agent(CHROME_MAC_UA).build_client().unwrap();
}
#[test]
fn default_is_firefox139() {
assert_eq!(BrowserProfile::default(), BrowserProfile::Firefox139);
assert_eq!(BrowserProfile::default().emulation(), Emulation::Firefox139);
}
#[test]
fn named_variants_default_to_macos_and_no_ua_override() {
assert_eq!(BrowserProfile::Chrome145.os(), EmulationOS::MacOS);
assert_eq!(BrowserProfile::Chrome145.maybe_user_agent_override(), None);
}
#[test]
fn custom_carries_os_and_ua_override() {
let profile = BrowserProfile::Custom(CustomBrowserProfile { emulation: Emulation::Chrome145, os: EmulationOS::Windows, maybe_user_agent: Some("custom-agent/1.0".to_string()) });
assert_eq!(profile.emulation(), Emulation::Chrome145);
assert_eq!(profile.os(), EmulationOS::Windows);
assert_eq!(profile.maybe_user_agent_override(), Some("custom-agent/1.0"));
}
#[test]
fn every_profile_builds_a_client() {
for profile in [BrowserProfile::Firefox139, BrowserProfile::Firefox147, BrowserProfile::Chrome131, BrowserProfile::Chrome145, BrowserProfile::Safari18] {
profile.build_client().unwrap_or_else(|err| panic!("profile {} failed to build a client: {err}", profile.label()));
}
}
}
+36
View File
@@ -0,0 +1,36 @@
//! browser_emulation
//!
//! A small, shared vocabulary of browser identities for anti-bot-sensitive
//! HTTP clients (Cloudflare-guarded scraping endpoints, websockets, etc.).
//!
//! Pick a [`BrowserProfile`] and it configures `wreq` end to end: the TLS/JA3
//! and HTTP/2 fingerprint, the User-Agent, and the coherent identity headers
//! (`sec-ch-ua`, `accept`, `accept-encoding`, …) that a real browser sends.
//! These MUST live on the client/connection, not on individual requests, so
//! the primary entry point builds a configured client:
//!
//! ```ignore
//! let client = BrowserProfile::Firefox139.build_client()?;
//! // Every request from `client` now carries Firefox 139's fingerprint.
//! ```
//!
//! When the cookies you send were captured from a specific browser (e.g. a
//! Cloudflare `cf_clearance` cookie, which is bound to the capturing browser's
//! User-Agent), pick the profile that matches that browser so the cookie
//! validates.
// Never allow these
#![forbid(private_bounds)]
#![forbid(private_interfaces)]
#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things
// Okay to toggle
#![forbid(unreachable_patterns)]
#![forbid(unused_imports)]
#![forbid(unused_mut)]
#![forbid(unused_variables)]
// Always allow
#![allow(dead_code)]
#![allow(non_snake_case)]
pub mod browser_profile;
@@ -0,0 +1,37 @@
[package]
name = "cookie_store_wrapper"
edition = "2024"
version = "0.0.2"
publish = false
[lib]
name = "cookie_store_wrapper"
path = "src/lib.rs"
[features]
default = []
# Implements the wreq `CookieStore` trait on `SharedCookieStore` so a wreq
# client can read from and write Set-Cookie responses into a shared store.
wreq = ["dep:wreq"]
[dependencies]
# External
chrono.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
url.workspace = true
# NB: For converting cookie expiry timestamps; the `cookie` crate (via
# `cookie_store`) already depends on `time`, so this adds no new weight.
time = "0.3"
wreq = { workspace = true, optional = true }
# NB: The RFC 6265 cookie store implementation this crate wraps. `serde_json`
# enables cookie serialization; we skip `public_suffix` to keep dependencies
# minimal.
cookie_store = { version = "0.21.1", default-features = false, features = ["serde_json"] }
[dev-dependencies]
serde_derive.workspace = true
toml.workspace = true
@@ -0,0 +1,80 @@
use std::collections::VecDeque;
use std::time::SystemTime;
/// Oldest records are dropped once the log exceeds this many entries.
pub const MAX_CHANGE_LOG_RECORDS: usize = 256;
/// A bounded, in-memory history of cookie store mutations, for debugging
/// auth flows ("did the provider rotate the session cookie?", "when did we
/// lose it?"). Values are intentionally not recorded — only names, domains,
/// and what happened.
#[derive(Clone, Debug, Default)]
pub struct CookieChangeLog {
records: VecDeque<CookieChangeRecord>,
next_sequence_number: u64,
}
#[derive(Clone, Debug)]
pub struct CookieChangeRecord {
pub sequence_number: u64,
pub timestamp: SystemTime,
pub action: CookieChangeAction,
pub cookie_name: String,
pub maybe_domain: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CookieChangeAction {
/// A new cookie was stored.
Inserted,
/// An existing cookie's value was replaced.
Updated,
/// A server sent an expired Set-Cookie, deleting the stored cookie.
ExpiredByServer,
/// A cookie was refused (domain mismatch, malformed, etc.).
Rejected,
/// The whole store was cleared.
Cleared,
}
impl CookieChangeLog {
pub fn record(&mut self, action: CookieChangeAction, cookie_name: &str, maybe_domain: Option<String>) {
self.records.push_back(CookieChangeRecord { sequence_number: self.next_sequence_number, timestamp: SystemTime::now(), action, cookie_name: cookie_name.to_owned(), maybe_domain });
self.next_sequence_number += 1;
while self.records.len() > MAX_CHANGE_LOG_RECORDS {
self.records.pop_front();
}
}
/// Records in chronological order, oldest first.
pub fn iter(&self) -> impl Iterator<Item = &CookieChangeRecord> {
self.records.iter()
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_is_bounded_and_keeps_newest_records() {
let mut log = CookieChangeLog::default();
for index in 0..(MAX_CHANGE_LOG_RECORDS + 10) {
log.record(CookieChangeAction::Inserted, &format!("cookie_{index}"), None);
}
assert_eq!(log.len(), MAX_CHANGE_LOG_RECORDS);
let first = log.iter().next().unwrap();
assert_eq!(first.sequence_number, 10);
let last = log.iter().last().unwrap();
assert_eq!(last.cookie_name, format!("cookie_{}", MAX_CHANGE_LOG_RECORDS + 9));
}
}
@@ -0,0 +1,329 @@
use crate::change_log::{CookieChangeAction, CookieChangeLog};
use log::warn;
use ::cookie_store::{CookieError, CookieStore as CookieStoreRfc, RawCookie, StoreAction};
use url::Url;
/// A cookie jar with RFC 6265 storage semantics.
///
/// Cookies are keyed by `(domain, path, name)` — not just name — and a repeat
/// `Set-Cookie` for the same key replaces the old value, while an expired
/// `Set-Cookie` deletes it (the standard server-side removal mechanism).
/// Every mutation is recorded in a bounded [`CookieChangeLog`] for debugging.
///
/// This is a plain value type; wrap it in
/// [`crate::shared_cookie_store::SharedCookieStore`] to share it between
/// threads.
#[derive(Clone, Debug, Default)]
pub struct CookieStore {
store: CookieStoreRfc,
change_log: CookieChangeLog,
}
/// A cookie captured from a browser or webview, with as much metadata as the
/// browser reported.
#[derive(Clone, Debug)]
pub struct CapturedCookie {
pub name: String,
pub value: String,
/// Domain as reported by the browser. May carry a leading dot
/// (e.g. `.storyteller.ai`), which marks a parent-domain cookie.
pub maybe_domain: Option<String>,
pub maybe_path: Option<String>,
}
impl CookieStore {
pub fn empty() -> Self {
Self::default()
}
/// Parse a raw `Cookie:` request-header string (`"a=1; b=2"`) into a new
/// store of host-only cookies for `source_url`'s host. This is the entry
/// point for hand-entered cookie headers, where per-cookie attributes are
/// not known.
pub fn from_cookie_header(cookie_header: &str, source_url: &Url) -> Self {
let mut store = Self::empty();
store.insert_cookie_header(cookie_header, source_url);
store
}
pub(crate) fn from_rfc_store(store: CookieStoreRfc) -> Self {
Self { store, change_log: CookieChangeLog::default() }
}
/// Insert a bare name/value cookie as a host-only cookie for `source_url`'s
/// host. Returns whether the cookie was stored (or expired an existing one).
pub fn insert_named(&mut self, name: &str, value: &str, source_url: &Url) -> bool {
let raw = RawCookie::new(name.to_owned(), value.to_owned());
self.insert_raw(&raw, source_url)
}
/// Insert a cookie captured from a browser or webview.
///
/// When the capture carries a domain, the cookie is stored as a
/// domain-suffix cookie for it (so it matches subdomains, mirroring how the
/// browser scoped it). Without a domain it falls back to a host-only cookie
/// for `fallback_source_url`.
pub fn insert_captured(&mut self, captured: CapturedCookie, fallback_source_url: &Url) -> bool {
let maybe_domain = captured.maybe_domain.as_deref().map(|domain| domain.trim_start_matches('.').to_ascii_lowercase()).filter(|domain| !domain.is_empty());
let Some(domain) = maybe_domain else {
return self.insert_named(&captured.name, &captured.value, fallback_source_url);
};
let source_url = match Url::parse(&format!("https://{domain}/")) {
Ok(url) => url,
Err(err) => {
warn!("Cannot build source URL for captured cookie {:?} (domain {:?}): {:?}", captured.name, domain, err);
return false;
},
};
let mut raw = RawCookie::new(captured.name, captured.value);
raw.set_domain(domain);
raw.set_path(captured.maybe_path.unwrap_or_else(|| "/".to_owned()));
self.insert_raw(&raw, &source_url)
}
/// Insert every `name=value` pair of a raw `Cookie:` request-header string
/// as a host-only cookie for `source_url`'s host. Returns how many cookies
/// were stored.
pub fn insert_cookie_header(&mut self, cookie_header: &str, source_url: &Url) -> usize {
cookie_header.split(';').filter_map(|pair| pair.trim().split_once('=')).filter(|(name, _)| !name.trim().is_empty()).map(|(name, value)| (name.trim().to_owned(), value.trim().to_owned())).collect::<Vec<(String, String)>>().into_iter().filter(|(name, value)| self.insert_named(name, value, source_url)).count()
}
/// Apply one `Set-Cookie` header value received from `request_url`,
/// following the RFC 6265 storage model (replace / expire / reject).
pub fn apply_set_cookie_header(&mut self, set_cookie_value: &str, request_url: &Url) -> bool {
let cookie_name = set_cookie_value.split('=').next().unwrap_or(set_cookie_value).trim().to_owned();
let result = self.store.parse(set_cookie_value, request_url);
self.record_insert_result(result, &cookie_name, request_url)
}
/// Apply several `Set-Cookie` header values received from `request_url`.
/// Returns how many were accepted.
pub fn apply_set_cookie_headers<'a>(&mut self, set_cookie_values: impl Iterator<Item = &'a str>, request_url: &Url) -> usize {
set_cookie_values.filter(|value| self.apply_set_cookie_header(value, request_url)).count()
}
pub fn clear_all(&mut self) {
self.store.clear();
self.change_log.record(CookieChangeAction::Cleared, "*", None);
}
/// Whether an unexpired cookie with this name exists on any domain.
pub fn has_cookie(&self, name: &str) -> bool {
self.store.iter_unexpired().any(|cookie| cookie.name() == name)
}
/// The value of the first unexpired cookie with this name, on any domain.
pub fn get_cookie_value(&self, name: &str) -> Option<&str> {
self.store.iter_unexpired().find(|cookie| cookie.name() == name).map(|cookie| cookie.value())
}
/// `(name, value)` pairs of every unexpired cookie, across all domains.
pub fn iter_name_values(&self) -> impl Iterator<Item = (&str, &str)> {
self.store.iter_unexpired().map(|cookie| (cookie.name(), cookie.value()))
}
/// Names of every unexpired cookie, across all domains.
pub fn cookie_names(&self) -> Vec<String> {
self.iter_name_values().map(|(name, _)| name.to_owned()).collect()
}
pub fn len(&self) -> usize {
self.store.iter_unexpired().count()
}
pub fn is_empty(&self) -> bool {
self.store.iter_unexpired().next().is_none()
}
/// NB: Just use this as a heuristic, and do not call it in a loop.
pub fn calculate_approx_cookie_character_length(&self) -> usize {
const COOKIE_SEP_LENGTH: usize = 2; // '=' and ';'
self.iter_name_values().map(|(name, value)| name.len() + value.len() + COOKIE_SEP_LENGTH).sum()
}
/// Every unexpired cookie joined into one `Cookie:` header string,
/// regardless of domain or path.
///
/// This is a blunt tool for scraping clients that captured cookies for one
/// known site and want to send them all back. It performs no request
/// matching; two same-named cookies on different domains both appear.
/// Prefer [`Self::cookie_header_for_url`] when the request URL is known.
pub fn to_cookie_string(&self) -> String {
self.iter_name_values().map(|(name, value)| format!("{}={}", name, value)).collect::<Vec<String>>().join("; ")
}
/// A `Cookie:` header value containing only the cookies that RFC 6265
/// domain/path/secure matching would send to `request_url`, or `None` when
/// no cookies match.
pub fn cookie_header_for_url(&self, request_url: &Url) -> Option<String> {
let header = self.store.get_request_values(request_url).map(|(name, value)| format!("{}={}", name, value)).collect::<Vec<String>>().join("; ");
if header.is_empty() { None } else { Some(header) }
}
/// The recent mutation history, for debugging.
pub fn change_log(&self) -> &CookieChangeLog {
&self.change_log
}
pub(crate) fn rfc_store(&self) -> &CookieStoreRfc {
&self.store
}
pub(crate) fn insert_raw(&mut self, raw: &RawCookie<'static>, source_url: &Url) -> bool {
let cookie_name = raw.name().to_owned();
let result = self.store.insert_raw(raw, source_url);
self.record_insert_result(result, &cookie_name, source_url)
}
fn record_insert_result(&mut self, result: Result<StoreAction, CookieError>, cookie_name: &str, request_url: &Url) -> bool {
let maybe_domain = request_url.host_str().map(str::to_owned);
match result {
Ok(StoreAction::Inserted) => {
self.change_log.record(CookieChangeAction::Inserted, cookie_name, maybe_domain);
true
},
Ok(StoreAction::UpdatedExisting) => {
self.change_log.record(CookieChangeAction::Updated, cookie_name, maybe_domain);
true
},
Ok(StoreAction::ExpiredExisting) => {
self.change_log.record(CookieChangeAction::ExpiredByServer, cookie_name, maybe_domain);
true
},
Err(err) => {
warn!("Rejected cookie {:?} from {}: {:?}", cookie_name, request_url, err);
self.change_log.record(CookieChangeAction::Rejected, cookie_name, maybe_domain);
false
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::change_log::CookieChangeAction;
const SITE_URL: &str = "https://www.example.com/";
mod insert_tests {
use super::*;
#[test]
fn insert_named_stores_host_only_cookie() {
let mut store = CookieStore::empty();
assert!(store.insert_named("session", "abc123", &site_url()));
assert!(store.has_cookie("session"));
assert_eq!(store.get_cookie_value("session"), Some("abc123"));
assert_eq!(store.len(), 1);
}
#[test]
fn insert_named_replaces_existing_value() {
let mut store = CookieStore::empty();
store.insert_named("session", "old", &site_url());
store.insert_named("session", "new", &site_url());
assert_eq!(store.len(), 1);
assert_eq!(store.get_cookie_value("session"), Some("new"));
}
#[test]
fn insert_captured_with_parent_domain_matches_subdomains() {
let mut store = CookieStore::empty();
let captured = CapturedCookie { name: "auth".to_string(), value: "token".to_string(), maybe_domain: Some(".example.com".to_string()), maybe_path: None };
assert!(store.insert_captured(captured, &site_url()));
let subdomain_url = Url::parse("https://api.example.com/v1/things").unwrap();
assert_eq!(store.cookie_header_for_url(&subdomain_url), Some("auth=token".to_string()));
}
#[test]
fn insert_captured_without_domain_falls_back_to_source_url() {
let mut store = CookieStore::empty();
let captured = CapturedCookie { name: "sid".to_string(), value: "xyz".to_string(), maybe_domain: None, maybe_path: None };
assert!(store.insert_captured(captured, &site_url()));
assert_eq!(store.cookie_header_for_url(&site_url()), Some("sid=xyz".to_string()));
}
}
mod set_cookie_header_tests {
use super::*;
#[test]
fn apply_set_cookie_header_stores_cookie() {
let mut store = CookieStore::empty();
assert!(store.apply_set_cookie_header("session=abc; Path=/; HttpOnly", &site_url()));
assert_eq!(store.get_cookie_value("session"), Some("abc"));
}
#[test]
fn expired_set_cookie_deletes_existing_cookie() {
let mut store = CookieStore::empty();
store.apply_set_cookie_header("session=abc; Path=/", &site_url());
assert!(store.has_cookie("session"));
let accepted = store.apply_set_cookie_header("session=gone; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT", &site_url());
assert!(accepted);
assert!(!store.has_cookie("session"));
}
#[test]
fn set_cookie_for_mismatched_domain_is_rejected() {
let mut store = CookieStore::empty();
let accepted = store.apply_set_cookie_header("evil=1; Domain=other.com", &site_url());
assert!(!accepted);
assert!(!store.has_cookie("evil"));
}
}
mod header_rendering_tests {
use super::*;
#[test]
fn to_cookie_string_joins_all_cookies() {
let mut store = CookieStore::empty();
store.insert_named("a", "1", &site_url());
store.insert_named("b", "2", &site_url());
let header = store.to_cookie_string();
let mut parts = header.split("; ").collect::<Vec<_>>();
parts.sort();
assert_eq!(parts, vec!["a=1", "b=2"]);
}
#[test]
fn cookie_header_for_url_excludes_other_hosts() {
let mut store = CookieStore::empty();
store.insert_named("mine", "1", &site_url());
store.insert_named("theirs", "2", &Url::parse("https://other.example.org/").unwrap());
assert_eq!(store.cookie_header_for_url(&site_url()), Some("mine=1".to_string()));
}
}
mod change_log_tests {
use super::*;
#[test]
fn mutations_are_recorded() {
let mut store = CookieStore::empty();
store.insert_named("session", "a", &site_url());
store.insert_named("session", "b", &site_url());
let actions = store.change_log().iter().map(|record| record.action).collect::<Vec<_>>();
assert_eq!(actions, vec![CookieChangeAction::Inserted, CookieChangeAction::Updated]);
}
}
fn site_url() -> Url {
Url::parse(SITE_URL).unwrap()
}
}
@@ -0,0 +1,36 @@
//! cookie_store_wrapper
//!
//! Cookie jar for scraping-style API clients.
//!
//! Wraps the RFC 6265 `cookie_store` crate so stored cookies follow real HTTP
//! semantics: domain and path matching, replacement on repeat Set-Cookie, and
//! server-driven expiry. On top of that this crate adds Serde support (embed a
//! [`cookie_store::CookieStore`] directly in your own serializable structs), a
//! bounded change log for debugging auth flows, and a cheaply clonable
//! thread-safe handle ([`shared_cookie_store::SharedCookieStore`]).
//!
//! With the `wreq` feature enabled, `SharedCookieStore` implements wreq's
//! `CookieStore` trait, so a wreq client built with it as the cookie provider
//! transparently applies every `Set-Cookie` response to the shared store.
// Never allow these
#![forbid(private_bounds)]
#![forbid(private_interfaces)]
#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things
// Okay to toggle
#![forbid(unreachable_patterns)]
#![forbid(unused_imports)]
#![forbid(unused_mut)]
#![forbid(unused_variables)]
// Always allow
#![allow(dead_code)]
#![allow(non_snake_case)]
pub mod change_log;
pub mod cookie_store;
pub mod serde_support;
pub mod shared_cookie_store;
#[cfg(feature = "wreq")]
pub mod wreq_cookie_provider;
@@ -0,0 +1,312 @@
//! Serde support for [`CookieStore`] and [`SharedCookieStore`].
//!
//! Both types implement `Serialize` and `Deserialize` directly, so callers
//! can embed a cookie store in their own serializable structs (JSON state
//! files, TOML credential files, etc.) without a conversion type:
//!
//! ```ignore
//! #[derive(Serialize, Deserialize)]
//! struct CookieCredential {
//! updated_at: Option<DateTime<Utc>>,
//! cookies: CookieStore, // NB: keep array-of-table fields LAST for TOML
//! }
//! ```
//!
//! The wire shape is a flat list of cookies, chosen to stay human-editable
//! when it lands in a TOML file someone opens in vim:
//!
//! ```toml
//! [[cookie.cookies]]
//! name = "session"
//! value = "abc123"
//! domain = "example.com"
//! secure = true
//! http_only = true
//! expires = "2027-01-01T00:00:00+00:00"
//! ```
//!
//! Defaulted attributes (`path = "/"`, false flags, session-cookie expiry)
//! are omitted on write but accepted on read. Expired cookies are dropped on
//! both save and load. The change log is debug state and is not serialized.
use crate::cookie_store::CookieStore;
use crate::shared_cookie_store::SharedCookieStore;
use ::cookie_store::Cookie as CookieRfc;
use ::cookie_store::{CookieDomain, CookieExpiration, RawCookie};
use chrono::{DateTime, SecondsFormat, Utc};
use log::warn;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::SystemTime;
use url::Url;
const DEFAULT_PATH: &str = "/";
/// The wire shape of one cookie. Public so callers can build tooling around
/// the stored format, but normal use is via `CookieStore`'s serde impls.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SerializedCookie {
pub name: String,
pub value: String,
/// The domain the cookie belongs to, without a leading dot. Subdomains
/// also match unless `host_only` is set.
pub domain: String,
/// Restrict the cookie to exactly `domain` (no subdomains).
#[serde(default, skip_serializing_if = "is_false")]
pub host_only: bool,
#[serde(default = "default_path", skip_serializing_if = "is_default_path")]
pub path: String,
#[serde(default, skip_serializing_if = "is_false")]
pub secure: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub http_only: bool,
/// RFC 3339 expiry time. Omitted for session cookies (which never expire
/// on disk).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
}
impl Serialize for CookieStore {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(self.rfc_store().iter_unexpired().filter_map(serialized_cookie_from_rfc))
}
}
impl<'de> Deserialize<'de> for CookieStore {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let cookies = Vec::<SerializedCookie>::deserialize(deserializer)?;
let mut store = CookieStore::empty();
for cookie in cookies {
insert_serialized_cookie(&mut store, cookie);
}
Ok(store)
}
}
/// Serializes as a point-in-time snapshot of the shared store.
impl Serialize for SharedCookieStore {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.with_read(|store| store.serialize(serializer))
}
}
/// Deserializes into a fresh handle, unrelated to any existing clones.
impl<'de> Deserialize<'de> for SharedCookieStore {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(SharedCookieStore::new(CookieStore::deserialize(deserializer)?))
}
}
/// Compares the cookies (name, value, and attributes), ignoring the change
/// log and insertion order.
impl PartialEq for CookieStore {
fn eq(&self, other: &Self) -> bool {
sorted_serialized_cookies(self) == sorted_serialized_cookies(other)
}
}
impl Eq for CookieStore {}
fn serialized_cookie_from_rfc(cookie: &CookieRfc<'static>) -> Option<SerializedCookie> {
let (domain, host_only) = match &cookie.domain {
CookieDomain::HostOnly(host) => (host.clone(), true),
CookieDomain::Suffix(suffix) => (suffix.clone(), false),
other => {
warn!("Cannot serialize cookie {:?} with domain {:?}; skipping.", cookie.name(), other);
return None;
},
};
let expires = match &cookie.expires {
CookieExpiration::AtUtc(at_utc) => {
let system_time = SystemTime::from(*at_utc);
Some(DateTime::<Utc>::from(system_time).to_rfc3339_opts(SecondsFormat::Secs, false))
},
CookieExpiration::SessionEnd => None,
};
Some(SerializedCookie { name: cookie.name().to_owned(), value: cookie.value().to_owned(), domain, host_only, path: (*cookie.path).to_owned(), secure: cookie.secure().unwrap_or(false), http_only: cookie.http_only().unwrap_or(false), expires })
}
fn insert_serialized_cookie(store: &mut CookieStore, cookie: SerializedCookie) {
let source_url = match Url::parse(&format!("https://{}/", cookie.domain)) {
Ok(url) => url,
Err(err) => {
warn!("Cannot restore cookie {:?} with domain {:?}: {:?}", cookie.name, cookie.domain, err);
return;
},
};
let mut raw = RawCookie::new(cookie.name, cookie.value);
raw.set_path(cookie.path);
raw.set_secure(cookie.secure);
raw.set_http_only(cookie.http_only);
if !cookie.host_only {
raw.set_domain(cookie.domain);
}
if let Some(expires) = &cookie.expires {
match DateTime::parse_from_rfc3339(expires) {
Ok(at) => {
let system_time = SystemTime::from(at.with_timezone(&Utc));
raw.set_expires(time::OffsetDateTime::from(system_time));
},
Err(err) => {
warn!("Cookie {:?} has unparseable expires {:?} ({:?}); treating as session cookie.", raw.name(), expires, err);
},
}
}
// NB: An expired cookie is rejected here (with a warning), which is the
// intended load behavior.
store.insert_raw(&raw, &source_url);
}
fn sorted_serialized_cookies(store: &CookieStore) -> Vec<SerializedCookie> {
let mut cookies = store.rfc_store().iter_unexpired().filter_map(serialized_cookie_from_rfc).collect::<Vec<SerializedCookie>>();
cookies.sort_by(|a, b| (&a.domain, &a.path, &a.name).cmp(&(&b.domain, &b.path, &b.name)));
cookies
}
fn default_path() -> String {
DEFAULT_PATH.to_owned()
}
fn is_default_path(path: &str) -> bool {
path == DEFAULT_PATH
}
fn is_false(value: &bool) -> bool {
!value
}
#[cfg(test)]
mod tests {
use super::*;
use serde_derive::{Deserialize, Serialize};
#[test]
fn cookie_store_round_trips_through_json() {
let store = example_store();
let serialized = serde_json::to_string(&store).unwrap();
let restored: CookieStore = serde_json::from_str(&serialized).unwrap();
assert_eq!(restored.len(), 3);
assert_eq!(restored.get_cookie_value("session"), Some("abc123"));
assert_eq!(restored.get_cookie_value("pref"), Some("dark"));
assert_eq!(restored.get_cookie_value("keeper"), Some("forever"));
assert_eq!(restored, store);
}
#[test]
fn cookie_store_nests_inside_caller_toml_structs() {
#[derive(Serialize, Deserialize)]
struct CredentialFile {
service: String,
// NB: Array-of-table fields go last so TOML stays valid.
cookies: CookieStore,
}
let file = CredentialFile { service: "midjourney_cookies".to_string(), cookies: example_store() };
let serialized = toml::to_string(&file).unwrap();
let restored: CredentialFile = toml::from_str(&serialized).unwrap();
assert_eq!(restored.service, "midjourney_cookies");
assert_eq!(restored.cookies, file.cookies);
}
#[test]
fn toml_output_is_human_editable() {
#[derive(Serialize)]
struct CredentialFile {
cookies: CookieStore,
}
let site_url = Url::parse("https://www.example.com/").unwrap();
let mut store = CookieStore::empty();
store.apply_set_cookie_header("session=abc123; Secure; HttpOnly; Path=/", &site_url);
let serialized = toml::to_string(&CredentialFile { cookies: store }).unwrap();
// The whole cookie reads as a flat, obvious table.
let expected = "\
[[cookies]]
name = \"session\"
value = \"abc123\"
domain = \"www.example.com\"
host_only = true
secure = true
http_only = true
";
assert_eq!(serialized, expected);
}
#[test]
fn hand_written_toml_with_defaults_parses() {
#[derive(Deserialize)]
struct CredentialFile {
cookies: CookieStore,
}
let hand_written = r#"
[[cookies]]
name = "session"
value = "abc123"
domain = "example.com"
"#;
let file: CredentialFile = toml::from_str(hand_written).unwrap();
assert_eq!(file.cookies.get_cookie_value("session"), Some("abc123"));
// Domain cookies (the default) match subdomains.
let subdomain_url = Url::parse("https://api.example.com/").unwrap();
assert_eq!(file.cookies.cookie_header_for_url(&subdomain_url), Some("session=abc123".to_string()),);
}
#[test]
fn expires_round_trips_and_expired_cookies_are_dropped() {
let json = r#"[
{"name": "fresh", "value": "1", "domain": "example.com", "expires": "2100-01-01T00:00:00+00:00"},
{"name": "stale", "value": "2", "domain": "example.com", "expires": "2001-01-01T00:00:00+00:00"}
]"#;
let restored: CookieStore = serde_json::from_str(json).unwrap();
assert!(restored.has_cookie("fresh"));
assert!(!restored.has_cookie("stale"));
let reserialized = serde_json::to_string(&restored).unwrap();
assert!(reserialized.contains("\"expires\":\"2100-01-01T00:00:00+00:00\""));
}
#[test]
fn shared_cookie_store_round_trips_through_json() {
let shared = SharedCookieStore::new(example_store());
let serialized = serde_json::to_string(&shared).unwrap();
let restored: SharedCookieStore = serde_json::from_str(&serialized).unwrap();
assert!(restored.has_cookie("session"));
}
#[test]
fn legacy_version_1_data_fails_to_parse() {
let legacy_json = r#"{"cookies":[{"name":"session","value":"abc"}]}"#;
let result = serde_json::from_str::<CookieStore>(legacy_json);
assert!(result.is_err());
}
fn example_store() -> CookieStore {
let site_url = Url::parse("https://www.example.com/").unwrap();
let mut store = CookieStore::empty();
store.insert_named("session", "abc123", &site_url);
store.apply_set_cookie_header("pref=dark; Domain=example.com; Path=/", &site_url);
store.apply_set_cookie_header("keeper=forever; Path=/; Expires=Tue, 03 Aug 2100 00:38:37 GMT", &site_url);
store
}
}
@@ -0,0 +1,78 @@
use crate::cookie_store::CookieStore;
use std::sync::{Arc, PoisonError, RwLock};
use url::Url;
/// A cheaply clonable, thread-safe handle to a [`CookieStore`].
///
/// Clones share the same underlying store, so a mutation made by one thread
/// (e.g. a Set-Cookie applied by an HTTP client) is visible to all holders.
/// With the `wreq` feature enabled this type implements wreq's `CookieStore`
/// trait and can be passed to `ClientBuilder::cookie_provider`.
#[derive(Clone, Debug, Default)]
pub struct SharedCookieStore {
inner: Arc<RwLock<CookieStore>>,
}
impl SharedCookieStore {
pub fn empty() -> Self {
Self::default()
}
pub fn new(store: CookieStore) -> Self {
Self { inner: Arc::new(RwLock::new(store)) }
}
/// A point-in-time copy of the underlying store.
pub fn snapshot(&self) -> CookieStore {
self.with_read(|store| store.clone())
}
/// Swap the underlying store for all holders of this handle.
pub fn replace(&self, store: CookieStore) {
self.with_write(|current| *current = store);
}
pub fn with_read<R>(&self, reader: impl FnOnce(&CookieStore) -> R) -> R {
// NB: A panic mid-read cannot corrupt the store, so recover from poison.
let guard = self.inner.read().unwrap_or_else(PoisonError::into_inner);
reader(&guard)
}
pub fn with_write<R>(&self, writer: impl FnOnce(&mut CookieStore) -> R) -> R {
let mut guard = self.inner.write().unwrap_or_else(PoisonError::into_inner);
writer(&mut guard)
}
pub fn has_cookie(&self, name: &str) -> bool {
self.with_read(|store| store.has_cookie(name))
}
pub fn to_cookie_string(&self) -> String {
self.with_read(|store| store.to_cookie_string())
}
pub fn cookie_header_for_url(&self, request_url: &Url) -> Option<String> {
self.with_read(|store| store.cookie_header_for_url(request_url))
}
pub fn apply_set_cookie_header(&self, set_cookie_value: &str, request_url: &Url) -> bool {
self.with_write(|store| store.apply_set_cookie_header(set_cookie_value, request_url))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clones_share_mutations() {
let site_url = Url::parse("https://www.example.com/").unwrap();
let shared = SharedCookieStore::empty();
let clone = shared.clone();
clone.apply_set_cookie_header("session=abc; Path=/", &site_url);
assert!(shared.has_cookie("session"));
assert_eq!(shared.cookie_header_for_url(&site_url), Some("session=abc".to_string()));
}
}
@@ -0,0 +1,60 @@
//! Implements wreq's `CookieStore` trait on [`SharedCookieStore`], so a wreq
//! client can use our store as its cookie provider:
//!
//! ```ignore
//! let cookies = SharedCookieStore::empty();
//! let client = wreq::Client::builder()
//! .cookie_provider(cookies.clone())
//! .build()?;
//! // Every Set-Cookie the client receives now lands in `cookies`,
//! // and every request sends the matching stored cookies.
//! ```
use crate::shared_cookie_store::SharedCookieStore;
use log::warn;
use url::Url;
use wreq::Uri;
use wreq::cookie::{CookieStore as WreqCookieStore, Cookies};
use wreq::header::HeaderValue;
impl WreqCookieStore for SharedCookieStore {
fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, uri: &Uri) {
let Some(request_url) = uri_to_url(uri) else {
warn!("Cannot convert URI to URL for Set-Cookie storage: {}", uri);
return;
};
self.with_write(|store| {
for header in cookie_headers {
match header.to_str() {
Ok(value) => {
store.apply_set_cookie_header(value, &request_url);
},
Err(err) => {
warn!("Set-Cookie header from {} is not valid UTF-8: {:?}", request_url, err);
},
}
}
});
}
fn cookies(&self, uri: &Uri) -> Cookies {
let Some(request_url) = uri_to_url(uri) else {
return Cookies::Empty;
};
let maybe_header = self.cookie_header_for_url(&request_url);
match maybe_header {
Some(header) => match HeaderValue::from_str(&header) {
Ok(value) => Cookies::Compressed(value),
Err(err) => {
warn!("Stored cookies for {} do not form a valid header: {:?}", request_url, err);
Cookies::Empty
},
},
None => Cookies::Empty,
}
}
}
fn uri_to_url(uri: &Uri) -> Option<Url> {
Url::parse(&uri.to_string()).ok()
}