Skip to main content

ssh_cli/platform/
mod.rs

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