Skip to main content

ssh_cli/platform/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-CLOSE-04: pure module — no `unsafe` permitted.
3#![forbid(unsafe_code)]
4//! Operating-system conditional abstractions.
5//!
6//! Platform initialization ([`initialize_platform`]) is the **first I/O-related
7//! step** after signal/telemetry bootstrap in [`crate::run`]. It configures:
8//!
9//! - **Windows**: console UTF-8 (code page 65001) + virtual terminal processing
10//!   for ANSI colors under cmd.exe / PowerShell 5.1 / Windows Terminal
11//! - **Linux / Unix**: sandbox detection (Flatpak/Snap) with observability warn
12//! - **macOS**: no-op init (paths via `directories`; Gatekeeper is user-side)
13//!
14//! # Runtime environment
15//!
16//! [`detect_runtime`] classifies WSL, containers, CI, Termux, and distribution
17//! sandboxes **without** spawning external processes. Results feed
18//! `vps doctor --json` diagnostics (agent-visible, no secrets).
19//!
20//! # Product scope (N/A by design)
21//!
22//! - Browser / Chrome / chromedriver discovery — not an SSH concern
23//! - WASM / WASI targets — `russh` requires real sockets; not shipped
24//! - Job Objects / local `Command` children — no privileged local subprocess tree
25//! - OpenBSD pledge/unveil, seccomp, setrlimit — optional hardening; not default
26//!
27//! # External processes (G-PROC audit)
28//!
29//! Runtime **never** shells out (`uname`, `ssh`, `scp`, `systemctl`, etc.).
30//! SSH transport is pure [`russh`]. The only `std::process::Command` uses in the
31//! tree are:
32//!
33//! | Site | Binary | When | Failure mode |
34//! |------|--------|------|--------------|
35//! | `build.rs` | `git` (optional) | embed commit hash | `unknown` / env / `.commit_hash` |
36//! | integration tests | `ssh-keygen` (optional fixture) | OpenSSH key files | skip / assert |
37//! | integration tests | `ssh-cli` under test | assert_cmd e2e | test failure |
38//!
39//! Toolchain MSRV **1.85.0** exceeds Rust **1.77.2** (CVE-2024-24576 / BatBadBut);
40//! product still never invokes `.bat`/`.cmd` children.
41
42use anyhow::Result;
43use serde::Serialize;
44
45#[cfg(target_os = "linux")]
46mod linux;
47#[cfg(target_os = "macos")]
48mod macos;
49#[cfg(target_os = "windows")]
50mod windows;
51
52/// Initializes the platform before user-facing I/O.
53///
54/// MUST be called early in [`crate::run`] (after signals + log bootstrap).
55///
56/// # Errors
57/// Propagates platform setup failures (Windows console APIs currently warn and
58/// still return `Ok` so agents are not blocked on console edge cases).
59pub fn initialize_platform() -> Result<()> {
60    #[cfg(target_os = "windows")]
61    {
62        windows::configure_console()?;
63    }
64
65    #[cfg(target_os = "linux")]
66    {
67        linux::detect_sandbox();
68    }
69
70    #[cfg(target_os = "macos")]
71    {
72        macos::initialize();
73    }
74
75    // Cross-platform observability: one structured debug line at boot.
76    let env = detect_runtime();
77    tracing::debug!(
78        os = env.os,
79        arch = env.arch,
80        wsl = env.is_wsl,
81        container = env.is_container,
82        ci = env.is_ci,
83        termux = env.is_termux,
84        sandbox = env.sandbox.unwrap_or("none"),
85        "runtime environment detected"
86    );
87
88    Ok(())
89}
90
91/// Normalizes a stdin line by stripping trailing `\r` (CRLF → LF).
92///
93/// Required on Windows where pipes may emit `\r\n`. Does not alter embedded
94/// newlines in multi-line payloads (only trims end-of-line CR/LF).
95#[must_use]
96pub fn normalize_stdin_line(line: &str) -> &str {
97    line.trim_end_matches(['\r', '\n'])
98}
99
100/// Returns `true` if stdout is connected to a terminal (TTY).
101///
102/// Prefer [`crate::terminal::is_interactive`] for color decisions (also honors
103/// `TERM=dumb`). This helper is the raw TTY probe for platform code.
104#[must_use]
105pub fn is_tty() -> bool {
106    std::io::IsTerminal::is_terminal(&std::io::stdout())
107}
108
109/// Detected host runtime (process environment classification).
110///
111/// All fields are pure heuristics from env vars and a few well-known paths —
112/// no shell-outs (`uname`, `systemd-detect-virt`, etc.).
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
114pub struct RuntimeEnvironment {
115    /// `std::env::consts::OS` (e.g. `linux`, `macos`, `windows`).
116    pub os: &'static str,
117    /// `std::env::consts::ARCH` (e.g. `x86_64`, `aarch64`).
118    pub arch: &'static str,
119    /// Running under Windows Subsystem for Linux (WSL1/WSL2).
120    pub is_wsl: bool,
121    /// Running inside a container (Docker/Podman/Kubernetes/etc.).
122    pub is_container: bool,
123    /// Continuous integration environment (`CI=true` or known vendor vars).
124    pub is_ci: bool,
125    /// Android Termux (bionic) environment.
126    pub is_termux: bool,
127    /// Distribution sandbox when known: `"flatpak"`, `"snap"`, or `None`.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub sandbox: Option<&'static str>,
130}
131
132/// Classifies the current process runtime (cheap, side-effect free).
133#[must_use]
134pub fn detect_runtime() -> RuntimeEnvironment {
135    RuntimeEnvironment {
136        os: std::env::consts::OS,
137        arch: std::env::consts::ARCH,
138        is_wsl: detect_wsl(),
139        is_container: detect_container(),
140        is_ci: detect_ci(),
141        is_termux: detect_termux(),
142        sandbox: detect_sandbox_kind(),
143    }
144}
145
146fn detect_wsl() -> bool {
147    if std::env::var_os("WSL_DISTRO_NAME").is_some()
148        || std::env::var_os("WSL_INTEROP").is_some()
149        || std::env::var_os("WSLENV").is_some()
150    {
151        return true;
152    }
153    // WSL1/2 often expose Microsoft in /proc/version (Linux only).
154    #[cfg(target_os = "linux")]
155    {
156        if let Ok(v) = std::fs::read_to_string("/proc/version") {
157            let lower = v.to_ascii_lowercase();
158            if lower.contains("microsoft") || lower.contains("wsl") {
159                return true;
160            }
161        }
162    }
163    false
164}
165
166fn detect_container() -> bool {
167    if std::env::var_os("KUBERNETES_SERVICE_HOST").is_some()
168        || std::env::var_os("container").is_some()
169    {
170        return true;
171    }
172    // Docker classic marker; Podman often uses /run/.containerenv.
173    if std::path::Path::new("/.dockerenv").exists()
174        || std::path::Path::new("/run/.containerenv").exists()
175    {
176        return true;
177    }
178    // cgroup hint (best-effort; may false-positive on some hosts — still useful).
179    #[cfg(target_os = "linux")]
180    {
181        if let Ok(cg) = std::fs::read_to_string("/proc/1/cgroup") {
182            let lower = cg.to_ascii_lowercase();
183            if lower.contains("docker")
184                || lower.contains("containerd")
185                || lower.contains("kubepods")
186                || lower.contains("libpod")
187                || lower.contains("/lxc/")
188            {
189                return true;
190            }
191        }
192    }
193    false
194}
195
196fn detect_ci() -> bool {
197    // Generic + common vendors (GitHub, GitLab, Azure, Circle, Buildkite, Travis, Jenkins).
198    if std::env::var("CI").map(|v| !v.is_empty() && v != "0" && v != "false") == Ok(true) {
199        return true;
200    }
201    const VENDOR_VARS: &[&str] = &[
202        "GITHUB_ACTIONS",
203        "GITLAB_CI",
204        "TF_BUILD",
205        "CIRCLECI",
206        "BUILDKITE",
207        "TRAVIS",
208        "JENKINS_URL",
209        "APPVEYOR",
210        "TEAMCITY_VERSION",
211        "BITBUCKET_BUILD_NUMBER",
212    ];
213    VENDOR_VARS.iter().any(|k| std::env::var_os(k).is_some())
214}
215
216fn detect_termux() -> bool {
217    std::env::var_os("TERMUX_VERSION").is_some()
218        || std::env::var_os("TERMUX_APK_RELEASE").is_some()
219        || std::env::var("PREFIX")
220            .map(|p| p.contains("com.termux"))
221            .unwrap_or(false)
222}
223
224fn detect_sandbox_kind() -> Option<&'static str> {
225    if std::env::var_os("FLATPAK_ID").is_some() {
226        return Some("flatpak");
227    }
228    if std::env::var_os("SNAP").is_some() {
229        return Some("snap");
230    }
231    None
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use serial_test::serial;
238
239    #[test]
240    fn normalize_strips_trailing_cr() {
241        assert_eq!(normalize_stdin_line("test\r"), "test");
242        assert_eq!(normalize_stdin_line("test\r\n"), "test");
243        assert_eq!(normalize_stdin_line("test\n"), "test");
244        assert_eq!(normalize_stdin_line("test"), "test");
245    }
246
247    #[test]
248    fn normalize_empty_string() {
249        assert_eq!(normalize_stdin_line(""), "");
250    }
251
252    #[test]
253    fn normalize_newlines_only() {
254        assert_eq!(normalize_stdin_line("\n\n\n"), "");
255    }
256
257    #[test]
258    fn normalize_mixed_crlf_lf_keeps_interior() {
259        assert_eq!(
260            normalize_stdin_line("line1\r\nline2\r\nline3"),
261            "line1\r\nline2\r\nline3"
262        );
263    }
264
265    #[test]
266    fn normalize_with_spaces() {
267        assert_eq!(
268            normalize_stdin_line("text with spaces  \r\n"),
269            "text with spaces  "
270        );
271    }
272
273    #[test]
274    fn is_tty_returns_bool() {
275        let _ = is_tty();
276    }
277
278    #[test]
279    fn detect_runtime_has_os_and_arch() {
280        let env = detect_runtime();
281        assert!(!env.os.is_empty());
282        assert!(!env.arch.is_empty());
283    }
284
285    #[test]
286    #[serial]
287    fn detect_ci_honors_ci_env() {
288        let prev = std::env::var("CI").ok();
289        crate::test_util::env::set_var("CI", "true");
290        assert!(detect_ci());
291        match prev {
292            Some(v) => crate::test_util::env::set_var("CI", v),
293            None => crate::test_util::env::remove_var("CI"),
294        }
295    }
296
297    #[test]
298    #[serial]
299    fn detect_sandbox_flatpak() {
300        let prev_f = std::env::var("FLATPAK_ID").ok();
301        let prev_s = std::env::var("SNAP").ok();
302        crate::test_util::env::set_var("FLATPAK_ID", "org.example.App");
303        crate::test_util::env::remove_var("SNAP");
304        assert_eq!(detect_sandbox_kind(), Some("flatpak"));
305        match prev_f {
306            Some(v) => crate::test_util::env::set_var("FLATPAK_ID", v),
307            None => crate::test_util::env::remove_var("FLATPAK_ID"),
308        }
309        match prev_s {
310            Some(v) => crate::test_util::env::set_var("SNAP", v),
311            None => crate::test_util::env::remove_var("SNAP"),
312        }
313    }
314
315    #[test]
316    #[serial]
317    fn detect_sandbox_snap() {
318        let prev_f = std::env::var("FLATPAK_ID").ok();
319        let prev_s = std::env::var("SNAP").ok();
320        crate::test_util::env::remove_var("FLATPAK_ID");
321        crate::test_util::env::set_var("SNAP", "/snap/app");
322        assert_eq!(detect_sandbox_kind(), Some("snap"));
323        match prev_f {
324            Some(v) => crate::test_util::env::set_var("FLATPAK_ID", v),
325            None => crate::test_util::env::remove_var("FLATPAK_ID"),
326        }
327        match prev_s {
328            Some(v) => crate::test_util::env::set_var("SNAP", v),
329            None => crate::test_util::env::remove_var("SNAP"),
330        }
331    }
332
333    #[test]
334    #[serial]
335    fn detect_termux_via_version() {
336        let prev = std::env::var("TERMUX_VERSION").ok();
337        crate::test_util::env::set_var("TERMUX_VERSION", "0.118");
338        assert!(detect_termux());
339        match prev {
340            Some(v) => crate::test_util::env::set_var("TERMUX_VERSION", v),
341            None => crate::test_util::env::remove_var("TERMUX_VERSION"),
342        }
343    }
344
345    #[test]
346    #[serial]
347    fn detect_wsl_via_distro_name() {
348        let prev = std::env::var("WSL_DISTRO_NAME").ok();
349        crate::test_util::env::set_var("WSL_DISTRO_NAME", "Ubuntu");
350        assert!(detect_wsl());
351        match prev {
352            Some(v) => crate::test_util::env::set_var("WSL_DISTRO_NAME", v),
353            None => crate::test_util::env::remove_var("WSL_DISTRO_NAME"),
354        }
355    }
356
357    #[test]
358    fn runtime_environment_serializes_json_keys() {
359        let env = RuntimeEnvironment {
360            os: "linux",
361            arch: "x86_64",
362            is_wsl: false,
363            is_container: true,
364            is_ci: true,
365            is_termux: false,
366            sandbox: Some("flatpak"),
367        };
368        let v = serde_json::to_value(env).unwrap();
369        assert_eq!(v["os"], "linux");
370        assert_eq!(v["is_container"], true);
371        assert_eq!(v["sandbox"], "flatpak");
372    }
373}