mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
backport midjourney fixes from ArtCraft-X (#1953)
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user