feat(fetch): write final cookies to a jar

This commit is contained in:
ldm0
2026-08-23 20:05:57 +08:00
parent 3d201df523
commit fc251afff6
8 changed files with 263 additions and 8 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ mod cache;
mod netscape;
pub use cache::{load_cookie_cache, save_cookie_cache};
pub use netscape::load_cookie_file;
pub use netscape::{CookieFileSaveReport, load_cookie_file, save_cookie_file};
#[cfg(test)]
mod tests;
+85 -1
View File
@@ -1,9 +1,21 @@
use std::{fs::File, io::BufReader, path::Path};
use std::{fmt::Write as _, fs::File, io::BufReader, path::Path};
use anyhow::{Context, Result};
use moli_cookie_jar::{StoredCookie, StoredCookieSameSite, StoredCookieSourceScheme};
use time::OffsetDateTime;
use crate::atomic_file::write_file_atomically;
const NETSCAPE_COOKIE_FILE_HEADER: &str = "# Netscape HTTP Cookie File\n\
# This file was generated by Moli. Do not edit.\n\n";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CookieFileSaveReport {
pub written: usize,
pub skipped_expired: usize,
pub skipped_partitioned: usize,
}
pub fn load_cookie_file(path: impl AsRef<Path>) -> Result<Vec<StoredCookie>> {
let path = path.as_ref();
let file = File::open(path)
@@ -12,6 +24,78 @@ pub fn load_cookie_file(path: impl AsRef<Path>) -> Result<Vec<StoredCookie>> {
.with_context(|| format!("failed to parse cookie file `{}`", path.display()))
}
/// Saves unexpired, unpartitioned cookies in the interoperable Netscape format.
///
/// Netscape cookie files cannot represent partition keys, so partitioned
/// cookies are deliberately omitted instead of broadening their scope on a
/// later import. The returned report describes written and omitted records.
pub fn save_cookie_file(
path: impl AsRef<Path>,
cookies: impl IntoIterator<Item = StoredCookie>,
) -> Result<CookieFileSaveReport> {
let path = path.as_ref();
let mut output = NETSCAPE_COOKIE_FILE_HEADER.to_owned();
let mut report = CookieFileSaveReport::default();
for cookie in cookies {
if cookie.is_expired() {
report.skipped_expired += 1;
continue;
}
if cookie.partition_key.is_some() {
report.skipped_partitioned += 1;
continue;
}
append_netscape_cookie(&mut output, &cookie)?;
report.written += 1;
}
write_file_atomically(path, output.as_bytes(), "cookie jar")?;
Ok(report)
}
fn append_netscape_cookie(output: &mut String, cookie: &StoredCookie) -> Result<()> {
let domain = cookie.domain.trim_start_matches('.');
anyhow::ensure!(!domain.is_empty(), "cookie domain is empty");
anyhow::ensure!(!cookie.name.is_empty(), "cookie name is empty");
anyhow::ensure!(
cookie.path.starts_with('/'),
"cookie path must start with `/`"
);
for (field, value) in [
("domain", domain),
("path", cookie.path.as_str()),
("name", cookie.name.as_str()),
("value", cookie.value.as_str()),
] {
anyhow::ensure!(
!value.contains(['\t', '\r', '\n']),
"cookie {field} cannot be represented in a Netscape cookie file"
);
}
if cookie.http_only {
output.push_str("#HttpOnly_");
}
if !cookie.host_only {
output.push('.');
}
output.push_str(domain);
let tail_match = if cookie.host_only { "FALSE" } else { "TRUE" };
let secure = if cookie.secure { "TRUE" } else { "FALSE" };
let expires = cookie
.expires
.map(|expires| expires.unix_timestamp())
.unwrap_or(0);
writeln!(
output,
"\t{tail_match}\t{}\t{secure}\t{expires}\t{}\t{}",
cookie.path, cookie.name, cookie.value
)
.expect("writing to a String cannot fail");
Ok(())
}
pub(crate) fn parse_netscape_cookie_file(
reader: impl std::io::BufRead,
) -> Result<Vec<StoredCookie>> {
+81
View File
@@ -187,6 +187,87 @@ fn netscape_cookie_parser_imports_httponly_tailmatch_cookie() -> Result<()> {
Ok(())
}
#[test]
fn netscape_cookie_writer_round_trips_representable_cookies() -> Result<()> {
let temp = TempDir::new("netscape-write-roundtrip");
let target = temp.path.join("cookies.txt");
let mut domain_cookie = stored_cookie("session", "fixture");
domain_cookie.host_only = false;
domain_cookie.path = "/account/".to_owned();
domain_cookie.secure = true;
domain_cookie.http_only = true;
domain_cookie.expires = Some(OffsetDateTime::now_utc() + time::Duration::days(1));
let host_cookie = stored_cookie("host", "session value");
let report = save_cookie_file(&target, vec![domain_cookie, host_cookie])?;
assert_eq!(report.written, 2);
assert_eq!(report.skipped_expired, 0);
assert_eq!(report.skipped_partitioned, 0);
let contents = fs::read_to_string(&target)?;
assert!(contents.starts_with("# Netscape HTTP Cookie File\n"));
assert!(contents.contains("#HttpOnly_.example.com\tTRUE\t/account/\tTRUE\t"));
let loaded = load_cookie_file(&target)?;
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].domain, "example.com");
assert!(!loaded[0].host_only);
assert_eq!(loaded[0].path, "/account/");
assert!(loaded[0].secure);
assert!(loaded[0].http_only);
assert_eq!(loaded[0].name, "session");
assert_eq!(loaded[0].value, "fixture");
assert!(loaded[0].expires.is_some());
assert!(loaded[1].host_only);
assert_eq!(loaded[1].name, "host");
assert_eq!(loaded[1].value, "session value");
assert_eq!(loaded[1].expires, None);
Ok(())
}
#[test]
fn netscape_cookie_writer_omits_expired_and_partitioned_cookies() -> Result<()> {
let temp = TempDir::new("netscape-write-filter");
let target = temp.path.join("cookies.txt");
let mut expired = stored_cookie("expired", "gone");
expired.expires = Some(OffsetDateTime::now_utc() - time::Duration::days(1));
let mut partitioned = stored_cookie("partitioned", "scoped");
partitioned.partition_key = Some(StoredCookiePartitionKey::site(
"https://top.example".to_owned(),
false,
));
let report = save_cookie_file(
&target,
vec![expired, partitioned, stored_cookie("plain", "kept")],
)?;
assert_eq!(report.written, 1);
assert_eq!(report.skipped_expired, 1);
assert_eq!(report.skipped_partitioned, 1);
let loaded = load_cookie_file(&target)?;
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "plain");
Ok(())
}
#[test]
fn netscape_cookie_writer_rejects_unrepresentable_fields() {
let temp = TempDir::new("netscape-write-invalid");
let target = temp.path.join("cookies.txt");
let cookie = stored_cookie("session", "line\tbreak");
let error = save_cookie_file(&target, vec![cookie])
.expect_err("tabs cannot be encoded in Netscape cookie records");
assert!(
error
.to_string()
.contains("cookie value cannot be represented"),
"error={error:#}"
);
assert!(!target.exists());
}
#[test]
fn netscape_cookie_parser_preserves_trailing_slash_path_scope() -> Result<()> {
let cookies = parse_netscape_cookie_file(std::io::Cursor::new(
+30 -6
View File
@@ -58,7 +58,7 @@ pub async fn run_cli_with_config<W: Write>(
let fetched_document = match fetch_result {
Ok(document) => document,
Err(error) => {
finalize_fetch_browser(browser);
finalize_fetch_browser_after_failure(browser, args.cookie_jar.as_deref());
return Err(with_fetch_context(error, &args.url));
}
};
@@ -67,7 +67,7 @@ pub async fn run_cli_with_config<W: Write>(
FetchedDocument::Page(page) => page,
FetchedDocument::Raw(raw_document) => {
if readiness.has_page_waits() || args.delay_ms > 0 {
finalize_fetch_browser(browser);
finalize_fetch_browser_after_failure(browser, args.cookie_jar.as_deref());
return Err(with_fetch_context(
anyhow!(
"raw non-HTML document fetch does not support page wait options"
@@ -83,7 +83,8 @@ pub async fn run_cli_with_config<W: Write>(
.context("failed to write raw fetch output")
.map_err(|error| with_fetch_context(error, &args.url))?;
let _ = stdout.flush();
finalize_fetch_browser(browser);
finalize_fetch_browser(browser, args.cookie_jar.as_deref())
.map_err(|error| with_fetch_context(error, &args.url))?;
return Ok(());
}
};
@@ -95,7 +96,7 @@ pub async fn run_cli_with_config<W: Write>(
"failed to close fetched page after readiness failure"
);
}
finalize_fetch_browser(browser);
finalize_fetch_browser_after_failure(browser, args.cookie_jar.as_deref());
return Err(with_fetch_context(error, &args.url));
}
@@ -118,7 +119,8 @@ pub async fn run_cli_with_config<W: Write>(
if let Err(error) = page.close_async().await {
tracing::warn!(error = %error, "failed to close fetched page before browser shutdown");
}
finalize_fetch_browser(browser);
finalize_fetch_browser(browser, args.cookie_jar.as_deref())
.map_err(|error| with_fetch_context(error, &args.url))?;
}
Commands::Serve(_) => {
if config.browser.fetch().obey_robots() {
@@ -235,12 +237,34 @@ fn load_cookie_state_cookies(config: &AppConfig) -> Result<Vec<moli_cookie_jar::
Ok(cookies)
}
fn finalize_fetch_browser(browser: Browser) {
fn finalize_fetch_browser_after_failure(browser: Browser, cookie_jar: Option<&str>) {
if let Err(error) = finalize_fetch_browser(browser, cookie_jar) {
tracing::warn!(
error = %error,
"failed to finalize cookie jar after fetch failure"
);
}
}
fn finalize_fetch_browser(browser: Browser, cookie_jar: Option<&str>) -> Result<()> {
let save_result = cookie_jar.map_or(Ok(()), |path| -> Result<()> {
let report = cookie_cache::save_browser_cookie_file(&browser, path)
.with_context(|| format!("failed to save cookie jar `{path}`"))?;
if report.skipped_partitioned > 0 {
tracing::warn!(
path,
skipped = report.skipped_partitioned,
"omitted partitioned cookies from Netscape cookie jar"
);
}
Ok(())
});
// Fetch is a one-shot CLI path, but the browser must still be dropped in an
// orderly way. Letting network threads survive until process exit can race
// OpenSSL global cleanup with libcurl transfers still in progress.
// Browser::drop owns profile cookie writeback when --profile-dir is set.
drop(browser);
save_result
}
#[cfg(test)]
+4
View File
@@ -61,6 +61,10 @@ pub struct FetchArgs {
#[arg(long, value_name = "TEXT")]
pub body: Option<String>,
/// Write final unpartitioned cookies to a Netscape cookie file.
#[arg(long, value_name = "PATH")]
pub cookie_jar: Option<String>,
#[arg(long)]
pub noscript: bool,
+10
View File
@@ -22,6 +22,16 @@ pub fn load_browser_cookie_file(browser: &Browser, path: impl AsRef<Path>) -> Re
browser.import_cookies(load_cookie_file(path)?)
}
pub fn save_browser_cookie_file(
browser: &Browser,
path: impl AsRef<Path>,
) -> Result<moli_cookie_cache::CookieFileSaveReport> {
let cookies = browser
.cookies()
.context("failed to snapshot browser cookies for cookie jar")?;
moli_cookie_cache::save_cookie_file(path, cookies)
}
pub fn save_browser_cookie_cache(browser: &Browser, path: impl AsRef<Path>) -> Result<()> {
save_cookie_cache(
path,
+7
View File
@@ -76,6 +76,7 @@ fn parses_explicit_fetch_command_with_compatibility_flags() {
dump: Some(DumpFormat::SemanticTree),
method: "GET".to_owned(),
body: None,
cookie_jar: None,
headers: vec![
RequestHeaderArg {
name: "X-Test".to_owned(),
@@ -416,6 +417,7 @@ fn infers_fetch_mode_from_bare_url() {
dump: None,
method: "GET".to_owned(),
body: None,
cookie_jar: None,
headers: vec![],
noscript: false,
with_base: false,
@@ -458,6 +460,7 @@ fn parses_bare_dump_with_explicit_fetch_command_and_defaults_to_html() {
dump: Some(DumpFormat::Html),
method: "GET".to_owned(),
body: None,
cookie_jar: None,
headers: vec![],
noscript: false,
with_base: false,
@@ -501,6 +504,7 @@ fn parses_header_flag_with_explicit_fetch_command() {
dump: None,
method: "GET".to_owned(),
body: None,
cookie_jar: None,
headers: vec![RequestHeaderArg {
name: "X-Test".to_owned(),
value: "one".to_owned(),
@@ -1187,6 +1191,8 @@ fn parses_fetch_network_policy_flags() {
"/tmp/browser-cookies.txt",
"--cookie-file",
"/tmp/extra-cookies.txt",
"--cookie-jar",
"/tmp/final-cookies.txt",
"--block-private-networks",
"--block-cidrs",
"198.18.0.0/15,203.0.113.0/24",
@@ -1220,6 +1226,7 @@ fn parses_fetch_network_policy_flags() {
"/tmp/extra-cookies.txt".to_owned()
]
);
assert_eq!(args.cookie_jar.as_deref(), Some("/tmp/final-cookies.txt"));
assert!(args.common.block_private_networks);
assert_eq!(
args.common.block_cidrs.as_deref(),
+45
View File
@@ -1225,6 +1225,51 @@ fn cli_cookie_file_imports_netscape_cookie_before_fetch() -> Result<()> {
Ok(())
}
#[test]
fn cli_cookie_jar_writes_set_cookie_for_later_import() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
let server = runtime.block_on(FixtureServer::spawn())?;
let url = server.url("/cookie");
let cookie_jar = unique_temp_file_path("cookie-jar-writeback", "cookies.txt")?;
let cookie_jar_arg = cookie_jar.to_string_lossy().into_owned();
let first = run_fetch_cli_with_dump_and_args(&url, "html", &["--cookie-jar", &cookie_jar_arg])?;
assert!(
first.status.success(),
"first moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&first.stdout),
String::from_utf8_lossy(&first.stderr)
);
let first_stdout = clean_output(&first.stdout);
assert!(
first_stdout.contains("<main>cookie=missing</main>"),
"stdout={first_stdout}"
);
let jar_contents = std::fs::read_to_string(&cookie_jar)?;
assert!(jar_contents.starts_with("# Netscape HTTP Cookie File\n"));
assert!(
jar_contents.contains("\tsession\tfixture\n"),
"cookie jar={jar_contents}"
);
let second =
run_fetch_cli_with_dump_and_args(&url, "html", &["--cookie-file", &cookie_jar_arg])?;
runtime.block_on(server.shutdown());
assert!(
second.status.success(),
"second moli fetch failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&second.stdout),
String::from_utf8_lossy(&second.stderr)
);
let second_stdout = clean_output(&second.stdout);
assert!(
second_stdout.contains("<main>cookie=seen</main>"),
"stdout={second_stdout}"
);
Ok(())
}
#[test]
fn cli_profile_dir_persists_cookies_after_successful_fetch() -> Result<()> {
let runtime = tokio::runtime::Runtime::new()?;