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