ironflow_core/operations/shell.rs
1//! Shell operation - run system commands with timeout and environment control.
2//!
3//! The [`Shell`] builder spawns a command via `sh -c`, captures stdout/stderr,
4//! and returns a [`ShellOutput`] on success. It implements [`IntoFuture`] so you
5//! can `await` it directly without calling `.run()`:
6//!
7//! ```no_run
8//! use ironflow_core::operations::shell::Shell;
9//!
10//! # async fn example() -> Result<(), ironflow_core::error::OperationError> {
11//! // These two are equivalent:
12//! let output = Shell::new("echo hello").await?;
13//! let output = Shell::new("echo hello").run().await?;
14//! # Ok(())
15//! # }
16//! ```
17//!
18//! For safe execution without shell interpretation, use [`Shell::exec`]:
19//!
20//! ```no_run
21//! use ironflow_core::operations::shell::Shell;
22//!
23//! # async fn example() -> Result<(), ironflow_core::error::OperationError> {
24//! let output = Shell::exec("echo", &["hello", "world"]).await?;
25//! # Ok(())
26//! # }
27//! ```
28
29use std::fmt;
30use std::future::{Future, IntoFuture};
31use std::pin::Pin;
32use std::process::Stdio;
33use std::time::{Duration, Instant};
34use tokio::process::Command;
35use tracing::{debug, error, warn};
36
37use crate::error::OperationError;
38#[cfg(feature = "prometheus")]
39use crate::metric_names;
40use crate::utils::truncate_output;
41
42/// How the command is executed.
43enum ShellMode {
44 /// Pass the command string to `sh -c`.
45 Shell(String),
46 /// Execute the program directly with explicit arguments, bypassing shell
47 /// interpretation.
48 Exec { program: String, args: Vec<String> },
49}
50
51impl fmt::Display for ShellMode {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 match self {
54 Self::Shell(cmd) => f.write_str(cmd),
55 Self::Exec { program, args } => {
56 write!(f, "{program}")?;
57 for arg in args {
58 write!(f, " {arg}")?;
59 }
60 Ok(())
61 }
62 }
63 }
64}
65
66/// Builder for executing a shell command.
67///
68/// Supports optional timeout, working directory, environment variables, and
69/// clean-environment mode. Output is truncated to [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE)
70/// to prevent OOM on large outputs.
71///
72/// # Security
73///
74/// Commands created with [`Shell::new`] are executed via `sh -c`, which means
75/// shell metacharacters (`;`, `|`, `$()`, `` ` ``, etc.) are interpreted.
76/// **Do not** incorporate untrusted input into command strings without proper
77/// validation. Use [`Shell::env`] to pass dynamic data safely through
78/// environment variables, or use [`Shell::exec`] to bypass shell interpretation
79/// entirely.
80///
81/// # Examples
82///
83/// ```no_run
84/// use std::time::Duration;
85/// use ironflow_core::operations::shell::Shell;
86///
87/// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
88/// let output = Shell::new("cargo test")
89/// .dir("/path/to/project")
90/// .timeout(Duration::from_secs(120))
91/// .env("RUST_LOG", "debug")
92/// .await?;
93///
94/// println!("stdout: {}", output.stdout());
95/// # Ok(())
96/// # }
97/// ```
98/// Default timeout for shell commands (5 minutes).
99const DEFAULT_SHELL_TIMEOUT: Duration = Duration::from_secs(300);
100
101#[must_use = "a Shell command does nothing until .run() or .await is called"]
102pub struct Shell {
103 mode: ShellMode,
104 timeout: Duration,
105 dir: Option<String>,
106 env_vars: Vec<(String, String)>,
107 inherit_env: bool,
108 dry_run: Option<bool>,
109}
110
111impl Shell {
112 /// Create a new shell builder for the given command string.
113 ///
114 /// The command is passed to `sh -c`, so pipes, redirects, and other shell
115 /// features work as expected.
116 ///
117 /// # Security
118 ///
119 /// **Never** interpolate untrusted input directly into the command string.
120 /// Doing so creates a **command injection** vulnerability:
121 ///
122 /// ```no_run
123 /// # use ironflow_core::operations::shell::Shell;
124 /// // DANGEROUS - attacker controls `user_input`
125 /// # let user_input = "safe";
126 /// let _ = Shell::new(&format!("cat {user_input}"));
127 ///
128 /// // SAFE - use arguments via a wrapper script or validate input first
129 /// let _ = Shell::new("cat -- ./known_safe_file.txt");
130 /// ```
131 ///
132 /// If you need to pass dynamic values, either validate them rigorously
133 /// or use [`Shell::env`] to pass data through environment variables
134 /// (which are not interpreted by the shell).
135 pub fn new(command: &str) -> Self {
136 Self {
137 mode: ShellMode::Shell(command.to_string()),
138 timeout: DEFAULT_SHELL_TIMEOUT,
139 dir: None,
140 env_vars: Vec::new(),
141 inherit_env: true,
142 dry_run: None,
143 }
144 }
145
146 /// Create a new builder that executes a program directly without shell
147 /// interpretation.
148 ///
149 /// Unlike [`Shell::new`], this does **not** pass the command through
150 /// `sh -c`. The `program` is invoked directly with the given `args`,
151 /// so shell metacharacters in arguments are treated as literal text.
152 /// This is the preferred way to run commands with untrusted arguments.
153 ///
154 /// # Examples
155 ///
156 /// ```no_run
157 /// use ironflow_core::operations::shell::Shell;
158 ///
159 /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
160 /// let output = Shell::exec("git", &["log", "--oneline", "-5"]).await?;
161 /// println!("{}", output.stdout());
162 /// # Ok(())
163 /// # }
164 /// ```
165 pub fn exec(program: &str, args: &[&str]) -> Self {
166 Self {
167 mode: ShellMode::Exec {
168 program: program.to_string(),
169 args: args.iter().map(|a| (*a).to_string()).collect(),
170 },
171 timeout: DEFAULT_SHELL_TIMEOUT,
172 dir: None,
173 env_vars: Vec::new(),
174 inherit_env: true,
175 dry_run: None,
176 }
177 }
178
179 /// Override the maximum duration for the command.
180 ///
181 /// If the command does not complete within this duration, it is killed and
182 /// an [`OperationError::Timeout`] is returned. Defaults to 5 minutes.
183 pub fn timeout(mut self, timeout: Duration) -> Self {
184 self.timeout = timeout;
185 self
186 }
187
188 /// Set the working directory for the spawned process.
189 pub fn dir(mut self, dir: &str) -> Self {
190 self.dir = Some(dir.to_string());
191 self
192 }
193
194 /// Add an environment variable to the spawned process.
195 ///
196 /// Can be called multiple times to set several variables.
197 pub fn env(mut self, key: &str, value: &str) -> Self {
198 self.env_vars.push((key.to_string(), value.to_string()));
199 self
200 }
201
202 /// Clear the inherited environment so the process starts with an empty
203 /// environment (plus any variables added via [`env`](Shell::env)).
204 pub fn clean_env(mut self) -> Self {
205 self.inherit_env = false;
206 self
207 }
208
209 /// Enable or disable dry-run mode for this specific operation.
210 ///
211 /// When dry-run is active, the command is logged but not executed.
212 /// A synthetic [`ShellOutput`] is returned with empty stdout/stderr,
213 /// exit code 0, and 0ms duration.
214 ///
215 /// If not set, falls back to the global dry-run setting
216 /// (see [`set_dry_run`](crate::dry_run::set_dry_run)).
217 pub fn dry_run(mut self, enabled: bool) -> Self {
218 self.dry_run = Some(enabled);
219 self
220 }
221
222 /// Execute the command and wait for it to complete.
223 ///
224 /// # Errors
225 ///
226 /// * [`OperationError::Shell`] - if the command exits with a non-zero code
227 /// or cannot be spawned.
228 /// * [`OperationError::Timeout`] - if the command exceeds the configured
229 /// [`timeout`](Shell::timeout).
230 #[tracing::instrument(name = "shell", skip_all, fields(command = %self.mode))]
231 pub async fn run(self) -> Result<ShellOutput, OperationError> {
232 let command_display = self.mode.to_string();
233
234 if crate::dry_run::effective_dry_run(self.dry_run) {
235 debug!(command = %command_display, "[dry-run] shell command skipped");
236 return Ok(ShellOutput {
237 stdout: String::new(),
238 stderr: String::new(),
239 exit_code: 0,
240 duration_ms: 0,
241 });
242 }
243
244 debug!(command = %command_display, "executing shell command");
245
246 let start = Instant::now();
247
248 let mut cmd = match &self.mode {
249 ShellMode::Shell(command) => {
250 let mut c = Command::new("sh");
251 c.arg("-c").arg(command);
252 c
253 }
254 ShellMode::Exec { program, args } => {
255 let mut c = Command::new(program);
256 c.args(args);
257 c
258 }
259 };
260
261 cmd.stdout(Stdio::piped())
262 .stderr(Stdio::piped())
263 .kill_on_drop(true);
264
265 if !self.inherit_env {
266 cmd.env_clear();
267 }
268
269 if let Some(ref dir) = self.dir {
270 cmd.current_dir(dir);
271 }
272
273 for (key, value) in &self.env_vars {
274 cmd.env(key, value);
275 }
276
277 let child = cmd.spawn().map_err(|e| OperationError::Shell {
278 exit_code: -1,
279 stderr: format!("failed to spawn shell: {e}"),
280 })?;
281
282 let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await {
283 Ok(result) => result.map_err(|e| OperationError::Shell {
284 exit_code: -1,
285 stderr: format!("failed to wait for shell: {e}"),
286 })?,
287 Err(_) => {
288 return Err(OperationError::Timeout {
289 step: command_display,
290 limit: self.timeout,
291 });
292 }
293 };
294
295 let duration_ms = start.elapsed().as_millis() as u64;
296 let stdout = truncate_output(&output.stdout, "shell stdout");
297 let stderr = truncate_output(&output.stderr, "shell stderr");
298
299 let exit_code = output.status.code().unwrap_or_else(|| {
300 #[cfg(unix)]
301 {
302 use std::os::unix::process::ExitStatusExt;
303 if let Some(signal) = output.status.signal() {
304 warn!(signal, "process killed by signal");
305 return -signal;
306 }
307 }
308 -1
309 });
310
311 #[cfg(feature = "prometheus")]
312 metrics::histogram!(metric_names::SHELL_DURATION_SECONDS)
313 .record(duration_ms as f64 / 1000.0);
314
315 if !output.status.success() {
316 error!(exit_code, stderr = %stderr, "shell command failed");
317 #[cfg(feature = "prometheus")]
318 metrics::counter!(metric_names::SHELL_TOTAL, "status" => metric_names::STATUS_ERROR)
319 .increment(1);
320 return Err(OperationError::Shell { exit_code, stderr });
321 }
322
323 debug!(
324 exit_code,
325 stdout_len = stdout.len(),
326 duration_ms,
327 "shell command completed"
328 );
329
330 #[cfg(feature = "prometheus")]
331 metrics::counter!(metric_names::SHELL_TOTAL, "status" => metric_names::STATUS_SUCCESS)
332 .increment(1);
333
334 Ok(ShellOutput {
335 stdout,
336 stderr,
337 exit_code,
338 duration_ms,
339 })
340 }
341}
342
343impl IntoFuture for Shell {
344 type Output = Result<ShellOutput, OperationError>;
345 type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
346
347 fn into_future(self) -> Self::IntoFuture {
348 Box::pin(self.run())
349 }
350}
351
352/// Output of a successful shell command execution.
353///
354/// Contains the captured stdout, stderr, exit code, and duration.
355#[derive(Debug)]
356pub struct ShellOutput {
357 stdout: String,
358 stderr: String,
359 exit_code: i32,
360 duration_ms: u64,
361}
362
363impl ShellOutput {
364 /// Return the captured standard output, trimmed and truncated to
365 /// [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE).
366 pub fn stdout(&self) -> &str {
367 &self.stdout
368 }
369
370 /// Return the captured standard error, trimmed and truncated to
371 /// [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE).
372 pub fn stderr(&self) -> &str {
373 &self.stderr
374 }
375
376 /// Return the process exit code (`0` on success).
377 pub fn exit_code(&self) -> i32 {
378 self.exit_code
379 }
380
381 /// Return the wall-clock duration of the command in milliseconds.
382 pub fn duration_ms(&self) -> u64 {
383 self.duration_ms
384 }
385}