tail-fin-gemini 0.6.5

Gemini (Google) adapter for tail-fin: cookie-authenticated HTTP client — text + file attach + multi-turn; no browser
Documentation
use std::collections::HashMap;
use std::path::Path;

use tail_fin_common::TailFinError;

/// Minimal cookie store — just name→value for the cookies Gemini needs.
#[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"
            ))
        })
    }

    /// Build a `Cookie:` header value containing every stored cookie.
    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
    }
}

/// Parse a Netscape-format cookie file (`# HTTP Cookie File` header
/// followed by tab-separated rows: `domain  flag  path  secure  expiry
/// name  value`). Only the name/value columns are kept.
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;
        }
        // Refuse anything that could inject a new HTTP header when the
        // cookie lands in a `Cookie:` header. `Set-Cookie` values don't
        // permit CTLs either, so a well-formed file can't trip this.
        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 {
    // Cookie names are RFC 6265 tokens — no CTLs, no separators.
    !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 {
    // Cookie values tolerate most printable ASCII; what we MUST reject
    // is anything that would let an attacker terminate the Cookie
    // header and inject another one (CR, LF) or terminate a cookie
    // pair within the header (; or , when we'd glue them).
    !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() {
        // A malicious cookie file trying to smuggle a second header
        // must not end up adding `X-Admin: 1` to the Cookie output.
        // (Between `.lines()` stripping the CRLF and our value-safety
        // check, this defends in depth.)
        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() {
        // Semicolons would terminate the cookie pair inside the Cookie
        // header and let a value masquerade as another cookie.
        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() {
        // Regression: real SAPISID values contain `/` and `-`; must NOT
        // be rejected as injection.
        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")
        );
    }
}