Skip to main content

vault_core/
session_import.rs

1//! Web-session capture parsers (Phase 24.5, step 3).
2//!
3//! One implementation, in `vault-core`: the app reaches it over IPC and the CLI
4//! calls it directly, the `pools.ts` shape, so a paste that imports one way in
5//! the window cannot import another way from the terminal.
6//!
7//! Inputs: DevTools **Copy as cURL** (bash, cmd and PowerShell), **HAR**,
8//! `Set-Cookie` lines, and a **Firefox `cookies.sqlite`**. Everything parsed is
9//! attacker-controlled text (a pasted file from a site you were logged into), so
10//! nothing is executed or fetched, lengths are bounded, and cookie values are
11//! checked against RFC 6265's cookie-octet set (S10).
12//!
13//! What a capture is *not*: a place for other credentials. A cURL or HAR paste
14//! carries an `Authorization` header and cookies for CDNs and trackers; S12 says
15//! only the chosen origin's survive and the caller is told what was dropped, by
16//! name and never by value.
17
18use serde::Serialize;
19use serde_json::Value;
20
21/// Hard cap on pasted input. A HAR of a long session is megabytes; anything past
22/// this is not a login capture.
23pub const MAX_INPUT: usize = 32 * 1024 * 1024;
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize)]
26pub struct Cookie {
27    pub name: String,
28    pub value: String,
29    pub domain: Option<String>,
30    pub path: Option<String>,
31    pub secure: bool,
32    pub http_only: bool,
33    /// Unix seconds. `0` is a session cookie.
34    pub expires: i64,
35    /// CHIPS. `cookies.txt` cannot express it, so that export refuses (S3).
36    pub partitioned: bool,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Serialize)]
40pub struct Capture {
41    /// `scheme://host[:port]` the capture was scoped to.
42    pub origin: Option<String>,
43    pub cookies: Vec<Cookie>,
44    /// Headers worth keeping (never `Cookie`, never `Authorization`).
45    pub headers: Vec<(String, String)>,
46    pub user_agent: Option<String>,
47    /// What was left out, by name only — see S12.
48    pub dropped: Vec<String>,
49}
50
51/// Headers that are either consumed elsewhere or are separate credentials.
52const SENSITIVE: [&str; 4] = [
53    "authorization",
54    "proxy-authorization",
55    "x-api-key",
56    "x-auth-token",
57];
58/// Transport noise that tells a replay nothing and bloats the stored recipe.
59const NOISE: [&str; 8] = [
60    "host",
61    "content-length",
62    "accept-encoding",
63    "connection",
64    "cookie",
65    "user-agent",
66    "priority",
67    "te",
68];
69
70fn origin_of(url: &str) -> Option<String> {
71    let (scheme, rest) = url.split_once("://")?;
72    if scheme != "http" && scheme != "https" {
73        return None;
74    }
75    let host = rest.split(['/', '?', '#']).next()?;
76    // userinfo never belongs in an origin
77    let host = host.rsplit('@').next()?;
78    if host.is_empty() {
79        return None;
80    }
81    Some(format!("{scheme}://{host}"))
82}
83
84fn host_of(origin: &str) -> &str {
85    let h = origin.split_once("://").map_or(origin, |(_, r)| r);
86    h.rsplit_once(':')
87        .filter(|(_, p)| p.chars().all(|c| c.is_ascii_digit()) && !h.ends_with(']'))
88        .map_or(h, |(h, _)| h)
89}
90
91/// RFC 6265 cookie-octet: no controls, space, `"`, `,`, `;` or `\`. A value
92/// wrapped in one pair of double quotes is allowed to contain the rest.
93fn valid_cookie_value(v: &str) -> bool {
94    let inner = v
95        .strip_prefix('"')
96        .and_then(|x| x.strip_suffix('"'))
97        .unwrap_or(v);
98    inner
99        .chars()
100        .all(|c| (c as u32) > 0x20 && (c as u32) < 0x7f && !matches!(c, '"' | ',' | ';' | '\\'))
101}
102
103fn cookie_pairs(header: &str, out: &mut Vec<Cookie>, dropped: &mut Vec<String>) {
104    for piece in header.split(';') {
105        let piece = piece.trim();
106        let Some((name, value)) = piece.split_once('=') else {
107            continue;
108        };
109        let (name, value) = (name.trim(), value.trim());
110        if name.is_empty() {
111            continue;
112        }
113        if !valid_cookie_value(value) {
114            dropped.push(format!("cookie {name} (illegal character in value)"));
115            continue;
116        }
117        upsert(
118            out,
119            Cookie {
120                name: name.to_string(),
121                value: value.to_string(),
122                ..Default::default()
123            },
124        );
125    }
126}
127
128/// Same name + domain + path replaces; a later capture of a cookie wins (S2).
129fn upsert(out: &mut Vec<Cookie>, c: Cookie) {
130    // An attribute-less cookie (from a request) and an attributed one of the same
131    // name (from a response) are the same cookie seen twice; two attributed ones
132    // with different domains or paths are genuinely different (S2).
133    let bare = |x: &Cookie| x.domain.is_none() && x.path.is_none();
134    if let Some(slot) = out.iter_mut().find(|x| {
135        x.name == c.name && ((x.domain == c.domain && x.path == c.path) || bare(x) || bare(&c))
136    }) {
137        *slot = c;
138    } else {
139        out.push(c);
140    }
141}
142
143fn keep_header(name: &str, value: &str, cap: &mut Capture) {
144    let lower = name.to_ascii_lowercase();
145    if lower == "user-agent" {
146        cap.user_agent = Some(value.to_string());
147    } else if SENSITIVE.contains(&lower.as_str()) {
148        let note = format!("{name} header");
149        if !cap.dropped.contains(&note) {
150            cap.dropped.push(note);
151        }
152    } else if !NOISE.contains(&lower.as_str()) && !lower.starts_with("sec-fetch-") {
153        cap.headers.push((name.to_string(), value.to_string()));
154    }
155}
156
157// ── Shell tokenising ─────────────────────────────────────────────────────────
158
159/// Splits a bash command line: `'...'`, `"..."` with backslash escapes, `$'...'`
160/// (ANSI-C), and `\`-newline continuations. `cmd` mode is un-caret-ed first.
161fn shell_split(s: &str) -> Vec<String> {
162    let c: Vec<char> = s.chars().collect();
163    let mut out = Vec::new();
164    let mut cur = String::new();
165    let mut have = false;
166    let mut i = 0;
167    while i < c.len() {
168        match c[i] {
169            '\\' if matches!(c.get(i + 1), Some('\n')) => i += 2,
170            '\\' if matches!(c.get(i + 1), Some('\r')) => i += 3,
171            '\\' => {
172                if let Some(n) = c.get(i + 1) {
173                    cur.push(*n);
174                    have = true;
175                }
176                i += 2;
177            }
178            '\'' => {
179                have = true;
180                i += 1;
181                while i < c.len() && c[i] != '\'' {
182                    cur.push(c[i]);
183                    i += 1;
184                }
185                i += 1;
186            }
187            '$' if c.get(i + 1) == Some(&'\'') => {
188                have = true;
189                i += 2;
190                while i < c.len() && c[i] != '\'' {
191                    if c[i] == '\\' && i + 1 < c.len() {
192                        i += 1;
193                        match c[i] {
194                            'n' => cur.push('\n'),
195                            'r' => cur.push('\r'),
196                            't' => cur.push('\t'),
197                            'u' | 'x' => {
198                                let width = if c[i] == 'u' { 4 } else { 2 };
199                                let hex: String = c[i + 1..].iter().take(width).collect();
200                                match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
201                                    Some(ch) => {
202                                        cur.push(ch);
203                                        i += hex.len();
204                                    }
205                                    None => cur.push(c[i]),
206                                }
207                            }
208                            other => cur.push(other),
209                        }
210                    } else {
211                        cur.push(c[i]);
212                    }
213                    i += 1;
214                }
215                i += 1;
216            }
217            '"' => {
218                have = true;
219                i += 1;
220                while i < c.len() && c[i] != '"' {
221                    if c[i] == '\\' && matches!(c.get(i + 1), Some('"' | '\\' | '$' | '`')) {
222                        i += 1;
223                    }
224                    cur.push(c[i]);
225                    i += 1;
226                }
227                i += 1;
228            }
229            ch if ch.is_whitespace() => {
230                if have {
231                    out.push(std::mem::take(&mut cur));
232                    have = false;
233                }
234                i += 1;
235            }
236            ch => {
237                cur.push(ch);
238                have = true;
239                i += 1;
240            }
241        }
242    }
243    if have {
244        out.push(cur);
245    }
246    out
247}
248
249/// Chrome's "Copy as cURL (cmd)" caret-escapes everything and uses `^"` quotes.
250fn uncaret(s: &str) -> String {
251    let joined = s.replace("^\r\n", "").replace("^\n", "");
252    let mut out = String::new();
253    let mut it = joined.chars().peekable();
254    while let Some(ch) = it.next() {
255        if ch == '^' {
256            if let Some(n) = it.next() {
257                out.push(n);
258            }
259        } else {
260            out.push(ch);
261        }
262    }
263    // cmd quoting escapes an inner quote as \" inside "..."
264    out
265}
266
267/// DevTools **Copy as cURL**, bash or cmd flavour.
268pub fn parse_curl(text: &str) -> Result<Capture, String> {
269    if text.len() > MAX_INPUT {
270        return Err("input is too large to be a login capture".into());
271    }
272    let text = text.trim();
273    let normalized = if text.contains("^\"") || text.contains("^\n") {
274        uncaret(text)
275    } else {
276        text.to_string()
277    };
278    let toks = shell_split(&normalized);
279    let mut it = toks.into_iter();
280    match it.next().as_deref() {
281        Some("curl") | Some("curl.exe") => {}
282        _ => return Err("not a curl command".into()),
283    }
284    let mut cap = Capture::default();
285    let mut url: Option<String> = None;
286    let mut body = false;
287    let mut args = it.peekable();
288    while let Some(a) = args.next() {
289        let take = |args: &mut std::iter::Peekable<std::vec::IntoIter<String>>| args.next();
290        match a.as_str() {
291            "-H" | "--header" => {
292                if let Some(h) = take(&mut args) {
293                    if let Some((k, v)) = h.split_once(':') {
294                        let (k, v) = (k.trim(), v.trim());
295                        if k.eq_ignore_ascii_case("cookie") {
296                            cookie_pairs(v, &mut cap.cookies, &mut cap.dropped);
297                        } else {
298                            keep_header(k, v, &mut cap);
299                        }
300                    }
301                }
302            }
303            "-b" | "--cookie" => {
304                if let Some(v) = take(&mut args) {
305                    if v.contains('=') {
306                        cookie_pairs(&v, &mut cap.cookies, &mut cap.dropped);
307                    } else {
308                        cap.dropped.push("cookie file reference (-b FILE)".into());
309                    }
310                }
311            }
312            "-A" | "--user-agent" => cap.user_agent = take(&mut args),
313            "-d" | "--data" | "--data-raw" | "--data-binary" | "--data-urlencode" | "-F"
314            | "--form" => {
315                let _ = take(&mut args);
316                body = true;
317            }
318            "-X" | "--request" | "-e" | "--referer" | "-o" | "--output" | "-m" | "--max-time"
319            | "--connect-timeout" | "-x" | "--proxy" | "-u" | "--user" => {
320                if matches!(a.as_str(), "-u" | "--user") {
321                    cap.dropped.push("basic-auth credentials (-u)".into());
322                }
323                let _ = take(&mut args);
324            }
325            "--url" => url = take(&mut args),
326            s if s.starts_with('-') => {} // --compressed, -s, -L, --insecure ...
327            s => {
328                if url.is_none() {
329                    url = Some(s.to_string());
330                }
331            }
332        }
333    }
334    if body {
335        cap.dropped.push("request body".into());
336    }
337    cap.origin = url.as_deref().and_then(origin_of);
338    if cap.origin.is_none() {
339        return Err("no http(s) URL in the curl command".into());
340    }
341    Ok(cap)
342}
343
344/// DevTools **Copy as PowerShell** (`Invoke-WebRequest`).
345pub fn parse_powershell(text: &str) -> Result<Capture, String> {
346    if !text.contains("Invoke-WebRequest") && !text.contains("Invoke-RestMethod") {
347        return Err("not a PowerShell Invoke-WebRequest".into());
348    }
349    let mut cap = Capture::default();
350    // PowerShell escapes a quote inside "..." as `" and a backtick as ``.
351    let unesc = |s: &str| s.replace("`\"", "\"").replace("``", "`").replace("`$", "$");
352    let quoted = |hay: &str, from: usize| -> Option<(String, usize)> {
353        let rest = &hay[from..];
354        let open = rest.find('"')?;
355        let bytes = rest.as_bytes();
356        let mut i = open + 1;
357        while i < bytes.len() {
358            if bytes[i] == b'`' {
359                i += 2;
360                continue;
361            }
362            if bytes[i] == b'"' {
363                return Some((unesc(&rest[open + 1..i]), from + i + 1));
364            }
365            i += 1;
366        }
367        None
368    };
369    // cookies: New-Object System.Net.Cookie("name", "value", "/", "domain")
370    let mut at = 0;
371    while let Some(p) = text[at..].find("System.Net.Cookie(") {
372        let start = at + p + "System.Net.Cookie(".len();
373        let Some((name, n1)) = quoted(text, start) else {
374            break;
375        };
376        let Some((value, n2)) = quoted(text, n1) else {
377            break;
378        };
379        let path = quoted(text, n2);
380        let (path_v, n3) = path.map_or((None, n2), |(p, n)| (Some(p), n));
381        let dom = quoted(text, n3);
382        let (dom_v, n4) = dom.map_or((None, n3), |(d, n)| (Some(d), n));
383        if valid_cookie_value(&value) {
384            upsert(
385                &mut cap.cookies,
386                Cookie {
387                    name,
388                    value,
389                    path: path_v,
390                    domain: dom_v,
391                    ..Default::default()
392                },
393            );
394        } else {
395            cap.dropped
396                .push(format!("cookie {name} (illegal character in value)"));
397        }
398        at = n4;
399    }
400    // headers: @{ "name"="value" \n "name2"="value2" }
401    if let Some(h) = text.find("-Headers @{") {
402        let mut pos = h + "-Headers @{".len();
403        while let Some((k, n)) = quoted(text, pos) {
404            // stop at the closing brace of the hashtable
405            if text[pos..n].contains('}') {
406                break;
407            }
408            let Some((v, n2)) = quoted(text, n) else {
409                break;
410            };
411            keep_header(&k, &v, &mut cap);
412            pos = n2;
413            if text[pos..].trim_start().starts_with('}') {
414                break;
415            }
416        }
417    }
418    if let Some(u) = text.find("-Uri ") {
419        if let Some((url, _)) = quoted(text, u) {
420            cap.origin = origin_of(&url);
421        }
422    }
423    if let Some(ua) = text.find("-UserAgent ") {
424        if let Some((v, _)) = quoted(text, ua) {
425            cap.user_agent = Some(v);
426        }
427    }
428    if text.contains("-Body ") {
429        cap.dropped.push("request body".into());
430    }
431    if cap.origin.is_none() {
432        return Err("no -Uri in the PowerShell command".into());
433    }
434    Ok(cap)
435}
436
437// ── HAR ──────────────────────────────────────────────────────────────────────
438
439/// Parses a HAR **in memory** and keeps only the chosen origin's request cookies
440/// and headers. The HAR itself is never stored: it holds every other site the
441/// browser talked to. With several origins and no `origin`, the error lists
442/// them so the caller can ask.
443pub fn parse_har(text: &str, origin: Option<&str>) -> Result<Capture, String> {
444    if text.len() > MAX_INPUT {
445        return Err("HAR is larger than 32 MiB; export a smaller capture".into());
446    }
447    let doc: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
448    let entries = doc
449        .pointer("/log/entries")
450        .and_then(Value::as_array)
451        .ok_or("not a HAR: no log.entries")?;
452
453    let origin_for = |e: &Value| {
454        e.pointer("/request/url")
455            .and_then(Value::as_str)
456            .and_then(origin_of)
457    };
458    let want = match origin {
459        Some(o) => origin_of(o).ok_or_else(|| format!("'{o}' is not an http(s) origin"))?,
460        None => {
461            let mut seen: Vec<String> = Vec::new();
462            for e in entries {
463                if let Some(o) = origin_for(e) {
464                    if !seen.contains(&o) {
465                        seen.push(o);
466                    }
467                }
468            }
469            match seen.len() {
470                0 => return Err("the HAR has no http(s) requests".into()),
471                1 => seen.remove(0),
472                _ => {
473                    return Err(format!(
474                        "the HAR touches {} origins; pass --origin one of: {}",
475                        seen.len(),
476                        seen.join(", ")
477                    ))
478                }
479            }
480        }
481    };
482
483    let mut cap = Capture {
484        origin: Some(want.clone()),
485        ..Default::default()
486    };
487    let mut other_hosts: Vec<String> = Vec::new();
488    for e in entries {
489        let Some(o) = origin_for(e) else { continue };
490        if o != want {
491            let host = host_of(&o).to_string();
492            if !other_hosts.contains(&host) {
493                other_hosts.push(host);
494            }
495            continue;
496        }
497        let req = &e["request"];
498        for c in req["cookies"].as_array().into_iter().flatten() {
499            let (Some(n), Some(v)) = (c["name"].as_str(), c["value"].as_str()) else {
500                continue;
501            };
502            if valid_cookie_value(v) {
503                upsert(
504                    &mut cap.cookies,
505                    Cookie {
506                        name: n.into(),
507                        value: v.into(),
508                        ..Default::default()
509                    },
510                );
511            } else {
512                cap.dropped
513                    .push(format!("cookie {n} (illegal character in value)"));
514            }
515        }
516        // Response cookies carry the attributes request cookies lack.
517        for c in e["response"]["cookies"].as_array().into_iter().flatten() {
518            let (Some(n), Some(v)) = (c["name"].as_str(), c["value"].as_str()) else {
519                continue;
520            };
521            if !valid_cookie_value(v) {
522                continue;
523            }
524            let expires = c["expires"]
525                .as_str()
526                .and_then(|s| {
527                    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
528                        .ok()
529                })
530                .map_or(0, |d| d.unix_timestamp());
531            upsert(
532                &mut cap.cookies,
533                Cookie {
534                    name: n.into(),
535                    value: v.into(),
536                    domain: c["domain"].as_str().map(String::from),
537                    path: c["path"].as_str().map(String::from),
538                    secure: c["secure"].as_bool().unwrap_or(false),
539                    http_only: c["httpOnly"].as_bool().unwrap_or(false),
540                    expires,
541                    partitioned: false,
542                },
543            );
544        }
545        for h in req["headers"].as_array().into_iter().flatten() {
546            if let (Some(n), Some(v)) = (h["name"].as_str(), h["value"].as_str()) {
547                if n.starts_with(':') {
548                    continue; // HTTP/2 pseudo-headers
549                }
550                // later requests win by header name
551                cap.headers.retain(|(k, _)| !k.eq_ignore_ascii_case(n));
552                keep_header(n, v, &mut cap);
553            }
554        }
555        if req.get("postData").is_some() && !cap.dropped.iter().any(|d| d == "request body") {
556            cap.dropped.push("request body".into());
557        }
558    }
559    if !other_hosts.is_empty() {
560        cap.dropped.push(format!(
561            "requests to {} other host(s): {}",
562            other_hosts.len(),
563            other_hosts.join(", ")
564        ));
565    }
566    Ok(cap)
567}
568
569// ── Set-Cookie ───────────────────────────────────────────────────────────────
570
571fn parse_http_date(s: &str) -> Option<i64> {
572    // RFC 2822 allows nested `(comments)`, and `time` < 0.3.47 recurses on them
573    // (RUSTSEC-2026-0009: stack exhaustion). The text is attacker-controlled — a
574    // pasted `Set-Cookie` from any site — and a real cookie date is ~30 bytes with
575    // no parentheses, so both are refused before the parser sees them.
576    if s.len() > 64 || s.contains(['(', ')']) {
577        return None;
578    }
579    time::OffsetDateTime::parse(s.trim(), &time::format_description::well_known::Rfc2822)
580        .ok()
581        .map(|d| d.unix_timestamp())
582        .or_else(|| {
583            // "Wed, 21-Oct-2015 07:28:00 GMT" (the obsolete cookie spelling)
584            let fixed = s.trim().replace('-', " ");
585            time::OffsetDateTime::parse(&fixed, &time::format_description::well_known::Rfc2822)
586                .ok()
587                .map(|d| d.unix_timestamp())
588        })
589}
590
591/// One or more `Set-Cookie:` lines (or bare `name=value; Attr` lines).
592pub fn parse_set_cookie(text: &str, now_unix: i64) -> Result<Capture, String> {
593    let mut cap = Capture::default();
594    for line in text.lines() {
595        let line = line.trim();
596        let line = line
597            .strip_prefix("Set-Cookie:")
598            .or_else(|| line.strip_prefix("set-cookie:"))
599            .unwrap_or(line)
600            .trim();
601        if line.is_empty() || line.starts_with('#') {
602            continue;
603        }
604        let mut parts = line.split(';');
605        let Some((name, value)) = parts.next().and_then(|p| p.split_once('=')) else {
606            continue;
607        };
608        let (name, value) = (name.trim(), value.trim());
609        if name.is_empty() {
610            continue;
611        }
612        if !valid_cookie_value(value) {
613            cap.dropped
614                .push(format!("cookie {name} (illegal character in value)"));
615            continue;
616        }
617        let mut c = Cookie {
618            name: name.into(),
619            value: value.into(),
620            ..Default::default()
621        };
622        let mut max_age: Option<i64> = None;
623        for attr in parts {
624            let (k, v) = attr
625                .split_once('=')
626                .map_or((attr.trim(), ""), |(k, v)| (k.trim(), v.trim()));
627            match k.to_ascii_lowercase().as_str() {
628                "domain" => {
629                    c.domain = Some(v.trim_start_matches('.').to_string())
630                        .filter(|d| !d.is_empty())
631                        .map(|d| format!(".{d}"))
632                }
633                "path" => c.path = Some(v.to_string()),
634                "secure" => c.secure = true,
635                "httponly" => c.http_only = true,
636                "partitioned" => c.partitioned = true,
637                "max-age" => max_age = v.parse().ok(),
638                "expires" => c.expires = parse_http_date(v).unwrap_or(0),
639                _ => {}
640            }
641        }
642        // Max-Age beats Expires (RFC 6265 5.3).
643        if let Some(m) = max_age {
644            c.expires = if m <= 0 { 1 } else { now_unix + m };
645        }
646        // __Host- requires Secure, Path=/ and no Domain; a violating cookie is
647        // one the browser would have rejected, so importing it would mislead.
648        if name.starts_with("__Host-")
649            && (!c.secure || c.path.as_deref() != Some("/") || c.domain.is_some())
650        {
651            cap.dropped
652                .push(format!("cookie {name} (violates the __Host- prefix rules)"));
653            continue;
654        }
655        if name.starts_with("__Secure-") && !c.secure {
656            cap.dropped.push(format!(
657                "cookie {name} (violates the __Secure- prefix rules)"
658            ));
659            continue;
660        }
661        upsert(&mut cap.cookies, c);
662    }
663    if cap.cookies.is_empty() && cap.dropped.is_empty() {
664        return Err("no Set-Cookie lines found".into());
665    }
666    Ok(cap)
667}
668
669// ── Firefox ──────────────────────────────────────────────────────────────────
670
671/// Reads a Firefox profile's `cookies.sqlite` for one host (matching `host` and
672/// its subdomains). Opened read-only from a **copy** (Firefox keeps the live file
673/// locked and its WAL separate). Firefox does not encrypt this file.
674///
675/// Chrome on Windows is deliberately not here: since Chrome 127, app-bound
676/// encryption ties cookie decryption to the Chrome process, so no external tool
677/// can read it — the same wall `yt-dlp --cookies-from-browser` hits.
678pub fn read_firefox(path: &std::path::Path, host: &str) -> Result<Capture, String> {
679    let dir = std::env::temp_dir().join(format!(
680        "unv-ff-{}-{}",
681        std::process::id(),
682        crate::new_uuid()
683    ));
684    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
685    let result = (|| {
686        let copy = dir.join("cookies.sqlite");
687        std::fs::copy(path, &copy).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
688        let wal = path.with_extension("sqlite-wal");
689        if wal.exists() {
690            let _ = std::fs::copy(&wal, dir.join("cookies.sqlite-wal"));
691        }
692        let conn = rusqlite::Connection::open_with_flags(
693            &copy,
694            rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE, // WAL replay needs write on the copy
695        )
696        .map_err(|e| e.to_string())?;
697        let mut stmt = conn
698            .prepare(
699                "SELECT name, value, host, path, expiry, isSecure, isHttpOnly, originAttributes \
700                 FROM moz_cookies WHERE host = ?1 OR host = ?2 OR host LIKE ?3",
701            )
702            .map_err(|e| format!("not a Firefox cookies.sqlite: {e}"))?;
703        let host = host.trim_start_matches('.');
704        let rows = stmt
705            .query_map(
706                rusqlite::params![host, format!(".{host}"), format!("%.{host}")],
707                |r| {
708                    Ok(Cookie {
709                        name: r.get(0)?,
710                        value: r.get(1)?,
711                        domain: Some(r.get::<_, String>(2)?),
712                        path: Some(r.get::<_, String>(3)?),
713                        // Newer Firefox stores milliseconds
714                        expires: {
715                            let e: i64 = r.get(4)?;
716                            if e > 100_000_000_000 {
717                                e / 1000
718                            } else {
719                                e
720                            }
721                        },
722                        secure: r.get::<_, i64>(5)? != 0,
723                        http_only: r.get::<_, i64>(6)? != 0,
724                        partitioned: r.get::<_, String>(7)?.contains("partitionKey="),
725                    })
726                },
727            )
728            .map_err(|e| e.to_string())?;
729        let mut cap = Capture::default();
730        for row in rows {
731            let c = row.map_err(|e| e.to_string())?;
732            if valid_cookie_value(&c.value) {
733                upsert(&mut cap.cookies, c);
734            } else {
735                cap.dropped
736                    .push(format!("cookie {} (illegal character in value)", c.name));
737            }
738        }
739        Ok(cap)
740    })();
741    let _ = std::fs::remove_dir_all(&dir);
742    result
743}
744
745/// What to say when someone asks for Chrome.
746pub const CHROME_REFUSAL: &str =
747    "Chrome cookies are not read. Since Chrome 127 on Windows, app-bound encryption ties cookie \
748     decryption to the Chrome process, so no outside tool can read them (yt-dlp's \
749     --cookies-from-browser fails the same way). Export from DevTools (Copy as cURL, or Save all \
750     as HAR) or use Firefox.";
751
752/// Sniffs which dialect a paste is.
753pub fn detect(text: &str) -> &'static str {
754    let t = text.trim_start();
755    if t.starts_with("curl") {
756        "curl"
757    } else if t.contains("Invoke-WebRequest") || t.contains("Invoke-RestMethod") {
758        "powershell"
759    } else if t.starts_with('{') && t.contains("\"log\"") {
760        "har"
761    } else {
762        "set-cookie"
763    }
764}
765
766/// Parse a paste of unknown dialect.
767pub fn parse_auto(text: &str, origin: Option<&str>, now_unix: i64) -> Result<Capture, String> {
768    match detect(text) {
769        "curl" => parse_curl(text),
770        "powershell" => parse_powershell(text),
771        "har" => parse_har(text, origin),
772        _ => parse_set_cookie(text, now_unix),
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    const BASH: &str = r#"curl 'https://www.example.com/api/me?x=1' \
781  -H 'accept: application/json' \
782  -H 'authorization: Bearer topsecret' \
783  -H 'cookie: sid=abc123; csrftoken=zz9; bad=a b' \
784  -H 'user-agent: Mozilla/5.0 (X11; Linux x86_64)' \
785  -H 'sec-fetch-mode: cors' \
786  -H 'x-csrf-token: zz9' \
787  --data-raw '{"a":1}' \
788  --compressed"#;
789
790    #[test]
791    fn bash_curl_keeps_the_origins_session_and_names_what_it_dropped() {
792        let c = parse_curl(BASH).unwrap();
793        assert_eq!(c.origin.as_deref(), Some("https://www.example.com"));
794        assert_eq!(
795            c.cookies
796                .iter()
797                .map(|c| c.name.as_str())
798                .collect::<Vec<_>>(),
799            ["sid", "csrftoken"]
800        );
801        assert_eq!(
802            c.user_agent.as_deref(),
803            Some("Mozilla/5.0 (X11; Linux x86_64)")
804        );
805        let names: Vec<_> = c.headers.iter().map(|(k, _)| k.as_str()).collect();
806        assert_eq!(
807            names,
808            ["accept", "x-csrf-token"],
809            "noise and credentials are not kept"
810        );
811        // S12: reported by name, never by value.
812        assert!(c.dropped.iter().any(|d| d == "authorization header"));
813        assert!(c.dropped.iter().any(|d| d == "request body"));
814        assert!(c
815            .dropped
816            .iter()
817            .any(|d| d.contains("bad") && d.contains("illegal")));
818        assert!(!format!("{:?}", c.dropped).contains("topsecret"));
819    }
820
821    #[test]
822    fn cmd_flavoured_curl_is_uncaret_ed() {
823        let cmd = "curl ^\"https://www.example.com/^\" ^\n  -H ^\"cookie: sid=abc^\" ^\n  -H ^\"user-agent: UA/1^\"";
824        let c = parse_curl(cmd).unwrap();
825        assert_eq!(c.origin.as_deref(), Some("https://www.example.com"));
826        assert_eq!(c.cookies[0].value, "abc");
827        assert_eq!(c.user_agent.as_deref(), Some("UA/1"));
828    }
829
830    #[test]
831    fn ansi_c_quoting_is_decoded() {
832        let c = parse_curl("curl 'https://e.test/' -H $'user-agent: caf\\u00e9'").unwrap();
833        assert_eq!(c.user_agent.as_deref(), Some("café"));
834    }
835
836    #[test]
837    fn powershell_capture_yields_cookies_headers_and_origin() {
838        let ps = r#"$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
839$session.UserAgent = "x"
840$session.Cookies.Add((New-Object System.Net.Cookie("sid", "abc", "/", "www.example.com")))
841Invoke-WebRequest -UseBasicParsing -Uri "https://www.example.com/a" `
842-WebSession $session `
843-Headers @{
844"accept"="text/html"
845"authorization"="Bearer nope"
846}"#;
847        let c = parse_powershell(ps).unwrap();
848        assert_eq!(c.origin.as_deref(), Some("https://www.example.com"));
849        assert_eq!(c.cookies[0].name, "sid");
850        assert_eq!(c.cookies[0].domain.as_deref(), Some("www.example.com"));
851        assert_eq!(
852            c.headers,
853            vec![("accept".to_string(), "text/html".to_string())]
854        );
855        assert!(c.dropped.iter().any(|d| d == "authorization header"));
856    }
857
858    fn har() -> String {
859        serde_json::json!({"log":{"entries":[
860          {"request":{"url":"https://www.example.com/a","headers":[
861              {"name":":authority","value":"x"},{"name":"user-agent","value":"UA"},
862              {"name":"authorization","value":"Bearer t"},{"name":"x-csrf-token","value":"old"}],
863            "cookies":[{"name":"sid","value":"1"}]},
864           "response":{"cookies":[{"name":"sid","value":"2","domain":".example.com","path":"/","secure":true,"httpOnly":true,"expires":"2030-01-01T00:00:00Z"}]}},
865          {"request":{"url":"https://www.example.com/b","headers":[{"name":"x-csrf-token","value":"new"}],
866            "cookies":[{"name":"csrf","value":"c"}]},"response":{"cookies":[]}},
867          {"request":{"url":"https://cdn.tracker.test/p","headers":[],"cookies":[{"name":"t","value":"9"}]},"response":{"cookies":[]}}
868        ]}}).to_string()
869    }
870
871    #[test]
872    fn har_keeps_one_origin_and_lists_the_others_it_dropped() {
873        let c = parse_har(&har(), Some("https://www.example.com")).unwrap();
874        assert!(
875            c.cookies.iter().all(|c| c.name != "t"),
876            "a CDN's cookie must not be kept"
877        );
878        let sid = c.cookies.iter().find(|c| c.name == "sid").unwrap();
879        assert_eq!(
880            sid.value, "2",
881            "the response cookie's value and attributes win"
882        );
883        assert!(sid.secure && sid.http_only && sid.expires > 0);
884        assert_eq!(
885            c.headers,
886            vec![("x-csrf-token".to_string(), "new".to_string())]
887        );
888        assert!(c.dropped.iter().any(|d| d.contains("cdn.tracker.test")));
889        assert!(c.dropped.iter().any(|d| d == "authorization header"));
890    }
891
892    #[test]
893    fn har_with_several_origins_asks_instead_of_guessing() {
894        let err = parse_har(&har(), None).unwrap_err();
895        assert!(err.contains("--origin") && err.contains("cdn.tracker.test"));
896    }
897
898    #[test]
899    fn set_cookie_attributes_and_prefix_rules() {
900        let now = 1_700_000_000;
901        let c = parse_set_cookie(
902            "Set-Cookie: sid=abc; Domain=example.com; Path=/; Secure; HttpOnly; Max-Age=3600; Partitioned\n\
903             Set-Cookie: __Host-x=1; Domain=example.com; Path=/; Secure\n\
904             Set-Cookie: old=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT",
905            now,
906        )
907        .unwrap();
908        let sid = &c.cookies[0];
909        assert_eq!(sid.domain.as_deref(), Some(".example.com"));
910        assert_eq!(sid.expires, now + 3600, "Max-Age beats Expires");
911        assert!(sid.secure && sid.http_only && sid.partitioned);
912        assert!(c.cookies.iter().all(|c| c.name != "__Host-x"));
913        assert!(c.dropped.iter().any(|d| d.contains("__Host-")));
914        assert_eq!(
915            c.cookies.iter().find(|c| c.name == "old").unwrap().expires,
916            1_445_412_480
917        );
918    }
919
920    #[test]
921    fn a_hostile_expires_value_cannot_exhaust_the_stack() {
922        let nested = format!(
923            "Wed, 21 Oct 2015 07:28:00 GMT {}{}",
924            "(".repeat(200_000),
925            ")".repeat(200_000)
926        );
927        let c = parse_set_cookie(&format!("Set-Cookie: a=b; Expires={nested}"), 0).unwrap();
928        assert_eq!(
929            c.cookies[0].expires, 0,
930            "an unparseable date is a session cookie, not a crash"
931        );
932    }
933
934    #[test]
935    fn detect_routes_each_dialect() {
936        assert_eq!(detect("curl 'https://a.test'"), "curl");
937        assert_eq!(
938            detect("Invoke-WebRequest -Uri \"https://a.test\""),
939            "powershell"
940        );
941        assert_eq!(detect("{\"log\":{}}"), "har");
942        assert_eq!(detect("Set-Cookie: a=b"), "set-cookie");
943    }
944
945    #[test]
946    fn firefox_sqlite_is_read_from_a_copy_and_filtered_by_host() {
947        let dir = std::env::temp_dir().join(format!("unv-ffsrc-{}", crate::new_uuid()));
948        std::fs::create_dir_all(&dir).unwrap();
949        let db = dir.join("cookies.sqlite");
950        {
951            let c = rusqlite::Connection::open(&db).unwrap();
952            c.execute_batch(
953                "CREATE TABLE moz_cookies (name TEXT, value TEXT, host TEXT, path TEXT, expiry INTEGER, isSecure INTEGER, isHttpOnly INTEGER, originAttributes TEXT);
954                 INSERT INTO moz_cookies VALUES ('sid','1','.example.com','/',1893456000,1,1,'');
955                 INSERT INTO moz_cookies VALUES ('p','2','www.example.com','/',1893456000000,0,0,'^partitionKey=%28https%2Cexample.com%29');
956                 INSERT INTO moz_cookies VALUES ('other','3','.evil.test','/',0,0,0,'');",
957            )
958            .unwrap();
959        }
960        let cap = read_firefox(&db, "example.com").unwrap();
961        assert_eq!(cap.cookies.len(), 2);
962        assert!(cap.cookies.iter().all(|c| c.name != "other"));
963        let p = cap.cookies.iter().find(|c| c.name == "p").unwrap();
964        assert!(p.partitioned);
965        assert_eq!(p.expires, 1_893_456_000, "millisecond expiry is normalised");
966        assert!(db.exists(), "the live file is never modified");
967        let _ = std::fs::remove_dir_all(&dir);
968    }
969}