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 match open_cookie_database(&cookie_path)
113 .and_then(|database| count_cookies_by_domain(&database, family, domains))
114 {
115 Ok((total, by_domain)) => {
116 let matched = by_domain
118 .as_ref()
119 .map(|counts| counts.values().sum::<u64>())
120 .unwrap_or(total);
121 if !domains.is_empty() && matched == 0 {
122 continue;
123 }
124 sources.push(CookieSourceListing {
125 browser: profile.browser,
126 profile: profile.name,
127 path: profile.path,
128 is_default: profile.is_default,
129 cookies: Some(total),
130 by_domain,
131 error: None,
132 });
133 }
134 Err(error) => sources.push(CookieSourceListing {
135 browser: profile.browser,
136 profile: profile.name,
137 path: profile.path,
138 is_default: profile.is_default,
139 cookies: None,
140 by_domain: None,
141 error: Some(error.to_string()),
142 }),
143 }
144 }
145 Ok(sources)
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct ImportSource {
151 pub browser: String,
153 pub profile: Option<String>,
155 pub warning: Option<MigrationEntry>,
158}
159
160fn matched_cookies(source: &CookieSourceListing) -> u64 {
161 source
162 .by_domain
163 .as_ref()
164 .map(|counts| counts.values().sum())
165 .unwrap_or(0)
166}
167
168pub fn resolve_import_source(
176 browser: &str,
177 domains: &[String],
178 platform: &str,
179 home_dir: &Path,
180 environment: &Environment,
181 run_command: Option<&RunCommand>,
182) -> Result<ImportSource> {
183 if !is_default_browser_keyword(browser) || domains.is_empty() {
184 return Ok(ImportSource {
185 browser: resolve_source_browser(browser, platform, environment, run_command)?
186 .to_string(),
187 profile: None,
188 warning: None,
189 });
190 }
191 let platform = normalize_platform(platform);
192 let fallback;
193 let runner = match run_command {
194 Some(runner) => runner,
195 None => {
196 fallback = default_run_command();
197 &fallback
198 }
199 };
200 let system_default = resolve_default_browser(platform, environment, runner)?;
201 let holders: Vec<CookieSourceListing> =
202 list_cookie_sources(domains, platform, home_dir, environment)?
203 .into_iter()
204 .filter(|source| source.error.is_none())
205 .collect();
206 let from_default: Vec<&CookieSourceListing> = holders
207 .iter()
208 .filter(|source| Some(source.browser.as_str()) == system_default)
209 .collect();
210 let candidates = if from_default.is_empty() {
211 holders.iter().collect()
212 } else {
213 from_default
214 };
215 let listed = domains.join(", ");
216 let Some(best) = candidates.iter().copied().reduce(|best, source| {
219 if matched_cookies(source) > matched_cookies(best) {
220 source
221 } else {
222 best
223 }
224 }) else {
225 let browser = system_default.ok_or_else(|| {
226 anyhow!(
227 "Could not determine the system default browser, and no installed browser holds cookies for {listed}."
228 )
229 })?;
230 return Ok(ImportSource {
231 browser: browser.to_string(),
232 profile: None,
233 warning: None,
234 });
235 };
236 let warning = (Some(best.browser.as_str()) != system_default).then(|| {
237 let (reason, detail) = match system_default {
238 Some(default) => (
239 "default-browser-fallback",
240 format!("The default browser ({default}) holds no cookies for"),
241 ),
242 None => (
243 "default-browser-unknown",
244 "Could not determine the default browser to read cookies for".to_string(),
245 ),
246 };
247 MigrationEntry::new("source", best.browser.clone(), reason).with_detail(format!(
248 "{detail} {listed}; imported from {} instead.",
249 best.browser
250 ))
251 });
252 Ok(ImportSource {
253 browser: best.browser.clone(),
254 profile: Some(best.profile.clone()),
255 warning,
256 })
257}
258
259#[cfg(test)]
260pub(crate) mod tests {
261 use super::*;
263 use std::fs;
264
265 pub(crate) struct TempDir(PathBuf);
266
267 impl TempDir {
268 pub(crate) fn new(prefix: &str) -> Self {
269 use std::sync::atomic::{AtomicU64, Ordering};
270 static COUNTER: AtomicU64 = AtomicU64::new(0);
271 let nanos = std::time::SystemTime::now()
272 .duration_since(std::time::UNIX_EPOCH)
273 .map(|elapsed| elapsed.as_nanos())
274 .unwrap_or_default();
275 let count = COUNTER.fetch_add(1, Ordering::Relaxed);
276 let path = std::env::temp_dir()
277 .join(format!("{prefix}{}-{nanos:x}-{count}", std::process::id()));
278 fs::create_dir_all(&path).expect("temporary directory");
279 Self(path)
280 }
281
282 pub(crate) fn path(&self) -> &Path {
283 &self.0
284 }
285 }
286
287 impl Drop for TempDir {
288 fn drop(&mut self) {
289 let _ = fs::remove_dir_all(&self.0);
290 }
291 }
292
293 pub(crate) struct FirefoxCookie {
294 pub name: &'static str,
295 pub value: &'static str,
296 pub host: &'static str,
297 }
298
299 pub(crate) fn write_firefox_cookies(profile_dir: &Path, cookies: &[FirefoxCookie]) {
300 fs::create_dir_all(profile_dir).expect("profile dir");
301 let database = Connection::open(profile_dir.join("cookies.sqlite")).expect("open");
302 database
303 .execute_batch(
304 "CREATE TABLE moz_cookies (\
305 name TEXT, value TEXT, host TEXT, path TEXT, expiry INTEGER,\
306 isSecure INTEGER, isHttpOnly INTEGER, sameSite INTEGER\
307 );",
308 )
309 .expect("schema");
310 for cookie in cookies {
311 database
312 .execute(
313 "INSERT INTO moz_cookies \
314 (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite) \
315 VALUES (?1, ?2, ?3, '/', 0, 0, 0, 0)",
316 params![cookie.name, cookie.value, cookie.host],
317 )
318 .expect("insert");
319 }
320 }
321
322 fn make_firefox_profile(home: &Path, cookies: &[FirefoxCookie]) -> PathBuf {
323 make_firefox_profile_in(home, ".mozilla/firefox", "default-release", cookies)
324 }
325
326 fn make_firefox_profile_in(
328 home: &Path,
329 root: &str,
330 name: &str,
331 cookies: &[FirefoxCookie],
332 ) -> PathBuf {
333 let root = home.join(root);
334 let profile_name = format!("xyz.{name}");
335 let profile_path = root.join(&profile_name);
336 fs::create_dir_all(&profile_path).expect("profile");
337 fs::write(
338 root.join("profiles.ini"),
339 format!("[Profile0]\nName={name}\nIsRelative=1\nPath={profile_name}\nDefault=1\n"),
340 )
341 .expect("profiles.ini");
342 write_firefox_cookies(&profile_path, cookies);
343 profile_path
344 }
345
346 #[test]
347 fn reports_cookie_counts_per_profile_without_reading_values() {
348 let temp = TempDir::new("bc-src-");
349 let profile_path = make_firefox_profile(
350 temp.path(),
351 &[
352 FirefoxCookie {
353 name: "a",
354 value: "secret-1",
355 host: ".example.com",
356 },
357 FirefoxCookie {
358 name: "b",
359 value: "secret-2",
360 host: ".example.com",
361 },
362 FirefoxCookie {
363 name: "c",
364 value: "secret-3",
365 host: ".other.test",
366 },
367 ],
368 );
369
370 let sources = list_cookie_sources(&[], "linux", temp.path(), &Environment::new()).unwrap();
371 assert_eq!(sources.len(), 1);
372 let source = &sources[0];
373 assert_eq!(source.browser, "firefox");
374 assert_eq!(source.path, profile_path);
375 assert_eq!(source.cookies, Some(3));
376 assert_eq!(source.by_domain, None);
377 let payload = serde_json::to_string(&sources).unwrap();
379 assert!(!payload.contains("secret-"), "{payload}");
380 }
381
382 #[test]
383 fn counts_per_domain_and_omits_profiles_that_hold_none() {
384 let temp = TempDir::new("bc-src2-");
385 make_firefox_profile(
386 temp.path(),
387 &[
388 FirefoxCookie {
389 name: "a",
390 value: "1",
391 host: ".example.com",
392 },
393 FirefoxCookie {
394 name: "b",
395 value: "2",
396 host: ".example.com",
397 },
398 ],
399 );
400
401 let matched = list_cookie_sources(
402 &["example.com".to_string()],
403 "linux",
404 temp.path(),
405 &Environment::new(),
406 )
407 .unwrap();
408 assert_eq!(matched.len(), 1);
409 assert_eq!(
410 matched[0].by_domain,
411 Some(BTreeMap::from([("example.com".to_string(), 2)]))
412 );
413
414 let none = list_cookie_sources(
415 &["absent.test".to_string()],
416 "linux",
417 temp.path(),
418 &Environment::new(),
419 )
420 .unwrap();
421 assert!(none.is_empty());
422 }
423
424 const GITHUB_COOKIE: [FirefoxCookie; 1] = [FirefoxCookie {
425 name: "a",
426 value: "1",
427 host: ".github.com",
428 }];
429 const OTHER_COOKIE: [FirefoxCookie; 1] = [FirefoxCookie {
430 name: "b",
431 value: "2",
432 host: ".other.test",
433 }];
434
435 fn firefox_is_default() -> RunCommand {
436 std::sync::Arc::new(|_: &str, _: &[&str], _: &Environment| Ok("firefox.desktop\n".into()))
437 }
438
439 fn no_default() -> RunCommand {
440 std::sync::Arc::new(|_: &str, _: &[&str], _: &Environment| Err(anyhow!("no xdg")))
441 }
442
443 fn resolve(
444 browser: &str,
445 domains: &[&str],
446 home: &Path,
447 runner: &RunCommand,
448 ) -> Result<ImportSource> {
449 let domains: Vec<String> = domains.iter().map(|domain| domain.to_string()).collect();
450 resolve_import_source(
451 browser,
452 &domains,
453 "linux",
454 home,
455 &Environment::new(),
456 Some(runner),
457 )
458 }
459
460 #[test]
461 fn import_source_keeps_the_system_default_when_it_holds_the_domains() {
462 let temp = TempDir::new("bc-imp-");
464 make_firefox_profile(temp.path(), &GITHUB_COOKIE);
465 make_firefox_profile_in(temp.path(), ".librewolf", "default", &GITHUB_COOKIE);
466
467 let source = resolve(
468 "default",
469 &["github.com"],
470 temp.path(),
471 &firefox_is_default(),
472 )
473 .unwrap();
474 assert_eq!(
475 source,
476 ImportSource {
477 browser: "firefox".into(),
478 profile: Some("default-release".into()),
479 warning: None,
480 }
481 );
482 }
483
484 #[test]
485 fn import_source_falls_back_to_the_browser_that_holds_the_domains() {
486 let temp = TempDir::new("bc-imp2-");
487 make_firefox_profile(temp.path(), &OTHER_COOKIE);
488 make_firefox_profile_in(temp.path(), ".librewolf", "default", &GITHUB_COOKIE);
489
490 let source = resolve(
491 "default",
492 &["github.com"],
493 temp.path(),
494 &firefox_is_default(),
495 )
496 .unwrap();
497 assert_eq!(source.browser, "librewolf");
498 assert_eq!(source.profile.as_deref(), Some("default"));
499 let warning = source.warning.expect("fallback warning");
500 assert_eq!(warning.data_class, "source");
501 assert_eq!(warning.item, "librewolf");
502 assert_eq!(warning.reason, "default-browser-fallback");
503 assert_eq!(
504 warning.detail.as_deref(),
505 Some("The default browser (firefox) holds no cookies for github.com; imported from librewolf instead.")
506 );
507 }
508
509 #[test]
510 fn import_source_falls_back_when_the_default_is_unknown() {
511 let temp = TempDir::new("bc-imp3-");
512 let home = temp.path().join("home");
513 make_firefox_profile_in(&home, ".librewolf", "default", &GITHUB_COOKIE);
514
515 let source = resolve("auto", &["github.com"], &home, &no_default()).unwrap();
516 assert_eq!(source.browser, "librewolf");
517 assert_eq!(
518 source.warning.map(|warning| warning.reason).as_deref(),
519 Some("default-browser-unknown")
520 );
521 let error = resolve(
522 "default",
523 &["github.com"],
524 &temp.path().join("empty"),
525 &no_default(),
526 )
527 .unwrap_err();
528 assert!(
529 error
530 .to_string()
531 .contains("no installed browser holds cookies"),
532 "{error}"
533 );
534 }
535
536 #[test]
537 fn import_source_resolves_the_default_plainly_without_domains() {
538 let temp = TempDir::new("bc-imp4-");
539 let plain = |browser: &str, domains: &[&str]| {
540 resolve(browser, domains, temp.path(), &firefox_is_default()).unwrap()
541 };
542 assert_eq!(
543 plain("default", &[]),
544 ImportSource {
545 browser: "firefox".into(),
546 profile: None,
547 warning: None,
548 }
549 );
550 assert_eq!(plain("Opera", &["github.com"]).browser, "opera");
551 }
552}