browser_automation_cli/
residual.rs1use std::path::{Path, PathBuf};
30use std::time::{Duration, SystemTime};
31
32use serde::Serialize;
33
34pub const CLI_CHROME_MARKER_PREFIX: &str = "browser-automation-cli-chrome-";
36
37pub const CHROMIUM_TMP_PREFIX: &str = "org.chromium.Chromium.";
39
40pub const CHROMIUM_TMP_DOT_PREFIX: &str = ".org.chromium.Chromium.";
42
43pub const SINGLETON_MAX_BYTES: u64 = 4096;
45
46pub const SINGLETON_MAX_ENTRIES: usize = 8;
48
49pub const INVOCATION_SIDE_CHANNEL_WINDOW_SECS: u64 = 5;
51
52pub const MTIME_SKEW_SECS: u64 = 2;
54
55pub const STALE_MIN_AGE_SECS: u64 = 60;
59
60#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
62pub struct ResidualDiskReport {
63 pub cli_marker_dirs: usize,
65 pub chromium_tmp_singleton_orphans: usize,
67 pub scavenge_safe_candidates: usize,
69 pub live_cli_marker_processes: usize,
71}
72
73pub fn discover_owned_chromium_tmp_side_channels(
76 profile: Option<&Path>,
77 chrome_pid: Option<u32>,
78 not_before: SystemTime,
79) -> Vec<PathBuf> {
80 let mut out = Vec::new();
81 let tmp = std::env::temp_dir();
82 let Ok(entries) = std::fs::read_dir(&tmp) else {
83 return out;
84 };
85 let pid_s = chrome_pid.map(|p| p.to_string());
86 let profile_s = profile.map(|p| p.display().to_string());
87
88 for ent in entries.flatten() {
89 let name = ent.file_name();
90 let name = name.to_string_lossy();
91 let is_chromium_tmp = is_chromium_tmp_name(&name);
92 let is_cli_marker = name.starts_with(CLI_CHROME_MARKER_PREFIX);
93 if !is_chromium_tmp && !is_cli_marker {
94 continue;
95 }
96 let path = ent.path();
97 if !owned_by_current_user(&path) {
98 continue;
99 }
100 if !created_or_modified_after(&path, not_before) {
101 continue;
102 }
103 if is_cli_marker {
104 out.push(path);
106 continue;
107 }
108 if let Some(ref pid) = pid_s {
110 if path_references(&path, pid) {
111 out.push(path);
112 continue;
113 }
114 }
115 if let Some(ref prof) = profile_s {
116 if path_references(&path, prof) {
117 out.push(path);
118 continue;
119 }
120 }
121 if age_since(not_before) < Duration::from_secs(INVOCATION_SIDE_CHANNEL_WINDOW_SECS) {
124 if let Ok(meta) = path.metadata() {
126 if meta.len() <= SINGLETON_MAX_BYTES {
127 out.push(path);
128 }
129 }
130 }
131 }
132 out
133}
134
135pub fn list_cli_chrome_marker_dirs() -> Vec<PathBuf> {
137 let mut out = Vec::new();
138 let tmp = std::env::temp_dir();
139 let Ok(entries) = std::fs::read_dir(tmp) else {
140 return out;
141 };
142 for ent in entries.flatten() {
143 let name = ent.file_name();
144 let name = name.to_string_lossy();
145 if name.starts_with(CLI_CHROME_MARKER_PREFIX) {
146 out.push(ent.path());
147 }
148 }
149 out
150}
151
152pub fn scavenge_owned_chromium_tmp_orphans(
158 profile: Option<&Path>,
159 chrome_pid: Option<u32>,
160 not_before: SystemTime,
161) -> Vec<PathBuf> {
162 let candidates = discover_owned_chromium_tmp_side_channels(profile, chrome_pid, not_before);
163 wipe_safe_candidates(candidates)
164}
165
166pub fn scavenge_stale_singleton_orphans() -> Vec<PathBuf> {
173 scavenge_stale_singleton_orphans_with_min_age(Duration::from_secs(STALE_MIN_AGE_SECS))
174}
175
176pub fn scavenge_stale_singleton_orphans_with_min_age(min_age: Duration) -> Vec<PathBuf> {
181 let candidates = discover_stale_singleton_candidates(min_age);
182 let wiped = wipe_safe_candidates(candidates);
183 if !wiped.is_empty() {
184 tracing::debug!(
185 count = wiped.len(),
186 min_age_secs = min_age.as_secs(),
187 "scavenge_stale_singleton_orphans wiped residual paths"
188 );
189 }
190 wiped
191}
192
193pub fn residual_disk_report() -> ResidualDiskReport {
195 let markers = list_cli_chrome_marker_dirs();
196 let stale = discover_stale_singleton_candidates(Duration::from_secs(STALE_MIN_AGE_SECS));
197 let proc_index = index_proc_cmdlines();
198 let live_cli = proc_index
202 .iter()
203 .filter(|cmd| is_live_cli_chrome_cmdline(cmd))
204 .count();
205 let orphans = count_chromium_singleton_shaped();
207 ResidualDiskReport {
208 cli_marker_dirs: markers.len(),
209 chromium_tmp_singleton_orphans: orphans,
210 scavenge_safe_candidates: stale.len(),
211 live_cli_marker_processes: live_cli,
212 }
213}
214
215fn discover_stale_singleton_candidates(min_age: Duration) -> Vec<PathBuf> {
216 let mut out = Vec::new();
217 let tmp = std::env::temp_dir();
218 let Ok(entries) = std::fs::read_dir(&tmp) else {
219 return out;
220 };
221 let now = SystemTime::now();
222 for ent in entries.flatten() {
223 let name = ent.file_name();
224 let name = name.to_string_lossy();
225 let is_cli_marker = name.starts_with(CLI_CHROME_MARKER_PREFIX);
226 let is_chromium_tmp = is_chromium_tmp_name(&name);
227 if is_google_chrome_tmp_name(&name) {
229 continue;
230 }
231 if !is_cli_marker && !is_chromium_tmp {
232 continue;
233 }
234 let path = ent.path();
235 if !owned_by_current_user(&path) {
236 continue;
237 }
238 if !is_cli_marker && !is_singleton_only_or_small(&path) {
239 continue;
240 }
241 if is_cli_marker {
242 if !is_singleton_only_or_small(&path) && dir_entry_count(&path) > 0 {
244 }
246 }
247 if !path_older_than(&path, now, min_age) {
248 continue;
249 }
250 out.push(path);
251 }
252 out
253}
254
255fn count_chromium_singleton_shaped() -> usize {
256 let tmp = std::env::temp_dir();
257 let Ok(entries) = std::fs::read_dir(tmp) else {
258 return 0;
259 };
260 let mut n = 0usize;
261 for ent in entries.flatten() {
262 let name = ent.file_name();
263 let name = name.to_string_lossy();
264 if !is_chromium_tmp_name(&name) {
265 continue;
266 }
267 let path = ent.path();
268 if owned_by_current_user(&path) && is_singleton_only_or_small(&path) {
269 n += 1;
270 }
271 }
272 n
273}
274
275fn wipe_safe_candidates(candidates: Vec<PathBuf>) -> Vec<PathBuf> {
276 let proc_index = index_proc_cmdlines();
279 let wipeable: Vec<PathBuf> = crate::concurrency::map_cpu(&candidates, |path| {
280 if path_has_live_process(path, &proc_index) {
281 return None;
282 }
283 let name = path
284 .file_name()
285 .map(|s| s.to_string_lossy().into_owned())
286 .unwrap_or_default();
287 let is_cli_marker = name.starts_with(CLI_CHROME_MARKER_PREFIX);
288 let is_singletonish = is_singleton_only_or_small(path);
289 if is_cli_marker || is_singletonish {
290 Some(path.clone())
291 } else {
292 None
293 }
294 })
295 .into_iter()
296 .flatten()
297 .collect();
298 crate::concurrency::map_cpu(&wipeable, |path| {
300 wipe_path(path);
301 path.clone()
302 })
303}
304
305fn is_chromium_tmp_name(name: &str) -> bool {
306 name.starts_with(CHROMIUM_TMP_PREFIX) || name.starts_with(CHROMIUM_TMP_DOT_PREFIX)
307}
308
309fn is_google_chrome_tmp_name(name: &str) -> bool {
310 name.starts_with(".com.google.Chrome.") || name.starts_with("com.google.Chrome.")
311}
312
313fn is_singleton_only_or_small(path: &Path) -> bool {
314 if !path.is_dir() {
315 if let Ok(meta) = path.metadata() {
316 return meta.len() <= SINGLETON_MAX_BYTES;
317 }
318 return false;
319 }
320 let Ok(entries) = std::fs::read_dir(path) else {
321 return false;
322 };
323 let mut count = 0usize;
324 let mut only_singleton = true;
325 for ent in entries.flatten() {
326 count += 1;
327 if count > SINGLETON_MAX_ENTRIES {
328 return false;
329 }
330 let n = ent.file_name();
331 let n = n.to_string_lossy();
332 if !(n.starts_with("Singleton")
333 || n == "DevToolsActivePort"
334 || n.starts_with(".org.chromium")
335 || n.ends_with(".lock"))
336 {
337 only_singleton = false;
338 }
339 }
340 only_singleton || count == 0
341}
342
343fn dir_entry_count(path: &Path) -> usize {
344 std::fs::read_dir(path)
345 .map(|it| it.flatten().count())
346 .unwrap_or(0)
347}
348
349fn path_older_than(path: &Path, now: SystemTime, min_age: Duration) -> bool {
350 let Ok(meta) = path.metadata() else {
351 return false;
352 };
353 let modified = meta.modified().ok();
354 let created = meta.created().ok();
355 let age = |t: SystemTime| now.duration_since(t).unwrap_or_default();
356 if let Some(m) = modified {
357 if age(m) >= min_age {
358 return true;
359 }
360 }
361 if let Some(c) = created {
362 if age(c) >= min_age {
363 return true;
364 }
365 }
366 false
368}
369
370#[cfg(unix)]
375pub fn index_proc_cmdlines() -> Vec<String> {
376 let mut out = Vec::new();
377 let Ok(proc) = std::fs::read_dir("/proc") else {
378 return out;
379 };
380 for ent in proc.flatten() {
381 let name = ent.file_name();
382 let name = name.to_string_lossy();
383 if !name.chars().all(|c| c.is_ascii_digit()) {
384 continue;
385 }
386 let cmdline = ent.path().join("cmdline");
387 if let Ok(bytes) = std::fs::read(cmdline) {
388 out.push(String::from_utf8_lossy(&bytes).into_owned());
390 }
391 }
392 out
393}
394
395#[cfg(not(unix))]
396pub fn index_proc_cmdlines() -> Vec<String> {
397 Vec::new()
398}
399
400fn path_has_live_process(path: &Path, proc_index: &[String]) -> bool {
402 let needle = path.display().to_string();
403 if needle.is_empty() {
404 return false;
405 }
406 proc_index.iter().any(|cmd| cmd.contains(&needle))
407}
408
409fn is_live_cli_chrome_cmdline(cmd: &str) -> bool {
412 if !cmd.contains(CLI_CHROME_MARKER_PREFIX) {
413 return false;
414 }
415 let looks_like_browser = cmd.contains("chromium")
417 || cmd.contains("google-chrome")
418 || cmd.contains("/chrome")
419 || cmd.contains("\0--type=")
420 || cmd.contains(" --type=")
421 || cmd.contains("--user-data-dir=");
422 if !looks_like_browser {
423 return false;
424 }
425 let looks_like_text_tool = cmd.contains("rg ")
427 || cmd.contains("grep ")
428 || cmd.contains("atomwrite")
429 || cmd.contains("sed ")
430 || cmd.contains("nvim")
431 || cmd.contains("code ")
432 || cmd.contains("cursor ");
433 !looks_like_text_tool
434}
435
436fn wipe_path(path: &Path) {
437 if !path.exists() {
438 return;
439 }
440 if path.is_dir() {
441 let _ = std::fs::remove_dir_all(path);
442 } else {
443 let _ = std::fs::remove_file(path);
444 }
445}
446
447fn owned_by_current_user(path: &Path) -> bool {
448 #[cfg(unix)]
449 {
450 use std::os::unix::fs::MetadataExt;
451 let Ok(meta) = path.metadata() else {
452 return false;
453 };
454 meta.uid() == unsafe { libc::getuid() }
460 }
461 #[cfg(not(unix))]
462 {
463 let _ = path;
464 true
465 }
466}
467
468fn created_or_modified_after(path: &Path, not_before: SystemTime) -> bool {
469 let Ok(meta) = path.metadata() else {
470 return false;
471 };
472 let modified = meta.modified().ok();
473 let created = meta.created().ok();
474 let skew = Duration::from_secs(MTIME_SKEW_SECS);
475 let threshold = not_before.checked_sub(skew).unwrap_or(not_before);
476 if let Some(m) = modified {
477 if m >= threshold {
478 return true;
479 }
480 }
481 if let Some(c) = created {
482 if c >= threshold {
483 return true;
484 }
485 }
486 false
487}
488
489fn path_references(path: &Path, needle: &str) -> bool {
490 if path.display().to_string().contains(needle) {
491 return true;
492 }
493 if let Ok(target) = std::fs::read_link(path) {
495 if target.to_string_lossy().contains(needle) {
496 return true;
497 }
498 }
499 if let Ok(meta) = path.metadata() {
501 if meta.is_file() && meta.len() <= SINGLETON_MAX_BYTES {
502 if let Ok(bytes) = std::fs::read(path) {
503 if String::from_utf8_lossy(&bytes).contains(needle) {
504 return true;
505 }
506 }
507 }
508 }
509 if path.is_dir() {
511 if let Ok(entries) = std::fs::read_dir(path) {
512 for ent in entries.flatten() {
513 let p = ent.path();
514 if path_references(&p, needle) {
515 return true;
516 }
517 }
518 }
519 }
520 false
521}
522
523fn age_since(t: SystemTime) -> Duration {
524 SystemTime::now().duration_since(t).unwrap_or_default()
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use std::fs;
531 use std::time::{Duration, SystemTime};
532
533 #[test]
534 fn marker_scan_finds_and_filters() {
535 let tmp = std::env::temp_dir();
536 let dir = tmp.join(format!(
537 "browser-automation-cli-chrome-test-{}",
538 uuid::Uuid::new_v4()
539 ));
540 let _ = fs::create_dir_all(&dir);
541 let found = list_cli_chrome_marker_dirs();
542 assert!(
543 found.iter().any(|p| p == &dir),
544 "expected marker dir in list"
545 );
546 let _ = fs::remove_dir_all(&dir);
547 }
548
549 #[test]
550 fn discover_respects_not_before() {
551 let future = SystemTime::now() + Duration::from_secs(3600);
552 let found = discover_owned_chromium_tmp_side_channels(None, None, future);
553 assert!(
554 found.is_empty(),
555 "future not_before must yield no side channels"
556 );
557 }
558
559 #[test]
560 fn stale_gc_removes_singleton_only_fixture() {
561 let tmp = std::env::temp_dir();
562 let dir = tmp.join(format!(
563 "org.chromium.Chromium.{}",
564 &uuid::Uuid::new_v4().to_string()[..8]
565 ));
566 let _ = fs::create_dir_all(&dir);
567 let cookie = dir.join("SingletonCookie");
569 #[cfg(unix)]
570 let _ = std::os::unix::fs::symlink("12345", &cookie);
571 #[cfg(not(unix))]
572 let _ = fs::write(&cookie, b"12345");
573 let sock = dir.join("SingletonSocket");
574 let _ = fs::write(&sock, b"");
576
577 assert!(is_singleton_only_or_small(&dir));
578 let wiped = scavenge_stale_singleton_orphans_with_min_age(Duration::ZERO);
580 assert!(
581 wiped.iter().any(|p| p == &dir) || !dir.exists(),
582 "stale singleton fixture must be wiped: wiped={wiped:?} exists={}",
583 dir.exists()
584 );
585 let _ = fs::remove_dir_all(&dir);
586 }
587
588 #[test]
589 fn residual_disk_report_is_finite() {
590 let r = residual_disk_report();
591 let _ = r.cli_marker_dirs
593 + r.chromium_tmp_singleton_orphans
594 + r.scavenge_safe_candidates
595 + r.live_cli_marker_processes;
596 }
597
598 #[test]
599 fn google_chrome_tmp_names_excluded_from_stale_gc_list() {
600 assert!(is_google_chrome_tmp_name(".com.google.Chrome.XYZ"));
601 assert!(is_google_chrome_tmp_name("com.google.Chrome.XYZ"));
602 assert!(!is_google_chrome_tmp_name("org.chromium.Chromium.XYZ"));
603 }
604
605 #[test]
606 fn live_cli_chrome_cmdline_ignores_shell_mentions() {
607 let shell = "bash -c ls /tmp/browser-automation-cli-chrome-abc";
608 assert!(!is_live_cli_chrome_cmdline(shell));
609 let chrome = "/usr/bin/chromium-browser --user-data-dir=/tmp/browser-automation-cli-chrome-abc --headless=new";
610 assert!(is_live_cli_chrome_cmdline(chrome));
611 }
612}