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