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        match open_cookie_database(&cookie_path)
113            .and_then(|database| count_cookies_by_domain(&database, family, domains))
114        {
115            Ok((total, by_domain)) => {
116                // When filtering by domain, skip profiles that hold none.
117                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/// The browser an import reads from, as chosen by [`resolve_import_source`].
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct ImportSource {
151    /// Canonical catalogue id of the source browser.
152    pub browser: String,
153    /// The profile holding the requested cookies, when one was chosen.
154    pub profile: Option<String>,
155    /// A migration-report warning explaining a fallback away from the system
156    /// default browser.
157    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
168/// Pick the source browser for an import.
169///
170/// A `default`/`auto` request scoped to `domains` uses the system default
171/// browser when it holds cookies for them and otherwise falls back to the
172/// installed browser profile holding the most, so "import my github.com
173/// sign-in" works whichever browser has it. Only names and counts are read
174/// (see [`list_cookie_sources`]), never cookie values.
175pub 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    // The first profile with the most matching cookies; listing order breaks
217    // ties, so a browser's default profile wins over its others.
218    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    // feature-parity: sources.cookie-listing@native-typed
262    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    /// A Firefox-family install under `home` at `root` with one default profile.
327    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        // Never expose a value anywhere in the payload.
378        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        // feature-parity: sources.default-domain-fallback@native-typed
463        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}