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