1mod 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
60pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct MigrationEntry {
87 #[serde(rename = "type")]
89 pub data_class: String,
90 pub item: String,
92 pub reason: String,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub detail: Option<String>,
97}
98
99impl MigrationEntry {
100 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 #[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct MigrationReportSource {
202 pub browser: String,
204 pub profile: String,
206 pub user_data_dir: Option<PathBuf>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct MigrationReport {
213 pub source: MigrationReportSource,
214 pub target: PathBuf,
216 pub migrated: MigratedCounts,
217 pub skipped: Vec<MigrationEntry>,
218 pub warnings: Vec<MigrationEntry>,
219 pub cookies: Vec<BrowserCookie>,
221}
222
223#[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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase")]
259pub struct MigrationSource {
260 pub browser: String,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub profile: Option<String>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub user_data_dir: Option<PathBuf>,
268}
269
270impl MigrationSource {
271 pub fn new(browser: impl Into<String>) -> Self {
273 Self {
274 browser: browser.into(),
275 ..Self::default()
276 }
277 }
278
279 #[must_use]
281 pub fn profile(mut self, profile: impl Into<String>) -> Self {
282 self.profile = Some(profile.into());
283 self
284 }
285
286 #[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#[derive(Clone, Default)]
296pub struct MigrationKeys {
297 pub resolve_source_key: Option<SourceKeyResolver>,
299 pub target_key: Option<Vec<u8>>,
301 pub target_prefix: Option<String>,
303 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#[derive(Clone)]
323pub struct MigrateProfileOptions {
324 pub from: MigrationSource,
325 pub to: PathBuf,
327 pub include: Vec<String>,
329 pub domains: Vec<String>,
331 pub platform: String,
333 pub target_browser: Option<String>,
335 pub password_csv: Option<PathBuf>,
337 pub include_payment_cards: bool,
339 pub keys: Option<MigrationKeys>,
341 pub home_dir: PathBuf,
343 pub keystore: KeystoreHooks,
345 pub read_cookies: CookieReader,
347 pub environment: Environment,
349 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 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 #[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 #[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 #[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 #[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 #[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 #[must_use]
441 pub fn include_payment_cards(mut self, consent: bool) -> Self {
442 self.include_payment_cards = consent;
443 self
444 }
445
446 #[must_use]
448 pub fn keys(mut self, keys: MigrationKeys) -> Self {
449 self.keys = Some(keys);
450 self
451 }
452
453 #[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 #[must_use]
462 pub fn keystore(mut self, keystore: KeystoreHooks) -> Self {
463 self.keystore = keystore;
464 self
465 }
466
467 #[must_use]
469 pub fn read_cookies(mut self, read_cookies: CookieReader) -> Self {
470 self.read_cookies = read_cookies;
471 self
472 }
473
474 #[must_use]
476 pub fn environment(mut self, environment: Environment) -> Self {
477 self.environment = environment;
478 self
479 }
480
481 #[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 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
645pub 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 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;