mod bookmarks;
mod chromium_crypto;
mod chromium_writers;
mod cookies;
mod data_classes;
pub(crate) mod domains;
mod extensions;
mod firefox;
mod firefox_bookmarks;
mod firefox_der;
mod firefox_history;
mod firefox_nss;
mod fs_utils;
mod history;
mod os_crypt_keys;
mod password_metadata;
mod passwords;
mod preferences;
pub(crate) mod safari;
mod safari_import;
pub(crate) mod sqlite_snapshot;
mod validation;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use crate::browser::browser_cookie_sources::resolve_import_source;
use crate::browser::browser_cookies::BrowserCookie;
use crate::browser::browser_profiles::{
browser_profile_root_in, current_platform, normalize_platform, resolve_browser_profile,
BrowserProfileOptions,
};
use crate::browser::browser_sources::{
browser_family, current_environment, is_single_profile_browser, Environment,
};
use crate::browser::default_browser::RunCommand;
pub use cookies::{CookieReader, DBSC_BOUND_COOKIE_NAMES};
pub use firefox_nss::PrimaryPasswordError;
pub use os_crypt_keys::{
KeystoreHooks, SafeStoragePasswordReader, SourceKeyResolver, TargetKey, WindowsKeyReader,
};
pub use preferences::MIGRATED_PREFERENCE_PATHS;
pub const ALL_DATA_CLASSES: [&str; 18] = [
"cookies",
"bookmarks",
"history",
"passwords",
"preferences",
"extensions",
"localStorage",
"indexedDB",
"sessionStorage",
"autofill",
"paymentCards",
"searchEngines",
"siteSettings",
"openTabs",
"downloads",
"readingList",
"clientCertificates",
"passkeys",
];
const 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.";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationEntry {
#[serde(rename = "type")]
pub data_class: String,
pub item: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl MigrationEntry {
pub fn new(
data_class: impl Into<String>,
item: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
data_class: data_class.into(),
item: item.into(),
reason: reason.into(),
detail: None,
}
}
#[must_use]
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ClassOutcome {
pub migrated: u64,
pub skipped: Vec<MigrationEntry>,
pub warnings: Vec<MigrationEntry>,
}
impl ClassOutcome {
pub(crate) fn migrated(count: u64) -> Self {
Self {
migrated: count,
..Self::default()
}
}
pub(crate) fn skipped(entry: MigrationEntry) -> Self {
Self {
skipped: vec![entry],
..Self::default()
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct MigratedCounts {
pub cookies: u64,
pub bookmarks: u64,
pub history: u64,
pub passwords: u64,
pub preferences: u64,
pub extensions: u64,
pub local_storage: u64,
#[serde(rename = "indexedDB")]
pub indexed_db: u64,
pub session_storage: u64,
pub autofill: u64,
pub payment_cards: u64,
pub search_engines: u64,
pub site_settings: u64,
pub open_tabs: u64,
pub downloads: u64,
pub reading_list: u64,
pub client_certificates: u64,
pub passkeys: u64,
}
impl MigratedCounts {
fn add(&mut self, data_class: &str, count: u64) {
let slot = match data_class {
"cookies" => &mut self.cookies,
"bookmarks" => &mut self.bookmarks,
"history" => &mut self.history,
"passwords" => &mut self.passwords,
"preferences" => &mut self.preferences,
"extensions" => &mut self.extensions,
"localStorage" => &mut self.local_storage,
"indexedDB" => &mut self.indexed_db,
"sessionStorage" => &mut self.session_storage,
"autofill" => &mut self.autofill,
"paymentCards" => &mut self.payment_cards,
"searchEngines" => &mut self.search_engines,
"siteSettings" => &mut self.site_settings,
"openTabs" => &mut self.open_tabs,
"downloads" => &mut self.downloads,
"readingList" => &mut self.reading_list,
"clientCertificates" => &mut self.client_certificates,
"passkeys" => &mut self.passkeys,
_ => unreachable!("validated migration data class"),
};
*slot += count;
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MigrationReportSource {
pub browser: String,
pub profile: String,
pub user_data_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationReport {
pub source: MigrationReportSource,
pub target: PathBuf,
pub migrated: MigratedCounts,
pub skipped: Vec<MigrationEntry>,
pub warnings: Vec<MigrationEntry>,
pub cookies: Vec<BrowserCookie>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationSummary {
pub source: MigrationReportSource,
pub target: PathBuf,
pub migrated: MigratedCounts,
pub skipped: Vec<MigrationEntry>,
pub warnings: Vec<MigrationEntry>,
}
impl MigrationReport {
pub fn into_parts(self) -> (Vec<BrowserCookie>, MigrationSummary) {
(
self.cookies,
MigrationSummary {
source: self.source,
target: self.target,
migrated: self.migrated,
skipped: self.skipped,
warnings: self.warnings,
},
)
}
fn merge(&mut self, data_class: &str, outcome: ClassOutcome) {
self.migrated.add(data_class, outcome.migrated);
self.skipped.extend(outcome.skipped);
self.warnings.extend(outcome.warnings);
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MigrationSource {
pub browser: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_data_dir: Option<PathBuf>,
}
impl MigrationSource {
pub fn new(browser: impl Into<String>) -> Self {
Self {
browser: browser.into(),
..Self::default()
}
}
#[must_use]
pub fn profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
#[must_use]
pub fn user_data_dir(mut self, user_data_dir: impl Into<PathBuf>) -> Self {
self.user_data_dir = Some(user_data_dir.into());
self
}
}
#[derive(Clone, Default)]
pub struct MigrationKeys {
pub resolve_source_key: Option<SourceKeyResolver>,
pub target_key: Option<Vec<u8>>,
pub target_prefix: Option<String>,
pub primary_password: Option<Vec<u8>>,
}
impl std::fmt::Debug for MigrationKeys {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("MigrationKeys")
.field("resolve_source_key", &self.resolve_source_key.is_some())
.field(
"target_key",
&self.target_key.as_ref().map(|_| "<redacted>"),
)
.field("target_prefix", &self.target_prefix)
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct MigrateProfileOptions {
pub from: MigrationSource,
pub to: PathBuf,
pub include: Vec<String>,
pub domains: Vec<String>,
pub platform: String,
pub target_browser: Option<String>,
pub password_csv: Option<PathBuf>,
pub include_payment_cards: bool,
pub keys: Option<MigrationKeys>,
pub home_dir: PathBuf,
pub keystore: KeystoreHooks,
pub read_cookies: CookieReader,
pub environment: Environment,
pub run_command: Option<RunCommand>,
}
impl std::fmt::Debug for MigrateProfileOptions {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("MigrateProfileOptions")
.field("from", &self.from)
.field("to", &self.to)
.field("include", &self.include)
.field("domains", &self.domains)
.field("platform", &self.platform)
.field("target_browser", &self.target_browser)
.field("keys", &self.keys)
.field("home_dir", &self.home_dir)
.finish_non_exhaustive()
}
}
impl MigrateProfileOptions {
pub fn new(from: MigrationSource, to: impl Into<PathBuf>) -> Self {
Self {
from,
to: to.into(),
include: ALL_DATA_CLASSES
.iter()
.map(|name| name.to_string())
.collect(),
domains: Vec::new(),
platform: current_platform().to_string(),
target_browser: None,
password_csv: None,
include_payment_cards: false,
keys: None,
home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
keystore: KeystoreHooks::default(),
read_cookies: cookies::default_cookie_reader(),
environment: current_environment(),
run_command: None,
}
}
#[must_use]
pub fn include<I, S>(mut self, include: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.include = include.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn domains<I, S>(mut self, domains: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.domains = domains.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
self.platform = normalize_platform(platform.as_ref()).to_string();
self
}
#[must_use]
pub fn target_browser(mut self, target_browser: impl Into<String>) -> Self {
self.target_browser = Some(target_browser.into());
self
}
#[must_use]
pub fn password_csv(mut self, path: impl Into<PathBuf>) -> Self {
self.password_csv = Some(path.into());
self
}
#[must_use]
pub fn include_payment_cards(mut self, consent: bool) -> Self {
self.include_payment_cards = consent;
self
}
#[must_use]
pub fn keys(mut self, keys: MigrationKeys) -> Self {
self.keys = Some(keys);
self
}
#[must_use]
pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
self.home_dir = home_dir.into();
self
}
#[must_use]
pub fn keystore(mut self, keystore: KeystoreHooks) -> Self {
self.keystore = keystore;
self
}
#[must_use]
pub fn read_cookies(mut self, read_cookies: CookieReader) -> Self {
self.read_cookies = read_cookies;
self
}
#[must_use]
pub fn environment(mut self, environment: Environment) -> Self {
self.environment = environment;
self
}
#[must_use]
pub fn run_command(mut self, run_command: RunCommand) -> Self {
self.run_command = Some(run_command);
self
}
}
fn is_chromium(browser: &str) -> bool {
browser_family(browser)
.map(|family| family == "chromium")
.unwrap_or(false)
}
fn is_firefox_browser(browser: &str) -> bool {
browser_family(browser)
.map(|family| family == "firefox")
.unwrap_or(false)
}
fn resolve_source_profile_dir(
browser: &str,
profile: &str,
options: &MigrateProfileOptions,
) -> Result<PathBuf> {
let nests_profiles = is_chromium(browser) && !is_single_profile_browser(browser)?;
let in_root = |root: PathBuf| {
if nests_profiles {
root.join(profile)
} else {
root
}
};
if let Some(user_data_dir) = &options.from.user_data_dir {
return Ok(in_root(user_data_dir.clone()));
}
if is_chromium(browser) {
return Ok(in_root(browser_profile_root_in(
browser,
&options.platform,
&options.home_dir,
&options.environment,
)?));
}
let profile_options = BrowserProfileOptions::default()
.home_dir(&options.home_dir)
.platform(&options.platform)
.environment(options.environment.clone());
Ok(resolve_browser_profile(browser, Some(profile), &profile_options)?.path)
}
fn resolve_password_keys(
options: &MigrateProfileOptions,
browser: &str,
target_browser: &str,
source_profile_dir: &Path,
) -> Result<Option<MigrationKeys>> {
if let Some(keys) = &options.keys {
if keys.target_key.as_ref().is_some_and(|key| !key.is_empty()) {
return Ok(Some(keys.clone()));
}
}
let platform = options.platform.as_str();
if platform != "darwin" && platform != "linux" {
return Ok(None);
}
let TargetKey { key, prefix } =
os_crypt_keys::resolve_target_key(target_browser, platform, &options.keystore)?;
let resolve_source_key = is_chromium(browser).then(|| {
os_crypt_keys::create_source_key_resolver(
browser,
platform,
Some(os_crypt_keys::local_state_path_for_profile(
source_profile_dir,
)),
options.keystore.clone(),
)
});
Ok(Some(MigrationKeys {
resolve_source_key,
target_key: Some(key),
target_prefix: Some(prefix),
primary_password: None,
}))
}
fn migrate_passwords_class(
options: &MigrateProfileOptions,
browser: &str,
source_profile_dir: &Path,
report: &mut MigrationReport,
) -> Result<()> {
if browser == "yandex" && source_profile_dir.join("Ya Passman Data").exists() {
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."));
if !source_profile_dir.join("Login Data").exists() {
return Ok(());
}
}
let is_firefox = is_firefox_browser(browser);
let target_browser = options.target_browser.clone().unwrap_or_else(|| {
if is_firefox {
"chrome".into()
} else {
browser.into()
}
});
let keys = resolve_password_keys(options, browser, &target_browser, source_profile_dir)?;
let Some((keys, target_key)) = keys.and_then(|keys| {
let target_key = keys.target_key.clone().filter(|key| !key.is_empty())?;
Some((keys, target_key))
}) else {
report.skipped.push(MigrationEntry::new(
"passwords",
"Login Data",
"target-key-unavailable",
));
report.warnings.push(
MigrationEntry::new("passwords", "Login Data", "target-key-unavailable")
.with_detail(TARGET_KEY_UNAVAILABLE_DETAIL),
);
return Ok(());
};
let outcome = if is_firefox {
firefox::migrate_firefox_passwords_filtered(
source_profile_dir,
&options.to,
&firefox::FirefoxPasswordKeys {
platform: &options.platform,
target_key: &target_key,
target_prefix: keys.target_prefix.as_deref(),
primary_password: keys.primary_password.as_deref().unwrap_or_default(),
},
&options.domains,
)?
} else {
let resolve_source_key = keys.resolve_source_key.clone().unwrap_or_else(|| {
os_crypt_keys::create_source_key_resolver(
browser,
&options.platform,
Some(os_crypt_keys::local_state_path_for_profile(
source_profile_dir,
)),
options.keystore.clone(),
)
});
passwords::migrate_passwords_filtered(
source_profile_dir,
&options.to,
&passwords::PasswordKeys {
platform: &options.platform,
resolve_source_key: &resolve_source_key,
target_key: &target_key,
target_prefix: keys.target_prefix.as_deref(),
},
&options.domains,
)?
};
report.merge("passwords", outcome);
Ok(())
}
pub fn migrate_profile(options: MigrateProfileOptions) -> Result<MigrationReport> {
if options.from.browser.is_empty() {
return Err(anyhow!("migrate_profile requires from.browser"));
}
if options.to.as_os_str().is_empty() {
return Err(anyhow!("migrate_profile requires a target directory (to)"));
}
validation::validate_options(&options)?;
let no_domains: &[String] = &[];
let source = resolve_import_source(
&options.from.browser,
if options.from.user_data_dir.is_some() {
no_domains
} else {
&options.domains
},
&options.platform,
&options.home_dir,
&options.environment,
options.run_command.as_ref(),
)?;
let browser = source.browser;
if !matches!(browser_family(&browser)?, "chromium" | "firefox" | "safari") {
return Err(anyhow!("Browser {browser} supports detection only; its profile format is not supported for migration"));
}
let profile = options
.from
.profile
.clone()
.or(source.profile)
.unwrap_or_else(|| "Default".to_string());
let is_firefox = is_firefox_browser(&browser);
let source_profile_dir = resolve_source_profile_dir(&browser, &profile, &options)?;
validation::validate_paths(&source_profile_dir, &options.to)?;
let selected = |name: &str| options.include.iter().any(|entry| entry == name);
let mut report = MigrationReport {
source: MigrationReportSource {
browser: browser.clone(),
profile: profile.clone(),
user_data_dir: options.from.user_data_dir.clone(),
},
target: options.to.clone(),
migrated: MigratedCounts::default(),
skipped: Vec::new(),
warnings: source.warning.into_iter().collect(),
cookies: Vec::new(),
};
let target = options.to.as_path();
for data_class in data_classes::ADDITIONAL_DATA_CLASSES
.iter()
.filter(|name| selected(name))
{
report.skipped.extend(data_classes::report_additional_class(
data_class,
&source_profile_dir,
options.include_payment_cards,
));
}
if selected("cookies") {
if is_firefox {
let cookies =
firefox::read_firefox_profile_cookies(&source_profile_dir, &options.domains)?;
report.migrated.cookies = cookies.len() as u64;
report.cookies = cookies;
} else {
let (cookies, outcome) = cookies::migrate_cookies(
&cookies::CookieSource {
browser: &browser,
profile: Some(&profile),
source_profile_dir: Some(&source_profile_dir),
domains: &options.domains,
platform: &options.platform,
home_dir: &options.home_dir,
},
&options.read_cookies,
)?;
report.cookies = cookies;
report.merge("cookies", outcome);
}
}
if browser_family(&browser)? == "safari" {
let keys = if selected("passwords") && options.password_csv.is_some() {
resolve_password_keys(
&options,
&browser,
options.target_browser.as_deref().unwrap_or("chrome"),
&source_profile_dir,
)?
} else {
None
};
let legacy = (options.from.user_data_dir.is_none()
&& source_profile_dir
.parent()
.and_then(Path::file_name)
.is_none_or(|name| name != "Profiles"))
.then(|| {
options
.home_dir
.join("Library")
.join(if browser == "safari" {
"Safari"
} else {
"Safari Technology Preview"
})
});
for data_class in ALL_DATA_CLASSES.iter().filter(|name| {
**name != "cookies"
&& !data_classes::ADDITIONAL_DATA_CLASSES.contains(name)
&& selected(name)
}) {
report.merge(
data_class,
safari_import::migrate_safari_class(
data_class,
&source_profile_dir,
&options,
keys.as_ref(),
legacy.as_deref(),
)?,
);
}
if !report.cookies.is_empty() {
report.warnings.push(
MigrationEntry::new("cookies", &browser, "safari-samesite-unavailable")
.with_detail(
"Cookies.binarycookies does not store SameSite; imported cookies use Lax.",
),
);
}
return Ok(report);
}
if selected("bookmarks") {
let outcome = if is_firefox {
firefox::migrate_firefox_bookmarks(&source_profile_dir, target)?
} else {
bookmarks::migrate_bookmarks(&source_profile_dir, target)?
};
report.merge("bookmarks", outcome);
}
if selected("history") {
let outcome = if is_firefox {
firefox_history::migrate_firefox_history(&source_profile_dir, target, &options.domains)?
} else {
history::migrate_history_filtered(&source_profile_dir, target, &options.domains)?
};
report.merge("history", outcome);
}
if selected("preferences") && !is_firefox {
let outcome = preferences::migrate_preferences(&source_profile_dir, target)?;
report.merge("preferences", outcome);
}
if selected("extensions") && !is_firefox {
let outcome = extensions::migrate_extensions(&source_profile_dir, target)?;
report.merge("extensions", outcome);
}
if is_firefox {
for data_class in ["preferences", "extensions"] {
if selected(data_class) {
report.skipped.push(MigrationEntry {
data_class: data_class.into(),
item: source_profile_dir.display().to_string(),
reason: "firefox-class-not-supported".into(),
detail: Some("Firefox preferences and extensions cannot be copied into a Chromium profile.".into()),
});
}
}
}
if selected("passwords") {
migrate_passwords_class(&options, &browser, &source_profile_dir, &mut report)?;
}
Ok(report)
}
#[cfg(test)]
#[path = "tests/mod.rs"]
mod tests;