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}