browser_automation_cli/
platform.rs1use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct HostEnvironment {
14 pub wsl: bool,
16 pub container: bool,
18 pub ci: bool,
20 pub termux: bool,
22 pub flatpak: bool,
24 pub snap: bool,
26}
27
28impl HostEnvironment {
29 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum BrowserSandbox {
73 None,
75 Snap,
77 Flatpak,
79}
80
81impl BrowserSandbox {
82 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 pub fn is_restricted(self) -> bool {
93 !matches!(self, Self::None)
94 }
95}
96
97pub 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 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
121pub 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
140pub fn which_bin(name: &str) -> Option<PathBuf> {
144 if name.is_empty() {
145 return None;
146 }
147 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
180pub 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
199pub 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
212pub 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 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 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 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}