Skip to main content

browser_commander/browser/migration/
mod.rs

1//! Profile migration, mirroring `js/src/browser/migration/index.js`.
2//!
3//! Copies a person's data from a real browser profile into a dedicated target
4//! profile, one data class at a time, and returns the report shape documented
5//! in `docs/cli-and-bridge.md`. Nothing is ever written to the source profile,
6//! and every database read goes through a consistent read-only snapshot, so the
7//! migration is safe to run while the source browser is open.
8//!
9//! Cookies are returned (not written): a running Chromium re-derives its own
10//! cookie encryption, so the launcher seeds them over CDP with the existing
11//! `seed_cookies` path. Every other data class is written into the target
12//! profile directory.
13
14mod bookmarks;
15mod chromium_crypto;
16mod cookies;
17mod extensions;
18mod firefox;
19mod firefox_bookmarks;
20mod firefox_der;
21mod firefox_nss;
22mod fs_utils;
23mod history;
24mod os_crypt_keys;
25mod passwords;
26mod preferences;
27pub(crate) mod sqlite_snapshot;
28
29use std::path::{Path, PathBuf};
30
31use anyhow::{anyhow, Result};
32use serde::{Deserialize, Serialize};
33
34use crate::browser::browser_cookie_sources::resolve_import_source;
35use crate::browser::browser_cookies::BrowserCookie;
36use crate::browser::browser_profiles::{
37    browser_profile_root_in, current_platform, normalize_platform, resolve_browser_profile,
38    BrowserProfileOptions,
39};
40use crate::browser::browser_sources::{
41    browser_family, current_environment, is_single_profile_browser, Environment,
42};
43use crate::browser::default_browser::RunCommand;
44
45pub use cookies::{CookieReader, DBSC_BOUND_COOKIE_NAMES};
46pub use firefox_nss::PrimaryPasswordError;
47pub use os_crypt_keys::{
48    KeystoreHooks, SafeStoragePasswordReader, SourceKeyResolver, TargetKey, WindowsKeyReader,
49};
50pub use preferences::MIGRATED_PREFERENCE_PATHS;
51
52/// Every data class a migration can copy, in the order they run.
53pub const ALL_DATA_CLASSES: [&str; 6] = [
54    "cookies",
55    "bookmarks",
56    "history",
57    "passwords",
58    "preferences",
59    "extensions",
60];
61
62const TARGET_KEY_UNAVAILABLE_DETAIL: &str = "A target encryption key was not available (on Windows the launcher must generate one and write it into the target Local State); passwords were not migrated.";
63
64/// One skipped item or warning in a migration report.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct MigrationEntry {
67    /// Data class (`cookies`, `bookmarks`, ...).
68    #[serde(rename = "type")]
69    pub data_class: String,
70    /// The file, host or cookie the entry is about.
71    pub item: String,
72    /// A stable machine-readable reason.
73    pub reason: String,
74    /// Human-readable detail, when there is any.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub detail: Option<String>,
77}
78
79impl MigrationEntry {
80    /// Build an entry without detail.
81    pub fn new(
82        data_class: impl Into<String>,
83        item: impl Into<String>,
84        reason: impl Into<String>,
85    ) -> Self {
86        Self {
87            data_class: data_class.into(),
88            item: item.into(),
89            reason: reason.into(),
90            detail: None,
91        }
92    }
93
94    /// Attach human-readable detail.
95    #[must_use]
96    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
97        self.detail = Some(detail.into());
98        self
99    }
100}
101
102/// What one data-class step migrated, skipped and warned about.
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub(crate) struct ClassOutcome {
105    pub migrated: u64,
106    pub skipped: Vec<MigrationEntry>,
107    pub warnings: Vec<MigrationEntry>,
108}
109
110impl ClassOutcome {
111    pub(crate) fn migrated(count: u64) -> Self {
112        Self {
113            migrated: count,
114            ..Self::default()
115        }
116    }
117
118    pub(crate) fn skipped(entry: MigrationEntry) -> Self {
119        Self {
120            skipped: vec![entry],
121            ..Self::default()
122        }
123    }
124}
125
126/// Per-class migrated counts.
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
128pub struct MigratedCounts {
129    pub cookies: u64,
130    pub bookmarks: u64,
131    pub history: u64,
132    pub passwords: u64,
133    pub preferences: u64,
134    pub extensions: u64,
135}
136
137impl MigratedCounts {
138    fn add(&mut self, data_class: &str, count: u64) {
139        let slot = match data_class {
140            "cookies" => &mut self.cookies,
141            "bookmarks" => &mut self.bookmarks,
142            "history" => &mut self.history,
143            "passwords" => &mut self.passwords,
144            "preferences" => &mut self.preferences,
145            _ => &mut self.extensions,
146        };
147        *slot += count;
148    }
149}
150
151/// The source a migration read from.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct MigrationReportSource {
155    /// Normalized browser name.
156    pub browser: String,
157    /// Profile name (`Default` unless one was requested).
158    pub profile: String,
159    /// The explicit user data directory, or `null`.
160    pub user_data_dir: Option<PathBuf>,
161}
162
163/// The full result of [`migrate_profile`], including the cookies to seed.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct MigrationReport {
166    pub source: MigrationReportSource,
167    /// Target profile directory.
168    pub target: PathBuf,
169    pub migrated: MigratedCounts,
170    pub skipped: Vec<MigrationEntry>,
171    pub warnings: Vec<MigrationEntry>,
172    /// Cookies read from the source, to seed over CDP (not written to disk).
173    pub cookies: Vec<BrowserCookie>,
174}
175
176/// A migration report without its cookies, as `launch_real_browser` returns
177/// it (the cookies are seeded, never echoed back).
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct MigrationSummary {
180    pub source: MigrationReportSource,
181    pub target: PathBuf,
182    pub migrated: MigratedCounts,
183    pub skipped: Vec<MigrationEntry>,
184    pub warnings: Vec<MigrationEntry>,
185}
186
187impl MigrationReport {
188    /// Split the report into its cookies and the cookie-free summary.
189    pub fn into_parts(self) -> (Vec<BrowserCookie>, MigrationSummary) {
190        (
191            self.cookies,
192            MigrationSummary {
193                source: self.source,
194                target: self.target,
195                migrated: self.migrated,
196                skipped: self.skipped,
197                warnings: self.warnings,
198            },
199        )
200    }
201
202    fn merge(&mut self, data_class: &str, outcome: ClassOutcome) {
203        self.migrated.add(data_class, outcome.migrated);
204        self.skipped.extend(outcome.skipped);
205        self.warnings.extend(outcome.warnings);
206    }
207}
208
209/// Where to migrate from (`from` in the JavaScript API).
210#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct MigrationSource {
213    /// Source browser (`chrome`, `edge`/`msedge`, `brave`, `chromium`, `firefox`).
214    pub browser: String,
215    /// Profile name; defaults to `Default`.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub profile: Option<String>,
218    /// Explicit user data directory (for Firefox, the profile directory).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub user_data_dir: Option<PathBuf>,
221}
222
223impl MigrationSource {
224    /// Migrate from `browser`'s default profile.
225    pub fn new(browser: impl Into<String>) -> Self {
226        Self {
227            browser: browser.into(),
228            ..Self::default()
229        }
230    }
231
232    /// Select a named profile.
233    #[must_use]
234    pub fn profile(mut self, profile: impl Into<String>) -> Self {
235        self.profile = Some(profile.into());
236        self
237    }
238
239    /// Read from an explicit user data directory.
240    #[must_use]
241    pub fn user_data_dir(mut self, user_data_dir: impl Into<PathBuf>) -> Self {
242        self.user_data_dir = Some(user_data_dir.into());
243        self
244    }
245}
246
247/// Injected password keys (`keys` in the JavaScript API).
248#[derive(Clone, Default)]
249pub struct MigrationKeys {
250    /// Resolves the source key per encryption prefix (Chromium sources).
251    pub resolve_source_key: Option<SourceKeyResolver>,
252    /// The dedicated profile's encryption key.
253    pub target_key: Option<Vec<u8>>,
254    /// Version prefix to write (`v10`/`v11`).
255    pub target_prefix: Option<String>,
256    /// The Firefox primary password, when one is set.
257    pub primary_password: Option<Vec<u8>>,
258}
259
260impl std::fmt::Debug for MigrationKeys {
261    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        formatter
263            .debug_struct("MigrationKeys")
264            .field("resolve_source_key", &self.resolve_source_key.is_some())
265            .field(
266                "target_key",
267                &self.target_key.as_ref().map(|_| "<redacted>"),
268            )
269            .field("target_prefix", &self.target_prefix)
270            .finish_non_exhaustive()
271    }
272}
273
274/// Options for [`migrate_profile`].
275#[derive(Clone)]
276pub struct MigrateProfileOptions {
277    pub from: MigrationSource,
278    /// Target profile directory.
279    pub to: PathBuf,
280    /// Data classes to migrate; defaults to [`ALL_DATA_CLASSES`].
281    pub include: Vec<String>,
282    /// Cookie domain filter (empty means all).
283    pub domains: Vec<String>,
284    /// Platform convention (`linux`, `darwin`, `win32`).
285    pub platform: String,
286    /// The launching browser channel, used to derive the target key.
287    pub target_browser: Option<String>,
288    /// Injected password keys.
289    pub keys: Option<MigrationKeys>,
290    /// Home directory used to find conventional profile locations.
291    pub home_dir: PathBuf,
292    /// OS keystore readers (Safe Storage, Windows `Local State`).
293    pub keystore: KeystoreHooks,
294    /// Installed-browser cookie reader for Chromium sources.
295    pub read_cookies: CookieReader,
296    /// Environment used to expand profile-root templates.
297    pub environment: Environment,
298    /// Command runner for the default-browser lookup. With `from.browser`
299    /// `default`/`auto` and `domains`, an import falls back from a default
300    /// browser holding none of them to the installed profile holding the most,
301    /// adding a `default-browser-fallback` warning.
302    pub run_command: Option<RunCommand>,
303}
304
305impl std::fmt::Debug for MigrateProfileOptions {
306    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        formatter
308            .debug_struct("MigrateProfileOptions")
309            .field("from", &self.from)
310            .field("to", &self.to)
311            .field("include", &self.include)
312            .field("domains", &self.domains)
313            .field("platform", &self.platform)
314            .field("target_browser", &self.target_browser)
315            .field("keys", &self.keys)
316            .field("home_dir", &self.home_dir)
317            .finish_non_exhaustive()
318    }
319}
320
321impl MigrateProfileOptions {
322    /// Migrate everything from `from` into the target profile directory `to`.
323    pub fn new(from: MigrationSource, to: impl Into<PathBuf>) -> Self {
324        Self {
325            from,
326            to: to.into(),
327            include: ALL_DATA_CLASSES
328                .iter()
329                .map(|name| name.to_string())
330                .collect(),
331            domains: Vec::new(),
332            platform: current_platform().to_string(),
333            target_browser: None,
334            keys: None,
335            home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
336            keystore: KeystoreHooks::default(),
337            read_cookies: cookies::default_cookie_reader(),
338            environment: current_environment(),
339            run_command: None,
340        }
341    }
342
343    /// Restrict the migrated data classes.
344    #[must_use]
345    pub fn include<I, S>(mut self, include: I) -> Self
346    where
347        I: IntoIterator<Item = S>,
348        S: Into<String>,
349    {
350        self.include = include.into_iter().map(Into::into).collect();
351        self
352    }
353
354    /// Restrict migrated cookies to hosts containing one of `domains`.
355    #[must_use]
356    pub fn domains<I, S>(mut self, domains: I) -> Self
357    where
358        I: IntoIterator<Item = S>,
359        S: Into<String>,
360    {
361        self.domains = domains.into_iter().map(Into::into).collect();
362        self
363    }
364
365    /// Override the platform convention.
366    #[must_use]
367    pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
368        self.platform = normalize_platform(platform.as_ref()).to_string();
369        self
370    }
371
372    /// Set the launching browser channel (target key derivation).
373    #[must_use]
374    pub fn target_browser(mut self, target_browser: impl Into<String>) -> Self {
375        self.target_browser = Some(target_browser.into());
376        self
377    }
378
379    /// Inject password keys.
380    #[must_use]
381    pub fn keys(mut self, keys: MigrationKeys) -> Self {
382        self.keys = Some(keys);
383        self
384    }
385
386    /// Override the home directory.
387    #[must_use]
388    pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
389        self.home_dir = home_dir.into();
390        self
391    }
392
393    /// Override the OS keystore readers.
394    #[must_use]
395    pub fn keystore(mut self, keystore: KeystoreHooks) -> Self {
396        self.keystore = keystore;
397        self
398    }
399
400    /// Override the Chromium cookie reader.
401    #[must_use]
402    pub fn read_cookies(mut self, read_cookies: CookieReader) -> Self {
403        self.read_cookies = read_cookies;
404        self
405    }
406
407    /// Override the environment used to expand profile-root templates.
408    #[must_use]
409    pub fn environment(mut self, environment: Environment) -> Self {
410        self.environment = environment;
411        self
412    }
413
414    /// Inject the default-browser lookup's command runner.
415    #[must_use]
416    pub fn run_command(mut self, run_command: RunCommand) -> Self {
417        self.run_command = Some(run_command);
418        self
419    }
420}
421
422fn is_chromium(browser: &str) -> bool {
423    browser_family(browser)
424        .map(|family| family == "chromium")
425        .unwrap_or(false)
426}
427
428fn is_firefox_browser(browser: &str) -> bool {
429    browser_family(browser)
430        .map(|family| family == "firefox")
431        .unwrap_or(false)
432}
433
434fn resolve_source_profile_dir(
435    browser: &str,
436    profile: &str,
437    options: &MigrateProfileOptions,
438) -> Result<PathBuf> {
439    // A Chromium profile lives in a named subdirectory of the user data dir,
440    // except in single-profile browsers (Opera) that keep it in the root; for
441    // Firefox the user data directory already points at the profile.
442    let nests_profiles = is_chromium(browser) && !is_single_profile_browser(browser)?;
443    let in_root = |root: PathBuf| {
444        if nests_profiles {
445            root.join(profile)
446        } else {
447            root
448        }
449    };
450    if let Some(user_data_dir) = &options.from.user_data_dir {
451        return Ok(in_root(user_data_dir.clone()));
452    }
453    if is_chromium(browser) {
454        return Ok(in_root(browser_profile_root_in(
455            browser,
456            &options.platform,
457            &options.home_dir,
458            &options.environment,
459        )?));
460    }
461    let profile_options = BrowserProfileOptions::default()
462        .home_dir(&options.home_dir)
463        .platform(&options.platform)
464        .environment(options.environment.clone());
465    Ok(resolve_browser_profile(browser, Some(profile), &profile_options)?.path)
466}
467
468fn resolve_password_keys(
469    options: &MigrateProfileOptions,
470    browser: &str,
471    target_browser: &str,
472    source_profile_dir: &Path,
473) -> Result<Option<MigrationKeys>> {
474    if let Some(keys) = &options.keys {
475        if keys.target_key.as_ref().is_some_and(|key| !key.is_empty()) {
476            return Ok(Some(keys.clone()));
477        }
478    }
479    let platform = options.platform.as_str();
480    if platform != "darwin" && platform != "linux" {
481        return Ok(None);
482    }
483    let TargetKey { key, prefix } =
484        os_crypt_keys::resolve_target_key(target_browser, platform, &options.keystore)?;
485    let resolve_source_key = is_chromium(browser).then(|| {
486        os_crypt_keys::create_source_key_resolver(
487            browser,
488            platform,
489            Some(os_crypt_keys::local_state_path_for_profile(
490                source_profile_dir,
491            )),
492            options.keystore.clone(),
493        )
494    });
495    Ok(Some(MigrationKeys {
496        resolve_source_key,
497        target_key: Some(key),
498        target_prefix: Some(prefix),
499        primary_password: None,
500    }))
501}
502
503fn migrate_passwords_class(
504    options: &MigrateProfileOptions,
505    browser: &str,
506    source_profile_dir: &Path,
507    report: &mut MigrationReport,
508) -> Result<()> {
509    let is_firefox = is_firefox_browser(browser);
510    let target_browser = options.target_browser.clone().unwrap_or_else(|| {
511        if is_firefox {
512            "chrome".into()
513        } else {
514            browser.into()
515        }
516    });
517    let keys = resolve_password_keys(options, browser, &target_browser, source_profile_dir)?;
518    let Some((keys, target_key)) = keys.and_then(|keys| {
519        let target_key = keys.target_key.clone().filter(|key| !key.is_empty())?;
520        Some((keys, target_key))
521    }) else {
522        report.skipped.push(MigrationEntry::new(
523            "passwords",
524            "Login Data",
525            "target-key-unavailable",
526        ));
527        report.warnings.push(
528            MigrationEntry::new("passwords", "Login Data", "target-key-unavailable")
529                .with_detail(TARGET_KEY_UNAVAILABLE_DETAIL),
530        );
531        return Ok(());
532    };
533    let outcome = if is_firefox {
534        firefox::migrate_firefox_passwords(
535            source_profile_dir,
536            &options.to,
537            &firefox::FirefoxPasswordKeys {
538                platform: &options.platform,
539                target_key: &target_key,
540                target_prefix: keys.target_prefix.as_deref(),
541                primary_password: keys.primary_password.as_deref().unwrap_or_default(),
542            },
543        )?
544    } else {
545        let resolve_source_key = keys.resolve_source_key.clone().unwrap_or_else(|| {
546            os_crypt_keys::create_source_key_resolver(
547                browser,
548                &options.platform,
549                Some(os_crypt_keys::local_state_path_for_profile(
550                    source_profile_dir,
551                )),
552                options.keystore.clone(),
553            )
554        });
555        passwords::migrate_passwords(
556            source_profile_dir,
557            &options.to,
558            &passwords::PasswordKeys {
559                platform: &options.platform,
560                resolve_source_key: &resolve_source_key,
561                target_key: &target_key,
562                target_prefix: keys.target_prefix.as_deref(),
563            },
564        )?
565    };
566    report.merge("passwords", outcome);
567    Ok(())
568}
569
570/// Migrate a browser profile into a dedicated target profile directory.
571///
572/// Blocking: it reads SQLite snapshots and may query the OS keystore. From
573/// async code, call it through `tokio::task::spawn_blocking`.
574pub fn migrate_profile(options: MigrateProfileOptions) -> Result<MigrationReport> {
575    if options.from.browser.is_empty() {
576        return Err(anyhow!("migrate_profile requires from.browser"));
577    }
578    if options.to.as_os_str().is_empty() {
579        return Err(anyhow!("migrate_profile requires a target directory (to)"));
580    }
581    // An explicit user data dir names the source, so only an installed-browser
582    // import is steered towards the profile holding the requested domains.
583    let no_domains: &[String] = &[];
584    let source = resolve_import_source(
585        &options.from.browser,
586        if options.from.user_data_dir.is_some() {
587            no_domains
588        } else {
589            &options.domains
590        },
591        &options.platform,
592        &options.home_dir,
593        &options.environment,
594        options.run_command.as_ref(),
595    )?;
596    let browser = source.browser;
597    let profile = options
598        .from
599        .profile
600        .clone()
601        .or(source.profile)
602        .unwrap_or_else(|| "Default".to_string());
603    let is_firefox = is_firefox_browser(&browser);
604    let source_profile_dir = resolve_source_profile_dir(&browser, &profile, &options)?;
605    let selected = |name: &str| options.include.iter().any(|entry| entry == name);
606    let mut report = MigrationReport {
607        source: MigrationReportSource {
608            browser: browser.clone(),
609            profile: profile.clone(),
610            user_data_dir: options.from.user_data_dir.clone(),
611        },
612        target: options.to.clone(),
613        migrated: MigratedCounts::default(),
614        skipped: Vec::new(),
615        warnings: source.warning.into_iter().collect(),
616        cookies: Vec::new(),
617    };
618    let target = options.to.as_path();
619
620    if selected("cookies") {
621        if is_firefox {
622            let cookies =
623                firefox::read_firefox_profile_cookies(&source_profile_dir, &options.domains)?;
624            report.migrated.cookies = cookies.len() as u64;
625            report.cookies = cookies;
626        } else {
627            let (cookies, outcome) = cookies::migrate_cookies(
628                &cookies::CookieSource {
629                    browser: &browser,
630                    profile: Some(&profile),
631                    source_profile_dir: Some(&source_profile_dir),
632                    domains: &options.domains,
633                    platform: &options.platform,
634                    home_dir: &options.home_dir,
635                },
636                &options.read_cookies,
637            )?;
638            report.cookies = cookies;
639            report.merge("cookies", outcome);
640        }
641    }
642
643    if browser_family(&browser)? == "safari" {
644        for data_class in ALL_DATA_CLASSES
645            .iter()
646            .filter(|name| **name != "cookies" && selected(name))
647        {
648            let (reason, detail) = if *data_class == "passwords" {
649                ("safari-password-export-required", "Safari passwords live in the Keychain. Export Passwords from Safari or the Passwords app to CSV; CSV import is tracked separately and is not supported yet.")
650            } else {
651                ("safari-class-not-supported", "Safari currently supports cookie import only; this data class has not been translated.")
652            };
653            report
654                .skipped
655                .push(MigrationEntry::new(*data_class, &browser, reason).with_detail(detail));
656        }
657        if !report.cookies.is_empty() {
658            report.warnings.push(
659                MigrationEntry::new("cookies", &browser, "safari-samesite-unavailable")
660                    .with_detail(
661                        "Cookies.binarycookies does not store SameSite; imported cookies use Lax.",
662                    ),
663            );
664        }
665        return Ok(report);
666    }
667
668    if selected("bookmarks") {
669        let outcome = if is_firefox {
670            firefox::migrate_firefox_bookmarks(&source_profile_dir, target)?
671        } else {
672            bookmarks::migrate_bookmarks(&source_profile_dir, target)?
673        };
674        report.merge("bookmarks", outcome);
675    }
676
677    if selected("history") {
678        let outcome = if is_firefox {
679            firefox::report_firefox_history(&source_profile_dir)?
680        } else {
681            history::migrate_history(&source_profile_dir, target)?
682        };
683        report.merge("history", outcome);
684    }
685
686    if selected("preferences") && !is_firefox {
687        let outcome = preferences::migrate_preferences(&source_profile_dir, target)?;
688        report.merge("preferences", outcome);
689    }
690
691    if selected("extensions") && !is_firefox {
692        let outcome = extensions::migrate_extensions(&source_profile_dir, target)?;
693        report.merge("extensions", outcome);
694    }
695
696    if selected("passwords") {
697        migrate_passwords_class(&options, &browser, &source_profile_dir, &mut report)?;
698    }
699
700    Ok(report)
701}
702
703#[cfg(test)]
704#[path = "tests/mod.rs"]
705mod tests;