Skip to main content

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}