Skip to main content

browser_automation_cli/
platform.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Cross-platform host helpers (PATH lookup, console, sandbox, environment).
3//!
4//! Product law: Chrome CDP is **host-only** (not WASM). Browser path override is
5//! XDG `chrome_path` / CLI launch options — not product env vars.
6//!
7//! Rules: `docs_rules/rules_rust_multiplataforma_sistemas_operacionais.md`.
8
9use std::path::{Path, PathBuf};
10
11/// Result of probing the host for container / CI / WSL / Termux / sandbox env.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct HostEnvironment {
14    /// Running under Windows Subsystem for Linux (`WSL_DISTRO_NAME` / `/proc` markers).
15    pub wsl: bool,
16    /// Docker / Podman / k8s container markers.
17    pub container: bool,
18    /// Common CI env keys present (local observability only; never product settings).
19    pub ci: bool,
20    /// Android Termux (`PREFIX` under `/data/data/com.termux`).
21    pub termux: bool,
22    /// Process is inside a Flatpak sandbox (`FLATPAK_ID`).
23    pub flatpak: bool,
24    /// Process is inside a Snap sandbox (`SNAP`).
25    pub snap: bool,
26}
27
28impl HostEnvironment {
29    /// Probe the current process environment and filesystem markers once.
30    pub fn detect() -> Self {
31        Self {
32            wsl: detect_wsl(),
33            container: detect_container(),
34            ci: detect_ci(),
35            termux: detect_termux(),
36            flatpak: std::env::var_os("FLATPAK_ID").is_some(),
37            snap: std::env::var_os("SNAP").is_some(),
38        }
39    }
40
41    /// Compact label for doctor / diagnostics JSON.
42    pub fn summary(&self) -> String {
43        let mut tags = Vec::with_capacity(6);
44        if self.wsl {
45            tags.push("wsl");
46        }
47        if self.container {
48            tags.push("container");
49        }
50        if self.ci {
51            tags.push("ci");
52        }
53        if self.termux {
54            tags.push("termux");
55        }
56        if self.flatpak {
57            tags.push("flatpak");
58        }
59        if self.snap {
60            tags.push("snap");
61        }
62        if tags.is_empty() {
63            "host".into()
64        } else {
65            tags.join("+")
66        }
67    }
68}
69
70/// How a resolved browser binary is packaged (affects automation reliability).
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum BrowserSandbox {
73    /// System / portable install (APT, RPM, MSI, dmg, etc.).
74    None,
75    /// Snap confinement (`/snap/…` or `$SNAP`).
76    Snap,
77    /// Flatpak confinement (`/var/lib/flatpak/…`, `~/.var/app/…`, `$FLATPAK_ID`).
78    Flatpak,
79}
80
81impl BrowserSandbox {
82    /// Human-readable id for doctor JSON.
83    pub fn as_str(self) -> &'static str {
84        match self {
85            Self::None => "none",
86            Self::Snap => "snap",
87            Self::Flatpak => "flatpak",
88        }
89    }
90
91    /// True when distribution sandbox may block CDP automation.
92    pub fn is_restricted(self) -> bool {
93        !matches!(self, Self::None)
94    }
95}
96
97/// Classify a browser executable path (and process env) for sandbox warnings.
98pub fn detect_browser_sandbox(path: &Path) -> BrowserSandbox {
99    let s = path.to_string_lossy();
100    if s.contains("/snap/") || s.starts_with("/snap/") || std::env::var_os("SNAP").is_some() {
101        // Prefer path prefix when both set (host CLI launching snap chrome).
102        if s.contains("/snap/") {
103            return BrowserSandbox::Snap;
104        }
105    }
106    if s.contains("/var/lib/flatpak/")
107        || s.contains("/.local/share/flatpak/")
108        || s.contains("/.var/app/")
109    {
110        return BrowserSandbox::Flatpak;
111    }
112    if std::env::var_os("FLATPAK_ID").is_some() {
113        return BrowserSandbox::Flatpak;
114    }
115    if std::env::var_os("SNAP").is_some() && s.contains("snap") {
116        return BrowserSandbox::Snap;
117    }
118    BrowserSandbox::None
119}
120
121/// Emit a local warning when the resolved browser is snap/flatpak confined.
122pub fn warn_if_sandboxed_browser(path: &Path) {
123    match detect_browser_sandbox(path) {
124        BrowserSandbox::None => {}
125        BrowserSandbox::Snap => {
126            tracing::warn!(
127                path = %path.display(),
128                "Chrome/Chromium resolved under Snap; CDP automation may fail. Prefer APT/RPM install or: config set chrome_path /path/to/chrome"
129            );
130        }
131        BrowserSandbox::Flatpak => {
132            tracing::warn!(
133                path = %path.display(),
134                "Chrome/Chromium resolved under Flatpak; host /tmp and user-data-dir may be blocked. Prefer system package or config set chrome_path"
135            );
136        }
137    }
138}
139
140/// Locate an executable on `$PATH` without shelling out to `which`/`where`.
141///
142/// On Windows, also tries `{name}.exe`. Returns the first existing regular file.
143pub fn which_bin(name: &str) -> Option<PathBuf> {
144    if name.is_empty() {
145        return None;
146    }
147    // Absolute / relative path with separators: honor directly when executable.
148    let as_path = Path::new(name);
149    if as_path.components().count() > 1 || as_path.is_absolute() {
150        if is_executable_file(as_path) {
151            return Some(as_path.to_path_buf());
152        }
153        return None;
154    }
155    let paths = std::env::var_os("PATH")?;
156    for dir in std::env::split_paths(&paths) {
157        let candidate = dir.join(name);
158        if is_executable_file(&candidate) {
159            return Some(candidate);
160        }
161        #[cfg(windows)]
162        {
163            let with_exe = dir.join(format!("{name}.exe"));
164            if is_executable_file(&with_exe) {
165                return Some(with_exe);
166            }
167            let with_cmd = dir.join(format!("{name}.cmd"));
168            if with_cmd.is_file() {
169                return Some(with_cmd);
170            }
171            let with_bat = dir.join(format!("{name}.bat"));
172            if with_bat.is_file() {
173                return Some(with_bat);
174            }
175        }
176    }
177    None
178}
179
180/// True when `path` is a regular file and (on Unix) has any execute bit.
181pub fn is_executable_file(path: &Path) -> bool {
182    if !path.is_file() {
183        return false;
184    }
185    #[cfg(unix)]
186    {
187        use std::os::unix::fs::PermissionsExt;
188        match path.metadata() {
189            Ok(meta) => meta.permissions().mode() & 0o111 != 0,
190            Err(_) => false,
191        }
192    }
193    #[cfg(not(unix))]
194    {
195        true
196    }
197}
198
199/// First existing executable among candidate paths (skips missing / non-exec).
200pub fn first_existing_executable<'a, I>(candidates: I) -> Option<PathBuf>
201where
202    I: IntoIterator<Item = &'a Path>,
203{
204    for p in candidates {
205        if is_executable_file(p) {
206            return Some(p.to_path_buf());
207        }
208    }
209    None
210}
211
212/// Configure Windows console for UTF-8 (CP 65001) and virtual terminal ANSI.
213///
214/// No-op on non-Windows. Failures are ignored (already UTF-8 / redirected handles).
215pub fn configure_console() {
216    #[cfg(windows)]
217    {
218        configure_console_windows();
219    }
220}
221
222#[cfg(windows)]
223fn configure_console_windows() {
224    use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
225    use windows_sys::Win32::System::Console::{
226        GetConsoleMode, GetStdHandle, SetConsoleCP, SetConsoleMode, SetConsoleOutputCP,
227        ENABLE_VIRTUAL_TERMINAL_PROCESSING, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE,
228    };
229
230    // SAFETY: Win32 console APIs are process-wide and safe at single-threaded boot.
231    // CP_UTF8 = 65001. VT mode enables ANSI on modern Windows Terminal / conhost.
232    // See: https://learn.microsoft.com/windows/console/console-virtual-terminal-sequences
233    const CP_UTF8: u32 = 65001;
234    unsafe {
235        let _ = SetConsoleOutputCP(CP_UTF8);
236        let _ = SetConsoleCP(CP_UTF8);
237        for nstd in [STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
238            let h = GetStdHandle(nstd);
239            if h == INVALID_HANDLE_VALUE || h.is_null() {
240                continue;
241            }
242            let mut mode: u32 = 0;
243            if GetConsoleMode(h, &mut mode) == 0 {
244                continue;
245            }
246            let _ = SetConsoleMode(h, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
247        }
248    }
249}
250
251fn detect_wsl() -> bool {
252    if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
253        return true;
254    }
255    #[cfg(target_os = "linux")]
256    {
257        if let Ok(osrelease) = std::fs::read_to_string("/proc/sys/kernel/osrelease") {
258            let lower = osrelease.to_ascii_lowercase();
259            if lower.contains("microsoft") || lower.contains("wsl") {
260                return true;
261            }
262        }
263    }
264    false
265}
266
267fn detect_container() -> bool {
268    if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() {
269        return true;
270    }
271    if std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() {
272        return true;
273    }
274    #[cfg(target_os = "linux")]
275    {
276        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
277            if cgroup.contains("docker")
278                || cgroup.contains("kubepods")
279                || cgroup.contains("lxc")
280                || cgroup.contains("containerd")
281                || cgroup.contains("podman")
282            {
283                return true;
284            }
285        }
286    }
287    false
288}
289
290fn detect_ci() -> bool {
291    // Observability only — product settings never bind to these keys.
292    const KEYS: &[&str] = &[
293        "CI",
294        "GITHUB_ACTIONS",
295        "GITLAB_CI",
296        "BUILDKITE",
297        "CIRCLECI",
298        "TRAVIS",
299        "APPVEYOR",
300        "TF_BUILD",
301        "JENKINS_URL",
302    ];
303    KEYS.iter().any(|k| std::env::var_os(k).is_some())
304}
305
306fn detect_termux() -> bool {
307    if std::env::var_os("TERMUX_VERSION").is_some() {
308        return true;
309    }
310    if let Some(prefix) = std::env::var_os("PREFIX") {
311        let p = PathBuf::from(prefix);
312        if p.starts_with("/data/data/com.termux") {
313            return true;
314        }
315    }
316    false
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use std::io::Write;
323
324    #[test]
325    fn host_environment_detect_does_not_panic() {
326        let env = HostEnvironment::detect();
327        assert!(!env.summary().is_empty());
328    }
329
330    #[test]
331    fn sandbox_none_for_ordinary_path() {
332        assert_eq!(
333            detect_browser_sandbox(Path::new("/usr/bin/google-chrome")),
334            BrowserSandbox::None
335        );
336    }
337
338    #[test]
339    fn sandbox_snap_by_path() {
340        assert_eq!(
341            detect_browser_sandbox(Path::new("/snap/bin/chromium")),
342            BrowserSandbox::Snap
343        );
344    }
345
346    #[test]
347    fn sandbox_flatpak_by_path() {
348        assert_eq!(
349            detect_browser_sandbox(Path::new(
350                "/var/lib/flatpak/exports/bin/com.google.Chrome"
351            )),
352            BrowserSandbox::Flatpak
353        );
354        assert_eq!(
355            detect_browser_sandbox(Path::new(
356                "/home/u/.local/share/flatpak/exports/bin/com.google.Chrome"
357            )),
358            BrowserSandbox::Flatpak
359        );
360    }
361
362    #[test]
363    fn which_bin_empty_name_none() {
364        assert!(which_bin("").is_none());
365    }
366
367    #[test]
368    fn which_bin_finds_sh_on_unix() {
369        #[cfg(unix)]
370        {
371            // `/bin/sh` or PATH `sh` almost always present on Unix CI hosts.
372            let found = which_bin("sh").or_else(|| which_bin("/bin/sh"));
373            assert!(found.is_some(), "expected sh on PATH or /bin/sh");
374            assert!(is_executable_file(found.as_ref().unwrap()));
375        }
376    }
377
378    #[test]
379    fn first_existing_skips_missing() {
380        let missing = Path::new("/nonexistent/browser-automation-cli-chrome-xyz");
381        let mut tmp = tempfile::NamedTempFile::new().expect("tmp");
382        writeln!(tmp, "x").ok();
383        #[cfg(unix)]
384        {
385            use std::os::unix::fs::PermissionsExt;
386            let mut perms = tmp.as_file().metadata().unwrap().permissions();
387            perms.set_mode(0o755);
388            std::fs::set_permissions(tmp.path(), perms).unwrap();
389        }
390        let found = first_existing_executable([missing, tmp.path()]);
391        assert_eq!(found.as_deref(), Some(tmp.path()));
392    }
393
394    #[test]
395    fn browser_sandbox_as_str() {
396        assert_eq!(BrowserSandbox::None.as_str(), "none");
397        assert!(BrowserSandbox::Snap.is_restricted());
398        assert!(!BrowserSandbox::None.is_restricted());
399    }
400}