Skip to main content

browser_automation_cli/
residual.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Owned residual path discovery for one-shot Chrome (GAP-017 / GAP-020 / RES-01…12).
3//!
4//! Never wipes host Chromes (Flatpak/VS Code). Only paths that are:
5//! - under our marker prefix `browser-automation-cli-chrome-*`, or
6//! - Singleton files inside our profile, or
7//! - `/tmp/org.chromium.Chromium.*` / `/tmp/.org.chromium.Chromium.*` that are
8//!   Singleton-only (or empty), owned by this uid, with no live process holding
9//!   the path — either from this invocation window or cross-run stale GC.
10//!
11//! Host Flatpak tmp noise (`.com.google.Chrome.*` / `com.google.Chrome.*`) is
12//! **never** deleted by cross-run GC (product law).
13//!
14//! # Workload
15//!
16//! **I/O-bound** temp/`/proc` scan during BORN and FINALIZE. Discovery of temp
17//! entries stays sequential (directory iterator).
18//!
19//! **PAR-89:** live-process checks use a **single** `/proc` cmdline index
20//! (`index_proc_cmdlines`) then [`crate::concurrency::map_cpu`] over candidates
21//! (threshold 32). Never rescan `/proc` inside each parallel task (that would
22//! be O(N×P) thrashing under Rayon).
23//!
24//! **PAR-90:** independent wipe of disjoint paths uses `map_cpu` when large.
25//! Subprocess Chrome itself is never unbounded-forked (one launch per one-shot
26//! process). The brief `std::thread::sleep` settle in browser residual path is
27//! on the **sync** FINALIZE path (not a Tokio worker).
28
29use std::path::{Path, PathBuf};
30use std::time::{Duration, SystemTime};
31
32use serde::Serialize;
33
34/// Prefix for CLI-owned temp Chrome user-data-dir profiles.
35pub const CLI_CHROME_MARKER_PREFIX: &str = "browser-automation-cli-chrome-";
36
37/// Chromium side-channel prefix under the process temp dir.
38pub const CHROMIUM_TMP_PREFIX: &str = "org.chromium.Chromium.";
39
40/// Hidden Chromium side-channel prefix under the process temp dir.
41pub const CHROMIUM_TMP_DOT_PREFIX: &str = ".org.chromium.Chromium.";
42
43/// Max file/dir payload size treated as Singleton-only residue (bytes).
44pub const SINGLETON_MAX_BYTES: u64 = 4096;
45
46/// Max directory entries allowed when classifying Singleton-only residue.
47pub const SINGLETON_MAX_ENTRIES: usize = 8;
48
49/// Tight window after launch for unattributed chromium tmp (seconds).
50pub const INVOCATION_SIDE_CHANNEL_WINDOW_SECS: u64 = 5;
51
52/// Skew allowance when comparing mtime/ctime to `not_before` (seconds).
53pub const MTIME_SKEW_SECS: u64 = 2;
54
55/// Minimum age for cross-run stale Singleton GC (seconds).
56///
57/// Avoids racing a Chromium process still creating files under the same uid.
58pub const STALE_MIN_AGE_SECS: u64 = 60;
59
60/// Machine-readable residual disk hygiene report (doctor / agents).
61#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
62pub struct ResidualDiskReport {
63    /// Count of `browser-automation-cli-chrome-*` dirs under temp.
64    pub cli_marker_dirs: usize,
65    /// Count of Chromium Singleton-only tmp dirs that look orphaned.
66    pub chromium_tmp_singleton_orphans: usize,
67    /// Count of paths that would be wiped by stale GC right now.
68    pub scavenge_safe_candidates: usize,
69    /// Live processes whose cmdline contains the CLI chrome marker prefix.
70    pub live_cli_marker_processes: usize,
71}
72
73/// Discover Chromium side-channel paths in the process temp dir that belong to
74/// this launch (GAP-020).
75pub 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            // Always ours when marker + recent + our uid.
105            out.push(path);
106            continue;
107        }
108        // Chromium tmp: require pid or profile reference when possible.
109        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        // Recent + our uid + chromium tmp pattern created within the tight window:
122        // still only if younger than a tight window (owned launch noise).
123        if age_since(not_before) < Duration::from_secs(INVOCATION_SIDE_CHANNEL_WINDOW_SECS) {
124            // Conservative: only empty lock files / small singleton sockets.
125            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
135/// Collect residual marker profile dirs left under temp (should be empty after healthy DIE).
136pub 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
152/// FINALIZE scavenger (GAP-A002): wipe **owned** Chromium tmp leftovers that are
153/// safe to remove — our uid, chromium/cli marker name, no live process holding
154/// the path, and either created after `not_before` or Singleton-only / empty.
155///
156/// Never touches Flatpak/host Chrome profiles outside temp marker patterns.
157pub 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
166/// Cross-run GC of stale Singleton-only Chromium tmp + CLI marker orphans (RES-02).
167///
168/// Automatic BORN/FINALIZE hygiene: removes disk litter that survives healthy
169/// process DIE when Chromium side-channels sit outside the marker profile.
170///
171/// **Never** deletes `com.google.Chrome.*` host Flatpak temp noise.
172pub fn scavenge_stale_singleton_orphans() -> Vec<PathBuf> {
173    scavenge_stale_singleton_orphans_with_min_age(Duration::from_secs(STALE_MIN_AGE_SECS))
174}
175
176/// Same as [`scavenge_stale_singleton_orphans`] with an explicit age floor.
177///
178/// Production uses [`STALE_MIN_AGE_SECS`]. Tests may pass `Duration::ZERO` to
179/// force immediate GC of fixture paths.
180pub 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
193/// Snapshot residual disk hygiene without mutating the filesystem.
194pub 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    // Count only browser-like processes that hold our marker profile.
199    // Avoid false positives from agent/shell cmdlines that merely *mention*
200    // the marker string (grep, find, editors, residual scripts).
201    let live_cli = proc_index
202        .iter()
203        .filter(|cmd| is_live_cli_chrome_cmdline(cmd))
204        .count();
205    // Count chromium singleton-shaped dirs (including those younger than age floor).
206    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        // Explicitly exclude host Google Chrome Flatpak temp prefixes.
228        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            // Marker dirs: wipe when empty/small or fully Singleton-shaped; age floor applies.
243            if !is_singleton_only_or_small(&path) && dir_entry_count(&path) > 0 {
244                // Non-empty full profile still young may be live CLI launch — require no live holder below.
245            }
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    // PAR-89: index /proc cmdlines **once**, then map_cpu candidate checks against
277    // the shared index (never N full /proc scans under Rayon).
278    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    // PAR-90: independent remove_dir_all/remove_file on disjoint paths.
299    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    // If neither timestamp is available, treat as not stale (safe).
367    false
368}
369
370/// One-shot index of live process cmdlines under `/proc` (unix).
371///
372/// **PAR-89:** call **once** per scavenge, then pass the slice into
373/// [`path_has_live_process`]. Never rebuild inside `map_cpu` tasks.
374#[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            // cmdline is NUL-separated; keep as lossy string for substring search.
389            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
400/// True if any indexed cmdline contains `path` (best-effort; no external `rg`).
401fn 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
409/// True when a process cmdline looks like a Chrome/Chromium instance using our
410/// temp profile marker (not a shell/agent that only mentions the string).
411fn is_live_cli_chrome_cmdline(cmd: &str) -> bool {
412    if !cmd.contains(CLI_CHROME_MARKER_PREFIX) {
413        return false;
414    }
415    // Chrome multiproc children always carry a browser binary path or --type=.
416    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    // Exclude pure text tools / editors that might embed the marker path in argv.
426    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        // SAFETY:
455        // - Contract: compare path owner uid to the real uid of this process.
456        // - Invariant: `getuid` has no preconditions and returns the caller's real uid.
457        // - Used only to refuse deleting residual paths not owned by the current user.
458        // - See: `man 2 getuid`; `MetadataExt::uid` for the file side.
459        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    // Symlink target may encode hostname-pid.
494    if let Ok(target) = std::fs::read_link(path) {
495        if target.to_string_lossy().contains(needle) {
496            return true;
497        }
498    }
499    // Small text files may contain the pid.
500    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    // Directory: check Singleton* symlink targets / small files.
510    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        // Singleton-shaped contents.
568        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        // Regular empty file standing in for a dead socket.
575        let _ = fs::write(&sock, b"");
576
577        assert!(is_singleton_only_or_small(&dir));
578        // Age floor zero in tests so we do not depend on utimensat/filetime.
579        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        // Just ensure fields are accessible and non-panicking.
592        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}