Skip to main content

browser_automation_cli/
residual.rs

1//! Owned residual path discovery for one-shot Chrome (GAP-017 / GAP-020).
2//!
3//! Never wipes host Chromes (Flatpak/VS Code). Only paths that are:
4//! - under our marker prefix `browser-automation-cli-chrome-*`, or
5//! - Singleton files inside our profile, or
6//! - `/tmp/org.chromium.Chromium.*` / `/tmp/.org.chromium.Chromium.*` created
7//!   after launch, owned by this uid, and referencing our chrome pid or profile.
8
9use std::path::{Path, PathBuf};
10use std::time::{Duration, SystemTime};
11
12/// Discover Chromium side-channel paths in the process temp dir that belong to
13/// this launch (GAP-020).
14pub fn discover_owned_chromium_tmp_side_channels(
15    profile: Option<&Path>,
16    chrome_pid: Option<u32>,
17    not_before: SystemTime,
18) -> Vec<PathBuf> {
19    let mut out = Vec::new();
20    let tmp = std::env::temp_dir();
21    let Ok(entries) = std::fs::read_dir(&tmp) else {
22        return out;
23    };
24    let pid_s = chrome_pid.map(|p| p.to_string());
25    let profile_s = profile.map(|p| p.display().to_string());
26
27    for ent in entries.flatten() {
28        let name = ent.file_name();
29        let name = name.to_string_lossy();
30        let is_chromium_tmp = name.starts_with("org.chromium.Chromium.")
31            || name.starts_with(".org.chromium.Chromium.")
32            || name.starts_with(".com.google.Chrome.")
33            || name.starts_with("com.google.Chrome.");
34        let is_cli_marker = name.starts_with("browser-automation-cli-chrome-");
35        if !is_chromium_tmp && !is_cli_marker {
36            continue;
37        }
38        let path = ent.path();
39        if !owned_by_current_user(&path) {
40            continue;
41        }
42        if !created_or_modified_after(&path, not_before) {
43            continue;
44        }
45        if is_cli_marker {
46            // Always ours when marker + recent + our uid.
47            out.push(path);
48            continue;
49        }
50        // Chromium tmp: require pid or profile reference when possible.
51        if let Some(ref pid) = pid_s {
52            if path_references(&path, pid) {
53                out.push(path);
54                continue;
55            }
56        }
57        if let Some(ref prof) = profile_s {
58            if path_references(&path, prof) {
59                out.push(path);
60                continue;
61            }
62        }
63        // Recent + our uid + chromium tmp pattern created within 2s of launch:
64        // still only if younger than a tight window (owned launch noise).
65        if age_since(not_before) < Duration::from_secs(5) {
66            // Conservative: only empty lock files / small singleton sockets under 4KiB.
67            if let Ok(meta) = path.metadata() {
68                if meta.len() <= 4096 {
69                    out.push(path);
70                }
71            }
72        }
73    }
74    out
75}
76
77/// Collect residual marker profile dirs left under temp (should be empty after healthy DIE).
78pub fn list_cli_chrome_marker_dirs() -> Vec<PathBuf> {
79    let mut out = Vec::new();
80    let tmp = std::env::temp_dir();
81    let Ok(entries) = std::fs::read_dir(tmp) else {
82        return out;
83    };
84    for ent in entries.flatten() {
85        let name = ent.file_name();
86        let name = name.to_string_lossy();
87        if name.starts_with("browser-automation-cli-chrome-") {
88            out.push(ent.path());
89        }
90    }
91    out
92}
93
94/// FINALIZE scavenger (GAP-A002): wipe **owned** Chromium tmp leftovers that are
95/// safe to remove — our uid, chromium/cli marker name, no live process holding
96/// the path, and either created after `not_before` or Singleton-only / empty.
97///
98/// Never touches Flatpak/host Chrome profiles outside temp marker patterns.
99pub fn scavenge_owned_chromium_tmp_orphans(
100    profile: Option<&Path>,
101    chrome_pid: Option<u32>,
102    not_before: SystemTime,
103) -> Vec<PathBuf> {
104    let candidates = discover_owned_chromium_tmp_side_channels(profile, chrome_pid, not_before);
105    let mut wiped = Vec::new();
106    for path in candidates {
107        if path_has_live_process(&path) {
108            continue;
109        }
110        // Prefer Singleton-only / small dirs; also wipe our marker profiles.
111        let name = path
112            .file_name()
113            .map(|s| s.to_string_lossy().into_owned())
114            .unwrap_or_default();
115        let is_cli_marker = name.starts_with("browser-automation-cli-chrome-");
116        let is_singletonish = is_singleton_only_or_small(&path);
117        if is_cli_marker || is_singletonish {
118            wipe_path(&path);
119            wiped.push(path);
120        }
121    }
122    wiped
123}
124
125fn is_singleton_only_or_small(path: &Path) -> bool {
126    if !path.is_dir() {
127        if let Ok(meta) = path.metadata() {
128            return meta.len() <= 4096;
129        }
130        return false;
131    }
132    let Ok(entries) = std::fs::read_dir(path) else {
133        return false;
134    };
135    let mut count = 0usize;
136    let mut only_singleton = true;
137    for ent in entries.flatten() {
138        count += 1;
139        if count > 8 {
140            return false;
141        }
142        let n = ent.file_name();
143        let n = n.to_string_lossy();
144        if !(n.starts_with("Singleton")
145            || n == "DevToolsActivePort"
146            || n.starts_with(".org.chromium")
147            || n.ends_with(".lock"))
148        {
149            only_singleton = false;
150        }
151    }
152    only_singleton || count == 0
153}
154
155fn path_has_live_process(path: &Path) -> bool {
156    let needle = path.display().to_string();
157    #[cfg(unix)]
158    {
159        // Best-effort: scan /proc/*/cmdline for the path (no external rg).
160        let Ok(proc) = std::fs::read_dir("/proc") else {
161            return false;
162        };
163        for ent in proc.flatten() {
164            let name = ent.file_name();
165            let name = name.to_string_lossy();
166            if !name.chars().all(|c| c.is_ascii_digit()) {
167                continue;
168            }
169            let cmdline = ent.path().join("cmdline");
170            if let Ok(bytes) = std::fs::read(cmdline) {
171                let s = String::from_utf8_lossy(&bytes);
172                if s.contains(&needle) {
173                    return true;
174                }
175            }
176        }
177        false
178    }
179    #[cfg(not(unix))]
180    {
181        let _ = needle;
182        false
183    }
184}
185
186fn wipe_path(path: &Path) {
187    if !path.exists() {
188        return;
189    }
190    if path.is_dir() {
191        let _ = std::fs::remove_dir_all(path);
192    } else {
193        let _ = std::fs::remove_file(path);
194    }
195}
196
197fn owned_by_current_user(path: &Path) -> bool {
198    #[cfg(unix)]
199    {
200        use std::os::unix::fs::MetadataExt;
201        let Ok(meta) = path.metadata() else {
202            return false;
203        };
204        meta.uid() == unsafe { libc::getuid() }
205    }
206    #[cfg(not(unix))]
207    {
208        let _ = path;
209        true
210    }
211}
212
213fn created_or_modified_after(path: &Path, not_before: SystemTime) -> bool {
214    let Ok(meta) = path.metadata() else {
215        return false;
216    };
217    let modified = meta.modified().ok();
218    let created = meta.created().ok();
219    let skew = Duration::from_secs(2);
220    let threshold = not_before.checked_sub(skew).unwrap_or(not_before);
221    if let Some(m) = modified {
222        if m >= threshold {
223            return true;
224        }
225    }
226    if let Some(c) = created {
227        if c >= threshold {
228            return true;
229        }
230    }
231    false
232}
233
234fn path_references(path: &Path, needle: &str) -> bool {
235    if path.display().to_string().contains(needle) {
236        return true;
237    }
238    // Symlink target may encode hostname-pid.
239    if let Ok(target) = std::fs::read_link(path) {
240        if target.to_string_lossy().contains(needle) {
241            return true;
242        }
243    }
244    // Small text files may contain the pid.
245    if let Ok(meta) = path.metadata() {
246        if meta.is_file() && meta.len() <= 4096 {
247            if let Ok(bytes) = std::fs::read(path) {
248                if String::from_utf8_lossy(&bytes).contains(needle) {
249                    return true;
250                }
251            }
252        }
253    }
254    false
255}
256
257fn age_since(t: SystemTime) -> Duration {
258    SystemTime::now().duration_since(t).unwrap_or_default()
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::fs;
265    use std::time::SystemTime;
266
267    #[test]
268    fn marker_scan_finds_and_filters() {
269        let tmp = std::env::temp_dir();
270        let dir = tmp.join(format!(
271            "browser-automation-cli-chrome-test-{}",
272            uuid::Uuid::new_v4()
273        ));
274        let _ = fs::create_dir_all(&dir);
275        let found = list_cli_chrome_marker_dirs();
276        assert!(
277            found.iter().any(|p| p == &dir),
278            "expected marker dir in list"
279        );
280        let _ = fs::remove_dir_all(&dir);
281    }
282
283    #[test]
284    fn discover_respects_not_before() {
285        let future = SystemTime::now() + Duration::from_secs(3600);
286        let found = discover_owned_chromium_tmp_side_channels(None, None, future);
287        assert!(
288            found.is_empty(),
289            "future not_before must yield no side channels"
290        );
291    }
292}