1use std::collections::HashSet;
29
30const MAX_DEPTH: usize = 8;
32
33const MAX_COMMANDS: usize = 256;
36
37const MAX_HEAD_SCAN: usize = 8;
40
41const PASSTHROUGH_WRAPPERS: &[&str] = &[
45 "sudo", "doas", "env", "nohup", "nice", "ionice", "time", "timeout", "stdbuf", "setsid",
46 "command", "builtin", "exec", "xargs", "unbuffer", "busybox", "chroot", "proot",
47];
48
49const SHELL_NAMES: &[&str] = &[
51 "sh", "bash", "zsh", "dash", "ksh", "ksh93", "mksh", "ash", "fish", "csh", "tcsh", "rbash",
52 "yash",
53];
54
55pub fn expanded_commands(command: &str) -> Vec<String> {
63 let mut expander = Expander {
64 out: Vec::new(),
65 seen: HashSet::new(),
66 };
67 let trimmed = command.trim();
68 if !trimmed.is_empty() {
69 expander.seen.insert(trimmed.to_string());
70 expander.out.push(trimmed.to_string());
71 }
72 expander.expand(command, 0);
73 expander.out
74}
75
76struct Expander {
77 out: Vec<String>,
78 seen: HashSet<String>,
79}
80
81impl Expander {
82 fn emit(&mut self, tokens: &[String]) {
83 if self.out.len() >= MAX_COMMANDS {
84 return;
85 }
86 let joined = tokens
87 .iter()
88 .filter(|token| !token.is_empty())
89 .cloned()
90 .collect::<Vec<_>>()
91 .join(" ");
92 if joined.is_empty() {
93 return;
94 }
95 if self.seen.insert(joined.clone()) {
96 self.out.push(joined);
97 }
98 }
99
100 fn expand(&mut self, input: &str, depth: usize) {
103 if depth > MAX_DEPTH || self.out.len() >= MAX_COMMANDS {
104 return;
105 }
106 let chars: Vec<char> = input.chars().collect();
107 let n = chars.len();
108 let mut i = 0usize;
109 let mut commands: Vec<Vec<String>> = Vec::new();
110 let mut words: Vec<String> = Vec::new();
111 let mut word = String::new();
112 let mut started = false;
113 let mut nested: Vec<String> = Vec::new();
114
115 while i < n {
116 let c = chars[i];
117 match c {
118 '\\' => {
122 if i + 1 < n {
123 if chars[i + 1] != '\n' {
124 word.push(chars[i + 1]);
125 started = true;
126 }
127 i += 2;
128 } else {
129 i += 1;
130 }
131 }
132 '\'' => {
134 started = true;
135 i += 1;
136 while i < n && chars[i] != '\'' {
137 word.push(chars[i]);
138 i += 1;
139 }
140 i = (i + 1).min(n);
141 }
142 '"' => {
144 started = true;
145 i += 1;
146 while i < n && chars[i] != '"' {
147 match chars[i] {
148 '\\' if i + 1 < n => {
149 word.push(chars[i + 1]);
150 i += 2;
151 }
152 '`' => {
153 let (inner, next) = read_backtick(&chars, i);
154 nested.push(inner);
155 i = next;
156 }
157 '$' if i + 1 < n && chars[i + 1] == '(' => {
158 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
159 nested.push(inner);
160 i = next;
161 }
162 '$' if i + 1 < n && chars[i + 1] == '{' => {
163 let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
164 nested.push(inner);
165 i = next;
166 }
167 ch => {
168 word.push(ch);
169 i += 1;
170 }
171 }
172 }
173 i = (i + 1).min(n);
174 }
175 '$' if i + 1 < n && chars[i + 1] == '\'' => {
177 started = true;
178 i += 2;
179 while i < n && chars[i] != '\'' {
180 if chars[i] == '\\' && i + 1 < n {
181 word.push(chars[i + 1]);
182 i += 2;
183 } else {
184 word.push(chars[i]);
185 i += 1;
186 }
187 }
188 i = (i + 1).min(n);
189 }
190 '`' => {
194 let (inner, next) = read_backtick(&chars, i);
195 nested.push(inner);
196 i = next;
197 }
198 '$' if i + 1 < n && chars[i + 1] == '(' => {
199 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
200 nested.push(inner);
201 i = next;
202 }
203 '$' if i + 1 < n && chars[i + 1] == '{' => {
206 let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
207 nested.push(inner);
208 i = next;
209 }
210 '<' | '>' if i + 1 < n && chars[i + 1] == '(' => {
212 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
213 nested.push(inner);
214 i = next;
215 }
216 ' ' | '\t' => {
217 flush_word(&mut words, &mut word, &mut started);
218 i += 1;
219 }
220 '(' | ')' => {
224 flush_word(&mut words, &mut word, &mut started);
225 end_command(&mut commands, &mut words);
226 i += 1;
227 }
228 '\n' | '\r' | ';' | '&' | '|' => {
231 flush_word(&mut words, &mut word, &mut started);
232 end_command(&mut commands, &mut words);
233 i += 1;
234 while i < n && matches!(chars[i], '\n' | '\r' | ';' | '&' | '|') {
235 i += 1;
236 }
237 }
238 _ => {
239 word.push(c);
240 started = true;
241 i += 1;
242 }
243 }
244 }
245 flush_word(&mut words, &mut word, &mut started);
246 end_command(&mut commands, &mut words);
247
248 for tokens in &commands {
249 self.record(tokens, depth);
250 }
251 for inner in nested {
252 self.expand(&inner, depth + 1);
253 }
254 }
255
256 fn record(&mut self, tokens: &[String], depth: usize) {
259 if tokens.is_empty() {
260 return;
261 }
262 self.emit(tokens);
263
264 let stripped = strip_leading_wrappers(tokens);
267 if stripped.len() != tokens.len() {
268 self.emit(stripped);
269 }
270
271 if let Some(head) = find_wrapper_head(tokens) {
273 let name = basename(&tokens[head]).to_ascii_lowercase();
274 if name == "eval" {
275 let payload = tokens[head + 1..].join(" ");
276 self.expand(&payload, depth + 1);
277 } else if let Some(script) = shell_c_argument(&tokens[head..]) {
278 self.expand(script, depth + 1);
279 }
280 }
281 }
282}
283
284fn flush_word(words: &mut Vec<String>, word: &mut String, started: &mut bool) {
285 if *started || !word.is_empty() {
286 words.push(std::mem::take(word));
287 *started = false;
288 }
289}
290
291fn end_command(commands: &mut Vec<Vec<String>>, words: &mut Vec<String>) {
292 words.retain(|word| !matches!(word.as_str(), "{" | "}"));
297 if !words.is_empty() {
298 commands.push(std::mem::take(words));
299 }
300}
301
302fn read_backtick(chars: &[char], start: usize) -> (String, usize) {
305 let mut i = start + 1;
306 let mut inner = String::new();
307 while i < chars.len() {
308 match chars[i] {
309 '\\' if i + 1 < chars.len() => {
310 inner.push(chars[i]);
311 inner.push(chars[i + 1]);
312 i += 2;
313 }
314 '`' => return (inner, i + 1),
315 c => {
316 inner.push(c);
317 i += 1;
318 }
319 }
320 }
321 (inner, i)
322}
323
324fn read_delimited(chars: &[char], open_at: usize, open: char, close: char) -> (String, usize) {
327 let mut depth = 1usize;
328 let mut i = open_at + 1;
329 let mut inner = String::new();
330 while i < chars.len() {
331 let c = chars[i];
332 if c == '\\' && i + 1 < chars.len() {
333 inner.push(c);
334 inner.push(chars[i + 1]);
335 i += 2;
336 continue;
337 }
338 if c == open {
339 depth += 1;
340 } else if c == close {
341 depth -= 1;
342 if depth == 0 {
343 return (inner, i + 1);
344 }
345 }
346 inner.push(c);
347 i += 1;
348 }
349 (inner, i)
350}
351
352fn basename(token: &str) -> &str {
354 token
355 .rsplit(['/', '\\'])
356 .next()
357 .filter(|part| !part.is_empty())
358 .unwrap_or(token)
359}
360
361fn is_env_assignment(token: &str) -> bool {
362 match token.split_once('=') {
363 Some((name, _)) => {
364 !name.is_empty()
365 && !name.starts_with('-')
366 && name
367 .chars()
368 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
369 }
370 None => false,
371 }
372}
373
374fn is_scalar_operand(token: &str) -> bool {
377 let body = token.trim_end_matches(['s', 'm', 'h', 'd']);
378 !body.is_empty() && body.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
379}
380
381fn is_passthrough_wrapper(token: &str) -> bool {
382 let name = basename(token).to_ascii_lowercase();
383 PASSTHROUGH_WRAPPERS.contains(&name.as_str())
384}
385
386fn is_shell_name(token: &str) -> bool {
387 let name = basename(token).to_ascii_lowercase();
388 SHELL_NAMES.contains(&name.as_str())
389}
390
391fn strip_leading_wrappers(tokens: &[String]) -> &[String] {
398 let mut start = 0usize;
399 let mut dropped_wrapper = false;
400 while start < tokens.len() {
401 let token = &tokens[start];
402 if is_env_assignment(token) {
403 start += 1;
404 } else if is_passthrough_wrapper(token) {
405 dropped_wrapper = true;
406 start += 1;
407 } else if dropped_wrapper && is_scalar_operand(token) {
408 start += 1;
409 } else {
410 break;
411 }
412 }
413 &tokens[start..]
414}
415
416fn find_wrapper_head(tokens: &[String]) -> Option<usize> {
426 let mut previous_was_short_flag = false;
427 for (index, token) in tokens.iter().enumerate().take(MAX_HEAD_SCAN) {
428 if is_shell_name(token) || basename(token).eq_ignore_ascii_case("eval") {
429 return Some(index);
430 }
431 let skippable = is_env_assignment(token)
432 || is_passthrough_wrapper(token)
433 || token.starts_with('-')
434 || is_scalar_operand(token)
435 || previous_was_short_flag;
436 if !skippable {
437 return None;
438 }
439 previous_was_short_flag = token.starts_with('-') && !token.starts_with("--");
440 }
441 None
442}
443
444fn shell_c_argument(tokens: &[String]) -> Option<&str> {
455 let mut index = 1usize;
456 while index < tokens.len() {
457 let token = tokens[index].as_str();
458 let takes_command_line = match token.strip_prefix("--") {
459 Some(long) => long.eq_ignore_ascii_case("command"),
460 None => token
461 .strip_prefix('-')
462 .is_some_and(|flags| flags.contains('c')),
463 };
464 if takes_command_line {
465 return tokens.get(index + 1).map(String::as_str);
466 }
467 index += 1;
468 }
469 None
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 fn expand(command: &str) -> Vec<String> {
477 expanded_commands(command)
478 }
479
480 fn contains(command: &str, expected: &str) -> bool {
481 expand(command).iter().any(|target| target == expected)
482 }
483
484 #[test]
485 fn backtick_body_is_a_command() {
486 assert!(contains("`rm -rf /`", "rm -rf /"));
487 assert!(contains("echo `rm -rf /`", "rm -rf /"));
488 assert!(contains("echo `rm -rf /`", "echo"));
489 }
490
491 #[test]
492 fn dollar_paren_body_is_a_command() {
493 assert!(contains("echo $(rm -rf /)", "rm -rf /"));
494 assert!(contains("x=$(rm -rf /)", "rm -rf /"));
495 assert!(contains("echo \"$(rm -rf /)\"", "rm -rf /"));
496 }
497
498 #[test]
499 fn nested_substitution_is_followed() {
500 assert!(contains("echo $(echo `rm -rf /`)", "rm -rf /"));
501 }
502
503 #[test]
504 fn quotes_are_removed_from_operands() {
505 assert!(contains("rm -rf \"/\"", "rm -rf /"));
506 assert!(contains("rm -rf '/'", "rm -rf /"));
507 assert!(contains("\"rm\" -rf /", "rm -rf /"));
508 assert!(contains("rm -r\"f\" /", "rm -rf /"));
509 }
510
511 #[test]
512 fn single_quoted_text_is_not_a_command() {
513 let targets = expand("echo '`rm -rf /`'");
515 assert!(
516 !targets.iter().any(|t| t == "rm -rf /"),
517 "single-quoted text must not become a command: {targets:?}"
518 );
519 }
520
521 #[test]
522 fn escaped_operators_do_not_split() {
523 let targets = expand("echo a\\;b");
524 assert_eq!(targets.len(), 2, "{targets:?}");
525 assert!(targets.contains(&"echo a;b".to_string()), "{targets:?}");
526 }
527
528 #[test]
529 fn control_operators_split_commands() {
530 for command in [
531 "ls && rm -rf /",
532 "ls || rm -rf /",
533 "ls ; rm -rf /",
534 "ls | rm -rf /",
535 "ls & rm -rf /",
536 "ls\nrm -rf /",
537 ] {
538 assert!(contains(command, "rm -rf /"), "{command}");
539 }
540 }
541
542 #[test]
543 fn wrappers_and_payloads_are_unwrapped() {
544 for command in [
545 "sudo rm -rf /",
546 "env rm -rf /",
547 "timeout 5 rm -rf /",
548 "nohup rm -rf /",
549 "xargs rm -rf /",
550 "/usr/bin/sudo rm -rf /",
551 "eval 'rm -rf /'",
552 "bash -c 'rm -rf /'",
553 "sh -lc \"rm -rf /\"",
554 "sudo -u root bash -c 'rm -rf /'",
555 "bash -o vi -c 'rm -rf /'",
558 "zsh --norcs -c 'rm -rf /'",
559 ] {
560 assert!(
561 contains(command, "rm -rf /"),
562 "{command}: {:?}",
563 expand(command)
564 );
565 }
566 }
567
568 #[test]
569 fn wrapper_head_scan_stops_at_a_real_command() {
570 let targets = expand("echo bash -c 'rm -rf /'");
572 assert!(
573 !targets.iter().any(|t| t == "rm -rf /"),
574 "arguments of a printing command must not be parsed as code: {targets:?}"
575 );
576 }
577
578 #[test]
579 fn process_and_parameter_substitution_bodies_are_commands() {
580 assert!(contains("diff <(rm -rf /) b", "rm -rf /"));
581 assert!(contains("echo ${x:-$(rm -rf /)}", "rm -rf /"));
582 }
583
584 #[test]
585 fn expansion_is_bounded() {
586 let deep = "$(".repeat(64) + "rm -rf /" + &")".repeat(64);
587 let targets = expand(&deep);
588 assert!(targets.len() <= MAX_COMMANDS);
589 }
590
591 #[test]
592 fn grouping_is_a_command_boundary() {
593 assert!(contains("(rm -rf /)", "rm -rf /"));
594 assert!(contains("{ rm -rf /; }", "rm -rf /"));
595 assert!(contains("(cd /tmp && rm -rf /)", "rm -rf /"));
596 assert!(contains(
598 "find . \\( -name a \\) -print",
599 "find . ( -name a ) -print"
600 ));
601 }
602
603 #[test]
604 fn plain_command_expands_to_itself() {
605 assert_eq!(expand("git status -s"), vec!["git status -s".to_string()]);
606 }
607}