mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-29 06:00:55 +02:00
refactor: profile imports
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
//! Copying a source profile into the new one.
|
||||
//!
|
||||
//! Two things a plain recursive copy gets wrong, both of which produce a
|
||||
//! profile that looks imported and is not:
|
||||
//!
|
||||
//! - **Torn databases.** Users import from a browser they are still using. A
|
||||
//! naive walk copies `Cookies` and `Cookies-wal` at different instants, and
|
||||
//! Chromium's `sql::Database` razes the result on open. `VACUUM INTO` takes a
|
||||
//! transactionally consistent snapshot instead, WAL content included, even
|
||||
//! while the source holds the file.
|
||||
//! - **Multi-GB of caches.** `Cache/`, `Code Cache/`, `GPUCache/` and friends
|
||||
//! carry no user state and dominate both copy time and disk use.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Directories that never carry user state. Matched on the path relative to the
|
||||
/// profile root, so `Service Worker/CacheStorage` is dropped while
|
||||
/// `Service Worker/Database` survives.
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
"Cache",
|
||||
"Code Cache",
|
||||
"GPUCache",
|
||||
"GrShaderCache",
|
||||
"ShaderCache",
|
||||
"DawnCache",
|
||||
"DawnGraphiteCache",
|
||||
"DawnWebGPUCache",
|
||||
"GraphiteDawnCache",
|
||||
"GPUPersistentCache",
|
||||
"Service Worker/CacheStorage",
|
||||
"Service Worker/ScriptCache",
|
||||
"blob_storage",
|
||||
"Crashpad",
|
||||
"Crash Reports",
|
||||
"BrowserMetrics",
|
||||
"optimization_guide_model_store",
|
||||
"optimization_guide_hint_cache_store",
|
||||
"Safe Browsing",
|
||||
"Safe Browsing Network",
|
||||
"component_crx_cache",
|
||||
"extensions_crx_cache",
|
||||
"Download Service",
|
||||
"Site Characteristics Database",
|
||||
"shared_proto_db",
|
||||
"segmentation_platform",
|
||||
"Sync App Settings",
|
||||
// SNSS command logs replay the source machine's windows and can embed
|
||||
// absolute local paths in PageState blobs.
|
||||
"Sessions",
|
||||
"Session Storage",
|
||||
];
|
||||
|
||||
/// Exact file names that are per-machine, per-run, or regenerated.
|
||||
const SKIP_FILES: &[&str] = &[
|
||||
"LOCK",
|
||||
"LOG",
|
||||
"LOG.old",
|
||||
"SingletonLock",
|
||||
"SingletonCookie",
|
||||
"SingletonSocket",
|
||||
"RunningChromeVersion",
|
||||
"Last Version",
|
||||
"first_party_sets.db",
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
// The account-bound part of `Sync Data/`. The rest of that directory is the
|
||||
// local DataTypeStore — Reading List, Saved Tab Groups and friends, which
|
||||
// exist for users who never signed in — so the folder itself is carried.
|
||||
"Nigori.bin",
|
||||
// Signed-in ephemeral twins of the real stores. They are wiped on sign-out,
|
||||
// and the imported profile will not be signed in.
|
||||
"Login Data For Account",
|
||||
"Login Data For Account-journal",
|
||||
"Account Web Data",
|
||||
"Account Web Data-journal",
|
||||
];
|
||||
|
||||
/// Suffixes that belong to a database we snapshot separately, or to scratch
|
||||
/// state. Copying a `-wal` next to a vacuumed main file actively corrupts it.
|
||||
const SKIP_SUFFIXES: &[&str] = &["-journal", "-wal", "-shm", ".tmp", ".old", ".bak.tmp"];
|
||||
|
||||
/// SQLite stores worth a consistent snapshot. Anything not listed is copied
|
||||
/// byte-for-byte, which is correct for JSON, LevelDB and unpacked CRXs.
|
||||
const SQLITE_FILES: &[&str] = &[
|
||||
"Cookies",
|
||||
"History",
|
||||
"Favicons",
|
||||
"Top Sites",
|
||||
"Shortcuts",
|
||||
"Login Data",
|
||||
"Web Data",
|
||||
"Affiliation Database",
|
||||
"Network Action Predictor",
|
||||
"DIPS",
|
||||
"Trust Tokens",
|
||||
"BudgetDatabase",
|
||||
"AutofillStrikeDatabase",
|
||||
"Reporting and NEL",
|
||||
"SCT Auditing Pending Reports",
|
||||
"Device Bound Sessions",
|
||||
"MediaDeviceSalts",
|
||||
"PreferredApps",
|
||||
"heavy_ad_intervention_opt_out.db",
|
||||
"SharedStorage",
|
||||
"BrowsingTopicsSiteData",
|
||||
"ClientCertificates",
|
||||
"PersistentOriginTrials",
|
||||
"Web Applications",
|
||||
];
|
||||
|
||||
pub struct CopyOutcome {
|
||||
pub bytes_copied: u64,
|
||||
/// Names of stores that could not be snapshotted and were skipped rather
|
||||
/// than copied in a corrupt state.
|
||||
pub unreadable_stores: Vec<String>,
|
||||
}
|
||||
|
||||
fn is_skipped_dir(relative: &Path) -> bool {
|
||||
let normalized = relative.to_string_lossy().replace('\\', "/");
|
||||
SKIP_DIRS.iter().any(|skip| {
|
||||
normalized == *skip
|
||||
|| normalized.ends_with(&format!("/{skip}"))
|
||||
// `BrowserMetrics-spare.pma` and friends.
|
||||
|| normalized.starts_with(&format!("{skip}-"))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_skipped_file(name: &str) -> bool {
|
||||
SKIP_FILES.contains(&name)
|
||||
|| SKIP_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
|
||||
|| name.starts_with("BrowserMetrics")
|
||||
}
|
||||
|
||||
/// Copy the source's permission bits onto a file we produced ourselves.
|
||||
///
|
||||
/// `fs::copy` already preserves the mode, but `VACUUM INTO` lets SQLite create
|
||||
/// the destination at its own default (0644). Cookies, Login Data and Web Data
|
||||
/// are 0600 in both the source browser and Wayfern, and an import must not be
|
||||
/// the step that widens them.
|
||||
#[cfg(unix)]
|
||||
fn mirror_mode(source: &Path, dest: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(metadata) = fs::metadata(source) {
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
let _ = fs::set_permissions(dest, fs::Permissions::from_mode(mode));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn mirror_mode(_source: &Path, _dest: &Path) {}
|
||||
|
||||
/// Create a directory owner-only, matching what Chromium gives a profile.
|
||||
fn create_private_dir(path: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take a consistent snapshot of a SQLite database.
|
||||
///
|
||||
/// Returns `Ok(false)` when the file is not actually SQLite (an empty
|
||||
/// placeholder, say), so the caller can fall back to a plain copy.
|
||||
fn vacuum_into(source: &Path, dest: &Path) -> Result<bool, String> {
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
|
||||
let conn = match Connection::open_with_flags(
|
||||
source,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
|
||||
) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => return Err(format!("open failed: {e}")),
|
||||
};
|
||||
|
||||
// Confirm it really is a database before trusting VACUUM's error reporting.
|
||||
if conn
|
||||
.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if dest.exists() {
|
||||
fs::remove_file(dest).map_err(|e| format!("could not replace destination: {e}"))?;
|
||||
}
|
||||
|
||||
// `VACUUM INTO` needs the path as a SQL string literal; single quotes are
|
||||
// the only character that can break out of one.
|
||||
let target = dest.to_string_lossy().replace('\'', "''");
|
||||
conn
|
||||
.execute_batch(&format!("VACUUM INTO '{target}'"))
|
||||
.map_err(|e| format!("VACUUM INTO failed: {e}"))?;
|
||||
mirror_mode(source, dest);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Copy `source` (a Chromium profile directory) into `dest`, skipping caches
|
||||
/// and snapshotting databases.
|
||||
pub fn copy_profile_tree(source: &Path, dest: &Path) -> Result<CopyOutcome, String> {
|
||||
let mut outcome = CopyOutcome {
|
||||
bytes_copied: 0,
|
||||
unreadable_stores: Vec::new(),
|
||||
};
|
||||
create_private_dir(dest).map_err(|e| format!("Failed to create {}: {e}", dest.display()))?;
|
||||
copy_dir(source, dest, Path::new(""), &mut outcome)?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn copy_dir(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
relative: &Path,
|
||||
outcome: &mut CopyOutcome,
|
||||
) -> Result<(), String> {
|
||||
let entries =
|
||||
fs::read_dir(source).map_err(|e| format!("Failed to read {}: {e}", source.display()))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
let child_relative = relative.join(name);
|
||||
let source_path = entry.path();
|
||||
let dest_path = dest.join(name);
|
||||
|
||||
// Symlinks are followed nowhere: Chromium writes them for the singleton
|
||||
// lock, and a copied one would point at the source machine.
|
||||
let metadata = match fs::symlink_metadata(&source_path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if metadata.is_dir() {
|
||||
if is_skipped_dir(&child_relative) {
|
||||
continue;
|
||||
}
|
||||
create_private_dir(&dest_path)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", dest_path.display()))?;
|
||||
copy_dir(&source_path, &dest_path, &child_relative, outcome)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_skipped_file(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if SQLITE_FILES.contains(&name) {
|
||||
match vacuum_into(&source_path, &dest_path) {
|
||||
Ok(true) => {
|
||||
outcome.bytes_copied += fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
|
||||
continue;
|
||||
}
|
||||
Ok(false) => {
|
||||
// Not a database after all; fall through to a byte copy.
|
||||
}
|
||||
Err(e) => {
|
||||
// A store we cannot snapshot is a store we must not copy: a torn
|
||||
// copy is deleted by Chromium on open, which looks identical to
|
||||
// "the import silently lost my data".
|
||||
log::warn!("Skipping unreadable store {}: {e}", source_path.display());
|
||||
outcome.unreadable_stores.push(name.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match fs::copy(&source_path, &dest_path) {
|
||||
Ok(bytes) => outcome.bytes_copied += bytes,
|
||||
Err(e) => log::warn!("Failed to copy {}: {e}", source_path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every `Default/`-level store that holds real user data, for reporting.
|
||||
pub fn count_leveldb_origins(leveldb_dir: &Path) -> usize {
|
||||
// Counting keys would mean linking a LevelDB implementation. The number of
|
||||
// `.ldb`/`.log` segments is a stable proxy for "there is data here", which
|
||||
// is all the report claims.
|
||||
let Ok(entries) = fs::read_dir(leveldb_dir) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.ends_with(".ldb") || n.ends_with(".log"))
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn touch(path: &Path, contents: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caches_are_not_copied() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(&source.join("Cache").join("data_0"), &[0u8; 4096]);
|
||||
touch(
|
||||
&source.join("Code Cache").join("js").join("x"),
|
||||
&[0u8; 4096],
|
||||
);
|
||||
touch(
|
||||
&source.join("Service Worker").join("CacheStorage").join("y"),
|
||||
&[0u8; 4096],
|
||||
);
|
||||
touch(
|
||||
&source
|
||||
.join("Service Worker")
|
||||
.join("Database")
|
||||
.join("CURRENT"),
|
||||
b"MANIFEST-000001\n",
|
||||
);
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
assert!(dest.join("Preferences").exists());
|
||||
assert!(!dest.join("Cache").exists());
|
||||
assert!(!dest.join("Code Cache").exists());
|
||||
assert!(!dest.join("Service Worker").join("CacheStorage").exists());
|
||||
assert!(
|
||||
dest.join("Service Worker").join("Database").exists(),
|
||||
"the Service Worker registry is real data and must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_journal_files_are_not_copied() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(
|
||||
&source.join("Local Storage").join("leveldb").join("LOCK"),
|
||||
b"",
|
||||
);
|
||||
touch(
|
||||
&source.join("Local Storage").join("leveldb").join("CURRENT"),
|
||||
b"MANIFEST-000001\n",
|
||||
);
|
||||
touch(&source.join("History-journal"), b"junk");
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
assert!(!dest
|
||||
.join("Local Storage")
|
||||
.join("leveldb")
|
||||
.join("LOCK")
|
||||
.exists());
|
||||
assert!(dest
|
||||
.join("Local Storage")
|
||||
.join("leveldb")
|
||||
.join("CURRENT")
|
||||
.exists());
|
||||
assert!(!dest.join("History-journal").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_stores_are_snapshotted_and_stay_queryable() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
|
||||
let db = source.join("History");
|
||||
let conn = Connection::open(&db).unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT); INSERT INTO urls(url) VALUES('https://example.com');")
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
let copied = Connection::open(dest.join("History")).unwrap();
|
||||
let count: i64 = copied
|
||||
.query_row("SELECT count(*) FROM urls", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_captures_uncheckpointed_wal_content() {
|
||||
// The whole reason for VACUUM INTO: a running browser leaves recent writes
|
||||
// in the WAL, and a plain file copy loses them.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
|
||||
let db = source.join("History");
|
||||
let conn = Connection::open(&db).unwrap();
|
||||
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT);")
|
||||
.unwrap();
|
||||
conn
|
||||
.execute("INSERT INTO urls(url) VALUES('https://in-wal.example')", [])
|
||||
.unwrap();
|
||||
// Deliberately do not checkpoint or close: this is the live-browser shape.
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
drop(conn);
|
||||
|
||||
let copied = Connection::open(dest.join("History")).unwrap();
|
||||
let url: String = copied
|
||||
.query_row("SELECT url FROM urls", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(url, "https://in-wal.example");
|
||||
assert!(
|
||||
!dest.join("History-wal").exists(),
|
||||
"a stale -wal beside a vacuumed file corrupts it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symlinks_are_never_followed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
let outside = dir.path().join("outside.txt");
|
||||
touch(&outside, b"secret");
|
||||
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&outside, source.join("SingletonLock")).unwrap();
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
assert!(!dest.join("SingletonLock").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_scoped_stores_are_dropped() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(&source.join("Login Data For Account"), b"x");
|
||||
touch(&source.join("Sync Data").join("Nigori.bin"), b"x");
|
||||
touch(
|
||||
&source.join("Sync Data").join("LevelDB").join("CURRENT"),
|
||||
b"x",
|
||||
);
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
assert!(!dest.join("Login Data For Account").exists());
|
||||
assert!(
|
||||
!dest.join("Sync Data").join("Nigori.bin").exists(),
|
||||
"the Nigori keyset is bound to a Google account"
|
||||
);
|
||||
assert!(
|
||||
dest
|
||||
.join("Sync Data")
|
||||
.join("LevelDB")
|
||||
.join("CURRENT")
|
||||
.exists(),
|
||||
"the rest of Sync Data is local state such as the reading list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn copied_databases_keep_the_browsers_private_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
|
||||
let db = source.join("Cookies");
|
||||
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE cookies(x INTEGER);")
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
fs::set_permissions(&db, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
// VACUUM INTO would otherwise create the snapshot at SQLite's default 0644.
|
||||
let mode = fs::metadata(dest.join("Cookies"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(
|
||||
mode & 0o777,
|
||||
0o600,
|
||||
"an import must not widen a cookie store"
|
||||
);
|
||||
let dir_mode = fs::metadata(&dest).unwrap().permissions().mode();
|
||||
assert_eq!(dir_mode & 0o777, 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_store_is_reported_not_copied_corrupt() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
// A file that opens as SQLite but is structurally broken.
|
||||
touch(
|
||||
&source.join("Cookies"),
|
||||
b"SQLite format 3\0garbage-not-a-db",
|
||||
);
|
||||
|
||||
let outcome = copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
assert!(
|
||||
!dest.join("Cookies").exists() || outcome.unreadable_stores.is_empty(),
|
||||
"a store is either snapshotted cleanly or skipped and reported"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_sqlite_file_with_a_store_name_still_copies() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(&source.join("Top Sites"), b"");
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
assert!(dest.join("Top Sites").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Recovering the *source* browser's os_crypt key.
|
||||
//!
|
||||
//! Every Chromium-family browser seals cookies, passwords and payment data with
|
||||
//! a key held outside the profile: the macOS Keychain, a DPAPI blob in
|
||||
//! `Local State`, or the Freedesktop secret service. Import has to open that
|
||||
//! lock before it can re-seal anything with Wayfern's portable key
|
||||
//! ([`super::os_crypt::TargetKey`]).
|
||||
//!
|
||||
//! Failure here is never fatal. A declined Keychain prompt or a locked keyring
|
||||
//! degrades to "everything except the secrets came across", recorded as a
|
||||
//! warning, because a partial profile is worth far more than a failed import.
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::os_crypt::CryptoKey;
|
||||
use super::os_crypt::SourceKeyring;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::os_crypt::MAC_ITERATIONS;
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use super::os_crypt::{derive_key, CryptoKey};
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::os_crypt::{POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS};
|
||||
use super::report::warning;
|
||||
use std::path::Path;
|
||||
|
||||
/// Keychain / secret-service identities to try for a source family, most
|
||||
/// specific first.
|
||||
///
|
||||
/// Trying several is safe and costs nothing: a lookup for a service that does
|
||||
/// not exist fails without prompting, so at most one dialog appears — the one
|
||||
/// for the item that is actually there. That is what lets a single `chromium`
|
||||
/// family key cover both Google Chrome and vanilla Chromium, which share a
|
||||
/// detection entry but not a Keychain item.
|
||||
fn brand_candidates(family: &str, source_path: &Path) -> Vec<&'static str> {
|
||||
let path = source_path.to_string_lossy();
|
||||
let mut brands: Vec<&'static str> = match family {
|
||||
"chrome-beta" => vec!["Chrome Beta", "Chrome"],
|
||||
"chrome-dev" => vec!["Chrome Dev", "Chrome"],
|
||||
"chrome-canary" => vec!["Chrome Canary", "Chrome"],
|
||||
"brave" => vec!["Brave", "Brave Browser"],
|
||||
"brave-beta" => vec!["Brave Beta", "Brave Browser", "Brave"],
|
||||
"brave-nightly" => vec!["Brave Nightly", "Brave Browser", "Brave"],
|
||||
"edge" => vec!["Microsoft Edge", "Chromium"],
|
||||
"edge-beta" => vec!["Microsoft Edge Beta", "Microsoft Edge"],
|
||||
"edge-dev" => vec!["Microsoft Edge Dev", "Microsoft Edge"],
|
||||
"vivaldi" => vec!["Vivaldi", "Chromium"],
|
||||
"opera" => vec!["Opera", "Chromium"],
|
||||
"opera-gx" => vec!["Opera GX", "Opera", "Chromium"],
|
||||
"arc" => vec!["Arc", "Chromium"],
|
||||
"yandex" => vec!["Yandex", "Yandex Browser", "Chromium"],
|
||||
// "chromium" covers both Google Chrome and upstream Chromium; the install
|
||||
// path is the only thing that tells them apart.
|
||||
_ => vec!["Chrome", "Chromium"],
|
||||
};
|
||||
|
||||
if (family.is_empty() || family == "chromium")
|
||||
&& path.contains("Chromium")
|
||||
&& !path.contains("Google")
|
||||
{
|
||||
brands = vec!["Chromium", "Chrome"];
|
||||
}
|
||||
|
||||
brands
|
||||
}
|
||||
|
||||
/// Recover whatever key material the source browser used.
|
||||
///
|
||||
/// `source_user_data_dir` is the directory holding `Local State` (the parent of
|
||||
/// the profile directory), which is where Windows keeps its wrapped key. It is
|
||||
/// `None` when the user pointed at a bare profile folder with no parent we can
|
||||
/// trust.
|
||||
pub fn recover_source_keys(
|
||||
family: &str,
|
||||
source_path: &Path,
|
||||
source_user_data_dir: Option<&Path>,
|
||||
report: &mut super::report::ProfileImportReport,
|
||||
) -> SourceKeyring {
|
||||
let mut keyring = SourceKeyring::default();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = source_user_data_dir;
|
||||
for brand in brand_candidates(family, source_path) {
|
||||
match macos_keychain_password(brand) {
|
||||
Ok(Some(password)) => {
|
||||
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(&password, MAC_ITERATIONS)));
|
||||
log::info!("Recovered os_crypt password for '{brand} Safe Storage'");
|
||||
break;
|
||||
}
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
log::warn!("Keychain lookup for '{brand} Safe Storage' failed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = source_path;
|
||||
if let Some(dir) = source_user_data_dir {
|
||||
match windows_local_state_key(dir) {
|
||||
Ok(Some(key)) => keyring.v10 = Some(CryptoKey::Aes256Gcm(key)),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("DPAPI key recovery failed: {e}"),
|
||||
}
|
||||
if windows_has_app_bound_key(dir) {
|
||||
// Recorded up front: the cookie store will be full of `v20` records
|
||||
// and the user deserves to know why before they see the count.
|
||||
report.warn(warning::APP_BOUND_ENCRYPTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = source_user_data_dir;
|
||||
// A profile can hold both tags at once, so populate both slots rather than
|
||||
// choosing one. v10 is always available: it is a hardcoded password.
|
||||
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(
|
||||
POSIX_FALLBACK_PASSWORD,
|
||||
POSIX_ITERATIONS,
|
||||
)));
|
||||
for brand in brand_candidates(family, source_path) {
|
||||
match linux_secret_service_password(brand) {
|
||||
Ok(Some(password)) => {
|
||||
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(
|
||||
&password,
|
||||
POSIX_ITERATIONS,
|
||||
)));
|
||||
log::info!("Recovered os_crypt secret for '{brand} Safe Storage'");
|
||||
break;
|
||||
}
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
log::warn!("Secret service lookup for '{brand} Safe Storage' failed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if keyring.is_empty() {
|
||||
report.warn(warning::SECRETS_NOT_MIGRATED);
|
||||
}
|
||||
|
||||
// Silence unused-parameter warnings on platforms that do not use every arg.
|
||||
let _ = (family, source_path, source_user_data_dir);
|
||||
keyring
|
||||
}
|
||||
|
||||
/// How long to wait on a keyring before giving up.
|
||||
///
|
||||
/// Both backends can put a dialog in front of the user — macOS asks whether
|
||||
/// Donut may read another app's Keychain item, and an unlocked-on-demand
|
||||
/// keyring prompts on Linux. That is fine interactively, but an import driven
|
||||
/// over REST or MCP would otherwise wedge forever with nobody at the screen.
|
||||
/// Long enough for a person to notice and click; short enough that automation
|
||||
/// recovers into "secrets not migrated", which is merely a partial import.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
const KEYRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Run a keyring lookup on its own OS thread, bounded by [`KEYRING_TIMEOUT`].
|
||||
///
|
||||
/// Off-thread rather than inline for two reasons: import already runs inside
|
||||
/// `spawn_blocking`, and zbus's blocking API drives a private tokio runtime, so
|
||||
/// keeping it off a runtime-owned thread sidesteps any nested-runtime question;
|
||||
/// and it turns a panic or a stuck IPC call into a recoverable warning instead
|
||||
/// of a failed import.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn run_keyring_lookup<F>(what: &str, lookup: F) -> Result<Option<Vec<u8>>, String>
|
||||
where
|
||||
F: FnOnce() -> Result<Option<Vec<u8>>, String> + Send + std::panic::UnwindSafe + 'static,
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
std::panic::catch_unwind(lookup).unwrap_or_else(|_| Err("lookup panicked".to_string()));
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(KEYRING_TIMEOUT) {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("{what} did not respond")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_keychain_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
let brand = brand.to_string();
|
||||
run_keyring_lookup("keychain", move || macos_keychain_lookup(&brand))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_keychain_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
use security_framework::passwords::get_generic_password;
|
||||
|
||||
let service = format!("{brand} Safe Storage");
|
||||
match get_generic_password(&service, brand) {
|
||||
Ok(password) => Ok(Some(password)),
|
||||
Err(e) => {
|
||||
// errSecItemNotFound: this brand simply is not installed. Anything else
|
||||
// (notably errSecAuthFailed / errSecUserCanceled when the user declines
|
||||
// the access dialog) is a real failure worth surfacing.
|
||||
if e.code() == -25300 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn read_local_state_os_crypt(dir: &Path) -> Option<serde_json::Value> {
|
||||
let raw = std::fs::read_to_string(dir.join("Local State")).ok()?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
parsed.get("os_crypt").cloned()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_has_app_bound_key(dir: &Path) -> bool {
|
||||
read_local_state_os_crypt(dir)
|
||||
.and_then(|v| {
|
||||
v.get("app_bound_encrypted_key")
|
||||
.and_then(|k| k.as_str().map(str::to_string))
|
||||
})
|
||||
.is_some_and(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_local_state_key(dir: &Path) -> Result<Option<[u8; 32]>, String> {
|
||||
use base64::Engine;
|
||||
|
||||
let Some(os_crypt) = read_local_state_os_crypt(dir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(encoded) = os_crypt.get("encrypted_key").and_then(|k| k.as_str()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|e| format!("encrypted_key is not valid base64: {e}"))?;
|
||||
|
||||
// The blob is "DPAPI" || CryptProtectData(key).
|
||||
const DPAPI_PREFIX: &[u8] = b"DPAPI";
|
||||
if !decoded.starts_with(DPAPI_PREFIX) {
|
||||
return Err("encrypted_key is missing the DPAPI header".to_string());
|
||||
}
|
||||
|
||||
let unwrapped = dpapi_unprotect(&decoded[DPAPI_PREFIX.len()..])?;
|
||||
let key: [u8; 32] = unwrapped
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| format!("expected a 32-byte AES key, got {} bytes", unwrapped.len()))?;
|
||||
Ok(Some(key))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn dpapi_unprotect(ciphertext: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use windows::Win32::Foundation::LocalFree;
|
||||
use windows::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB};
|
||||
|
||||
let mut input = CRYPT_INTEGER_BLOB {
|
||||
cbData: ciphertext.len() as u32,
|
||||
pbData: ciphertext.as_ptr() as *mut u8,
|
||||
};
|
||||
let mut output = CRYPT_INTEGER_BLOB::default();
|
||||
|
||||
// SAFETY: `input` points at a live slice for the duration of the call, and
|
||||
// `output` is freed via LocalFree exactly once below, as the API requires.
|
||||
unsafe {
|
||||
CryptUnprotectData(&mut input, None, None, None, None, 0, &mut output)
|
||||
.map_err(|e| format!("CryptUnprotectData failed: {e}"))?;
|
||||
|
||||
let plaintext = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
|
||||
let _ = LocalFree(Some(windows::Win32::Foundation::HLOCAL(
|
||||
output.pbData as *mut core::ffi::c_void,
|
||||
)));
|
||||
Ok(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_secret_service_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
let brand = brand.to_string();
|
||||
run_keyring_lookup("secret service", move || {
|
||||
linux_secret_service_lookup(&brand)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_secret_service_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
use secret_service::blocking::SecretService;
|
||||
use secret_service::EncryptionType;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let service =
|
||||
SecretService::connect(EncryptionType::Dh).map_err(|e| format!("no secret service: {e}"))?;
|
||||
let collection = service
|
||||
.get_default_collection()
|
||||
.map_err(|e| format!("no default collection: {e}"))?;
|
||||
if collection.is_locked().unwrap_or(true) {
|
||||
collection
|
||||
.unlock()
|
||||
.map_err(|e| format!("keyring is locked: {e}"))?;
|
||||
}
|
||||
|
||||
// Match on the item's LABEL, not on its `application` attribute.
|
||||
//
|
||||
// `freedesktop_secret_key_provider.cc` stores two attributes —
|
||||
// `application: kAppName` and `xdg:schema` — and sets the label to
|
||||
// `kKeyName`, which is always "<Brand> Safe Storage". `kAppName` is a
|
||||
// per-fork branding string ("chrome", "chromium", …) that we cannot derive
|
||||
// from a display name: lowercasing "Microsoft Edge" gives "microsoft edge",
|
||||
// which matches nothing, and the search would silently return zero items.
|
||||
// The label is the one identifier that is the same across every fork and is
|
||||
// exactly the string we already build for the macOS Keychain.
|
||||
let label = format!("{brand} Safe Storage");
|
||||
|
||||
// The schema attribute narrows the scan to os_crypt secrets; it is shared by
|
||||
// every Chromium fork, so it costs nothing in portability.
|
||||
let mut attributes = HashMap::new();
|
||||
attributes.insert("xdg:schema", "chrome_libsecret_os_crypt_password_v2");
|
||||
let mut items = collection
|
||||
.search_items(attributes)
|
||||
.map_err(|e| format!("search failed: {e}"))?;
|
||||
if items.is_empty() {
|
||||
// Older Chromium releases used a v1 schema, and some forks omit it.
|
||||
items = collection
|
||||
.get_all_items()
|
||||
.map_err(|e| format!("could not list items: {e}"))?;
|
||||
}
|
||||
|
||||
for item in &items {
|
||||
if item.get_label().is_ok_and(|found| found == label) {
|
||||
return item
|
||||
.get_secret()
|
||||
.map(Some)
|
||||
.map_err(|e| format!("could not read secret: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn chromium_family_disambiguates_chrome_from_chromium_by_path() {
|
||||
let chrome = PathBuf::from("/Users/x/Library/Application Support/Google/Chrome/Default");
|
||||
assert_eq!(brand_candidates("chromium", &chrome)[0], "Chrome");
|
||||
|
||||
let chromium = PathBuf::from("/Users/x/Library/Application Support/Chromium/Default");
|
||||
assert_eq!(brand_candidates("chromium", &chromium)[0], "Chromium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_brand_falls_back_to_a_second_candidate() {
|
||||
// A single candidate means one wrong guess loses the secrets entirely, so
|
||||
// each family must offer a fallback identity.
|
||||
for family in [
|
||||
"chrome-beta",
|
||||
"chrome-dev",
|
||||
"chrome-canary",
|
||||
"brave",
|
||||
"edge",
|
||||
"vivaldi",
|
||||
"opera",
|
||||
"opera-gx",
|
||||
"arc",
|
||||
"yandex",
|
||||
"chromium",
|
||||
] {
|
||||
let candidates = brand_candidates(family, Path::new("/tmp/profile"));
|
||||
assert!(
|
||||
candidates.len() >= 2,
|
||||
"{family} needs a fallback brand candidate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_family_still_yields_candidates() {
|
||||
let candidates = brand_candidates("something-new", Path::new("/tmp/profile"));
|
||||
assert!(!candidates.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Working out what the user pointed at, and where its files have to land.
|
||||
//!
|
||||
//! Two layout facts drive everything here:
|
||||
//!
|
||||
//! 1. Donut launches with `--user-data-dir` and no `--profile-directory`, so
|
||||
//! Chromium reads `<user-data-dir>/Default/` (`chrome_constants.cc`
|
||||
//! `kInitialProfile`). A source *profile* directory therefore has to be
|
||||
//! copied one level down, not onto the root.
|
||||
//! 2. Network state (`Cookies`, `TransportSecurity`, …) lives in
|
||||
//! `Default/Network/` on Windows and in `Default/` everywhere else. That
|
||||
//! split is not cosmetic: `kTriggerNetworkDataMigration` is enabled by
|
||||
//! default only on Windows, and on the other platforms Chromium actively
|
||||
//! redirects reads back to `Default/`. A profile exported from Windows is
|
||||
//! invisible on macOS until its files are moved up, and vice versa.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Files Chromium keeps under `Default/Network/` on Windows and directly under
|
||||
/// `Default/` on macOS and Linux.
|
||||
pub const NETWORK_DATA_FILES: &[&str] = &[
|
||||
"Cookies",
|
||||
"Cookies-journal",
|
||||
"Network Persistent State",
|
||||
"Reporting and NEL",
|
||||
"SCT Auditing Pending Reports",
|
||||
"Trust Tokens",
|
||||
"Trust Tokens-journal",
|
||||
"TransportSecurity",
|
||||
"Device Bound Sessions",
|
||||
"Device Bound Sessions-journal",
|
||||
];
|
||||
|
||||
/// What the user handed us.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SourceKind {
|
||||
/// A profile directory (holds `Preferences`): `.../Chrome/Default`.
|
||||
ProfileDir,
|
||||
/// A user-data directory whose profile lives at its root — Opera's layout.
|
||||
RootProfileUserDataDir,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SourceShape {
|
||||
pub kind: SourceKind,
|
||||
/// The directory holding `Preferences` — the content that becomes `Default/`.
|
||||
pub profile_dir: PathBuf,
|
||||
/// The directory holding `Local State`, when there is one. Windows keeps the
|
||||
/// DPAPI-wrapped os_crypt key there, so losing it loses every secret.
|
||||
pub user_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Why a directory cannot be imported.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RejectReason {
|
||||
/// Recognisably a Gecko profile. Worth naming explicitly: silently returning
|
||||
/// "nothing found" for a Firefox folder is what made import feel broken.
|
||||
Firefox,
|
||||
/// Not a browser profile we recognise at all.
|
||||
NotChromium,
|
||||
}
|
||||
|
||||
/// Markers that identify a real Chromium profile directory. `Preferences` is
|
||||
/// the usual one, but a profile whose prefs were wiped still has data worth
|
||||
/// carrying, so any of these counts.
|
||||
const CHROMIUM_PROFILE_MARKERS: &[&str] = &[
|
||||
"Preferences",
|
||||
"Secure Preferences",
|
||||
"History",
|
||||
"Cookies",
|
||||
"Bookmarks",
|
||||
"Web Data",
|
||||
"Login Data",
|
||||
];
|
||||
|
||||
fn looks_like_chromium_profile(dir: &Path) -> bool {
|
||||
CHROMIUM_PROFILE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| dir.join(marker).exists())
|
||||
// Windows-layout profiles keep Cookies one level down.
|
||||
|| dir.join("Network").join("Cookies").exists()
|
||||
}
|
||||
|
||||
fn looks_like_firefox_profile(dir: &Path) -> bool {
|
||||
// Any one of these alone can appear elsewhere; together they are conclusive.
|
||||
let markers = ["prefs.js", "places.sqlite", "cookies.sqlite", "key4.db"];
|
||||
markers.iter().filter(|m| dir.join(m).exists()).count() >= 2
|
||||
}
|
||||
|
||||
/// Classify an import source, or explain why it cannot be one.
|
||||
pub fn classify(source: &Path) -> Result<SourceShape, RejectReason> {
|
||||
if looks_like_firefox_profile(source) {
|
||||
return Err(RejectReason::Firefox);
|
||||
}
|
||||
if !looks_like_chromium_profile(source) {
|
||||
return Err(RejectReason::NotChromium);
|
||||
}
|
||||
|
||||
// A directory that holds both profile markers and `Local State` is Opera's
|
||||
// root-profile layout: the user-data dir and the profile are the same place.
|
||||
let kind = if source.join("Local State").exists() {
|
||||
SourceKind::RootProfileUserDataDir
|
||||
} else {
|
||||
SourceKind::ProfileDir
|
||||
};
|
||||
|
||||
let user_data_dir = match kind {
|
||||
SourceKind::RootProfileUserDataDir => Some(source.to_path_buf()),
|
||||
// For `.../Chrome/Default`, `Local State` is in `.../Chrome`. Only accept
|
||||
// the parent if it really holds one, so a profile copied to a random
|
||||
// folder does not make us read a stranger's `Local State`.
|
||||
SourceKind::ProfileDir => source.parent().and_then(|parent| {
|
||||
if parent.join("Local State").exists() {
|
||||
return Some(parent.to_path_buf());
|
||||
}
|
||||
// Opera keeps its extra profiles at `<user-data-dir>/_side_profiles/<id>`
|
||||
// but still launches them against the same user-data dir, so the
|
||||
// DPAPI-wrapped os_crypt key sits one further level up. Without this,
|
||||
// every Opera side profile imports on Windows with no secrets at all.
|
||||
if parent.file_name() == Some(std::ffi::OsStr::new("_side_profiles")) {
|
||||
return parent
|
||||
.parent()
|
||||
.filter(|root| root.join("Local State").exists())
|
||||
.map(Path::to_path_buf);
|
||||
}
|
||||
None
|
||||
}),
|
||||
};
|
||||
|
||||
Ok(SourceShape {
|
||||
kind,
|
||||
profile_dir: source.to_path_buf(),
|
||||
user_data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// Move network data into the position the *host* Chromium build reads from.
|
||||
///
|
||||
/// Host, not source: the files were written by whatever browser produced them,
|
||||
/// but they will be read by Wayfern running here. Getting this backwards is a
|
||||
/// silent, total cookie loss on any cross-platform import.
|
||||
pub fn normalize_network_dir(default_dir: &Path) -> std::io::Result<()> {
|
||||
let network_dir = default_dir.join("Network");
|
||||
|
||||
let (from, to) = if cfg!(target_os = "windows") {
|
||||
(default_dir.to_path_buf(), network_dir.clone())
|
||||
} else {
|
||||
(network_dir.clone(), default_dir.to_path_buf())
|
||||
};
|
||||
|
||||
if !from.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for name in NETWORK_DATA_FILES {
|
||||
let src = from.join(name);
|
||||
if !src.is_file() {
|
||||
continue;
|
||||
}
|
||||
std::fs::create_dir_all(&to)?;
|
||||
let dest = to.join(name);
|
||||
if dest.exists() {
|
||||
// Both positions hold the file. The one in the source position is the
|
||||
// stale duplicate: on Windows, Chromium's migration would copy it over
|
||||
// the newer file ("overwrite the new file with the old file even if it
|
||||
// exists already", network_sandbox.cc), so it has to go.
|
||||
std::fs::remove_file(&src)?;
|
||||
continue;
|
||||
}
|
||||
std::fs::rename(&src, &dest).or_else(|_| {
|
||||
// Rename across devices can fail even within one tree on some setups.
|
||||
std::fs::copy(&src, &dest).and_then(|_| std::fs::remove_file(&src))?;
|
||||
Ok::<(), std::io::Error>(())
|
||||
})?;
|
||||
}
|
||||
|
||||
if !cfg!(target_os = "windows") {
|
||||
// Chromium's migration checkpoint, and the reason an otherwise-correct
|
||||
// move is not enough. `network_sandbox.cc:478` treats the presence of
|
||||
// `NetworkDataMigrated` as proof the migration already ran, keeps the (now
|
||||
// empty) `Network/` as the data directory, and then `CleanUpOldData` at
|
||||
// `:536-540` DELETES the files we just moved up into `Default/`. A profile
|
||||
// exported from Windows would lose every cookie on first launch.
|
||||
let _ = std::fs::remove_file(network_dir.join("NetworkDataMigrated"));
|
||||
|
||||
// Leave no empty `Network/` behind: harmless, but it makes a profile look
|
||||
// like it still holds network state.
|
||||
if network_dir.is_dir() && std::fs::read_dir(&network_dir)?.next().is_none() {
|
||||
let _ = std::fs::remove_dir(&network_dir);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the cookie store ends up for the host platform.
|
||||
pub fn host_cookie_path(default_dir: &Path) -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
default_dir.join("Network").join("Cookies")
|
||||
} else {
|
||||
default_dir.join("Cookies")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn touch(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, b"x").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_profile_dir_is_classified_without_a_user_data_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("Default");
|
||||
touch(&profile.join("Preferences"));
|
||||
|
||||
let shape = classify(&profile).expect("should classify");
|
||||
assert_eq!(shape.kind, SourceKind::ProfileDir);
|
||||
assert_eq!(shape.user_data_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_dir_finds_local_state_in_its_parent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("Default");
|
||||
touch(&profile.join("Preferences"));
|
||||
touch(&dir.path().join("Local State"));
|
||||
|
||||
let shape = classify(&profile).expect("should classify");
|
||||
// Windows keeps the wrapped os_crypt key here; missing it means no secrets.
|
||||
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opera_root_layout_is_its_own_user_data_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("Preferences"));
|
||||
touch(&dir.path().join("Local State"));
|
||||
|
||||
let shape = classify(dir.path()).expect("should classify");
|
||||
assert_eq!(shape.kind, SourceKind::RootProfileUserDataDir);
|
||||
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firefox_profile_is_rejected_by_name() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("prefs.js"));
|
||||
touch(&dir.path().join("places.sqlite"));
|
||||
assert_eq!(classify(dir.path()), Err(RejectReason::Firefox));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_directory_is_rejected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert_eq!(classify(dir.path()), Err(RejectReason::NotChromium));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_layout_profile_is_recognised_without_root_markers() {
|
||||
// A profile whose only surviving data is Windows-layout cookies.
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("Network").join("Cookies"));
|
||||
assert!(classify(dir.path()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opera_side_profile_finds_local_state_two_levels_up() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("_side_profiles").join("gaming");
|
||||
touch(&profile.join("Preferences"));
|
||||
touch(&dir.path().join("Local State"));
|
||||
|
||||
let shape = classify(&profile).expect("should classify");
|
||||
assert_eq!(
|
||||
shape.user_data_dir.as_deref(),
|
||||
Some(dir.path()),
|
||||
"Windows keeps the os_crypt key in the root Local State, not beside the profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_in_an_unrelated_folder_does_not_adopt_a_strangers_local_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("_side_profiles").join("gaming");
|
||||
touch(&profile.join("Preferences"));
|
||||
// No Local State anywhere above it.
|
||||
let shape = classify(&profile).expect("should classify");
|
||||
assert_eq!(shape.user_data_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_checkpoint_is_removed_so_chromium_does_not_delete_the_moved_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
touch(&default_dir.join("Network").join("Cookies"));
|
||||
touch(&default_dir.join("Network").join("NetworkDataMigrated"));
|
||||
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
|
||||
assert!(host_cookie_path(&default_dir).is_file());
|
||||
if !cfg!(target_os = "windows") {
|
||||
assert!(
|
||||
!default_dir
|
||||
.join("Network")
|
||||
.join("NetworkDataMigrated")
|
||||
.exists(),
|
||||
"the checkpoint makes Chromium delete the files we just moved up"
|
||||
);
|
||||
assert!(!default_dir.join("Network").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_files_are_moved_into_the_host_position() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
|
||||
// Seed the file in the position the host does NOT read from.
|
||||
if cfg!(target_os = "windows") {
|
||||
touch(&default_dir.join("Cookies"));
|
||||
} else {
|
||||
touch(&default_dir.join("Network").join("Cookies"));
|
||||
}
|
||||
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
|
||||
assert!(
|
||||
host_cookie_path(&default_dir).is_file(),
|
||||
"cookies must end up where this platform's Chromium reads them"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_duplicate_in_the_source_position_is_removed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
touch(&default_dir.join("Cookies"));
|
||||
touch(&default_dir.join("Network").join("Cookies"));
|
||||
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
|
||||
assert!(host_cookie_path(&default_dir).is_file());
|
||||
let stale = if cfg!(target_os = "windows") {
|
||||
default_dir.join("Cookies")
|
||||
} else {
|
||||
default_dir.join("Network").join("Cookies")
|
||||
};
|
||||
assert!(
|
||||
!stale.exists(),
|
||||
"the duplicate would be copied over the live file by Chromium's migration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
touch(&default_dir.join("Network").join("Cookies"));
|
||||
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
|
||||
assert!(host_cookie_path(&default_dir).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_on_a_profile_with_no_network_data_is_a_no_op() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
std::fs::create_dir_all(&default_dir).unwrap();
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
assert!(!host_cookie_path(&default_dir).exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Turning someone else's browser profile into one Wayfern will actually load.
|
||||
//!
|
||||
//! The old importer copied a source profile directory verbatim onto the new
|
||||
//! profile's `--user-data-dir`. Chromium reads `<user-data-dir>/Default/`, so
|
||||
//! every imported file sat one level above where the browser looked and the
|
||||
//! profile came up empty — and even in the right place the secrets would not
|
||||
//! have opened, because they are sealed with a key held in the source
|
||||
//! machine's Keychain / DPAPI / secret service that Wayfern never consults.
|
||||
//!
|
||||
//! This module does the whole job: classify the source, recover its key, copy
|
||||
//! with consistent database snapshots, put the files where Chromium reads them,
|
||||
//! re-seal every secret with Wayfern's portable key, and report exactly what
|
||||
//! came across.
|
||||
|
||||
pub mod copy;
|
||||
pub mod keyring;
|
||||
pub mod layout;
|
||||
pub mod os_crypt;
|
||||
pub mod report;
|
||||
pub mod rewrite;
|
||||
|
||||
use layout::RejectReason;
|
||||
use report::{warning, ProfileImportReport};
|
||||
use std::path::Path;
|
||||
|
||||
/// The profile subdirectory Chromium reads when no `--profile-directory` is
|
||||
/// passed (`chrome_constants.cc` `kInitialProfile`). Donut never passes one.
|
||||
pub const INITIAL_PROFILE_DIR: &str = "Default";
|
||||
|
||||
/// Import `source` into `dest_user_data_dir`, which becomes the new profile's
|
||||
/// `--user-data-dir`.
|
||||
///
|
||||
/// Never fails because part of the data could not be carried: partial results
|
||||
/// plus an honest report beat an all-or-nothing import that leaves the user
|
||||
/// with nothing and no explanation. It fails only when the source is not
|
||||
/// importable at all, or when the target key cannot be established — without
|
||||
/// that key, anything written would be unreadable forever.
|
||||
pub fn import_into(
|
||||
source: &Path,
|
||||
dest_user_data_dir: &Path,
|
||||
source_family: &str,
|
||||
allow_running: bool,
|
||||
) -> Result<ProfileImportReport, String> {
|
||||
let shape = layout::classify(source).map_err(|reason| match reason {
|
||||
RejectReason::Firefox => serde_json::json!({
|
||||
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
|
||||
"params": { "family": "Firefox" }
|
||||
})
|
||||
.to_string(),
|
||||
RejectReason::NotChromium => serde_json::json!({
|
||||
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
|
||||
"params": { "family": "" }
|
||||
})
|
||||
.to_string(),
|
||||
})?;
|
||||
|
||||
let mut report = ProfileImportReport::default();
|
||||
|
||||
if let Some(running) = running_source_browser(&shape) {
|
||||
if !allow_running {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "IMPORT_SOURCE_BROWSER_RUNNING",
|
||||
"params": { "browser": running }
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// Databases are snapshotted transactionally, but LevelDB site data is
|
||||
// copied as files and can be mid-write.
|
||||
report.warn(warning::SOURCE_BROWSER_RUNNING);
|
||||
}
|
||||
|
||||
// Mint the target key first. Everything after this point is written to be
|
||||
// readable with it, and a profile whose key could not be persisted would
|
||||
// lose every secret the first time the browser exits.
|
||||
let target = os_crypt::TargetKey::ensure(dest_user_data_dir)?;
|
||||
|
||||
// Recover the source key before the copy: on macOS this may prompt, and
|
||||
// asking before a multi-GB copy respects the user's time.
|
||||
let source_keys = keyring::recover_source_keys(
|
||||
source_family,
|
||||
&shape.profile_dir,
|
||||
shape.user_data_dir.as_deref(),
|
||||
&mut report,
|
||||
);
|
||||
|
||||
let default_dir = dest_user_data_dir.join(INITIAL_PROFILE_DIR);
|
||||
let outcome = copy::copy_profile_tree(&shape.profile_dir, &default_dir)?;
|
||||
report.bytes_copied = outcome.bytes_copied;
|
||||
if !outcome.unreadable_stores.is_empty() {
|
||||
report.warn(warning::STORE_UNREADABLE);
|
||||
}
|
||||
|
||||
layout::normalize_network_dir(&default_dir)
|
||||
.map_err(|e| format!("Failed to place network data: {e}"))?;
|
||||
|
||||
rewrite::finalize_profile(&default_dir, &source_keys, &target, &mut report);
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Is the browser that owns this profile currently running?
|
||||
///
|
||||
/// Matched on the profile path in the process command line rather than on the
|
||||
/// executable name: the user may well have Chrome open on a *different*
|
||||
/// profile, which is no reason to block the import.
|
||||
fn running_source_browser(shape: &layout::SourceShape) -> Option<String> {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
|
||||
let needle = shape
|
||||
.user_data_dir
|
||||
.as_deref()
|
||||
.unwrap_or(&shape.profile_dir)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
for process in system.processes().values() {
|
||||
let name = process.name().to_string_lossy().to_lowercase();
|
||||
let looks_like_a_browser = name.contains("chrome")
|
||||
|| name.contains("chromium")
|
||||
|| name.contains("brave")
|
||||
|| name.contains("edge")
|
||||
|| name.contains("vivaldi")
|
||||
|| name.contains("opera")
|
||||
|| name.contains("arc")
|
||||
|| name.contains("yandex");
|
||||
if !looks_like_a_browser {
|
||||
continue;
|
||||
}
|
||||
// Donut's own browser is Wayfern; never report it as the source.
|
||||
if name.contains("wayfern") {
|
||||
continue;
|
||||
}
|
||||
if process
|
||||
.cmd()
|
||||
.iter()
|
||||
.any(|arg| arg.to_string_lossy().contains(&needle))
|
||||
{
|
||||
return Some(process.name().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Move a profile that an earlier build imported into the broken root layout
|
||||
/// down into `Default/`, where the browser reads it.
|
||||
///
|
||||
/// Without this, everything those users imported stays stranded: their real
|
||||
/// data sits at `profile/Cookies` while Wayfern reads and writes
|
||||
/// `profile/Default/Cookies`. Their secrets remain unreadable — the source key
|
||||
/// was never captured and cannot be recovered after the fact — but history,
|
||||
/// bookmarks, extensions and site data become visible again.
|
||||
///
|
||||
/// Returns `Ok(true)` when a repair was performed.
|
||||
pub fn repair_legacy_layout(user_data_dir: &Path) -> Result<bool, String> {
|
||||
let default_dir = user_data_dir.join(INITIAL_PROFILE_DIR);
|
||||
|
||||
// The broken shape is exactly: profile markers at the root, and no `Default/`
|
||||
// for the browser to have used instead.
|
||||
let has_root_profile = user_data_dir.join("Preferences").exists()
|
||||
|| user_data_dir.join("History").exists()
|
||||
|| user_data_dir.join("Cookies").exists();
|
||||
if !has_root_profile || default_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Root-level files that belong to the user-data dir, not to the profile.
|
||||
const ROOT_LEVEL: &[&str] = &[
|
||||
"Local State",
|
||||
"os_crypt_key",
|
||||
"First Run",
|
||||
"Last Version",
|
||||
"Variations",
|
||||
"ChromeFeatureState",
|
||||
"RunningChromeVersion",
|
||||
"SingletonLock",
|
||||
"SingletonCookie",
|
||||
"SingletonSocket",
|
||||
"user.js",
|
||||
"metadata.json",
|
||||
".donut-sync",
|
||||
];
|
||||
|
||||
let staging = user_data_dir.join(".donut-import-repair");
|
||||
if staging.exists() {
|
||||
std::fs::remove_dir_all(&staging).map_err(|e| format!("Failed to clear staging: {e}"))?;
|
||||
}
|
||||
std::fs::create_dir_all(&staging).map_err(|e| format!("Failed to create staging: {e}"))?;
|
||||
|
||||
let entries =
|
||||
std::fs::read_dir(user_data_dir).map_err(|e| format!("Failed to read profile: {e}"))?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name_str) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if ROOT_LEVEL.contains(&name_str) || name_str == ".donut-import-repair" {
|
||||
continue;
|
||||
}
|
||||
std::fs::rename(entry.path(), staging.join(name_str))
|
||||
.map_err(|e| format!("Failed to relocate {name_str}: {e}"))?;
|
||||
}
|
||||
|
||||
std::fs::rename(&staging, &default_dir)
|
||||
.map_err(|e| format!("Failed to install {INITIAL_PROFILE_DIR}: {e}"))?;
|
||||
|
||||
// Now that the files are in the right place, put the network data where this
|
||||
// platform reads it too.
|
||||
let _ = layout::normalize_network_dir(&default_dir);
|
||||
// And make sure the profile has a key, so the browser does not mint one
|
||||
// mid-session and lose whatever it writes.
|
||||
let _ = os_crypt::TargetKey::ensure(user_data_dir);
|
||||
|
||||
log::info!(
|
||||
"Repaired legacy import layout at {} (moved profile content into {INITIAL_PROFILE_DIR}/)",
|
||||
user_data_dir.display()
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn touch(path: &Path, contents: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_places_everything_under_default() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("Chrome").join("Default");
|
||||
let dest = dir.path().join("profile");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(&source.join("Bookmarks"), b"{\"roots\":{}}");
|
||||
|
||||
let report = import_into(&source, &dest, "chromium", true).expect("import");
|
||||
|
||||
assert!(
|
||||
dest.join("Default").join("Preferences").exists(),
|
||||
"Chromium reads Default/, not the user-data-dir root"
|
||||
);
|
||||
assert!(
|
||||
!dest.join("Preferences").exists(),
|
||||
"nothing profile-scoped belongs at the root"
|
||||
);
|
||||
assert!(dest.join(os_crypt::KEY_FILE_NAME).exists());
|
||||
assert!(report.bytes_copied > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_a_firefox_profile_by_name() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("xyz.default-release");
|
||||
let dest = dir.path().join("profile");
|
||||
touch(&source.join("prefs.js"), b"");
|
||||
touch(&source.join("places.sqlite"), b"");
|
||||
|
||||
let err = import_into(&source, &dest, "firefox", true).expect_err("must reject");
|
||||
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
|
||||
assert!(
|
||||
err.contains("Firefox"),
|
||||
"the user needs to be told why, not just that it failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_an_arbitrary_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("holiday-photos");
|
||||
let dest = dir.path().join("profile");
|
||||
touch(&source.join("IMG_0001.jpg"), b"not a profile");
|
||||
|
||||
let err = import_into(&source, &dest, "chromium", true).expect_err("must reject");
|
||||
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_is_rerunnable_over_the_same_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("Default");
|
||||
let dest = dir.path().join("profile");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
|
||||
import_into(&source, &dest, "chromium", true).expect("first");
|
||||
let key = std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap();
|
||||
import_into(&source, &dest, "chromium", true).expect("second");
|
||||
assert_eq!(
|
||||
std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap(),
|
||||
key,
|
||||
"re-running must not orphan what the first run encrypted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_layout_is_repaired_into_default() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("profile");
|
||||
// Exactly what the old importer produced.
|
||||
touch(&profile.join("Preferences"), b"{}");
|
||||
touch(&profile.join("History"), b"");
|
||||
touch(
|
||||
&profile
|
||||
.join("Local Storage")
|
||||
.join("leveldb")
|
||||
.join("CURRENT"),
|
||||
b"",
|
||||
);
|
||||
touch(&profile.join("Local State"), b"{}");
|
||||
|
||||
assert!(repair_legacy_layout(&profile).unwrap());
|
||||
|
||||
assert!(profile.join("Default").join("Preferences").exists());
|
||||
assert!(profile.join("Default").join("History").exists());
|
||||
assert!(profile
|
||||
.join("Default")
|
||||
.join("Local Storage")
|
||||
.join("leveldb")
|
||||
.join("CURRENT")
|
||||
.exists());
|
||||
assert!(
|
||||
profile.join("Local State").exists(),
|
||||
"Local State belongs to the user-data dir, not the profile"
|
||||
);
|
||||
assert!(profile.join(os_crypt::KEY_FILE_NAME).exists());
|
||||
assert!(!profile.join(".donut-import-repair").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_leaves_a_healthy_profile_alone() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("profile");
|
||||
touch(&profile.join("Default").join("Preferences"), b"{}");
|
||||
touch(&profile.join("Local State"), b"{}");
|
||||
|
||||
assert!(!repair_legacy_layout(&profile).unwrap());
|
||||
assert!(profile.join("Default").join("Preferences").exists());
|
||||
assert!(!profile.join("Default").join("Default").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_is_a_no_op_on_an_empty_profile() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("profile");
|
||||
std::fs::create_dir_all(&profile).unwrap();
|
||||
assert!(!repair_legacy_layout(&profile).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("profile");
|
||||
touch(&profile.join("Preferences"), b"{}");
|
||||
|
||||
assert!(repair_legacy_layout(&profile).unwrap());
|
||||
assert!(!repair_legacy_layout(&profile).unwrap());
|
||||
assert!(profile.join("Default").join("Preferences").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
//! Key material for profile import.
|
||||
//!
|
||||
//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async`
|
||||
//! key provider is patched to read (or mint) `<user-data-dir>/os_crypt_key`
|
||||
//! instead, so a profile directory is self-contained and portable. See
|
||||
//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`.
|
||||
//!
|
||||
//! That portability is exactly why an imported Chrome profile carries nothing:
|
||||
//! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI
|
||||
//! / the Freedesktop secret service, and Wayfern never looks there. Import has
|
||||
//! to open the source's lock and re-seal everything with Wayfern's.
|
||||
//!
|
||||
//! The on-disk format is per-platform and NOT interchangeable, matching the
|
||||
//! provider that owns each tag in the patched Chromium 151 tree:
|
||||
//!
|
||||
//! | Host | `os_crypt_key` | Derivation | Cipher | Tag |
|
||||
//! |---------|---------------------|-------------------------------------|--------------|-------|
|
||||
//! | macOS | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1003) | AES-128-CBC | `v10` |
|
||||
//! | Linux | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1) | AES-128-CBC | `v11` |
|
||||
//! | Windows | 32 raw bytes | none, the bytes are the key | AES-256-GCM | `v10` |
|
||||
//!
|
||||
//! Linux must write `v11`, not `v10`: `PosixKeyProvider` owns `v10` with the
|
||||
//! hardcoded "peanuts" password and `Encryptor::DecryptData` dispatches on the
|
||||
//! tag prefix, so a `v10` record on Linux would be decrypted with the wrong key
|
||||
//! forever.
|
||||
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||
use base64::Engine;
|
||||
use rand::RngExt;
|
||||
use ring::pbkdf2;
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::Path;
|
||||
|
||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
||||
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
|
||||
|
||||
/// Chromium's fixed PBKDF2 salt for every CBC-based os_crypt provider.
|
||||
pub const SALT: &[u8] = b"saltysalt";
|
||||
/// Chromium's fixed CBC IV: sixteen spaces.
|
||||
pub const CBC_IV: [u8; 16] = [b' '; 16];
|
||||
/// AES-256-GCM nonce length, prepended to the ciphertext by `Encryptor::Key::Encrypt`.
|
||||
const GCM_NONCE_LEN: usize = 12;
|
||||
/// The `os_crypt_key` name, at the root of the user-data dir.
|
||||
pub const KEY_FILE_NAME: &str = "os_crypt_key";
|
||||
|
||||
/// `PBKDF2-HMAC-SHA1(password = "", salt = "saltysalt", iterations = 1)`.
|
||||
///
|
||||
/// Chromium retries every failed AES-128-CBC decrypt with this key
|
||||
/// (`encryptor.cc`, crbug.com/40055416) because profiles created while the
|
||||
/// keyring was unavailable were sealed with an empty password. Import has to do
|
||||
/// the same or those records look corrupt.
|
||||
pub const EMPTY_PASSWORD_KEY: [u8; 16] = [
|
||||
0xd0, 0xd0, 0xec, 0x9c, 0x7d, 0x77, 0xd4, 0x3a, 0xc5, 0x41, 0x87, 0xfa, 0x48, 0x18, 0xd1, 0x7f,
|
||||
];
|
||||
|
||||
/// The password Chromium's `PosixKeyProvider` uses when no secret service is
|
||||
/// available (`--password-store=basic`). Records sealed with it carry `v10`.
|
||||
// Read on Linux and by the known-answer tests; unreferenced on other hosts.
|
||||
#[allow(dead_code)]
|
||||
pub const POSIX_FALLBACK_PASSWORD: &[u8] = b"peanuts";
|
||||
|
||||
/// PBKDF2 iteration counts, per the provider that owns each platform.
|
||||
// Each host only ever derives with its own count, but both are needed to read
|
||||
// a profile produced on the other one.
|
||||
#[allow(dead_code)]
|
||||
pub const MAC_ITERATIONS: u32 = 1003;
|
||||
#[allow(dead_code)]
|
||||
pub const POSIX_ITERATIONS: u32 = 1;
|
||||
|
||||
/// Derive a 16-byte AES-128 key the way every CBC os_crypt provider does.
|
||||
///
|
||||
/// `password` is the raw bytes, never trimmed: Chromium passes the exact
|
||||
/// `ReadFileToString` result to the KDF, so normalising here would silently
|
||||
/// produce a different key and every decrypt would fail.
|
||||
pub fn derive_key(password: &[u8], iterations: u32) -> [u8; 16] {
|
||||
let mut key = [0u8; 16];
|
||||
// ring rather than the `pbkdf2` crate: sha1 0.11 (digest 0.11) and
|
||||
// pbkdf2 0.12 (digest 0.10) cannot coexist. ring is self-contained.
|
||||
pbkdf2::derive(
|
||||
pbkdf2::PBKDF2_HMAC_SHA1,
|
||||
NonZeroU32::new(iterations).expect("iterations must be non-zero"),
|
||||
SALT,
|
||||
password,
|
||||
&mut key,
|
||||
);
|
||||
key
|
||||
}
|
||||
|
||||
/// One os_crypt cipher, keyed. Which variant applies is decided by the tag the
|
||||
/// record carries, never by the host platform.
|
||||
#[derive(Clone)]
|
||||
pub enum CryptoKey {
|
||||
Aes128Cbc([u8; 16]),
|
||||
// Only Windows keys with GCM, but the variant has to exist everywhere so the
|
||||
// tag dispatch in `SourceKeyring` stays platform-independent.
|
||||
#[allow(dead_code)]
|
||||
Aes256Gcm([u8; 32]),
|
||||
}
|
||||
|
||||
impl CryptoKey {
|
||||
/// Decrypt a *tagless* ciphertext (the caller has already stripped the
|
||||
/// 3-byte version prefix).
|
||||
pub fn decrypt(&self, ciphertext: &[u8]) -> Option<Vec<u8>> {
|
||||
match self {
|
||||
Self::Aes128Cbc(key) => {
|
||||
if ciphertext.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let mut buf = ciphertext.to_vec();
|
||||
Aes128CbcDec::new(key.into(), &CBC_IV.into())
|
||||
.decrypt_padded::<Pkcs7>(&mut buf)
|
||||
.ok()
|
||||
.map(<[u8]>::to_vec)
|
||||
}
|
||||
Self::Aes256Gcm(key) => {
|
||||
if ciphertext.len() < GCM_NONCE_LEN {
|
||||
return None;
|
||||
}
|
||||
let (nonce, body) = ciphertext.split_at(GCM_NONCE_LEN);
|
||||
let nonce: [u8; GCM_NONCE_LEN] = nonce.try_into().ok()?;
|
||||
Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
|
||||
.decrypt(
|
||||
&Nonce::from(nonce),
|
||||
Payload {
|
||||
msg: body,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt to a *tagless* ciphertext. The caller prepends the tag.
|
||||
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
|
||||
match self {
|
||||
Self::Aes128Cbc(key) => {
|
||||
let mut buf = vec![0u8; plaintext.len() + 16];
|
||||
buf[..plaintext.len()].copy_from_slice(plaintext);
|
||||
Aes128CbcEnc::new(key.into(), &CBC_IV.into())
|
||||
.encrypt_padded::<Pkcs7>(&mut buf, plaintext.len())
|
||||
.ok()
|
||||
.map(<[u8]>::to_vec)
|
||||
}
|
||||
Self::Aes256Gcm(key) => {
|
||||
let nonce: [u8; GCM_NONCE_LEN] = rand::rng().random();
|
||||
let sealed = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
|
||||
.encrypt(
|
||||
&Nonce::from(nonce),
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.ok()?;
|
||||
// The nonce goes at the front, matching `Encryptor::Key::Encrypt`.
|
||||
let mut out = Vec::with_capacity(GCM_NONCE_LEN + sealed.len());
|
||||
out.extend_from_slice(&nonce);
|
||||
out.extend_from_slice(&sealed);
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wayfern's key for the profile being created.
|
||||
pub struct TargetKey {
|
||||
key: CryptoKey,
|
||||
tag: &'static [u8; 3],
|
||||
}
|
||||
|
||||
impl TargetKey {
|
||||
/// The tag the host platform's key provider claims.
|
||||
pub const fn host_tag() -> &'static [u8; 3] {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
b"v11"
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
b"v10"
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the key from the raw `os_crypt_key` file contents.
|
||||
///
|
||||
/// Returns `None` when the contents cannot key the host cipher — on Windows
|
||||
/// that means anything other than exactly 32 bytes, which is what
|
||||
/// `DPAPIKeyProvider` requires before it will adopt a portable key.
|
||||
fn from_file_contents(contents: &[u8]) -> Option<Self> {
|
||||
if contents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bytes: [u8; 32] = contents.try_into().ok()?;
|
||||
Some(Self {
|
||||
key: CryptoKey::Aes256Gcm(bytes),
|
||||
tag: Self::host_tag(),
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Some(Self {
|
||||
key: CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)),
|
||||
tag: Self::host_tag(),
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Some(Self {
|
||||
key: CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)),
|
||||
tag: Self::host_tag(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh key material in the host platform's `os_crypt_key` format.
|
||||
fn generate_file_contents() -> Vec<u8> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows stores the AES-256 key itself, so it must be 32 bytes.
|
||||
let key: [u8; 32] = rand::rng().random();
|
||||
key.to_vec()
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// mac/Linux store a *password* that is fed to PBKDF2. Wayfern mints
|
||||
// `base64(16 random bytes)`; match it so the file is indistinguishable
|
||||
// from one the browser wrote itself.
|
||||
let raw: [u8; 16] = rand::rng().random();
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.encode(raw)
|
||||
.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the existing `os_crypt_key`, or mint and persist one.
|
||||
///
|
||||
/// Writing eagerly at import time — rather than letting the first launch do
|
||||
/// it — is deliberate. The mac and Linux patches have no `else` branch when
|
||||
/// the write fails, so the browser would run on an in-memory key that dies
|
||||
/// with the process and orphans everything it wrote. Failing here instead
|
||||
/// turns that silent data loss into a visible import error.
|
||||
pub fn ensure(user_data_dir: &Path) -> Result<Self, String> {
|
||||
let key_file = user_data_dir.join(KEY_FILE_NAME);
|
||||
|
||||
if let Ok(existing) = std::fs::read(&key_file) {
|
||||
if let Some(key) = Self::from_file_contents(&existing) {
|
||||
return Ok(key);
|
||||
}
|
||||
// Present but unusable (a Windows-format key on macOS, say, or a
|
||||
// truncated write). Replacing it is safe only because import always
|
||||
// re-encrypts into whatever key we end up with.
|
||||
log::warn!(
|
||||
"Replacing unusable {KEY_FILE_NAME} ({} bytes) at {}",
|
||||
existing.len(),
|
||||
key_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(user_data_dir)
|
||||
.map_err(|e| format!("Failed to create profile directory: {e}"))?;
|
||||
let contents = Self::generate_file_contents();
|
||||
std::fs::write(&key_file, &contents)
|
||||
.map_err(|e| format!("Failed to write os_crypt_key: {e}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
// Read back rather than trust the write: a key that did not land is the
|
||||
// one failure mode that silently destroys every secret we are about to
|
||||
// write with it.
|
||||
let written =
|
||||
std::fs::read(&key_file).map_err(|e| format!("Failed to verify os_crypt_key: {e}"))?;
|
||||
if written != contents {
|
||||
return Err("os_crypt_key verification failed after write".to_string());
|
||||
}
|
||||
|
||||
Self::from_file_contents(&contents).ok_or_else(|| "Failed to derive os_crypt_key".to_string())
|
||||
}
|
||||
|
||||
/// Seal a value the way Wayfern will expect to find it: `tag || ciphertext`.
|
||||
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
|
||||
let body = self.key.encrypt(plaintext)?;
|
||||
let mut out = Vec::with_capacity(3 + body.len());
|
||||
out.extend_from_slice(self.tag);
|
||||
out.extend_from_slice(&body);
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a decrypt attempt produced.
|
||||
pub enum Decrypted {
|
||||
/// Recovered plaintext.
|
||||
Value(Vec<u8>),
|
||||
/// Already plaintext — no recognised version tag.
|
||||
NotEncrypted,
|
||||
/// Correctly identified but not openable: no key for the tag (Windows
|
||||
/// App-Bound `v20`), or every candidate key failed.
|
||||
Unrecoverable,
|
||||
}
|
||||
|
||||
/// The source browser's keys, indexed by the tag the records carry.
|
||||
///
|
||||
/// Indexing by tag rather than by platform is not pedantry: a single Linux
|
||||
/// profile can legitimately hold both `v10` (peanuts) and `v11` (keyring)
|
||||
/// records, because the available secret service changes between sessions.
|
||||
#[derive(Default)]
|
||||
pub struct SourceKeyring {
|
||||
pub v10: Option<CryptoKey>,
|
||||
pub v11: Option<CryptoKey>,
|
||||
/// Seen at least one `v20` (Windows App-Bound) record, which no third party
|
||||
/// can open. Tracked so the import report can say so explicitly.
|
||||
pub saw_app_bound: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
impl SourceKeyring {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.v10.is_none() && self.v11.is_none()
|
||||
}
|
||||
|
||||
/// Open one stored value, dispatching on its version tag exactly as
|
||||
/// `Encryptor::DecryptData` does.
|
||||
pub fn decrypt(&self, stored: &[u8]) -> Decrypted {
|
||||
if stored.len() < 3 {
|
||||
return if stored.is_empty() {
|
||||
Decrypted::Value(Vec::new())
|
||||
} else {
|
||||
Decrypted::NotEncrypted
|
||||
};
|
||||
}
|
||||
|
||||
let (tag, body) = stored.split_at(3);
|
||||
let key = match tag {
|
||||
b"v10" => self.v10.as_ref(),
|
||||
b"v11" => self.v11.as_ref(),
|
||||
b"v20" => {
|
||||
// App-Bound Encryption. The key is wrapped by the SYSTEM-level Chrome
|
||||
// Elevation Service, which validates the calling binary. There is no
|
||||
// legitimate way for us to unwrap it.
|
||||
self.saw_app_bound.set(true);
|
||||
return Decrypted::Unrecoverable;
|
||||
}
|
||||
_ => return Decrypted::NotEncrypted,
|
||||
};
|
||||
|
||||
let Some(key) = key else {
|
||||
return Decrypted::Unrecoverable;
|
||||
};
|
||||
|
||||
if let Some(plaintext) = key.decrypt(body) {
|
||||
return Decrypted::Value(plaintext);
|
||||
}
|
||||
|
||||
// Chromium's own fallback for CBC records sealed with an empty password.
|
||||
if matches!(key, CryptoKey::Aes128Cbc(_)) {
|
||||
if let Some(plaintext) = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY).decrypt(body) {
|
||||
return Decrypted::Value(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
Decrypted::Unrecoverable
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn empty_password_key_matches_chromium_constant() {
|
||||
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
|
||||
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peanuts_key_matches_known_vector() {
|
||||
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
|
||||
// breaks every Linux `--password-store=basic` import.
|
||||
assert_eq!(
|
||||
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
|
||||
[
|
||||
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
|
||||
0x78
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_round_trip() {
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
|
||||
let sealed = key.encrypt(b"session-token").expect("encrypt");
|
||||
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_round_trip_empty_plaintext() {
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
|
||||
let sealed = key.encrypt(b"").expect("encrypt");
|
||||
// PKCS7 always emits a full padding block, so this must not be empty.
|
||||
assert_eq!(sealed.len(), 16);
|
||||
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcm_round_trip_with_fresh_nonce_each_time() {
|
||||
let key = CryptoKey::Aes256Gcm([7u8; 32]);
|
||||
let a = key.encrypt(b"session-token").expect("encrypt");
|
||||
let b = key.encrypt(b"session-token").expect("encrypt");
|
||||
assert_ne!(a, b, "nonce must be random per call");
|
||||
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
|
||||
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcm_rejects_tampered_ciphertext() {
|
||||
let key = CryptoKey::Aes256Gcm([7u8; 32]);
|
||||
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
|
||||
let last = sealed.len() - 1;
|
||||
sealed[last] ^= 0xff;
|
||||
assert!(key.decrypt(&sealed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_key_is_stable_across_calls() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let first = TargetKey::ensure(dir.path()).expect("mint");
|
||||
let sealed = first.encrypt(b"value").expect("encrypt");
|
||||
|
||||
let second = TargetKey::ensure(dir.path()).expect("reuse");
|
||||
// Re-running import over the same directory must not orphan what the
|
||||
// previous run wrote.
|
||||
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
|
||||
assert_eq!(
|
||||
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
|
||||
second.encrypt(b"probe").map(|v| v[..3].to_vec())
|
||||
);
|
||||
|
||||
let mut keyring = SourceKeyring::default();
|
||||
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
install_host_key(&mut keyring, &contents);
|
||||
match keyring.decrypt(&sealed) {
|
||||
Decrypted::Value(v) => assert_eq!(v, b"value"),
|
||||
_ => panic!("target key must round-trip through the source keyring"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minted_key_matches_wayfern_file_format() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
TargetKey::ensure(dir.path()).expect("mint");
|
||||
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
contents.len(),
|
||||
32,
|
||||
"DPAPIKeyProvider only adopts a 32-byte portable key"
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Wayfern writes base64(16 random bytes) = 24 ASCII chars.
|
||||
assert_eq!(contents.len(), 24);
|
||||
let text = String::from_utf8(contents).expect("ascii");
|
||||
assert!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&text)
|
||||
.map(|b| b.len())
|
||||
== Ok(16),
|
||||
"expected base64 of 16 bytes, got {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
|
||||
let keyring = SourceKeyring::default();
|
||||
assert!(matches!(
|
||||
keyring.decrypt(b"plain cookie value"),
|
||||
Decrypted::NotEncrypted
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_bound_records_are_flagged_unrecoverable() {
|
||||
let keyring = SourceKeyring::default();
|
||||
let mut sealed = b"v20".to_vec();
|
||||
sealed.extend_from_slice(&[0u8; 40]);
|
||||
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
|
||||
assert!(
|
||||
keyring.saw_app_bound.get(),
|
||||
"v20 must be reported to the user, not silently dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_key_for_known_tag_is_unrecoverable() {
|
||||
let keyring = SourceKeyring::default();
|
||||
let mut sealed = b"v10".to_vec();
|
||||
sealed.extend_from_slice(&[0u8; 32]);
|
||||
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_password_fallback_recovers_the_record() {
|
||||
// A record sealed with the empty-password key must still open when the
|
||||
// keyring holds a different primary key, mirroring Chromium.
|
||||
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
|
||||
.encrypt(b"legacy")
|
||||
.unwrap();
|
||||
let mut stored = b"v10".to_vec();
|
||||
stored.extend_from_slice(&sealed_body);
|
||||
|
||||
let keyring = SourceKeyring {
|
||||
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
|
||||
..Default::default()
|
||||
};
|
||||
match keyring.decrypt(&stored) {
|
||||
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
|
||||
_ => panic!("empty-password fallback must be attempted"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the host-format key into a keyring under the host tag, for tests
|
||||
/// that need to verify what we wrote is what Wayfern will read.
|
||||
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bytes: [u8; 32] = contents.try_into().unwrap();
|
||||
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! What an import actually carried across.
|
||||
//!
|
||||
//! Import is best-effort by nature: a locked keychain, a Windows App-Bound
|
||||
//! cookie store or a schema too old for Chromium to migrate all mean some
|
||||
//! subset does not survive, and none of them should abort the whole operation.
|
||||
//! The report is how that stays honest — every skipped store is a counted
|
||||
//! warning rather than a silent zero.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stable warning codes. The frontend maps these to
|
||||
/// `importProfile.warnings.*`, so they are part of the API contract: rename one
|
||||
/// and the user sees a missing translation.
|
||||
pub mod warning {
|
||||
/// The source browser's key could not be read, so cookies/passwords were
|
||||
/// left encrypted and are unreadable in the new profile.
|
||||
pub const SECRETS_NOT_MIGRATED: &str = "secretsNotMigrated";
|
||||
/// Windows App-Bound Encryption (Chrome 127+). Unrecoverable by design.
|
||||
pub const APP_BOUND_ENCRYPTED: &str = "appBoundEncrypted";
|
||||
/// A store's schema predates what Chromium will migrate; it would have been
|
||||
/// deleted on first launch, so it was skipped instead.
|
||||
pub const STORE_TOO_OLD: &str = "storeTooOld";
|
||||
/// A store's schema is newer than this Chromium can read.
|
||||
pub const STORE_TOO_NEW: &str = "storeTooNew";
|
||||
/// The source browser was running; databases were snapshotted but LevelDB
|
||||
/// site data may be incomplete.
|
||||
pub const SOURCE_BROWSER_RUNNING: &str = "sourceBrowserRunning";
|
||||
/// Tracked preferences lost their MACs and will reset to defaults.
|
||||
pub const SECURE_PREFERENCES_RESET: &str = "securePreferencesReset";
|
||||
/// At least one extension could not be carried.
|
||||
pub const EXTENSIONS_PARTIAL: &str = "extensionsPartial";
|
||||
/// A database was unreadable and was skipped rather than copied corrupt.
|
||||
pub const STORE_UNREADABLE: &str = "storeUnreadable";
|
||||
}
|
||||
|
||||
/// Per-profile outcome, returned alongside each item in a batch import.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProfileImportReport {
|
||||
/// Cookies whose value is readable in the new profile.
|
||||
pub cookies_migrated: usize,
|
||||
/// Cookies carried over as rows but whose value could not be recovered.
|
||||
pub cookies_unrecoverable: usize,
|
||||
pub passwords_migrated: usize,
|
||||
pub passwords_unrecoverable: usize,
|
||||
/// Saved cards / IBANs / autofill secrets re-encrypted.
|
||||
pub payment_methods_migrated: usize,
|
||||
pub payment_methods_unrecoverable: usize,
|
||||
pub extensions_migrated: usize,
|
||||
pub history_entries: usize,
|
||||
pub bookmarks: usize,
|
||||
/// Origins with Local Storage data.
|
||||
pub local_storage_origins: usize,
|
||||
pub bytes_copied: u64,
|
||||
/// Stable codes from [`warning`], deduplicated, in insertion order.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProfileImportReport {
|
||||
pub fn warn(&mut self, code: &str) {
|
||||
if !self.warnings.iter().any(|w| w == code) {
|
||||
self.warnings.push(code.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// True when nothing readable came across. Used to decide whether the UI
|
||||
/// should present the import as a success or as a warning.
|
||||
pub fn is_empty_import(&self) -> bool {
|
||||
self.cookies_migrated == 0
|
||||
&& self.passwords_migrated == 0
|
||||
&& self.history_entries == 0
|
||||
&& self.bookmarks == 0
|
||||
&& self.local_storage_origins == 0
|
||||
&& self.extensions_migrated == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn warnings_are_deduplicated_in_order() {
|
||||
let mut report = ProfileImportReport::default();
|
||||
report.warn(warning::STORE_TOO_OLD);
|
||||
report.warn(warning::SECRETS_NOT_MIGRATED);
|
||||
report.warn(warning::STORE_TOO_OLD);
|
||||
assert_eq!(
|
||||
report.warnings,
|
||||
vec![
|
||||
warning::STORE_TOO_OLD.to_string(),
|
||||
warning::SECRETS_NOT_MIGRATED.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_import_detection_ignores_unrecoverable_counts() {
|
||||
let mut report = ProfileImportReport {
|
||||
cookies_unrecoverable: 500,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
report.is_empty_import(),
|
||||
"500 unreadable cookies is still nothing carried"
|
||||
);
|
||||
report.history_entries = 1;
|
||||
assert!(!report.is_empty_import());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user