Why shell-free execution matters

The shell is powerful, but it is also an interpreter. If you build a command like this:

let cmd = format!("convert {} output.png", user_input);

and then execute it through a shell, the input is no longer just a filename. Characters such as ;, &, |, $(), backticks, and quotes may change the meaning of the command. Even when you think you have escaped input, shell quoting is easy to get wrong across platforms.

In Rust, the safest pattern is to avoid the shell entirely unless you truly need shell features like globbing, pipes, or redirection. Most applications do not.

Common risks

RiskExampleImpact
Command injection"; rm -rf /"Arbitrary command execution
Argument confusion--output=/etc/passwdUnexpected program behavior
PATH hijackingRunning git from an untrusted directoryExecuting the wrong binary
Environment abuseMalicious PATH, LD_PRELOAD, or locale settingsAltered runtime behavior
Output misuseTreating stderr as trusted dataHidden failures or spoofed results

Prefer Command over shell strings

Rust’s std::process::Command builds an argument vector and executes the program directly. That means the executable receives exactly the arguments you specify, without shell parsing.

Unsafe pattern: shell interpolation

Avoid patterns like these:

use std::process::Command;

let user = "somefile; echo hacked";
let command = format!("ls {}", user);

// Do not do this with a shell wrapper.
Command::new("sh")
    .arg("-c")
    .arg(command)
    .status()
    .unwrap();

If user is attacker-controlled, the shell interprets the injected ; echo hacked.

Safe pattern: structured arguments

Use Command::arg or Command::args instead:

use std::process::Command;

let user = "somefile; echo hacked";

let status = Command::new("ls")
    .arg("--")
    .arg(user)
    .status()
    .expect("failed to run ls");

println!("exit status: {status}");

Here, user is passed as a single argument. The -- marker is also important for many Unix tools because it tells the program to stop parsing options. Without it, a filename like -rf might be treated as a flag.


Treat user input as data, not syntax

A secure process boundary starts with a simple rule: user input should never become part of the command syntax. It should only become an argument value, a file path that you validate, or a constrained option from a whitelist.

Good uses of user input

  • A filename passed as a single argument
  • A numeric timeout parsed with u64
  • A mode selected from an enum
  • A fixed set of subcommands chosen by your application logic

Bad uses of user input

  • Concatenating into a shell command string
  • Building flags from raw text
  • Passing unvalidated input to sh -c
  • Using user input as the executable path without verification

A useful design pattern is to map input into a typed domain first:

enum ExportFormat {
    Png,
    Webp,
    Avif,
}

impl ExportFormat {
    fn as_arg(&self) -> &'static str {
        match self {
            ExportFormat::Png => "png",
            ExportFormat::Webp => "webp",
            ExportFormat::Avif => "avif",
        }
    }
}

This prevents arbitrary strings from becoming command options.


Validate executable paths and avoid PATH surprises

When you call Command::new("tool"), the operating system may search for the binary using PATH. That is convenient, but it can be risky if the environment is untrusted or if your application runs in a directory where an attacker can place a fake executable.

Safer approaches

  • Use an absolute path to the executable when possible.
  • If you must rely on PATH, control the environment.
  • Avoid launching commands from writable directories.
  • Consider verifying the binary location during deployment.

Example with an absolute path:

use std::process::Command;

let status = Command::new("/usr/bin/convert")
    .arg("input.jpg")
    .arg("output.png")
    .status()?;

If you need to support multiple platforms or installation layouts, resolve the executable path during startup and fail closed if it is not trusted.

Environment cleanup

A process inherits environment variables by default. That can be dangerous when external tools behave differently under attacker-controlled variables.

use std::process::Command;

let status = Command::new("/usr/bin/git")
    .env_clear()
    .env("HOME", "/var/empty")
    .env("PATH", "/usr/bin:/bin")
    .arg("status")
    .status()?;

env_clear() removes inherited variables, reducing surprises from settings like PATH, IFS, LD_PRELOAD, RUST_LOG, or locale-related variables.


Use whitelists for flags and subcommands

Many command-line tools accept a large number of flags. If you let users supply raw flags, you are effectively giving them control over program behavior. That may be acceptable in a developer tool, but it is usually unsafe in a server or automation context.

Instead, expose only the options your application intends to support.

Example: controlled image conversion

use std::process::Command;

enum Quality {
    Low,
    Medium,
    High,
}

impl Quality {
    fn as_value(&self) -> &'static str {
        match self {
            Quality::Low => "60",
            Quality::Medium => "80",
            Quality::High => "95",
        }
    }
}

fn convert(input: &str, output: &str, quality: Quality) -> std::io::Result<()> {
    let status = Command::new("/usr/bin/convert")
        .arg(input)
        .arg("-quality")
        .arg(quality.as_value())
        .arg(output)
        .status()?;

    if status.success() {
        Ok(())
    } else {
        Err(std::io::Error::other("conversion failed"))
    }
}

This design prevents arbitrary flags while still giving callers a useful API.


Handle arguments, stdin, and output safely

Sometimes the safest way to pass data is not through arguments at all. Large blobs, multiline content, or untrusted text may be better sent through stdin.

When to use stdin

  • Feeding source text to a formatter
  • Passing a configuration blob to a helper process
  • Sending large data that would be awkward as an argument

Example:

use std::io::Write;
use std::process::{Command, Stdio};

let mut child = Command::new("/usr/bin/sort")
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .spawn()?;

if let Some(stdin) = child.stdin.as_mut() {
    stdin.write_all(b"banana\napple\ncarrot\n")?;
}

let output = child.wait_with_output()?;
println!("{}", String::from_utf8_lossy(&output.stdout));

This avoids shell redirection and keeps the data channel explicit.

Be careful with output handling

Do not assume stdout or stderr is trustworthy just because it came from a local tool. Treat output as untrusted data until you parse and validate it. Also, always check the exit status before using the output.

let output = Command::new("/usr/bin/id").output()?;
if !output.status.success() {
    return Err(std::io::Error::other("id failed"));
}

Avoid shell features unless absolutely necessary

Sometimes developers reach for sh -c because it seems easier than assembling a command with arguments. In security-sensitive code, that convenience is usually not worth the risk.

Shell-free alternatives

NeedSafer alternative
Run a program with argumentsCommand::new(...).arg(...)
Redirect input/outputStdio::piped() or file handles
Chain multiple stepsSpawn separate processes in Rust
Expand glob patternsUse Rust filesystem APIs to enumerate files
Pipe output between toolsRead output and pass it to the next process explicitly

If you truly need shell behavior, isolate it behind a narrow, audited interface and never pass raw user input into the shell command string. Even then, prefer to replace shell usage with explicit Rust code whenever possible.


Design a secure process-launch API

If your crate or service exposes process execution to other developers, design the API so the safe path is the easy path.

Recommended API principles

  1. Accept typed parameters, not raw command strings
  2. Use enums for allowed flags and modes
  3. Require absolute paths for executables when practical
  4. Clear or constrain inherited environment variables
  5. Return structured errors
  6. Log only sanitized metadata, not full command lines with secrets

A small wrapper can enforce these rules:

use std::process::{Command, Output};

pub struct ToolRunner {
    exe: String,
}

impl ToolRunner {
    pub fn new(exe: impl Into<String>) -> Self {
        Self { exe: exe.into() }
    }

    pub fn run(&self, input: &str) -> std::io::Result<Output> {
        Command::new(&self.exe)
            .env_clear()
            .arg("--")
            .arg(input)
            .output()
    }
}

This example is intentionally simple, but the pattern scales well: keep the unsafe surface area small and explicit.


Test for injection resistance

Security bugs in process execution often hide in edge cases. Add tests that include shell metacharacters, leading dashes, spaces, Unicode, and empty strings.

Useful test cases

  • "; touch /tmp/pwned"
  • "--help"
  • "file name with spaces.txt"
  • "$(id)"
  • `"whoami"`
  • "-rf"
  • "C:\\Program Files\\app\\input.txt"

Your test should verify that these values are treated as literal arguments, not syntax. If your code must reject some values, validate them before launching the process and return a clear error.

Example validation

fn validate_filename(name: &str) -> Result<(), &'static str> {
    if name.is_empty() {
        return Err("filename cannot be empty");
    }
    if name.contains('\0') {
        return Err("filename contains NUL");
    }
    Ok(())
}

Validation should be based on your application’s requirements, not on a vague attempt to “sanitize” everything.


Practical checklist

Before launching an external process in Rust, verify the following:

  • You are using Command, not a shell string
  • Every user-controlled value is passed as data, not syntax
  • The executable path is trusted
  • PATH and other inherited environment variables are controlled
  • Flags and subcommands are whitelisted
  • -- is used where appropriate to stop option parsing
  • Output and exit codes are checked
  • Tests cover malicious and edge-case inputs

Conclusion

Safe process execution in Rust is mostly about discipline: avoid shells, keep input structured, and reduce the amount of ambient trust your child processes inherit. std::process::Command gives you the right primitives, but security depends on how you compose them.

If you treat external commands as a narrow, typed interface rather than a string-building exercise, you can integrate powerful CLI tools without opening the door to command injection or environment-based abuse.

Learn more with useful resources