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