feat: cookie jar persistence + --storage-dir flag + fix HTTP cookie header filtering (#114)
* feat: cookie jar persistence + storage-dir + filtering fix (clean v4)
* fix: resolve build errors, add storage_dir param throughout server chain
* fix: storage_dir end-to-end, plus two test-literal field additions
Five issues found while testing PR 114 end-to-end.
Build fixes (two test literals out of sync with new struct fields):
- crates/obscura-net/src/cookies.rs: CookieInfo literal in
test_domain_matches_subdomain_without_leading_dot was missing the
same_site and expires fields added in #170.
- crates/obscura-cli/src/main.rs: Command::Fetch literal in
matcher_still_uses_fetch_variant was missing the new storage_dir
field. Without these cargo test --workspace would not compile.
run_fetch was branching between BrowserContext::with_storage (which
hardcodes proxy=None, stealth=false, user_agent=None) and
with_options (which has no storage_dir slot), so --storage-dir +
--proxy + --stealth silently dropped the proxy and stealth flags.
Same bug SGavrl flagged on dispatch.rs in the previous round, just on
the other code path. Switched to with_storage_full which carries all
four. Verified the fetch now errors out against an invalid proxy.
run_fetch --eval short-circuited the function before
context.save_cookies() ran, so cookies set during an eval session
were never persisted to {storage_dir}/cookies.json. Moved the save
above the return.
obscura serve loaded cookies on startup but never saved them
anywhere, so a session that set cookies via Network.setCookie or
navigation dropped them on shutdown. Subscribed to Ctrl-C once in
put a single save_cookies() after the loop so both the Ctrl-C path
and the channel-closed path persist cookies. Verified end-to-end.
---------
Co-authored-by: zhangyang-crazy-one <[email protected]>
Co-authored-by: SGavrl <[email protected]>
This commit is contained in:
8 files changed
+343
-39
No files matched your search
@@ -8,7 +8,7 @@ pub struct CookieJar {
|
||||
cookies: RwLock<HashMap<String, HashMap<String, CookieEntry>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct CookieEntry {
|
||||
name: String,
|
||||
value: String,
|
||||
@@ -372,6 +372,69 @@ impl CookieJar {
|
||||
pub fn clear(&self) {
|
||||
self.cookies.write().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Serialize all non-expired cookies to a JSON file.
|
||||
/// Writes atomically via tempfile then rename.
|
||||
pub fn save_to_file(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
|
||||
use std::io::Write;
|
||||
|
||||
let cookies = self.cookies.read().unwrap();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let mut all: Vec<CookieInfo> = Vec::new();
|
||||
for domain_cookies in cookies.values() {
|
||||
for entry in domain_cookies.values() {
|
||||
if let Some(exp) = entry.expires {
|
||||
if exp < now {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
all.push(CookieInfo {
|
||||
name: entry.name.clone(),
|
||||
value: entry.value.clone(),
|
||||
domain: entry.domain.clone(),
|
||||
path: entry.path.clone(),
|
||||
secure: entry.secure,
|
||||
http_only: entry.http_only,
|
||||
same_site: entry.same_site.clone(),
|
||||
expires: entry.expires.map(|e| e as i64),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&all).map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
||||
})?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(
|
||||
path.parent().unwrap_or(std::path::Path::new(".")),
|
||||
)?;
|
||||
tmp.write_all(json.as_bytes())?;
|
||||
tmp.persist(path).map_err(|e| e.error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load cookies from a JSON file into the jar.
|
||||
/// Merges with existing cookies (does not clear).
|
||||
/// Returns the number of cookies loaded.
|
||||
pub fn load_from_file(&self, path: &std::path::Path) -> Result<usize, std::io::Error> {
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let cookies: Vec<CookieInfo> =
|
||||
serde_json::from_str(&data).map_err(|e| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
||||
})?;
|
||||
let count = cookies.len();
|
||||
self.set_cookies_from_cdp(cookies);
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CookieJar {
|
||||
@@ -649,4 +712,75 @@ mod tests {
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
assert!(jar.get_cookie_header(&url).is_empty());
|
||||
}
|
||||
fn test_save_load_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("cookies.json");
|
||||
|
||||
let jar = CookieJar::new();
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
jar.set_cookie("session=abc123; Domain=example.com; Path=/", &url);
|
||||
jar.set_cookie("token=xyz; Secure; HttpOnly", &url);
|
||||
|
||||
jar.save_to_file(&path).unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
let jar2 = CookieJar::new();
|
||||
let count = jar2.load_from_file(&path).unwrap();
|
||||
assert_eq!(count, 2);
|
||||
|
||||
let header = jar2.get_cookie_header(&url);
|
||||
assert!(header.contains("session=abc123"));
|
||||
assert!(header.contains("token=xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent_file_returns_zero() {
|
||||
let jar = CookieJar::new();
|
||||
let count = jar
|
||||
.load_from_file(std::path::Path::new("/nonexistent/cookies.json"))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_matches_subdomain_without_leading_dot() {
|
||||
let jar = CookieJar::new();
|
||||
jar.set_cookies_from_cdp(vec![CookieInfo {
|
||||
name: "session".to_string(),
|
||||
value: "abc".to_string(),
|
||||
domain: "xiaohongshu.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
secure: false,
|
||||
http_only: true,
|
||||
same_site: String::new(),
|
||||
expires: None,
|
||||
}]);
|
||||
let url = Url::parse("https://www.xiaohongshu.com/explore").unwrap();
|
||||
let header = jar.get_cookie_header(&url);
|
||||
assert!(header.contains("session=abc"), "Cookie header was: '{}'", header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookie_from_file_load_then_send_in_request() {
|
||||
// Simulate what happens: load cookies from file → navigate → cookie should be in request
|
||||
use std::io::Write;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("cookies.json");
|
||||
|
||||
// Write cookies like we exported from Chrome
|
||||
let cookies = serde_json::json!([
|
||||
{"name": "a1", "value": "testval", "domain": "xiaohongshu.com", "path": "/", "secure": false, "httpOnly": false},
|
||||
{"name": "web_session", "value": "sess123", "domain": "xiaohongshu.com", "path": "/", "secure": false, "httpOnly": true},
|
||||
]);
|
||||
std::fs::write(&path, serde_json::to_string(&cookies).unwrap()).unwrap();
|
||||
|
||||
let jar = CookieJar::new();
|
||||
let count = jar.load_from_file(&path).unwrap();
|
||||
assert_eq!(count, 2, "Should load 2 cookies");
|
||||
|
||||
let url = Url::parse("https://www.xiaohongshu.com/explore").unwrap();
|
||||
let header = jar.get_cookie_header(&url);
|
||||
assert!(header.contains("a1=testval"), "Missing a1 in: '{}'", header);
|
||||
assert!(header.contains("web_session=sess123"), "Missing web_session in: '{}'", header);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user