unjar 0.1.0

Read and export cookies from local browser profiles. CLI and Rust library.
Documentation
use serde::{Deserialize, Serialize};

/// A single cookie extracted from a browser store.
///
/// `expires` is a unix timestamp in seconds; `0` marks a session cookie.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cookie {
  /// Host or domain scope, with a leading dot for domain cookies.
  pub domain: String,
  /// Cookie name.
  pub name: String,
  /// Cookie value.
  pub value: String,
  /// URL path scope.
  pub path: String,
  /// Unix expiration timestamp in seconds, or `0` for a session cookie.
  pub expires: i64,
  /// Whether the cookie is restricted to secure connections.
  pub secure: bool,
  /// Whether scripts are prevented from reading the cookie.
  pub http_only: bool,
}

impl Cookie {
  /// Return whether this is a session cookie.
  pub fn is_session(&self) -> bool {
    self.expires == 0
  }

  /// Whether the cookie applies to subdomains (leading-dot domain).
  fn include_subdomains(&self) -> bool {
    self.domain.starts_with('.')
  }

  /// Whether the stored cookie domain matches `host` per RFC 6265.
  fn matches(&self, host: &str) -> bool {
    let host = host.as_bytes();
    let domain = self.domain.trim_start_matches('.').as_bytes();
    if host.eq_ignore_ascii_case(domain) {
      return true;
    }

    self.include_subdomains()
      && host.len() > domain.len()
      && host[host.len() - domain.len()..].eq_ignore_ascii_case(domain)
      && host[host.len() - domain.len() - 1] == b'.'
  }
}

/// A collection of cookies with export helpers.
#[derive(Debug, Default, Clone)]
pub struct CookieJar {
  cookies: Vec<Cookie>,
}

impl CookieJar {
  pub(crate) fn new(mut cookies: Vec<Cookie>) -> Self {
    cookies.sort_by(|a, b| {
      a.domain
        .trim_start_matches('.')
        .cmp(b.domain.trim_start_matches('.'))
        .then_with(|| a.domain.cmp(&b.domain))
        .then_with(|| a.name.cmp(&b.name))
        .then_with(|| a.path.cmp(&b.path))
    });
    Self { cookies }
  }

  /// Return the number of cookies in the jar.
  pub fn len(&self) -> usize {
    self.cookies.len()
  }

  /// Return whether the jar contains no cookies.
  pub fn is_empty(&self) -> bool {
    self.cookies.is_empty()
  }

  /// Iterate over the cookies in deterministic order.
  pub fn iter(&self) -> impl Iterator<Item = &Cookie> {
    self.cookies.iter()
  }

  /// Keep cookies whose stored domain matches `host`.
  ///
  /// This does not evaluate URL path, scheme, expiration, or other request attributes.
  pub fn domain(&self, host: &str) -> Self {
    self.domains(&[host])
  }

  /// Keep cookies whose stored domain matches any of `hosts`.
  ///
  /// This does not evaluate URL path, scheme, expiration, or other request attributes.
  pub fn domains(&self, hosts: &[&str]) -> Self {
    let hosts: Vec<_> = hosts.iter().map(|host| host.trim_start_matches('.')).collect();
    Self {
      cookies: self
        .cookies
        .iter()
        .filter(|cookie| hosts.iter().any(|host| cookie.matches(host)))
        .cloned()
        .collect(),
    }
  }

  /// Serialize as a pretty JSON array.
  pub fn to_json(&self) -> String {
    serde_json::to_string_pretty(&self.cookies).expect("serializing cookies cannot fail")
  }

  /// Serialize into Netscape `cookies.txt` format (curl, wget, yt-dlp).
  pub fn to_netscape(&self) -> String {
    let mut out = String::from("# Netscape HTTP Cookie File\n");
    out.push_str("# https://curl.se/docs/http-cookies.html\n");
    out.push_str("# This file was generated by unjar. Edit at your own risk.\n\n");

    for c in &self.cookies {
      let sub = if c.include_subdomains() { "TRUE" } else { "FALSE" };
      let secure = if c.secure { "TRUE" } else { "FALSE" };
      let http_only = if c.http_only { "#HttpOnly_" } else { "" };
      out.push_str(&format!(
        "{}{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
        http_only, c.domain, sub, c.path, secure, c.expires, c.name, c.value
      ));
    }

    out
  }

  /// Serialize as a `Cookie:` header value (`k=v; k2=v2`).
  pub fn to_header(&self) -> String {
    self.cookies.iter().map(|c| format!("{}={}", c.name, c.value)).collect::<Vec<_>>().join("; ")
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  fn cookie(domain: &str, name: &str, value: &str) -> Cookie {
    Cookie {
      domain: domain.into(),
      name: name.into(),
      value: value.into(),
      path: "/".into(),
      expires: 0,
      secure: true,
      http_only: false,
    }
  }

  fn sample() -> CookieJar {
    CookieJar::new(vec![
      cookie(".x.com", "auth_token", "aaa"), // domain cookie: x.com + subdomains
      cookie("x.com", "g_state", "ggg"),     // host-only: x.com exactly
      cookie("api.x.com", "ct0", "bbb"),     // host-only: api.x.com exactly
      cookie("example.com", "sid", "ccc"),
    ])
  }

  #[test]
  fn domain_cookie_matches_host_and_subdomains() {
    // querying api.x.com gets the .x.com domain cookie plus its own host-only one,
    // but never the host-only cookie scoped to the parent x.com.
    let jar = sample().domain("api.x.com");
    let names: Vec<_> = jar.iter().map(|c| c.name.as_str()).collect();
    assert_eq!(names, ["ct0", "auth_token"]);
  }

  #[test]
  fn domain_matching_is_case_insensitive() {
    let jar = sample().domain("API.X.COM");
    let names: Vec<_> = jar.iter().map(|c| c.name.as_str()).collect();
    assert_eq!(names, ["ct0", "auth_token"]);
  }

  #[test]
  fn host_only_cookie_does_not_leak_across_hosts() {
    // querying x.com must not pull in the api.x.com child-host cookie (ct0).
    let jar = sample().domain("x.com");
    let names: Vec<_> = jar.iter().map(|c| c.name.as_str()).collect();
    assert_eq!(names, ["auth_token", "g_state"]);
  }

  #[test]
  fn multiple_domains_are_combined_without_duplicates() {
    let jar = sample().domains(&["x.com", "api.x.com"]);
    let names: Vec<_> = jar.iter().map(|c| c.name.as_str()).collect();
    assert_eq!(names, ["ct0", "auth_token", "g_state"]);
  }

  #[test]
  fn header_joins_name_value_pairs() {
    let jar = sample().domain("x.com");
    assert_eq!(jar.to_header(), "auth_token=aaa; g_state=ggg");
  }

  #[test]
  fn netscape_has_header_and_tab_rows() {
    let out = sample().domain("example.com").to_netscape();
    assert!(out.starts_with("# Netscape HTTP Cookie File"));
    assert!(out.contains("example.com\tFALSE\t/\tTRUE\t0\tsid\tccc"));
  }

  #[test]
  fn netscape_preserves_http_only() {
    let mut cookie = cookie(".example.com", "sid", "ccc");
    cookie.http_only = true;
    let out = CookieJar::new(vec![cookie]).to_netscape();
    assert!(out.contains("#HttpOnly_.example.com\tTRUE\t/\tTRUE\t0\tsid\tccc"));
  }

  #[test]
  fn json_round_trips() {
    let jar = sample();
    let parsed: Vec<Cookie> = serde_json::from_str(&jar.to_json()).unwrap();
    assert_eq!(parsed.len(), 4);
  }
}