1use anyhow::{anyhow, bail, Context, Result};
17use regex::Regex;
18use serde::Serialize;
19use serde_json::{json, Value};
20use std::path::{Path, PathBuf};
21
22use crate::cli::env_resolver::{self, ResolvedBrowser};
23use crate::cli::mcp::{acquire_bidi_lock_if_needed, resolve_browser};
24use crate::cli::trace::CommandTrace;
25use crate::detect::Engine;
26use crate::registry::Registry;
27use crate::session::targets::{open_bidi, open_cdp};
28
29#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
30pub(crate) struct NormalCookie {
31 pub(crate) domain: String,
32 pub(crate) name: String,
33 pub(crate) value: String,
34 pub(crate) path: String,
35 pub(crate) secure: bool,
36 pub(crate) http_only: bool,
37 pub(crate) same_site: Option<String>,
38 pub(crate) expires: Option<i64>,
39}
40
41pub(crate) async fn fetch_cookies(resolved: &ResolvedBrowser) -> Result<Vec<NormalCookie>> {
43 match resolved.engine {
44 Engine::Cdp => fetch_cdp(&resolved.endpoint).await,
45 Engine::Bidi => fetch_bidi(&resolved.endpoint).await,
46 }
47}
48
49#[allow(clippy::too_many_arguments)]
50pub async fn run(
51 browser: Option<String>,
52 domain: Option<String>,
53 name: Option<String>,
54 format: String,
55 output: Option<PathBuf>,
56 reveal: bool,
57 json: bool,
58) -> Result<()> {
59 let mut trace = CommandTrace::new("cookies");
60 let result: Result<()> = async {
61 let effective_format = if json { "json".to_string() } else { format };
62 match effective_format.as_str() {
63 "json" | "netscape" | "header" => {}
64 other => {
65 bail!("unknown --format `{other}`: expected one of `netscape`, `json`, `header`")
66 }
67 }
68
69 let domain_re = domain
70 .as_deref()
71 .map(Regex::new)
72 .transpose()
73 .context("invalid --domain regex")?;
74 let name_re = name
75 .as_deref()
76 .map(Regex::new)
77 .transpose()
78 .context("invalid --name regex")?;
79
80 let raw = browser.unwrap_or_default();
85 let parsed = if raw.is_empty() {
86 None
87 } else {
88 Some(env_resolver::parse_target(&raw)?)
89 };
90 if parsed.as_ref().and_then(|p| p.tab.as_ref()).is_some() {
91 bail!(
92 "`cookies` operates browser-wide; tab suffixes are not supported \
93 (got `{raw}`). Use a bare browser selector instead."
94 );
95 }
96 let browser_only = parsed.as_ref().map(|_| raw.clone()).unwrap_or_default();
97 let resolved = resolve_browser(if browser_only.is_empty() {
98 None
99 } else {
100 Some(browser_only.clone())
101 })
102 .await?;
103 trace.browser(&browser_only).engine(resolved.engine);
104 trace.route("browser-wide");
105 let _bidi_lock = {
108 let registry = Registry::open()?;
109 acquire_bidi_lock_if_needed(®istry, &resolved)?
110 };
111 let raw = fetch_cookies(&resolved).await?;
112
113 let cookies: Vec<NormalCookie> = raw
114 .into_iter()
115 .filter(|c| matches_filter(c, domain_re.as_ref(), name_re.as_ref()))
116 .collect();
117
118 let to_stdout = output.is_none();
121 let body = match effective_format.as_str() {
122 "json" => format_json(&cookies)?,
123 "netscape" => format_netscape(&cookies),
124 "header" => format_header(&cookies, !to_stdout || reveal),
125 _ => unreachable!(),
126 };
127
128 match output {
129 Some(path) => {
130 write_file(&path, &body)?;
131 eprintln!("wrote {} cookies to {}", cookies.len(), path.display());
132 }
133 None => {
134 use std::io::Write;
135 let mut out = std::io::stdout().lock();
136 out.write_all(body.as_bytes())?;
137 if !body.ends_with('\n') {
138 out.write_all(b"\n")?;
139 }
140 }
141 }
142 Ok(())
143 }
144 .await;
145 trace.finish(result)
146}
147
148async fn fetch_cdp(endpoint: &str) -> Result<Vec<NormalCookie>> {
149 let client = open_cdp(endpoint).await?;
150 let result = client.get_all_cookies().await?;
151 client.close().await;
152 let arr = result
153 .get("cookies")
154 .and_then(|v| v.as_array())
155 .ok_or_else(|| anyhow!("CDP cookie export: missing `cookies` array"))?;
156 Ok(arr.iter().map(normalize_cdp).collect())
157}
158
159async fn fetch_bidi(endpoint: &str) -> Result<Vec<NormalCookie>> {
160 let client = open_bidi(endpoint).await?;
161 client.session_new().await?;
162 let result = client.send("storage.getCookies", json!({})).await;
163 let _ = client.session_end().await;
164 let result = result?;
165 let arr = result
166 .get("cookies")
167 .and_then(|v| v.as_array())
168 .ok_or_else(|| anyhow!("BiDi storage.getCookies: missing `cookies` array"))?;
169 Ok(arr.iter().map(normalize_bidi).collect())
170}
171
172fn str_field(v: &Value, k: &str) -> String {
173 v.get(k)
174 .and_then(|x| x.as_str())
175 .unwrap_or_default()
176 .to_string()
177}
178
179fn bool_field(v: &Value, k: &str) -> bool {
180 v.get(k).and_then(|x| x.as_bool()).unwrap_or(false)
181}
182
183pub(crate) fn normalize_cdp(v: &Value) -> NormalCookie {
184 let expires =
185 v.get("expires")
186 .and_then(|x| x.as_i64())
187 .and_then(|n| if n < 0 { None } else { Some(n) });
188 let same_site = v
189 .get("sameSite")
190 .and_then(|x| x.as_str())
191 .map(|s| s.to_string());
192 NormalCookie {
193 domain: str_field(v, "domain"),
194 name: str_field(v, "name"),
195 value: str_field(v, "value"),
196 path: str_field(v, "path"),
197 secure: bool_field(v, "secure"),
198 http_only: bool_field(v, "httpOnly"),
199 same_site,
200 expires,
201 }
202}
203
204pub(crate) fn normalize_bidi(v: &Value) -> NormalCookie {
205 let value = v
206 .get("value")
207 .and_then(|inner| inner.get("value").and_then(|x| x.as_str()))
208 .unwrap_or_default()
209 .to_string();
210 let expires = v.get("expiry").and_then(|x| x.as_i64());
211 let same_site = v
212 .get("sameSite")
213 .and_then(|x| x.as_str())
214 .map(|s| s.to_string());
215 NormalCookie {
216 domain: str_field(v, "domain"),
217 name: str_field(v, "name"),
218 value,
219 path: str_field(v, "path"),
220 secure: bool_field(v, "secure"),
221 http_only: bool_field(v, "httpOnly"),
222 same_site,
223 expires,
224 }
225}
226
227fn matches_filter(c: &NormalCookie, domain: Option<&Regex>, name: Option<&Regex>) -> bool {
228 domain.map_or(true, |re| re.is_match(&c.domain)) && name.map_or(true, |re| re.is_match(&c.name))
229}
230
231fn format_json(cookies: &[NormalCookie]) -> Result<String> {
232 Ok(serde_json::to_string_pretty(cookies)?)
233}
234
235fn format_netscape(cookies: &[NormalCookie]) -> String {
236 let mut out = String::from("# Netscape HTTP Cookie File\n");
237 for c in cookies {
238 let include_sub = if c.domain.starts_with('.') {
239 "TRUE"
240 } else {
241 "FALSE"
242 };
243 let secure = if c.secure { "TRUE" } else { "FALSE" };
244 let expires = c.expires.unwrap_or(0);
245 out.push_str(&format!(
246 "{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
247 c.domain, include_sub, c.path, secure, expires, c.name, c.value
248 ));
249 }
250 out
251}
252
253fn format_header(cookies: &[NormalCookie], reveal: bool) -> String {
254 let parts: Vec<String> = cookies
255 .iter()
256 .map(|c| {
257 let v = if reveal {
258 c.value.as_str()
259 } else {
260 "<redacted>"
261 };
262 format!("{}={}", c.name, v)
263 })
264 .collect();
265 format!("Cookie: {}", parts.join("; "))
266}
267
268fn write_file(path: &Path, body: &str) -> Result<()> {
269 std::fs::write(path, body).with_context(|| format!("failed to write {}", path.display()))?;
270 #[cfg(unix)]
271 {
272 use std::os::unix::fs::PermissionsExt;
273 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
274 .with_context(|| format!("failed to chmod 600 {}", path.display()))?;
275 }
276 Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn c(domain: &str, name: &str, value: &str) -> NormalCookie {
284 NormalCookie {
285 domain: domain.to_string(),
286 name: name.to_string(),
287 value: value.to_string(),
288 path: "/".to_string(),
289 secure: false,
290 http_only: false,
291 same_site: None,
292 expires: None,
293 }
294 }
295
296 #[test]
297 fn netscape_byte_for_byte() {
298 let cookies = vec![
299 NormalCookie {
300 domain: ".example.com".to_string(),
301 name: "a".to_string(),
302 value: "1".to_string(),
303 path: "/".to_string(),
304 secure: true,
305 http_only: false,
306 same_site: None,
307 expires: Some(1700000000),
308 },
309 NormalCookie {
310 domain: "host.test".to_string(),
311 name: "b".to_string(),
312 value: "two".to_string(),
313 path: "/x".to_string(),
314 secure: false,
315 http_only: true,
316 same_site: Some("Lax".to_string()),
317 expires: None,
318 },
319 ];
320 let got = format_netscape(&cookies);
321 let want = "# Netscape HTTP Cookie File\n\
322 .example.com\tTRUE\t/\tTRUE\t1700000000\ta\t1\n\
323 host.test\tFALSE\t/x\tFALSE\t0\tb\ttwo\n";
324 assert_eq!(got, want);
325 }
326
327 #[test]
328 fn header_output_revealed() {
329 let cookies = vec![c("x.test", "a", "1"), c("x.test", "b", "2")];
330 assert_eq!(format_header(&cookies, true), "Cookie: a=1; b=2");
331 }
332
333 #[test]
334 fn header_output_redacted() {
335 let cookies = vec![c("x.test", "a", "1"), c("x.test", "b", "2")];
336 assert_eq!(
337 format_header(&cookies, false),
338 "Cookie: a=<redacted>; b=<redacted>"
339 );
340 }
341
342 #[test]
343 fn domain_regex_filter_unanchored() {
344 let re = Regex::new(r"\.example\.com$").unwrap();
347 let dot = c(".example.com", "a", "1");
348 let www = c("www.example.com", "a", "1");
349 let other = c("evil.test", "a", "1");
350 assert!(matches_filter(&dot, Some(&re), None));
351 assert!(matches_filter(&www, Some(&re), None));
352 assert!(!matches_filter(&other, Some(&re), None));
353 }
354
355 #[test]
356 fn name_regex_filter() {
357 let re = Regex::new(r"^session_").unwrap();
358 let yes = c("x.test", "session_id", "1");
359 let no = c("x.test", "csrf", "2");
360 assert!(matches_filter(&yes, None, Some(&re)));
361 assert!(!matches_filter(&no, None, Some(&re)));
362 }
363
364 #[test]
365 fn json_output_round_trips() {
366 let cookies = vec![NormalCookie {
367 domain: ".example.com".to_string(),
368 name: "a".to_string(),
369 value: "v".to_string(),
370 path: "/".to_string(),
371 secure: true,
372 http_only: true,
373 same_site: Some("Strict".to_string()),
374 expires: Some(42),
375 }];
376 let s = format_json(&cookies).unwrap();
377 let v: Value = serde_json::from_str(&s).unwrap();
378 assert_eq!(v[0]["domain"], ".example.com");
379 assert_eq!(v[0]["name"], "a");
380 assert_eq!(v[0]["value"], "v");
381 assert_eq!(v[0]["secure"], true);
382 assert_eq!(v[0]["http_only"], true);
383 assert_eq!(v[0]["same_site"], "Strict");
384 assert_eq!(v[0]["expires"], 42);
385 }
386
387 #[test]
388 fn normalize_cdp_maps_fields() {
389 let v = json!({
390 "name": "sid",
391 "value": "abc",
392 "domain": ".example.com",
393 "path": "/",
394 "expires": 1700000000_i64,
395 "httpOnly": true,
396 "secure": true,
397 "sameSite": "Lax",
398 "session": false
399 });
400 let n = normalize_cdp(&v);
401 assert_eq!(n.name, "sid");
402 assert_eq!(n.value, "abc");
403 assert_eq!(n.domain, ".example.com");
404 assert_eq!(n.path, "/");
405 assert_eq!(n.expires, Some(1700000000));
406 assert!(n.http_only);
407 assert!(n.secure);
408 assert_eq!(n.same_site.as_deref(), Some("Lax"));
409 }
410
411 #[test]
412 fn normalize_cdp_session_cookie_has_no_expires() {
413 let v = json!({
414 "name": "s",
415 "value": "x",
416 "domain": "host.test",
417 "path": "/",
418 "expires": -1_i64,
419 "httpOnly": false,
420 "secure": false,
421 "session": true
422 });
423 let n = normalize_cdp(&v);
424 assert_eq!(n.expires, None);
425 assert_eq!(n.same_site, None);
426 }
427
428 #[test]
429 fn normalize_bidi_maps_fields() {
430 let v = json!({
431 "name": "sid",
432 "value": {"type": "string", "value": "abc"},
433 "domain": "example.com",
434 "path": "/",
435 "expiry": 1700000000_i64,
436 "httpOnly": true,
437 "secure": true,
438 "sameSite": "lax",
439 "size": 10
440 });
441 let n = normalize_bidi(&v);
442 assert_eq!(n.name, "sid");
443 assert_eq!(n.value, "abc");
444 assert_eq!(n.domain, "example.com");
445 assert_eq!(n.expires, Some(1700000000));
446 assert!(n.http_only);
447 assert!(n.secure);
448 assert_eq!(n.same_site.as_deref(), Some("lax"));
449 }
450
451 #[test]
452 fn normalize_bidi_no_expiry_is_session() {
453 let v = json!({
454 "name": "s",
455 "value": {"type": "string", "value": "x"},
456 "domain": "example.com",
457 "path": "/",
458 "httpOnly": false,
459 "secure": false,
460 "size": 1
461 });
462 let n = normalize_bidi(&v);
463 assert_eq!(n.expires, None);
464 }
465}