mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-17 07:05:44 +02:00
552 lines
17 KiB
Rust
552 lines
17 KiB
Rust
//! 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());
|
|
}
|
|
}
|