返回 CodeWhale
shell_expand.rs
根目录 / crates / execpolicy / src / shell_expand.rs
1 //! Expand a shell command line into the set of commands a shell would run.
2 //!
3 //! Deny rules are the one gate that holds under `AskForApproval::Never`, so
4 //! they cannot be matched against the raw command string: the string a user
5 //! types and the set of commands the shell executes are different things. A
6 //! command substitution runs its body (`` `rm -rf /` ``, `$(rm -rf /)`), a
7 //! quoted argument executes with the quotes removed (`rm -rf "/"`), and a
8 //! wrapper hands its payload straight back to a shell (`bash -c '…'`,
9 //! `eval '…'`, `sudo …`).
10 //!
11 //! Matching one string pattern per metacharacter loses that race by
12 //! construction — every new quoting or wrapping form is another bypass. This
13 //! module instead tokenizes the command the way a POSIX shell word-splits it
14 //! and returns *every* command line that would actually be executed, so deny
15 //! rules can be matched against each one.
16 //!
17 //! Deliberately conservative in the deny direction: when a construct is
18 //! ambiguous the expander emits extra candidate command lines rather than
19 //! fewer. Over-emitting only makes deny matching stricter — `denied_prefix_matches`
20 //! stays anchored at the first positional token, so an extra candidate that no
21 //! rule names is inert. Under-emitting is a bypass.
22 //!
23 //! What it does *not* do is evaluate anything: `$VAR` is left as literal text,
24 //! and single-quoted text is never treated as code (`echo '` + "`" + `rm -rf /`" +
25 //! "`" + `'` really does just print). Fidelity to shell semantics is the point in
26 //! both directions.
27
28 use std::collections::HashSet;
29
30 /// Maximum nesting depth followed through substitutions and `-c` payloads.
31 const MAX_DEPTH: usize = 8;
32
33 /// Upper bound on emitted command lines, so a pathological input cannot turn
34 /// one policy check into unbounded work.
35 const MAX_COMMANDS: usize = 256;
36
37 /// How far into a command the search for a wrapper head (`bash -c`, `eval`)
38 /// will walk past flags and wrapper words.
39 const MAX_HEAD_SCAN: usize = 8;
40
41 /// Words that prefix another command rather than being the command: the real
42 /// invocation is what follows. Stripping them keeps `sudo rm -rf /` matchable
43 /// by an `rm -rf /` rule.
44 const 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
49 /// Shells whose `-c` argument is a command line to be parsed, not an operand.
50 const SHELL_NAMES: &[&str] = &[
51 "sh", "bash", "zsh", "dash", "ksh", "ksh93", "mksh", "ash", "fish", "csh", "tcsh", "rbash",
52 "yash",
53 ];
54
55 /// Returns every command line the shell would execute for `command`.
56 ///
57 /// Results contain word-split commands, substitutions and wrapper payloads.
58 /// Literal data, including quoted heredoc bodies, is not treated as code.
59 /// Windows scans retain native path separators as well as POSIX candidates.
60 pub fn expanded_commands(command: &str) -> Vec<String> {
61 expanded_commands_for_platform(command, cfg!(windows))
62 }
63
64 fn expanded_commands_for_platform(command: &str, windows: bool) -> Vec<String> {
65 let mut expander = Expander {
66 out: Vec::new(),
67 seen: HashSet::new(),
68 literal_backslashes: windows,
69 };
70 // Native Windows shells preserve path separators. Also retain the POSIX
71 // interpretation for Bash/WSL commands. Both passes use the same bounded,
72 // heredoc-aware parser and only contribute deny targets, never grants.
73 expander.expand(command, 0);
74 if windows {
75 expander.literal_backslashes = false;
76 expander.expand(command, 0);
77 }
78 expander.out
79 }
80
81 struct Expander {
82 out: Vec<String>,
83 seen: HashSet<String>,
84 literal_backslashes: bool,
85 }
86
87 impl Expander {
88 fn emit(&mut self, tokens: &[String]) {
89 if self.out.len() >= MAX_COMMANDS {
90 return;
91 }
92 let joined = tokens
93 .iter()
94 .filter(|token| !token.is_empty())
95 .cloned()
96 .collect::<Vec<_>>()
97 .join(" ");
98 if joined.is_empty() {
99 return;
100 }
101 if self.seen.insert(joined.clone()) {
102 self.out.push(joined);
103 }
104 }
105
106 /// Word-split `input` into command lines and record each one, recursing
107 /// into every nested command text found along the way.
108 fn expand(&mut self, input: &str, depth: usize) {
109 if depth > MAX_DEPTH || self.out.len() >= MAX_COMMANDS {
110 return;
111 }
112 // shlex does not implement ANSI-C/localized quoting or shell CR
113 // semantics. Keep the conservative scan for those forms rather than
114 // let an unrecognized heredoc delimiter hide following commands.
115 if input.contains('\r') || input.contains("$'") || input.contains("$\"") {
116 for segment in super::command_segments(input) {
117 self.emit(&[segment]);
118 }
119 }
120 let chars: Vec<char> = input.chars().collect();
121 let n = chars.len();
122 let mut i = 0usize;
123 let mut commands: Vec<Vec<String>> = Vec::new();
124 let mut words: Vec<String> = Vec::new();
125 let mut word = String::new();
126 let mut started = false;
127 let mut quoted = false;
128 let mut redirect_operand = false;
129 let mut nested: Vec<String> = Vec::new();
130 let mut heredocs = Vec::new();
131
132 while i < n {
133 let c = chars[i];
134 match c {
135 '#' if !started && word.is_empty() => {
136 while i < n && chars[i] != '\n' {
137 i += 1;
138 }
139 }
140 // A backslash outside quotes escapes exactly one character,
141 // including an operator: `echo a\;b` is one word, not two
142 // commands. A backslash-newline is a line continuation.
143 '\\' if self.literal_backslashes => {
144 word.push('\\');
145 started = true;
146 i += 1;
147 }
148 '\\' => {
149 if i + 1 < n {
150 if chars[i + 1] != '\n' {
151 word.push(chars[i + 1]);
152 started = true;
153 quoted = true;
154 }
155 i += 2;
156 } else {
157 i += 1;
158 }
159 }
160 // Single quotes are fully literal: no substitution, no escapes.
161 '\'' => {
162 started = true;
163 quoted = true;
164 i += 1;
165 while i < n && chars[i] != '\'' {
166 word.push(chars[i]);
167 i += 1;
168 }
169 i = (i + 1).min(n);
170 }
171 // Double quotes suppress word splitting but NOT substitution.
172 '"' => {
173 started = true;
174 quoted = true;
175 i += 1;
176 while i < n && chars[i] != '"' {
177 match chars[i] {
178 '\\' if self.literal_backslashes => {
179 word.push('\\');
180 i += 1;
181 }
182 '\\' if i + 1 < n => {
183 word.push(chars[i + 1]);
184 i += 2;
185 }
186 '`' => {
187 let (inner, next) = read_backtick(&chars, i);
188 nested.push(inner);
189 i = next;
190 }
191 '$' if i + 1 < n && chars[i + 1] == '(' => {
192 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
193 nested.push(inner);
194 i = next;
195 }
196 '$' if i + 1 < n && chars[i + 1] == '{' => {
197 let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
198 nested.push(inner);
199 i = next;
200 }
201 ch => {
202 word.push(ch);
203 i += 1;
204 }
205 }
206 }
207 i = (i + 1).min(n);
208 }
209 // `$'…'` (ANSI-C quoting) is literal text with C escapes.
210 '$' if i + 1 < n && chars[i + 1] == '\'' => {
211 started = true;
212 quoted = true;
213 i += 2;
214 while i < n && chars[i] != '\'' {
215 if chars[i] == '\\' && i + 1 < n {
216 word.push(chars[i + 1]);
217 i += 2;
218 } else {
219 word.push(chars[i]);
220 i += 1;
221 }
222 }
223 i = (i + 1).min(n);
224 }
225 // Command substitution, both spellings. The body is a command
226 // line in its own right; the substitution contributes no text
227 // to the enclosing word (we do not evaluate output).
228 '`' => {
229 started |= redirect_operand;
230 let (inner, next) = read_backtick(&chars, i);
231 nested.push(inner);
232 i = next;
233 }
234 '$' if i + 1 < n && chars[i + 1] == '(' => {
235 started |= redirect_operand;
236 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
237 nested.push(inner);
238 i = next;
239 }
240 // `${…}` is an expansion, not a command — but it can *contain*
241 // one (`${x:-$(rm -rf /)}`), so the body is rescanned.
242 '$' if i + 1 < n && chars[i + 1] == '{' => {
243 started |= redirect_operand;
244 let (inner, next) = read_delimited(&chars, i + 1, '{', '}');
245 nested.push(inner);
246 i = next;
247 }
248 // Process substitution `<(…)` / `>(…)` also runs its body.
249 '<' | '>' if i + 1 < n && chars[i + 1] == '(' => {
250 started |= redirect_operand;
251 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
252 nested.push(inner);
253 i = next;
254 }
255 '<' if chars.get(i + 1) == Some(&'<') && chars.get(i + 2) != Some(&'<') => {
256 if !quoted && is_redirect_descriptor(&word) {
257 word.clear();
258 started = false;
259 }
260 flush_word(
261 &mut words,
262 &mut word,
263 &mut started,
264 &mut quoted,
265 &mut redirect_operand,
266 );
267 i += 2;
268 let strip_tabs = chars.get(i) == Some(&'-');
269 if strip_tabs {
270 i += 1;
271 }
272 while i < n && matches!(chars[i], ' ' | '\t') {
273 i += 1;
274 }
275 let start = i;
276 let mut quote = None;
277 let mut literal = false;
278 while i < n {
279 let ch = chars[i];
280 if quote.is_none()
281 && matches!(ch, ' ' | '\t' | '\n' | ';' | '|' | '&' | '<' | '>')
282 {
283 break;
284 }
285 if ch == '\\' && quote != Some('\'') {
286 literal = true;
287 i = (i + 2).min(n);
288 continue;
289 }
290 if matches!(ch, '\'' | '"') {
291 literal = true;
292 if quote == Some(ch) {
293 quote = None;
294 } else if quote.is_none() {
295 quote = Some(ch);
296 }
297 }
298 i += 1;
299 }
300 let raw: String = chars[start..i].iter().collect();
301 if let Some(delimiter) = shlex::split(&raw)
302 .and_then(|mut words| (words.len() == 1).then(|| words.remove(0)))
303 {
304 heredocs.push((delimiter, literal, strip_tabs));
305 }
306 }
307 // Unquoted redirections are syntax, even without whitespace.
308 // Keep the command words on both sides together, but omit the
309 // descriptor and next operand. Parse that operand normally so
310 // nested substitutions are still checked as commands.
311 '<' | '>' | '&' if redirection_len(&chars[i..]) > 0 => {
312 if !quoted && is_redirect_descriptor(&word) {
313 word.clear();
314 started = false;
315 }
316 flush_word(
317 &mut words,
318 &mut word,
319 &mut started,
320 &mut quoted,
321 &mut redirect_operand,
322 );
323 redirect_operand = true;
324 i += redirection_len(&chars[i..]);
325 }
326 ' ' | '\t' => {
327 flush_word(
328 &mut words,
329 &mut word,
330 &mut started,
331 &mut quoted,
332 &mut redirect_operand,
333 );
334 i += 1;
335 }
336 // A subshell boundary. `$(`, `<(` and `>(` were consumed by the
337 // arms above, so a bare paren here is grouping: the body is a
338 // command list of its own, not part of the surrounding word.
339 '(' | ')' => {
340 flush_word(
341 &mut words,
342 &mut word,
343 &mut started,
344 &mut quoted,
345 &mut redirect_operand,
346 );
347 end_command(&mut commands, &mut words);
348 redirect_operand = false;
349 i += 1;
350 }
351 // Control operators end the current command line. `&&`, `||`,
352 // `;;`, `|&` and runs of newlines collapse into one break.
353 '\n' | '\r' | ';' | '&' | '|' => {
354 flush_word(
355 &mut words,
356 &mut word,
357 &mut started,
358 &mut quoted,
359 &mut redirect_operand,
360 );
361 end_command(&mut commands, &mut words);
362 redirect_operand = false;
363 i += 1;
364 if c == '\n' && !heredocs.is_empty() {
365 let shell_stdin = commands.iter().any(|tokens| {
366 find_wrapper_head(tokens).is_some_and(|head| {
367 let name = basename(&tokens[head]).to_ascii_lowercase();
368 SHELL_NAMES.contains(&name.as_str())
369 || matches!(name.as_str(), "source" | ".")
370 })
371 });
372 for (delimiter, literal, strip_tabs) in heredocs.drain(..) {
373 let mut body = String::new();
374 while i < n {
375 let start = i;
376 while i < n && chars[i] != '\n' {
377 i += 1;
378 }
379 let mut line: String = chars[start..i].iter().collect();
380 if i < n {
381 i += 1;
382 }
383 // An unquoted heredoc joins escaped newlines
384 // before checking its delimiter (E\ + OF can
385 // terminate EOF). Do not swallow later code.
386 while !literal
387 && line.chars().rev().take_while(|c| *c == '\\').count() % 2
388 == 1
389 && i < n
390 {
391 line.pop();
392 let start = i;
393 while i < n && chars[i] != '\n' {
394 i += 1;
395 }
396 line.extend(chars[start..i].iter());
397 if i < n {
398 i += 1;
399 }
400 }
401 let line = if strip_tabs {
402 line.trim_start_matches('\t')
403 } else {
404 &line
405 };
406 if line == delimiter {
407 break;
408 }
409 body.push_str(line);
410 body.push('\n');
411 }
412 if shell_stdin {
413 nested.push(body);
414 } else if !literal {
415 nested.extend(heredoc_substitutions(&body));
416 }
417 }
418 }
419 }
420 _ => {
421 word.push(c);
422 started = true;
423 i += 1;
424 }
425 }
426 }
427 flush_word(
428 &mut words,
429 &mut word,
430 &mut started,
431 &mut quoted,
432 &mut redirect_operand,
433 );
434 end_command(&mut commands, &mut words);
435
436 for tokens in &commands {
437 self.record(tokens, depth);
438 }
439 for inner in nested {
440 self.expand(&inner, depth + 1);
441 }
442 }
443
444 /// Record one word-split command line, plus the invocation hiding inside it
445 /// when the head is a wrapper.
446 fn record(&mut self, tokens: &[String], depth: usize) {
447 if tokens.is_empty() {
448 return;
449 }
450 self.emit(tokens);
451
452 // `sudo rm -rf /` is an `rm -rf /`. Strip wrapper words (and the scalar
453 // arguments that belong to them, e.g. `timeout 5`) and emit what's left.
454 let stripped = strip_leading_wrappers(tokens);
455 if stripped.len() != tokens.len() {
456 self.emit(stripped);
457 }
458
459 // `eval …` and `sh -c …` take a *command line* as data. Parse it.
460 if let Some(head) = find_wrapper_head(tokens) {
461 let name = basename(&tokens[head]).to_ascii_lowercase();
462 if name == "eval" {
463 let payload = tokens[head + 1..].join(" ");
464 self.expand(&payload, depth + 1);
465 } else if let Some(script) = shell_c_argument(&tokens[head..]) {
466 self.expand(script, depth + 1);
467 }
468 }
469 }
470 }
471
472 /// Unquoted heredocs expand substitutions, but quotes and ordinary lines are data.
473 fn heredoc_substitutions(body: &str) -> Vec<String> {
474 let chars: Vec<char> = body.chars().collect();
475 let mut result = Vec::new();
476 let mut i = 0;
477 while i < chars.len() {
478 match chars[i] {
479 '\\' if chars
480 .get(i + 1)
481 .is_some_and(|c| matches!(c, '$' | '`' | '\\' | '\n')) =>
482 {
483 i += 2
484 }
485 '`' => {
486 let (inner, next) = read_backtick(&chars, i);
487 result.push(inner);
488 i = next;
489 }
490 '$' if chars.get(i + 1) == Some(&'(') => {
491 let (inner, next) = read_delimited(&chars, i + 1, '(', ')');
492 result.push(inner);
493 i = next;
494 }
495 _ => i += 1,
496 }
497 }
498 result
499 }
500
501 fn flush_word(
502 words: &mut Vec<String>,
503 word: &mut String,
504 started: &mut bool,
505 quoted: &mut bool,
506 redirect_operand: &mut bool,
507 ) {
508 if *started || !word.is_empty() {
509 if *redirect_operand {
510 word.clear();
511 *redirect_operand = false;
512 } else {
513 words.push(std::mem::take(word));
514 }
515 *started = false;
516 }
517 *quoted = false;
518 }
519
520 fn redirection_len(chars: &[char]) -> usize {
521 match chars {
522 ['&', '>', '>', ..] | ['<', '<', '<' | '-', ..] => 3,
523 ['&', '>', ..] | ['<', '<' | '>' | '&', ..] | ['>', '>' | '&' | '|', ..] => 2,
524 ['<' | '>', ..] => 1,
525 _ => 0,
526 }
527 }
528
529 fn is_redirect_descriptor(word: &str) -> bool {
530 if !word.is_empty() && word.bytes().all(|b| b.is_ascii_digit()) {
531 return true;
532 }
533 // Bash also accepts an unquoted `{name}` in place of an IO number.
534 word.strip_prefix('{')
535 .and_then(|word| word.strip_suffix('}'))
536 .is_some_and(|name| {
537 let mut chars = name.chars();
538 chars
539 .next()
540 .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
541 && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
542 })
543 }
544
545 fn end_command(commands: &mut Vec<Vec<String>>, words: &mut Vec<String>) {
546 // `{` and `}` stand alone as reserved words in `{ cmd; }` — they group a
547 // command rather than being part of one. Dropping them here keeps every
548 // downstream consumer (wrapper detection, emission) looking at real
549 // command words only.
550 words.retain(|word| !matches!(word.as_str(), "{" | "}"));
551 if !words.is_empty() {
552 commands.push(std::mem::take(words));
553 }
554 }
555
556 /// Read a backtick substitution. `start` indexes the opening backtick; returns
557 /// the body and the index just past the closing backtick.
558 fn read_backtick(chars: &[char], start: usize) -> (String, usize) {
559 let mut i = start + 1;
560 let mut inner = String::new();
561 while i < chars.len() {
562 match chars[i] {
563 '\\' if i + 1 < chars.len() => {
564 inner.push(chars[i]);
565 inner.push(chars[i + 1]);
566 i += 2;
567 }
568 '`' => return (inner, i + 1),
569 c => {
570 inner.push(c);
571 i += 1;
572 }
573 }
574 }
575 (inner, i)
576 }
577
578 /// Read a balanced `open`/`close` region. `open_at` indexes the opening
579 /// delimiter; returns the body and the index just past the matching close.
580 fn read_delimited(chars: &[char], open_at: usize, open: char, close: char) -> (String, usize) {
581 let mut depth = 1usize;
582 let mut i = open_at + 1;
583 let mut inner = String::new();
584 while i < chars.len() {
585 let c = chars[i];
586 if c == '\\' && i + 1 < chars.len() {
587 inner.push(c);
588 inner.push(chars[i + 1]);
589 i += 2;
590 continue;
591 }
592 if c == open {
593 depth += 1;
594 } else if c == close {
595 depth -= 1;
596 if depth == 0 {
597 return (inner, i + 1);
598 }
599 }
600 inner.push(c);
601 i += 1;
602 }
603 (inner, i)
604 }
605
606 /// The final path component, so `/usr/bin/sudo` reads as `sudo`.
607 fn basename(token: &str) -> &str {
608 token
609 .rsplit(['/', '\\'])
610 .next()
611 .filter(|part| !part.is_empty())
612 .unwrap_or(token)
613 }
614
615 fn is_env_assignment(token: &str) -> bool {
616 match token.split_once('=') {
617 Some((name, _)) => {
618 !name.is_empty()
619 && !name.starts_with('-')
620 && name
621 .chars()
622 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
623 }
624 None => false,
625 }
626 }
627
628 /// True for a bare scalar operand that belongs to a wrapper word rather than
629 /// starting a command — `timeout 5`, `nice -n 10`, `timeout 1.5s`.
630 fn is_scalar_operand(token: &str) -> bool {
631 let body = token.trim_end_matches(['s', 'm', 'h', 'd']);
632 !body.is_empty() && body.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
633 }
634
635 fn is_passthrough_wrapper(token: &str) -> bool {
636 let name = basename(token).to_ascii_lowercase();
637 PASSTHROUGH_WRAPPERS.contains(&name.as_str())
638 }
639
640 fn is_shell_name(token: &str) -> bool {
641 let name = basename(token).to_ascii_lowercase();
642 SHELL_NAMES.contains(&name.as_str())
643 }
644
645 /// Drop leading environment assignments, wrapper words, and the scalar operands
646 /// those wrappers take, returning the remaining slice.
647 ///
648 /// Flags are deliberately *not* dropped: `denied_prefix_matches` already skips
649 /// unrelated flags (and, ambiguously, their values) when anchoring a rule, so
650 /// leaving `-u root` in place is both correct and matchable.
651 fn strip_leading_wrappers(tokens: &[String]) -> &[String] {
652 let mut start = 0usize;
653 let mut dropped_wrapper = false;
654 while start < tokens.len() {
655 let token = &tokens[start];
656 if is_env_assignment(token) {
657 start += 1;
658 } else if is_passthrough_wrapper(token) {
659 dropped_wrapper = true;
660 start += 1;
661 } else if dropped_wrapper && is_scalar_operand(token) {
662 start += 1;
663 } else {
664 break;
665 }
666 }
667 &tokens[start..]
668 }
669
670 /// Index of the `eval` / shell word that introduces a nested command line, if
671 /// this invocation has one.
672 ///
673 /// The scan walks past environment assignments, wrapper words, flags, and the
674 /// operand immediately following a single-dash flag (which may be that flag's
675 /// value, as in `sudo -u root bash -c …`). It stops at the first token that
676 /// cannot plausibly precede the real command, which is what keeps
677 /// `echo bash -c 'rm -rf /'` — a command that only prints — from being read as
678 /// a shell invocation.
679 fn find_wrapper_head(tokens: &[String]) -> Option<usize> {
680 let mut previous_was_short_flag = false;
681 for (index, token) in tokens.iter().enumerate().take(MAX_HEAD_SCAN) {
682 if is_shell_name(token) || basename(token).eq_ignore_ascii_case("eval") {
683 return Some(index);
684 }
685 let skippable = is_env_assignment(token)
686 || is_passthrough_wrapper(token)
687 || token.starts_with('-')
688 || is_scalar_operand(token)
689 || previous_was_short_flag;
690 if !skippable {
691 return None;
692 }
693 previous_was_short_flag = token.starts_with('-') && !token.starts_with("--");
694 }
695 None
696 }
697
698 /// The command-line argument of a shell's `-c` flag, if present.
699 ///
700 /// `tokens[0]` is the shell. Combined short flags count (`bash -lc '…'`).
701 /// The scan deliberately does NOT stop at the first non-flag operand: an
702 /// earlier version did, and `bash -o vi -c 'payload'` walked straight past
703 /// the deny expander because `vi` (the argument of `-o`) ended the scan
704 /// before `-c` was seen (2026-08-04 review). Continuing the scan can
705 /// over-read a `-c` that is really an argument to a script
706 /// (`bash script.sh -c x`), but this expander's contract is explicit that
707 /// over-emitting targets is safe and under-emitting is a bypass.
708 fn shell_c_argument(tokens: &[String]) -> Option<&str> {
709 let mut index = 1usize;
710 while index < tokens.len() {
711 let token = tokens[index].as_str();
712 let takes_command_line = match token.strip_prefix("--") {
713 Some(long) => long.eq_ignore_ascii_case("command"),
714 None => token
715 .strip_prefix('-')
716 .is_some_and(|flags| flags.contains('c')),
717 };
718 if takes_command_line {
719 return tokens.get(index + 1).map(String::as_str);
720 }
721 index += 1;
722 }
723 None
724 }
725
726 #[cfg(test)]
727 mod tests {
728 use super::*;
729
730 fn expand(command: &str) -> Vec<String> {
731 // Exercise the POSIX grammar consistently on every test host.
732 expanded_commands_for_platform(command, false)
733 }
734
735 #[test]
736 fn windows_scan_retains_native_paths_and_posix_deny_candidates() {
737 for (command, expected) in [
738 (
739 r"C:\Windows\System32\cat.exe ~/.ssh/id_rsa",
740 r"C:\Windows\System32\cat.exe ~/.ssh/id_rsa",
741 ),
742 (r"del /f c:\users\x\file", r"del /f c:\users\x\file"),
743 (
744 r"echo safe & xcopy /e /y c:\src d:\dst",
745 r"xcopy /e /y c:\src d:\dst",
746 ),
747 (
748 r#""C:\Program Files\cat.exe" "c:\path with spaces\file""#,
749 r"C:\Program Files\cat.exe c:\path with spaces\file",
750 ),
751 (r"del relative\file", r"del relative\file"),
752 (
753 r"\\server\share\cat.exe file",
754 r"\\server\share\cat.exe file",
755 ),
756 (r"bash -c 'rm -rf \/'", "rm -rf /"),
757 ] {
758 let targets = expanded_commands_for_platform(command, true);
759 assert!(
760 targets.iter().any(|target| target == expected),
761 "missing {expected:?} from {targets:?}"
762 );
763 assert!(targets.len() <= MAX_COMMANDS);
764 }
765 let targets =
766 expanded_commands_for_platform("cat <<'EOF'\ndel c:\\users\\x\\file\nEOF", true);
767 assert!(
768 !targets.iter().any(|target| target.starts_with("del ")),
769 "literal heredoc data must stay inert: {targets:?}"
770 );
771 }
772
773 fn contains(command: &str, expected: &str) -> bool {
774 expand(command).iter().any(|target| target == expected)
775 }
776
777 #[test]
778 fn backtick_body_is_a_command() {
779 assert!(contains("`rm -rf /`", "rm -rf /"));
780 assert!(contains("echo `rm -rf /`", "rm -rf /"));
781 assert!(contains("echo `rm -rf /`", "echo"));
782 }
783
784 #[test]
785 fn dollar_paren_body_is_a_command() {
786 assert!(contains("echo $(rm -rf /)", "rm -rf /"));
787 assert!(contains("x=$(rm -rf /)", "rm -rf /"));
788 assert!(contains("echo \"$(rm -rf /)\"", "rm -rf /"));
789 }
790
791 #[test]
792 fn nested_substitution_is_followed() {
793 assert!(contains("echo $(echo `rm -rf /`)", "rm -rf /"));
794 }
795
796 #[test]
797 fn quotes_are_removed_from_operands() {
798 assert!(contains("rm -rf \"/\"", "rm -rf /"));
799 assert!(contains("rm -rf '/'", "rm -rf /"));
800 assert!(contains("\"rm\" -rf /", "rm -rf /"));
801 assert!(contains("rm -r\"f\" /", "rm -rf /"));
802 }
803
804 #[test]
805 fn single_quoted_text_is_not_a_command() {
806 // A literal backtick inside single quotes is printed, not executed.
807 let targets = expand("echo '`rm -rf /`'");
808 assert!(
809 !targets.iter().any(|t| t == "rm -rf /"),
810 "single-quoted text must not become a command: {targets:?}"
811 );
812 }
813
814 #[test]
815 fn escaped_operators_do_not_split() {
816 let targets = expand("echo a\\;b");
817 assert_eq!(targets, vec!["echo a;b".to_string()]);
818 assert!(targets.contains(&"echo a;b".to_string()), "{targets:?}");
819 }
820
821 #[test]
822 fn control_operators_split_commands() {
823 for command in [
824 "ls && rm -rf /",
825 "ls || rm -rf /",
826 "ls ; rm -rf /",
827 "ls | rm -rf /",
828 "ls & rm -rf /",
829 "ls\nrm -rf /",
830 ] {
831 assert!(contains(command, "rm -rf /"), "{command}");
832 }
833 }
834
835 #[test]
836 fn wrappers_and_payloads_are_unwrapped() {
837 for command in [
838 "sudo rm -rf /",
839 "env rm -rf /",
840 "timeout 5 rm -rf /",
841 "nohup rm -rf /",
842 "xargs rm -rf /",
843 "/usr/bin/sudo rm -rf /",
844 "eval 'rm -rf /'",
845 "bash -c 'rm -rf /'",
846 "sh -lc \"rm -rf /\"",
847 "sudo -u root bash -c 'rm -rf /'",
848 // 2026-08-04: `-o vi` used to end the flag scan before `-c` was
849 // seen, so the payload skipped deny expansion entirely.
850 "bash -o vi -c 'rm -rf /'",
851 "zsh --norcs -c 'rm -rf /'",
852 ] {
853 assert!(
854 contains(command, "rm -rf /"),
855 "{command}: {:?}",
856 expand(command)
857 );
858 }
859 }
860
861 #[test]
862 fn wrapper_head_scan_stops_at_a_real_command() {
863 // `echo` prints its arguments; nothing here is executed as a shell.
864 let targets = expand("echo bash -c 'rm -rf /'");
865 assert!(
866 !targets.iter().any(|t| t == "rm -rf /"),
867 "arguments of a printing command must not be parsed as code: {targets:?}"
868 );
869 }
870
871 #[test]
872 fn process_and_parameter_substitution_bodies_are_commands() {
873 assert!(contains("diff <(rm -rf /) b", "rm -rf /"));
874 assert!(contains("echo ${x:-$(rm -rf /)}", "rm -rf /"));
875 }
876
877 #[test]
878 fn expansion_is_bounded() {
879 let deep = "$(".repeat(64) + "rm -rf /" + &")".repeat(64);
880 let targets = expand(&deep);
881 assert!(targets.len() <= MAX_COMMANDS);
882 }
883
884 #[test]
885 fn grouping_is_a_command_boundary() {
886 assert!(contains("(rm -rf /)", "rm -rf /"));
887 assert!(contains("{ rm -rf /; }", "rm -rf /"));
888 assert!(contains("(cd /tmp && rm -rf /)", "rm -rf /"));
889 // Escaped and quoted parens are operands, not grouping.
890 assert!(contains(
891 "find . \\( -name a \\) -print",
892 "find . ( -name a ) -print"
893 ));
894 }
895
896 #[test]
897 fn plain_command_expands_to_itself() {
898 assert_eq!(expand("git status -s"), vec!["git status -s".to_string()]);
899 }
900 }
901
901 lines RUST