use std::fs;
use std::io::Read;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::browser_profiles::{browser_profile_root, current_platform, normalize_cookie_browser};
use super::migration::sqlite_snapshot::with_database_snapshot;
use super::profile_directory::{
configure_user_data_dir_for_profile, create_temporary_user_data_dir, prepare_user_data_dir,
};
use super::real_browser::{launch_real_browser_owned, RealBrowserLaunchResult, RealBrowserOptions};
#[derive(Debug, Clone)]
pub struct SnapshotOptions {
pub browser: String,
pub profile: String,
pub user_data_dir: Option<PathBuf>,
}
impl Default for SnapshotOptions {
fn default() -> Self {
Self {
browser: "chrome".into(),
profile: "Default".into(),
user_data_dir: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SnapshotSource {
pub browser: String,
pub profile: String,
pub user_data_dir: PathBuf,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SnapshotCounts {
pub files: u64,
pub databases: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotSkipped {
pub item: String,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotReport {
pub source: SnapshotSource,
pub target: PathBuf,
pub copied: SnapshotCounts,
pub skipped: Vec<SnapshotSkipped>,
pub warnings: Vec<String>,
}
pub fn snapshot_skip_reason(relative: &str) -> Option<&'static str> {
let name = relative.rsplit('/').next().unwrap_or(relative);
if name.starts_with("Singleton") || matches!(name, "lockfile" | "LOCK") {
Some("lock")
} else if matches!(
name,
"Cache"
| "Code Cache"
| "GPUCache"
| "DawnCache"
| "DawnGraphiteCache"
| "DawnWebGPUCache"
| "GrShaderCache"
| "ShaderCache"
) || relative.ends_with("Service Worker/CacheStorage")
{
Some("cache")
} else if name == "Crashpad" {
Some("crash-reports")
} else if matches!(
name,
"Sessions" | "Current Session" | "Current Tabs" | "Last Session" | "Last Tabs"
) {
Some("session")
} else if ["-journal", "-wal", "-shm"]
.iter()
.any(|suffix| name.ends_with(suffix))
{
Some("sqlite-sidecar")
} else {
None
}
}
fn skip(report: &mut SnapshotReport, relative: &Path, reason: &str, detail: Option<String>) {
report.skipped.push(SnapshotSkipped {
item: relative.to_string_lossy().replace('\\', "/"),
reason: reason.into(),
detail,
});
}
fn copy_file(source: &Path, target: &Path, report: &mut SnapshotReport) -> Result<()> {
fs::create_dir_all(
target
.parent()
.ok_or_else(|| anyhow!("Invalid snapshot file"))?,
)?;
let mut header = [0; 16];
let count = fs::File::open(source)?.read(&mut header)?;
if count == 16 && &header == b"SQLite format 3\0" {
with_database_snapshot(source, |snapshot| {
let database = rusqlite::Connection::open_with_flags(
snapshot,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)?;
database.backup(rusqlite::MAIN_DB, target, None)?;
rusqlite::Connection::open(target)?.execute_batch("PRAGMA journal_mode=DELETE")?;
Ok(())
})?;
report.copied.databases += 1;
} else {
fs::copy(source, target)?;
}
report.copied.files += 1;
Ok(())
}
fn copy_item(relative: &Path, report: &mut SnapshotReport) -> Result<()> {
let source = report.source.user_data_dir.join(relative);
let target = report.target.join(relative);
let metadata = fs::symlink_metadata(&source)?;
let item = relative.to_string_lossy().replace('\\', "/");
let reason = snapshot_skip_reason(&item)
.or_else(|| metadata.file_type().is_symlink().then_some("symlink"));
if let Some(reason) = reason {
skip(report, relative, reason, None);
} else if metadata.is_dir() {
fs::create_dir_all(&target)?;
let mut entries = fs::read_dir(source)?.collect::<std::io::Result<Vec<_>>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let child = relative.join(entry.file_name());
if let Err(error) = copy_item(&child, report) {
skip(report, &child, "unreadable", Some(error.to_string()));
}
}
} else if metadata.is_file() {
if let Err(error) = copy_file(&source, &target, report) {
let _ = fs::remove_file(&target);
return Err(error);
}
} else {
skip(report, relative, "special-file", None);
}
Ok(())
}
fn resolved_destination(path: &Path) -> Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::CurDir => {}
other => normalized.push(other.as_os_str()),
}
}
let mut ancestor = normalized.as_path();
while !ancestor.exists() {
ancestor = ancestor
.parent()
.ok_or_else(|| anyhow!("Invalid snapshot destination"))?;
}
Ok(ancestor
.canonicalize()?
.join(normalized.strip_prefix(ancestor)?))
}
pub fn snapshot_user_data_dir(
options: &SnapshotOptions,
to: Option<&Path>,
) -> Result<SnapshotReport> {
let browser = normalize_cookie_browser(&options.browser)?;
if browser == "firefox" {
return Err(anyhow!(
"snapshot attach requires a Chromium-family browser"
));
}
if options.profile.is_empty()
|| matches!(options.profile.as_str(), "." | "..")
|| options.profile.contains(['/', '\\', '\0'])
{
return Err(anyhow!(
"profile must be a directory name such as Default or Profile 1"
));
}
let source = match &options.user_data_dir {
Some(source) => source.clone(),
None => browser_profile_root(
browser,
current_platform(),
&dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?,
)?,
};
let metadata = fs::symlink_metadata(source.join(&options.profile))
.context("Could not read source profile")?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(anyhow!("source profile must be a directory, not a symlink"));
}
let target = if let Some(to) = to {
let target = resolved_destination(to)?;
if target.starts_with(source.canonicalize()?) {
return Err(anyhow!("snapshot target must not be inside the source"));
}
fs::create_dir_all(&target)?;
if fs::read_dir(&target)?.next().is_some() {
return Err(anyhow!("snapshot target must be empty"));
}
target
} else {
create_temporary_user_data_dir(None)?
};
let mut report = SnapshotReport {
source: SnapshotSource {
browser: browser.into(),
profile: options.profile.clone(),
user_data_dir: source,
},
target,
copied: SnapshotCounts::default(),
skipped: vec![],
warnings: vec![],
};
let result = (|| -> Result<()> {
if report.source.user_data_dir.join("Local State").exists() {
copy_item(Path::new("Local State"), &mut report)?;
}
copy_item(Path::new(&options.profile), &mut report)?;
prepare_user_data_dir(&report.target)?;
let preferences = report.target.join(&options.profile).join("Preferences");
let mut prefs: Value = if preferences.exists() {
serde_json::from_slice(&fs::read(&preferences)?)?
} else {
json!({})
};
let object = prefs
.as_object_mut()
.ok_or_else(|| anyhow!("Preferences must contain a JSON object"))?;
let profile = object.entry("profile").or_insert_with(|| json!({}));
let profile = profile
.as_object_mut()
.ok_or_else(|| anyhow!("Preferences.profile must contain a JSON object"))?;
profile.insert("exit_type".into(), json!("Normal"));
profile.insert("exited_cleanly".into(), json!(true));
fs::write(&preferences, serde_json::to_vec(&prefs)?)?;
configure_user_data_dir_for_profile(
&report.target,
&options.profile,
None,
&json!({}),
&json!({}),
)?;
Ok(())
})();
if let Err(error) = result {
if to.is_none() {
let _ = fs::remove_dir_all(&report.target);
}
return Err(error);
}
Ok(report)
}
pub struct SnapshotLaunchResult {
pub launch: RealBrowserLaunchResult,
pub snapshot: SnapshotReport,
}
impl Deref for SnapshotLaunchResult {
type Target = RealBrowserLaunchResult;
fn deref(&self) -> &Self::Target {
&self.launch
}
}
pub(crate) struct OwnedCopy {
pub(crate) report: SnapshotReport,
pub(crate) armed: bool,
}
pub(crate) async fn copy_owned_snapshot(source: SnapshotOptions) -> Result<OwnedCopy> {
tokio::task::spawn_blocking(move || {
snapshot_user_data_dir(&source, None).map(|report| OwnedCopy {
report,
armed: true,
})
})
.await?
}
impl Drop for OwnedCopy {
fn drop(&mut self) {
if self.armed {
let _ = fs::remove_dir_all(&self.report.target);
}
}
}
pub async fn launch_snapshot(
source: SnapshotOptions,
mut options: RealBrowserOptions,
) -> Result<SnapshotLaunchResult> {
if options.user_data_dir.is_some() || options.migrate_from.is_some() {
return Err(anyhow!(
"snapshot is mutually exclusive with user_data_dir and migrate_from"
));
}
if options
.args
.iter()
.chain(&options.extra_args)
.any(|arg| arg.split('=').next() == Some("--profile-directory"))
{
return Err(anyhow!(
"snapshot manages --profile-directory; use SnapshotOptions.profile"
));
}
let profile = source.profile.clone();
let mut owned = copy_owned_snapshot(source).await?;
options.user_data_dir = Some(owned.report.target.clone());
options.profile_directory = profile.clone();
options
.args
.insert(0, format!("--profile-directory={profile}"));
let launch = launch_real_browser_owned(options, true).await?;
let snapshot = owned.report.clone();
owned.armed = false;
Ok(SnapshotLaunchResult { launch, snapshot })
}