1mod 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
52pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct MigrationEntry {
67 #[serde(rename = "type")]
69 pub data_class: String,
70 pub item: String,
72 pub reason: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub detail: Option<String>,
77}
78
79impl MigrationEntry {
80 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 #[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct MigrationReportSource {
155 pub browser: String,
157 pub profile: String,
159 pub user_data_dir: Option<PathBuf>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct MigrationReport {
166 pub source: MigrationReportSource,
167 pub target: PathBuf,
169 pub migrated: MigratedCounts,
170 pub skipped: Vec<MigrationEntry>,
171 pub warnings: Vec<MigrationEntry>,
172 pub cookies: Vec<BrowserCookie>,
174}
175
176#[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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct MigrationSource {
213 pub browser: String,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub profile: Option<String>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub user_data_dir: Option<PathBuf>,
221}
222
223impl MigrationSource {
224 pub fn new(browser: impl Into<String>) -> Self {
226 Self {
227 browser: browser.into(),
228 ..Self::default()
229 }
230 }
231
232 #[must_use]
234 pub fn profile(mut self, profile: impl Into<String>) -> Self {
235 self.profile = Some(profile.into());
236 self
237 }
238
239 #[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#[derive(Clone, Default)]
249pub struct MigrationKeys {
250 pub resolve_source_key: Option<SourceKeyResolver>,
252 pub target_key: Option<Vec<u8>>,
254 pub target_prefix: Option<String>,
256 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#[derive(Clone)]
276pub struct MigrateProfileOptions {
277 pub from: MigrationSource,
278 pub to: PathBuf,
280 pub include: Vec<String>,
282 pub domains: Vec<String>,
284 pub platform: String,
286 pub target_browser: Option<String>,
288 pub keys: Option<MigrationKeys>,
290 pub home_dir: PathBuf,
292 pub keystore: KeystoreHooks,
294 pub read_cookies: CookieReader,
296 pub environment: Environment,
298 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 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 #[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 #[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 #[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 #[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 #[must_use]
381 pub fn keys(mut self, keys: MigrationKeys) -> Self {
382 self.keys = Some(keys);
383 self
384 }
385
386 #[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 #[must_use]
395 pub fn keystore(mut self, keystore: KeystoreHooks) -> Self {
396 self.keystore = keystore;
397 self
398 }
399
400 #[must_use]
402 pub fn read_cookies(mut self, read_cookies: CookieReader) -> Self {
403 self.read_cookies = read_cookies;
404 self
405 }
406
407 #[must_use]
409 pub fn environment(mut self, environment: Environment) -> Self {
410 self.environment = environment;
411 self
412 }
413
414 #[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 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
570pub 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 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;