mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-22 17:30:52 +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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user