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. Supported file imports are written into the target;
12//! classes without a native writer receive explicit skipped reports.
13
14mod bookmarks;
15mod chromium_crypto;
16mod chromium_writers;
17mod cookies;
18mod data_classes;
19pub(crate) mod domains;
20mod extensions;
21mod firefox;
22mod firefox_bookmarks;
23mod firefox_der;
24mod firefox_history;
25mod firefox_nss;
26mod fs_utils;
27mod history;
28mod os_crypt_keys;
29mod password_metadata;
30mod passwords;
31mod preferences;
32pub(crate) mod safari;
33mod safari_import;
34pub(crate) mod sqlite_snapshot;
35mod validation;
36
37use std::path::{Path, PathBuf};
38
39use anyhow::{anyhow, Result};
40use serde::{Deserialize, Serialize};
41
42use crate::browser::browser_cookie_sources::resolve_import_source;
43use crate::browser::browser_cookies::BrowserCookie;
44use crate::browser::browser_profiles::{
45    browser_profile_root_in, current_platform, normalize_platform, resolve_browser_profile,
46    BrowserProfileOptions,
47};
48use crate::browser::browser_sources::{
49    browser_family, current_environment, is_single_profile_browser, Environment,
50};
51use crate::browser::default_browser::RunCommand;
52
53pub use cookies::{CookieReader, DBSC_BOUND_COOKIE_NAMES};
54pub use firefox_nss::PrimaryPasswordError;
55pub use os_crypt_keys::{
56    KeystoreHooks, SafeStoragePasswordReader, SourceKeyResolver, TargetKey, WindowsKeyReader,
57};
58pub use preferences::MIGRATED_PREFERENCE_PATHS;
59
60/// Every recognized class, in report order; unsupported writers are reported.
61pub const ALL_DATA_CLASSES: [&str; 18] = [
62    "cookies",
63    "bookmarks",
64    "history",
65    "passwords",
66    "preferences",
67    "extensions",
68    "localStorage",
69    "indexedDB",
70    "sessionStorage",
71    "autofill",
72    "paymentCards",
73    "searchEngines",
74    "siteSettings",
75    "openTabs",
76    "downloads",
77    "readingList",
78    "clientCertificates",
79    "passkeys",
80];
81
82const 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.";
83
84/// One skipped item or warning in a migration report.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct MigrationEntry {
87    /// Data class (`cookies`, `bookmarks`, ...).
88    #[serde(rename = "type")]
89    pub data_class: String,
90    /// The file, host or cookie the entry is about.
91    pub item: String,
92    /// A stable machine-readable reason.
93    pub reason: String,
94    /// Human-readable detail, when there is any.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub detail: Option<String>,
97}
98
99impl MigrationEntry {
100    /// Build an entry without detail.
101    pub fn new(
102        data_class: impl Into<String>,
103        item: impl Into<String>,
104        reason: impl Into<String>,
105    ) -> Self {
106        Self {
107            data_class: data_class.into(),
108            item: item.into(),
109            reason: reason.into(),
110            detail: None,
111        }
112    }
113
114    /// Attach human-readable detail.
115    #[must_use]
116    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
117        self.detail = Some(detail.into());
118        self
119    }
120}
121
122/// What one data-class step migrated, skipped and warned about.
123#[derive(Debug, Clone, Default, PartialEq, Eq)]
124pub(crate) struct ClassOutcome {
125    pub migrated: u64,
126    pub skipped: Vec<MigrationEntry>,
127    pub warnings: Vec<MigrationEntry>,
128}
129
130impl ClassOutcome {
131    pub(crate) fn migrated(count: u64) -> Self {
132        Self {
133            migrated: count,
134            ..Self::default()
135        }
136    }
137
138    pub(crate) fn skipped(entry: MigrationEntry) -> Self {
139        Self {
140            skipped: vec![entry],
141            ..Self::default()
142        }
143    }
144}
145
146/// Per-class migrated counts.
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(default, rename_all = "camelCase")]
149pub struct MigratedCounts {
150    pub cookies: u64,
151    pub bookmarks: u64,
152    pub history: u64,
153    pub passwords: u64,
154    pub preferences: u64,
155    pub extensions: u64,
156    pub local_storage: u64,
157    #[serde(rename = "indexedDB")]
158    pub indexed_db: u64,
159    pub session_storage: u64,
160    pub autofill: u64,
161    pub payment_cards: u64,
162    pub search_engines: u64,
163    pub site_settings: u64,
164    pub open_tabs: u64,
165    pub downloads: u64,
166    pub reading_list: u64,
167    pub client_certificates: u64,
168    pub passkeys: u64,
169}
170
171impl MigratedCounts {
172    fn add(&mut self, data_class: &str, count: u64) {
173        let slot = match data_class {
174            "cookies" => &mut self.cookies,
175            "bookmarks" => &mut self.bookmarks,
176            "history" => &mut self.history,
177            "passwords" => &mut self.passwords,
178            "preferences" => &mut self.preferences,
179            "extensions" => &mut self.extensions,
180            "localStorage" => &mut self.local_storage,
181            "indexedDB" => &mut self.indexed_db,
182            "sessionStorage" => &mut self.session_storage,
183            "autofill" => &mut self.autofill,
184            "paymentCards" => &mut self.payment_cards,
185            "searchEngines" => &mut self.search_engines,
186            "siteSettings" => &mut self.site_settings,
187            "openTabs" => &mut self.open_tabs,
188            "downloads" => &mut self.downloads,
189            "readingList" => &mut self.reading_list,
190            "clientCertificates" => &mut self.client_certificates,
191            "passkeys" => &mut self.passkeys,
192            _ => unreachable!("validated migration data class"),
193        };
194        *slot += count;
195    }
196}
197
198/// The source a migration read from.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct MigrationReportSource {
202    /// Normalized browser name.
203    pub browser: String,
204    /// Profile name (`Default` unless one was requested).
205    pub profile: String,
206    /// The explicit user data directory, or `null`.
207    pub user_data_dir: Option<PathBuf>,
208}
209
210/// The full result of [`migrate_profile`], including the cookies to seed.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct MigrationReport {
213    pub source: MigrationReportSource,
214    /// Target profile directory.
215    pub target: PathBuf,
216    pub migrated: MigratedCounts,
217    pub skipped: Vec<MigrationEntry>,
218    pub warnings: Vec<MigrationEntry>,
219    /// Cookies read from the source, to seed over CDP (not written to disk).
220    pub cookies: Vec<BrowserCookie>,
221}
222
223/// A migration report without its cookies, as `launch_real_browser` returns
224/// it (the cookies are seeded, never echoed back).
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct MigrationSummary {
227    pub source: MigrationReportSource,
228    pub target: PathBuf,
229    pub migrated: MigratedCounts,
230    pub skipped: Vec<MigrationEntry>,
231    pub warnings: Vec<MigrationEntry>,
232}
233
234impl MigrationReport {
235    /// Split the report into its cookies and the cookie-free summary.
236    pub fn into_parts(self) -> (Vec<BrowserCookie>, MigrationSummary) {
237        (
238            self.cookies,
239            MigrationSummary {
240                source: self.source,
241                target: self.target,
242                migrated: self.migrated,
243                skipped: self.skipped,
244                warnings: self.warnings,
245            },
246        )
247    }
248
249    fn merge(&mut self, data_class: &str, outcome: ClassOutcome) {
250        self.migrated.add(data_class, outcome.migrated);
251        self.skipped.extend(outcome.skipped);
252        self.warnings.extend(outcome.warnings);
253    }
254}
255
256/// Where to migrate from (`from` in the JavaScript API).
257#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase")]
259pub struct MigrationSource {
260    /// Source browser (`chrome`, `edge`/`msedge`, `brave`, `chromium`, `firefox`).
261    pub browser: String,
262    /// Profile name; defaults to `Default`.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub profile: Option<String>,
265    /// Explicit user data directory (for Firefox, the profile directory).
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub user_data_dir: Option<PathBuf>,
268}
269
270impl MigrationSource {
271    /// Migrate from `browser`'s default profile.
272    pub fn new(browser: impl Into<String>) -> Self {
273        Self {
274            browser: browser.into(),
275            ..Self::default()
276        }
277    }
278
279    /// Select a named profile.
280    #[must_use]
281    pub fn profile(mut self, profile: impl Into<String>) -> Self {
282        self.profile = Some(profile.into());
283        self
284    }
285
286    /// Read from an explicit user data directory.
287    #[must_use]
288    pub fn user_data_dir(mut self, user_data_dir: impl Into<PathBuf>) -> Self {
289        self.user_data_dir = Some(user_data_dir.into());
290        self
291    }
292}
293
294/// Injected password keys (`keys` in the JavaScript API).
295#[derive(Clone, Default)]
296pub struct MigrationKeys {
297    /// Resolves the source key per encryption prefix (Chromium sources).
298    pub resolve_source_key: Option<SourceKeyResolver>,
299    /// The dedicated profile's encryption key.
300    pub target_key: Option<Vec<u8>>,
301    /// Version prefix to write (`v10`/`v11`).
302    pub target_prefix: Option<String>,
303    /// The Firefox primary password, when one is set.
304    pub primary_password: Option<Vec<u8>>,
305}
306
307impl std::fmt::Debug for MigrationKeys {
308    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309        formatter
310            .debug_struct("MigrationKeys")
311            .field("resolve_source_key", &self.resolve_source_key.is_some())
312            .field(
313                "target_key",
314                &self.target_key.as_ref().map(|_| "<redacted>"),
315            )
316            .field("target_prefix", &self.target_prefix)
317            .finish_non_exhaustive()
318    }
319}
320
321/// Options for [`migrate_profile`].
322#[derive(Clone)]
323pub struct MigrateProfileOptions {
324    pub from: MigrationSource,
325    /// Target profile directory.
326    pub to: PathBuf,
327    /// Data classes to migrate; defaults to [`ALL_DATA_CLASSES`].
328    pub include: Vec<String>,
329    /// Per-site host/subdomain filter (empty means all).
330    pub domains: Vec<String>,
331    /// Platform convention (`linux`, `darwin`, `win32`).
332    pub platform: String,
333    /// The launching browser channel, used to derive the target key.
334    pub target_browser: Option<String>,
335    /// Explicit Safari/Passwords app password CSV export.
336    pub password_csv: Option<PathBuf>,
337    /// Explicit consent to import payment cards; selecting the class is insufficient.
338    pub include_payment_cards: bool,
339    /// Injected password keys.
340    pub keys: Option<MigrationKeys>,
341    /// Home directory used to find conventional profile locations.
342    pub home_dir: PathBuf,
343    /// OS keystore readers (Safe Storage, Windows `Local State`).
344    pub keystore: KeystoreHooks,
345    /// Installed-browser cookie reader for Chromium sources.
346    pub read_cookies: CookieReader,
347    /// Environment used to expand profile-root templates.
348    pub environment: Environment,
349    /// Command runner for the default-browser lookup. With `from.browser`
350    /// `default`/`auto` and `domains`, an import falls back from a default
351    /// browser holding none of them to the installed profile holding the most,
352    /// adding a `default-browser-fallback` warning.
353    pub run_command: Option<RunCommand>,
354}
355
356impl std::fmt::Debug for MigrateProfileOptions {
357    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358        formatter
359            .debug_struct("MigrateProfileOptions")
360            .field("from", &self.from)
361            .field("to", &self.to)
362            .field("include", &self.include)
363            .field("domains", &self.domains)
364            .field("platform", &self.platform)
365            .field("target_browser", &self.target_browser)
366            .field("keys", &self.keys)
367            .field("home_dir", &self.home_dir)
368            .finish_non_exhaustive()
369    }
370}
371
372impl MigrateProfileOptions {
373    /// Migrate everything from `from` into the target profile directory `to`.
374    pub fn new(from: MigrationSource, to: impl Into<PathBuf>) -> Self {
375        Self {
376            from,
377            to: to.into(),
378            include: ALL_DATA_CLASSES
379                .iter()
380                .map(|name| name.to_string())
381                .collect(),
382            domains: Vec::new(),
383            platform: current_platform().to_string(),
384            target_browser: None,
385            password_csv: None,
386            include_payment_cards: false,
387            keys: None,
388            home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
389            keystore: KeystoreHooks::default(),
390            read_cookies: cookies::default_cookie_reader(),
391            environment: current_environment(),
392            run_command: None,
393        }
394    }
395
396    /// Restrict the migrated data classes.
397    #[must_use]
398    pub fn include<I, S>(mut self, include: I) -> Self
399    where
400        I: IntoIterator<Item = S>,
401        S: Into<String>,
402    {
403        self.include = include.into_iter().map(Into::into).collect();
404        self
405    }
406
407    /// Restrict migrated per-site data to whole hosts or their subdomains.
408    #[must_use]
409    pub fn domains<I, S>(mut self, domains: I) -> Self
410    where
411        I: IntoIterator<Item = S>,
412        S: Into<String>,
413    {
414        self.domains = domains.into_iter().map(Into::into).collect();
415        self
416    }
417
418    /// Override the platform convention.
419    #[must_use]
420    pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
421        self.platform = normalize_platform(platform.as_ref()).to_string();
422        self
423    }
424
425    /// Set the launching browser channel (target key derivation).
426    #[must_use]
427    pub fn target_browser(mut self, target_browser: impl Into<String>) -> Self {
428        self.target_browser = Some(target_browser.into());
429        self
430    }
431
432    /// Import only this explicitly supplied Safari password export.
433    #[must_use]
434    pub fn password_csv(mut self, path: impl Into<PathBuf>) -> Self {
435        self.password_csv = Some(path.into());
436        self
437    }
438
439    /// Grant or withhold payment-card consent separately from class selection.
440    #[must_use]
441    pub fn include_payment_cards(mut self, consent: bool) -> Self {
442        self.include_payment_cards = consent;
443        self
444    }
445
446    /// Inject password keys.
447    #[must_use]
448    pub fn keys(mut self, keys: MigrationKeys) -> Self {
449        self.keys = Some(keys);
450        self
451    }
452
453    /// Override the home directory.
454    #[must_use]
455    pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
456        self.home_dir = home_dir.into();
457        self
458    }
459
460    /// Override the OS keystore readers.
461    #[must_use]
462    pub fn keystore(mut self, keystore: KeystoreHooks) -> Self {
463        self.keystore = keystore;
464        self
465    }
466
467    /// Override the Chromium cookie reader.
468    #[must_use]
469    pub fn read_cookies(mut self, read_cookies: CookieReader) -> Self {
470        self.read_cookies = read_cookies;
471        self
472    }
473
474    /// Override the environment used to expand profile-root templates.
475    #[must_use]
476    pub fn environment(mut self, environment: Environment) -> Self {
477        self.environment = environment;
478        self
479    }
480
481    /// Inject the default-browser lookup's command runner.
482    #[must_use]
483    pub fn run_command(mut self, run_command: RunCommand) -> Self {
484        self.run_command = Some(run_command);
485        self
486    }
487}
488
489fn is_chromium(browser: &str) -> bool {
490    browser_family(browser)
491        .map(|family| family == "chromium")
492        .unwrap_or(false)
493}
494
495fn is_firefox_browser(browser: &str) -> bool {
496    browser_family(browser)
497        .map(|family| family == "firefox")
498        .unwrap_or(false)
499}
500
501fn resolve_source_profile_dir(
502    browser: &str,
503    profile: &str,
504    options: &MigrateProfileOptions,
505) -> Result<PathBuf> {
506    // A Chromium profile lives in a named subdirectory of the user data dir,
507    // except in single-profile browsers (Opera) that keep it in the root; for
508    // Firefox the user data directory already points at the profile.
509    let nests_profiles = is_chromium(browser) && !is_single_profile_browser(browser)?;
510    let in_root = |root: PathBuf| {
511        if nests_profiles {
512            root.join(profile)
513        } else {
514            root
515        }
516    };
517    if let Some(user_data_dir) = &options.from.user_data_dir {
518        return Ok(in_root(user_data_dir.clone()));
519    }
520    if is_chromium(browser) {
521        return Ok(in_root(browser_profile_root_in(
522            browser,
523            &options.platform,
524            &options.home_dir,
525            &options.environment,
526        )?));
527    }
528    let profile_options = BrowserProfileOptions::default()
529        .home_dir(&options.home_dir)
530        .platform(&options.platform)
531        .environment(options.environment.clone());
532    Ok(resolve_browser_profile(browser, Some(profile), &profile_options)?.path)
533}
534
535fn resolve_password_keys(
536    options: &MigrateProfileOptions,
537    browser: &str,
538    target_browser: &str,
539    source_profile_dir: &Path,
540) -> Result<Option<MigrationKeys>> {
541    if let Some(keys) = &options.keys {
542        if keys.target_key.as_ref().is_some_and(|key| !key.is_empty()) {
543            return Ok(Some(keys.clone()));
544        }
545    }
546    let platform = options.platform.as_str();
547    if platform != "darwin" && platform != "linux" {
548        return Ok(None);
549    }
550    let TargetKey { key, prefix } =
551        os_crypt_keys::resolve_target_key(target_browser, platform, &options.keystore)?;
552    let resolve_source_key = is_chromium(browser).then(|| {
553        os_crypt_keys::create_source_key_resolver(
554            browser,
555            platform,
556            Some(os_crypt_keys::local_state_path_for_profile(
557                source_profile_dir,
558            )),
559            options.keystore.clone(),
560        )
561    });
562    Ok(Some(MigrationKeys {
563        resolve_source_key,
564        target_key: Some(key),
565        target_prefix: Some(prefix),
566        primary_password: None,
567    }))
568}
569
570fn migrate_passwords_class(
571    options: &MigrateProfileOptions,
572    browser: &str,
573    source_profile_dir: &Path,
574    report: &mut MigrationReport,
575) -> Result<()> {
576    if browser == "yandex" && source_profile_dir.join("Ya Passman Data").exists() {
577        report.skipped.push(MigrationEntry::new("passwords", "Ya Passman Data", "yandex-passman-encryption-unsupported").with_detail("Ya Passman Data uses local_encryptor_data and may require a Yandex master password; this extra encryption layer is not supported. Export passwords to a supported format instead."));
578        if !source_profile_dir.join("Login Data").exists() {
579            return Ok(());
580        }
581    }
582    let is_firefox = is_firefox_browser(browser);
583    let target_browser = options.target_browser.clone().unwrap_or_else(|| {
584        if is_firefox {
585            "chrome".into()
586        } else {
587            browser.into()
588        }
589    });
590    let keys = resolve_password_keys(options, browser, &target_browser, source_profile_dir)?;
591    let Some((keys, target_key)) = keys.and_then(|keys| {
592        let target_key = keys.target_key.clone().filter(|key| !key.is_empty())?;
593        Some((keys, target_key))
594    }) else {
595        report.skipped.push(MigrationEntry::new(
596            "passwords",
597            "Login Data",
598            "target-key-unavailable",
599        ));
600        report.warnings.push(
601            MigrationEntry::new("passwords", "Login Data", "target-key-unavailable")
602                .with_detail(TARGET_KEY_UNAVAILABLE_DETAIL),
603        );
604        return Ok(());
605    };
606    let outcome = if is_firefox {
607        firefox::migrate_firefox_passwords_filtered(
608            source_profile_dir,
609            &options.to,
610            &firefox::FirefoxPasswordKeys {
611                platform: &options.platform,
612                target_key: &target_key,
613                target_prefix: keys.target_prefix.as_deref(),
614                primary_password: keys.primary_password.as_deref().unwrap_or_default(),
615            },
616            &options.domains,
617        )?
618    } else {
619        let resolve_source_key = keys.resolve_source_key.clone().unwrap_or_else(|| {
620            os_crypt_keys::create_source_key_resolver(
621                browser,
622                &options.platform,
623                Some(os_crypt_keys::local_state_path_for_profile(
624                    source_profile_dir,
625                )),
626                options.keystore.clone(),
627            )
628        });
629        passwords::migrate_passwords_filtered(
630            source_profile_dir,
631            &options.to,
632            &passwords::PasswordKeys {
633                platform: &options.platform,
634                resolve_source_key: &resolve_source_key,
635                target_key: &target_key,
636                target_prefix: keys.target_prefix.as_deref(),
637            },
638            &options.domains,
639        )?
640    };
641    report.merge("passwords", outcome);
642    Ok(())
643}
644
645/// Migrate a browser profile into a dedicated target profile directory.
646///
647/// Blocking: it reads SQLite snapshots and may query the OS keystore. From
648/// async code, call it through `tokio::task::spawn_blocking`.
649pub fn migrate_profile(options: MigrateProfileOptions) -> Result<MigrationReport> {
650    if options.from.browser.is_empty() {
651        return Err(anyhow!("migrate_profile requires from.browser"));
652    }
653    if options.to.as_os_str().is_empty() {
654        return Err(anyhow!("migrate_profile requires a target directory (to)"));
655    }
656    validation::validate_options(&options)?;
657    // An explicit user data dir names the source, so only an installed-browser
658    // import is steered towards the profile holding the requested domains.
659    let no_domains: &[String] = &[];
660    let source = resolve_import_source(
661        &options.from.browser,
662        if options.from.user_data_dir.is_some() {
663            no_domains
664        } else {
665            &options.domains
666        },
667        &options.platform,
668        &options.home_dir,
669        &options.environment,
670        options.run_command.as_ref(),
671    )?;
672    let browser = source.browser;
673    if !matches!(browser_family(&browser)?, "chromium" | "firefox" | "safari") {
674        return Err(anyhow!("Browser {browser} supports detection only; its profile format is not supported for migration"));
675    }
676    let profile = options
677        .from
678        .profile
679        .clone()
680        .or(source.profile)
681        .unwrap_or_else(|| "Default".to_string());
682    let is_firefox = is_firefox_browser(&browser);
683    let source_profile_dir = resolve_source_profile_dir(&browser, &profile, &options)?;
684    validation::validate_paths(&source_profile_dir, &options.to)?;
685    let selected = |name: &str| options.include.iter().any(|entry| entry == name);
686    let mut report = MigrationReport {
687        source: MigrationReportSource {
688            browser: browser.clone(),
689            profile: profile.clone(),
690            user_data_dir: options.from.user_data_dir.clone(),
691        },
692        target: options.to.clone(),
693        migrated: MigratedCounts::default(),
694        skipped: Vec::new(),
695        warnings: source.warning.into_iter().collect(),
696        cookies: Vec::new(),
697    };
698    let target = options.to.as_path();
699    for data_class in data_classes::ADDITIONAL_DATA_CLASSES
700        .iter()
701        .filter(|name| selected(name))
702    {
703        report.skipped.extend(data_classes::report_additional_class(
704            data_class,
705            &source_profile_dir,
706            options.include_payment_cards,
707        ));
708    }
709
710    if selected("cookies") {
711        if is_firefox {
712            let cookies =
713                firefox::read_firefox_profile_cookies(&source_profile_dir, &options.domains)?;
714            report.migrated.cookies = cookies.len() as u64;
715            report.cookies = cookies;
716        } else {
717            let (cookies, outcome) = cookies::migrate_cookies(
718                &cookies::CookieSource {
719                    browser: &browser,
720                    profile: Some(&profile),
721                    source_profile_dir: Some(&source_profile_dir),
722                    domains: &options.domains,
723                    platform: &options.platform,
724                    home_dir: &options.home_dir,
725                },
726                &options.read_cookies,
727            )?;
728            report.cookies = cookies;
729            report.merge("cookies", outcome);
730        }
731    }
732
733    if browser_family(&browser)? == "safari" {
734        let keys = if selected("passwords") && options.password_csv.is_some() {
735            resolve_password_keys(
736                &options,
737                &browser,
738                options.target_browser.as_deref().unwrap_or("chrome"),
739                &source_profile_dir,
740            )?
741        } else {
742            None
743        };
744        let legacy = (options.from.user_data_dir.is_none()
745            && source_profile_dir
746                .parent()
747                .and_then(Path::file_name)
748                .is_none_or(|name| name != "Profiles"))
749        .then(|| {
750            options
751                .home_dir
752                .join("Library")
753                .join(if browser == "safari" {
754                    "Safari"
755                } else {
756                    "Safari Technology Preview"
757                })
758        });
759        for data_class in ALL_DATA_CLASSES.iter().filter(|name| {
760            **name != "cookies"
761                && !data_classes::ADDITIONAL_DATA_CLASSES.contains(name)
762                && selected(name)
763        }) {
764            report.merge(
765                data_class,
766                safari_import::migrate_safari_class(
767                    data_class,
768                    &source_profile_dir,
769                    &options,
770                    keys.as_ref(),
771                    legacy.as_deref(),
772                )?,
773            );
774        }
775        if !report.cookies.is_empty() {
776            report.warnings.push(
777                MigrationEntry::new("cookies", &browser, "safari-samesite-unavailable")
778                    .with_detail(
779                        "Cookies.binarycookies does not store SameSite; imported cookies use Lax.",
780                    ),
781            );
782        }
783        return Ok(report);
784    }
785
786    if selected("bookmarks") {
787        let outcome = if is_firefox {
788            firefox::migrate_firefox_bookmarks(&source_profile_dir, target)?
789        } else {
790            bookmarks::migrate_bookmarks(&source_profile_dir, target)?
791        };
792        report.merge("bookmarks", outcome);
793    }
794
795    if selected("history") {
796        let outcome = if is_firefox {
797            firefox_history::migrate_firefox_history(&source_profile_dir, target, &options.domains)?
798        } else {
799            history::migrate_history_filtered(&source_profile_dir, target, &options.domains)?
800        };
801        report.merge("history", outcome);
802    }
803
804    if selected("preferences") && !is_firefox {
805        let outcome = preferences::migrate_preferences(&source_profile_dir, target)?;
806        report.merge("preferences", outcome);
807    }
808
809    if selected("extensions") && !is_firefox {
810        let outcome = extensions::migrate_extensions(&source_profile_dir, target)?;
811        report.merge("extensions", outcome);
812    }
813
814    if is_firefox {
815        for data_class in ["preferences", "extensions"] {
816            if selected(data_class) {
817                report.skipped.push(MigrationEntry {
818                    data_class: data_class.into(),
819                    item: source_profile_dir.display().to_string(),
820                    reason: "firefox-class-not-supported".into(),
821                    detail: Some("Firefox preferences and extensions cannot be copied into a Chromium profile.".into()),
822                });
823            }
824        }
825    }
826
827    if selected("passwords") {
828        migrate_passwords_class(&options, &browser, &source_profile_dir, &mut report)?;
829    }
830
831    Ok(report)
832}
833
834#[cfg(test)]
835#[path = "tests/mod.rs"]
836mod tests;