Skip to main content

browser_commander/browser/
browser_cookie_sources.rs

1//! Which installed browsers hold cookies, and which one an import reads from.
2//!
3//! Everything here touches only host names and row counts, never cookie
4//! values, so it is safe to run before asking a person to import anything.
5
6use 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/// One installed browser profile that holds cookies, with per-domain counts
24/// when a domain filter is supplied. Cookie values are never read, so this is
25/// the data behind the `cookies sources` command.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct CookieSourceListing {
29    /// Normalized browser name.
30    pub browser: String,
31    /// On-disk profile name.
32    pub profile: String,
33    /// Profile directory holding the cookie database.
34    pub path: PathBuf,
35    /// Whether the browser marks this profile as the default one.
36    pub is_default: bool,
37    /// Total cookie count, absent when the database could not be read.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub cookies: Option<u64>,
40    /// Per-domain counts, present only when a domain filter was supplied.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub by_domain: Option<BTreeMap<String, u64>>,
43    /// Message describing why this profile's cookies could not be counted.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub error: Option<String>,
46}
47
48/// Count cookies in a database by domain, without ever reading a cookie value.
49/// Only host names and row counts are touched, so this is safe to expose for a
50/// "which browser holds cookies for this domain" listing.
51fn 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
96/// List the installed browser profiles that hold cookies, with per-domain
97/// counts when `domains` is given. Values are never read or returned — this is
98/// the data behind the `cookies sources` command.
99pub 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                // When filtering by domain, skip profiles that hold none.
139                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/// The browser an import reads from, as chosen by [`resolve_import_source`].
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct ImportSource {
173    /// Canonical catalogue id of the source browser.
174    pub browser: String,
175    /// The profile holding the requested cookies, when one was chosen.
176    pub profile: Option<String>,
177    /// A migration-report warning explaining a fallback away from the system
178    /// default browser.
179    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
190/// Pick the source browser for an import.
191///
192/// A `default`/`auto` request scoped to `domains` uses the system default
193/// browser when it holds cookies for them and otherwise falls back to the
194/// installed browser profile holding the most, so "import my github.com
195/// sign-in" works whichever browser has it. Only names and counts are read
196/// (see [`list_cookie_sources`]), never cookie values.
197pub 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    // The first profile with the most matching cookies; listing order breaks
251    // ties, so a browser's default profile wins over its others.
252    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    // feature-parity: sources.cookie-listing@native-typed
296    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    /// A Firefox-family install under `home` at `root` with one default profile.
362    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        // Never expose a value anywhere in the payload.
413        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        // feature-parity: sources.default-domain-fallback@native-typed
571        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}