1use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::{anyhow, Result};
10use rusqlite::{params, Connection};
11use serde::{Deserialize, Serialize};
12
13use super::browser_cookies::open_cookie_database;
14use super::browser_profiles::{
15 find_cookie_database, is_default_browser_keyword, list_browser_profiles, normalize_platform,
16 resolve_source_browser, BrowserProfileOptions,
17};
18use super::browser_sources::{browser_family, Environment};
19use super::default_browser::{default_run_command, resolve_default_browser, RunCommand};
20use super::migration::MigrationEntry;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct CookieSourceListing {
28 pub browser: String,
30 pub profile: String,
32 pub path: PathBuf,
34 pub is_default: bool,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub cookies: Option<u64>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub by_domain: Option<BTreeMap<String, u64>>,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub error: Option<String>,
45}
46
47fn count_cookies_by_domain(
51 database: &Connection,
52 family: &str,
53 domains: &[String],
54) -> Result<(u64, Option<BTreeMap<String, u64>>)> {
55 let column = if family == "firefox" {
56 "host"
57 } else {
58 "host_key"
59 };
60 let table = if family == "firefox" {
61 "moz_cookies"
62 } else {
63 "cookies"
64 };
65 let count_for = |filter: Option<&str>| -> Result<u64> {
66 let count = match filter {
67 Some(domain) => {
68 let query = format!("SELECT COUNT(*) FROM {table} WHERE {column} LIKE ?1");
69 database.query_row(&query, params![format!("%{domain}%")], |row| {
70 row.get::<_, i64>(0)
71 })?
72 }
73 None => {
74 let query = format!("SELECT COUNT(*) FROM {table}");
75 database.query_row(&query, [], |row| row.get::<_, i64>(0))?
76 }
77 };
78 Ok(count.max(0) as u64)
79 };
80 let total = count_for(None)?;
81 if domains.is_empty() {
82 return Ok((total, None));
83 }
84 let mut by_domain = BTreeMap::new();
85 for domain in domains {
86 by_domain.insert(domain.clone(), count_for(Some(domain))?);
87 }
88 Ok((total, Some(by_domain)))
89}
90
91pub fn list_cookie_sources(
95 domains: &[String],
96 platform: &str,
97 home_dir: &Path,
98 environment: &Environment,
99) -> Result<Vec<CookieSourceListing>> {
100 let profiles = list_browser_profiles(
101 BrowserProfileOptions::default()
102 .home_dir(home_dir)
103 .platform(platform)
104 .environment(environment.clone()),
105 )?;
106 let mut sources = Vec::new();
107 for profile in profiles {
108 let Some(cookie_path) = find_cookie_database(&profile.browser, &profile.path) else {
109 continue;
110 };
111 let family = browser_family(&profile.browser)?;
112 let counts = if family == "safari" {
113 super::safari_cookies::read_safari_cookie_file(&cookie_path, environment)
114 .and_then(|data| super::safari_cookies::count_safari_cookies(&data, domains))
115 } else {
116 open_cookie_database(&cookie_path)
117 .and_then(|database| count_cookies_by_domain(&database, family, domains))
118 };
119 match counts {
120 Ok((total, by_domain)) => {
121 let matched = by_domain
123 .as_ref()
124 .map(|counts| counts.values().sum::<u64>())
125 .unwrap_or(total);
126 if !domains.is_empty() && matched == 0 {
127 continue;
128 }
129 sources.push(CookieSourceListing {
130 browser: profile.browser,
131 profile: profile.name,
132 path: profile.path,
133 is_default: profile.is_default,
134 cookies: Some(total),
135 by_domain,
136 error: None,
137 });
138 }
139 Err(error) => sources.push(CookieSourceListing {
140 browser: profile.browser,
141 profile: profile.name,
142 path: profile.path,
143 is_default: profile.is_default,
144 cookies: None,
145 by_domain: None,
146 error: Some(error.to_string()),
147 }),
148 }
149 }
150 Ok(sources)
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct ImportSource {
156 pub browser: String,
158 pub profile: Option<String>,
160 pub warning: Option<MigrationEntry>,
163}
164
165fn matched_cookies(source: &CookieSourceListing) -> u64 {
166 source
167 .by_domain
168 .as_ref()
169 .map(|counts| counts.values().sum())
170 .unwrap_or(0)
171}
172
173pub fn resolve_import_source(
181 browser: &str,
182 domains: &[String],
183 platform: &str,
184 home_dir: &Path,
185 environment: &Environment,
186 run_command: Option<&RunCommand>,
187) -> Result<ImportSource> {
188 if !is_default_browser_keyword(browser) || domains.is_empty() {
189 return Ok(ImportSource {
190 browser: resolve_source_browser(browser, platform, environment, run_command)?
191 .to_string(),
192 profile: None,
193 warning: None,
194 });
195 }
196 let platform = normalize_platform(platform);
197 let fallback;
198 let runner = match run_command {
199 Some(runner) => runner,
200 None => {
201 fallback = default_run_command();
202 &fallback
203 }
204 };
205 let system_default = resolve_default_browser(platform, environment, runner)?;
206 let listed_sources = list_cookie_sources(domains, platform, home_dir, environment)?;
207 let unreadable_default = listed_sources
208 .iter()
209 .find(|source| Some(source.browser.as_str()) == system_default && source.error.is_some())
210 .and_then(|source| source.error.clone());
211 let holders: Vec<CookieSourceListing> = listed_sources
212 .into_iter()
213 .filter(|source| source.error.is_none())
214 .collect();
215 let from_default: Vec<&CookieSourceListing> = holders
216 .iter()
217 .filter(|source| Some(source.browser.as_str()) == system_default)
218 .collect();
219 if from_default.is_empty() {
220 if let Some(error) = unreadable_default {
221 return Err(anyhow!(
222 "Could not inspect the default browser ({}): {error}",
223 system_default.unwrap_or("unknown")
224 ));
225 }
226 }
227 let candidates = if from_default.is_empty() {
228 holders.iter().collect()
229 } else {
230 from_default
231 };
232 let listed = domains.join(", ");
233 let Some(best) = candidates.iter().copied().reduce(|best, source| {
236 if matched_cookies(source) > matched_cookies(best) {
237 source
238 } else {
239 best
240 }
241 }) else {
242 let browser = system_default.ok_or_else(|| {
243 anyhow!(
244 "Could not determine the system default browser, and no installed browser holds cookies for {listed}."
245 )
246 })?;
247 return Ok(ImportSource {
248 browser: browser.to_string(),
249 profile: None,
250 warning: None,
251 });
252 };
253 let warning = (Some(best.browser.as_str()) != system_default).then(|| {
254 let (reason, detail) = match system_default {
255 Some(default) => (
256 "default-browser-fallback",
257 format!("The default browser ({default}) holds no cookies for"),
258 ),
259 None => (
260 "default-browser-unknown",
261 "Could not determine the default browser to read cookies for".to_string(),
262 ),
263 };
264 MigrationEntry::new("source", best.browser.clone(), reason).with_detail(format!(
265 "{detail} {listed}; imported from {} instead.",
266 best.browser
267 ))
268 });
269 Ok(ImportSource {
270 browser: best.browser.clone(),
271 profile: Some(best.profile.clone()),
272 warning,
273 })
274}
275
276#[cfg(test)]
277pub(crate) mod tests {
278 use super::*;
280 use std::fs;
281
282 pub(crate) struct TempDir(PathBuf);
283
284 impl TempDir {
285 pub(crate) fn new(prefix: &str) -> Self {
286 use std::sync::atomic::{AtomicU64, Ordering};
287 static COUNTER: AtomicU64 = AtomicU64::new(0);
288 let nanos = std::time::SystemTime::now()
289 .duration_since(std::time::UNIX_EPOCH)
290 .map(|elapsed| elapsed.as_nanos())
291 .unwrap_or_default();
292 let count = COUNTER.fetch_add(1, Ordering::Relaxed);
293 let path = std::env::temp_dir()
294 .join(format!("{prefix}{}-{nanos:x}-{count}", std::process::id()));
295 fs::create_dir_all(&path).expect("temporary directory");
296 Self(path)
297 }
298
299 pub(crate) fn path(&self) -> &Path {
300 &self.0
301 }
302 }
303
304 impl Drop for TempDir {
305 fn drop(&mut self) {
306 let _ = fs::remove_dir_all(&self.0);
307 }
308 }
309
310 pub(crate) struct FirefoxCookie {
311 pub name: &'static str,
312 pub value: &'static str,
313 pub host: &'static str,
314 }
315
316 pub(crate) fn write_firefox_cookies(profile_dir: &Path, cookies: &[FirefoxCookie]) {
317 fs::create_dir_all(profile_dir).expect("profile dir");
318 let database = Connection::open(profile_dir.join("cookies.sqlite")).expect("open");
319 database
320 .execute_batch(
321 "CREATE TABLE moz_cookies (\
322 name TEXT, value TEXT, host TEXT, path TEXT, expiry INTEGER,\
323 isSecure INTEGER, isHttpOnly INTEGER, sameSite INTEGER\
324 );",
325 )
326 .expect("schema");
327 for cookie in cookies {
328 database
329 .execute(
330 "INSERT INTO moz_cookies \
331 (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite) \
332 VALUES (?1, ?2, ?3, '/', 0, 0, 0, 0)",
333 params![cookie.name, cookie.value, cookie.host],
334 )
335 .expect("insert");
336 }
337 }
338
339 fn make_firefox_profile(home: &Path, cookies: &[FirefoxCookie]) -> PathBuf {
340 make_firefox_profile_in(home, ".mozilla/firefox", "default-release", cookies)
341 }
342
343 fn make_firefox_profile_in(
345 home: &Path,
346 root: &str,
347 name: &str,
348 cookies: &[FirefoxCookie],
349 ) -> PathBuf {
350 let root = home.join(root);
351 let profile_name = format!("xyz.{name}");
352 let profile_path = root.join(&profile_name);
353 fs::create_dir_all(&profile_path).expect("profile");
354 fs::write(
355 root.join("profiles.ini"),
356 format!("[Profile0]\nName={name}\nIsRelative=1\nPath={profile_name}\nDefault=1\n"),
357 )
358 .expect("profiles.ini");
359 write_firefox_cookies(&profile_path, cookies);
360 profile_path
361 }
362
363 #[test]
364 fn reports_cookie_counts_per_profile_without_reading_values() {
365 let temp = TempDir::new("bc-src-");
366 let profile_path = make_firefox_profile(
367 temp.path(),
368 &[
369 FirefoxCookie {
370 name: "a",
371 value: "secret-1",
372 host: ".example.com",
373 },
374 FirefoxCookie {
375 name: "b",
376 value: "secret-2",
377 host: ".example.com",
378 },
379 FirefoxCookie {
380 name: "c",
381 value: "secret-3",
382 host: ".other.test",
383 },
384 ],
385 );
386
387 let sources = list_cookie_sources(&[], "linux", temp.path(), &Environment::new()).unwrap();
388 assert_eq!(sources.len(), 1);
389 let source = &sources[0];
390 assert_eq!(source.browser, "firefox");
391 assert_eq!(source.path, profile_path);
392 assert_eq!(source.cookies, Some(3));
393 assert_eq!(source.by_domain, None);
394 let payload = serde_json::to_string(&sources).unwrap();
396 assert!(!payload.contains("secret-"), "{payload}");
397 }
398
399 #[test]
400 fn counts_per_domain_and_omits_profiles_that_hold_none() {
401 let temp = TempDir::new("bc-src2-");
402 make_firefox_profile(
403 temp.path(),
404 &[
405 FirefoxCookie {
406 name: "a",
407 value: "1",
408 host: ".example.com",
409 },
410 FirefoxCookie {
411 name: "b",
412 value: "2",
413 host: ".example.com",
414 },
415 ],
416 );
417
418 let matched = list_cookie_sources(
419 &["example.com".to_string()],
420 "linux",
421 temp.path(),
422 &Environment::new(),
423 )
424 .unwrap();
425 assert_eq!(matched.len(), 1);
426 assert_eq!(
427 matched[0].by_domain,
428 Some(BTreeMap::from([("example.com".to_string(), 2)]))
429 );
430
431 let none = list_cookie_sources(
432 &["absent.test".to_string()],
433 "linux",
434 temp.path(),
435 &Environment::new(),
436 )
437 .unwrap();
438 assert!(none.is_empty());
439 }
440
441 const GITHUB_COOKIE: [FirefoxCookie; 1] = [FirefoxCookie {
442 name: "a",
443 value: "1",
444 host: ".github.com",
445 }];
446 const OTHER_COOKIE: [FirefoxCookie; 1] = [FirefoxCookie {
447 name: "b",
448 value: "2",
449 host: ".other.test",
450 }];
451
452 fn firefox_is_default() -> RunCommand {
453 std::sync::Arc::new(|_: &str, _: &[&str], _: &Environment| Ok("firefox.desktop\n".into()))
454 }
455
456 fn no_default() -> RunCommand {
457 std::sync::Arc::new(|_: &str, _: &[&str], _: &Environment| Err(anyhow!("no xdg")))
458 }
459
460 fn resolve(
461 browser: &str,
462 domains: &[&str],
463 home: &Path,
464 runner: &RunCommand,
465 ) -> Result<ImportSource> {
466 let domains: Vec<String> = domains.iter().map(|domain| domain.to_string()).collect();
467 resolve_import_source(
468 browser,
469 &domains,
470 "linux",
471 home,
472 &Environment::new(),
473 Some(runner),
474 )
475 }
476
477 #[test]
478 fn import_source_keeps_the_system_default_when_it_holds_the_domains() {
479 let temp = TempDir::new("bc-imp-");
481 make_firefox_profile(temp.path(), &GITHUB_COOKIE);
482 make_firefox_profile_in(temp.path(), ".librewolf", "default", &GITHUB_COOKIE);
483
484 let source = resolve(
485 "default",
486 &["github.com"],
487 temp.path(),
488 &firefox_is_default(),
489 )
490 .unwrap();
491 assert_eq!(
492 source,
493 ImportSource {
494 browser: "firefox".into(),
495 profile: Some("default-release".into()),
496 warning: None,
497 }
498 );
499 }
500
501 #[test]
502 fn import_source_falls_back_to_the_browser_that_holds_the_domains() {
503 let temp = TempDir::new("bc-imp2-");
504 make_firefox_profile(temp.path(), &OTHER_COOKIE);
505 make_firefox_profile_in(temp.path(), ".librewolf", "default", &GITHUB_COOKIE);
506
507 let source = resolve(
508 "default",
509 &["github.com"],
510 temp.path(),
511 &firefox_is_default(),
512 )
513 .unwrap();
514 assert_eq!(source.browser, "librewolf");
515 assert_eq!(source.profile.as_deref(), Some("default"));
516 let warning = source.warning.expect("fallback warning");
517 assert_eq!(warning.data_class, "source");
518 assert_eq!(warning.item, "librewolf");
519 assert_eq!(warning.reason, "default-browser-fallback");
520 assert_eq!(
521 warning.detail.as_deref(),
522 Some("The default browser (firefox) holds no cookies for github.com; imported from librewolf instead.")
523 );
524 }
525
526 #[test]
527 fn import_source_falls_back_when_the_default_is_unknown() {
528 let temp = TempDir::new("bc-imp3-");
529 let home = temp.path().join("home");
530 make_firefox_profile_in(&home, ".librewolf", "default", &GITHUB_COOKIE);
531
532 let source = resolve("auto", &["github.com"], &home, &no_default()).unwrap();
533 assert_eq!(source.browser, "librewolf");
534 assert_eq!(
535 source.warning.map(|warning| warning.reason).as_deref(),
536 Some("default-browser-unknown")
537 );
538 let error = resolve(
539 "default",
540 &["github.com"],
541 &temp.path().join("empty"),
542 &no_default(),
543 )
544 .unwrap_err();
545 assert!(
546 error
547 .to_string()
548 .contains("no installed browser holds cookies"),
549 "{error}"
550 );
551 }
552
553 #[test]
554 fn import_source_resolves_the_default_plainly_without_domains() {
555 let temp = TempDir::new("bc-imp4-");
556 let plain = |browser: &str, domains: &[&str]| {
557 resolve(browser, domains, temp.path(), &firefox_is_default()).unwrap()
558 };
559 assert_eq!(
560 plain("default", &[]),
561 ImportSource {
562 browser: "firefox".into(),
563 profile: None,
564 warning: None,
565 }
566 );
567 assert_eq!(plain("Opera", &["github.com"]).browser, "opera");
568 }
569}