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;
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
47pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct MigrationEntry {
64 #[serde(rename = "type")]
66 pub data_class: String,
67 pub item: String,
69 pub reason: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub detail: Option<String>,
74}
75
76impl MigrationEntry {
77 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 #[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct MigrationReportSource {
152 pub browser: String,
154 pub profile: String,
156 pub user_data_dir: Option<PathBuf>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct MigrationReport {
163 pub source: MigrationReportSource,
164 pub target: PathBuf,
166 pub migrated: MigratedCounts,
167 pub skipped: Vec<MigrationEntry>,
168 pub warnings: Vec<MigrationEntry>,
169 pub cookies: Vec<BrowserCookie>,
171}
172
173#[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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct MigrationSource {
210 pub browser: String,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub profile: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub user_data_dir: Option<PathBuf>,
218}
219
220impl MigrationSource {
221 pub fn new(browser: impl Into<String>) -> Self {
223 Self {
224 browser: browser.into(),
225 ..Self::default()
226 }
227 }
228
229 #[must_use]
231 pub fn profile(mut self, profile: impl Into<String>) -> Self {
232 self.profile = Some(profile.into());
233 self
234 }
235
236 #[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#[derive(Clone, Default)]
246pub struct MigrationKeys {
247 pub resolve_source_key: Option<SourceKeyResolver>,
249 pub target_key: Option<Vec<u8>>,
251 pub target_prefix: Option<String>,
253 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#[derive(Clone)]
273pub struct MigrateProfileOptions {
274 pub from: MigrationSource,
275 pub to: PathBuf,
277 pub include: Vec<String>,
279 pub domains: Vec<String>,
281 pub platform: String,
283 pub target_browser: Option<String>,
285 pub keys: Option<MigrationKeys>,
287 pub home_dir: PathBuf,
289 pub keystore: KeystoreHooks,
291 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 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 #[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 #[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 #[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 #[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 #[must_use]
369 pub fn keys(mut self, keys: MigrationKeys) -> Self {
370 self.keys = Some(keys);
371 self
372 }
373
374 #[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 #[must_use]
383 pub fn keystore(mut self, keystore: KeystoreHooks) -> Self {
384 self.keystore = keystore;
385 self
386 }
387
388 #[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 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
527pub 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;