browser_commander/browser/
snapshot.rs1use 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#[derive(Debug, Clone)]
21pub struct SnapshotOptions {
22 pub browser: String,
24 pub profile: String,
26 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#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct SnapshotSource {
44 pub browser: String,
46 pub profile: String,
48 pub user_data_dir: PathBuf,
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct SnapshotCounts {
55 pub files: u64,
57 pub databases: u64,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SnapshotSkipped {
64 pub item: String,
66 pub reason: String,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub detail: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SnapshotReport {
76 pub source: SnapshotSource,
78 pub target: PathBuf,
80 pub copied: SnapshotCounts,
82 pub skipped: Vec<SnapshotSkipped>,
84 pub warnings: Vec<String>,
86}
87
88pub 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
187fn 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
216pub 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
315pub struct SnapshotLaunchResult {
318 pub launch: RealBrowserLaunchResult,
320 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
353pub 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 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 owned.armed = false;
386 Ok(SnapshotLaunchResult { launch, snapshot })
387}