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::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#[derive(Debug, Clone)]
22pub struct SnapshotOptions {
23 pub browser: String,
25 pub profile: String,
27 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#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct SnapshotSource {
45 pub browser: String,
47 pub profile: String,
49 pub user_data_dir: PathBuf,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct SnapshotCounts {
56 pub files: u64,
58 pub databases: u64,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct SnapshotSkipped {
65 pub item: String,
67 pub reason: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub detail: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SnapshotReport {
77 pub source: SnapshotSource,
79 pub target: PathBuf,
81 pub copied: SnapshotCounts,
83 pub skipped: Vec<SnapshotSkipped>,
85 pub warnings: Vec<String>,
87}
88
89pub 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
188fn 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
217pub 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
316pub struct SnapshotLaunchResult {
319 pub launch: RealBrowserLaunchResult,
321 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
354pub 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 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 owned.armed = false;
387 Ok(SnapshotLaunchResult { launch, snapshot })
388}