diff --git a/crates/ox-core/src/permission/shell.rs b/crates/ox-core/src/permission/shell.rs
index 08bea09fd..417d6128c 100644
--- a/crates/ox-core/src/permission/shell.rs
+++ b/crates/ox-core/src/permission/shell.rs
@@ -5,338 +5,18 @@
//! unrecognized: rules may deny it or leave it to ask, but never allow it. `exec` patterns parse
//! here too, so they read words the way commands do.
-use std::iter::{self, Peekable};
-use std::mem;
-use std::ops::Range;
-use std::path::{Component, Path, PathBuf};
-use std::str::Chars;
-use std::sync::LazyLock;
+mod invocation;
+mod pattern;
+mod reader;
+mod walk;
+mod word;
+mod wrapper;
-use anyhow::{Result, bail, ensure};
-use regex::Regex;
-use tree_sitter::{Node, Parser, Tree};
+use std::path::Path;
-use super::{PathResolver, PathTarget, Workspace, Zone, fold_name};
-use crate::util::path::expand_user;
-
-/// Shells and the commands that start one as another user. The walk follows the literal `-c`
-/// script each runs, so deny rules see the commands inside it, and reads the script file a shell
-/// runs, as [`shell_run`] finds them.
-const SHELLS: &[&str] = &[
- "ash", "bash", "csh", "dash", "fish", "hush", "ksh", "mksh", "pwsh", "runuser", "sh", "su",
- "tcsh", "zsh",
-];
-
-/// Builtins that change how bash finds or runs later commands, run code from a trap, the history,
-/// or completion, or evaluate arithmetic, which may run a substitution, so a command that runs one
-/// is unrecognized.
-const DISPATCH_BUILTINS: &[&str] = &[
- "alias", "bind", "compgen", "complete", "compopt", "enable", "fc", "hash", "history", "let",
- "shopt", "trap",
-];
-
-/// Builtins that change the working directory, after which a relative redirection target no longer
-/// names the file the gate resolved.
-const DIRECTORY_CHANGERS: &[&str] = &["cd", "popd", "pushd"];
-
-/// Commands whose file operands the gate weighs against `read` rules, whether or not they run as
-/// [known-safe](safe_operands) reads.
-const READERS: &[&str] = &["cat", "find", "grep", "head", "ls", "sed", "tail", "wc"];
-
-/// Files one globbing word may match before the analysis gives up on listing them.
-const MAX_GLOB_MATCHES: usize = 1024;
-
-/// Variables a command may set or export without changing what a rule names, since none of them
-/// changes which program runs, what it loads, or where it writes.
-const SAFE_VARIABLES: &[&str] = &[
- "BLOCKSIZE",
- "BLOCK_SIZE",
- "CARGO_TERM_COLOR",
- "CGO_ENABLED",
- "CHARSET",
- "COLORTERM",
- "FORCE_COLOR",
- "GCC_COLORS",
- "GO111MODULE",
- "GOARCH",
- "GOEXPERIMENT",
- "GOOS",
- "GREP_COLOR",
- "GREP_COLORS",
- "LANG",
- "LANGUAGE",
- "LC_ALL",
- "LC_CTYPE",
- "LC_TIME",
- "LSCOLORS",
- "LS_COLORS",
- "NODE_ENV",
- "NO_COLOR",
- "PYTHONDONTWRITEBYTECODE",
- "PYTHONUNBUFFERED",
- "RUST_BACKTRACE",
- "RUST_LOG",
- "TERM",
- "TIME_STYLE",
- "TZ",
-];
-
-/// Redirection operators that write their target.
-const WRITE_OPERATORS: &[&str] = &[">", ">>", ">|", "&>", "&>>", ">&"];
-
-/// Redirection targets that are devices, so redirecting to or from them touches no file.
-const DEVICES: &[&str] = &["/dev/null", "/dev/stderr", "/dev/stdout", "/dev/tty"];
-
-/// Characters of an unrecognized construct a prompt names before cutting it short.
-const MAX_CONSTRUCT_CHARS: usize = 40;
-
-/// Wrappers that run a command given after their own options, so rules see through them.
-const WRAPPERS: &[Wrapper] = &[
- Wrapper {
- name: "builtin",
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "busybox",
- ..Wrapper::RUNNER
- },
- Wrapper {
- name: "command",
- options: &[flag("-V"), flag("-p"), flag("-v")],
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "coproc",
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "doas",
- options: &[valued("-a"), flag("-n"), flag("-s"), valued("-u")],
- ..Wrapper::RUNNER
- },
- Wrapper {
- name: "env",
- options: &[
- flag("-"),
- flag("-0"),
- valued("-C").with(Effect::Directory),
- valued("-S").with(Effect::Splits),
- flag("-i"),
- valued("-u"),
- flag("-v"),
- valued("--chdir").with(Effect::Directory),
- flag("--debug"),
- flag("--ignore-environment"),
- flag("--null"),
- valued("--split-string").with(Effect::Splits),
- valued("--unset"),
- ],
- assignments: Assignments::After,
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "exec",
- options: &[valued("-a"), flag("-c"), flag("-l")],
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "nice",
- options: &[valued("-n"), valued("--adjustment")],
- numeric: true,
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "nohup",
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "pkexec",
- options: &[
- valued("-u"),
- flag("--disable-internal-agent"),
- flag("--keep-cwd"),
- valued("--user"),
- ],
- elsewhere: true,
- ..Wrapper::RUNNER
- },
- Wrapper {
- name: "stdbuf",
- options: &[
- valued("-e"),
- valued("-i"),
- valued("-o"),
- valued("--error"),
- valued("--input"),
- valued("--output"),
- ],
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "sudo",
- options: &[
- flag("-A"),
- flag("-B"),
- valued("-C"),
- valued("-D").with(Effect::Directory),
- flag("-E"),
- flag("-H"),
- flag("-N"),
- flag("-P"),
- valued("-R").with(Effect::Directory),
- flag("-S"),
- valued("-T"),
- valued("-a"),
- flag("-b"),
- valued("-c"),
- valued("-g"),
- flag("-i"),
- flag("-k"),
- flag("-n"),
- valued("-p"),
- valued("-r"),
- flag("-s"),
- valued("-t"),
- valued("-u"),
- flag("--askpass"),
- flag("--background"),
- flag("--bell"),
- valued("--chdir").with(Effect::Directory),
- valued("--chroot").with(Effect::Directory),
- valued("--close-from"),
- valued("--command-timeout"),
- valued("--group"),
- valued("--login-class"),
- flag("--login"),
- flag("--no-update"),
- flag("--non-interactive"),
- optional("--preserve-env"),
- flag("--preserve-groups"),
- valued("--prompt"),
- flag("--reset-timestamp"),
- valued("--role"),
- flag("--set-home"),
- flag("--shell"),
- flag("--stdin"),
- valued("--type"),
- valued("--user"),
- ],
- assignments: Assignments::Among,
- ..Wrapper::RUNNER
- },
- Wrapper {
- name: "time",
- options: &[flag("-p")],
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "timeout",
- options: &[
- valued("-k"),
- valued("-s"),
- flag("-v"),
- flag("--foreground"),
- valued("--kill-after"),
- flag("--preserve-status"),
- valued("--signal"),
- flag("--verbose"),
- ],
- positionals: 1,
- ..Wrapper::TRANSPARENT
- },
- Wrapper {
- name: "xargs",
- options: &[
- flag("-0"),
- valued("-E"),
- valued("-I").with(Effect::Replace),
- valued("-J").with(Effect::Insert),
- valued("-L"),
- valued("-P"),
- valued("-R"),
- valued("-S"),
- valued("-a").with(Effect::Reads),
- valued("-d"),
- optional("-e"),
- optional("-i").with(Effect::Replace),
- optional("-l"),
- valued("-n"),
- flag("-o"),
- flag("-p"),
- flag("-r"),
- valued("-s"),
- flag("-t"),
- flag("-x"),
- valued("--arg-file").with(Effect::Reads),
- valued("--delimiter"),
- optional("--eof"),
- flag("--exit"),
- flag("--interactive"),
- valued("--max-args"),
- valued("--max-chars"),
- optional("--max-lines"),
- valued("--max-procs"),
- flag("--no-run-if-empty"),
- flag("--null"),
- flag("--open-tty"),
- optional("--replace").with(Effect::Replace),
- flag("--verbose"),
- ],
- kind: Kind::Input,
- ..Wrapper::TRANSPARENT
- },
-];
-
-/// The placeholder `xargs -i` and `--replace` replace when given no value, and `find` replaces with
-/// each path it finds.
-const DEFAULT_REPLACE: &str = "{}";
-
-/// The word that stands for the arguments `xargs` reads from its input.
-const INPUT_WORD: &str = "";
-
-/// `find` primaries that run a command or write a file.
-const FIND_ACTIONS: &[&str] = &[
- "-delete", "-exec", "-execdir", "-fls", "-fprint", "-fprint0", "-fprintf", "-ok", "-okdir",
-];
-
-/// `runuser`'s options, which run the command after them directly when `-u` names the user.
-const RUNUSER: Wrapper = Wrapper {
- name: "runuser",
- options: &[
- valued("-G"),
- flag("-P"),
- valued("-g"),
- flag("-m"),
- flag("-p"),
- valued("-u"),
- valued("-w"),
- valued("--group"),
- flag("--preserve-environment"),
- flag("--pty"),
- valued("--supp-group"),
- valued("--user"),
- valued("--whitelist-environment"),
- ],
- ..Wrapper::RUNNER
-};
-
-/// `find` primaries that run the command after them.
-const FIND_RUNNERS: &[&str] = &["-exec", "-execdir", "-ok", "-okdir"];
-
-/// A `sed` script of commands that only print, delete, substitute, or move text between buffers. It
-/// leaves out `r`, `w`, and `e`, and the `w` and `e` substitution flags, which read a file, write
-/// one, or run a command.
-static SAFE_SED_SCRIPT: LazyLock = LazyLock::new(|| {
- let text = r"(?:[^/\\\n]|\\.)*";
- let address = format!(r"(?:\d+(?:~\d+)?|\$|/{text}/I?)");
- let command = format!(
- r"(?:{address}(?:\s*,\s*{address})?\s*!?\s*)?(?:[=dDgGhHlnNpPqQxz]|s/{text}/{text}/[gpiIm0-9]*|y/{text}/{text}/)"
- );
- Regex::new(&format!(
- r"^\s*(?:{command}\s*(?:[;\n]\s*{command}\s*)*)?;?\s*$"
- ))
- .expect("the sed script pattern is valid")
-});
+use self::walk::{DIRECTORY_CHANGERS, EnteredDirs, Walk, basename, spelled_from};
+use self::wrapper::INPUT_WORD;
+use crate::permission::{PathResolver, PathTarget, Workspace, fold_name};
// ── Command Target ──
@@ -351,10 +31,10 @@ pub(crate) struct CommandTarget {
writes: Vec,
/// The files it reads for certain, resolved like a read's path: each input or read-write
/// redirection, each file a wrapper option names, the script `source`, `.`, or a shell runs,
- /// and each [certain](Operand::certain) operand of one of [`READERS`], including the root of a
- /// tree it traverses.
+ /// and each [certain](Operand::certain) operand of a [reader](reader::READERS), including the
+ /// root of a tree it traverses.
reads: Vec,
- /// The first construct the analysis does not recognize, as [`construct`] cuts it for a prompt.
+ /// The first unrecognized construct, cut for a prompt by [`walk::construct`].
unrecognized: Option,
}
@@ -435,20 +115,19 @@ impl CommandTarget {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Segment {
/// The words an allow rule must name, without redirections. Leading assignments of
- /// [`SAFE_VARIABLES`] and wrappers allow rules see through are left out, up to the first
- /// assignment of another variable or other wrapper, which starts the list and keeps every word
- /// after it. Ends in [`INPUT_WORD`] when `xargs` appends its input words.
+ /// [safe variables](wrapper::SAFE_VARIABLES) and transparent wrappers are left out, up to the
+ /// first other assignment or wrapper, which starts the list and keeps every word after it.
+ /// Ends in [`INPUT_WORD`] when `xargs` appends its input words.
named: Vec,
/// Where the program may start in `named`, first past every assignment and wrapper, then at
/// each word past a wrapper option the analysis does not know.
programs: Vec,
- /// The files one of [`READERS`] may read beyond those the command reads for certain, resolved
- /// like a read's path: each tree it may traverse, whose descendants a rule may match below its
- /// root, and each word whose role or matches the analysis cannot pin. Empty for any other
- /// command.
+ /// The files a [reader](reader::READERS) may read beyond those read for certain, resolved like
+ /// a read's path: each tree it may traverse, whose descendants a rule may match below its root,
+ /// and each word whose role or matches the analysis cannot pin. Empty for any other command.
reads: Vec>,
- /// Whether one of [`READERS`] takes a word whose files the analysis cannot list, such as one
- /// that expands a variable, so it may read any file.
+ /// Whether a [reader](reader::READERS) takes a word whose files the analysis cannot list, such
+ /// as one that expands a variable, so it may read any file.
reads_anything: bool,
/// Whether it is a shipped known-safe read-only command, whose reads the analysis lists, in a
/// command that expands no variable anywhere.
@@ -620,4551 +299,186 @@ impl ShellWord {
}
}
-// ── Walk ──
-
-/// Node kinds the walk recognizes by walking their children: the statements, words, and redirection
-/// parts that only group or spell what the kinds it weighs on their own hold.
-const WALKED_KINDS: &[&str] = &[
- "ansi_c_string",
- "brace_expression",
- "case_item",
- "case_statement",
- "command_name",
- "command_substitution",
- "comment",
- "compound_statement",
- "concatenation",
- "do_group",
- "elif_clause",
- "else_clause",
- "extglob_pattern",
- "file_descriptor",
- "heredoc_body",
- "heredoc_content",
- "heredoc_end",
- "heredoc_redirect",
- "heredoc_start",
- "herestring_redirect",
- "if_statement",
- "list",
- "negated_command",
- "number",
- "pipeline",
- "process_substitution",
- "program",
- "raw_string",
- "regex",
- "special_variable_name",
- "string",
- "string_content",
- "subshell",
- "test_operator",
- "translated_string",
- "variable_assignments",
- "variable_name",
- "word",
-];
-
-/// What one command and the scripts nested in it run and write, gathered over its syntax tree.
-struct Walk<'a> {
- workspace: &'a Workspace,
- paths: PathResolver<'a>,
- /// Whether the environment `bash` starts with sets a variable.
- inherited: &'a dyn Fn(&str) -> bool,
- segments: Vec,
- writes: Vec,
- reads: Vec,
- /// The first directory change, as [`construct`] cuts it for a prompt.
- dir_change: Option,
- entered: EnteredDirs,
- /// The variables that the commands enclosing the node the walk is at set for it, as
- /// [`Inherited::variables`] lists them, such as a `BASH_ENV` before an `eval` or a `bash -c`.
- environment: Vec<(String, Option)>,
- /// Whether some directory change may leave the workspace. Only a first `cd` or `pushd` that
- /// runs once, to a target [`Walk::enters_workspace`] accepts, stays inside.
- leaves_workspace: bool,
- /// How many loop bodies enclose the node the walk is at, where a directory change may run more
- /// than once.
- repeats: usize,
- /// Whether some redirection, reader operand, script that `source` or a shell runs, or program
- /// spelled with a directory names a relative path.
- relative_path: bool,
- /// Whether any part of the command expands a variable at run time, wherever the value ends up.
- expands: bool,
- /// The first construct the walk does not recognize, as [`construct`] cuts it for a prompt.
- unrecognized: Option,
+/// One word of an `exec` pattern.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) enum PatternWord {
+ Literal(String),
+ /// An unquoted `*` standing alone.
+ Any,
}
-/// The directories the `cd` and `pushd` commands of a command may have entered.
-#[derive(Default)]
-struct EnteredDirs {
- /// Each directory as its literal targets spell it from the working directory. A later relative
- /// path also names a file under each, since a failed change, a subshell, or `||` may leave any
- /// of them, or the working directory itself, current.
- dirs: Vec,
- /// Each directory a `pushd -n` adds to the directory stack without entering it, as written,
- /// which only a [`stack_tilde`] names, resolved from the directory current where it does.
- stacked: Vec,
- /// Whether a change may have entered a directory the analysis cannot spell, so a later
- /// relative path may name any file.
- unknown: bool,
- /// Whether a `pushd -n` may have stacked a directory the analysis cannot spell, so a later
- /// [`stack_tilde`] may name any file.
- unknown_stacked: bool,
+/// Parses the words of one simple command for an `exec` rule.
+pub(super) fn pattern_words(pattern: &str) -> anyhow::Result> {
+ self::pattern::parse(pattern)
}
-impl Walk<'_> {
- fn script(&mut self, source: &str) {
- let tree = parse_tree(source);
- let root = tree.root_node();
- if root.has_error() {
- let error = first_error(root).map_or("", |node| &source[node.byte_range()]);
- self.mark_unrecognized(if error.trim().is_empty() {
- source
- } else {
- error
- });
- } else if !splits_words_like_bash(root, source) {
- self.mark_unrecognized(source);
- }
- self.visit(root, source);
- }
-
- /// Records `text` as the construct that leaves the command unrecognized, unless an earlier one
- /// already has.
- fn mark_unrecognized(&mut self, text: &str) {
- self.unrecognized.get_or_insert_with(|| construct(text));
- }
-
- /// Returns whether a command may set the variable `name` and stay recognized: it is one of the
- /// [`SAFE_VARIABLES`], or a local name, which only later `$name` expansions read, as
- /// [`is_local_name`] decides.
- fn may_set(&self, name: &str) -> bool {
- SAFE_VARIABLES.contains(&name) || is_local_name(name, self.inherited)
- }
+// ── Testing ──
- /// Visits `node`, recognizing it by its kind. Each kind the walk does not recognize leaves the
- /// command unrecognized, and the walk still visits its children, so deny rules see the commands
- /// inside it.
- fn visit(&mut self, node: Node<'_>, src: &str) {
- match node.kind() {
- "command" => self.command(node, &[], src),
- "redirected_statement" => self.redirected_statement(node, Vec::new(), src),
- "file_redirect" => self.redirect(node, src),
- "declaration_command" | "unset_command" => self.declaration(node, src),
- "test_command" => self.test(node, src),
- "expansion" => self.expansion(node, src),
- "simple_expansion" => {
- self.expands = true;
- self.visit_children(node, src);
- }
- "variable_assignment" => {
- let local = node
- .child_by_field_name("name")
- .is_some_and(|name| self.may_set(&src[name.byte_range()]));
- if !local {
- self.mark_unrecognized(&src[node.byte_range()]);
- }
- self.visit_children(node, src);
- }
- "array" => {
- let mut cursor = node.walk();
- for element in node.named_children(&mut cursor) {
- if keys_by_arithmetic(element, src) {
- self.mark_unrecognized(&src[element.byte_range()]);
- }
- }
- self.visit_children(node, src);
- }
- "for_statement" => {
- let local = node.child(0).is_some_and(|keyword| keyword.kind() == "for")
- && node
- .child_by_field_name("variable")
- .is_some_and(|name| self.may_set(&src[name.byte_range()]));
- if !local {
- self.mark_unrecognized(&src[node.byte_range()]);
- }
- self.repeated(node, src);
- }
- "while_statement" => self.repeated(node, src),
- "compound_statement" if node.child(0).is_some_and(|open| open.kind() == "((") => {
- self.mark_unrecognized(&src[node.byte_range()]);
- self.visit_children(node, src);
- }
- kind if WALKED_KINDS.contains(&kind) => self.visit_children(node, src),
- _ => {
- self.mark_unrecognized(&src[node.byte_range()]);
- self.visit_children(node, src);
- }
- }
- }
+#[cfg(test)]
+mod local_testing {
+ use std::path::Path;
- fn visit_children(&mut self, node: Node<'_>, src: &str) {
- let mut cursor = node.walk();
- for child in node.named_children(&mut cursor) {
- self.visit(child, src);
- }
- }
+ use super::{CommandTarget, PathResolver, PathTarget, Segment, ShellWord, Workspace};
- /// Visits the children of a loop, whose body may run a directory change more than once.
- fn repeated(&mut self, node: Node<'_>, src: &str) {
- self.repeats += 1;
- self.visit_children(node, src);
- self.repeats -= 1;
+ pub(super) fn parse(command: &str) -> CommandTarget {
+ parse_inheriting(command, &[])
}
- /// Visits a redirected `statement`, whose redirections may carry words bash passes to the
- /// simple command it runs, followed by the `trailing` words of a statement around it.
- fn redirected_statement<'t>(
- &mut self,
- statement: Node<'t>,
- trailing: Vec>,
- src: &str,
- ) {
- let mut cursor = statement.walk();
- let redirects: Vec> = statement
- .children_by_field_name("redirect", &mut cursor)
- .collect();
- let words: Vec> = redirects
- .iter()
- .flat_map(|&redirect| redirect_words(redirect))
- .chain(trailing)
- .collect();
- match statement.child_by_field_name("body") {
- Some(body) => self.statement(body, words, src),
- None if !words.is_empty() => self.parts(&[], &words, src),
- None => {}
- }
- for redirect in redirects {
- self.visit(redirect, src);
- }
+ /// Parses `command` where the environment `bash` starts with sets only `inherited`.
+ pub(super) fn parse_inheriting(command: &str, inherited: &[&str]) -> CommandTarget {
+ CommandTarget::parse_inheriting(command, &Workspace::new(Path::new("/repo")), &|name| {
+ inherited.contains(&name)
+ })
}
- /// Visits `node`, handing the `trailing` words a redirection carries to the simple command bash
- /// gives them: the last one of a pipeline or list, the one a `!` negates, or a new one after
- /// any assignments that stand alone. Under any other statement, which bash rejects, they leave
- /// the command unrecognized.
- fn statement<'t>(&mut self, node: Node<'t>, trailing: Vec>, src: &str) {
- if trailing.is_empty() {
- return self.visit(node, src);
- }
- let mut cursor = node.walk();
- match node.kind() {
- "command" => self.command(node, &trailing, src),
- "redirected_statement" => self.redirected_statement(node, trailing, src),
- "list" | "negated_command" | "pipeline" => {
- let children: Vec> = node.named_children(&mut cursor).collect();
- if let Some((&last, rest)) = children.split_last() {
- for &child in rest {
- self.visit(child, src);
- }
- self.statement(last, trailing, src);
- }
- }
- "variable_assignment" => self.parts(&[node], &trailing, src),
- "variable_assignments" => {
- let assignments: Vec> = node.named_children(&mut cursor).collect();
- self.parts(&assignments, &trailing, src);
- }
- _ => {
- self.mark_unrecognized(&src[node.byte_range()]);
- self.visit(node, src);
- }
- }
+ pub(super) fn is_safe(command: &str) -> bool {
+ parse(command).segments[0].is_safe()
}
- fn command<'t>(&mut self, node: Node<'t>, trailing: &[Node<'t>], src: &str) {
- let parts = command_parts(node);
- if runs_a_reserved_word(&parts, src) {
- self.mark_unrecognized(&src[node.byte_range()]);
- }
- self.parts(&parts, trailing, src);
+ pub(super) fn resolve(path: &str, workspace: &Workspace) -> PathTarget {
+ PathResolver::new(workspace).resolve(path)
}
- /// Records the simple command whose assignments, name, arguments, and redirections are `parts`,
- /// in source order, followed by the `trailing` words its statement's redirections carry.
- fn parts<'t>(&mut self, parts: &[Node<'t>], trailing: &[Node<'t>], src: &str) {
- let nodes: Vec> = parts
+ pub(super) fn joined(words: &[ShellWord]) -> String {
+ words
.iter()
- .flat_map(|&part| match part.kind() {
- "command_name" => part.named_child(0).into_iter().collect(),
- "file_redirect" | "heredoc_redirect" => redirect_words(part),
- "variable_assignment" | "herestring_redirect" => Vec::new(),
- _ => vec![part],
- })
- .collect();
- let trailing = words(trailing, src);
- let mut words = words(&nodes, src);
- words.extend(parts.last().and_then(|&last| dropped_dash(last, src)));
- words.extend(trailing);
- let prefix = (parts.iter())
- .filter(|part| part.kind() == "variable_assignment")
- .map(|part| assigned(&src[part.byte_range()]));
- let wrapped = (words.iter())
- .filter_map(|word| word.value.as_deref()?.split_once('='))
- .map(|(name, _)| name);
- let mut environment = self.environment.clone();
- environment.extend(
- prefix
- .chain(wrapped)
- .filter(|name| DIR_VARIABLES.contains(name))
- .map(|name| (name.to_owned(), None)),
- );
- self.within(environment, |walk| walk.assigned_parts(parts, words, src));
- }
-
- /// Records the simple command of `parts` with its `words`, once the environment holds each of
- /// the [`DIR_VARIABLES`] the command may set, which its words then cannot resolve.
- fn assigned_parts(&mut self, parts: &[Node<'_>], words: Vec, src: &str) {
- let words: Vec = (words.into_iter())
- .map(|word| self.resolvable(word, src))
- .collect();
- let mut assignments = Vec::new();
- let mut unsafe_assignment = None;
- for &part in parts {
- if part.kind() == "variable_assignment" {
- if !SAFE_VARIABLES.contains(&assigned(&src[part.byte_range()])) {
- unsafe_assignment.get_or_insert(assignments.len());
- }
- assignments.push(self.assignment_word(part, src));
- self.visit_children(part, src);
- } else if part.is_named() {
- self.visit(part, src);
- }
- }
-
- self.simple_command(&words, assignments, unsafe_assignment, Fed::default(), src);
+ .map(ShellWord::text)
+ .collect::>()
+ .join(" ")
}
- /// Returns whether the environment sets one of the [`DIR_VARIABLES`], so the analysis cannot
- /// resolve a tilde or a [`DirVariable`] from its own.
- fn sets_dir_variable(&self) -> bool {
- sets_dir_variable(&self.environment)
+ /// The program `segment` runs when every wrapper option is known.
+ pub(super) fn program(segment: &Segment) -> &[ShellWord] {
+ segment.programs().next().unwrap()
}
+}
- /// `word` with no value the analysis knows where it may expand one of the [`DIR_VARIABLES`]
- /// the environment sets.
- fn resolvable(&self, mut word: Word, src: &str) -> Word {
- if self.sets_dir_variable() && reads_dir_variable(&src[word.span.clone()]) {
- word.value = None;
- }
- word
- }
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
- /// The prefix assignment `node` as [`assignment_word`] reads it, which is not literal where it
- /// may expand one of the [`DIR_VARIABLES`] the environment sets.
- fn assignment_word(&self, node: Node<'_>, src: &str) -> ShellWord {
- let text = &src[node.byte_range()];
- if self.sets_dir_variable() && reads_dir_variable(text) {
- return ShellWord {
- text: text.to_owned(),
- literal: false,
- };
- }
- assignment_word(node, src)
- }
+ use indoc::indoc;
- /// Records a `declare`, `export`, `local`, `readonly`, `typeset`, or `unset` command, which the
- /// grammar parses apart from other simple commands, as the simple command bash runs.
- fn declaration(&mut self, node: Node<'_>, src: &str) {
- let mut cursor = node.walk();
- let mut children = node.children(&mut cursor);
- let keyword = children
- .next()
- .expect("a declaration command starts with its keyword");
- let parts: Vec> = children.filter(Node::is_named).collect();
- let mut all = vec![Word::token(keyword, src)];
- all.extend(words(&parts, src));
- for &part in &parts {
- if part.kind() == "variable_assignment" {
- self.visit_children(part, src);
- } else {
- self.visit(part, src);
- }
- }
- self.simple_command(&all, Vec::new(), None, Fed::default(), src);
- }
+ use super::*;
+ use crate::permission::shell::local_testing::{
+ is_safe, joined, parse, parse_inheriting, program,
+ };
+ use crate::permission::shell::walk::MAX_CONSTRUCT_CHARS;
+ use crate::util::path::local_testing::abs;
+ use crate::util::path::testing::HOME_VAR;
- /// Records a `[ ... ]` test as the simple command of the `[` builtin, whose words are the
- /// tokens between the brackets in source order. `[[ ... ]]` evaluates its operands as
- /// arithmetic, and a token the grammar reads as an operator where bash reads a redirection or a
- /// list operator, such as `>` or `&&`, means the rules would weigh other words than bash runs,
- /// so either leaves the command unrecognized.
- fn test(&mut self, node: Node<'_>, src: &str) {
- let mut cursor = node.walk();
- let children: Vec> = node.children(&mut cursor).collect();
- let mut tokens = Vec::new();
- let recognized = match children.as_slice() {
- [open, inner @ .., close] if open.kind() == "[" && close.kind() == "]" => inner
- .iter()
- .all(|&child| test_tokens(child, &mut tokens))
- .then_some((*open, *close)),
- _ => None,
- };
- let Some((open, close)) = recognized else {
- self.mark_unrecognized(&src[node.byte_range()]);
- self.visit_children(node, src);
- return;
- };
- for &token in &tokens {
- if token.is_named() {
- self.visit(token, src);
- }
- }
- let mut all = vec![Word::token(open, src)];
- all.extend(words(&tokens, src));
- all.push(Word::token(close, src));
- self.simple_command(&all, Vec::new(), None, Fed::default(), src);
- }
+ mod command_target {
+ use super::*;
- /// Weighs a `${...}` expansion, which leaves its word expanding, and the words of its operands.
- /// It stays recognized when it only substitutes text, as [`substitutes_text`] decides.
- fn expansion(&mut self, node: Node<'_>, src: &str) {
- self.expands = true;
- if !substitutes_text(node, src) {
- self.mark_unrecognized(&src[node.byte_range()]);
- }
- let mut cursor = node.walk();
- for child in node.named_children(&mut cursor) {
- match child.kind() {
- "special_variable_name" | "variable_name" => {}
- "subscript" => {
- if let Some(index) = child.child_by_field_name("index") {
- self.visit(index, src);
- }
- }
- _ => self.visit(child, src),
- }
- }
- }
+ mod parse {
+ use super::*;
- /// Records the simple command of `all` and the commands it runs through `find`, led by the
- /// `assignments` before it, of which the one at `unsafe_assignment` is the first to set a
- /// variable outside [`SAFE_VARIABLES`]. `fed` holds what an `xargs` or `find` around it passes
- /// it, such as the placeholder a `find` action's paths replace.
- fn simple_command(
- &mut self,
- all: &[Word],
- mut assignments: Vec,
- unsafe_assignment: Option,
- fed: Fed,
- src: &str,
- ) {
- if all.is_empty() {
- return;
- }
- let unwrapped = self.unwrap(all, fed, src);
- let pinned = unsafe_assignment.is_some() || unwrapped.named < unwrapped.starts[0];
- let safe = all[unwrapped.starts[0]..]
- .split_first()
- .filter(|_| !pinned)
- .and_then(|(first, args)| {
- let name = first.literal()?;
- if matches!(name, "cd" | "pushd") {
- self.enters_workspace(name, args).then(Vec::new)
- } else {
- safe_operands(name, args)
- }
- });
- let mut operands: Vec<_> = unwrapped
- .reads
- .iter()
- .cloned()
- .map(Operand::certain)
- .collect();
- let mut reads_anything = false;
- let mut finds = Vec::new();
- for (i, &start) in unwrapped.starts.iter().enumerate() {
- let Some(first) = all.get(start) else {
- continue;
- };
- let Some(name) = first
- .literal()
- .filter(|name| !unwrapped.fed.replaced(start, name))
- else {
- if i == 0 {
- self.mark_unrecognized(span_text(&all[start..], src));
- }
- continue;
- };
- let inherited =
- Inherited::new(&self.environment, &unwrapped, start, &assignments, all, src);
- let shell = self.program(name, &all[start..], i == 0, &inherited, src);
- reads_anything |= shell.anything;
- operands.extend(shell.possible);
- let folded = fold_name(basename(name));
- if folded == "find" {
- finds.push((start + 1, inherited.variables));
- }
- match &safe {
- Some(files) if i == 0 => operands.extend(files.iter().cloned()),
- _ if READERS.contains(&folded.as_str()) => {
- let args = &all[start + 1..];
- reads_anything |= self.reader_reads(&folded, args, &mut operands, src)
- || unwrapped.fed.passes_input();
- }
- _ => {}
+ /// Each simple command's named words, joined on spaces.
+ fn texts(command: &str) -> Vec {
+ parse(command)
+ .segments
+ .iter()
+ .map(|segment| joined(segment.named()))
+ .collect()
}
- }
- let (reads, moved_anywhere) = self.resolve_operands(&operands);
- reads_anything |= moved_anywhere;
-
- let mut words: Vec = all
- .iter()
- .enumerate()
- .map(|(i, word)| {
- let mut word = word.shell_word(src);
- word.literal &= !unwrapped.fed.replaced(i, &word.text);
- word
- })
- .collect();
- let mut named: Vec = match unsafe_assignment {
- Some(first) => assignments
- .split_off(first)
- .into_iter()
- .chain(words)
- .collect(),
- None => words.split_off(unwrapped.named),
- };
- let programs = unwrapped
- .starts
- .iter()
- .map(|start| named.len() + start - all.len())
- .collect();
- if unwrapped.fed.appends {
- named.push(ShellWord {
- text: INPUT_WORD.to_owned(),
- literal: false,
- });
- }
- self.segments.push(Segment {
- named,
- programs,
- reads,
- reads_anything,
- safe: safe.is_some(),
- });
- for (args, environment) in finds {
- self.within(environment, |walk| walk.find_actions(&all[args..], src));
- }
- }
-
- /// Runs `walk` on this walk with `environment` as the variables the enclosing commands set.
- fn within(
- &mut self,
- environment: Vec<(String, Option)>,
- walk: impl FnOnce(&mut Self) -> T,
- ) -> T {
- let outer = mem::replace(&mut self.environment, environment);
- let result = walk(self);
- self.environment = outer;
- result
- }
-
- /// Adds the files reader `name` with `args` reads to `operands`, its own and, for `sed`, those
- /// its scripts read, and returns whether it may read any file.
- fn reader_reads(
- &mut self,
- name: &str,
- args: &[Word],
- operands: &mut Vec,
- src: &str,
- ) -> bool {
- let (runs, mut anything) = self.reader_operands(name, args);
- if name == "sed" {
- anything |= self.sed_scripts(args, &runs, operands, src);
- }
- operands.extend(runs.into_iter().flat_map(|(dir, files)| {
- files
- .into_iter()
- .map(move |operand| operand.spelled_from(&dir))
- }));
- anything
- }
- /// Resolves the `operands` of one simple command under each directory it may run in,
- /// recording each file it reads for certain as the command's read, and returns those it may
- /// read, each uncertain operand and each tree it traverses, whose descendants a rule may hold
- /// below its root, with whether a relative operand may name any file after a directory change
- /// the walk cannot spell.
- fn resolve_operands(&mut self, operands: &[Operand]) -> (Vec>, bool) {
- let relative = operands
- .iter()
- .any(|operand| Path::new(&operand.read.path).is_relative());
- self.relative_path |= relative;
- let unknown = relative && self.entered.unknown
- || self.entered.unknown_stacked
- && operands
+ fn words(command: &str) -> Vec {
+ program(&parse(command).segments[0])
.iter()
- .any(|operand| stack_tilde(&operand.read.path).is_some());
- let mut possible = Vec::new();
- for operand in operands {
- for path in self.spellings(&operand.read.path) {
- let spelled = ReaderPath {
- path,
- recursive: operand.read.recursive,
- };
- let read = spelled.resolve(&mut self.paths);
- if operand.certain {
- self.reads.push(read.path.clone());
- }
- if !operand.certain || read.recursive {
- possible.push(read);
- }
+ .map(|word| word.text().to_owned())
+ .collect()
}
- }
- (possible, unknown)
- }
-
- /// Weighs the program `name` that `words` run with their arguments: a shell whose literal
- /// script the walk follows, a directory change, a path relative to the working directory, and,
- /// at the `primary` start bash runs, a builtin, as [`Self::builtin`] weighs it. It returns
- /// what [`Self::nested_script`] does for a shell.
- fn program(
- &mut self,
- name: &str,
- words: &[Word],
- primary: bool,
- inherited: &Inherited<'_>,
- src: &str,
- ) -> ShellReads {
- let args = &words[1..];
- self.relative_path |= is_relative_program(name);
- let base = basename(name);
- let folded = fold_name(base);
- let (shell, builtin) = self.within(inherited.variables.clone(), |walk| {
- let shell = [base, folded.as_str()]
- .into_iter()
- .find(|shell| SHELLS.contains(shell))
- .map(|shell| walk.nested_script(shell, words, inherited, src))
- .unwrap_or_default();
- (shell, !primary || walk.builtin(name, args, src))
- });
- if !builtin {
- self.mark_unrecognized(span_text(words, src));
- }
- if DIRECTORY_CHANGERS.contains(&name) {
- let inside = self.enters_workspace(name, args);
- self.change_dir(inside, words, src);
- self.enter_dir(name, args);
- }
- shell
- }
- /// Returns whether the walk recognizes running `name` with `args`, which holds for any name no
- /// builtin has. The [`DISPATCH_BUILTINS`] are unrecognized, as is a builtin that sets a
- /// variable other than one [`Self::may_set`] admits, or tests a variable whose name may hold a
- /// subscript, since a subscript is an arithmetic context that may run a substitution. `eval` is
- /// recognized with a literal script, which the walk follows, `source` with a literal path,
- /// `set` only with the options [`sets_only_tracing_options`] admits, and `export` only for the
- /// [`SAFE_VARIABLES`].
- fn builtin(&mut self, name: &str, args: &[Word], src: &str) -> bool {
- let local = |word: Option<&Word>| {
- word.and_then(|word| word.value.as_deref())
- .is_some_and(|name| self.may_set(name))
- };
- match name {
- _ if DISPATCH_BUILTINS.contains(&name) => false,
- "." | "source" => match sourced_file(args) {
- Some(path) => {
- self.open(path, true);
- true
- }
- None => false,
- },
- "eval" => match literal_script(args) {
- Some(script) => {
- self.script(&script);
- true
+ #[test]
+ fn hands_the_words_a_redirection_carries_to_the_command_bash_gives_them() {
+ for (command, expected) in [
+ ("<<< x", &[][..]),
+ ("cat <<'EOF' a b\nx\nEOF", &["cat a b"][..]),
+ ("cat x <<'EOF' a\nx\nEOF", &["cat x a"]),
+ ("true && cat <<'EOF' a\nx\nEOF", &["true", "cat a"]),
+ ("ls | cat <<'EOF' a\nx\nEOF", &["ls", "cat a"]),
+ ("! git <<'EOF' push\nx\nEOF", &["git push"]),
+ ("FOO=1 <<'EOF' git push\nx\nEOF", &["FOO=1 git push"]),
+ (
+ "FOO=1 BAR=2 <<'EOF' git push\nx\nEOF",
+ &["FOO=1 BAR=2 git push"],
+ ),
+ (">/dev/null <<'EOF' git push\nx\nEOF", &["git push"]),
+ ("git <<'EOF' 2>&1 push -f\nx\nEOF", &["git push -f"]),
+ ("git >/dev/null push -f", &["git push -f"]),
+ ("echo >&2 hi", &["echo hi"]),
+ ] {
+ assert_eq!(texts(command), expected, "{command:?}");
}
- None => false,
- },
- "export" => {
- !args.is_empty()
- && args.iter().all(|word| {
- declared_name(word, src).is_some_and(|name| SAFE_VARIABLES.contains(&name))
- })
- }
- // `-i` evaluates each later value as arithmetic, `-n` sends later writes to the
- // variable a value names, and `-x` exports the variable.
- "declare" | "local" | "readonly" | "typeset" => {
- builtin_options(args, src, "", "", "inx").is_some_and(|(_, operands)| {
- operands
- .iter()
- .all(|word| declared_name(word, src).is_some_and(|name| self.may_set(name)))
- })
}
- "getopts" => local(args.get(1)),
- "jobs" => builtin_options(args, src, "", "", "x").is_some(),
- "mapfile" | "readarray" => builtin_options(args, src, "cdnOsu", "", "C")
- .is_some_and(|(_, operands)| operands.iter().all(|word| local(Some(word)))),
- "printf" => match args {
- [first, rest @ ..] => match first.value.as_deref() {
- Some(option) if option.starts_with("-v") => match &option[2..] {
- "" => local(rest.first()),
- attached => self.may_set(attached),
- },
- Some(_) => true,
- None => rest.first().is_none_or(|word| local(Some(word))),
- },
- [] => true,
- },
- "read" => {
- builtin_options(args, src, "dinNptu", "a", "").is_some_and(|(names, operands)| {
- names.iter().all(|name| self.may_set(name))
- && operands.iter().all(|word| local(Some(word)))
- })
- }
- "set" => sets_only_tracing_options(args),
- "test" | "[" => !may_test_a_subscript(args),
- "unset" => builtin_options(args, src, "", "", "")
- .is_some_and(|(_, operands)| operands.iter().all(|word| local(Some(word)))),
- "wait" => builtin_options(args, src, "", "p", "")
- .is_some_and(|(names, _)| names.iter().all(|name| self.may_set(name))),
- _ => true,
- }
- }
- /// Records a directory change by `words`, which may leave the workspace unless it is the first,
- /// it runs once, and `inside` holds.
- fn change_dir(&mut self, inside: bool, words: &[Word], src: &str) {
- self.leaves_workspace |= self.dir_change.is_some() || self.repeats > 0 || !inside;
- self.dir_change
- .get_or_insert_with(|| construct(span_text(words, src)));
- }
-
- /// Records where `name` with `args` may move the working directory: a `cd` or `pushd` to one
- /// literal target, or a bare `cd` to the home directory, joined to each directory the walk
- /// already knows. A `pushd -n` stacks its literal target as written, or else leaves the stack
- /// unknown. Any other change, or one that may run more than once, leaves the directory unknown.
- fn enter_dir(&mut self, name: &str, args: &[Word]) {
- let target = match dir_target(name, args) {
- DirTarget::Home => home().filter(|_| !self.sets_dir_variable()),
- DirTarget::Path(path) => Some(path.to_owned()),
- DirTarget::Stay(Stacked::Nothing) => return,
- DirTarget::Stay(Stacked::Path(path)) => {
- let path = PathBuf::from(path);
- if !self.entered.stacked.contains(&path) {
- self.entered.stacked.push(path);
+ #[test]
+ fn splits_simple_commands_across_every_separator() {
+ for (command, expected) in [
+ ("a; b", &["a", "b"][..]),
+ ("a && b || c", &["a", "b", "c"]),
+ ("a | b |& c", &["a", "b", "c"]),
+ ("a & b", &["a", "b"]),
+ ("a\nb", &["a", "b"]),
+ ("(cd x && ls) ; { echo a; }", &["cd x", "ls", "echo a"]),
+ ("! git diff --quiet", &["git diff --quiet"]),
+ ("if true; then ls -la; fi", &["true", "ls -la"]),
+ ] {
+ assert_eq!(texts(command), expected, "{command:?}");
}
- return;
- }
- DirTarget::Stay(Stacked::Unknown) => {
- self.entered.unknown_stacked = true;
- return;
- }
- DirTarget::Unknown => None,
- };
- let Some(target) = target.filter(|_| self.repeats == 0) else {
- self.entered.unknown = true;
- return;
- };
- let joined: Vec<_> = iter::once(Path::new(""))
- .chain(self.entered.dirs.iter().map(PathBuf::as_path))
- .map(|dir| dir.join(&target))
- .collect();
- for dir in joined {
- if !self.entered.dirs.contains(&dir) {
- self.entered.dirs.push(dir);
+ assert!(parse("cargo test --all").unrecognized().is_none());
}
- }
- }
- /// The [`reader_operands`] of reader `name` with `args`, expanding each glob in the working
- /// directory and in each directory the walk may have entered, where a match is spelled from
- /// that directory. Each run pairs the operands with the directory they are spelled from, empty
- /// for the working directory.
- fn reader_operands(&self, name: &str, args: &[Word]) -> (Vec<(PathBuf, Vec)>, bool) {
- let cwd = self.workspace.cwd();
- let (files, mut anything) = reader_operands(name, args, cwd);
- let mut runs = vec![(PathBuf::new(), files)];
- if args.iter().any(|word| word.glob) {
- for dir in &self.entered.dirs {
- let base = cwd.join(dir);
- let (files, unknown) = reader_operands(name, args, &base);
- anything |= unknown;
- runs.push((base, files));
+ #[test]
+ fn strips_leading_assignments_and_keeps_words_unquoted() {
+ assert_eq!(texts(r#"LANG=C TZ="x y" cargo test"#), ["cargo test"]);
+ assert_eq!(
+ words(indoc! {r#"
+ "rm" -rf 'a b' c\ d "e\"f\
+ g\h" "$X"
+ "#}),
+ ["rm", "-rf", "a b", "c d", "e\"fg\\h", "\"$X\""]
+ );
+ assert!(texts("FOO=1").is_empty(), "an assignment runs no command");
}
- }
- (runs, anything)
- }
-
- /// `path` as the working directory and each directory the walk may have entered name it. A
- /// path that starts with a [`stack_tilde`] also names its rest under the working directory,
- /// each entered directory, and each stacked one as either of those resolves it, any of which
- /// the tilde may name.
- fn spellings(&self, path: &str) -> Vec {
- let mut spellings = vec![path.to_owned()];
- if Path::new(path).is_relative() {
- spellings.extend(
- self.entered
- .dirs
- .iter()
- .map(|dir| dir.join(path).to_string_lossy().into_owned()),
- );
- }
- if let Some(rest) = stack_tilde(path) {
- let rest = rest.trim_start_matches('/');
- let currents =
- iter::once(Path::new("")).chain(self.entered.dirs.iter().map(PathBuf::as_path));
- let named = currents.flat_map(|current| {
- iter::once(current.to_path_buf()).chain(
- self.entered
- .stacked
- .iter()
- .map(move |stacked| current.join(stacked)),
- )
- });
- spellings.extend(named.map(|dir| match dir.join(rest) {
- spelled if spelled.as_os_str().is_empty() => ".".to_owned(),
- spelled => spelled.to_string_lossy().into_owned(),
- }));
- }
- spellings
- }
-
- /// Returns whether `name` with `args` stays inside a workspace root, either as a `pushd -n`,
- /// which enters nothing whatever it stacks, or by entering one literal directory in a root
- /// spelled without `..`, which bash's `cd` applies to the spelled path while the gate resolves
- /// links first.
- fn enters_workspace(&mut self, name: &str, args: &[Word]) -> bool {
- let path = match dir_target(name, args) {
- DirTarget::Path(path) => path,
- DirTarget::Stay(_) => return true,
- DirTarget::Home | DirTarget::Unknown => return false,
- };
- Some(path)
- .filter(|path| {
- !Path::new(path)
- .components()
- .any(|c| c == Component::ParentDir)
- })
- .is_some_and(|path| self.paths.resolve(path).zone == Zone::Workspace)
- }
- /// Strips the wrappers that lead `words`. The named words keep every wrapper from the first one
- /// allow rules do not see through, that sets a variable outside [`SAFE_VARIABLES`], that is
- /// spelled with a directory, since `./nice` may be any program, or whose options the analysis
- /// cannot read. An option it does not know hides where the command starts, so the program may
- /// then start at any later word. What an enclosing `xargs` or `find` has `fed` the words joins
- /// what the wrappers feed it, and input that replaces part of an assignment's name, which
- /// sets a variable the analysis cannot name, leaves the command unrecognized.
- fn unwrap(&mut self, words: &[Word], fed: Fed, src: &str) -> Unwrapped {
- let mut unwrapped = Unwrapped::default();
- let mut pending = vec![0];
- // Every word from here on has been pending, so an unknown option past it adds none.
- let mut spread = words.len();
- let mut input_runs = false;
- while let Some(mut at) = pending.pop() {
- let primary = unwrapped.starts.is_empty();
- let mut pinned = false;
- while let Some((name, args)) = words[at..].split_first()
- && let Some(name) = name.value.as_deref()
- && let Some(wrapper) = WRAPPERS.iter().find(|w| w.name == basename(name))
- {
- self.relative_path |= is_relative_program(name);
- let run = match wrapper.invocation(args) {
- Ok(run) => run,
- Err(unknown) => {
- unwrapped.fed.appends |= wrapper.kind == Kind::Input;
- let from = at + 1 + unknown;
- if wrapper.assignments != Assignments::None {
- unwrapped.assignments.extend(
- (from..words.len())
- .filter(|&index| src[words[index].span.clone()].contains('=')),
- );
- }
- if from < spread {
- pending.extend((from..spread).rev());
- spread = from;
- }
- break;
- }
- };
- if run.elsewhere {
- self.change_dir(false, &words[at..], src);
- }
- if run.splits {
- self.mark_unrecognized(span_text(&words[at..], src));
- }
- unwrapped.reads.extend(run.reads);
- if run.start == args.len() {
- let input = wrapper.kind == Kind::Input;
- input_runs |= unwrapped.fed.appends && !input;
- unwrapped.fed.appends |= input && run.replace.is_none();
- break;
- }
- let sets_unsafe = (run.assignments.iter())
- .filter_map(|&index| args[index].value.as_deref())
- .any(|arg| !SAFE_VARIABLES.contains(&env_name(arg)));
- (unwrapped.assignments).extend(run.assignments.iter().map(|index| at + 1 + index));
- at += 1 + run.start;
- if wrapper.kind == Kind::Input {
- match run.replace {
- Some(placeholder)
- if !placeholder.whole
- || words[at..].iter().any(|word| {
- word.value.as_deref() == Some(placeholder.text.as_str())
- }) =>
- {
- unwrapped.fed.replaces.push((at, placeholder));
- }
- _ => unwrapped.fed.appends = true,
- }
+ #[test]
+ fn keeps_an_assignment_that_may_change_the_program_in_the_text_a_rule_names() {
+ for (command, text, name) in [
+ (
+ "RUST_LOG=debug LD_PRELOAD=./x.so cat a",
+ "LD_PRELOAD=./x.so cat a",
+ "cat",
+ ),
+ ("GIT_PAGER=sh git log", "GIT_PAGER=sh git log", "git"),
+ ("PATH=/tmp ls", "PATH=/tmp ls", "ls"),
+ (
+ "env BASH_ENV=./x.sh ./build.sh",
+ "env BASH_ENV=./x.sh ./build.sh",
+ "./build.sh",
+ ),
+ (
+ "nice env LD_PRELOAD=./x.so nohup cat a",
+ "env LD_PRELOAD=./x.so nohup cat a",
+ "cat",
+ ),
+ ] {
+ let command = parse(command);
+ let segment = &command.segments[0];
+ assert_eq!(joined(segment.named()), text, "{command:?}");
+ assert_eq!(program(segment)[0].text(), name, "{command:?}");
+ assert!(!segment.is_safe(), "{command:?}");
+ assert!(command.unrecognized().is_none(), "{command:?}");
}
- pinned |= sets_unsafe || name != wrapper.name || wrapper.kind != Kind::Transparent;
- if primary && !pinned {
- unwrapped.named = at;
+ for (command, text) in [
+ ("LD_PRELOAD=$PWD/x.so cat a", "LD_PRELOAD=./x.so cat a"),
+ ("LD_PRELOAD=$PWD.so cat a", "LD_PRELOAD=$PWD.so cat a"),
+ ] {
+ let segment = &parse(command).segments[0];
+
+ assert_eq!(joined(segment.named()), text, "{command:?}");
}
- }
- if !unwrapped.starts.contains(&at) {
- unwrapped.starts.push(at);
- }
- }
- if input_runs {
- unwrapped.starts.push(words.len());
- }
- unwrapped.fed.appends |= fed.appends;
- unwrapped.fed.replaces.extend(fed.replaces);
- if unwrapped.assignments.iter().any(|&index| {
- (words[index].value.as_deref())
- .is_some_and(|text| unwrapped.fed.replaced(index, env_name(text)))
- }) {
- self.mark_unrecognized(span_text(words, src));
- }
- unwrapped
- }
-
- /// Records the commands `find`'s `-exec`, `-execdir`, `-ok`, and `-okdir` actions run, each up
- /// to its `;`, or its `+` after `{}`.
- fn find_actions(&mut self, args: &[Word], src: &str) {
- let mut rest = args;
- while let Some(at) = rest.iter().position(|word| {
- word.value
- .as_deref()
- .is_some_and(|value| FIND_RUNNERS.contains(&value))
- }) {
- let body = &rest[at + 1..];
- let end = body
- .iter()
- .enumerate()
- .position(|(i, word)| match word.value.as_deref() {
- Some(";") => true,
- Some("+") => i > 0 && body[i - 1].value.as_deref() == Some("{}"),
- _ => false,
- })
- .unwrap_or(body.len());
- if rest[at]
- .value
- .as_deref()
- .is_some_and(|value| value.ends_with("dir"))
- {
- self.change_dir(false, &rest[at..at + 1 + end], src);
- }
- let found = Placeholder {
- text: DEFAULT_REPLACE.to_owned(),
- whole: false,
- };
- let fed = Fed {
- appends: false,
- replaces: vec![(0, found)],
- };
- self.simple_command(&body[..end], Vec::new(), None, fed, src);
- rest = body.get(end + 1..).unwrap_or_default();
- }
- }
-
- /// Weighs what `runner`, one of the [`SHELLS`], runs with the arguments after `words[0]`: the
- /// literal script a shell's `-c` runs, which the walk follows so deny rules see the commands
- /// inside it, the script file and startup files it runs, which are reads, or the command
- /// `runuser -u` runs without a shell. A script [`shell_run`] cannot tell, or one the
- /// `inherited` input replaces, leaves the command unrecognized. It returns the files the shell
- /// may run that the walk cannot name for certain or reads as trees.
- fn nested_script(
- &mut self,
- runner: &str,
- words: &[Word],
- inherited: &Inherited<'_>,
- src: &str,
- ) -> ShellReads {
- let args = &words[1..];
- if runner == "runuser"
- && let Ok(run) = RUNUSER.invocation(args)
- && args[..run.start].iter().any(|word| {
- word.value
- .as_deref()
- .is_some_and(|arg| arg == "-u" || arg == "--user" || arg.starts_with("--user="))
- })
- {
- let fed = inherited
- .fed
- .for_command_at(inherited.start + 1 + run.start);
- self.simple_command(&args[run.start..], Vec::new(), None, fed, src);
- return ShellReads::default();
- }
- // `su` and `runuser` hand the words after the user name to the user's shell, whose
- // grammar the analysis does not know, so a command option anywhere names their script and
- // any other word there, or an input word, may name a script file. The shell keeps the
- // environment but for `HOME`, which they set to the user's home directory unless an
- // option preserves the environment.
- if matches!(runner, "runuser" | "su") {
- let script = command_option_script(args);
- if let Some(script) = &script {
- if (1..words.len()).any(|index| inherited.replaced(index, &words[index])) {
- self.mark_unrecognized(span_text(words, src));
- }
- self.script(script);
- }
- let su = su_args(args);
- let home = su.keeps_home.then_some(&HOME_VARIABLE);
- let variables = [&BASH_GRAMMAR, &POSIX_SHELL_GRAMMAR, &ZSH_GRAMMAR]
- .into_iter()
- .flat_map(|grammar| grammar.variables)
- .chain(home);
- let mut reads = self.startup_variables(variables, inherited, words, src);
- reads.anything |= script.is_none() && inherited.fed.passes_input();
- reads.possible.extend(
- su.shell
- .iter()
- .filter_map(Word::literal)
- .filter(|arg| !arg.starts_with('-'))
- .map(|path| Operand::possible(ReaderPath::exact(path.to_owned()))),
- );
- return reads;
- }
- let shell = shell_run(runner, args);
- let variables = shell_grammar(runner)
- .map_or(&[][..], |grammar| grammar.variables)
- .iter()
- .chain([&HOME_VARIABLE]);
- let mut reads = self.startup_variables(variables, inherited, words, src);
- for (index, file) in shell.startup {
- match expand_tilde(file).filter(|_| !inherited.replaced(1 + index, &args[index])) {
- Some(file) => self.open(&file, true),
- None => reads.anything = true,
- }
- }
- match shell.run {
- ShellRun::Command { index, script } if !inherited.replaced(1 + index, &args[index]) => {
- self.script(script);
- }
- ShellRun::File { index, .. } if inherited.replaced(1 + index, &args[index]) => {
- reads.anything = true;
- }
- ShellRun::File { path, .. } => self.open(path, true),
- ShellRun::Bare => reads.anything |= inherited.fed.appends,
- ShellRun::Stdin => {}
- ShellRun::Command { .. } | ShellRun::Unknown => {
- if let Some(script) = command_option_script(args) {
- self.script(&script);
- }
- self.mark_unrecognized(span_text(words, src));
- }
- }
- reads
- }
-
- /// Weighs the startup files that the `inherited` values of the startup `variables` name for a
- /// shell: a file a value names opens, and the directory one names is a tree the shell may read
- /// any file below. The shell `words` run stays unrecognized when a file's name runs a command
- /// substitution as bash expands it.
- fn startup_variables<'v>(
- &mut self,
- variables: impl IntoIterator,
- inherited: &Inherited<'_>,
- words: &[Word],
- src: &str,
- ) -> ShellReads {
- let mut reads = ShellReads::default();
- for variable in variables {
- match inherited.value(variable.name).map(Option::as_deref) {
- None | Some(Some("")) => {}
- Some(Some(dir)) if variable.directory => {
- reads.possible.push(Operand::tree(dir.to_owned()));
- }
- Some(Some(value)) => {
- if value.contains("$(") || value.contains('`') {
- self.mark_unrecognized(span_text(words, src));
- }
- match expanded_file(value, !inherited.sets_dir_variable()) {
- Some(files) => files.iter().for_each(|file| self.open(file, true)),
- None => reads.anything = true,
- }
- }
- Some(None) => reads.anything = true,
- }
- }
- reads
- }
-
- fn redirect(&mut self, node: Node<'_>, src: &str) {
- self.visit_children(node, src);
- let mut cursor = node.walk();
- let Some(operator) = node.children(&mut cursor).find(|child| !child.is_named()) else {
- return;
- };
- // tree-sitter-bash has no `<>`, so it parses one as a `<` before an error `>` or as an
- // error `<` before a `>`. Bash drops a line continuation between the two first.
- let read_write = match operator.kind() {
- "<" => src[operator.end_byte()..]
- .trim_start_matches("\\\n")
- .starts_with('>'),
- ">" => src[..operator.start_byte()]
- .trim_end_matches("\\\n")
- .strip_suffix('<')
- .is_some_and(|before| {
- before.chars().rev().take_while(|&c| c == '\\').count() % 2 == 0
- }),
- _ => false,
- };
- let operator = operator.kind();
- let reads = operator == "<";
- if !reads && !WRITE_OPERATORS.contains(&operator) {
- return;
- }
- let destinations: Vec> = node
- .children_by_field_name("destination", &mut cursor)
- .collect();
- // Further destinations are the command's words, which `redirect_words` hands to it.
- let Some(&destination) = destinations.first() else {
- return;
- };
-
- let word = Word::new(&[destination], src);
- let Some(path) = word.literal() else {
- self.mark_unrecognized(&src[node.byte_range()]);
- return;
- };
- if operator == ">&" && (destination.kind() == "number" || path == "-") {
- return;
- }
- if reads || read_write {
- self.open(path, true);
- }
- if !reads || read_write {
- self.open(path, false);
- }
- }
-
- /// Records that the command opens `path` to read it, or else to write it, unless it is one of
- /// the [`DEVICES`].
- fn open(&mut self, path: &str, read: bool) {
- if DEVICES.contains(&path) {
- return;
- }
- self.relative_path |= Path::new(path).is_relative();
- for spelled in self.spellings(path) {
- let access = self.paths.resolve(&spelled);
- if read {
- self.reads.push(access);
- } else {
- self.writes.push(access);
- }
- }
- }
-
- /// Weighs what the `sed` call with `args` does beyond reading the operands in `runs`, adding
- /// the files its scripts read to `operands` and returning whether it may read any file. Each
- /// file any argument order or build reads or writes counts for certain. An in-place call
- /// writes each operand and each backup a suffix makes of it, spelled from the operand's
- /// directory, and a suffix that expands, or one GNU's build takes beside `--follow-symlinks`,
- /// which backs up the link's target, leaves the command unrecognized. Each word some build
- /// takes as the script parses with that build's commands, and the files it reads and writes
- /// count, even those before a part the analysis cannot read. A script that expands, that may
- /// run a command, or that comes from a file leaves the command unrecognized, as does one GNU's
- /// build takes that does not parse, since another GNU release may accept it. A word BSD's
- /// build fails to parse adds only the files it writes before the error, where BSD exits.
- fn sed_scripts(
- &mut self,
- args: &[Word],
- runs: &[(PathBuf, Vec)],
- operands: &mut Vec,
- src: &str,
- ) -> bool {
- let values: Vec<_> = args.iter().map(|word| word.value.as_deref()).collect();
- let parsed = reader_args("sed", &values);
- let backups = &parsed.script.backups;
- let mut opaque = parsed.script.from_file
- || parsed.script.follows_links
- && backups.iter().any(|backup| backup.dialect == Dialect::Gnu)
- || backups
- .iter()
- .any(|backup| backup.suffix.is_none() || args[backup.index].glob);
- if parsed.script.in_place {
- for (dir, files) in runs {
- for file in files {
- let path = &file.read.path;
- self.open(&spelled_from(dir, path), false);
- for backup in backups.iter().filter_map(|backup| backup.path(path)) {
- self.open(&spelled_from(dir, &backup), false);
- }
- }
- }
- }
- for word in parsed.script.words {
- let Some(value) = word.value else {
- opaque = true;
- continue;
- };
- let script = sed_script(&value, word.dialect).unwrap_or_else(|writes| {
- opaque |= word.dialect == Dialect::Gnu;
- SedScript {
- writes,
- ..SedScript::default()
- }
- });
- opaque |= script.runs;
- operands.extend(script.reads.into_iter().map(Operand::certain));
- for path in &script.writes {
- self.open(path, false);
- }
- }
- if opaque {
- self.mark_unrecognized(span_text(args, src));
- }
- opaque
- }
-}
-
-/// `path` as a command in `dir` names it, which is `path` itself for an empty `dir`.
-fn spelled_from(dir: &Path, path: &str) -> String {
- dir.join(path).to_string_lossy().into_owned()
-}
-
-/// Where a `cd`, `pushd`, or `popd` with `args` moves the working directory.
-#[derive(Debug, PartialEq, Eq)]
-enum DirTarget<'w> {
- /// The home directory, where a bare `cd` goes.
- Home,
- /// One literal directory.
- Path(&'w str),
- /// Nowhere, since `pushd -n` changes only the directory stack.
- Stay(Stacked<'w>),
- /// Anywhere: `cd -`, `popd`, a stack rotation, a [`stack_tilde`], or a target that expands.
- Unknown,
-}
-
-/// What a `pushd -n` adds to the directory stack.
-#[derive(Debug, PartialEq, Eq)]
-enum Stacked<'w> {
- /// Nothing, since with no operand or a rotation it only reorders the stack.
- Nothing,
- /// The literal directory it names, as written, which bash resolves only when a
- /// [`stack_tilde`] uses it.
- Path(&'w str),
- /// A directory the analysis cannot spell, as a target that expands or a [`stack_tilde`] names.
- Unknown,
-}
-
-/// The target of directory changer `name` with `args`, after the options bash's `cd` (`-L`, `-P`,
-/// and `-e`) or `pushd` (`-n`) takes and a `--` that ends them. `cd -@` enters a file's extended
-/// attributes, which no path names, so it goes anywhere, and `pushd -n` stays.
-fn dir_target<'w>(name: &str, args: &'w [Word]) -> DirTarget<'w> {
- let options = match name {
- "cd" => "LPe",
- "pushd" => "n",
- _ => return DirTarget::Unknown,
- };
- let mut rest = args;
- let mut ended = false;
- let mut stays = false;
- while let Some((first, after)) = rest.split_first() {
- let Some(value) = first.literal() else {
- break;
- };
- if value == "--" {
- ended = true;
- } else if !value
- .strip_prefix('-')
- .is_some_and(|flags| !flags.is_empty() && flags.chars().all(|c| options.contains(c)))
- {
- break;
- } else {
- stays |= name == "pushd";
- }
- rest = after;
- if ended {
- break;
- }
- }
- let target = match rest {
- [] if name == "cd" => return DirTarget::Home,
- [] if stays => return DirTarget::Stay(Stacked::Nothing),
- [target] => target
- .literal()
- .filter(|path| !path.is_empty() && stack_tilde(path).is_none()),
- _ => None,
- };
- match target {
- Some(rotation) if stays && !ended && is_stack_rotation(rotation) => {
- DirTarget::Stay(Stacked::Nothing)
- }
- Some(path) if ended || !path.starts_with(['-', '+']) => {
- if stays {
- DirTarget::Stay(Stacked::Path(path))
- } else {
- DirTarget::Path(path)
- }
- }
- _ if stays => DirTarget::Stay(Stacked::Unknown),
- _ => DirTarget::Unknown,
- }
-}
-
-/// Returns whether `arg` rotates the directory stack, as in `pushd +1` or `pushd -0`.
-fn is_stack_rotation(arg: &str) -> bool {
- arg.strip_prefix(['+', '-'])
- .is_some_and(|digits| !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()))
-}
-
-/// The children of `command` that make up its simple command: assignments, name, redirections, and
-/// arguments, including an argument the grammar reads as an anonymous token, such as a bare `==`,
-/// which bash passes as a word.
-fn command_parts(command: Node<'_>) -> Vec> {
- let mut cursor = command.walk();
- command
- .children(&mut cursor)
- .zip(0..)
- .filter(|&(part, index)| {
- part.is_named() || command.field_name_for_child(index) == Some("argument")
- })
- .map(|(part, _)| part)
- .collect()
-}
-
-/// The words bash reads from `nodes`, in source order, joining the parts of one word that the
-/// grammar splits where only backslash escapes separate them.
-fn words(nodes: &[Node<'_>], src: &str) -> Vec {
- nodes
- .chunk_by(|left, right| Gap::between(*left, *right, src).joins())
- .map(|parts| Word::new(parts, src))
- .collect()
-}
-
-/// What separates two tokens tree-sitter-bash parses apart.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Gap {
- /// Nothing.
- Empty,
- /// Unescaped spaces, tabs, or newlines, with any line continuations, which end a word.
- Blank,
- /// Backslash escapes of characters other than a newline, which bash reads as part of a word.
- Escapes,
- /// Line continuations, which bash removes before it reads any token, so the tokens abut.
- Continuations,
- /// Anything else, such as an escape beside a blank or a mix of escapes and continuations.
- Other,
-}
-
-impl Gap {
- fn between(left: Node<'_>, right: Node<'_>, src: &str) -> Self {
- Self::of(&src[left.end_byte()..right.start_byte()])
- }
-
- fn of(text: &str) -> Self {
- let (mut blank, mut escape, mut continuation) = (false, false, false);
- let mut chars = text.chars();
- while let Some(c) = chars.next() {
- match c {
- ' ' | '\t' | '\n' => blank = true,
- '\\' => match chars.next() {
- Some('\n') => continuation = true,
- Some(_) | None => escape = true,
- },
- _ => return Self::Other,
- }
- }
- match (blank, escape, continuation) {
- (false, false, false) => Self::Empty,
- (true, false, _) => Self::Blank,
- (false, true, false) => Self::Escapes,
- (false, false, true) => Self::Continuations,
- (_, true, _) => Self::Other,
- }
- }
-
- /// Returns whether bash reads the tokens on either side as one word.
- fn joins(self) -> bool {
- matches!(self, Self::Empty | Self::Escapes | Self::Continuations)
- }
-
- /// The text bash reads in `text`, a gap that [`joins`](Self::joins): each escaped character,
- /// with line continuations removed.
- fn value(text: &str) -> String {
- let mut value = String::new();
- let mut chars = text.chars();
- while chars.next().is_some() {
- value.extend(chars.next().filter(|&escaped| escaped != '\n'));
- }
- value
- }
-}
-
-/// Returns whether tree-sitter-bash ends every word in the tree under `root` where bash does, so
-/// the words a rule sees are the words bash runs. Outside quotes, only blanks and line
-/// continuations may separate tokens. Escapes alone may separate only the parts of one command
-/// word, which [`words`] joins, and line continuations alone may only follow a control operator,
-/// since bash removes them before it reads tokens. A `$` expansion may not span a blank. Command
-/// and process substitutions are checked as scripts of their own, even inside quotes.
-fn splits_words_like_bash(root: Node<'_>, src: &str) -> bool {
- let edge = |text: &str| matches!(Gap::of(text), Gap::Empty | Gap::Blank | Gap::Continuations);
- edge(&src[..root.start_byte()])
- && edge(&src[root.end_byte()..])
- && node_splits_words_like_bash(root, src, false)
-}
-
-fn node_splits_words_like_bash(node: Node<'_>, src: &str, quoted: bool) -> bool {
- let quoted = match node.kind() {
- "command_substitution" | "process_substitution" => false,
- "expansion" | "heredoc_body" | "string" | "translated_string" => true,
- _ => quoted,
- };
- let mut cursor = node.walk();
- let children: Vec> = node.children(&mut cursor).collect();
- (quoted
- || children
- .windows(2)
- .all(|pair| separates_like_bash(node, pair[0], pair[1], src)))
- && children
- .iter()
- .all(|&child| node_splits_words_like_bash(child, src, quoted))
-}
-
-/// Returns whether bash reads the source between the sibling tokens `left` and `right` of `parent`
-/// as tree-sitter-bash does.
-fn separates_like_bash(parent: Node<'_>, left: Node<'_>, right: Node<'_>, src: &str) -> bool {
- let text = &src[left.end_byte()..right.start_byte()];
- match Gap::of(text) {
- Gap::Empty => true,
- Gap::Blank => parent.kind() != "simple_expansion",
- Gap::Escapes => parent.kind() == "command" && is_word_part(left) && is_word_part(right),
- Gap::Continuations => {
- matches!(left.kind(), "&" | "&&" | ";" | "|" | "|&" | "||") && right.is_named()
- }
- Gap::Other => parent.kind() == "redirected_statement" && is_dropped_dash(text),
- }
-}
-
-/// Returns whether `part`, a child of a command, is its name or an argument.
-fn is_word_part(part: Node<'_>) -> bool {
- !matches!(
- part.kind(),
- "variable_assignment" | "file_redirect" | "herestring_redirect" | "heredoc_redirect"
- )
-}
-
-/// Returns whether `gap` holds only the lone `-` word tree-sitter-bash drops from the end of a
-/// command before a numbered redirection or a heredoc, as in `python3 - < bool {
- let blank = |c: char| c == ' ' || c == '\t';
- gap.starts_with(blank) && gap.ends_with(blank) && gap.trim_matches(blank) == "-"
-}
-
-/// The lone `-` bash passes after `last`, the last part of a simple command, when tree-sitter-bash
-/// drops it, as [`is_dropped_dash`] describes.
-fn dropped_dash(last: Node<'_>, src: &str) -> Option {
- let rest = &src[last.end_byte()..];
- let start = last.end_byte() + rest.find(|c| c != ' ' && c != '\t')?;
- let dash = start..start + 1;
- (start > last.end_byte()
- && src[start..].starts_with('-')
- && src[dash.end..].starts_with([' ', '\t']))
- .then(|| Word {
- span: dash,
- value: Some("-".to_owned()),
- glob: false,
- splits: false,
- })
-}
-
-/// The words bash passes to the command `redirect` belongs to, which tree-sitter-bash parses as
-/// part of it: the destinations of a file redirection after its first, and the words after a
-/// heredoc operator, with those of a file redirection inside the heredoc, in source order.
-fn redirect_words(redirect: Node<'_>) -> Vec> {
- let mut cursor = redirect.walk();
- match redirect.kind() {
- "file_redirect" => redirect
- .children_by_field_name("destination", &mut cursor)
- .skip(1)
- .collect(),
- "heredoc_redirect" => {
- let mut words: Vec> = redirect
- .children_by_field_name("argument", &mut cursor)
- .collect();
- words.extend(
- redirect
- .children_by_field_name("redirect", &mut cursor)
- .flat_map(redirect_words),
- );
- words.sort_by_key(Node::start_byte);
- words
- }
- _ => Vec::new(),
- }
-}
-
-/// Bash's reserved words other than `time`, which tree-sitter-bash reads as a command name and
-/// [`runs_a_reserved_word`] reads past. The grammar gives each compound command it parses a node of
-/// its own, so a command word in bash's command position spelled as one of these comes from a
-/// mis-parse: of `coproc`, which the grammar lacks, or of a compound after `time`.
-const RESERVED_WORDS: &[&str] = &[
- "!", "[[", "]]", "case", "coproc", "do", "done", "elif", "else", "esac", "fi", "for",
- "function", "if", "in", "select", "then", "until", "while", "{", "}",
-];
-
-/// Returns whether bash reads the word in the command position of the command made of `parts` as a
-/// reserved word: its name, or the first word after a leading `time` keyword and its options.
-fn runs_a_reserved_word(parts: &[Node<'_>], src: &str) -> bool {
- if parts
- .first()
- .is_none_or(|name| name.kind() != "command_name")
- {
- return false;
- }
- let mut after_time = false;
- for part in parts {
- match &src[part.byte_range()] {
- "time" => after_time = true,
- "-p" | "--" if after_time => {}
- word => return RESERVED_WORDS.contains(&word),
- }
- }
- false
-}
-
-/// The first node under `root`, in source order, that tree-sitter-bash could not parse or inserted
-/// to recover from an error.
-fn first_error(root: Node<'_>) -> Option> {
- let mut cursor = root.walk();
- loop {
- let node = cursor.node();
- if node.is_error() || node.is_missing() {
- return Some(node);
- }
- if node.has_error() && cursor.goto_first_child() {
- continue;
- }
- while !cursor.goto_next_sibling() {
- if !cursor.goto_parent() {
- return None;
- }
- }
- }
-}
-
-/// The first line of `text`, cut to [`MAX_CONSTRUCT_CHARS`] characters, as a prompt names an
-/// unrecognized construct.
-fn construct(text: &str) -> String {
- let text = text.trim();
- let line = text.lines().next().unwrap_or_default();
- match line.char_indices().nth(MAX_CONSTRUCT_CHARS) {
- Some((end, _)) => format!("{}...", &line[..end]),
- None if line.len() < text.len() => format!("{line}..."),
- None => line.to_owned(),
- }
-}
-
-/// The source `words` span, from the first one's start to the last one's end.
-fn span_text<'s>(words: &[Word], src: &'s str) -> &'s str {
- let start = words.iter().map(|word| word.span.start).min().unwrap_or(0);
- let end = words
- .iter()
- .map(|word| word.span.end)
- .max()
- .unwrap_or(start);
- &src[start..end]
-}
-
-/// Collects into `tokens` the tokens of `node`, part of a `[ ... ]` test, that bash passes to `[`
-/// as words, or returns `false` when one is a token bash reads otherwise, as a redirection or a
-/// list operator, or that only `[[ ... ]]` and arithmetic have.
-fn test_tokens<'t>(node: Node<'t>, tokens: &mut Vec>) -> bool {
- match node.kind() {
- "binary_expression" | "unary_expression" => {
- let mut cursor = node.walk();
- let children: Vec> = node.children(&mut cursor).collect();
- children.into_iter().all(|child| test_tokens(child, tokens))
- }
- "!"
- | "!="
- | "="
- | "=="
- | "ansi_c_string"
- | "command_substitution"
- | "concatenation"
- | "expansion"
- | "extglob_pattern"
- | "number"
- | "raw_string"
- | "simple_expansion"
- | "string"
- | "test_operator"
- | "word" => {
- tokens.push(node);
- true
- }
- _ => false,
- }
-}
-
-/// Operators a `${...}` expansion may apply and still only substitute text: a default, an
-/// alternate, or an error for an unset or empty value, a pattern removed or replaced, or a change
-/// of case. The grammar names each after the variable.
-const TEXT_OPERATORS: &[&str] = &[
- ":-", "-", ":+", "+", ":?", "?", "#", "##", "%", "%%", "/", "//", "/#", "/%", "^", "^^", ",",
- ",,",
-];
-
-/// Returns whether `expansion`, a `${...}` node, only substitutes text: it names a value as
-/// [`names_a_value`] reads one, led by at most a `#` for its length or followed by at most one of
-/// the [`TEXT_OPERATORS`]. Any other form assigns, evaluates an arithmetic context that may run a
-/// substitution, as an offset or another subscript does, follows a name to another variable, or
-/// transforms the value, as `@P` prompt expansion does. The grammar leaves a pattern operand as one
-/// `regex` token even where bash expands a `$` or a backquote in it, so such a pattern counts as
-/// unrecognized too.
-fn substitutes_text(expansion: Node<'_>, src: &str) -> bool {
- let mut cursor = expansion.walk();
- let parts: Vec> = expansion
- .children(&mut cursor)
- .filter(|part| !matches!(part.kind(), "${" | "}"))
- .collect();
- let rest = match parts.as_slice() {
- // `${#}` and `${!}`, the argument count and the last background process.
- [only] if matches!(only.kind(), "#" | "!") => return true,
- [length, name] if length.kind() == "#" => return names_a_value(*name, src),
- [name, rest @ ..] if names_a_value(*name, src) => rest,
- _ => return false,
- };
- match rest {
- [] => true,
- [operator, operands @ ..] => {
- let replaces = operator.kind().starts_with('/');
- TEXT_OPERATORS.contains(&operator.kind())
- && operands.iter().all(|operand| match operand.kind() {
- "/" => replaces,
- "regex" => !src[operand.byte_range()].contains(['$', '`']),
- _ => operand.is_named(),
- })
- }
- }
-}
-
-/// Returns whether `node`, the name in a `${...}` expansion, names a value without evaluating
-/// anything: a variable, a special parameter, or an array element by number or all elements by `@`
-/// or `*`.
-fn names_a_value(node: Node<'_>, src: &str) -> bool {
- match node.kind() {
- "special_variable_name" | "variable_name" => true,
- "subscript" => node.child_by_field_name("index").is_some_and(|index| {
- index.kind() == "number" || matches!(&src[index.byte_range()], "@" | "*")
- }),
- _ => false,
- }
-}
-
-/// Returns whether `element`, part of an `( ... )` array value, sets a key other than a number,
-/// which an indexed array evaluates as arithmetic, where a name's value may run a substitution.
-fn keys_by_arithmetic(element: Node<'_>, src: &str) -> bool {
- let mut cursor = element.walk();
- let parts: Vec> = element.children(&mut cursor).collect();
- let text = |node: &Node<'_>| &src[node.byte_range()];
- match parts.as_slice() {
- [open, rest @ ..] if element.kind() == "concatenation" && text(open) == "[" => {
- !matches!(rest, [key, close, ..] if key.kind() == "number" && text(close) == "]")
- }
- _ => false,
- }
-}
-
-fn parse_tree(source: &str) -> Tree {
- let mut parser = Parser::new();
- parser
- .set_language(&tree_sitter_bash::LANGUAGE.into())
- .expect("the bundled bash grammar matches the tree-sitter ABI");
- parser
- .parse(source, None)
- .expect("parsing stops early only on a timeout or cancellation, and neither is set")
-}
-
-/// The last component of a command path, so `/bin/sh` counts as `sh`.
-fn basename(name: &str) -> &str {
- name.rsplit(['/', '\\']).next().unwrap_or(name)
-}
-
-/// Returns whether bash runs the program `name` from a path relative to the working directory, as
-/// it does for a name with a `/` that does not start at the root, such as `./build.sh`.
-fn is_relative_program(name: &str) -> bool {
- name.contains('/') && Path::new(name).is_relative()
-}
-
-// ── Shell ──
-
-/// What a shell runs, as [`shell_run`] reads its arguments.
-enum ShellRun<'w> {
- /// The literal script `-c` runs, from the argument at `index`.
- Command { index: usize, script: &'w str },
- /// The literal script file it runs, from the argument at `index`.
- File { index: usize, path: &'w str },
- /// The script `-s` reads from standard input, whose operands are its arguments.
- Stdin,
- /// No script word, so it reads standard input, or runs the script file the first word an
- /// `xargs` appends names.
- Bare,
- /// A script the analysis cannot tell, since a word may expand or an option is one the shell's
- /// [`ShellGrammar`] does not list.
- Unknown,
-}
-
-/// A shell's arguments as [`shell_run`] reads them.
-struct ShellArgs<'w> {
- run: ShellRun<'w>,
- /// The startup files a long option names, which an interactive shell sources, each with the
- /// index of its argument.
- startup: Vec<(usize, &'w str)>,
-}
-
-/// A variable a shell reads as it starts, which names a startup file or a directory of them.
-struct StartupVariable {
- name: &'static str,
- /// Whether the value names a directory the shell may read any file below, since the startup
- /// files each shell and mode reads there differ, or else the file itself, which the shell
- /// expands as [`expanded_file`] reads it.
- directory: bool,
-}
-
-impl StartupVariable {
- const fn file(name: &'static str) -> Self {
- Self {
- name,
- directory: false,
- }
- }
-
- const fn directory(name: &'static str) -> Self {
- Self {
- name,
- directory: true,
- }
- }
-}
-
-/// The variable every shell reads its startup files below, whatever its grammar.
-const HOME_VARIABLE: StartupVariable = StartupVariable::directory("HOME");
-
-/// The variables a leading tilde or a [`DirVariable`] expands from. Where a command sets one, the
-/// analysis cannot resolve those expansions from its own environment.
-const DIR_VARIABLES: &[&str] = &["HOME", "OLDPWD", "PWD"];
-
-/// The files that `value`, a startup file's name, may name once a shell expands it: as written,
-/// as dash leaves it, and with a leading tilde expanded, as bash does it. `None` when it may name
-/// any file, since a parameter expansion or command substitution in it, which bash runs, has no
-/// value the analysis knows, or its tilde names a user the analysis cannot find or expands from a
-/// variable the command sets, which `tilde_known` rules out.
-fn expanded_file(value: &str, tilde_known: bool) -> Option> {
- if value.contains(['$', '`']) || !tilde_known && value.contains('~') {
- return None;
- }
- let expanded = expand_tilde(value)?;
- let mut files = vec![value.to_owned()];
- if expanded != value {
- files.push(expanded);
- }
- Some(files)
-}
-
-/// The files a shell may run that the walk cannot name for certain.
-#[derive(Default)]
-struct ShellReads {
- /// Words that may name a script file it runs, as the words `su` hands its user's shell may,
- /// and the trees of startup files it may run below a directory a startup variable names.
- possible: Vec,
- /// Whether it may run any file, such as one an `xargs` input word names or one a startup
- /// variable names with a value that expands.
- anything: bool,
-}
-
-/// What a program inherits from the rest of its simple command.
-struct Inherited<'c> {
- /// What an `xargs` or `find` around the command passes it.
- fed: &'c Fed,
- /// Where the program's words start among the command's, which `fed` indexes.
- start: usize,
- /// Each variable that the enclosing commands, the command's prefix assignments, and its
- /// wrappers set for the program, in order, with its value when literal.
- variables: Vec<(String, Option)>,
-}
-
-impl<'c> Inherited<'c> {
- /// What the program at `start` among the words `all` inherits from the `environment` the
- /// enclosing commands set and from the prefix `assignments`, the wrapper assignments at the
- /// indices `unwrapped` lists, and the input `unwrapped` feeds it. An appending `NAME+=VALUE`,
- /// or a value that input words replace, has no known value.
- fn new(
- environment: &[(String, Option)],
- unwrapped: &'c Unwrapped,
- start: usize,
- assignments: &[ShellWord],
- all: &[Word],
- src: &str,
- ) -> Self {
- let fed = &unwrapped.fed;
- let prefix = assignments.iter().map(|word| {
- let name = assigned(&word.text);
- let value = word.text[name.len()..]
- .strip_prefix('=')
- .filter(|_| word.literal);
- (name, value)
- });
- let wrapped = (unwrapped.assignments.iter())
- .filter(|&&index| index < start)
- .map(|&index| {
- let word = &all[index];
- let literal = word.literal();
- let text = literal.unwrap_or(&src[word.span.clone()]);
- let value = (text.split_once('=').map(|(_, value)| value))
- .filter(|_| literal.is_some() && !fed.replaced(index, text));
- (env_name(text), value)
- });
- let mut variables = environment.to_vec();
- variables.extend(
- prefix
- .chain(wrapped)
- .map(|(name, value)| (name.to_owned(), value.map(str::to_owned))),
- );
- Self {
- fed,
- start,
- variables,
- }
- }
-
- /// Returns whether input words replace part of `word`, the program's word at `index`.
- fn replaced(&self, index: usize, word: &Word) -> bool {
- word.value
- .as_deref()
- .is_some_and(|text| self.fed.replaced(self.start + index, text))
- }
-
- /// The value the program's variables last set `name` to, `None` when none sets it, and
- /// `Some(None)` when that value is unknown.
- fn value(&self, name: &str) -> Option<&Option> {
- self.variables
- .iter()
- .rev()
- .find(|(variable, _)| variable == name)
- .map(|(_, value)| value)
- }
-
- /// Returns whether the program's variables set one of the [`DIR_VARIABLES`].
- fn sets_dir_variable(&self) -> bool {
- sets_dir_variable(&self.variables)
- }
-}
-
-/// Returns whether `variables` set one of the [`DIR_VARIABLES`].
-fn sets_dir_variable(variables: &[(String, Option)]) -> bool {
- variables
- .iter()
- .any(|(name, _)| DIR_VARIABLES.contains(&name.as_str()))
-}
-
-/// Returns whether the word written `text` may expand a tilde or a [`DirVariable`], each of which
-/// one of the [`DIR_VARIABLES`] names.
-fn reads_dir_variable(text: &str) -> bool {
- text.contains('~')
- || ["$HOME", "${HOME}", "$PWD", "${PWD}"]
- .iter()
- .any(|variable| text.contains(variable))
-}
-
-/// The variable a `NAME=VALUE` word that `env` or `sudo` takes sets, which is everything before
-/// the first `=`, whatever it holds.
-fn env_name(word: &str) -> &str {
- word.split_once('=').map_or(word, |(name, _)| name)
-}
-
-/// The startup options of a shell, as its manual lists them.
-struct ShellGrammar {
- /// The long options it takes before any other, with one dash or two, and whether each names a
- /// startup file in the next word.
- long: &'static [(&'static str, bool)],
- /// The letters a `-` or `+` cluster may hold besides `c` and `s`, each a flag or, as `values`
- /// says, an option that takes a value.
- letters: &'static str,
- values: &'static str,
- /// Whether each value letter in a cluster takes the next word in turn, as bash's do. Otherwise
- /// one before the end of its cluster leaves the run unknown, since zsh, ksh, and mksh take the
- /// rest of the cluster as its value, and dash rejects it.
- stacked_values: bool,
- /// The letters that end the options with their cluster, as zsh's `-b` does.
- ends: &'static str,
- /// Whether a lone `+` ends the options, as in zsh, or is skipped, as in bash.
- plus_ends: bool,
- /// The variables besides [`HOME_VARIABLE`] whose value names a startup file it may run.
- variables: &'static [StartupVariable],
-}
-
-/// What `shell` runs with `args`, read as bash reads its startup options: long options first, then
-/// `-` and `+` clusters up to the first operand or a `-` or `--` that ends them, where an option
-/// that takes a value takes the next word. The first operand is the script `-c` runs, or else,
-/// without `-s`, the script file, and with neither the shell reads its script from standard input.
-/// A shell without a [`ShellGrammar`] runs an unknown script once it has an option.
-fn shell_run<'w>(shell: &str, args: &'w [Word]) -> ShellArgs<'w> {
- let grammar = shell_grammar(shell);
- let mut startup = Vec::new();
- let run = 'run: {
- let (mut command, mut stdin) = (false, false);
- let mut long = true;
- let mut words = args.iter().enumerate();
- let operand = loop {
- let Some((index, word)) = words.next() else {
- break None;
- };
- let Some(arg) = word.literal() else {
- break 'run ShellRun::Unknown;
- };
- if !arg.starts_with(['-', '+']) {
- break Some((index, word));
- }
- let Some(grammar) = grammar else {
- break 'run ShellRun::Unknown;
- };
- if matches!(arg, "-" | "--") || arg == "+" && grammar.plus_ends {
- break words.next();
- }
- let named = arg.strip_prefix("--").or_else(|| arg.strip_prefix('-'));
- if long
- && let Some(&(_, sources)) =
- grammar.long.iter().find(|(name, _)| Some(*name) == named)
- {
- if sources {
- let Some(file) = words
- .next()
- .and_then(|(index, word)| Some((index, word.literal()?)))
- else {
- break 'run ShellRun::Unknown;
- };
- startup.push(file);
- }
- continue;
- }
- long = false;
- let cluster = &arg[1..];
- for (offset, letter) in cluster.char_indices() {
- match letter {
- 'c' => command = true,
- 's' => stdin = true,
- _ if !grammar.letters.contains(letter) => break 'run ShellRun::Unknown,
- _ if grammar.values.contains(letter) => {
- let last = offset + letter.len_utf8() == cluster.len();
- if !last && !grammar.stacked_values
- || words.next().and_then(|(_, word)| word.literal()).is_none()
- {
- break 'run ShellRun::Unknown;
- }
- }
- _ => {}
- }
- }
- if cluster.contains(|letter| grammar.ends.contains(letter)) {
- break words.next();
- }
- };
- let operand = operand.map(|(index, word)| (index, word.literal()));
- match (operand, command) {
- (Some((index, Some(script))), true) => ShellRun::Command { index, script },
- (_, true) => ShellRun::Unknown,
- _ if stdin => ShellRun::Stdin,
- (Some((index, Some(path))), false) => ShellRun::File { index, path },
- (Some((_, None)), false) => ShellRun::Unknown,
- (None, false) => ShellRun::Bare,
- }
- };
- ShellArgs { run, startup }
-}
-
-/// The grammar of `shell`, or `None` for one whose options differ from the Bourne shells', such as
-/// `fish`, `csh`, or `pwsh`.
-fn shell_grammar(shell: &str) -> Option<&'static ShellGrammar> {
- match shell {
- "bash" => Some(&BASH_GRAMMAR),
- "zsh" => Some(&ZSH_GRAMMAR),
- "ash" | "dash" | "hush" | "ksh" | "mksh" | "sh" => Some(&POSIX_SHELL_GRAMMAR),
- _ => None,
- }
-}
-
-/// Bash's startup options in versions 3.2 through 5.3, one of which may reject an option another
-/// takes and then exit before running anything.
-const BASH_GRAMMAR: ShellGrammar = ShellGrammar {
- long: &[
- ("debug", false),
- ("debugger", false),
- ("dump-po-strings", false),
- ("dump-strings", false),
- ("help", false),
- ("init-file", true),
- ("login", false),
- ("noediting", false),
- ("noprofile", false),
- ("norc", false),
- ("posix", false),
- ("pretty-print", false),
- ("protected", false),
- ("rcfile", true),
- ("restricted", false),
- ("verbose", false),
- ("version", false),
- ("wordexp", false),
- ],
- // cspell:ignore abefhiklmnoprtuvx BCDEHIOPT
- letters: "abefhiklmnoprtuvxBCDEHIOPT",
- values: "oO",
- stacked_values: true,
- ends: "",
- plus_ends: false,
- variables: &[
- StartupVariable::file("BASH_ENV"),
- StartupVariable::file("ENV"),
- ],
-};
-
-/// The startup options POSIX gives `sh`, which the other Bourne-style shells share.
-const POSIX_SHELL_GRAMMAR: ShellGrammar = ShellGrammar {
- long: &[],
- // cspell:ignore Cefhilmnouvx
- letters: "abCefhilmnouvx",
- values: "o",
- stacked_values: false,
- ends: "",
- plus_ends: false,
- variables: &[StartupVariable::file("ENV")],
-};
-
-/// zsh's startup options among POSIX's, where `-b` and a lone `+` also end the options.
-const ZSH_GRAMMAR: ShellGrammar = ShellGrammar {
- ends: "b",
- plus_ends: true,
- variables: &[StartupVariable::directory("ZDOTDIR")],
- ..POSIX_SHELL_GRAMMAR
-};
-
-/// The arguments of `su` or `runuser` as util-linux reads them.
-struct SuArgs<'w> {
- /// The words it passes its user's shell after the user name.
- shell: &'w [Word],
- /// Whether the shell keeps the `HOME` the command sets: an option preserves the environment,
- /// as `-m`, `-p`, or `--preserve-environment` does, and none asks for a login shell, as `-`,
- /// `-l`, or `--login` does.
- keeps_home: bool,
-}
-
-/// What `su` or `runuser` with `args` passes its user's shell, as util-linux reads its arguments:
-/// options end at the first operand, a lone `-` after them asks for a login shell, and the next
-/// word names the user. A word that expands where an option stands ends the options as the user
-/// does, so every later word counts.
-fn su_args(args: &[Word]) -> SuArgs<'_> {
- const VALUES: &[&str] = &[
- "command",
- "group",
- "session-command",
- "shell",
- "supp-group",
- "user",
- "whitelist-environment",
- ];
- // cspell:ignore cgGsuw
- const VALUE_LETTERS: &str = "cgGsuw";
- let (mut login, mut preserves) = (false, false);
- let mut i = 0;
- while let Some(arg) = args.get(i).and_then(Word::literal) {
- if arg == "-" || !arg.starts_with('-') {
- break;
- }
- i += 1;
- if arg == "--" {
- break;
- }
- let takes = if let Some(long) = arg.strip_prefix("--") {
- let name = long.split_once('=').map_or(long, |(name, _)| name);
- login |= "login".starts_with(name);
- preserves |= "preserve-environment".starts_with(name);
- !long.contains('=') && VALUES.iter().any(|value| value.starts_with(long))
- } else {
- let cluster = &arg[1..];
- let value = cluster.find(|letter| VALUE_LETTERS.contains(letter));
- let flags = &cluster[..value.unwrap_or(cluster.len())];
- login |= flags.contains('l');
- preserves |= flags.contains(['m', 'p']);
- value.is_some_and(|at| at + 1 == cluster.len())
- };
- i += usize::from(takes);
- }
- if args.get(i).and_then(Word::literal) == Some("-") {
- login = true;
- i += 1;
- }
- SuArgs {
- shell: args.get(i + 1..).unwrap_or(&[]),
- keeps_home: preserves && !login,
- }
-}
-
-/// The literal file `source` or `.` with `args` runs, after a `--` that ends its options. `None`
-/// for any option, since bash 5.3's `-p` searches a path for the file and other options fail.
-fn sourced_file(args: &[Word]) -> Option<&str> {
- match args {
- [first, rest @ ..] if first.literal() == Some("--") => rest.first()?.literal(),
- [first, ..] => first
- .literal()
- .filter(|path| *path == "-" || !path.starts_with('-')),
- [] => None,
- }
-}
-
-/// The script the word after a `--command` or a `-c` cluster such as `-lc` names, wherever it
-/// stands, or a `--command=` value.
-fn command_option_script(args: &[Word]) -> Option {
- args.iter().enumerate().find_map(|(i, word)| {
- let arg = word.value.as_deref()?;
- if let Some(script) = arg.strip_prefix("--command=") {
- return Some(script.to_owned());
- }
- let command = arg.starts_with('-') && !arg.starts_with("--") && arg.contains('c');
- (command || arg == "--command")
- .then(|| args.get(i + 1)?.value.clone())
- .flatten()
- })
-}
-
-// ── Wrapper ──
-
-/// A wrapper's option grammar and what running a command through it changes.
-struct Wrapper {
- name: &'static str,
- options: &'static [Opt],
- /// Whether `-N` sets a number, as `nice -10` does.
- numeric: bool,
- /// Words after the options that precede the command, such as `timeout`'s duration.
- positionals: usize,
- assignments: Assignments,
- kind: Kind,
- /// Whether the command runs in another directory whatever the options say.
- elsewhere: bool,
-}
-
-impl Wrapper {
- /// A wrapper allow rules see through, with no options.
- const TRANSPARENT: Self = Self {
- name: "",
- options: &[],
- numeric: false,
- positionals: 0,
- assignments: Assignments::None,
- kind: Kind::Transparent,
- elsewhere: false,
- };
-
- /// A runner allow rules must name, with no options.
- const RUNNER: Self = Self {
- kind: Kind::Runner,
- ..Self::TRANSPARENT
- };
-
- /// Parses the options that lead `args`, or returns the index of the first word that hides where
- /// the command starts: an option the grammar does not know or one that expands, or any word
- /// before the command that bash may expand into other than one word.
- fn invocation(&self, args: &[Word]) -> Result {
- let parsed = self.options(args);
- let end = parsed.as_ref().map_or_else(|&at| at, |run| run.start);
- match args[..end].iter().position(|word| word.splits) {
- Some(at) => Err(at),
- None => parsed,
- }
- }
-
- fn options(&self, args: &[Word]) -> Result {
- let mut run = Invocation {
- start: args.len(),
- elsewhere: self.elsewhere,
- ..Invocation::default()
- };
- let mut i = 0;
- while let Some(word) = args.get(i) {
- let at = i;
- let arg = word.value.as_deref().ok_or(at)?;
- if arg == "--" {
- i += 1;
- break;
- }
- if !arg.starts_with('-') || (arg == "-" && self.option("-").is_none()) {
- if self.assignments == Assignments::Among && self.assignments.takes(arg) {
- run.assignments.push(at);
- i += 1;
- continue;
- }
- break;
- }
- i += 1;
- if arg == "-" {
- continue;
- }
- if arg.starts_with("--") {
- let (name, attached) = arg
- .split_once('=')
- .map_or((arg, None), |(name, value)| (name, Some(value)));
- let option = self.option(name).ok_or(at)?;
- let value = match (option.value, attached) {
- (Value::None, Some(_)) => return Err(at),
- (Value::Required, None) => {
- let Some(next) = args.get(i) else {
- return Ok(run);
- };
- i += 1;
- Arg::of(next)
- }
- (_, Some(value)) => Arg::Literal(value.to_owned()),
- (_, None) => Arg::None,
- };
- run.apply(option.effect, value).ok_or(at)?;
- continue;
- }
- let cluster = &arg[1..];
- if self.numeric && cluster.bytes().all(|b| b.is_ascii_digit()) {
- continue;
- }
- for (offset, c) in cluster.char_indices() {
- let option = self.short(c).ok_or(at)?;
- let rest = &cluster[offset + c.len_utf8()..];
- let value = match option.value {
- Value::None => Arg::None,
- _ if !rest.is_empty() => Arg::Literal(rest.to_owned()),
- Value::Optional => Arg::None,
- Value::Required => {
- let Some(next) = args.get(i) else {
- return Ok(run);
- };
- i += 1;
- Arg::of(next)
- }
- };
- run.apply(option.effect, value).ok_or(at)?;
- if option.value != Value::None {
- break;
- }
- }
- }
- i += self.positionals;
- while args
- .get(i)
- .and_then(|arg| arg.value.as_deref())
- .is_some_and(|arg| self.assignments.takes(arg))
- {
- run.assignments.push(i);
- i += 1;
- }
- run.start = i.min(args.len());
- Ok(run)
- }
-
- fn option(&self, name: &str) -> Option<&Opt> {
- self.options.iter().find(|option| option.name == name)
- }
-
- /// The single-letter option `-c`, which may share a cluster with others, as in `-nE`.
- fn short(&self, c: char) -> Option<&Opt> {
- self.options.iter().find(|option| {
- option
- .name
- .strip_prefix('-')
- .is_some_and(|letter| letter.chars().eq([c]))
- })
- }
-}
-
-/// One option of a wrapper.
-#[derive(Clone, Copy)]
-struct Opt {
- name: &'static str,
- value: Value,
- effect: Effect,
-}
-
-impl Opt {
- const fn with(self, effect: Effect) -> Self {
- Self { effect, ..self }
- }
-}
-
-const fn flag(name: &'static str) -> Opt {
- Opt {
- name,
- value: Value::None,
- effect: Effect::None,
- }
-}
-
-const fn valued(name: &'static str) -> Opt {
- Opt {
- value: Value::Required,
- ..flag(name)
- }
-}
-
-const fn optional(name: &'static str) -> Opt {
- Opt {
- value: Value::Optional,
- ..flag(name)
- }
-}
-
-/// Whether an option takes a value.
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum Value {
- None,
- /// A value as the next word or attached, as in `-n 5`, `-n5`, or `--signal=KILL`.
- Required,
- /// A value only when attached, as in `xargs -i{}`.
- Optional,
-}
-
-/// Where a wrapper takes `NAME=VALUE` words that set the command's environment.
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum Assignments {
- None,
- /// After the options or a `--` that ends them, as `env` takes any word holding a `=`, whatever
- /// its name, even one that starts with `-`.
- After,
- /// Before, between, and after the options, as `sudo` takes any word holding a `=` that does not
- /// start with `/` or `=`.
- Among,
-}
-
-impl Assignments {
- /// Returns whether a wrapper that takes assignments this way reads `arg` as one.
- fn takes(self, arg: &str) -> bool {
- match self {
- Self::None => false,
- Self::After => arg.contains('='),
- Self::Among => arg.contains('=') && !arg.starts_with(['/', '=']),
- }
- }
-}
-
-/// What a wrapper changes about the command it runs.
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum Kind {
- /// It changes only how the command runs, so allow rules and known-safe reads see through it.
- Transparent,
- /// It changes who runs the command, so it stays in the words an allow rule must name.
- Runner,
- /// It appends the words it reads from its input to the command, as `xargs` does, so it stays in
- /// the words an allow rule must name too.
- Input,
-}
-
-/// The value an option takes.
-enum Arg {
- None,
- Literal(String),
- /// A value that expands when the command runs.
- Expands,
-}
-
-impl Arg {
- fn of(word: &Word) -> Self {
- word.value.clone().map_or(Self::Expands, Self::Literal)
- }
-}
-
-/// What an option changes about the command the wrapper runs.
-#[derive(Clone, Copy)]
-enum Effect {
- None,
- /// The command runs in another directory.
- Directory,
- /// The value is a placeholder that each input word replaces, in place of being appended.
- Replace,
- /// The value is a placeholder that the input words replace when it stands as a whole word, as
- /// BSD `xargs -J` takes it, and that leaves them appended otherwise.
- Insert,
- /// The value names a file the wrapper reads.
- Reads,
- /// The value is a command line the wrapper splits into words of its own, as `env -S` does,
- /// which the analysis does not follow.
- Splits,
-}
-
-/// How a wrapper runs its command, from the options before it.
-#[derive(Default)]
-struct Invocation {
- /// Where the command starts among the wrapper's arguments, which is past them all when it runs
- /// none.
- start: usize,
- elsewhere: bool,
- /// The placeholder that input words replace, when an option sets one.
- replace: Option,
- /// The files an option names for the wrapper to read.
- reads: Vec,
- /// Whether an option hands the wrapper a command line it splits itself.
- splits: bool,
- /// The arguments the wrapper takes as `NAME=VALUE` assignments.
- assignments: Vec,
-}
-
-impl Invocation {
- /// Records an option's `effect` with its `value`, or returns `None` when the effect needs a
- /// value the analysis cannot read.
- fn apply(&mut self, effect: Effect, value: Arg) -> Option<()> {
- match effect {
- Effect::None => {}
- Effect::Directory => self.elsewhere = true,
- Effect::Replace | Effect::Insert => {
- let text = match value {
- Arg::None => DEFAULT_REPLACE.to_owned(),
- Arg::Literal(value) => value,
- Arg::Expands => return None,
- };
- self.replace = Some(Placeholder {
- text,
- whole: matches!(effect, Effect::Insert),
- });
- }
- Effect::Reads => match value {
- Arg::Literal(path) => self.reads.push(path),
- Arg::None | Arg::Expands => return None,
- },
- Effect::Splits => self.splits = true,
- }
- Some(())
- }
-}
-
-/// A word that input words replace when the command runs.
-#[derive(Clone)]
-struct Placeholder {
- text: String,
- /// Whether only a word that is exactly `text` is replaced.
- whole: bool,
-}
-
-/// What the wrappers leading a simple command leave to run, as indices into its words.
-#[derive(Default)]
-struct Unwrapped {
- /// Where the words an allow rule must name start.
- named: usize,
- /// Where the program may start: first where it does when every wrapper option is known, then
- /// each word past an option that is not. The word count stands for the input words of an
- /// `xargs` whose wrapped wrapper takes its command from them.
- starts: Vec,
- fed: Fed,
- /// The files wrapper options read.
- reads: Vec,
- /// The `NAME=VALUE` words that wrappers such as `env` take as assignments, or may take past an
- /// option the analysis cannot read.
- assignments: Vec,
-}
-
-/// The words an `xargs` or `find` passes a command, as indices into its words.
-#[derive(Default)]
-struct Fed {
- /// Whether an `xargs` appends its input words to the command.
- appends: bool,
- /// The placeholders `xargs` or `find` replaces with its input words or found paths, each with
- /// where its command starts.
- replaces: Vec<(usize, Placeholder)>,
-}
-
-impl Fed {
- /// What reaches the command whose words start at `offset` among these words.
- fn for_command_at(&self, offset: usize) -> Self {
- Self {
- appends: self.appends,
- replaces: self
- .replaces
- .iter()
- .map(|(start, placeholder)| (start.saturating_sub(offset), placeholder.clone()))
- .collect(),
- }
- }
-
- /// Returns whether `xargs` or `find` passes words to the command, which may name any file.
- fn passes_input(&self) -> bool {
- self.appends || !self.replaces.is_empty()
- }
-
- /// Returns whether input words replace part of `text`, the word at `index`.
- fn replaced(&self, index: usize, text: &str) -> bool {
- self.replaces.iter().any(|(start, placeholder)| {
- index >= *start
- && if placeholder.whole {
- text == placeholder.text
- } else {
- text.contains(placeholder.text.as_str())
- }
- })
- }
-}
-
-/// The variable a `NAME=VALUE`, `NAME+=VALUE`, or `NAME[i]=VALUE` assignment sets.
-fn assigned(assignment: &str) -> &str {
- assignment
- .find(['=', '+', '['])
- .map_or(assignment, |end| &assignment[..end])
-}
-
-/// Returns whether `name` is a shell identifier: a letter or `_`, then letters, digits, or `_`.
-fn is_identifier(name: &str) -> bool {
- name.starts_with(|c: char| c == '_' || c.is_ascii_alphabetic())
- && name.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
-}
-
-/// Returns whether a write to the variable `name` can only change later `$name` expansions, which
-/// the analysis already reads as unknown: it is an identifier with a lowercase letter, which no
-/// variable a noninteractive bash reads has, and the environment `bash` starts with does not set
-/// it, as `inherited` tells, so the write exports nothing.
-fn is_local_name(name: &str, inherited: &dyn Fn(&str) -> bool) -> bool {
- is_identifier(name) && name.bytes().any(|b| b.is_ascii_lowercase()) && !inherited(name)
-}
-
-/// The variable a `declare`-family or `export` argument names: its value up to an `=` or `+=`, or
-/// when the value expands, the identifier it is written with before an `=`. `None` for an option, a
-/// name the analysis cannot read, or a literal value that assigns `(...)`, which the builtin parses
-/// again as an array whose keys and values may run substitutions the walk never sees.
-fn declared_name<'w>(word: &'w Word, src: &'w str) -> Option<&'w str> {
- let text = word.value.as_deref().unwrap_or(&src[word.span.clone()]);
- let (name, value) = text.split_once('=').unwrap_or((text, ""));
- let name = name.strip_suffix('+').unwrap_or(name);
- let reparsed = word.value.is_some() && value.starts_with('(');
- (is_identifier(name) && !reparsed).then_some(name)
-}
-
-/// The script `eval` runs from `args`, joined by spaces after a leading `--`, which `eval` drops,
-/// or `None` when one of them expands.
-fn literal_script(args: &[Word]) -> Option {
- let args = match args {
- [first, rest @ ..] if first.value.as_deref() == Some("--") => rest,
- _ => args,
- };
- args.iter()
- .map(|word| word.value.as_deref())
- .collect::