use std::collections::HashMap;
use std::path::Path;
use tail_fin_common::TailFinError;
#[derive(Debug, Clone, Default)]
pub struct Cookies {
inner: HashMap<String, String>,
}
impl Cookies {
pub fn get(&self, name: &str) -> Option<&str> {
self.inner.get(name).map(String::as_str)
}
pub fn require(&self, name: &'static str) -> Result<&str, TailFinError> {
self.get(name).ok_or_else(|| {
TailFinError::Api(format!(
"missing cookie `{name}` — re-export from a logged-in browser"
))
})
}
pub fn to_header(&self) -> String {
let mut out = String::new();
for (k, v) in &self.inner {
if !out.is_empty() {
out.push_str("; ");
}
out.push_str(k);
out.push('=');
out.push_str(v);
}
out
}
}
pub fn load_netscape(path: &Path) -> Result<Cookies, TailFinError> {
let text = std::fs::read_to_string(path)
.map_err(|e| TailFinError::Io(format!("read cookies {}: {e}", path.display())))?;
Ok(parse_netscape(&text))
}
pub(crate) fn parse_netscape(text: &str) -> Cookies {
let mut map = HashMap::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.splitn(7, '\t').collect();
if parts.len() < 7 {
continue;
}
let name = parts[5].trim();
let value = parts[6].trim();
if name.is_empty() || value.is_empty() {
continue;
}
if !is_safe_cookie_name(name) || !is_safe_cookie_value(value) {
continue;
}
map.insert(name.to_string(), value.to_string());
}
Cookies { inner: map }
}
fn is_safe_cookie_name(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b > 0x20 && b < 0x7f && !matches!(b, b'=' | b';' | b',' | b'"'))
}
fn is_safe_cookie_value(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b >= 0x20 && b != 0x7f && !matches!(b, b'\r' | b'\n' | b';' | b','))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_name_value_only() {
let text = "# Netscape HTTP Cookie File\n\
.google.com\tTRUE\t/\tTRUE\t1791857366\tSAPISID\tabc123\n\
.google.com\tTRUE\t/\tTRUE\t1791857366\t__Secure-1PSID\txyz789\n";
let jar = parse_netscape(text);
assert_eq!(jar.get("SAPISID"), Some("abc123"));
assert_eq!(jar.get("__Secure-1PSID"), Some("xyz789"));
}
#[test]
fn ignores_comments_and_blanks() {
let text = "# comment\n\n.google.com\tTRUE\t/\tTRUE\t0\tFOO\tbar\n";
let jar = parse_netscape(text);
assert_eq!(jar.get("FOO"), Some("bar"));
}
#[test]
fn require_returns_error_for_missing_cookie() {
let jar = Cookies::default();
let err = jar.require("SAPISID").unwrap_err();
assert!(matches!(err, TailFinError::Api(_)));
}
#[test]
fn to_header_contains_all_cookies() {
let text = ".google.com\tTRUE\t/\tTRUE\t0\tA\t1\n\
.google.com\tTRUE\t/\tTRUE\t0\tB\t2\n";
let jar = parse_netscape(text);
let h = jar.to_header();
assert!(h.contains("A=1"));
assert!(h.contains("B=2"));
assert!(h.contains("; "));
}
#[test]
fn parse_prevents_crlf_header_injection() {
let text = ".google.com\tTRUE\t/\tTRUE\t0\tEVIL\tva\r\nX-Admin: 1\n";
let jar = parse_netscape(text);
let h = jar.to_header();
assert!(!h.contains("X-Admin"), "CRLF injection reached header: {h}");
assert!(!h.contains('\r'));
assert!(!h.contains('\n'));
}
#[test]
fn parse_rejects_semicolon_in_value() {
let text = ".google.com\tTRUE\t/\tTRUE\t0\tX\tok;Injected=1\n";
let jar = parse_netscape(text);
assert!(jar.get("X").is_none());
}
#[test]
fn parse_rejects_ctl_char_in_name() {
let text = ".google.com\tTRUE\t/\tTRUE\t0\tBAD\x00NAME\tv\n";
let jar = parse_netscape(text);
assert!(jar.inner.is_empty());
}
#[test]
fn parse_accepts_real_google_sapisid_shape() {
let text = ".google.com\tTRUE\t/\tTRUE\t0\tSAPISID\t-HpoaYO3mycRxkTC/AyF1lmv0QabYdkqC3\n";
let jar = parse_netscape(text);
assert_eq!(
jar.get("SAPISID"),
Some("-HpoaYO3mycRxkTC/AyF1lmv0QabYdkqC3")
);
}
}