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::{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/// One installed browser profile that holds cookies, with per-domain counts
23/// when a domain filter is supplied. Cookie values are never read, so this is
24/// the data behind the `cookies sources` command.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct CookieSourceListing {
28    /// Normalized browser name.
29    pub browser: String,
30    /// On-disk profile name.
31    pub profile: String,
32    /// Profile directory holding the cookie database.
33    pub path: PathBuf,
34    /// Whether the browser marks this profile as the default one.
35    pub is_default: bool,
36    /// Total cookie count, absent when the database could not be read.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub cookies: Option<u64>,
39    /// Per-domain counts, present only when a domain filter was supplied.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub by_domain: Option<BTreeMap<String, u64>>,
42    /// Message describing why this profile's cookies could not be counted.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub error: Option<String>,
45}
46
47/// Count cookies in a database by domain, without ever reading a cookie value.
48/// Only host names and row counts are touched, so this is safe to expose for a
49/// "which browser holds cookies for this domain" listing.
50fn 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
91/// List the installed browser profiles that hold cookies, with per-domain
92/// counts when `domains` is given. Values are never read or returned — this is
93/// the data behind the `cookies sources` command.
94pub 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                // When filtering by domain, skip profiles that hold none.
122                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/// The browser an import reads from, as chosen by [`resolve_import_source`].
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct ImportSource {
156    /// Canonical catalogue id of the source browser.
157    pub browser: String,
158    /// The profile holding the requested cookies, when one was chosen.
159    pub profile: Option<String>,
160    /// A migration-report warning explaining a fallback away from the system
161    /// default browser.
162    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
173/// Pick the source browser for an import.
174///
175/// A `default`/`auto` request scoped to `domains` uses the system default
176/// browser when it holds cookies for them and otherwise falls back to the
177/// installed browser profile holding the most, so "import my github.com
178/// sign-in" works whichever browser has it. Only names and counts are read
179/// (see [`list_cookie_sources`]), never cookie values.
180pub 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    // The first profile with the most matching cookies; listing order breaks
234    // ties, so a browser's default profile wins over its others.
235    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    // feature-parity: sources.cookie-listing@native-typed
279    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    /// A Firefox-family install under `home` at `root` with one default profile.
344    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        // Never expose a value anywhere in the payload.
395        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        // feature-parity: sources.default-domain-fallback@native-typed
480        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}