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