From 2bf7a5ea9aa6eb5797ba224fcc2002425bc2d947 Mon Sep 17 00:00:00 2001 From: A Farzat Date: Tue, 10 Feb 2026 18:58:29 +0300 Subject: Add a cookies module This parses the cookies found in the cookies.json file, making them ready to be used in http requests. --- Cargo.lock | 64 ++++++++++++++++++++++++++ Cargo.toml | 3 ++ src/cookies.rs | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 23 ++++++++-- 4 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 src/cookies.rs diff --git a/Cargo.lock b/Cargo.lock index 110440a..7465fb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[package]] name = "cfg-if" version = "1.0.4" @@ -134,6 +140,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "lazy_static" version = "1.5.0" @@ -227,12 +239,58 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" name = "safaribooks-rs" version = "0.1.0" dependencies = [ + "anyhow", "clap", "colored", + "serde", + "serde_json", "tracing", "tracing-subscriber", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -367,3 +425,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml index dca5568..447fc70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] +anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } colored = "3.1.1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter", "fmt"] } diff --git a/src/cookies.rs b/src/cookies.rs new file mode 100644 index 0000000..4e33dcc --- /dev/null +++ b/src/cookies.rs @@ -0,0 +1,138 @@ +use serde::Deserialize; +use serde_json::Value; +use std::{collections::HashMap, fs, path::Path}; + +/// One cookie entry; domain/path could be added later if needed. +#[derive(Debug, Clone, Deserialize)] +pub struct CookieEntry { + pub name: String, + pub value: String, +} + +/// The input JSON can be either a map or a list of cookie entries. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum CookiesJson { + Map(HashMap), + List(Vec), +} + +/// Normalized cookie store (name -> value). We keep it simple for now. +/// If later we need domain/path scoping, we can extend this type. +#[derive(Debug, Clone)] +pub struct CookieStore { + map: HashMap, +} + +impl CookieStore { + /// Create a CookieStore from a serde_json::Value (already parsed). + fn from_value(v: Value) -> anyhow::Result { + // Try to deserialize into either a map or a list. + let cj: CookiesJson = serde_json::from_value(v)?; + let mut map = HashMap::new(); + + match cj { + CookiesJson::Map(m) => { + // Direct mapping: { "name": "value", ... } + map.extend(m); + } + CookiesJson::List(list) => { + // Keep last occurrence on duplicates. + for e in list { + map.insert(e.name, e.value); + } + } + } + + Ok(Self { map }) + } + + /// Load cookies from a file path. + pub fn load_from(path: &Path) -> anyhow::Result { + let raw = fs::read_to_string(path)?; + let v: Value = serde_json::from_str(&raw)?; + Self::from_value(v) + } + + /// Number of cookies. + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// Return a sorted list of cookie names (safe to log). + pub fn cookie_names(&self) -> Vec { + let mut names: Vec<_> = self.map.keys().cloned().collect(); + names.sort(); + names + } + + /// Render the `Cookie` header value, e.g.: "a=1; b=2". + /// Deterministic order (by name) to help testing and reproducibility. + pub fn to_header_value(&self) -> String { + let mut pairs: Vec<_> = self.map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("; ") + } +} + +#[cfg(test)] +mod tests { + use super::CookieStore; + use serde_json::json; + + #[test] + fn loads_from_map() { + let v = json!({ + "sess": "abc", + "OptanonConsent": "xyz" + }); + let store = CookieStore::from_value(v).unwrap(); + assert_eq!(store.len(), 2); + let names = store.cookie_names(); + assert_eq!( + names, + vec!["OptanonConsent".to_string(), "sess".to_string()] + ); + let header = store.to_header_value(); + assert_eq!(header, "OptanonConsent=xyz; sess=abc"); + } + + #[test] + fn loads_from_list() { + let v = json!([ + { "name": "sess", "value": "abc" }, + { "name": "OptanonConsent", "value": "xyz", "domain": "learning.oreilly.com" } + ]); + let store = CookieStore::from_value(v).unwrap(); + assert_eq!(store.len(), 2); + assert_eq!(store.cookie_names(), vec!["OptanonConsent", "sess"]); + assert_eq!(store.to_header_value(), "OptanonConsent=xyz; sess=abc"); + } + + #[test] + fn duplicate_names_keep_last() { + let v = json!([ + { "name": "sess", "value": "OLD" }, + { "name": "sess", "value": "NEW" } + ]); + let store = CookieStore::from_value(v).unwrap(); + assert_eq!(store.len(), 1); + assert_eq!(store.to_header_value(), "sess=NEW"); + } + + #[test] + fn invalid_json_fails() { + let v = serde_json::Value::String("not-json-shape".to_string()); + let err = CookieStore::from_value(v).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.to_lowercase().contains("did not match any variant")); + } +} diff --git a/src/main.rs b/src/main.rs index ad33122..e582af9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ mod cli; mod config; +mod cookies; mod display; use clap::Parser; use cli::Args; +use cookies::CookieStore; use display::Display; fn main() { @@ -11,15 +13,30 @@ fn main() { let mut ui = Display::new(&args.bookid); - let cookies = config::cookies_file(); - if !cookies.exists() { + let cookies_path = config::cookies_file(); + if !cookies_path.exists() { ui.error_and_exit( "cookies.json not found.\n\ This version requires an existing authenticated session.", ); } - ui.info(&format!("Using cookies file: {}", cookies.display())); + // Load cookies + let store = match CookieStore::load_from(&cookies_path) { + Ok(c) => c, + Err(e) => ui.error_and_exit(&format!("Failed to read cookies.json: {e}")), + }; + + if store.is_empty() { + ui.error_and_exit("cookies.json is valid JSON but contains no cookies."); + } + + let names = store.cookie_names(); + ui.info(&format!( + "Loaded {} cookies: {}", + store.len(), + names.join(", ") + )); let output_dir = config::books_root().join(format!("(pending) ({})", args.bookid)); -- cgit v1.2.3-70-g09d2