Skip to main content

browser_commander/browser/
snapshot.rs

1//! Read-only copies of live Chromium profiles and native snapshot launches.
2
3use std::fs;
4use std::io::Read;
5use std::ops::Deref;
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, Context, Result};
9use serde::{Deserialize, Serialize};
10use serde_json::{json, Value};
11
12use super::browser_profiles::{browser_profile_root, current_platform, normalize_cookie_browser};
13use super::migration::sqlite_snapshot::with_database_snapshot;
14use super::profile_directory::{
15    configure_user_data_dir_for_profile, create_temporary_user_data_dir, prepare_user_data_dir,
16};
17use super::real_browser::{launch_real_browser_owned, RealBrowserLaunchResult, RealBrowserOptions};
18
19/// Source of a snapshot. The source browser may remain open throughout.
20#[derive(Debug, Clone)]
21pub struct SnapshotOptions {
22    /// Chromium-family browser (`chrome`, `edge`, `brave` or `chromium`).
23    pub browser: String,
24    /// On-disk profile name, such as `Default` or `Profile 1`.
25    pub profile: String,
26    /// Source user-data root; `None` discovers the installed browser's root.
27    pub user_data_dir: Option<PathBuf>,
28}
29
30impl Default for SnapshotOptions {
31    fn default() -> Self {
32        Self {
33            browser: "chrome".into(),
34            profile: "Default".into(),
35            user_data_dir: None,
36        }
37    }
38}
39
40/// Origin of a profile copy, using the same JSON names as JavaScript/Python.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct SnapshotSource {
44    /// Normalized browser name.
45    pub browser: String,
46    /// Selected profile name.
47    pub profile: String,
48    /// Source user-data root.
49    pub user_data_dir: PathBuf,
50}
51
52/// Copied files and consistent SQLite databases.
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct SnapshotCounts {
55    /// Successfully copied files, including databases.
56    pub files: u64,
57    /// SQLite databases copied with the online backup API.
58    pub databases: u64,
59}
60
61/// An excluded or unreadable item, relative to the user-data root.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SnapshotSkipped {
64    /// Relative file/directory name.
65    pub item: String,
66    /// Stable exclusion reason.
67    pub reason: String,
68    /// Error details for unreadable items.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub detail: Option<String>,
71}
72
73/// Snapshot result. The caller owns `target` unless passed to `launch_snapshot`.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SnapshotReport {
76    /// Original browser/profile.
77    pub source: SnapshotSource,
78    /// Copied user-data root.
79    pub target: PathBuf,
80    /// Successful copies.
81    pub copied: SnapshotCounts,
82    /// Excluded/unreadable files.
83    pub skipped: Vec<SnapshotSkipped>,
84    /// Nonfatal compatibility warnings.
85    pub warnings: Vec<String>,
86}
87
88/// Exclusions shared with the JavaScript and Python snapshotters.
89pub fn snapshot_skip_reason(relative: &str) -> Option<&'static str> {
90    let name = relative.rsplit('/').next().unwrap_or(relative);
91    if name.starts_with("Singleton") || matches!(name, "lockfile" | "LOCK") {
92        Some("lock")
93    } else if matches!(
94        name,
95        "Cache"
96            | "Code Cache"
97            | "GPUCache"
98            | "DawnCache"
99            | "DawnGraphiteCache"
100            | "DawnWebGPUCache"
101            | "GrShaderCache"
102            | "ShaderCache"
103    ) || relative.ends_with("Service Worker/CacheStorage")
104    {
105        Some("cache")
106    } else if name == "Crashpad" {
107        Some("crash-reports")
108    } else if matches!(
109        name,
110        "Sessions" | "Current Session" | "Current Tabs" | "Last Session" | "Last Tabs"
111    ) {
112        Some("session")
113    } else if ["-journal", "-wal", "-shm"]
114        .iter()
115        .any(|suffix| name.ends_with(suffix))
116    {
117        Some("sqlite-sidecar")
118    } else {
119        None
120    }
121}
122
123fn skip(report: &mut SnapshotReport, relative: &Path, reason: &str, detail: Option<String>) {
124    report.skipped.push(SnapshotSkipped {
125        item: relative.to_string_lossy().replace('\\', "/"),
126        reason: reason.into(),
127        detail,
128    });
129}
130
131fn copy_file(source: &Path, target: &Path, report: &mut SnapshotReport) -> Result<()> {
132    fs::create_dir_all(
133        target
134            .parent()
135            .ok_or_else(|| anyhow!("Invalid snapshot file"))?,
136    )?;
137    let mut header = [0; 16];
138    let count = fs::File::open(source)?.read(&mut header)?;
139    if count == 16 && &header == b"SQLite format 3\0" {
140        with_database_snapshot(source, |snapshot| {
141            let database = rusqlite::Connection::open_with_flags(
142                snapshot,
143                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
144            )?;
145            database.backup(rusqlite::MAIN_DB, target, None)?;
146            rusqlite::Connection::open(target)?.execute_batch("PRAGMA journal_mode=DELETE")?;
147            Ok(())
148        })?;
149        report.copied.databases += 1;
150    } else {
151        fs::copy(source, target)?;
152    }
153    report.copied.files += 1;
154    Ok(())
155}
156
157fn copy_item(relative: &Path, report: &mut SnapshotReport) -> Result<()> {
158    let source = report.source.user_data_dir.join(relative);
159    let target = report.target.join(relative);
160    let metadata = fs::symlink_metadata(&source)?;
161    let item = relative.to_string_lossy().replace('\\', "/");
162    let reason = snapshot_skip_reason(&item)
163        .or_else(|| metadata.file_type().is_symlink().then_some("symlink"));
164    if let Some(reason) = reason {
165        skip(report, relative, reason, None);
166    } else if metadata.is_dir() {
167        fs::create_dir_all(&target)?;
168        let mut entries = fs::read_dir(source)?.collect::<std::io::Result<Vec<_>>>()?;
169        entries.sort_by_key(|entry| entry.file_name());
170        for entry in entries {
171            let child = relative.join(entry.file_name());
172            if let Err(error) = copy_item(&child, report) {
173                skip(report, &child, "unreadable", Some(error.to_string()));
174            }
175        }
176    } else if metadata.is_file() {
177        if let Err(error) = copy_file(&source, &target, report) {
178            let _ = fs::remove_file(&target);
179            return Err(error);
180        }
181    } else {
182        skip(report, relative, "special-file", None);
183    }
184    Ok(())
185}
186
187// Resolve existing ancestors before creating a destination, so symlinks cannot
188// point a seemingly external destination back into the source profile.
189fn resolved_destination(path: &Path) -> Result<PathBuf> {
190    let absolute = if path.is_absolute() {
191        path.to_path_buf()
192    } else {
193        std::env::current_dir()?.join(path)
194    };
195    let mut normalized = PathBuf::new();
196    for component in absolute.components() {
197        match component {
198            std::path::Component::ParentDir => {
199                normalized.pop();
200            }
201            std::path::Component::CurDir => {}
202            other => normalized.push(other.as_os_str()),
203        }
204    }
205    let mut ancestor = normalized.as_path();
206    while !ancestor.exists() {
207        ancestor = ancestor
208            .parent()
209            .ok_or_else(|| anyhow!("Invalid snapshot destination"))?;
210    }
211    Ok(ancestor
212        .canonicalize()?
213        .join(normalized.strip_prefix(ancestor)?))
214}
215
216/// Copy one live Chromium profile without modifying the original. SQLite WAL
217/// commits are folded into standalone databases; caches, locks, sessions,
218/// sidecars, symlinks and special files are skipped and reported. An explicit
219/// destination must be empty and outside the source.
220pub fn snapshot_user_data_dir(
221    options: &SnapshotOptions,
222    to: Option<&Path>,
223) -> Result<SnapshotReport> {
224    let browser = normalize_cookie_browser(&options.browser)?;
225    if browser == "firefox" {
226        return Err(anyhow!(
227            "snapshot attach requires a Chromium-family browser"
228        ));
229    }
230    if options.profile.is_empty()
231        || matches!(options.profile.as_str(), "." | "..")
232        || options.profile.contains(['/', '\\', '\0'])
233    {
234        return Err(anyhow!(
235            "profile must be a directory name such as Default or Profile 1"
236        ));
237    }
238    let source = match &options.user_data_dir {
239        Some(source) => source.clone(),
240        None => browser_profile_root(
241            browser,
242            current_platform(),
243            &dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?,
244        )?,
245    };
246    let metadata = fs::symlink_metadata(source.join(&options.profile))
247        .context("Could not read source profile")?;
248    if metadata.file_type().is_symlink() || !metadata.is_dir() {
249        return Err(anyhow!("source profile must be a directory, not a symlink"));
250    }
251    let target = if let Some(to) = to {
252        let target = resolved_destination(to)?;
253        if target.starts_with(source.canonicalize()?) {
254            return Err(anyhow!("snapshot target must not be inside the source"));
255        }
256        fs::create_dir_all(&target)?;
257        if fs::read_dir(&target)?.next().is_some() {
258            return Err(anyhow!("snapshot target must be empty"));
259        }
260        target
261    } else {
262        create_temporary_user_data_dir(None)?
263    };
264    let mut report = SnapshotReport {
265        source: SnapshotSource {
266            browser: browser.into(),
267            profile: options.profile.clone(),
268            user_data_dir: source,
269        },
270        target,
271        copied: SnapshotCounts::default(),
272        skipped: vec![],
273        warnings: vec![],
274    };
275    let result = (|| -> Result<()> {
276        if report.source.user_data_dir.join("Local State").exists() {
277            copy_item(Path::new("Local State"), &mut report)?;
278        }
279        copy_item(Path::new(&options.profile), &mut report)?;
280        prepare_user_data_dir(&report.target)?;
281        let preferences = report.target.join(&options.profile).join("Preferences");
282        let mut prefs: Value = if preferences.exists() {
283            serde_json::from_slice(&fs::read(&preferences)?)?
284        } else {
285            json!({})
286        };
287        let object = prefs
288            .as_object_mut()
289            .ok_or_else(|| anyhow!("Preferences must contain a JSON object"))?;
290        let profile = object.entry("profile").or_insert_with(|| json!({}));
291        let profile = profile
292            .as_object_mut()
293            .ok_or_else(|| anyhow!("Preferences.profile must contain a JSON object"))?;
294        profile.insert("exit_type".into(), json!("Normal"));
295        profile.insert("exited_cleanly".into(), json!(true));
296        fs::write(&preferences, serde_json::to_vec(&prefs)?)?;
297        configure_user_data_dir_for_profile(
298            &report.target,
299            &options.profile,
300            None,
301            &json!({}),
302            &json!({}),
303        )?;
304        Ok(())
305    })();
306    if let Err(error) = result {
307        if to.is_none() {
308            let _ = fs::remove_dir_all(&report.target);
309        }
310        return Err(error);
311    }
312    Ok(report)
313}
314
315/// Connected native browser plus the snapshot report. Browser handles and
316/// `close()` are available through `Deref` to the ordinary launch result.
317pub struct SnapshotLaunchResult {
318    /// The browser launch owns and cleans up its temporary profile.
319    pub launch: RealBrowserLaunchResult,
320    /// Copy report; the original profile is never written to.
321    pub snapshot: SnapshotReport,
322}
323
324impl Deref for SnapshotLaunchResult {
325    type Target = RealBrowserLaunchResult;
326    fn deref(&self) -> &Self::Target {
327        &self.launch
328    }
329}
330
331pub(crate) struct OwnedCopy {
332    pub(crate) report: SnapshotReport,
333    pub(crate) armed: bool,
334}
335
336pub(crate) async fn copy_owned_snapshot(source: SnapshotOptions) -> Result<OwnedCopy> {
337    tokio::task::spawn_blocking(move || {
338        snapshot_user_data_dir(&source, None).map(|report| OwnedCopy {
339            report,
340            armed: true,
341        })
342    })
343    .await?
344}
345impl Drop for OwnedCopy {
346    fn drop(&mut self) {
347        if self.armed {
348            let _ = fs::remove_dir_all(&self.report.target);
349        }
350    }
351}
352
353/// Launch an owned snapshot with any CDP engine. The ordinary launch closer
354/// and exit listener delete the copy on shutdown or connection failure.
355pub async fn launch_snapshot(
356    source: SnapshotOptions,
357    mut options: RealBrowserOptions,
358) -> Result<SnapshotLaunchResult> {
359    if options.user_data_dir.is_some() || options.migrate_from.is_some() {
360        return Err(anyhow!(
361            "snapshot is mutually exclusive with user_data_dir and migrate_from"
362        ));
363    }
364    if options
365        .args
366        .iter()
367        .chain(&options.extra_args)
368        .any(|arg| arg.split('=').next() == Some("--profile-directory"))
369    {
370        return Err(anyhow!(
371            "snapshot manages --profile-directory; use SnapshotOptions.profile"
372        ));
373    }
374    let profile = source.profile.clone();
375    // If this future is cancelled, dropping the task result removes the copy.
376    let mut owned = copy_owned_snapshot(source).await?;
377    options.user_data_dir = Some(owned.report.target.clone());
378    options.profile_directory = profile.clone();
379    options
380        .args
381        .insert(0, format!("--profile-directory={profile}"));
382    let launch = launch_real_browser_owned(options, true).await?;
383    let snapshot = owned.report.clone();
384    // Transfer ownership to the launch's closer/exit listener.
385    owned.armed = false;
386    Ok(SnapshotLaunchResult { launch, snapshot })
387}