Skip to main content

runtimo_core/
cmd.rs

1//! Shared command execution helper.
2//!
3//! Provides `run_cmd_result` and `run_cmd`, both returning
4//! `Result<String, CmdError>` so callers can distinguish "command not found"
5//! from "command failed with exit code" from "I/O error spawning process".
6//!
7//! # Security Warning
8//!
9//! This module uses `sh -c` to execute commands. **NEVER interpolate user input**
10//! into command strings — only hardcoded, trusted commands should be used.
11//! All commands in this codebase are static literals with no user data interpolation.
12//!
13//! Violating this rule causes shell injection attacks. Use [`std::process::Command`]
14//! directly with `.arg()` for user-provided values.
15
16use std::process::Command;
17
18/// Error type for shell command execution failures.
19///
20/// # Variants
21/// - `NotFound`: The command executable was not found on the system PATH.
22///   Contains the command string that was attempted.
23/// - `Failed`: The command executed but exited with a non-zero status code.
24///   Contains the exit code and captured stderr output.
25/// - `Io`: The process failed to spawn (e.g., permission denied, fork failure).
26///   Wraps `std::io::Error` for ergonomic `?` propagation.
27///
28/// # Invariants
29/// - `NotFound` is distinct from `Failed`: a missing binary vs. a binary that
30///   ran and returned an error.
31/// - `Failed` always carries both the exit code and stderr — callers can
32///   inspect either for retry decisions.
33/// - `Io` wraps `std::io::Error` via `From` for ergonomic `?` propagation
34///   from `Command::output()`.
35///
36/// # Errors
37///
38/// This enum IS the error type — no separate error channel exists.
39/// [`run_cmd`] returns `Result<String, CmdError>`.
40#[derive(Debug, thiserror::Error)]
41#[allow(clippy::exhaustive_enums)] // error enums are intentionally exhaustive
42pub enum CmdError {
43    /// Command executable not found on the system PATH.
44    ///
45    /// The string value is the command that was attempted.
46    #[error("command not found: {0}")]
47    NotFound(String),
48
49    /// Command executed but exited with a non-zero status code.
50    ///
51    /// `code` is the process exit code (always non-negative on Unix).
52    /// `stderr` is the captured standard error output, trimmed.
53    #[error("command failed with exit code {code}: {stderr}")]
54    Failed {
55        /// The non-zero exit code from the command.
56        code: i32,
57        /// The trimmed stderr output from the command.
58        stderr: String,
59    },
60
61    /// Process failed to spawn.
62    ///
63    /// Wraps `std::io::Error` from `Command::output()`. Common causes:
64    /// permission denied, resource limit reached, fork failure.
65    #[error("io error: {0}")]
66    Io(#[from] std::io::Error),
67}
68
69/// Run a shell command and return trimmed stdout, or a [`CmdError`] if the
70/// command fails to execute or exits with a non-zero status.
71///
72/// # Input
73///
74/// `cmd` — A shell command string (must be a hardcoded literal — see module docs).
75///
76/// # Output
77///
78/// `Ok(String)` — Trimmed stdout when the command succeeds (exit code 0).
79///
80/// # Errors
81///
82/// Returns [`CmdError::NotFound`] if the command executable does not exist.
83/// Returns [`CmdError::Failed`] if the command exits with a non-zero status code —
84/// the variant carries both the exit code and captured stderr.
85/// Returns [`CmdError::Io`] if the process fails to spawn (permission denied, fork failure).
86///
87/// # Safety
88///
89/// **CRITICAL:** Only use with hardcoded, trusted command strings.
90/// Never interpolate user input, file paths, or any external data into `cmd`.
91/// This function uses `sh -c` which is vulnerable to shell injection if user data is included.
92///
93/// For user-provided values, use [`std::process::Command`] directly:
94/// ```rust,ignore
95/// std::process::Command::new("cat").arg(user_path).output()
96/// ```
97pub fn run_cmd_result(cmd: &str) -> std::result::Result<String, CmdError> {
98    // SECURITY: This is safe because all callers use hardcoded command literals.
99    // The commands are: "cat /proc/cpuinfo | grep...", "free -h | grep...", etc.
100    let output = Command::new("sh").arg("-c").arg(cmd).output()?;
101
102    if output.status.success() {
103        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
104    } else {
105        let code = output.status.code().unwrap_or(-1);
106        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
107        Err(CmdError::Failed { code, stderr })
108    }
109}
110
111/// Run a shell command and return trimmed stdout.
112///
113/// # Input
114///
115/// `cmd` — A shell command string (must be a hardcoded literal — see module docs).
116///
117/// # Output
118///
119/// `Ok(String)` — Trimmed stdout when the command succeeds (exit code 0).
120///
121/// # Errors
122///
123/// Returns [`CmdError`] if the command fails to execute or exits with a
124/// non-zero status. Callers should handle the error (log, fallback, or propagate).
125///
126/// # Safety
127///
128/// **CRITICAL:** Only use with hardcoded, trusted command strings.
129/// Never interpolate user input, file paths, or any external data into `cmd`.
130pub fn run_cmd(cmd: &str) -> std::result::Result<String, CmdError> {
131    run_cmd_result(cmd)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_run_cmd_echo() {
140        let result = run_cmd("echo hello").unwrap();
141        assert_eq!(result, "hello");
142    }
143
144    #[test]
145    fn test_run_cmd_echo_with_spaces() {
146        let result = run_cmd("echo 'hello world'").unwrap();
147        assert!(result.contains("hello world"), "Got: {}", result);
148    }
149
150    #[test]
151    fn test_run_cmd_empty_string() {
152        // Empty command should succeed with empty output
153        let result = run_cmd("").unwrap();
154        assert_eq!(result, "", "Empty command should produce empty output");
155    }
156
157    #[test]
158    fn test_run_cmd_nonexistent_command() {
159        // Command that doesn't exist — sh exits with error
160        let result = run_cmd("nonexistent_command_xyz_123");
161        assert!(result.is_err(), "Nonexistent command should return Err");
162        let err = result.unwrap_err();
163        assert!(matches!(err, CmdError::Failed { .. }));
164    }
165
166    #[test]
167    fn test_run_cmd_exit_nonzero() {
168        // `exit 1` makes sh exit with code 1
169        let result = run_cmd("exit 1");
170        assert!(result.is_err(), "Non-zero exit should return Err");
171        match result.unwrap_err() {
172            CmdError::Failed { code, stderr: _ } => assert_eq!(code, 1),
173            other => panic!("Expected CmdError::Failed, got: {:?}", other),
174        }
175    }
176
177    #[test]
178    fn test_run_cmd_returns_trimmed_output() {
179        // run_cmd trims whitespace from output
180        let result = run_cmd("echo '  spaces  '").unwrap();
181        assert_eq!(result, "spaces");
182    }
183
184    #[test]
185    fn test_cmd_error_display() {
186        let err = CmdError::NotFound("gcc".into());
187        assert!(err.to_string().contains("command not found"));
188
189        let err = CmdError::Failed {
190            code: 2,
191            stderr: "no input files".into(),
192        };
193        let msg = err.to_string();
194        assert!(msg.contains("exit code 2"));
195        assert!(msg.contains("no input files"));
196
197        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
198        let err: CmdError = io_err.into();
199        assert!(matches!(err, CmdError::Io(_)));
200        assert!(err.to_string().contains("io error"));
201    }
202
203    #[test]
204    fn test_cmd_error_debug_format() {
205        let err = CmdError::NotFound("test".into());
206        let debug = format!("{:?}", err);
207        assert!(debug.contains("NotFound"));
208
209        let err = CmdError::Failed {
210            code: 1,
211            stderr: "err".into(),
212        };
213        let debug = format!("{:?}", err);
214        assert!(debug.contains("Failed"));
215    }
216}