aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorA Farzat <a@farzat.xyz>2026-02-10 18:58:29 +0300
committerA Farzat <a@farzat.xyz>2026-02-10 18:58:29 +0300
commit2bf7a5ea9aa6eb5797ba224fcc2002425bc2d947 (patch)
treead3f180565fed5d3ef561ed367d7fd4a7ea98517
parent639ddf4b2e88bdc95a9e09eadea1be606327dea5 (diff)
downloadsafaribooks-rs-2bf7a5ea9aa6eb5797ba224fcc2002425bc2d947.tar.gz
safaribooks-rs-2bf7a5ea9aa6eb5797ba224fcc2002425bc2d947.zip
Add a cookies module
This parses the cookies found in the cookies.json file, making them ready to be used in http requests.
-rw-r--r--Cargo.lock64
-rw-r--r--Cargo.toml3
-rw-r--r--src/cookies.rs138
-rw-r--r--src/main.rs23
4 files changed, 225 insertions, 3 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 110440a..7465fb4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -62,6 +62,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -135,6 +141,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -227,13 +239,59 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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<String, String>),
+ List(Vec<CookieEntry>),
+}
+
+/// 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<String, String>,
+}
+
+impl CookieStore {
+ /// Create a CookieStore from a serde_json::Value (already parsed).
+ fn from_value(v: Value) -> anyhow::Result<Self> {
+ // 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<Self> {
+ 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<String> {
+ 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::<Vec<_>>()
+ .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));