返回 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 //! Because nothing is evaluated, some command words cannot be known without
29 //! running the shell: a parameter or command substitution (`$v`, `${v}`,
30 //! `$(…)`), a glob or brace expansion (`r[m]`, `{rm,-f,x}`), ANSI-C escapes,
31 //! and a shell that reads its script from a pipe, a here-string, or a process
32 //! substitution. [`Expansion::dynamic`] reports that case so the policy engine
33 //! can fail closed instead of matching deny rules against text the shell will
34 //! rewrite. [`Expansion::nested`] reports that some code runs inside another
35 //! command (a substitution, `eval`, a `-c` payload, `find -exec`), which is
36 //! never covered by an allow rule written for the outer command.
37 //!
38 //! Known limits: a script *file* (`bash ./x.sh`, `. ./env.sh`) is opaque
39 //! (a descriptor path such as `/dev/stdin` is not a file: it is dynamic), as
40 //! is any program that interprets its arguments as code (`python -c`, `ssh
41 //! host cmd`, `git -c alias.x=!cmd`). Aliases and functions defined in an
42 //! earlier call are not tracked. Arithmetic contexts (`(( ))`, `$(( ))`,
43 //! `let`, `[[ … -eq … ]]`) evaluate the *values* of variables they name, so a
44 //! value holding `a[$(cmd)]` runs `cmd` without it appearing in the text.
45 //! `cmd /c` and PowerShell `-Command` payloads are scanned with this POSIX
46 //! grammar, which finds their command words but not every cmd.exe or
47 //! PowerShell construct.
48
49 use std::collections::HashSet;
50
51 /// Maximum nesting depth followed through substitutions and `-c` payloads.
52 const MAX_DEPTH: usize = 8;
53
54 /// Upper bound on emitted command lines, so a pathological input cannot turn
55 /// one policy check into unbounded work.
56 const MAX_COMMANDS: usize = 256;
57
58 /// Upper bound on the parser states explored while locating the real command
59 /// word behind wrapper words. Running out marks the command unresolved.
60 const MAX_HEAD_STATES: usize = 512;
61
62 /// A word that prefixes another command rather than being the command: the
63 /// real invocation is what follows. Stripping it keeps `sudo rm -rf /`
64 /// matchable by an `rm -rf /` rule.
65 ///
66 /// The option grammar matters: an option that takes a separate value
67 /// (`sudo -u root`, `timeout -s KILL`) and a required operand
68 /// (`chroot NEWROOT`, `timeout DURATION`) both sit between the wrapper and the
69 /// command it runs. An option missing from the table may or may not take a
70 /// value, so both readings are explored.
71 struct Wrapper {
72 name: &'static str,
73 /// Short options whose value is the rest of the word or the next word.
74 short_values: &'static str,
75 /// Short options known to take no value. Any other short option may or
76 /// may not take one, so both readings are explored.
77 short_switches: &'static str,
78 /// Long options whose value may be the next word.
79 long_values: &'static [&'static str],
80 /// Long options known to take no value.
81 long_switches: &'static [&'static str],
82 /// Required operands before the command (`chroot NEWROOT`).
83 operands: u8,
84 }
85
86 static WRAPPERS: &[Wrapper] = &[
87 Wrapper {
88 name: "sudo",
89 short_values: "ughpCDrtTRUac",
90 short_switches: "AbBEeHiKklNnPSsVv",
91 long_values: &[
92 "user",
93 "group",
94 "host",
95 "prompt",
96 "close-from",
97 "chdir",
98 "role",
99 "type",
100 "command-timeout",
101 "chroot",
102 "other-user",
103 "auth-type",
104 "login-class",
105 ],
106 long_switches: &[
107 "login",
108 "preserve-env",
109 "non-interactive",
110 "background",
111 "edit",
112 "shell",
113 "stdin",
114 "reset-timestamp",
115 "remove-timestamp",
116 "validate",
117 "list",
118 "set-home",
119 "bell",
120 "askpass",
121 "preserve-groups",
122 ],
123 operands: 0,
124 },
125 Wrapper {
126 name: "doas",
127 short_values: "uCa",
128 short_switches: "Lns",
129 long_values: &[],
130 long_switches: &[],
131 operands: 0,
132 },
133 Wrapper {
134 name: "env",
135 short_values: "uCSPa",
136 short_switches: "i0v",
137 long_values: &["unset", "chdir", "argv0"],
138 long_switches: &["ignore-environment", "null", "debug"],
139 operands: 0,
140 },
141 Wrapper {
142 name: "nohup",
143 short_values: "",
144 short_switches: "",
145 long_values: &[],
146 long_switches: &[],
147 operands: 0,
148 },
149 Wrapper {
150 name: "nice",
151 short_values: "n",
152 short_switches: "",
153 long_values: &["adjustment"],
154 long_switches: &[],
155 operands: 0,
156 },
157 Wrapper {
158 name: "ionice",
159 short_values: "cnpPu",
160 short_switches: "t",
161 long_values: &["class", "classdata", "pid", "pgid", "uid"],
162 long_switches: &["ignore"],
163 operands: 0,
164 },
165 Wrapper {
166 name: "time",
167 short_values: "fo",
168 short_switches: "aplqvh",
169 long_values: &["format", "output"],
170 long_switches: &["append", "portability", "verbose", "quiet"],
171 operands: 0,
172 },
173 Wrapper {
174 name: "timeout",
175 short_values: "sk",
176 short_switches: "fpv",
177 long_values: &["signal", "kill-after"],
178 long_switches: &["preserve-status", "foreground", "verbose"],
179 operands: 1,
180 },
181 Wrapper {
182 name: "stdbuf",
183 short_values: "ioe",
184 short_switches: "",
185 long_values: &["input", "output", "error"],
186 long_switches: &[],
187 operands: 0,
188 },
189 Wrapper {
190 name: "setsid",
191 short_values: "",
192 short_switches: "cfw",
193 long_values: &[],
194 long_switches: &["ctty", "fork", "wait"],
195 operands: 0,
196 },
197 Wrapper {
198 name: "command",
199 short_values: "",
200 short_switches: "pvV",
201 long_values: &[],
202 long_switches: &[],
203 operands: 0,
204 },
205 Wrapper {
206 name: "builtin",
207 short_values: "",
208 short_switches: "",
209 long_values: &[],
210 long_switches: &[],
211 operands: 0,
212 },
213 Wrapper {
214 name: "exec",
215 short_values: "a",
216 short_switches: "cl",
217 long_values: &[],
218 long_switches: &[],
219 operands: 0,
220 },
221 Wrapper {
222 name: "xargs",
223 short_values: "adEILnPsJSR",
224 short_switches: "0eiloprtx",
225 long_values: &[
226 "arg-file",
227 "delimiter",
228 "eof",
229 "replace",
230 "max-lines",
231 "max-args",
232 "max-procs",
233 "max-chars",
234 "process-slot-var",
235 ],
236 long_switches: &[
237 "null",
238 "no-run-if-empty",
239 "interactive",
240 "verbose",
241 "exit",
242 "open-tty",
243 "show-limits",
244 ],
245 operands: 0,
246 },
247 Wrapper {
248 name: "unbuffer",
249 short_values: "",
250 short_switches: "p",
251 long_values: &[],
252 long_switches: &[],
253 operands: 0,
254 },
255 Wrapper {
256 name: "busybox",
257 short_values: "",
258 short_switches: "",
259 long_values: &[],
260 long_switches: &[],
261 operands: 0,
262 },
263 Wrapper {
264 name: "chroot",
265 short_values: "ugG",
266 short_switches: "",
267 long_values: &["userspec", "groups"],
268 long_switches: &["skip-chdir"],
269 operands: 1,
270 },
271 Wrapper {
272 name: "proot",
273 short_values: "rbmwqkRSvi",
274 short_switches: "0Vh",
275 long_values: &[
276 "rootfs",
277 "bind",
278 "mount",
279 "cwd",
280 "pwd",
281 "qemu",
282 "kernel-release",
283 "verbose",
284 ],
285 long_switches: &[],
286 operands: 0,
287 },
288 Wrapper {
289 name: "caffeinate",
290 short_values: "tw",
291 short_switches: "dimsu",
292 long_values: &[],
293 long_switches: &[],
294 operands: 0,
295 },
296 Wrapper {
297 name: "arch",
298 short_values: "ed",
299 short_switches: "",
300 long_values: &[],
301 long_switches: &[],
302 operands: 0,
303 },
304 Wrapper {
305 name: "sandbox-exec",
306 short_values: "fnpD",
307 short_switches: "",
308 long_values: &[],
309 long_switches: &[],
310 operands: 0,
311 },
312 Wrapper {
313 name: "noglob",
314 short_values: "",
315 short_switches: "",
316 long_values: &[],
317 long_switches: &[],
318 operands: 0,
319 },
320 Wrapper {
321 name: "nocorrect",
322 short_values: "",
323 short_switches: "",
324 long_values: &[],
325 long_switches: &[],
326 operands: 0,
327 },
328 Wrapper {
329 name: "-",
330 short_values: "",
331 short_switches: "",
332 long_values: &[],
333 long_switches: &[],
334 operands: 0,
335 },
336 Wrapper {
337 name: "nsenter",
338 short_values: "tSG",
339 short_switches: "amuinpUCTrwFZcy",
340 long_values: &["target", "setuid", "setgid"],
341 long_switches: &["all", "preserve-credentials", "no-fork"],
342 operands: 0,
343 },
344 Wrapper {
345 name: "unshare",
346 short_values: "SGRw",
347 short_switches: "mupinUCTrcfk",
348 long_values: &["root", "wd", "setuid", "setgid", "propagation", "setgroups"],
349 long_switches: &[
350 "fork",
351 "mount-proc",
352 "map-root-user",
353 "map-current-user",
354 "kill-child",
355 ],
356 operands: 0,
357 },
358 Wrapper {
359 name: "runuser",
360 short_values: "ugGcswf",
361 short_switches: "lmpP",
362 long_values: &[
363 "user",
364 "group",
365 "supp-group",
366 "command",
367 "shell",
368 "whitelist-environment",
369 "session-command",
370 ],
371 long_switches: &["login", "preserve-environment", "pty"],
372 operands: 0,
373 },
374 Wrapper {
375 name: "flock",
376 short_values: "wEc",
377 short_switches: "sxunoFh",
378 long_values: &["timeout", "wait", "conflict-exit-code", "command"],
379 long_switches: &[
380 "shared",
381 "exclusive",
382 "unlock",
383 "nonblock",
384 "nb",
385 "close",
386 "no-fork",
387 "verbose",
388 ],
389 operands: 1,
390 },
391 Wrapper {
392 name: "watch",
393 short_values: "nq",
394 short_switches: "bcdeghprtwx",
395 long_values: &["interval", "equexit"],
396 long_switches: &[
397 "beep",
398 "color",
399 "no-color",
400 "differences",
401 "errexit",
402 "chgexit",
403 "precise",
404 "no-title",
405 "no-wrap",
406 "exec",
407 ],
408 operands: 0,
409 },
410 Wrapper {
411 name: "wsl",
412 short_values: "du",
413 short_switches: "e",
414 long_values: &["distribution", "user", "cd", "shell-type"],
415 long_switches: &["exec"],
416 operands: 0,
417 }, // Privilege, sandbox, scheduling and tracing launchers: each runs its
418 // operands as a command.
419 Wrapper {
420 name: "pkexec",
421 short_values: "",
422 short_switches: "",
423 long_values: &["user"],
424 long_switches: &["disable-internal-agent", "keep-cwd"],
425 operands: 0,
426 },
427 Wrapper {
428 name: "run0",
429 short_values: "ugD",
430 short_switches: "",
431 long_values: &[
432 "user",
433 "group",
434 "nice",
435 "chdir",
436 "setenv",
437 "unit",
438 "property",
439 "description",
440 "slice",
441 "machine",
442 ],
443 long_switches: &["no-ask-password", "background", "pty", "pipe"],
444 operands: 0,
445 },
446 Wrapper {
447 name: "fakeroot",
448 short_values: "lsib",
449 short_switches: "uhv",
450 long_values: &["lib", "faked"],
451 long_switches: &["unknown-is-real"],
452 operands: 0,
453 },
454 Wrapper {
455 name: "taskset",
456 short_values: "",
457 short_switches: "acpV",
458 long_values: &[],
459 long_switches: &["all-tasks", "cpu-list", "pid"],
460 operands: 1,
461 },
462 Wrapper {
463 name: "chrt",
464 short_values: "TPD",
465 short_switches: "abdefimoprRv",
466 long_values: &["sched-runtime", "sched-period", "sched-deadline"],
467 long_switches: &[
468 "all-tasks",
469 "batch",
470 "deadline",
471 "fifo",
472 "idle",
473 "other",
474 "pid",
475 "rr",
476 "reset-on-fork",
477 "verbose",
478 "max",
479 ],
480 operands: 1,
481 },
482 Wrapper {
483 name: "prlimit",
484 short_values: "po",
485 short_switches: "",
486 long_values: &["pid", "output"],
487 long_switches: &["noheadings", "raw", "verbose"],
488 operands: 0,
489 },
490 Wrapper {
491 name: "strace",
492 short_values: "abeIoOpPsSuEX",
493 short_switches: "cCdDfFhikNqrtTvVwxyYzZ",
494 long_values: &[
495 "output",
496 "expr",
497 "trace",
498 "signal",
499 "status",
500 "attach",
501 "user",
502 "env",
503 "string-limit",
504 "trace-path",
505 ],
506 long_switches: &[
507 "follow-forks",
508 "output-separately",
509 "summary-only",
510 "summary",
511 "no-abbrev",
512 "verbose",
513 ],
514 operands: 0,
515 },
516 Wrapper {
517 name: "ltrace",
518 short_values: "aAeDFlnopsuxwX",
519 short_switches: "bcCfhiLrStTV",
520 long_values: &["output", "library", "indent", "align"],
521 long_switches: &["demangle", "help", "version"],
522 operands: 0,
523 },
524 Wrapper {
525 name: "systemd-run",
526 short_values: "puEHM",
527 short_switches: "rtqGdPS",
528 long_values: &[
529 "property",
530 "unit",
531 "description",
532 "slice",
533 "setenv",
534 "uid",
535 "gid",
536 "nice",
537 "working-directory",
538 "host",
539 "machine",
540 "service-type",
541 "on-active",
542 "on-boot",
543 "on-startup",
544 "on-unit-active",
545 "on-unit-inactive",
546 "on-calendar",
547 "timer-property",
548 "path-property",
549 "socket-property",
550 ],
551 long_switches: &[
552 "user",
553 "system",
554 "scope",
555 "pty",
556 "pipe",
557 "wait",
558 "collect",
559 "quiet",
560 "no-ask-password",
561 "remain-after-exit",
562 "same-dir",
563 "no-block",
564 "send-sighup",
565 ],
566 operands: 0,
567 },
568 Wrapper {
569 name: "numactl",
570 short_values: "NmCipPw",
571 short_switches: "laHsS",
572 long_values: &[
573 "cpunodebind",
574 "membind",
575 "physcpubind",
576 "interleave",
577 "preferred",
578 "preferred-many",
579 "weighted-interleave",
580 "huge",
581 "offset",
582 "length",
583 "mode",
584 "strict",
585 "shmmode",
586 "shmid",
587 "shm",
588 "file",
589 ],
590 long_switches: &["localalloc", "all", "hardware", "show", "touch"],
591 operands: 0,
592 },
593 Wrapper {
594 name: "firejail",
595 short_values: "",
596 short_switches: "",
597 long_values: &[],
598 long_switches: &[
599 "noprofile",
600 "quiet",
601 "private",
602 "private-dev",
603 "private-tmp",
604 "nonewprivs",
605 "noroot",
606 "seccomp",
607 "x11",
608 "appimage",
609 "allusers",
610 ],
611 operands: 0,
612 },
613 Wrapper {
614 name: "xvfb-run",
615 short_values: "efnpsw",
616 short_switches: "alh",
617 long_values: &[
618 "error-file",
619 "auth-file",
620 "server-num",
621 "xauth-protocol",
622 "server-args",
623 "wait",
624 ],
625 long_switches: &["auto-servernum", "listen-tcp", "help"],
626 operands: 0,
627 },
628 Wrapper {
629 name: "dbus-launch",
630 short_values: "",
631 short_switches: "",
632 long_values: &[],
633 long_switches: &[
634 "sh-syntax",
635 "csh-syntax",
636 "auto-syntax",
637 "binary-syntax",
638 "close-stderr",
639 "exit-with-session",
640 "exit-with-x11",
641 "version",
642 ],
643 operands: 0,
644 },
645 Wrapper {
646 name: "dbus-run-session",
647 short_values: "",
648 short_switches: "",
649 long_values: &["config-file", "dbus-daemon"],
650 long_switches: &["session", "version", "help"],
651 operands: 0,
652 },
653 Wrapper {
654 name: "sg",
655 short_values: "",
656 short_switches: "c",
657 long_values: &[],
658 long_switches: &[],
659 operands: 1,
660 },
661 Wrapper {
662 name: "proxychains",
663 short_values: "f",
664 short_switches: "q",
665 long_values: &[],
666 long_switches: &[],
667 operands: 0,
668 },
669 Wrapper {
670 name: "proxychains4",
671 short_values: "f",
672 short_switches: "q",
673 long_values: &[],
674 long_switches: &[],
675 operands: 0,
676 },
677 Wrapper {
678 name: "torsocks",
679 short_values: "uapP",
680 short_switches: "idqh",
681 long_values: &["user", "pass", "address", "port"],
682 long_switches: &["isolate", "debug", "quiet", "shell", "help", "version"],
683 operands: 0,
684 },
685 Wrapper {
686 name: "tsocks",
687 short_values: "",
688 short_switches: "",
689 long_values: &[],
690 long_switches: &[],
691 operands: 0,
692 },
693 Wrapper {
694 name: "eatmydata",
695 short_values: "",
696 short_switches: "",
697 long_values: &[],
698 long_switches: &[],
699 operands: 0,
700 },
701 Wrapper {
702 name: "cpulimit",
703 short_values: "lpe",
704 short_switches: "mzikvh",
705 long_values: &["limit", "pid", "exe", "cpu"],
706 long_switches: &[
707 "monitor-forks",
708 "lazy",
709 "include-children",
710 "kill",
711 "verbose",
712 "help",
713 ],
714 operands: 0,
715 },
716 Wrapper {
717 name: "setpriv",
718 short_values: "",
719 short_switches: "",
720 long_values: &[
721 "reuid",
722 "regid",
723 "groups",
724 "inh-caps",
725 "ambient-caps",
726 "bounding-set",
727 "securebits",
728 "pdeathsig",
729 "selinux-label",
730 "apparmor-profile",
731 "landlock-access",
732 "landlock-rule",
733 ],
734 long_switches: &[
735 "clear-groups",
736 "keep-groups",
737 "init-groups",
738 "nnp",
739 "no-new-privs",
740 "reset-env",
741 "dump",
742 "list-caps",
743 ],
744 operands: 0,
745 },
746 Wrapper {
747 name: "trickle",
748 short_values: "udwtlnL",
749 short_switches: "svh",
750 long_values: &[],
751 long_switches: &[],
752 operands: 0,
753 },
754 // GNU coreutils as installed on macOS (`brew install coreutils`).
755 Wrapper {
756 name: "gtimeout",
757 short_values: "sk",
758 short_switches: "fpv",
759 long_values: &["signal", "kill-after"],
760 long_switches: &["preserve-status", "foreground", "verbose"],
761 operands: 1,
762 },
763 Wrapper {
764 name: "gnice",
765 short_values: "n",
766 short_switches: "",
767 long_values: &["adjustment"],
768 long_switches: &[],
769 operands: 0,
770 },
771 Wrapper {
772 name: "gnohup",
773 short_values: "",
774 short_switches: "",
775 long_values: &[],
776 long_switches: &[],
777 operands: 0,
778 },
779 Wrapper {
780 name: "gstdbuf",
781 short_values: "ioe",
782 short_switches: "",
783 long_values: &["input", "output", "error"],
784 long_switches: &[],
785 operands: 0,
786 },
787 Wrapper {
788 name: "gchroot",
789 short_values: "ugG",
790 short_switches: "",
791 long_values: &["userspec", "groups"],
792 long_switches: &["skip-chdir"],
793 operands: 1,
794 },
795 ];
796
797 /// Shell reserved words that can lead a simple command without being it:
798 /// `if rm x; then …`, `while rm x; do …`, `! rm x`.
799 const RESERVED_PREFIXES: &[&str] = &[
800 "if", "then", "elif", "else", "do", "while", "until", "!", "coproc",
801 ];
802
803 /// Shells whose `-c` argument is a command line to be parsed, not an operand.
804 const SHELL_NAMES: &[&str] = &[
805 "sh", "bash", "zsh", "dash", "ksh", "ksh93", "mksh", "ash", "fish", "csh", "tcsh", "rbash",
806 "yash",
807 ];
808
809 /// The commands a shell line would run, plus what could not be resolved.
810 #[derive(Debug, Clone, Default, PartialEq, Eq)]
811 pub struct Expansion {
812 /// Every command line found, as [`expanded_commands`] returns them.
813 pub commands: Vec<String>,
814 /// A command word (or a shell's script) is only known at run time, so no
815 /// deny rule can be matched against it reliably.
816 pub dynamic: bool,
817 /// Some code runs nested inside another command: a command or process
818 /// substitution, `eval`, a shell `-c` payload or stdin script, or a
819 /// `find -exec` payload.
820 pub nested: bool,
821 /// An argument's value is resolved only at execution time. A prefix grant
822 /// cannot check whether that value introduces a write/execute option.
823 /// Unlike `dynamic`, this does not make the command head unknowable.
824 pub arguments_dynamic: bool,
825 /// Unquoted control operators or grouping. Recorded before command
826 /// deduplication: `echo x; echo x` still contains a command list.
827 pub control: bool,
828 /// Unquoted redirection syntax, including descriptor duplication and
829 /// heredocs. Quoted operator characters and heredoc body data are excluded.
830 pub redirects: bool,
831 }
832
833 /// Returns every command line the shell would execute for `command`.
834 ///
835 /// Results contain word-split commands, substitutions and wrapper payloads.
836 /// Literal data, including quoted heredoc bodies, is not treated as code.
837 /// Windows scans retain native path separators as well as POSIX candidates.
838 pub fn expanded_commands(command: &str) -> Vec<String> {
839 expand_command(command).commands
840 }
841
842 /// [`expanded_commands`] plus the flags a policy needs to fail closed.
843 pub fn expand_command(command: &str) -> Expansion {
844 expand_for_platform(command, cfg!(windows))
845 }
846
847 #[cfg(test)]
848 fn expanded_commands_for_platform(command: &str, windows: bool) -> Vec<String> {
849 expand_for_platform(command, windows).commands
850 }
851
852 fn expand_for_platform(command: &str, windows: bool) -> Expansion {
853 let mut expander = Expander {
854 out: Vec::new(),
855 seen: HashSet::new(),
856 literal_backslashes: windows,
857 dynamic: false,
858 nested: false,
859 arguments_dynamic: false,
860 control: false,
861 redirects: false,
862 };
863 // Native Windows shells preserve path separators. Also retain the POSIX
864 // interpretation for Bash/WSL commands. Both passes use the same bounded,
865 // heredoc-aware parser and only contribute deny targets, never grants.
866 expander.expand(command, 0);
867 if windows {
868 expander.literal_backslashes = false;
869 expander.expand(command, 0);
870 }
871 Expansion {
872 commands: expander.out,
873 dynamic: expander.dynamic,
874 nested: expander.nested,
875 arguments_dynamic: expander.arguments_dynamic,
876 control: expander.control,
877 redirects: expander.redirects,
878 }
879 }
880
881 struct Expander {
882 out: Vec<String>,
883 seen: HashSet<String>,
884 literal_backslashes: bool,
885 dynamic: bool,
886 nested: bool,
887 arguments_dynamic: bool,
888 control: bool,
889 redirects: bool,
890 }
891
892 /// The word being read, with what the parser learned about it.
893 #[derive(Default)]
894 struct WordBuf {
895 text: String,
896 started: bool,
897 quoted: bool,
898 redirect_operand: bool,
899 /// The word's final text depends on an expansion the parser does not run.
900 dynamic: bool,
901 /// An unquoted `{` appeared, so brace expansion may rewrite the word.
902 brace: bool,
903 /// An unquoted `[` appeared, so the word may be a bracket glob.
904 bracket: bool,
905 }
906
907 impl WordBuf {
908 fn flush(&mut self, line: &mut Line) {
909 if self.started || !self.text.is_empty() || self.dynamic {
910 if self.redirect_operand {
911 self.text.clear();
912 self.redirect_operand = false;
913 } else {
914 // `[` alone is the test builtin and `{}` is a find
915 // placeholder; only a closed bracket or a brace list expands.
916 let dynamic = self.dynamic
917 || (self.bracket && self.text.len() > 1 && self.text.contains(']'))
918 || (self.brace && (self.text.contains(',') || self.text.contains("..")));
919 line.words.push(std::mem::take(&mut self.text));
920 line.dynamic.push(dynamic);
921 }
922 self.started = false;
923 }
924 self.quoted = false;
925 self.dynamic = false;
926 self.brace = false;
927 self.bracket = false;
928 }
929 }
930
931 /// One simple command: its words, which of them are dynamic, and whether a
932 /// heredoc feeds its stdin.
933 #[derive(Default, Clone)]
934 struct Line {
935 words: Vec<String>,
936 dynamic: Vec<bool>,
937 heredoc: bool,
938 }
939
940 /// How a shell invocation receives its script.
941 enum ShellInput {
942 /// `-c` / `--command`: the word at each offset may be the command line.
943 Command(Vec<usize>),
944 /// A script file operand at this offset.
945 Script(usize),
946 /// The script is read from stdin.
947 Stdin,
948 }
949
950 impl Expander {
951 fn emit(&mut self, tokens: &[String]) {
952 let joined = tokens
953 .iter()
954 .filter(|token| !token.is_empty())
955 .cloned()
956 .collect::<Vec<_>>()
957 .join(" ");
958 if joined.is_empty() || self.seen.contains(&joined) {
959 return;
960 }
961 if self.out.len() >= MAX_COMMANDS {
962 // A dropped command line was never checked: unresolved.
963 self.dynamic = true;
964 return;
965 }
966 self.seen.insert(joined.clone());
967 self.out.push(joined);
968 }
969
970 /// Word-split `input` into command lines and record each one, recursing
971 /// into every nested command text found along the way.
972 fn expand(&mut self, input: &str, depth: usize) {
973 if depth > MAX_DEPTH || self.out.len() >= MAX_COMMANDS {
974 // Unexamined code is unresolved code.
975 self.dynamic = true;
976 return;
977 }
978 if depth > 0 && !input.trim().is_empty() {
979 self.nested = true;
980 }
981 // shlex does not implement ANSI-C/localized quoting or shell CR
982 // semantics. Keep the conservative scan for those forms rather than
983 // let an unrecognized heredoc delimiter hide following commands.
984 if input.contains('\r') || input.contains("$'") || input.contains("$\"") {
985 for segment in super::command_segments(input) {
986 self.emit(&[segment]);
987 }
988 }
989 let chars: Vec<char> = input.chars().collect();
990 let n = chars.len();
991 let mut i = 0usize;
992 let mut commands: Vec<Line> = Vec::new();
993 let mut line = Line::default();
994 let mut word = WordBuf::default();
995 let mut nested: Vec<String> = Vec::new();
996 let mut heredocs = Vec::new();
997 // Set when the text does not parse cleanly (an unterminated quote,
998 // substitution or heredoc, a `case` inside a substitution): where
999 // this parser ends a region may not be where the shell ends it.
1000 let mut uncertain = false;
1001
1002 while i < n {
1003 let c = chars[i];
1004 match c {
1005 '#' if !word.started && word.text.is_empty() => {
1006 while i < n && chars[i] != '\n' {
1007 i += 1;
1008 }
1009 }
1010 // A backslash outside quotes escapes exactly one character,
1011 // including an operator: `echo a\;b` is one word, not two
1012 // commands. A backslash-newline is a line continuation.
1013 '\\' if self.literal_backslashes => {
1014 word.text.push('\\');
1015 word.started = true;
1016 i += 1;
1017 }
1018 '\\' => {
1019 if i + 1 < n {
1020 if chars[i + 1] != '\n' {
1021 word.text.push(chars[i + 1]);
1022 word.started = true;
1023 word.quoted = true;
1024 }
1025 i += 2;
1026 } else {
1027 i += 1;
1028 }
1029 }
1030 // Single quotes are fully literal: no substitution, no escapes.
1031 '\'' => {
1032 word.started = true;
1033 word.quoted = true;
1034 i += 1;
1035 while i < n && chars[i] != '\'' {
1036 word.text.push(chars[i]);
1037 i += 1;
1038 }
1039 uncertain |= i >= n;
1040 i = (i + 1).min(n);
1041 }
1042 // Double quotes suppress word splitting but NOT substitution.
1043 '"' => {
1044 word.started = true;
1045 word.quoted = true;
1046 i += 1;
1047 while i < n && chars[i] != '"' {
1048 match chars[i] {
1049 '\\' if self.literal_backslashes => {
1050 word.text.push('\\');
1051 i += 1;
1052 }
1053 '\\' if i + 1 < n => {
1054 word.text.push(chars[i + 1]);
1055 i += 2;
1056 }
1057 '`' => {
1058 word.dynamic = true;
1059 let (inner, next, certain) = read_backtick(&chars, i);
1060 uncertain |= !certain;
1061 nested.push(inner);
1062 i = next;
1063 }
1064 '$' if i + 1 < n && matches!(chars[i + 1], '(' | '{') => {
1065 word.dynamic = true;
1066 let (inner, next, certain) = read_substitution(&chars, i + 1);
1067 uncertain |= !certain;
1068 nested.push(inner);
1069 i = next;
1070 }
1071 ch => {
1072 if ch == '$' && starts_parameter(chars.get(i + 1)) {
1073 word.dynamic = true;
1074 }
1075 word.text.push(ch);
1076 i += 1;
1077 }
1078 }
1079 }
1080 uncertain |= i >= n;
1081 i = (i + 1).min(n);
1082 }
1083 // `$'…'` (ANSI-C quoting) is literal text with C escapes. The
1084 // escapes are not decoded here, so a word that uses them is
1085 // not known statically (`$'\x72m'` is `rm`).
1086 '$' if i + 1 < n && chars[i + 1] == '\'' => {
1087 word.started = true;
1088 word.quoted = true;
1089 i += 2;
1090 while i < n && chars[i] != '\'' {
1091 if chars[i] == '\\' && i + 1 < n {
1092 word.dynamic = true;
1093 word.text.push(chars[i + 1]);
1094 i += 2;
1095 } else {
1096 word.text.push(chars[i]);
1097 i += 1;
1098 }
1099 }
1100 uncertain |= i >= n;
1101 i = (i + 1).min(n);
1102 }
1103 // Command substitution, both spellings. The body is a command
1104 // line in its own right; the substitution contributes no text
1105 // to the enclosing word (we do not evaluate output).
1106 '`' => {
1107 word.started |= word.redirect_operand;
1108 word.dynamic = true;
1109 let (inner, next, certain) = read_backtick(&chars, i);
1110 uncertain |= !certain;
1111 nested.push(inner);
1112 i = next;
1113 }
1114 // `$(…)`, and `${…}`: an expansion, not a command — but it can
1115 // *contain* one (`${x:-$(rm -rf /)}`), so the body is rescanned.
1116 // Process substitution `<(…)` / `>(…)` also runs its body.
1117 '$' | '<' | '>'
1118 if i + 1 < n && (chars[i + 1] == '(' || (c == '$' && chars[i + 1] == '{')) =>
1119 {
1120 word.started |= word.redirect_operand;
1121 word.dynamic = true;
1122 let (inner, next, certain) = read_substitution(&chars, i + 1);
1123 uncertain |= !certain;
1124 nested.push(inner);
1125 i = next;
1126 }
1127 '<' if chars.get(i + 1) == Some(&'<') && chars.get(i + 2) != Some(&'<') => {
1128 self.redirects = true;
1129 if !word.quoted && is_redirect_descriptor(&word.text) {
1130 word.text.clear();
1131 word.started = false;
1132 }
1133 word.flush(&mut line);
1134 line.heredoc = true;
1135 i += 2;
1136 let strip_tabs = chars.get(i) == Some(&'-');
1137 if strip_tabs {
1138 i += 1;
1139 }
1140 while i < n && matches!(chars[i], ' ' | '\t') {
1141 i += 1;
1142 }
1143 let start = i;
1144 let mut quote = None;
1145 let mut literal = false;
1146 while i < n {
1147 let ch = chars[i];
1148 if quote.is_none()
1149 && matches!(ch, ' ' | '\t' | '\n' | ';' | '|' | '&' | '<' | '>')
1150 {
1151 break;
1152 }
1153 if ch == '\\' && quote != Some('\'') {
1154 literal = true;
1155 i = (i + 2).min(n);
1156 continue;
1157 }
1158 if matches!(ch, '\'' | '"') {
1159 literal = true;
1160 if quote == Some(ch) {
1161 quote = None;
1162 } else if quote.is_none() {
1163 quote = Some(ch);
1164 }
1165 }
1166 i += 1;
1167 }
1168 let raw: String = chars[start..i].iter().collect();
1169 if let Some(delimiter) = shlex::split(&raw)
1170 .and_then(|mut words| (words.len() == 1).then(|| words.remove(0)))
1171 {
1172 heredocs.push((delimiter, literal, strip_tabs));
1173 }
1174 }
1175 // Unquoted redirections are syntax, even without whitespace.
1176 // Keep the command words on both sides together, but omit the
1177 // descriptor and next operand. Parse that operand normally so
1178 // nested substitutions are still checked as commands.
1179 '<' | '>' | '&' if redirection_len(&chars[i..]) > 0 => {
1180 self.redirects = true;
1181 if !word.quoted && is_redirect_descriptor(&word.text) {
1182 word.text.clear();
1183 word.started = false;
1184 }
1185 word.flush(&mut line);
1186 word.redirect_operand = true;
1187 i += redirection_len(&chars[i..]);
1188 }
1189 ' ' | '\t' => {
1190 word.flush(&mut line);
1191 i += 1;
1192 }
1193 // A subshell boundary. `$(`, `<(` and `>(` were consumed by the
1194 // arms above, so a bare paren here is grouping: the body is a
1195 // command list of its own, not part of the surrounding word.
1196 '(' | ')' => {
1197 self.control = true;
1198 word.flush(&mut line);
1199 end_command(&mut commands, &mut line);
1200 word.redirect_operand = false;
1201 if c == '(' && chars.get(i + 1) == Some(&'(') {
1202 // `(( … ))` is arithmetic, where `<<` is a shift and
1203 // not a heredoc. Its substitutions still run, and
1204 // `((cmd) )` is two subshells, so the body is scanned
1205 // as code on its own; a heredoc it appears to open
1206 // cannot swallow the lines after it.
1207 let (inner, next, certain) = read_substitution(&chars, i);
1208 uncertain |= !certain;
1209 nested.push(inner);
1210 i = next;
1211 } else {
1212 i += 1;
1213 }
1214 }
1215 // Control operators end the current command line. `&&`, `||`,
1216 // `;;`, `|&` and runs of newlines collapse into one break.
1217 '\n' | '\r' | ';' | '&' | '|' => {
1218 self.control = true;
1219 word.flush(&mut line);
1220 end_command(&mut commands, &mut line);
1221 word.redirect_operand = false;
1222 i += 1;
1223 if c == '\n' && !heredocs.is_empty() {
1224 let shell_stdin = commands.iter().any(|line| {
1225 command_heads(&line.words).heads.iter().any(|&head| {
1226 let name = command_name(&line.words[head]);
1227 SHELL_NAMES.contains(&name.as_str())
1228 || matches!(name.as_str(), "source" | ".")
1229 })
1230 });
1231 for (delimiter, literal, strip_tabs) in heredocs.drain(..) {
1232 let mut body = String::new();
1233 // A body that runs to the end of the input may be
1234 // a misread `<<`; what it swallowed is unexamined.
1235 let mut terminated = false;
1236 while i < n {
1237 let start = i;
1238 while i < n && chars[i] != '\n' {
1239 i += 1;
1240 }
1241 let mut text: String = chars[start..i].iter().collect();
1242 if i < n {
1243 i += 1;
1244 }
1245 // An unquoted heredoc joins escaped newlines
1246 // before checking its delimiter (E\ + OF can
1247 // terminate EOF). Do not swallow later code.
1248 while !literal
1249 && text.chars().rev().take_while(|c| *c == '\\').count() % 2
1250 == 1
1251 && i < n
1252 {
1253 text.pop();
1254 let start = i;
1255 while i < n && chars[i] != '\n' {
1256 i += 1;
1257 }
1258 text.extend(chars[start..i].iter());
1259 if i < n {
1260 i += 1;
1261 }
1262 }
1263 let text = if strip_tabs {
1264 text.trim_start_matches('\t')
1265 } else {
1266 &text
1267 };
1268 if text == delimiter {
1269 terminated = true;
1270 break;
1271 }
1272 body.push_str(text);
1273 body.push('\n');
1274 }
1275 uncertain |= !terminated;
1276 if shell_stdin {
1277 nested.push(body);
1278 } else if !literal {
1279 let (bodies, certain) = heredoc_substitutions(&body);
1280 uncertain |= !certain;
1281 nested.extend(bodies);
1282 }
1283 }
1284 }
1285 }
1286 _ => {
1287 if c == '$' && starts_parameter(chars.get(i + 1)) {
1288 word.dynamic = true;
1289 }
1290 match c {
1291 '*' | '?' => word.dynamic = true,
1292 '[' => word.bracket = true,
1293 '{' => word.brace = true,
1294 _ => {}
1295 }
1296 word.text.push(c);
1297 word.started = true;
1298 i += 1;
1299 }
1300 }
1301 }
1302 word.flush(&mut line);
1303 end_command(&mut commands, &mut line);
1304 self.dynamic |= uncertain;
1305
1306 for line in &commands {
1307 self.record(line, depth);
1308 }
1309 for inner in nested {
1310 self.expand(&inner, depth + 1);
1311 }
1312 }
1313
1314 /// Record one word-split command line, plus the invocation hiding inside it
1315 /// when the head is a wrapper, and flag what cannot be resolved.
1316 fn record(&mut self, line: &Line, depth: usize) {
1317 let tokens = &line.words;
1318 if tokens.is_empty() {
1319 return;
1320 }
1321 if depth > MAX_DEPTH {
1322 self.dynamic = true;
1323 return;
1324 }
1325 self.emit(tokens);
1326
1327 let heads = command_heads(tokens);
1328 self.dynamic |= heads.truncated;
1329 for payload in &heads.split_payloads {
1330 // `env -S 'cmd args'` splits its value into a command line.
1331 self.expand(payload, depth + 1);
1332 }
1333 let mut dynamic = line.dynamic.clone();
1334 mark_replacement_operands(tokens, &heads.heads, &mut dynamic);
1335 for &head in &heads.heads {
1336 // `sudo rm -rf /` is an `rm -rf /`: emit the command the wrappers
1337 // run, with their options and operands removed.
1338 if head > 0 {
1339 self.emit(&tokens[head..]);
1340 }
1341 // Also keep a wrapper's options in front of the command it runs,
1342 // so a deny rule can still skip them as flags if the option table
1343 // misreads one. (`command -v NAME` runs nothing: no head follows.)
1344 if wrapper_index(&tokens[head]).is_some()
1345 && heads.heads.iter().any(|&inner| inner > head)
1346 {
1347 self.emit(&tokens[head + 1..]);
1348 }
1349 // A command word (or a wrapper word before it) that the shell
1350 // rewrites at run time cannot be matched against any rule.
1351 if tokens[..=head]
1352 .iter()
1353 .zip(&dynamic)
1354 .any(|(token, dynamic)| *dynamic && !is_env_assignment(token))
1355 {
1356 self.dynamic = true;
1357 }
1358 let name = command_name(&tokens[head]);
1359 let args_dynamic = dynamic[head + 1..].iter().any(|dynamic| *dynamic);
1360 self.arguments_dynamic |= args_dynamic;
1361 match name.as_str() {
1362 // `eval …` takes a *command line* as data. Parse it.
1363 "eval" => self.code_payload(&tokens[head + 1..].join(" "), args_dynamic, depth),
1364 "source" | "." => match tokens.get(head + 1) {
1365 None if !line.heredoc => self.dynamic = true,
1366 Some(_) if dynamic[head + 1] => self.dynamic = true,
1367 Some(script) => self.dynamic |= script_read_at_run_time(script, line),
1368 _ => {}
1369 },
1370 "find" => self.record_find_exec(line, head, depth),
1371 // `trap 'code' SIGNAL…` runs its first operand later in this
1372 // shell.
1373 "trap" => {
1374 if let Some(at) =
1375 (head + 1..tokens.len()).find(|&at| !tokens[at].starts_with('-'))
1376 {
1377 self.code_payload(&tokens[at], dynamic[at], depth);
1378 }
1379 }
1380 // These hand the value of `-c` / `--command` to a shell.
1381 "su" | "runuser" | "flock" | "script" => {
1382 for (payload, payload_dynamic) in
1383 command_option_payloads(tokens, head, &dynamic)
1384 {
1385 self.code_payload(&payload, payload_dynamic, depth);
1386 }
1387 }
1388 // `watch` and `sg GROUP [-c]` join their operands and hand
1389 // them to `sh -c`.
1390 "watch" | "sg" => {
1391 for &inner in heads.heads.iter().filter(|&&inner| inner > head) {
1392 let inner_dynamic = dynamic[inner..].iter().any(|dynamic| *dynamic);
1393 self.code_payload(&tokens[inner..].join(" "), inner_dynamic, depth);
1394 }
1395 }
1396 // `cmd /c …` parses the rest as a cmd.exe command line, which
1397 // expands `%VAR%` and `!VAR!` and strips `^` escapes itself.
1398 "cmd" => {
1399 if let Some(at) = (head + 1..tokens.len()).find(|&at| {
1400 matches!(tokens[at].to_ascii_lowercase().as_str(), "/c" | "/k" | "/r")
1401 }) {
1402 let payload = tokens[at + 1..].join(" ");
1403 let payload_dynamic = dynamic[at + 1..].iter().any(|dynamic| *dynamic)
1404 || payload.contains(['%', '!', '^']);
1405 self.code_payload(&payload, payload_dynamic, depth);
1406 }
1407 }
1408 "powershell" | "pwsh" => self.record_powershell(tokens, head, &dynamic, depth),
1409 _ if SHELL_NAMES.contains(&name.as_str()) => {
1410 match shell_input(&tokens[head..]) {
1411 ShellInput::Command(offsets) => {
1412 for offset in offsets {
1413 self.code_payload(
1414 &tokens[head + offset],
1415 dynamic[head + offset],
1416 depth,
1417 );
1418 }
1419 }
1420 ShellInput::Script(offset) => {
1421 self.dynamic |= dynamic[head + offset]
1422 || script_read_at_run_time(&tokens[head + offset], line);
1423 }
1424 // A heredoc body was already expanded as the script;
1425 // any other stdin is only known at run time.
1426 ShellInput::Stdin => self.dynamic |= !line.heredoc,
1427 }
1428 }
1429 _ => {}
1430 }
1431 }
1432 }
1433
1434 /// Code handed to another command as a string: parse it as a command line.
1435 fn code_payload(&mut self, payload: &str, dynamic: bool, depth: usize) {
1436 self.dynamic |= dynamic;
1437 self.expand(payload, depth + 1);
1438 }
1439
1440 /// `powershell` / `pwsh`: `-Command` (or any prefix of it) takes the rest
1441 /// of the line as code, `-EncodedCommand` cannot be read statically, and
1442 /// a bare operand may start code too (Windows PowerShell's default).
1443 /// PowerShell syntax is only approximated by the POSIX parser, which is
1444 /// enough to find the command words a deny rule names.
1445 fn record_powershell(
1446 &mut self,
1447 tokens: &[String],
1448 head: usize,
1449 dynamic: &[bool],
1450 depth: usize,
1451 ) {
1452 let rest = |at: usize| {
1453 (
1454 tokens[at..].join(" "),
1455 dynamic[at..].iter().any(|dynamic| *dynamic),
1456 )
1457 };
1458 let mut positional_seen = false;
1459 for at in head + 1..tokens.len() {
1460 let Some(option) = tokens[at].strip_prefix(['-', '/']) else {
1461 if !positional_seen {
1462 positional_seen = true;
1463 let (payload, payload_dynamic) = rest(at);
1464 self.code_payload(&payload, payload_dynamic, depth);
1465 }
1466 continue;
1467 };
1468 let option = option.to_ascii_lowercase();
1469 if option.is_empty() {
1470 continue;
1471 }
1472 if "command".starts_with(option.as_str()) {
1473 if at + 1 >= tokens.len() || tokens[at + 1] == "-" {
1474 // The code arrives on stdin.
1475 self.dynamic = true;
1476 } else {
1477 let (payload, payload_dynamic) = rest(at + 1);
1478 self.code_payload(&payload, payload_dynamic, depth);
1479 }
1480 return;
1481 }
1482 if option == "ec" || "encodedcommand".starts_with(option.as_str()) {
1483 self.dynamic = true;
1484 return;
1485 }
1486 if "file".starts_with(option.as_str()) {
1487 // A script file is opaque, as it is for every other shell.
1488 return;
1489 }
1490 }
1491 }
1492
1493 /// `find … -exec CMD … ;` runs `CMD` for every match.
1494 fn record_find_exec(&mut self, line: &Line, head: usize, depth: usize) {
1495 let tokens = &line.words;
1496 let mut index = head + 1;
1497 while index < tokens.len() {
1498 if matches!(
1499 tokens[index].as_str(),
1500 "-exec" | "-execdir" | "-ok" | "-okdir"
1501 ) {
1502 let start = index + 1;
1503 let mut end = start;
1504 while end < tokens.len() && !matches!(tokens[end].as_str(), ";" | "+") {
1505 end += 1;
1506 }
1507 if start < end {
1508 self.nested = true;
1509 // `{}` is replaced by each file name found, so a word
1510 // holding it is only known at run time.
1511 let payload = Line {
1512 words: tokens[start..end].to_vec(),
1513 dynamic: (start..end)
1514 .map(|at| line.dynamic[at] || tokens[at].contains("{}"))
1515 .collect(),
1516 heredoc: false,
1517 };
1518 self.record(&payload, depth + 1);
1519 }
1520 index = end;
1521 }
1522 index += 1;
1523 }
1524 }
1525 }
1526
1527 /// Values of `-c CMD`, `-cCMD`, `--command CMD`, `--command=CMD` and
1528 /// `--session-command` after `tokens[head]`, with whether each is dynamic:
1529 /// the code `su`, `runuser`, `flock` and `script` hand to a shell.
1530 fn command_option_payloads(
1531 tokens: &[String],
1532 head: usize,
1533 dynamic: &[bool],
1534 ) -> Vec<(String, bool)> {
1535 let mut payloads = Vec::new();
1536 for (at, token) in tokens.iter().enumerate().skip(head + 1) {
1537 let next = || {
1538 tokens
1539 .get(at + 1)
1540 .map(|value| (value.clone(), dynamic[at + 1]))
1541 };
1542 if let Some(long) = token.strip_prefix("--") {
1543 let (name, inline) = match long.split_once('=') {
1544 Some((name, value)) => (name, Some(value)),
1545 None => (long, None),
1546 };
1547 if matches!(name, "command" | "session-command") {
1548 payloads.extend(match inline {
1549 Some(value) => Some((value.to_string(), dynamic[at])),
1550 None => next(),
1551 });
1552 }
1553 } else if let Some(flags) = token.strip_prefix('-')
1554 && let Some(offset) = flags.find('c')
1555 {
1556 let value = &flags[offset + 1..];
1557 payloads.extend(if value.is_empty() {
1558 next()
1559 } else {
1560 Some((value.to_string(), dynamic[at]))
1561 });
1562 }
1563 }
1564 payloads
1565 }
1566
1567 /// Mark the operands of an `xargs` that contain its replacement string
1568 /// (`-I R`, `-J R`, `-i[R]`, `--replace[=R]`, `{}` by default) as known only
1569 /// at run time: `xargs -I{} sh -c {}` runs whatever arrives on stdin.
1570 fn mark_replacement_operands(tokens: &[String], heads: &[usize], dynamic: &mut [bool]) {
1571 let Some(xargs) = WRAPPERS.iter().find(|wrapper| wrapper.name == "xargs") else {
1572 return;
1573 };
1574 for &head in heads {
1575 if command_name(&tokens[head]) != "xargs" {
1576 continue;
1577 }
1578 let mut replacements: Vec<String> = Vec::new();
1579 let mut values = HashSet::new();
1580 let mut index = head + 1;
1581 while index < tokens.len() && tokens[index].starts_with('-') && tokens[index] != "--" {
1582 let token = tokens[index].as_str();
1583 if let Some(long) = token.strip_prefix("--") {
1584 match long.split_once('=') {
1585 Some(("replace", value)) => replacements.push(value.to_string()),
1586 None if long == "replace" => replacements.push("{}".to_string()),
1587 None if xargs.long_values.contains(&long) => {
1588 values.insert(index + 1);
1589 index += 1;
1590 }
1591 _ => {}
1592 }
1593 } else {
1594 for (at, flag) in token[1..].char_indices() {
1595 let rest = &token[1 + at + flag.len_utf8()..];
1596 if flag == 'i' {
1597 replacements.push(if rest.is_empty() { "{}" } else { rest }.to_string());
1598 break;
1599 }
1600 if xargs.short_values.contains(flag) {
1601 let value = if rest.is_empty() {
1602 values.insert(index + 1);
1603 index += 1;
1604 tokens.get(index).cloned()
1605 } else {
1606 Some(rest.to_string())
1607 };
1608 if matches!(flag, 'I' | 'J') {
1609 replacements.extend(value);
1610 }
1611 break;
1612 }
1613 }
1614 }
1615 index += 1;
1616 }
1617 for (at, token) in tokens.iter().enumerate().skip(head + 1) {
1618 if !values.contains(&at)
1619 && !token.starts_with('-')
1620 && replacements.iter().any(|replacement| {
1621 !replacement.is_empty() && token.contains(replacement.as_str())
1622 })
1623 {
1624 dynamic[at] = true;
1625 }
1626 }
1627 }
1628 }
1629
1630 /// True when `$` followed by `next` starts a parameter expansion (`$v`, `$1`,
1631 /// `$@`, `$"…"`), as opposed to a literal dollar sign.
1632 fn starts_parameter(next: Option<&char>) -> bool {
1633 next.is_some_and(|c| {
1634 c.is_ascii_alphanumeric()
1635 || matches!(c, '_' | '@' | '*' | '#' | '?' | '$' | '!' | '-' | '"')
1636 })
1637 }
1638
1639 /// Unquoted heredocs expand substitutions, but quotes and ordinary lines are
1640 /// data. Also reports whether every substitution was read with certainty.
1641 fn heredoc_substitutions(body: &str) -> (Vec<String>, bool) {
1642 let chars: Vec<char> = body.chars().collect();
1643 let mut result = Vec::new();
1644 let mut all_certain = true;
1645 let mut i = 0;
1646 while i < chars.len() {
1647 match chars[i] {
1648 '\\' if chars
1649 .get(i + 1)
1650 .is_some_and(|c| matches!(c, '$' | '`' | '\\' | '\n')) =>
1651 {
1652 i += 2
1653 }
1654 '`' => {
1655 let (inner, next, certain) = read_backtick(&chars, i);
1656 all_certain &= certain;
1657 result.push(inner);
1658 i = next;
1659 }
1660 '$' if chars.get(i + 1) == Some(&'(') => {
1661 let (inner, next, certain) = read_substitution(&chars, i + 1);
1662 all_certain &= certain;
1663 result.push(inner);
1664 i = next;
1665 }
1666 _ => i += 1,
1667 }
1668 }
1669 (result, all_certain)
1670 }
1671
1672 fn redirection_len(chars: &[char]) -> usize {
1673 match chars {
1674 ['&', '>', '>', ..] | ['<', '<', '<' | '-', ..] => 3,
1675 ['&', '>', ..] | ['<', '<' | '>' | '&', ..] | ['>', '>' | '&' | '|', ..] => 2,
1676 ['<' | '>', ..] => 1,
1677 _ => 0,
1678 }
1679 }
1680
1681 fn is_redirect_descriptor(word: &str) -> bool {
1682 if !word.is_empty() && word.bytes().all(|b| b.is_ascii_digit()) {
1683 return true;
1684 }
1685 // Bash also accepts an unquoted `{name}` in place of an IO number.
1686 word.strip_prefix('{')
1687 .and_then(|word| word.strip_suffix('}'))
1688 .is_some_and(|name| {
1689 let mut chars = name.chars();
1690 chars
1691 .next()
1692 .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
1693 && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
1694 })
1695 }
1696
1697 /// Close the current simple command.
1698 ///
1699 /// `{` and `}` stand alone as reserved words in `{ cmd; }` — they group a
1700 /// command rather than being part of one, so they split the line
1701 /// (`function f { rm x; }` runs `rm x`). Reserved words that lead a command
1702 /// (`if`, `then`, `while`, `do`, `!`, …) are dropped so the word after them is
1703 /// read as the command word. Every downstream consumer (wrapper detection,
1704 /// emission) then looks at real command words only.
1705 fn end_command(commands: &mut Vec<Line>, line: &mut Line) {
1706 let line = std::mem::take(line);
1707 let mut piece = Line {
1708 heredoc: line.heredoc,
1709 ..Line::default()
1710 };
1711 for (word, dynamic) in line.words.into_iter().zip(line.dynamic) {
1712 if matches!(word.as_str(), "{" | "}") {
1713 push_command(commands, &mut piece);
1714 continue;
1715 }
1716 if piece.words.is_empty() && RESERVED_PREFIXES.contains(&word.as_str()) {
1717 continue;
1718 }
1719 piece.words.push(word);
1720 piece.dynamic.push(dynamic);
1721 }
1722 push_command(commands, &mut piece);
1723 }
1724
1725 fn push_command(commands: &mut Vec<Line>, piece: &mut Line) {
1726 if !piece.words.is_empty() {
1727 let heredoc = piece.heredoc;
1728 commands.push(std::mem::take(piece));
1729 piece.heredoc = heredoc;
1730 }
1731 }
1732
1733 /// Read a backtick substitution. `start` indexes the opening backtick; returns
1734 /// the body, the index just past the closing backtick, and whether a closing
1735 /// backtick was found.
1736 fn read_backtick(chars: &[char], start: usize) -> (String, usize, bool) {
1737 let mut i = start + 1;
1738 let mut inner = String::new();
1739 while i < chars.len() {
1740 match chars[i] {
1741 '\\' if i + 1 < chars.len() => {
1742 inner.push(chars[i]);
1743 inner.push(chars[i + 1]);
1744 i += 2;
1745 }
1746 '`' => return (inner, i + 1, true),
1747 c => {
1748 inner.push(c);
1749 i += 1;
1750 }
1751 }
1752 }
1753 (inner, i, false)
1754 }
1755
1756 /// Read a `( … )` or `{ … }` region. `open_at` indexes the opening delimiter;
1757 /// returns the body, the index just past the matching close, and whether the
1758 /// region was read with certainty.
1759 ///
1760 /// Quoted text, escapes, backticks and nested `$(`/`${` are skipped the way
1761 /// the shell skips them, so `$(echo ")"; rm x)` closes at the last paren. A
1762 /// `case` inside a `$( … )` is flagged uncertain rather than parsed: its
1763 /// `pattern)` closes no paren, so the shell may end the region elsewhere. An
1764 /// unterminated region is uncertain too.
1765 fn read_substitution(chars: &[char], open_at: usize) -> (String, usize, bool) {
1766 let n = chars.len();
1767 let (open, close) = if chars[open_at] == '{' {
1768 ('{', '}')
1769 } else {
1770 ('(', ')')
1771 };
1772 let start = open_at + 1;
1773 let mut depth = 1usize;
1774 let mut certain = true;
1775 let mut i = start;
1776 while i < n {
1777 let c = chars[i];
1778 match c {
1779 '\\' => i += 2,
1780 '\'' => {
1781 i += 1;
1782 while i < n && chars[i] != '\'' {
1783 i += 1;
1784 }
1785 certain &= i < n;
1786 i += 1;
1787 }
1788 '"' => {
1789 i += 1;
1790 while i < n && chars[i] != '"' {
1791 match chars[i] {
1792 '\\' => i += 2,
1793 '`' => {
1794 let (_, next, inner_certain) = read_backtick(chars, i);
1795 certain &= inner_certain;
1796 i = next;
1797 }
1798 '$' if matches!(chars.get(i + 1), Some('(' | '{')) => {
1799 let (_, next, inner_certain) = read_substitution(chars, i + 1);
1800 certain &= inner_certain;
1801 i = next;
1802 }
1803 _ => i += 1,
1804 }
1805 }
1806 certain &= i < n;
1807 i += 1;
1808 }
1809 '`' => {
1810 let (_, next, inner_certain) = read_backtick(chars, i);
1811 certain &= inner_certain;
1812 i = next;
1813 }
1814 '$' if matches!(chars.get(i + 1), Some('(' | '{')) => {
1815 let (_, next, inner_certain) = read_substitution(chars, i + 1);
1816 certain &= inner_certain;
1817 i = next;
1818 }
1819 _ if c == close => {
1820 depth -= 1;
1821 if depth == 0 {
1822 return (chars[start..i].iter().collect(), i + 1, certain);
1823 }
1824 i += 1;
1825 }
1826 _ => {
1827 if c == open {
1828 depth += 1;
1829 } else if open == '('
1830 && c == 'c'
1831 && chars[i..].starts_with(&['c', 'a', 's', 'e'])
1832 && chars.get(i + 4).is_none_or(|next| next.is_whitespace())
1833 && (i == start
1834 || matches!(
1835 chars[i - 1],
1836 ' ' | '\t' | '\n' | ';' | '&' | '|' | '(' | '!'
1837 ))
1838 {
1839 certain = false;
1840 }
1841 i += 1;
1842 }
1843 }
1844 }
1845 (chars[start..n.max(start)].iter().collect(), n, false)
1846 }
1847
1848 /// The final path component, so `/usr/bin/sudo` reads as `sudo`.
1849 fn basename(token: &str) -> &str {
1850 token
1851 .rsplit(['/', '\\'])
1852 .next()
1853 .filter(|part| !part.is_empty())
1854 .unwrap_or(token)
1855 }
1856
1857 fn is_env_assignment(token: &str) -> bool {
1858 match token.split_once('=') {
1859 Some((name, _)) => {
1860 !name.is_empty()
1861 && !name.starts_with('-')
1862 && name
1863 .chars()
1864 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1865 }
1866 None => false,
1867 }
1868 }
1869
1870 /// True for a bare scalar operand that may belong to a wrapper word rather
1871 /// than start a command — `timeout 5`, `nice -n 10`, `timeout 1.5s`.
1872 fn is_scalar_operand(token: &str) -> bool {
1873 let body = token.trim_end_matches(['s', 'm', 'h', 'd']);
1874 !body.is_empty() && body.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
1875 }
1876
1877 /// The command a word names, for comparing against a table of names: the
1878 /// lowercased basename with any `.exe` suffix removed, so `/usr/bin/sudo`,
1879 /// `bash.exe` and `C:\Git\bin\bash.EXE` read as `sudo` and `bash`.
1880 fn command_name(token: &str) -> String {
1881 let name = basename(token).to_ascii_lowercase();
1882 match name.strip_suffix(".exe") {
1883 Some(stem) if !stem.is_empty() => stem.to_string(),
1884 _ => name,
1885 }
1886 }
1887
1888 /// Index into [`WRAPPERS`] when `token` names a wrapper word.
1889 fn wrapper_index(token: &str) -> Option<usize> {
1890 let name = command_name(token);
1891 WRAPPERS.iter().position(|wrapper| wrapper.name == name)
1892 }
1893
1894 /// What [`command_heads`] found.
1895 struct Heads {
1896 /// Every index that may be a command word, ascending.
1897 heads: Vec<usize>,
1898 /// `env -S` values, which are command lines.
1899 split_payloads: Vec<String>,
1900 /// The walk ran out of states before every reading was explored.
1901 truncated: bool,
1902 }
1903
1904 /// Every index in `tokens` that may be a command word, walking leading
1905 /// environment assignments and wrapper words together with the options and
1906 /// operands those wrappers take. Wrapper words are themselves command words
1907 /// and are included. Also returns `env -S` payloads, which are command lines.
1908 ///
1909 /// Where the grammar is ambiguous (an option missing from the table that may
1910 /// take a value, a bare number after a wrapper) both readings are kept,
1911 /// because an extra candidate only makes deny matching stricter while a
1912 /// missing one is a bypass.
1913 fn command_heads(tokens: &[String]) -> Heads {
1914 #[derive(Clone, Copy, PartialEq, Eq, Hash)]
1915 enum State {
1916 /// At a command word position.
1917 Command,
1918 /// Inside the arguments of `WRAPPERS[wrapper]`.
1919 Wrapper {
1920 wrapper: usize,
1921 operands: u8,
1922 options_done: bool,
1923 },
1924 }
1925 let mut heads = Vec::new();
1926 let mut payloads = Vec::new();
1927 let mut truncated = false;
1928 let mut seen = HashSet::new();
1929 let mut stack = vec![(0usize, State::Command)];
1930 while let Some((index, state)) = stack.pop() {
1931 if index >= tokens.len() || seen.contains(&(index, state)) {
1932 continue;
1933 }
1934 if seen.len() >= MAX_HEAD_STATES {
1935 truncated = true;
1936 break;
1937 }
1938 seen.insert((index, state));
1939 let token = tokens[index].as_str();
1940 match state {
1941 State::Command => {
1942 if is_env_assignment(token) {
1943 stack.push((index + 1, State::Command));
1944 continue;
1945 }
1946 if !heads.contains(&index) {
1947 heads.push(index);
1948 }
1949 if let Some(position) = wrapper_index(token) {
1950 stack.push((
1951 index + 1,
1952 State::Wrapper {
1953 wrapper: position,
1954 operands: WRAPPERS[position].operands,
1955 options_done: false,
1956 },
1957 ));
1958 }
1959 }
1960 State::Wrapper {
1961 wrapper,
1962 operands,
1963 options_done,
1964 } => {
1965 let spec = &WRAPPERS[wrapper];
1966 let next = |operands, options_done| State::Wrapper {
1967 wrapper,
1968 operands,
1969 options_done,
1970 };
1971 if !options_done && token == "--" {
1972 stack.push((index + 1, next(operands, true)));
1973 } else if !options_done && token.len() > 1 && token.starts_with('-') {
1974 let same = next(operands, false);
1975 if let Some(long) = token.strip_prefix("--") {
1976 let (name, inline) = match long.split_once('=') {
1977 Some((name, value)) => (name, Some(value)),
1978 None => (long, None),
1979 };
1980 if spec.name == "env" && name == "split-string" {
1981 match inline {
1982 Some(value) => payloads.push(value.to_string()),
1983 None => payloads.extend(tokens.get(index + 1).cloned()),
1984 }
1985 }
1986 if inline.is_some() || spec.long_switches.contains(&name) {
1987 stack.push((index + 1, same));
1988 } else if spec.long_values.contains(&name) {
1989 stack.push((index + 2, same));
1990 } else {
1991 // Unknown long option: it may or may not take
1992 // the next word as its value.
1993 stack.push((index + 1, same));
1994 stack.push((index + 2, same));
1995 }
1996 } else {
1997 let flags = &token[1..];
1998 // `command -v NAME` / `-V` only looks NAME up.
1999 if spec.name == "command" && flags.contains(['v', 'V']) {
2000 continue;
2001 }
2002 if spec.name == "env"
2003 && let Some(at) = flags.find('S')
2004 {
2005 let value = &flags[at + 1..];
2006 if value.is_empty() {
2007 payloads.extend(tokens.get(index + 1).cloned());
2008 } else {
2009 payloads.push(value.to_string());
2010 }
2011 }
2012 // Walk the cluster: a switch continues it, a value
2013 // option takes the rest of the word or the next word,
2014 // and an option missing from the table is read both
2015 // ways (`env -P DIR cmd`, `xargs -J % cmd %`).
2016 let mut step = Some(1);
2017 for (at, flag) in flags.char_indices() {
2018 if spec.short_values.contains(flag) {
2019 if at + flag.len_utf8() == flags.len() {
2020 step = Some(2);
2021 }
2022 break;
2023 }
2024 if !flag.is_ascii_digit() && !spec.short_switches.contains(flag) {
2025 step = None;
2026 break;
2027 }
2028 }
2029 match step {
2030 Some(step) => stack.push((index + step, same)),
2031 None => {
2032 stack.push((index + 1, same));
2033 stack.push((index + 2, same));
2034 }
2035 }
2036 }
2037 } else if operands > 0 {
2038 stack.push((index + 1, next(operands - 1, options_done)));
2039 } else {
2040 if is_scalar_operand(token) {
2041 stack.push((index + 1, next(0, options_done)));
2042 }
2043 stack.push((index, State::Command));
2044 }
2045 }
2046 }
2047 }
2048 heads.sort_unstable();
2049 Heads {
2050 heads,
2051 split_payloads: payloads,
2052 truncated,
2053 }
2054 }
2055
2056 /// A script operand that names a file descriptor rather than a file:
2057 /// `/dev/stdin`, `/dev/fd/N` or `/proc/<pid>/fd/N`. Its text arrives through
2058 /// a pipe, a here-string or a redirect, so it is only known at run time —
2059 /// unless it is stdin and a heredoc body (already expanded as the script)
2060 /// feeds it.
2061 fn script_read_at_run_time(script: &str, line: &Line) -> bool {
2062 let mut parts: Vec<&str> = Vec::new();
2063 for part in script.split('/') {
2064 match part {
2065 "" | "." => {}
2066 ".." => {
2067 parts.pop();
2068 }
2069 part => parts.push(part),
2070 }
2071 }
2072 if !script.starts_with('/') {
2073 return false;
2074 }
2075 let descriptor = match parts.as_slice() {
2076 ["dev", "stdin"] => "0",
2077 ["dev", "fd", fd] => fd,
2078 ["proc", process, "fd", fd]
2079 if *process == "self"
2080 || *process == "thread-self"
2081 || process.chars().all(|ch| ch.is_ascii_digit()) =>
2082 {
2083 fd
2084 }
2085 _ => return false,
2086 };
2087 descriptor != "0" || !line.heredoc
2088 }
2089
2090 /// How the shell invocation `tokens` (with `tokens[0]` the shell) gets its
2091 /// script.
2092 ///
2093 /// Combined short flags count (`bash -lc '…'`). The scan deliberately does
2094 /// NOT stop at the first non-flag operand: an earlier version did, and
2095 /// `bash -o vi -c 'payload'` walked straight past the deny expander because
2096 /// `vi` (the argument of `-o`) ended the scan before `-c` was seen
2097 /// (2026-08-04 review). Continuing the scan can over-read a `-c` that is
2098 /// really an argument to a script (`bash script.sh -c x`), but this
2099 /// expander's contract is explicit that over-emitting targets is safe and
2100 /// under-emitting is a bypass.
2101 ///
2102 /// `-c` only switches the shell into command mode: options may still follow
2103 /// it, and the command string is the first *operand* after option parsing
2104 /// (`bash -c -e 'cmd'`, `sh -c -- 'cmd'`, `bash -c -o pipefail 'cmd'` all run
2105 /// `cmd`). Shells that read `-c`'s value as the very next word (fish) run
2106 /// that word instead, so both candidates are reported when they differ.
2107 fn shell_input(tokens: &[String]) -> ShellInput {
2108 let mut script = None;
2109 let mut from_stdin = false;
2110 let mut options_done = false;
2111 let mut command = Vec::new();
2112 let mut index = 1usize;
2113 while index < tokens.len() {
2114 let token = tokens[index].as_str();
2115 if !options_done && token == "--" {
2116 options_done = true;
2117 } else if !options_done && token == "-" {
2118 // `bash -` reads the script from stdin; after `-c` it only ends
2119 // the options.
2120 from_stdin |= command.is_empty();
2121 options_done = true;
2122 } else if let Some(long) = token.strip_prefix("--").filter(|_| !options_done) {
2123 if long.eq_ignore_ascii_case("command") {
2124 return match tokens.get(index + 1) {
2125 Some(_) => ShellInput::Command(vec![index + 1]),
2126 None => ShellInput::Stdin,
2127 };
2128 }
2129 if matches!(long, "rcfile" | "init-file") {
2130 index += 1;
2131 }
2132 } else if !options_done
2133 && token.len() > 1
2134 && (token.starts_with('-') || token.starts_with('+'))
2135 {
2136 let flags = &token[1..];
2137 if token.starts_with('-') && flags.contains('c') && command.is_empty() {
2138 command.push(index + 1);
2139 }
2140 from_stdin |= token.starts_with('-') && flags.contains('s');
2141 if flags.contains(['o', 'O']) {
2142 index += 1;
2143 }
2144 } else if !command.is_empty() {
2145 if command[0] != index {
2146 command.push(index);
2147 }
2148 return ShellInput::Command(command);
2149 } else if script.is_none() {
2150 script = Some(index);
2151 }
2152 index += 1;
2153 }
2154 command.retain(|&at| at < tokens.len());
2155 match script {
2156 _ if !command.is_empty() => ShellInput::Command(command),
2157 Some(index) if !from_stdin => ShellInput::Script(index),
2158 _ => ShellInput::Stdin,
2159 }
2160 }
2161
2162 #[cfg(test)]
2163 mod tests {
2164 use super::*;
2165
2166 fn expand(command: &str) -> Vec<String> {
2167 // Exercise the POSIX grammar consistently on every test host.
2168 expanded_commands_for_platform(command, false)
2169 }
2170
2171 #[test]
2172 fn windows_scan_retains_native_paths_and_posix_deny_candidates() {
2173 for (command, expected) in [
2174 (
2175 r"C:\Windows\System32\cat.exe ~/.ssh/id_rsa",
2176 r"C:\Windows\System32\cat.exe ~/.ssh/id_rsa",
2177 ),
2178 (r"del /f c:\users\x\file", r"del /f c:\users\x\file"),
2179 (
2180 r"echo safe & xcopy /e /y c:\src d:\dst",
2181 r"xcopy /e /y c:\src d:\dst",
2182 ),
2183 (
2184 r#""C:\Program Files\cat.exe" "c:\path with spaces\file""#,
2185 r"C:\Program Files\cat.exe c:\path with spaces\file",
2186 ),
2187 (r"del relative\file", r"del relative\file"),
2188 (
2189 r"\\server\share\cat.exe file",
2190 r"\\server\share\cat.exe file",
2191 ),
2192 (r"bash -c 'rm -rf \/'", "rm -rf /"),
2193 ] {
2194 let targets = expanded_commands_for_platform(command, true);
2195 assert!(
2196 targets.iter().any(|target| target == expected),
2197 "missing {expected:?} from {targets:?}"
2198 );
2199 assert!(targets.len() <= MAX_COMMANDS);
2200 }
2201 let targets =
2202 expanded_commands_for_platform("cat <<'EOF'\ndel c:\\users\\x\\file\nEOF", true);
2203 assert!(
2204 !targets.iter().any(|target| target.starts_with("del ")),
2205 "literal heredoc data must stay inert: {targets:?}"
2206 );
2207 }
2208
2209 fn contains(command: &str, expected: &str) -> bool {
2210 expand(command).iter().any(|target| target == expected)
2211 }
2212
2213 #[test]
2214 fn backtick_body_is_a_command() {
2215 assert!(contains("`rm -rf /`", "rm -rf /"));
2216 assert!(contains("echo `rm -rf /`", "rm -rf /"));
2217 assert!(contains("echo `rm -rf /`", "echo"));
2218 }
2219
2220 #[test]
2221 fn dollar_paren_body_is_a_command() {
2222 assert!(contains("echo $(rm -rf /)", "rm -rf /"));
2223 assert!(contains("x=$(rm -rf /)", "rm -rf /"));
2224 assert!(contains("echo \"$(rm -rf /)\"", "rm -rf /"));
2225 }
2226
2227 #[test]
2228 fn nested_substitution_is_followed() {
2229 assert!(contains("echo $(echo `rm -rf /`)", "rm -rf /"));
2230 }
2231
2232 #[test]
2233 fn quotes_are_removed_from_operands() {
2234 assert!(contains("rm -rf \"/\"", "rm -rf /"));
2235 assert!(contains("rm -rf '/'", "rm -rf /"));
2236 assert!(contains("\"rm\" -rf /", "rm -rf /"));
2237 assert!(contains("rm -r\"f\" /", "rm -rf /"));
2238 }
2239
2240 #[test]
2241 fn single_quoted_text_is_not_a_command() {
2242 // A literal backtick inside single quotes is printed, not executed.
2243 let targets = expand("echo '`rm -rf /`'");
2244 assert!(
2245 !targets.iter().any(|t| t == "rm -rf /"),
2246 "single-quoted text must not become a command: {targets:?}"
2247 );
2248 }
2249
2250 #[test]
2251 fn escaped_operators_do_not_split() {
2252 let targets = expand("echo a\\;b");
2253 assert_eq!(targets, vec!["echo a;b".to_string()]);
2254 assert!(targets.contains(&"echo a;b".to_string()), "{targets:?}");
2255 }
2256
2257 #[test]
2258 fn control_operators_split_commands() {
2259 for command in [
2260 "ls && rm -rf /",
2261 "ls || rm -rf /",
2262 "ls ; rm -rf /",
2263 "ls | rm -rf /",
2264 "ls & rm -rf /",
2265 "ls\nrm -rf /",
2266 ] {
2267 assert!(contains(command, "rm -rf /"), "{command}");
2268 }
2269 }
2270
2271 #[test]
2272 fn wrappers_and_payloads_are_unwrapped() {
2273 for command in [
2274 "sudo rm -rf /",
2275 "env rm -rf /",
2276 "timeout 5 rm -rf /",
2277 "nohup rm -rf /",
2278 "xargs rm -rf /",
2279 "/usr/bin/sudo rm -rf /",
2280 "eval 'rm -rf /'",
2281 "bash -c 'rm -rf /'",
2282 "sh -lc \"rm -rf /\"",
2283 "sudo -u root bash -c 'rm -rf /'",
2284 // 2026-08-04: `-o vi` used to end the flag scan before `-c` was
2285 // seen, so the payload skipped deny expansion entirely.
2286 "bash -o vi -c 'rm -rf /'",
2287 "zsh --norcs -c 'rm -rf /'",
2288 ] {
2289 assert!(
2290 contains(command, "rm -rf /"),
2291 "{command}: {:?}",
2292 expand(command)
2293 );
2294 }
2295 }
2296
2297 #[test]
2298 fn options_after_dash_c_do_not_hide_the_command_string() {
2299 // The command string is the first operand once options end, so an
2300 // option between `-c` and it does not change what runs.
2301 for command in [
2302 "bash -c -e 'rm -rf /'",
2303 "bash -c -l 'rm -rf /'",
2304 "sh -c -x 'rm -rf /'",
2305 "bash -c -- 'rm -rf /'",
2306 "sh -c - 'rm -rf /'",
2307 "bash -c +e 'rm -rf /'",
2308 "bash -lc -e 'rm -rf /'",
2309 "bash -c -o pipefail 'rm -rf /'",
2310 "bash -c -O extglob -e 'rm -rf /'",
2311 "bash script.sh -c 'rm -rf /'",
2312 ] {
2313 assert!(
2314 contains(command, "rm -rf /"),
2315 "{command}: {:?}",
2316 expand(command)
2317 );
2318 }
2319 // Arguments after the command string are positional parameters.
2320 assert!(!contains("bash -c 'echo $0' 'rm -rf /'", "rm -rf /"));
2321 }
2322
2323 #[test]
2324 fn script_operand_naming_a_descriptor_is_read_at_run_time() {
2325 for command in [
2326 "echo 'rm -rf /' | bash /dev/stdin",
2327 "bash /dev/stdin <<< 'rm -rf /'",
2328 "sh /dev/fd/0 <<< 'rm -rf /'",
2329 "sh /proc/self/fd/0 <<< 'rm -rf /'",
2330 "bash //dev/./stdin <<< 'rm -rf /'",
2331 "bash /dev/fd/3 3< script",
2332 ". /dev/stdin <<< 'rm -rf /'",
2333 "source /proc/self/fd/0 <<< 'rm -rf /'",
2334 ] {
2335 assert!(expand_command(command).dynamic, "{command}");
2336 }
2337 // A heredoc on stdin was expanded as the script itself.
2338 let heredoc = expand_for_platform("bash /dev/stdin <<EOF\nrm -rf /\nEOF", false);
2339 assert!(!heredoc.dynamic);
2340 assert!(heredoc.commands.iter().any(|target| target == "rm -rf /"));
2341 // An ordinary script file is still opaque, not unresolved.
2342 assert!(!expand_command("bash ./dev/stdin").dynamic);
2343 assert!(!expand_command(". ./env.sh").dynamic);
2344 }
2345
2346 #[test]
2347 fn wrapper_head_scan_stops_at_a_real_command() {
2348 // `echo` prints its arguments; nothing here is executed as a shell.
2349 let targets = expand("echo bash -c 'rm -rf /'");
2350 assert!(
2351 !targets.iter().any(|t| t == "rm -rf /"),
2352 "arguments of a printing command must not be parsed as code: {targets:?}"
2353 );
2354 }
2355
2356 #[test]
2357 fn process_and_parameter_substitution_bodies_are_commands() {
2358 assert!(contains("diff <(rm -rf /) b", "rm -rf /"));
2359 assert!(contains("echo ${x:-$(rm -rf /)}", "rm -rf /"));
2360 }
2361
2362 #[test]
2363 fn expansion_is_bounded() {
2364 let deep = "$(".repeat(64) + "rm -rf /" + &")".repeat(64);
2365 let targets = expand(&deep);
2366 assert!(targets.len() <= MAX_COMMANDS);
2367 }
2368
2369 #[test]
2370 fn grouping_is_a_command_boundary() {
2371 assert!(contains("(rm -rf /)", "rm -rf /"));
2372 assert!(contains("{ rm -rf /; }", "rm -rf /"));
2373 assert!(contains("(cd /tmp && rm -rf /)", "rm -rf /"));
2374 // Escaped and quoted parens are operands, not grouping.
2375 assert!(contains(
2376 "find . \\( -name a \\) -print",
2377 "find . ( -name a ) -print"
2378 ));
2379 }
2380
2381 #[test]
2382 fn syntax_metadata_is_independent_of_candidates_and_literal_data() {
2383 for command in [
2384 "printf x; printf x",
2385 "git log | git log",
2386 "cargo test && cargo test",
2387 "(git log)",
2388 ] {
2389 let expansion = expand_for_platform(command, false);
2390 assert!(expansion.control, "{command}: {expansion:?}");
2391 }
2392 assert_eq!(
2393 expand_for_platform("printf x; printf x", false)
2394 .commands
2395 .len(),
2396 1
2397 );
2398 assert!(expand_for_platform("sudo git log", false).commands.len() > 1);
2399 assert!(!expand_for_platform("sudo git log", false).control);
2400 for command in [
2401 r#"grep -E "a|b" src"#,
2402 r#"git commit -m "fix: a & b; c""#,
2403 r"echo a\;b\&c\|d\>e",
2404 "echo '> < ; | &'",
2405 "echo ok # ; | > ignored",
2406 ] {
2407 let expansion = expand_for_platform(command, false);
2408 assert!(
2409 !expansion.control && !expansion.redirects,
2410 "{command}: {expansion:?}"
2411 );
2412 }
2413 for command in [
2414 "cargo test 2>&1",
2415 "cargo test &>result.log",
2416 "cat a<>out",
2417 "cat a>|out",
2418 "cat 3<&0",
2419 "cat<<<literal",
2420 ] {
2421 for windows in [false, true] {
2422 let expansion = expand_for_platform(command, windows);
2423 assert!(
2424 expansion.redirects && !expansion.control,
2425 "{command}: {expansion:?}"
2426 );
2427 }
2428 }
2429 for command in ["git log $FLAGS", r#"git log "$FLAGS""#] {
2430 let expansion = expand_for_platform(command, false);
2431 assert!(
2432 expansion.arguments_dynamic && !expansion.dynamic,
2433 "{command}: {expansion:?}"
2434 );
2435 }
2436 assert!(!expand_for_platform("git log '$FLAGS'", false).arguments_dynamic);
2437 assert!(!expand_for_platform(r"git log \$FLAGS", false).arguments_dynamic);
2438 let heredoc = expand_for_platform("cat <<'EOF'\n; | > $(not-code)\nEOF", false);
2439 assert!(heredoc.redirects);
2440 assert!(!heredoc.nested);
2441 assert_eq!(heredoc.commands, vec!["cat"]);
2442 }
2443
2444 #[test]
2445 fn plain_command_expands_to_itself() {
2446 assert_eq!(expand("git status -s"), vec!["git status -s".to_string()]);
2447 }
2448 }
2449
2449 lines RUST