use std::collections::HashSet;
const MAX_DEPTH: usize = 8;
const MAX_COMMANDS: usize = 256;
const MAX_HEAD_SCAN: usize = 8;
const PASSTHROUGH_WRAPPERS: &[&str] = &[
"sudo", "doas", "env", "nohup", "nice", "ionice", "time", "timeout", "stdbuf", "setsid",
"command", "builtin", "exec", "xargs", "unbuffer", "busybox", "chroot", "proot",
];
const SHELL_NAMES: &[&str] = &[
"sh", "bash", "zsh", "dash", "ksh", "ksh93", "mksh", "ash", "fish", "csh", "tcsh", "rbash",
"yash",
];
pub fn expanded_commands(command: &str) -> Vec<String> {
let mut expander = Expander {
out: Vec::new(),
seen: HashSet::new(),
};
let trimmed = command.trim();
if !trimmed.is_empty() {
expander.seen.insert(trimmed.to_string());
expander.out.push(trimmed.to_string());
}
expander.expand(command, 0);
expander.out
}
struct Expander {
out: Vec<String>,
seen: HashSet<String>,
}
impl Expander {
fn emit(&mut self, tokens: &[String]) {
if self.out.len() >= MAX_COMMANDS {
return;
}
let joined = tokens
.iter()
.filter(|token| !token.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" ");
if joined.is_empty() {
return;
}
if self.seen.insert(joined.clone()) {
self.out.push(joined);
}
}
fn expand(&mut self, input: &str, depth: usize) {
if depth > MAX_DEPTH || self.out.len() >= MAX_COMMANDS {
return;
}
let chars: Vec<char> = input.chars().collect();
let n = chars.len();
let mut i = 0usize;
let mut commands: Vec<Vec<String>> = Vec::new();
let mut words: Vec<String> = Vec::new();
let mut word = String::new();
let mut started = false;
let mut nested: Vec<String> = Vec::new();
while i < n {
let c = chars[i];
match c {
'\\' => {
if i + 1 < n {
if chars[i + 1] != '\n' {
word.push(chars[i + 1]);
started = true;
}
i += 2;
} else {
i += 1;
}
}
'\'' => {
started = true;
i += 1;
while i < n && chars[i] != '\'' {
word.push(chars[i]);
i += 1;
}
i = (i + 1).min(n);
}
'"' => {
started = true;
i += 1;
while i < n && chars[i] != '"' {
match chars[i] {
'\\' if i + 1 < n => {
word.push(chars[i + 1]);
i += 2;
}
'`' => {
let (inner, next) = read_backtick(&chars, i);
nested.push(inner);
i = next;
}
'$' if i + 1 < n && chars[i + 1] == '(' => {
let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
nested.push(inner);
i = next;
}
'$' if i + 1 < n && chars[i + 1] == '{' => {
let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
nested.push(inner);
i = next;
}
ch => {
word.push(ch);
i += 1;
}
}
}
i = (i + 1).min(n);
}
'$' if i + 1 < n && chars[i + 1] == '\'' => {
started = true;
i += 2;
while i < n && chars[i] != '\'' {
if chars[i] == '\\' && i + 1 < n {
word.push(chars[i + 1]);
i += 2;
} else {
word.push(chars[i]);
i += 1;
}
}
i = (i + 1).min(n);
}
'`' => {
let (inner, next) = read_backtick(&chars, i);
nested.push(inner);
i = next;
}
'$' if i + 1 < n && chars[i + 1] == '(' => {
let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
nested.push(inner);
i = next;
}
'$' if i + 1 < n && chars[i + 1] == '{' => {
let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
nested.push(inner);
i = next;
}
'<' | '>' if i + 1 < n && chars[i + 1] == '(' => {
let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
nested.push(inner);
i = next;
}
' ' | '\t' => {
flush_word(&mut words, &mut word, &mut started);
i += 1;
}
'(' | ')' => {
flush_word(&mut words, &mut word, &mut started);
end_command(&mut commands, &mut words);
i += 1;
}
'\n' | '\r' | ';' | '&' | '|' => {
flush_word(&mut words, &mut word, &mut started);
end_command(&mut commands, &mut words);
i += 1;
while i < n && matches!(chars[i], '\n' | '\r' | ';' | '&' | '|') {
i += 1;
}
}
_ => {
word.push(c);
started = true;
i += 1;
}
}
}
flush_word(&mut words, &mut word, &mut started);
end_command(&mut commands, &mut words);
for tokens in &commands {
self.record(tokens, depth);
}
for inner in nested {
self.expand(&inner, depth + 1);
}
}
fn record(&mut self, tokens: &[String], depth: usize) {
if tokens.is_empty() {
return;
}
self.emit(tokens);
let stripped = strip_leading_wrappers(tokens);
if stripped.len() != tokens.len() {
self.emit(stripped);
}
if let Some(head) = find_wrapper_head(tokens) {
let name = basename(&tokens[head]).to_ascii_lowercase();
if name == "eval" {
let payload = tokens[head + 1..].join(" ");
self.expand(&payload, depth + 1);
} else if let Some(script) = shell_c_argument(&tokens[head..]) {
self.expand(script, depth + 1);
}
}
}
}
fn flush_word(words: &mut Vec<String>, word: &mut String, started: &mut bool) {
if *started || !word.is_empty() {
words.push(std::mem::take(word));
*started = false;
}
}
fn end_command(commands: &mut Vec<Vec<String>>, words: &mut Vec<String>) {
words.retain(|word| !matches!(word.as_str(), "{" | "}"));
if !words.is_empty() {
commands.push(std::mem::take(words));
}
}
fn read_backtick(chars: &[char], start: usize) -> (String, usize) {
let mut i = start + 1;
let mut inner = String::new();
while i < chars.len() {
match chars[i] {
'\\' if i + 1 < chars.len() => {
inner.push(chars[i]);
inner.push(chars[i + 1]);
i += 2;
}
'`' => return (inner, i + 1),
c => {
inner.push(c);
i += 1;
}
}
}
(inner, i)
}
fn read_delimited(chars: &[char], open_at: usize, open: char, close: char) -> (String, usize) {
let mut depth = 1usize;
let mut i = open_at + 1;
let mut inner = String::new();
while i < chars.len() {
let c = chars[i];
if c == '\\' && i + 1 < chars.len() {
inner.push(c);
inner.push(chars[i + 1]);
i += 2;
continue;
}
if c == open {
depth += 1;
} else if c == close {
depth -= 1;
if depth == 0 {
return (inner, i + 1);
}
}
inner.push(c);
i += 1;
}
(inner, i)
}
fn basename(token: &str) -> &str {
token
.rsplit(['/', '\\'])
.next()
.filter(|part| !part.is_empty())
.unwrap_or(token)
}
fn is_env_assignment(token: &str) -> bool {
match token.split_once('=') {
Some((name, _)) => {
!name.is_empty()
&& !name.starts_with('-')
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
None => false,
}
}
fn is_scalar_operand(token: &str) -> bool {
let body = token.trim_end_matches(['s', 'm', 'h', 'd']);
!body.is_empty() && body.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
}
fn is_passthrough_wrapper(token: &str) -> bool {
let name = basename(token).to_ascii_lowercase();
PASSTHROUGH_WRAPPERS.contains(&name.as_str())
}
fn is_shell_name(token: &str) -> bool {
let name = basename(token).to_ascii_lowercase();
SHELL_NAMES.contains(&name.as_str())
}
fn strip_leading_wrappers(tokens: &[String]) -> &[String] {
let mut start = 0usize;
let mut dropped_wrapper = false;
while start < tokens.len() {
let token = &tokens[start];
if is_env_assignment(token) {
start += 1;
} else if is_passthrough_wrapper(token) {
dropped_wrapper = true;
start += 1;
} else if dropped_wrapper && is_scalar_operand(token) {
start += 1;
} else {
break;
}
}
&tokens[start..]
}
fn find_wrapper_head(tokens: &[String]) -> Option<usize> {
let mut previous_was_short_flag = false;
for (index, token) in tokens.iter().enumerate().take(MAX_HEAD_SCAN) {
if is_shell_name(token) || basename(token).eq_ignore_ascii_case("eval") {
return Some(index);
}
let skippable = is_env_assignment(token)
|| is_passthrough_wrapper(token)
|| token.starts_with('-')
|| is_scalar_operand(token)
|| previous_was_short_flag;
if !skippable {
return None;
}
previous_was_short_flag = token.starts_with('-') && !token.starts_with("--");
}
None
}
fn shell_c_argument(tokens: &[String]) -> Option<&str> {
let mut index = 1usize;
while index < tokens.len() {
let token = tokens[index].as_str();
let takes_command_line = match token.strip_prefix("--") {
Some(long) => long.eq_ignore_ascii_case("command"),
None => token
.strip_prefix('-')
.is_some_and(|flags| flags.contains('c')),
};
if takes_command_line {
return tokens.get(index + 1).map(String::as_str);
}
index += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn expand(command: &str) -> Vec<String> {
expanded_commands(command)
}
fn contains(command: &str, expected: &str) -> bool {
expand(command).iter().any(|target| target == expected)
}
#[test]
fn backtick_body_is_a_command() {
assert!(contains("`rm -rf /`", "rm -rf /"));
assert!(contains("echo `rm -rf /`", "rm -rf /"));
assert!(contains("echo `rm -rf /`", "echo"));
}
#[test]
fn dollar_paren_body_is_a_command() {
assert!(contains("echo $(rm -rf /)", "rm -rf /"));
assert!(contains("x=$(rm -rf /)", "rm -rf /"));
assert!(contains("echo \"$(rm -rf /)\"", "rm -rf /"));
}
#[test]
fn nested_substitution_is_followed() {
assert!(contains("echo $(echo `rm -rf /`)", "rm -rf /"));
}
#[test]
fn quotes_are_removed_from_operands() {
assert!(contains("rm -rf \"/\"", "rm -rf /"));
assert!(contains("rm -rf '/'", "rm -rf /"));
assert!(contains("\"rm\" -rf /", "rm -rf /"));
assert!(contains("rm -r\"f\" /", "rm -rf /"));
}
#[test]
fn single_quoted_text_is_not_a_command() {
let targets = expand("echo '`rm -rf /`'");
assert!(
!targets.iter().any(|t| t == "rm -rf /"),
"single-quoted text must not become a command: {targets:?}"
);
}
#[test]
fn escaped_operators_do_not_split() {
let targets = expand("echo a\\;b");
assert_eq!(targets.len(), 2, "{targets:?}");
assert!(targets.contains(&"echo a;b".to_string()), "{targets:?}");
}
#[test]
fn control_operators_split_commands() {
for command in [
"ls && rm -rf /",
"ls || rm -rf /",
"ls ; rm -rf /",
"ls | rm -rf /",
"ls & rm -rf /",
"ls\nrm -rf /",
] {
assert!(contains(command, "rm -rf /"), "{command}");
}
}
#[test]
fn wrappers_and_payloads_are_unwrapped() {
for command in [
"sudo rm -rf /",
"env rm -rf /",
"timeout 5 rm -rf /",
"nohup rm -rf /",
"xargs rm -rf /",
"/usr/bin/sudo rm -rf /",
"eval 'rm -rf /'",
"bash -c 'rm -rf /'",
"sh -lc \"rm -rf /\"",
"sudo -u root bash -c 'rm -rf /'",
"bash -o vi -c 'rm -rf /'",
"zsh --norcs -c 'rm -rf /'",
] {
assert!(
contains(command, "rm -rf /"),
"{command}: {:?}",
expand(command)
);
}
}
#[test]
fn wrapper_head_scan_stops_at_a_real_command() {
let targets = expand("echo bash -c 'rm -rf /'");
assert!(
!targets.iter().any(|t| t == "rm -rf /"),
"arguments of a printing command must not be parsed as code: {targets:?}"
);
}
#[test]
fn process_and_parameter_substitution_bodies_are_commands() {
assert!(contains("diff <(rm -rf /) b", "rm -rf /"));
assert!(contains("echo ${x:-$(rm -rf /)}", "rm -rf /"));
}
#[test]
fn expansion_is_bounded() {
let deep = "$(".repeat(64) + "rm -rf /" + &")".repeat(64);
let targets = expand(&deep);
assert!(targets.len() <= MAX_COMMANDS);
}
#[test]
fn grouping_is_a_command_boundary() {
assert!(contains("(rm -rf /)", "rm -rf /"));
assert!(contains("{ rm -rf /; }", "rm -rf /"));
assert!(contains("(cd /tmp && rm -rf /)", "rm -rf /"));
assert!(contains(
"find . \\( -name a \\) -print",
"find . ( -name a ) -print"
));
}
#[test]
fn plain_command_expands_to_itself() {
assert_eq!(expand("git status -s"), vec!["git status -s".to_string()]);
}
}