From a9f366351760c33a1231e4df5ce4834f48c482f4 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 30 Oct 2026 21:07:23 +0800 Subject: [PATCH 1/4] refactor(permission): split shell analysis by concern Separate syntax walks, nested shell invocations, wrappers, words, exec patterns, and reader analysis while keeping command and rule contracts in shell.rs. Move the existing tests with their subjects. Startup files and inherited variables share the invocation parser used by nested shells. --- crates/ox-core/src/permission/shell.rs | 6312 +---------------- .../src/permission/shell/invocation.rs | 452 ++ .../ox-core/src/permission/shell/pattern.rs | 141 + crates/ox-core/src/permission/shell/reader.rs | 1967 +++++ crates/ox-core/src/permission/shell/walk.rs | 2160 ++++++ crates/ox-core/src/permission/shell/word.rs | 470 ++ .../ox-core/src/permission/shell/wrapper.rs | 779 ++ docs/design/permissions.md | 8 +- 8 files changed, 6198 insertions(+), 6091 deletions(-) create mode 100644 crates/ox-core/src/permission/shell/invocation.rs create mode 100644 crates/ox-core/src/permission/shell/pattern.rs create mode 100644 crates/ox-core/src/permission/shell/reader.rs create mode 100644 crates/ox-core/src/permission/shell/walk.rs create mode 100644 crates/ox-core/src/permission/shell/word.rs create mode 100644 crates/ox-core/src/permission/shell/wrapper.rs diff --git a/crates/ox-core/src/permission/shell.rs b/crates/ox-core/src/permission/shell.rs index 08bea09fd..74bfb7c9a 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,181 @@ 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 [ + ("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"]), + (">/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::>>() - .map(|values| values.join(" ")) -} - -/// The variables a builtin's options name and the operands after them, read past option clusters -/// where each letter in `valued` takes a value, and each in `naming` takes a variable's name. -/// `None` when an option holds a letter in `refused`, whose effect the analysis does not follow, or -/// a word that expands may be an option or a name. A word written from a letter or `_`, as an -/// assignment with an array value is, cannot expand to an option. `--` ends the options. -fn builtin_options<'w>( - args: &'w [Word], - src: &str, - valued: &str, - naming: &str, - refused: &str, -) -> Option<(Vec<&'w str>, &'w [Word])> { - let mut names = Vec::new(); - let mut at = 0; - while let Some(word) = args.get(at) { - let Some(value) = word.value.as_deref() else { - src[word.span.clone()] - .starts_with(|c: char| c == '_' || c.is_ascii_alphabetic()) - .then_some(())?; - break; - }; - let Some(cluster) = value - .strip_prefix('-') - .filter(|cluster| !cluster.is_empty()) - else { - break; - }; - at += 1; - if cluster == "-" { - break; - } - for (offset, letter) in cluster.char_indices() { - if refused.contains(letter) { - return None; - } - if !valued.contains(letter) && !naming.contains(letter) { - continue; - } - let attached = &cluster[offset + letter.len_utf8()..]; - let value = if attached.is_empty() { - at += 1; - args.get(at - 1).map(|word| word.value.as_deref()) - } else { - Some(Some(attached)) - }; - if naming.contains(letter) { - names.push(value.flatten()?); - } - break; - } - } - Some((names, &args[at.min(args.len())..])) -} - -/// Returns whether `args` only turn on or off the `set` options that make a script stop on errors -/// or trace what it runs: `e`, `u`, `v`, and `x` as letters, and `errexit`, `nounset`, `pipefail`, -/// `verbose`, and `xtrace` by name after `-o` or `+o`. -fn sets_only_tracing_options(args: &[Word]) -> bool { - let mut values = args.iter().map(|word| word.value.as_deref()); - let mut any = false; - while let Some(value) = values.next() { - let Some(cluster) = value - .and_then(|value| value.strip_prefix(['-', '+'])) - .filter(|cluster| !cluster.is_empty()) - else { - return false; - }; - let (letters, named) = cluster - .strip_suffix('o') - .map_or((cluster, false), |letters| (letters, true)); - if !letters.chars().all(|letter| "euvx".contains(letter)) { - return false; - } - if named - && !values.next().flatten().is_some_and(|name| { - ["errexit", "nounset", "pipefail", "verbose", "xtrace"].contains(&name) - }) - { - return false; - } - any = true; - } - any -} - -/// Returns whether `test` or `[` may evaluate a word as a variable's name with `-v` or `-R`, whose -/// subscript runs any substitution in it: a word that is or may expand to either operator, followed -/// by one that expands or holds a `[`. -fn may_test_a_subscript(args: &[Word]) -> bool { - args.windows(2).any(|pair| { - pair[0] - .value - .as_deref() - .is_none_or(|operator| matches!(operator, "-v" | "-R")) - && pair[1] - .value - .as_deref() - .is_none_or(|name| name.contains('[')) - }) -} - -// ── Word ── - -/// One word of a simple command. -struct Word { - /// Where the word is written in the source. - span: Range, - /// The value the shell gives the word after tilde expansion and quote removal, or `None` when - /// part of it expands at run time. - value: Option, - /// Whether an unquoted `*`, `?`, `[`, or `{` leaves the word to pathname or brace expansion. - glob: bool, - /// Whether bash may expand the word into other than one word, as [`splits`] decides. - splits: bool, -} - -impl Word { - /// The word bash reads from `parts`, which the grammar may split one word into with no blank - /// between them, as in `=="x"`. - fn new(parts: &[Node<'_>], src: &str) -> Self { - let (value, glob) = unquote_parts(parts.iter().copied(), src, true) - .filter(|(value, _)| pwd_bounded(parts[0], src, value)) - .unzip(); - Self { - span: parts[0].start_byte()..parts[parts.len() - 1].end_byte(), - value, - glob: glob.unwrap_or(false), - splits: parts.iter().any(|&part| splits(part, src)), - } - } - - /// The literal word an anonymous token spells, such as a declaration's keyword or a test's - /// bracket. - fn token(node: Node<'_>, src: &str) -> Self { - Self { - span: node.byte_range(), - value: Some(src[node.byte_range()].to_owned()), - glob: false, - splits: false, - } - } - - /// The word's value when it is known and leaves nothing to pathname or brace expansion. - fn literal(&self) -> Option<&str> { - self.value.as_deref().filter(|_| !self.glob) - } - - fn shell_word(&self, src: &str) -> ShellWord { - ShellWord { - text: self - .value - .clone() - .unwrap_or_else(|| src[self.span.clone()].to_owned()), - literal: self.literal().is_some(), - } - } -} - -/// A leading `NAME=VALUE` assignment as a word, with its value unquoted and a leading `~` expanded. -/// Neither pathname nor brace expansion applies to an assignment, so only a value or a subscript -/// that expands keeps it from being literal. -fn assignment_word(node: Node<'_>, src: &str) -> ShellWord { - let written = || ShellWord { - text: src[node.byte_range()].to_owned(), - literal: false, - }; - if node - .child_by_field_name("name") - .is_none_or(|name| name.kind() != "variable_name") - { - return written(); - } - let Some(value) = node.child_by_field_name("value") else { - return ShellWord { - text: src[node.byte_range()].to_owned(), - literal: true, - }; - }; - match unquote(value, src, true).filter(|(unquoted, _)| pwd_bounded(value, src, unquoted)) { - Some((unquoted, _)) => ShellWord { - text: format!("{}{unquoted}", &src[node.start_byte()..value.start_byte()]), - literal: true, - }, - None => written(), - } -} - -/// `node`'s value and whether it globs, for [`Word`]. Tilde expansion and a [`DirVariable`] -/// expansion apply to a `leading` word alone. -fn unquote(node: Node<'_>, src: &str, leading: bool) -> Option<(String, bool)> { - let text = &src[node.byte_range()]; - match node.kind() { - "word" => unquote_word(text, leading), - "!" | "!=" | "=" | "==" | "=~" | "number" | "test_operator" | "variable_name" => { - Some((text.to_owned(), false)) - } - "variable_assignment" => { - let word = assignment_word(node, src); - word.literal.then_some((word.text, false)) - } - "raw_string" => Some((text.get(1..text.len() - 1)?.to_owned(), false)), - "ansi_c_string" => Some((unquote_ansi_c(text.get(2..text.len() - 1)?)?, false)), - "simple_expansion" | "expansion" if leading => { - Some((dir_prefix(node, src)?.0.value()?, false)) - } - "string" => { - let dir = dir_prefix(node, src).filter(|_| leading); - let mut cursor = node.walk(); - if !node - .named_children(&mut cursor) - .skip(usize::from(dir.is_some())) - .all(|part| part.kind() == "string_content") - { - return None; - } - let (prefix, start) = match dir { - Some((dir, end)) => (dir.value()?, end), - None => (String::new(), node.start_byte() + 1), - }; - let inner = src.get(start..node.end_byte() - 1)?; - Some((prefix + &unquote_double(inner), false)) - } - "concatenation" => { - let mut cursor = node.walk(); - unquote_parts(node.children(&mut cursor), src, leading) - } - _ => None, - } -} - -/// The value and glob flag of the one word `parts` spell in order, each read as [`unquote`] reads -/// it and joined by the [`Gap::value`] between them, or `None` when any part has none. Adjacent -/// unquoted parts are read as one, with the escapes and line continuations between them, so a -/// tilde prefix spans them. A leading tilde expands only when its unquoted run holds the `/` that -/// ends the prefix or ends the word, since bash leaves a prefix with a quoted character as written. -fn unquote_parts<'t>( - parts: impl IntoIterator>, - src: &str, - leading: bool, -) -> Option<(String, bool)> { - let parts: Vec> = parts.into_iter().collect(); - let mut value = String::new(); - let mut glob = false; - let mut previous: Option> = None; - let mut runs = parts - .chunk_by(|left, right| left.kind() == "word" && right.kind() == "word") - .peekable(); - while let Some(run) = runs.next() { - let (first, last) = (run[0], run[run.len() - 1]); - if let Some(previous) = previous { - value.push_str(&Gap::value(&src[previous.end_byte()..first.start_byte()])); - } - let leading = leading && previous.is_none(); - let (text, run_glob) = if first.kind() == "word" { - let text = &src[first.start_byte()..last.end_byte()]; - let quoted_prefix = - text.starts_with('~') && !text.contains('/') && runs.peek().is_some(); - unquote_word(text, leading && !quoted_prefix)? - } else { - unquote(first, src, leading)? - }; - value.push_str(&text); - glob |= run_glob; - previous = Some(last); - } - Some((value, glob)) -} - -/// A variable that names a directory. The analysis resolves its plain expansion, such as `$HOME` -/// or `${PWD}`, at the start of a word, and `PWD`'s only where [`pwd_bounded`] holds. -#[derive(Clone, Copy)] -enum DirVariable { - /// `HOME`, which bash reads from the environment as the analysis does. - Home, - /// `PWD`, which bash keeps current across directory changes, so it names the working - /// directory as `.` does. - Pwd, -} - -impl DirVariable { - fn parse(text: &str) -> Option { - match text { - "$HOME" | "${HOME}" => Some(Self::Home), - "$PWD" | "${PWD}" => Some(Self::Pwd), - _ => None, - } - } - - /// The directory the variable expands to, or `None` without a home directory. - fn value(self) -> Option { - match self { - Self::Home => home(), - Self::Pwd => Some(".".to_owned()), - } - } -} - -/// The [`DirVariable`] whose plain expansion starts the word part `node`, bare or first in a -/// double-quoted string, with the byte where that expansion ends. -fn dir_prefix(node: Node<'_>, src: &str) -> Option<(DirVariable, usize)> { - match node.kind() { - "simple_expansion" | "expansion" => Some(( - DirVariable::parse(&src[node.byte_range()])?, - node.end_byte(), - )), - "string" => node - .named_child(0) - .filter(|part| { - part.start_byte() == node.start_byte() + 1 - && matches!(part.kind(), "simple_expansion" | "expansion") - }) - .and_then(|part| dir_prefix(part, src)), - "concatenation" => dir_prefix(node.child(0)?, src), - _ => None, - } -} - -/// Returns whether `value`, the word that the part `first` leads, names what bash expands it to. -/// The `.` a leading `$PWD` stands for does only when the expansion ends the word or a `/` -/// follows it, as bash requires of `~+`, since `$PWD.log` names a sibling of the directory. -fn pwd_bounded(first: Node<'_>, src: &str, value: &str) -> bool { - !matches!(dir_prefix(first, src), Some((DirVariable::Pwd, _))) - || value == "." - || value.starts_with("./") -} - -/// The home directory a leading `~` expands to, or `None` without one. -fn home() -> Option { - Some(expand_user("~").ok()?.to_string_lossy().into_owned()) -} - -/// An unquoted word's value, with backslash escapes removed and the tilde [`tilde_at`] finds -/// expanded by [`expand_tilde`]. -fn unquote_word(text: &str, leading: bool) -> Option<(String, bool)> { - let mut value = String::with_capacity(text.len()); - let mut glob = false; - let mut chars = text.chars(); - while let Some(c) = chars.next() { - match c { - '\\' => value.extend(chars.next().filter(|&next| next != '\n')), - '*' | '?' | '[' | '{' => { - glob = true; - value.push(c); - } - _ => value.push(c), - } - } - let joined = text.replace("\\\n", ""); - if leading && let Some(at) = tilde_at(&joined) { - let tail = &joined[at + 1..]; - let name = &tail[..tail.find('/').unwrap_or(tail.len())]; - if name.contains(['*', '?', '[', '{']) { - return None; - } - if !name.contains('\\') { - value = value[..at].to_owned() + &expand_tilde(&value[at..])?; - } - } - Some((value, glob)) -} - -/// Where the tilde bash may expand in `word` stands: leading it, or after the first `=` of a word -/// that reads as an assignment, as in `env BASH_ENV=~/x`. -fn tilde_at(word: &str) -> Option { - if word.starts_with('~') { - return Some(0); - } - let (name, value) = word.split_once('=')?; - (is_identifier(name) && value.starts_with('~')).then_some(name.len() + 1) -} - -/// `path` with a leading tilde prefix expanded as bash expands it, or `None` for a user the -/// analysis cannot find. A [`stack_tilde`] stays as written, since the directory it names depends -/// on the changes before it, which [`Walk::spellings`] weighs. -fn expand_tilde(path: &str) -> Option { - let Some(tail) = path.strip_prefix('~') else { - return Some(path.to_owned()); - }; - let name = &tail[..tail.find('/').unwrap_or(tail.len())]; - Some(match name { - "" => expand_user(path).ok()?.to_string_lossy().into_owned(), - "+" => format!(".{}", &path[2..]), - _ if stack_tilde(path).is_some() => path.to_owned(), - _ => user_home(name)? + &path[1 + name.len()..], - }) -} - -/// The rest of `path` after a leading tilde that names a directory on the stack: `~-`, the -/// previous directory, or `~N`, `~+N`, or `~-N`, the stack's Nth entry. Each names the working -/// directory, a directory a `cd` or `pushd` entered, or one a `pushd -n` stacked, or else stays -/// as written, since `ox` starts `bash` without `OLDPWD` and with only the working directory on -/// the stack. -fn stack_tilde(path: &str) -> Option<&str> { - let tail = path.strip_prefix('~')?; - let end = tail.find('/').unwrap_or(tail.len()); - let (name, rest) = tail.split_at(end); - let index = name.strip_prefix(['+', '-']).unwrap_or(name); - let stack = name == "-" || !index.is_empty() && index.chars().all(|c| c.is_ascii_digit()); - stack.then_some(rest) -} - -/// The home directory of the user `name`, as bash's `~name` finds it. -#[cfg(unix)] -fn user_home(name: &str) -> Option { - let user = nix::unistd::User::from_name(name).ok()??; - Some(user.dir.to_string_lossy().into_owned()) -} - -/// The home directory of the user `name`, which the analysis does not look up off Unix. -#[cfg(not(unix))] -fn user_home(_name: &str) -> Option { - None -} - -/// Returns whether bash may expand the word part `node` into other than one word: by splitting an -/// unquoted expansion or substitution, by pathname expansion, or by brace expansion, which any -/// unquoted `{` but the literal `{}` may open. -fn splits(node: Node<'_>, src: &str) -> bool { - match node.kind() { - "arithmetic_expansion" - | "brace_expression" - | "command_substitution" - | "expansion" - | "simple_expansion" => true, - "concatenation" => { - let mut cursor = node.walk(); - node.children(&mut cursor).any(|part| splits(part, src)) - } - "word" => { - let mut chars = src[node.byte_range()].chars().peekable(); - while let Some(c) = chars.next() { - match c { - '\\' => { - chars.next(); - } - '*' | '?' | '[' => return true, - '{' if chars.peek() != Some(&'}') => return true, - _ => {} - } - } - false - } - _ => false, - } -} - -/// A double-quoted string's value: a backslash escapes only `$`, `` ` ``, `"`, `\`, and newlines. -fn unquote_double(inner: &str) -> String { - let mut value = String::with_capacity(inner.len()); - let mut chars = inner.chars().peekable(); - while let Some(c) = chars.next() { - match (c, chars.peek()) { - ('\\', Some(&'\n')) => _ = chars.next(), - ('\\', Some(&next @ ('$' | '`' | '"' | '\\'))) => { - value.push(next); - chars.next(); - } - _ => value.push(c), - } - } - value -} - -/// A `$'...'` string's value, with its escapes decoded as bash does, or `None` when an escape names -/// no character, names a lone byte past ASCII, or names NUL, where bash cuts the word short. -fn unquote_ansi_c(inner: &str) -> Option { - let mut value = String::with_capacity(inner.len()); - let mut chars = inner.chars().peekable(); - while let Some(c) = chars.next() { - if c != '\\' { - value.push(c); - continue; - } - let escape = chars.next()?; - let decoded = match escape { - 'a' => '\u{7}', - 'b' => '\u{8}', - 'e' | 'E' => '\u{1b}', - 'f' => '\u{c}', - 'n' => '\n', - 'r' => '\r', - 't' => '\t', - 'v' => '\u{b}', - '\\' | '\'' | '"' | '?' => escape, - '0'..='7' => { - let first = escape.to_digit(8)?; - let code = take_digits(&mut chars, 8, 2) - .map_or(first, |(rest, width)| first * 8_u32.pow(width) + rest); - char::from_u32(code & 0xff).filter(char::is_ascii)? - } - 'x' => char::from_u32(take_digits(&mut chars, 16, 2)?.0).filter(char::is_ascii)?, - 'u' => char::from_u32(take_digits(&mut chars, 16, 4)?.0)?, - 'U' => char::from_u32(take_digits(&mut chars, 16, 8)?.0)?, - 'c' => char::from_u32(u32::from(chars.next()?) & 0x1f)?, - other => { - value.push('\\'); - other - } - }; - if decoded == '\0' { - return None; - } - value.push(decoded); - } - Some(value) -} - -/// Up to `max` digits in `radix` from the front of `chars`, as their value and their count, or -/// `None` when none leads. -fn take_digits(chars: &mut Peekable>, radix: u32, max: usize) -> Option<(u32, u32)> { - let mut taken = None; - for _ in 0..max { - let Some(digit) = chars.peek().and_then(|c| c.to_digit(radix)) else { - break; - }; - chars.next(); - let (value, count) = taken.unwrap_or((0, 0)); - taken = Some((value * radix + digit, count + 1)); - } - taken -} - -// ── Patterns ── - -/// One word of an `exec` pattern. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum PatternWord { - Literal(String), - /// An unquoted `*` standing alone. - Any, -} - -const NAMES_NO_COMMAND: &str = "it must name a command, such as `cargo test *`"; - -const ONE_COMMAND: &str = "it must be one simple command, such as `cargo test *`. \ - Write a pattern for each command of a chain or pipeline."; - -const HOLDS_REDIRECTION: &str = "it holds a redirection. \ - Leave it out, since a pattern matches a command \ - with its redirections set aside, \ - and an `edit` pattern covers the file it writes."; - -/// Parses an `exec` pattern into its words: one simple command, optionally led by assignments, -/// whose words are each literal or a lone unquoted `*`. -pub(super) fn pattern_words(pattern: &str) -> Result> { - let tree = parse_tree(pattern); - let root = tree.root_node(); - ensure!(!root.has_error(), "it does not parse as a shell command"); - ensure!( - splits_words_like_bash(root, pattern), - "the shell parser `ox` uses would split its words where bash does not. \ - Write it without escaped blanks or line continuations between words." - ); - let mut cursor = root.walk(); - let nodes: Vec> = root.named_children(&mut cursor).collect(); - let command = match nodes.as_slice() { - [] => bail!("{NAMES_NO_COMMAND}"), - [command] => *command, - [..] => bail!("{ONE_COMMAND}"), - }; - match command.kind() { - "command" => {} - "redirected_statement" => bail!("{HOLDS_REDIRECTION}"), - "variable_assignment" => bail!("{NAMES_NO_COMMAND}"), - _ => bail!("{ONE_COMMAND}"), - } - - let mut parsed = Vec::new(); - let mut nodes = Vec::new(); - let mut names_command = false; - for child in command_parts(command) { - match child.kind() { - "variable_assignment" => { - let word = assignment_word(child, pattern); - ensure!( - word.literal, - "the assignment {:?} expands at run time. \ - Quote its value to match it as written.", - word.text - ); - parsed.push(PatternWord::Literal(word.text)); - } - "file_redirect" | "herestring_redirect" | "heredoc_redirect" => { - bail!("{HOLDS_REDIRECTION}"); - } - "command_name" => { - names_command = true; - match child.named_child(0) { - Some(name) => nodes.push(name), - None => bail!("{NAMES_NO_COMMAND}"), - } - } - _ => nodes.push(child), - } - } - for word in words(&nodes, pattern) { - let written = &pattern[word.span.clone()]; - if written == "*" { - parsed.push(PatternWord::Any); - continue; - } - let Some(value) = word.value else { - bail!("the word {written:?} expands at run time. Quote it to match it as written."); - }; - if word.glob { - bail!( - "the word {written:?} holds a glob, \ - but only a `*` standing alone as a word is a wildcard. \ - Write a pattern for each command it should cover, \ - or quote the word to match it as written." - ); - } - parsed.push(PatternWord::Literal(value)); - } - ensure!(names_command, "{NAMES_NO_COMMAND}"); - Ok(parsed) -} - -// ── Known-Safe Commands ── - -/// The operators and the closing `]` of a `test` or `[` call. -const TEST_OPERATORS: &[&str] = &["!", "!=", "(", ")", "<", "=", "==", ">", "]"]; - -/// The words that may name a file read by the shipped known-safe read-only command `name`, or -/// `None` when `name` is no such command or `args` take it out of the safe set. Every argument must -/// be literal and free of globs, so each file it reads is one the gate can check. Every word of a -/// `test` or `[` call other than an option or a [`TEST_OPERATORS`] word counts as a file, so a -/// string it compares reads as one too. -fn safe_operands(name: &str, args: &[Word]) -> Option> { - let values = args.iter().map(Word::literal).collect::>>()?; - let files = || { - let known: Vec> = values.iter().copied().map(Some).collect(); - let operands = reader_args(name, &known); - (!operands.unknown).then(|| operands.reads(None).collect()) - }; - match name { - ":" | "echo" | "export" | "false" | "printf" | "pwd" | "true" => Some(Vec::new()), - "set" => sets_only_tracing_options(args).then(Vec::new), - "[" | "test" => Some( - values - .iter() - .filter(|value| !value.starts_with('-') && !TEST_OPERATORS.contains(value)) - .map(|&value| Operand::possible(ReaderPath::exact(value.to_owned()))) - .collect(), - ), - "cat" | "head" | "ls" | "tail" | "wc" => files(), - "find" if !values.iter().any(|value| FIND_ACTIONS.contains(value)) => files(), - "grep" if !values.iter().any(|value| is_recursive_grep(value)) => files(), - "sed" => sed_files(&values).map(|files| files.into_iter().map(Operand::certain).collect()), - _ => None, - } -} - -/// Returns whether a `grep` argument makes it read every file under a directory. `--directories` -/// and `-d` count whatever their value, since grep accepts `rec` for `recurse`. -fn is_recursive_grep(arg: &str) -> bool { - match long_option(arg) { - Some(name) => ["dereference-recursive", "directories", "recursive"] - .iter() - .any(|option| option.starts_with(name)), - None => arg.starts_with('-') && arg.contains(['d', 'r', 'R']), - } -} - -/// The files a `sed` call reads, or `None` when it may edit a file in place, read a script file, or -/// run a script [`SAFE_SED_SCRIPT`] does not accept. An option after an operand is `None` too, -/// since BSD `sed` reads it as a file and may then take the operand before it as the script. A `--` -/// there is a file to BSD and ends the options for GNU, which read the same script. -fn sed_files(values: &[&str]) -> Option> { - let mut scripts = Vec::new(); - let mut files = Vec::new(); - let mut args = values.iter().copied(); - while let Some(arg) = args.next() { - if !files.is_empty() && arg.starts_with('-') && !matches!(arg, "-" | "--") { - return None; - } - if arg == "--" { - if !files.is_empty() { - files.push(arg); - } - files.extend(args.by_ref()); - } else if let Some(name) = long_option(arg) { - if ["file", "in-place"] - .iter() - .any(|option| option.starts_with(name)) - { - return None; - } - if "expression".starts_with(name) { - match arg.split_once('=') { - Some((_, script)) => scripts.push(script), - None => scripts.push(args.next()?), - } - } - } else if let Some(cluster) = arg.strip_prefix('-').filter(|c| !c.is_empty()) { - if cluster.contains(['I', 'f', 'i']) { - return None; - } - if let Some(at) = cluster.find('e') { - let attached = &cluster[at + 1..]; - scripts.push(if attached.is_empty() { - args.next()? - } else { - attached - }); - } - } else { - files.push(arg); - } - } - if scripts.is_empty() && !files.is_empty() { - scripts.push(files.remove(0)); - } - let safe = !scripts.is_empty() - && scripts - .iter() - .all(|script| SAFE_SED_SCRIPT.is_match(script)); - safe.then(|| files.into_iter().map(str::to_owned).collect()) -} - -/// The files a `sed` script reads and writes and whether it runs a command. It follows the -/// commands of the `dialect` build: `r` and GNU's `R` read the file named by the rest of the line, -/// `w`, GNU's `W`, and the `s///w` flag write one, and GNU's `e` and `s///e` flag run a command. -/// GNU's commands cover BSD's, so a BSD script parses the same in GNU. Like [`SAFE_SED_SCRIPT`], it -/// ends a regular expression at the first unescaped delimiter. -/// -/// # Errors -/// -/// For a script the parser cannot read, which may do anything, it returns the files written by -/// the commands before the unreadable part, since both builds open them while compiling the script. -fn sed_script(script: &str, dialect: Dialect) -> Result> { - let mut parsed = SedScript::default(); - match read_sed_script(script, dialect, &mut parsed) { - Some(()) => Ok(parsed), - None => Err(parsed.writes), - } -} - -/// Adds what `script` does to `parsed`, as [`sed_script`] reads it, stopping with `None` at the -/// first part it cannot read. -fn read_sed_script(script: &str, dialect: Dialect, parsed: &mut SedScript) -> Option<()> { - let gnu = dialect == Dialect::Gnu; - let mut chars = script.chars().peekable(); - loop { - while chars.next_if(|c| c.is_whitespace() || *c == ';').is_some() {} - let Some(&next) = chars.peek() else { - return Some(()); - }; - if next == '#' { - rest_of_line(&mut chars); - continue; - } - if sed_address(&mut chars)? { - skip_blanks(&mut chars); - if chars.next_if_eq(&',').is_some() { - skip_blanks(&mut chars); - if chars.next_if(|c| matches!(c, '+' | '~')).is_some() { - skip_number(&mut chars)?; - } else if !sed_address(&mut chars)? { - return None; - } - } - } - skip_blanks(&mut chars); - while chars.next_if_eq(&'!').is_some() { - skip_blanks(&mut chars); - } - match chars.next()? { - 'e' | 'F' | 'L' | 'Q' | 'R' | 'T' | 'v' | 'W' | 'z' if !gnu => return None, - '{' => continue, - '}' | '=' | 'd' | 'D' | 'F' | 'g' | 'G' | 'h' | 'H' | 'n' | 'N' | 'p' | 'P' | 'x' - | 'z' => {} - 'l' | 'L' | 'q' | 'Q' => { - skip_blanks(&mut chars); - while chars.next_if(char::is_ascii_digit).is_some() {} - } - ':' | 'b' | 't' | 'T' | 'v' => { - while chars.next_if(|c| !matches!(c, ';' | '\n')).is_some() {} - } - 'a' | 'i' | 'c' => text_lines(&mut chars), - command @ ('r' | 'R' | 'w' | 'W') => { - let file = file_name(&mut chars)?; - if matches!(command, 'r' | 'R') { - parsed.reads.push(file); - } else { - parsed.writes.push(file); - } - continue; - } - 'e' => { - rest_of_line(&mut chars); - parsed.runs = true; - continue; - } - 's' => { - let delimiter = sed_delimiter(&mut chars)?; - sed_text(&mut chars, delimiter)?; - sed_text(&mut chars, delimiter)?; - loop { - match chars.peek() { - Some('g' | 'p' | 'i' | 'I' | 'm' | 'M' | '0'..='9') => { - chars.next(); - } - Some('e') if gnu => { - chars.next(); - parsed.runs = true; - } - Some('w') => { - chars.next(); - parsed.writes.push(file_name(&mut chars)?); - break; - } - _ => break, - } - } - } - 'y' => { - let delimiter = sed_delimiter(&mut chars)?; - sed_text(&mut chars, delimiter)?; - sed_text(&mut chars, delimiter)?; - } - _ => return None, - } - skip_blanks(&mut chars); - if chars - .peek() - .is_some_and(|c| !matches!(c, ';' | '\n' | '}' | '#')) - { - return None; - } - } -} - -/// What a `sed` script does beyond printing, deleting, substituting, and moving text. -#[derive(Debug, Default, PartialEq, Eq)] -struct SedScript { - reads: Vec, - writes: Vec, - runs: bool, -} - -type SedChars<'a> = Peekable>; - -fn skip_blanks(chars: &mut SedChars<'_>) { - while chars.next_if(|c| matches!(c, ' ' | '\t')).is_some() {} -} - -fn rest_of_line(chars: &mut SedChars<'_>) -> String { - let mut line = String::new(); - while let Some(c) = chars.next_if(|c| *c != '\n') { - line.push(c); - } - line -} - -fn skip_number(chars: &mut SedChars<'_>) -> Option<()> { - chars.next_if(char::is_ascii_digit)?; - while chars.next_if(char::is_ascii_digit).is_some() {} - Some(()) -} - -/// The non-empty file name a command takes: the rest of the line after leading blanks. -fn file_name(chars: &mut SedChars<'_>) -> Option { - skip_blanks(chars); - Some(rest_of_line(chars)).filter(|file| !file.is_empty()) -} - -/// Consumes the text of an `a`, `i`, or `c` command: the rest of the line, continued onto the next -/// one while a line ends in an unescaped backslash. -fn text_lines(chars: &mut SedChars<'_>) { - loop { - let line = rest_of_line(chars); - let trailing = line.chars().rev().take_while(|c| *c == '\\').count(); - if trailing % 2 == 0 || chars.next().is_none() { - return; - } - } -} - -/// Consumes one address, returning whether there was one: a line number, possibly with `~step`, -/// `$`, or a regular expression between slashes or after `\` with its own delimiter, with its -/// `I` and `M` flags. -fn sed_address(chars: &mut SedChars<'_>) -> Option { - match chars.peek() { - Some('0'..='9') => { - skip_number(chars)?; - if chars.next_if_eq(&'~').is_some() { - skip_number(chars)?; - } - } - Some('$') => { - chars.next(); - } - Some('/') => { - chars.next(); - sed_text(chars, '/')?; - while chars.next_if(|c| matches!(c, 'I' | 'M')).is_some() {} - } - Some('\\') => { - chars.next(); - let delimiter = sed_delimiter(chars)?; - sed_text(chars, delimiter)?; - while chars.next_if(|c| matches!(c, 'I' | 'M')).is_some() {} - } - _ => return Some(false), - } - Some(true) -} - -fn sed_delimiter(chars: &mut SedChars<'_>) -> Option { - chars.next().filter(|c| !matches!(c, '\n' | '\\')) -} - -/// Consumes text up to and including the unescaped `delimiter`. -fn sed_text(chars: &mut SedChars<'_>, delimiter: char) -> Option<()> { - loop { - match chars.next()? { - '\\' => { - chars.next()?; - } - c if c == delimiter => return Some(()), - _ => {} - } - } -} - -/// The name of a long option such as `--name` or `--name=value`, which GNU tools accept abbreviated -/// to any prefix that names one option. -fn long_option(arg: &str) -> Option<&str> { - let option = arg.strip_prefix("--")?; - let name = option.split_once('=').map_or(option, |(name, _)| name); - (!name.is_empty()).then_some(name) -} - -// ── Readers ── - -/// The paths reader `name` may read, expanding whole words before assigning their argument roles. -/// A multi-match expansion that can change roles leaves the reads unknown and keeps every possible -/// named operand. Only the words before it, and the literal words after it that are certain with -/// each glob read as one word, as an expanding word is, stay certain. Glob match ordering is not -/// assumed, since bash's locale can change it. -fn reader_operands(name: &str, args: &[Word], cwd: &Path) -> (Vec, bool) { - let written: Vec<_> = args.iter().map(|word| word.value.as_deref()).collect(); - let written = reader_args(name, &written); - let mut anything = written.unknown; - let mut values = Vec::new(); - let mut origins = Vec::new(); - let mut groups = Vec::new(); - for (index, word) in args.iter().enumerate() { - if let Some(pattern) = word.value.as_deref().filter(|_| word.glob) { - match expand_glob(pattern, cwd) { - Some(matches) if !matches.is_empty() => { - if matches.len() > 1 { - groups.push((values.len()..values.len() + matches.len(), index)); - } - origins.extend(matches.iter().map(|_| None)); - values.extend(matches.into_iter().map(Some)); - continue; - } - None => anything = true, - Some(_) => {} - } - } - origins.push((!word.glob).then_some(index)); - values.push(word.value.clone()); - } - let values: Vec<_> = values.iter().map(Option::as_deref).collect(); - let (files, unknown) = expanded_reader_paths(name, &written, &values, &origins, &groups); - (files, anything || unknown) -} - -/// The reads of `values`, the expanded words of a reader whose `written` words a glob expanded -/// into, where `origins` names the written word of each value that is not a glob match and each -/// of `groups` spans the matches of a multi-match glob with the index of its written word. -fn expanded_reader_paths( - name: &str, - written: &ReaderArgs, - values: &[Option<&str>], - origins: &[Option], - groups: &[(Range, usize)], -) -> (Vec, bool) { - let grammar = reader_grammar(name); - let expanded = reader_args(name, values); - let ambiguous = groups - .iter() - .find(|(range, source)| { - !written.operands.contains(source) - || range.clone().any(|index| { - !expanded.operands.contains(&index) - || values[index].is_some_and(|value| { - value.starts_with('-') && expanded.options_at(values, range.start) - || grammar.operands == OperandKind::Find - && grammar.find_expression(value) - }) - }) - }) - .map(|(range, _)| range.start); - let anything = written.unknown || expanded.unknown || ambiguous.is_some(); - let late_literals: Vec = ambiguous.map_or_else(Vec::new, |start| { - origins[start..].iter().flatten().copied().collect() - }); - let mut files: Vec = expanded - .reads(ambiguous) - .chain(written.files.iter().map(|(index, path)| Operand { - read: path.clone(), - certain: index.is_some_and(|index| { - late_literals.contains(&index) && written.opened.contains(&index) - }), - })) - .collect(); - if let Some(start) = ambiguous { - let recursive = files.iter().any(|operand| operand.read.recursive) - || grammar.may_recurse(values, start, &expanded); - for (index, value) in values.iter().enumerate() { - if index < start && expanded.fixed_values.contains(&index) { - continue; - } - if let Some(path) = value.filter(|path| { - !path.is_empty() && (grammar.operands != OperandKind::Stream || *path != "-") - }) { - files.push(Operand::possible(ReaderPath { - path: path.to_owned(), - recursive, - })); - } - } - let consumes_operand = expanded.options_at(values, start) - && values[start..].iter().flatten().any(|value| { - reader_args(name, &[Some(value), Some("")]) - .option_values - .contains(&1) - }); - let expression = grammar.operands == OperandKind::Find - && values[start..] - .iter() - .flatten() - .any(|value| grammar.find_expression(value)); - if recursive && (consumes_operand || expression) { - files.push(Operand::possible(ReaderPath { - path: ".".to_owned(), - recursive: true, - })); - } - } - files.sort_unstable_by(|left, right| left.read.path.cmp(&right.read.path)); - files.dedup_by(|next, current| { - let same = next.read.path == current.read.path; - if same { - current.read.recursive |= next.read.recursive; - current.certain |= next.certain; - } - same - }); - (files, anything) -} - -/// What one of [`READERS`] reads, from its arguments. -#[derive(Debug, Default, PartialEq, Eq)] -struct ReaderArgs { - /// Each path with the index of its word, or no index for an implicit cwd: each operand but a - /// leading pattern or script, and each value of an option that names a file. - files: Vec<(Option, ReaderPath)>, - /// Words that supply an option's value in every supported argument order and build. - fixed_values: Vec, - /// Words that are ordinary file operands in every supported argument order and build. - operands: Vec, - /// Words that some supported argument order or build opens as a file. - opened: Vec, - /// Whether some supported argument order or build searches the implicit cwd. - searches_cwd: bool, - script: ScriptArgs, - /// Words that supply an option's value in any supported argument order or build. - option_values: Vec, - /// Whether it may read a file the arguments do not name: a word expands, or an option names a - /// file that lists the files to read. - unknown: bool, -} - -/// Where a reader takes its pattern or script from, and whether it rewrites its files. -#[derive(Debug, Default, PartialEq, Eq)] -struct ScriptArgs { - /// Each word that any supported argument order or build takes as the pattern or script. - words: Vec, - /// Whether some supported argument order or build reads the pattern or script from a file. - from_file: bool, - /// Whether some supported argument order or build rewrites its file operands in place. - in_place: bool, - /// Whether some supported argument order or build rewrites a symlink's target in place. - follows_links: bool, - /// Each backup suffix some supported argument order or build gives an in-place rewrite. - backups: Vec, -} - -/// The backup suffix one build gives an in-place rewrite, from the word at `index`. -#[derive(Debug, PartialEq, Eq)] -struct Backup { - dialect: Dialect, - index: usize, - /// The suffix, or `None` when its word expands. - suffix: Option, -} - -impl Backup { - /// Where this build moves `file` before rewriting it, unless the suffix expands. GNU's build - /// replaces each `*` in the suffix with `file` as written, while BSD's, which has no template, - /// and GNU's for a suffix without `*` append the suffix. - fn path(&self, file: &str) -> Option { - let suffix = self.suffix.as_deref()?; - Some(if self.dialect == Dialect::Gnu && suffix.contains('*') { - suffix.replace('*', file) - } else { - format!("{file}{suffix}") - }) - } -} - -/// The word at `index`, with its value or `None` when it expands, that some argument order of the -/// `dialect` build takes as a reader's pattern or script. -#[derive(Debug, PartialEq, Eq)] -struct ScriptWord { - dialect: Dialect, - index: usize, - value: Option, -} - -impl ReaderArgs { - /// Each path in `files`, certain when its word is `opened` and comes before the first match - /// of an `ambiguous` glob, whose matches may shift the roles of later words, or when it is an - /// implicit cwd some order or build searches and no glob is ambiguous. - fn reads(&self, ambiguous: Option) -> impl Iterator + '_ { - self.files.iter().map(move |(index, read)| Operand { - read: read.clone(), - certain: match index { - Some(index) => { - self.opened.contains(index) && ambiguous.is_none_or(|start| *index < start) - } - None => self.searches_cwd && ambiguous.is_none(), - }, - }) - } - - fn options_at(&self, values: &[Option<&str>], index: usize) -> bool { - !values[..index] - .iter() - .enumerate() - .any(|(index, value)| *value == Some("--") && !self.option_values.contains(&index)) - } -} - -/// Reads the arguments of reader `name`, given each word's value or `None` for one that expands. -/// The gate cannot tell whether a GNU or a BSD build of `name` runs, or whether getopt stops -/// reading options at the first operand, as BSD and `POSIXLY_CORRECT` make it, so it counts each -/// file any of them would read as one it reads. A word that expands may split into any words, so -/// it leaves what the reader reads unknown wherever it stands, as does `find -files0-from`, which -/// reads the paths to search from a file. -fn reader_args(name: &str, values: &[Option<&str>]) -> ReaderArgs { - let grammar = reader_grammar(name); - let mut args = ReaderArgs { - fixed_values: (0..values.len()).collect(), - operands: (0..values.len()).collect(), - unknown: values.iter().any(Option::is_none) - || name == "find" && values.contains(&Some("-files0-from")), - ..ReaderArgs::default() - }; - let orders: &[bool] = if grammar.getopt { - &[true, false] - } else { - &[true] - }; - for dialect in [Dialect::Gnu, Dialect::Bsd] { - for &permute in orders { - let read = grammar.read(dialect, permute, values); - for word in read.script.words { - if !args.script.words.contains(&word) { - args.script.words.push(word); - } - } - args.script.from_file |= read.script.from_file; - args.script.in_place |= read.script.in_place; - args.script.follows_links |= read.script.follows_links; - args.script.backups.extend(read.script.backups); - args.fixed_values - .retain(|index| read.option_values.contains(index)); - args.operands.retain(|index| read.operands.contains(index)); - args.opened.extend(read.opened); - args.searches_cwd |= read.searches_cwd; - args.files.extend(read.files); - args.option_values.extend(read.option_values); - args.unknown |= read.unknown; - } - } - args.files.sort_unstable(); - args.files.dedup_by(|next, current| { - if next.0 != current.0 || next.1.path != current.1.path { - return false; - } - current.1.recursive |= next.1.recursive; - true - }); - args -} - -/// A build of the readers, whose option parsing and `sed` commands can differ. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Dialect { - /// GNU, as on Linux. - Gnu, - /// BSD, as macOS ships. - Bsd, -} - -/// A reader's option grammar, matching both its GNU and its BSD build. It lists the options that -/// take a value in either build, so a missing one reads its value as a file, which can only make -/// the gate more cautious. It also lists, as a [`switch`], each option whose full name abbreviates -/// a listed one, since getopt takes an exact name before a prefix. An option one build rejects may -/// stay listed, since the reader then exits before reading. -struct ReaderGrammar { - options: &'static [ReaderOpt], - /// Whether it reads its options with getopt, which may stop at the first operand. - getopt: bool, - /// Whether `-NUM` sets a count, as in `tail -20`. - numeric: bool, - /// Whether the first operand is a pattern or script unless an option gives one. - pattern: bool, - operands: OperandKind, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum OperandKind { - Stream, - Directory, - Find, -} - -impl ReaderGrammar { - const NONE: Self = Self { - options: &[], - getopt: true, - numeric: false, - pattern: false, - operands: OperandKind::Stream, - }; - - /// The files the `dialect` build reads from `values`, taking options after operands until `--` - /// when it may `permute` them. A short option that takes a value takes the rest of its cluster - /// or the next word, and a long one its `=` value or the next word. A long option the grammar - /// does not list counts its `=` value as a file, and since it may be one that gives the - /// pattern, the leading operand then counts as a file too. - fn read(&self, dialect: Dialect, permute: bool, values: &[Option<&str>]) -> ReaderArgs { - let mut args = ReaderArgs::default(); - let mut operands = Vec::new(); - let mut pattern_given = false; - let mut recursive = self.operands == OperandKind::Find; - let mut take = |args: &mut ReaderArgs, role: Role, index: usize, value: Option<&str>| { - pattern_given |= matches!(role, Role::Pattern | Role::PatternFile); - args.script.from_file |= matches!(role, Role::PatternFile); - args.script.in_place |= matches!(role, Role::InPlace); - args.script.follows_links |= matches!(role, Role::FollowsLinks); - if matches!(role, Role::InPlace) - && value != Some("") - && (value.is_some() || values[index].is_none()) - { - args.script.backups.push(Backup { - dialect, - index, - suffix: value.map(str::to_owned), - }); - } - if matches!(role, Role::Pattern) { - args.script.words.push(ScriptWord { - dialect, - index, - value: value.map(str::to_owned), - }); - } - recursive |= role.recurses(value); - if value.is_some() { - args.option_values.push(index); - } - args.unknown |= matches!(role, Role::FileList); - match (role, value) { - (Role::File | Role::PatternFile | Role::Directory | Role::FileList, Some(file)) - if !file.is_empty() => - { - args.files.push(( - Some(index), - ReaderPath { - path: file.to_owned(), - recursive: matches!(role, Role::Directory), - }, - )); - } - _ => {} - } - }; - let mut unplaced = false; - let mut i = 0; - let mut options = true; - while let Some(&value) = values.get(i) { - let index = i; - i += 1; - let Some(arg) = value.filter(|arg| options && arg.starts_with('-') && *arg != "-") - else { - options &= permute; - if value != Some("") { - operands.push((index, value)); - } - continue; - }; - if arg == "--" { - options = false; - } else if let Some(long) = arg.strip_prefix("--") { - let (name, attached) = long - .split_once('=') - .map_or((long, None), |(name, value)| (name, Some(value))); - let Some(option) = self.long(name) else { - unplaced = true; - take(&mut args, Role::File, index, attached); - continue; - }; - let (index, value) = option.argument(dialect, index, attached, values, &mut i); - take(&mut args, option.role, index, value); - } else { - let cluster = &arg[1..]; - if self.numeric && cluster.starts_with(|c: char| c.is_ascii_digit()) { - continue; - } - for (offset, c) in cluster.char_indices() { - let Some(option) = self.short(c) else { - continue; - }; - if option.value(dialect) == Value::None { - take(&mut args, option.role, index, None); - continue; - } - let rest = &cluster[offset + c.len_utf8()..]; - let attached = (!rest.is_empty()).then_some(rest); - let (index, value) = option.argument(dialect, index, attached, values, &mut i); - take(&mut args, option.role, index, value); - break; - } - } - } - let leading_pattern = self.pattern && !pattern_given && !unplaced; - self.place_operands(args, dialect, values, &operands, leading_pattern, recursive) - } - - /// Completes `args` with the `operands` [`Self::read`] found among `values`, the first of them - /// the pattern or script when `leading_pattern` holds, and each a tree when `recursive` does. - fn place_operands( - &self, - mut args: ReaderArgs, - dialect: Dialect, - values: &[Option<&str>], - operands: &[(usize, Option<&str>)], - leading_pattern: bool, - recursive: bool, - ) -> ReaderArgs { - let skip = usize::from(leading_pattern); - if let Some(&(index, value)) = operands.first().filter(|_| leading_pattern) { - args.script.words.push(ScriptWord { - dialect, - index, - value: value.map(str::to_owned), - }); - } - let tree_given = args.files.iter().any(|(_, path)| path.recursive); - let paths = self.operand_paths( - values, - operands.iter().copied().skip(skip), - recursive, - tree_given, - ); - args.operands - .extend(paths.iter().filter_map(|(index, _)| *index)); - args.files.extend(paths); - let tree_end = self.tree_end(values); - args.opened = args - .files - .iter() - .filter_map(|(index, _)| index.filter(|index| *index < tree_end)) - .collect(); - args.searches_cwd = args.files.iter().any(|(index, _)| index.is_none()); - args.fixed_values.clone_from(&args.option_values); - args - } - - /// Where the trees end and, for `find`, its expression starts, whose operands `find` never - /// opens, such as the value of `-name`. - fn tree_end(&self, values: &[Option<&str>]) -> usize { - if self.operands == OperandKind::Find { - values - .iter() - .position(|value| value.is_some_and(|value| self.find_expression(value))) - .unwrap_or(values.len()) - } else { - values.len() - } - } - - fn may_recurse(&self, values: &[Option<&str>], ambiguous: usize, args: &ReaderArgs) -> bool { - if !args.options_at(values, ambiguous) { - return false; - } - let recurses = |values: &[Option<&str>]| { - [Dialect::Gnu, Dialect::Bsd].into_iter().any(|dialect| { - self.read(dialect, true, values) - .files - .iter() - .any(|(_, path)| path.recursive) - }) - }; - for (index, value) in values.iter().enumerate() { - if index < ambiguous && args.fixed_values.contains(&index) { - continue; - } - let next = if index + 1 < ambiguous { - values[index + 1] - } else { - values[ambiguous..] - .iter() - .copied() - .flatten() - .find(|value| !value.is_empty() && "recurse".starts_with(value)) - }; - let probe = [*value, next]; - let probe = if next.is_some() { - &probe[..] - } else { - &probe[..1] - }; - if recurses(probe) { - return true; - } - } - false - } - - fn operand_paths<'a>( - &self, - values: &[Option<&str>], - operands: impl Iterator)>, - recursive: bool, - tree_given: bool, - ) -> Vec<(Option, ReaderPath)> { - let operands: Vec<_> = operands.collect(); - let has_operand = !operands.is_empty(); - let tree_end = self.tree_end(values); - let stdin = self.operands == OperandKind::Stream; - let mut files: Vec<_> = operands - .into_iter() - .filter_map(move |(index, operand)| { - let path = operand?; - if stdin && path == "-" { - return None; - } - Some(( - Some(index), - ReaderPath { - path: path.to_owned(), - recursive: recursive && index < tree_end, - }, - )) - }) - .collect(); - if recursive - && (self.operands == OperandKind::Find || !has_operand) - && !tree_given - && !files.iter().any(|(_, path)| path.recursive) - { - files.push(( - None, - ReaderPath { - path: ".".to_owned(), - recursive: true, - }, - )); - } - files - } - - fn find_expression(&self, arg: &str) -> bool { - if matches!(arg, "(" | "!") { - return true; - } - let Some(cluster) = arg.strip_prefix('-').filter(|_| arg != "--") else { - return false; - }; - for c in cluster.chars() { - let Some(option) = self.short(c) else { - return true; - }; - if option.gnu != Value::None || option.bsd != Value::None { - return false; - } - } - false - } - - /// The long option `--name`, or the one listed option `name` abbreviates, as getopt accepts. An - /// abbreviation getopt finds ambiguous among options the grammar leaves out makes the reader - /// exit before reading. - fn long(&self, name: &str) -> Option<&ReaderOpt> { - let longs = || { - self.options - .iter() - .filter_map(|option| Some((option.name.strip_prefix("--")?, option))) - }; - if let Some((_, option)) = longs().find(|(long, _)| *long == name) { - return Some(option); - } - let mut abbreviated = longs().filter(|(long, _)| long.starts_with(name)); - match (abbreviated.next(), abbreviated.next()) { - (Some((_, option)), None) => Some(option), - _ => None, - } - } - - /// The single-letter option `-c`. - fn short(&self, c: char) -> Option<&ReaderOpt> { - self.options.iter().find(|option| { - option - .name - .strip_prefix('-') - .is_some_and(|letter| letter.chars().eq([c])) - }) - } -} - -/// The option grammar of reader `name`. Every operand of `find`'s expression counts as a file. -fn reader_grammar(name: &str) -> &'static ReaderGrammar { - match name { - "find" => &FIND_GRAMMAR, - "grep" => &GREP_GRAMMAR, - "head" => &HEAD_GRAMMAR, - "ls" => &LS_GRAMMAR, - "sed" => &SED_GRAMMAR, - "tail" => &TAIL_GRAMMAR, - "wc" => &WC_GRAMMAR, - _ => &ReaderGrammar::NONE, - } -} - -const FIND_GRAMMAR: ReaderGrammar = ReaderGrammar { - options: &[ - takes("-D"), - switch("-E"), - switch("-H"), - switch("-L"), - takes("-O"), - switch("-P"), - switch("-X"), - switch("-d"), - takes("-f").with(Role::Directory), - switch("-s"), - switch("-x"), - ], - getopt: false, - operands: OperandKind::Find, - ..ReaderGrammar::NONE -}; - -const GREP_GRAMMAR: ReaderGrammar = ReaderGrammar { - options: &[ - takes("-A"), - takes("-B"), - takes("-C"), - takes("-D"), - takes("-d").with(Role::Directories), - takes("-e").with(Role::Pattern), - takes("-f").with(Role::PatternFile), - takes("-m"), - switch("-r").with(Role::Recursive), - switch("-R").with(Role::Recursive), - takes("--after-context"), - takes("--before-context"), - takes("--binary-files"), - switch("--binary"), - attached("--color"), - attached("--colour"), - takes("--context").bsd(Value::Optional), - takes("--devices"), - switch("--dereference-recursive").with(Role::Recursive), - takes("--directories").with(Role::Directories), - takes("--exclude"), - takes("--exclude-dir"), - takes("--exclude-from").with(Role::File), - takes("--file").with(Role::PatternFile), - takes("--group-separator"), - takes("--include"), - takes("--include-dir"), - takes("--label"), - takes("--max-count"), - takes("--regexp").with(Role::Pattern), - switch("--recursive").with(Role::Recursive), - ], - numeric: true, - pattern: true, - ..ReaderGrammar::NONE -}; - -const HEAD_GRAMMAR: ReaderGrammar = ReaderGrammar { - options: &[takes("-c"), takes("-n"), takes("--bytes"), takes("--lines")], - numeric: true, - ..ReaderGrammar::NONE -}; - -const LS_GRAMMAR: ReaderGrammar = ReaderGrammar { - options: &[ - takes("-D").bsd_only(), - takes("-I").gnu_only(), - switch("-R").with(Role::Recursive), - takes("-T").gnu_only(), - takes("-w").gnu_only(), - takes("--block-size"), - attached("--color"), - takes("--format"), - takes("--hide"), - attached("--hyperlink"), - takes("--ignore"), - takes("--indicator-style"), - takes("--quoting-style"), - switch("--recursive").with(Role::Recursive), - takes("--sort"), - takes("--tabsize"), - takes("--time"), - takes("--time-style"), - takes("--width"), - ], - operands: OperandKind::Directory, - ..ReaderGrammar::NONE -}; - -// Mirrors GNU sed 4.10, listing every long option it takes. -const SED_GRAMMAR: ReaderGrammar = ReaderGrammar { - options: &[ - takes("-e").with(Role::Pattern), - takes("-f").with(Role::PatternFile), - takes("-I").with(Role::InPlace), - attached("-i").bsd(Value::Required).with(Role::InPlace), - takes("-l").gnu_only(), - switch("--binary"), - switch("--debug"), - takes("--expression").with(Role::Pattern), - takes("--file").with(Role::PatternFile), - switch("--follow-symlinks").with(Role::FollowsLinks), - switch("--help"), - attached("--in-place").with(Role::InPlace), - takes("--line-length"), - switch("--null-data"), - switch("--posix"), - switch("--quiet"), - switch("--regexp-extended"), - switch("--sandbox"), - switch("--separate"), - switch("--silent"), - switch("--unbuffered"), - switch("--version"), - switch("--zero-terminated"), - ], - pattern: true, - ..ReaderGrammar::NONE -}; - -const TAIL_GRAMMAR: ReaderGrammar = ReaderGrammar { - options: &[ - takes("-b"), - takes("-c"), - takes("-n"), - takes("-s"), - takes("--blocks"), - takes("--bytes"), - attached("--follow"), - takes("--lines"), - takes("--max-unchanged-stats"), - takes("--pid"), - takes("--sleep-interval"), - ], - numeric: true, - ..ReaderGrammar::NONE -}; - -const WC_GRAMMAR: ReaderGrammar = ReaderGrammar { - options: &[ - takes("--files0-from").with(Role::FileList), - takes("--libxo"), - takes("--total"), - ], - ..ReaderGrammar::NONE -}; - -/// One reader option, which takes a value in either build unless it is a [`switch`]. -#[derive(Clone, Copy)] -struct ReaderOpt { - name: &'static str, - /// What the GNU build takes: [`Value::Required`] for a value attached or in the next word, - /// [`Value::Optional`] for one only attached, or [`Value::None`] for none. - gnu: Value, - /// What the BSD build takes, in the same terms. - bsd: Value, - role: Role, -} - -impl ReaderOpt { - const fn with(self, role: Role) -> Self { - Self { role, ..self } - } - - /// This option taking `value` in the BSD build. - const fn bsd(self, value: Value) -> Self { - Self { bsd: value, ..self } - } - - /// This option as a flag in the BSD build. - const fn gnu_only(self) -> Self { - self.bsd(Value::None) - } - - /// This option as a flag in the GNU build. - const fn bsd_only(self) -> Self { - Self { - gnu: Value::None, - ..self - } - } - - const fn value(&self, dialect: Dialect) -> Value { - match dialect { - Dialect::Gnu => self.gnu, - Dialect::Bsd => self.bsd, - } - } - - fn argument<'a>( - &self, - dialect: Dialect, - index: usize, - attached: Option<&'a str>, - values: &[Option<&'a str>], - next: &mut usize, - ) -> (usize, Option<&'a str>) { - match (self.value(dialect), attached) { - (Value::None, _) | (Value::Optional, None) => (index, None), - (_, Some(value)) => (index, Some(value)), - (Value::Required, None) => match values.get(*next) { - Some(&value) => { - let index = *next; - *next += 1; - (index, value) - } - None => (index, None), - }, - } - } -} - -const fn takes(name: &'static str) -> ReaderOpt { - ReaderOpt { - name, - gnu: Value::Required, - bsd: Value::Required, - role: Role::Value, - } -} - -const fn attached(name: &'static str) -> ReaderOpt { - ReaderOpt { - gnu: Value::Optional, - bsd: Value::Optional, - ..takes(name) - } -} - -const fn switch(name: &'static str) -> ReaderOpt { - ReaderOpt { - gnu: Value::None, - bsd: Value::None, - ..takes(name) - } -} - -/// What a reader option's value names. -#[derive(Clone, Copy)] -enum Role { - /// Neither a file nor the pattern, such as a count. - Value, - /// A file it reads. - File, - /// The pattern or script, so no operand is one. - Pattern, - /// A file it reads the pattern or script from, so no operand is one. - PatternFile, - /// A file that lists the files it reads. - FileList, - /// A suffix for the backup of each file it rewrites in place, given or not. - InPlace, - /// A flag that rewrites a symlink's target in place. - FollowsLinks, - /// A directory supplied through a `find -f` option. - Directory, - /// A flag that recursively traverses directory operands. - Recursive, - /// A value selecting whether `grep` traverses directories. - Directories, -} - -impl Role { - fn recurses(self, value: Option<&str>) -> bool { - match self { - Self::Recursive => true, - Self::Directories => { - value.is_some_and(|value| !value.is_empty() && "recurse".starts_with(value)) - } - _ => false, - } - } -} - -/// The filesystem paths matching `pattern` in `cwd`, including brace alternatives. Each component -/// with `*`, `?`, `[`, or `{` matches its directory's entries, skipping dot names unless the -/// component starts with `.` or includes a brace. This does not emit unmatched lexical brace words. -/// `None` when one name matches more than [`MAX_GLOB_MATCHES`] files, fails to compile, or meets a -/// file whose name is not UTF-8. -fn expand_glob(pattern: &str, cwd: &Path) -> Option> { - let mut paths = vec![PathBuf::new()]; - for component in Path::new(pattern).components() { - let name = component.as_os_str().to_string_lossy(); - if !matches!(component, Component::Normal(_)) || !name.contains(['*', '?', '[', '{']) { - for path in &mut paths { - path.push(component); - } - continue; - } - let glob = globset::GlobBuilder::new(&name) - .literal_separator(true) - .build() - .ok()? - .compile_matcher(); - let dotted = name.starts_with('.') || name.contains('{'); - let mut matched = Vec::new(); - for dir in &paths { - let Ok(entries) = std::fs::read_dir(cwd.join(dir)) else { - continue; - }; - for entry in entries.flatten() { - let entry_name = entry.file_name(); - let entry_name = entry_name.to_str()?; - if (dotted || !entry_name.starts_with('.')) && glob.is_match(entry_name) { - matched.push(dir.join(entry_name)); - if matched.len() > MAX_GLOB_MATCHES { - return None; - } - } - } - } - paths = matched; - } - Some( - paths - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(), - ) -} - -#[cfg(test)] -mod tests { - use indoc::indoc; - - use super::*; - use crate::util::path::local_testing::{abs, canonical_tempdir}; - use crate::util::path::slash_separated; - use crate::util::path::testing::HOME_VAR; - - fn parse(command: &str) -> CommandTarget { - parse_inheriting(command, &[]) - } - - /// Parses `command` where the environment `bash` starts with sets only `inherited`. - fn parse_inheriting(command: &str, inherited: &[&str]) -> CommandTarget { - CommandTarget::parse_inheriting(command, &Workspace::new(Path::new("/repo")), &|name| { - inherited.contains(&name) - }) - } - - fn is_safe(command: &str) -> bool { - parse(command).segments[0].is_safe() - } - - fn resolve(path: &str, workspace: &Workspace) -> PathTarget { - PathResolver::new(workspace).resolve(path) - } - - fn joined(words: &[ShellWord]) -> String { - words - .iter() - .map(ShellWord::text) - .collect::>() - .join(" ") - } - - /// The program `segment` runs when every wrapper option is known. - fn program(segment: &Segment) -> &[ShellWord] { - segment.programs().next().unwrap() - } - - mod command_target { - use super::*; - - mod parse { - use super::*; - - /// Each simple command's named words, joined on spaces. - fn texts(command: &str) -> Vec { - parse(command) - .segments - .iter() - .map(|segment| joined(segment.named())) - .collect() - } - - fn words(command: &str) -> Vec { - program(&parse(command).segments[0]) - .iter() - .map(|word| word.text().to_owned()) - .collect() - } - - #[test] - fn hands_the_words_a_redirection_carries_to_the_command_bash_gives_them() { - for (command, expected) in [ - ("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"]), - (">/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:?}"); - } - } - - #[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:?}"); - } - assert!(parse("cargo test --all").unrecognized().is_none()); - } - - #[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"); - } - - #[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:?}"); - } - 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:?}"); - } - assert!(is_safe("LANG=C TZ=UTC env NO_COLOR=1 cat a")); + assert!(is_safe("LANG=C TZ=UTC env NO_COLOR=1 cat a")); } #[test] @@ -6093,1271 +1402,94 @@ mod tests { "suspend", "test -f x", "times", - "true", - "type ls", - "typeset x", - "ulimit -n", - "umask 022", - "unalias ll", - "unset x", - "wait", - "wait -n -p job 1 2", - ] { - assert!(parse(command).unrecognized().is_none(), "{command:?}"); - } - } - - #[test] - fn collects_commands_inside_substitutions_and_literal_scripts() { - for command in [ - "echo $(rm -rf /)", - "sh -c 'rm -rf /'", - "bash -lc \"rm -rf /\"", - "eval rm -rf /", - "eval -- 'rm -rf /'", - "exec sh -c 'rm -rf /'", - "busybox sh -c 'rm -rf /'", - "su -c 'rm -rf /' root", - "su --command 'rm -rf /' root", - "su --command='rm -rf /' root", - "busybox hush -c 'rm -rf /'", - "runuser -u nobody rm -rf /", - "runuser --user=nobody -- rm -rf /", - ] { - assert!( - texts(command).contains(&"rm -rf /".to_owned()), - "{command:?}: {:?}", - texts(command) - ); - } - } - } - } - - mod segment { - use super::*; - - mod removes_recursively_by_force { - use super::*; - - #[test] - fn reads_both_options_in_every_spelling_before_a_double_dash() { - for command in [ - "rm -rf /", - "rm -fr x", - "rm -rfv /", - "rm -Rf x", - "rm -f -R x", - "rm -r --force /", - "rm --recursive --force x", - "rm --rec --f x", - "rm / -rf", - "RM -Rf x", - "/bin/rm -rf x", - "nice -n 5 rm -rf x", - "rm $FLAGS x", - "$RM -rf x", - "xargs sudo", - "sudo FOO=1 -u root rm -rf x", - ] { - assert!( - parse(command).segments[0].removes_recursively_by_force(), - "{command:?}" - ); - } - for command in [ - "rm -r x", - "rm -f x", - "rm --recursive x", - "rm -- -rf", - "rm -f -- -r", - "rm --forced -r x", - "rmdir -rf x", - "$RM x", - ] { - assert!( - !parse(command).segments[0].removes_recursively_by_force(), - "{command:?}" - ); - } - } - } - } - - mod walk { - use super::*; - - /// Each write's path relative to `cwd`, or as requested outside it. - fn writes(command: &str, cwd: &Path) -> Vec { - spelled(CommandTarget::parse(command, &Workspace::new(cwd)).writes) - } - - /// Each certain read's path relative to `cwd`, or as requested outside it. - fn reads(command: &str, cwd: &Path) -> Vec { - spelled(CommandTarget::parse(command, &Workspace::new(cwd)).reads) - } - - fn spelled(targets: Vec) -> Vec { - targets - .into_iter() - .map(|target| target.relative.unwrap_or(target.requested)) - .collect() - } - - mod nested_script { - use super::*; - - #[test] - fn reads_the_script_and_startup_files_a_shell_runs_after_its_options() { - let cwd_dir = canonical_tempdir(); - let cwd = cwd_dir.path(); - for (command, read) in [ - ("bash -- -x", &["-x"][..]), - ("bash - -x", &["-x"]), - ("bash + f", &["f"]), - ("bash -o -c f", &["f"]), - ("bash -oO a b f", &["f"]), - ("bash -x --norc f", &[]), - ("bash -s f", &[]), - ("bash -s -- f", &[]), - ("bash --rcfile rc -i", &["rc"]), - ("bash -init-file rc f", &["f", "rc"]), - ("zsh -b -x f", &["-x"]), - ("zsh + -x f", &["-x"]), - ("dash -o vi f", &["f"]), - ("fish f", &["f"]), - ] { - let mut read_paths = reads(command, cwd); - read_paths.sort(); - - assert_eq!(read_paths, read, "{command:?}"); - } - } - - #[test] - fn leaves_a_shell_unrecognized_when_its_script_is_unknown() { - // cspell:ignore eovi - for command in [ - "bash $F", - "bash -- $F", - "bash -c", - "bash -c \"$X\"", - "bash -c -- \"$X\"", - "bash --rcfile", - "bash --rcfile $F f", - "bash -o", - "bash -o $X f", - "bash --bogus f", - "zsh -ovi f", - "ksh -ovi f", - "mksh -eovi f", - "fish -n f", - "source", - "source -p . key", - ] { - assert!(parse(command).unrecognized().is_some(), "{command:?}"); - } - for command in [ - "bash -s -- \"$V\"", - "bash -c ls", - "bash -oO a b f", - "zsh -o vi f", - ] { - assert!(parse(command).unrecognized().is_none(), "{command:?}"); - } - } - - #[test] - fn leaves_a_shell_unrecognized_when_input_words_replace_its_script() { - for (command, replaced) in [ - ("echo a | xargs -I % bash -c 'cat %'", true), - ("echo a | xargs -i bash -c 'cat {}'", true), - ("echo a | xargs -I % su -c 'cat %' root", true), - ("echo a | xargs -I % runuser -u root bash -c 'cat %'", true), - ("find . -exec sh -c 'cat {}' \\;", true), - ("find . -execdir bash -c 'cat {}' \\;", true), - ("echo bash | xargs -I % % -c true", true), - ("BASH_ENV='$(true)' bash -c true", true), - ("env 'ENV=`true`' sh -i", true), - ("echo a | xargs -I % env %=x bash -c true", true), - ("echo a | xargs -I % sudo A%=x bash -c true", true), - ("echo a | xargs -I % env A=% bash -c true", false), - ("echo a | xargs -I % bash -c 'cat x' %", false), - ("echo a | xargs su -c 'cat x' root", false), - ("find . -exec sh -c 'cat x' \\;", false), - ] { - let parsed = parse(command); - - assert_eq!(parsed.unrecognized().is_some(), replaced, "{command:?}"); - } - } - - #[test] - fn flags_a_shell_that_may_run_any_file() { - for (command, anything) in [ - ("echo a | xargs bash", true), - ("echo a | xargs -I % bash %", true), - ("find . -exec bash '{}' \\;", true), - ("echo a | xargs su root", true), - ("echo a | xargs runuser -u root bash", true), - ("BASH_ENV=$F bash -c true", true), - ("env BASH_ENV=\"$F\" bash -c true", true), - ("BASH_ENV+=x bash -c true", true), - ("ENV=$F dash -c true", true), - ("ZDOTDIR=$D zsh -c true", true), - ("HOME=$H bash -i", true), - ("BASH_ENV='$HOME/x' bash -c true", true), - ("BASH_ENV='~no-such-user/x' bash -c true", true), - ("echo a | xargs -I % bash --rcfile % -i", true), - ("echo a | xargs -I % env BASH_ENV=% bash -c true", true), - ("BASH_ENV=$F su root -c true", true), - ("BASH_ENV=$F eval 'bash -c true'", true), - ("HOME=$H bash -c true", true), - ("HOME=$H fish -c true", true), - ("HOME=$H su -m root -c true", true), - ("env BASH_ENV=a* bash -c true", true), - ("HOME=$H su root -c true", false), - ("env --bogus BASH_ENV bash -c true", false), - ("echo a | xargs bash script.sh", false), - ("echo a | xargs su -c true root", false), - ("BASH_ENV= bash -c true", false), - ("ENV=$F zsh -c true", false), - ("BASH_ENV=$F fish", false), - ("bash -c true", false), - ] { - let parsed = parse(command); - - let flagged = parsed.segments.iter().any(Segment::reads_anything); - - assert_eq!(flagged, anything, "{command:?}"); - } - } - - #[test] - fn reads_the_startup_files_a_variable_names() { - let cwd_dir = canonical_tempdir(); - let cwd = cwd_dir.path(); - let tilde_path = Path::new("~+").join("a").to_string_lossy().into_owned(); - for (command, read) in [ - ("BASH_ENV=a bash -c true", &["a"][..]), - ("env ENV=a BASH_ENV=b bash x", &["a", "b", "x"]), - ("ENV=a sh -i", &["a"]), - ("BASH_ENV=a ENV=b dash -c true", &["b"]), - ("BASH_ENV=a BASH_ENV=b bash -c true", &["b"]), - ("BASH_ENV='~+/a' bash -c true", &["a", &tilde_path]), - ("BASH_ENV=a true; bash -c true", &[]), - ("BASH_ENV=a eval 'bash -c true'", &["a"]), - ("BASH_ENV=a sh -c 'bash -c true'", &["a"]), - ("BASH_ENV=a find . -exec bash -c true \\;", &["", "a"]), - ("BASH_ENV=a su root -c true", &["a"]), - ("BASH_ENV=a runuser -u root bash -c true", &["a"]), - ("ZDOTDIR=d zsh", &["d"]), - ("ZDOTDIR=d HOME=h zsh", &["d", "h"]), - ("BASH_ENV=a HOME=h bash -c true", &["a", "h"]), - ("HOME=h ksh -i", &["h"]), - ("HOME=h fish", &["h"]), - ("HOME=h su -m root -c true", &["h"]), - ("HOME=h su --preserve-environment root", &["h"]), - ("HOME=h runuser -p root -c true", &["h"]), - ("HOME=h su -c -l -m root", &["h"]), - ("HOME=h su -m -sl root -c true", &["h"]), - ("env --bogus bash -c true BASH_ENV=a", &[]), - ("HOME=h su root -c true", &[]), - ("HOME=h su -m - root -c true", &[]), - ("HOME=h su -mpl root -c true", &[]), - ("HOME=h su -m --login root -c true", &[]), - ("BASH_ENV= bash -c true", &[]), - ("ENV=a zsh", &[]), - ("ENV=a fish", &[]), - ] { - let mut read_paths = reads(command, cwd); - read_paths.sort(); - - assert_eq!(read_paths, read, "{command:?}"); - } - } - } - - mod visit { - use std::collections::BTreeSet; - - use tree_sitter::Language; - - use super::*; - - #[test] - fn leaves_unrecognized_only_the_grammar_kinds_that_evaluate_arithmetic_or_define_a_function() - { - let language: Language = tree_sitter_bash::LANGUAGE.into(); - let kinds: BTreeSet<&str> = (0..language.node_kind_count()) - .filter_map(|id| u16::try_from(id).ok()) - .filter(|&id| { - language.node_kind_is_named(id) && language.node_kind_is_visible(id) - }) - .filter_map(|id| language.node_kind_for_id(id)) - .collect(); - let weighed = [ - "array", - "command", - "declaration_command", - "expansion", - "file_redirect", - "for_statement", - "redirected_statement", - "simple_expansion", - "test_command", - "unset_command", - "variable_assignment", - "while_statement", - ]; - - let unknown: Vec<&&str> = WALKED_KINDS - .iter() - .chain(&weighed) - .filter(|kind| !kinds.contains(**kind)) - .collect(); - let unrecognized: Vec<&str> = kinds - .into_iter() - .filter(|kind| !WALKED_KINDS.contains(kind) && !weighed.contains(kind)) - .collect(); - - assert!(unknown.is_empty(), "{unknown:?}"); - assert_eq!( - unrecognized, - [ - "arithmetic_expansion", - "binary_expression", - "c_style_for_statement", - "function_definition", - "parenthesized_expression", - "postfix_expression", - "subscript", - "ternary_expression", - "unary_expression", - ] - ); + "true", + "type ls", + "typeset x", + "ulimit -n", + "umask 022", + "unalias ll", + "unset x", + "wait", + "wait -n -p job 1 2", + ] { + assert!(parse(command).unrecognized().is_none(), "{command:?}"); + } } #[test] - fn withholds_safety_from_every_segment_once_anything_expands_a_variable() { - let safety = |command: &str| -> Vec { - parse(command) - .segments - .iter() - .map(Segment::is_safe) - .collect() - }; - for (literal, expanding) in [ - ("cat <<< 'key=$TOKEN'", "cat <<< \"key=$TOKEN\""), - ("cat <<< TOKEN", "cat <<< ${TOKEN}"), - ("cat <<'EOF'\n$TOKEN\nEOF", "cat < out.txt 2>> log &> all >| clobber >& both", cwd), - ["out.txt", "log", "all", "clobber", "both"] - ); - let outside = abs("/etc/hosts"); - assert_eq!( - CommandTarget::parse(&format!("echo a > {outside}"), &Workspace::new(cwd)) - .writes, - [resolve(&outside, &Workspace::new(cwd))] - ); - } - - #[test] - fn counts_each_file_an_input_redirection_reads() { - let cwd_dir = canonical_tempdir(); - let cwd = cwd_dir.path(); - - let target = CommandTarget::parse( - "sort < in.txt 3< more.txt > out.txt", - &Workspace::new(cwd), - ); + mod segment { + use super::*; - assert_eq!(spelled(target.reads.clone()), ["in.txt", "more.txt"]); - assert_eq!(spelled(target.writes.clone()), ["out.txt"]); - assert!(target.unrecognized().is_none()); - assert!( - parse("cd x && cat < in.txt").unrecognized().is_some(), - "a relative input after a cd may name another file" - ); - } + mod removes_recursively_by_force { + use super::*; #[test] - fn counts_a_read_write_redirection_as_both() { - let cwd_dir = canonical_tempdir(); - let cwd = cwd_dir.path(); - for (command, read, written) in [ - ("cat <> key", "key", &["key"][..]), - ("cat 3<>key", "key", &["key"]), - ("sort <>in.txt > out.txt", "in.txt", &["in.txt", "out.txt"]), - ("cat <\\\n>key", "key", &["key"]), - ("cat <\\\n> key", "key", &["key"]), - ("echo a\\\\<>key", "key", &["key"]), + fn reads_both_options_in_every_spelling_before_a_double_dash() { + for command in [ + "rm -rf /", + "rm -fr x", + "rm -rfv /", + "rm -Rf x", + "rm -f -R x", + "rm -r --force /", + "rm --recursive --force x", + "rm --rec --f x", + "rm / -rf", + "RM -Rf x", + "/bin/rm -rf x", + "nice -n 5 rm -rf x", + "rm $FLAGS x", + "$RM -rf x", + "xargs sudo", + "sudo FOO=1 -u root rm -rf x", ] { - let target = CommandTarget::parse(command, &Workspace::new(cwd)); - - assert_eq!(spelled(target.reads.clone()), [read], "{command:?}"); - assert_eq!(spelled(target.writes.clone()), written, "{command:?}"); + assert!( + parse(command).segments[0].removes_recursively_by_force(), + "{command:?}" + ); } - } - - #[test] - fn skips_heredocs_descriptors_and_devices() { - let cwd_dir = canonical_tempdir(); - let cwd = cwd_dir.path(); for command in [ - "cat <<< x", - "cat <&1", - "make 3>&-", - "make <&3", - "make > /dev/null 2> /dev/stderr < /dev/null", + "rm -r x", + "rm -f x", + "rm --recursive x", + "rm -- -rf", + "rm -f -- -r", + "rm --forced -r x", + "rmdir -rf x", + "$RM x", ] { - assert!(writes(command, cwd).is_empty(), "{command:?}"); - assert!(reads(command, cwd).is_empty(), "{command:?}"); assert!( - CommandTarget::parse(command, &Workspace::new(cwd)) - .unrecognized() - .is_none(), + !parse(command).segments[0].removes_recursively_by_force(), "{command:?}" ); } } - - #[test] - fn to_a_target_it_cannot_place_is_unrecognized() { - for command in ["echo > \"$OUT\"", "echo > *.txt"] { - assert!(parse(command).unrecognized().is_some(), "{command:?}"); - } - } - } - } - - mod unquote_ansi_c { - use super::*; - - #[test] - fn decodes_each_escape_bash_does() { - for (inner, value) in [ - ("push", "push"), - (r"pu\x73h", "push"), - (r"\162m", "rm"), - (r"\55rf", "-rf"), - (r"\0101", "\u{8}1"), - (r"\u002drf", "-rf"), - (r"\U0000002drf", "-rf"), - (r"\u00e9", "\u{e9}"), - (r"a\tb\n\r\v\f\a\b", "a\tb\n\r\u{b}\u{c}\u{7}\u{8}"), - (r"\e[0m\E", "\u{1b}[0m\u{1b}"), - (r#"\'\"\\\?"#, r#"'"\?"#), - (r"\cA", "\u{1}"), - (r"\q", r"\q"), - ] { - assert_eq!(unquote_ansi_c(inner).as_deref(), Some(value), "{inner:?}"); - } - } - - #[test] - fn declines_an_escape_that_names_no_ascii_character_or_nul() { - for inner in [ - r"\x80", r"\200", r"\xZ", r"\0", r"\400", r"a\x00b", r"\c", "a\\", - ] { - assert_eq!(unquote_ansi_c(inner), None, "{inner:?}"); - } - } - } - - mod pattern_words { - use super::*; - - fn pattern(raw: &str) -> Vec { - pattern_words(raw).unwrap() - } - - fn literal(text: &str) -> PatternWord { - PatternWord::Literal(text.to_owned()) - } - - #[test] - fn unquotes_each_word_and_reads_a_lone_star_as_a_wildcard() { - temp_env::with_var(HOME_VAR, Some(abs("/home/u")), || { - let home = PathBuf::from(abs("/home/u")).join("x"); - assert_eq!( - pattern(r#"GIT_PAGER=cat git commit -m "a b" '*' \* ~/x *"#), - [ - literal("GIT_PAGER=cat"), - literal("git"), - literal("commit"), - literal("-m"), - literal("a b"), - literal("*"), - literal("*"), - literal(&home.to_string_lossy()), - PatternWord::Any, - ] - ); - }); - assert_eq!(pattern("*"), [PatternWord::Any]); - assert_eq!( - pattern("git * --force"), - [literal("git"), PatternWord::Any, literal("--force")] - ); - } - } - - mod safe_operands { - use super::*; - - #[test] - fn names_every_word_that_may_be_a_file() { - let command = CommandTarget::parse( - "grep -fpats --file=more -e x -- -y src", - &Workspace::new(Path::new("/repo")), - ); - let at = |name: &str| resolve(name, &Workspace::new(Path::new("/repo"))); - assert!(command.segments[0].is_safe()); - assert_eq!(command.reads, [at("pats"), at("more"), at("-y"), at("src")]); - assert!(!is_safe("wc --files0-from=list")); - let echo = parse("echo done"); - assert!(echo.segments[0].is_safe()); - assert_eq!(echo.reads, []); - } - - #[test] - fn admits_only_shipped_commands_with_literal_arguments() { - for command in [ - "cat a.rs", - "head -n 5 a", - "ls -la src", - "wc -l a", - "pwd", - "true", - "echo 'a $b'", - ] { - assert!(is_safe(command), "{command:?}"); - } - for command in [ - "cat *.rs", - "cat $F", - "cat {a,b}", - "echo $HOME *", - "echo $TOKEN", - "echo \"${TOKEN}\"", - "echo *", - "rm a", - "./cat a", - "git status", - ] { - assert!(!is_safe(command), "{command:?}"); - } - } - - #[test] - fn admits_builtins_that_only_print_test_files_or_trace() { - let at = - |name: &str| ReaderPath::exact(resolve(name, &Workspace::new(Path::new("/repo")))); - for (command, reads) in [ - (": done", &[][..]), - ("printf '%s\\n' a", &[]), - ("export RUST_LOG=debug", &[]), - ("set -eux -o pipefail", &[]), - ("[ -f Cargo.toml ]", &[at("Cargo.toml")]), - ("test ! -d /etc -a x = y", &[at("/etc"), at("x"), at("y")]), - ] { - let parsed = parse(command); - - assert!(parsed.segments[0].is_safe(), "{command:?}"); - assert_eq!(parsed.segments[0].reads(), reads, "{command:?}"); - assert!(parsed.unrecognized().is_none(), "{command:?}"); - } - for command in ["set -o posix", "set -a", "[ -f $F ]"] { - assert!(!is_safe(command), "{command:?}"); - } - } - - #[test] - fn guards_find_and_grep() { - assert!(is_safe("find . -name '*.rs' -type f")); - for action in FIND_ACTIONS { - assert!(!is_safe(&format!("find . {action} x")), "{action}"); - } - assert!(is_safe("grep -n TODO src/main.rs")); - for command in [ - "grep -r TODO .", - "grep -Rn TODO .", - "grep --recursive TODO .", - "grep -d recurse TODO .", - "grep --directories=recurse TODO .", - "grep --rec TODO .", - "grep --dir=rec TODO .", - "grep -d rec TODO .", - ] { - assert!(!is_safe(command), "{command:?}"); - } - } - } - - mod dir_target { - use super::*; - - /// The words of the first simple command in `command`. - fn command_words(command: &str) -> Vec { - fn first_command(node: Node<'_>) -> Option> { - if node.kind() == "command" { - return Some(node); - } - let mut cursor = node.walk(); - node.named_children(&mut cursor).find_map(first_command) - } - let tree = parse_tree(command); - let node = first_command(tree.root_node()).unwrap(); - let parts: Vec> = command_parts(node) - .into_iter() - .map(|part| match part.kind() { - "command_name" => part.named_child(0).unwrap(), - _ => part, - }) - .collect(); - words(&parts, command) - } - - #[test] - fn reads_where_a_change_goes_and_what_pushd_n_stacks() { - for (command, expected) in [ - ("cd", DirTarget::Home), - ("cd -P -- src", DirTarget::Path("src")), - ("pushd src", DirTarget::Path("src")), - ("pushd -n", DirTarget::Stay(Stacked::Nothing)), - ("pushd -n +1", DirTarget::Stay(Stacked::Nothing)), - ("pushd -n src", DirTarget::Stay(Stacked::Path("src"))), - ("pushd -n -- -x", DirTarget::Stay(Stacked::Path("-x"))), - ("pushd -n \"$D\"", DirTarget::Stay(Stacked::Unknown)), - ("pushd -n ~1", DirTarget::Stay(Stacked::Unknown)), - ("pushd \"$D\"", DirTarget::Unknown), - ("cd -", DirTarget::Unknown), - ("popd", DirTarget::Unknown), - ] { - let words = command_words(command); - - let target = dir_target(words[0].literal().unwrap(), &words[1..]); - - assert_eq!(target, expected, "{command:?}"); - } - } - } - - mod sed_files { - use super::*; - - #[test] - fn admits_scripts_that_only_print_or_rewrite_text() { - for command in [ - "sed -n '1,20p' a.rs", - "sed 's/foo/bar/g' a.rs", - "sed -e '/^$/d' -e 's/a\\/b/c/2' a.rs", - "sed --expression='$p' a.rs", - "sed --expression '$p' a.rs", - "sed --quiet 1p a.rs", - "sed -es/a/b/ a.rs", - "sed --exp=1p a.rs", - "sed 1p -- a.rs", - "sed -ne '3p;5q' a.rs", - "sed -n -- '1!G;h;$p' a.rs", - ] { - assert!(is_safe(command), "{command:?}"); - } - } - - #[test] - fn leaves_a_call_unrecognized_when_its_script_or_backup_is_unbounded() { - for command in [ - "sed -i $X s/a/b/ a.rs", - "sed -I \"$X\" s/a/b/ a.rs", - "sed -i bk/* s/a/b/ a.rs", - "sed --follow-symlinks -i.bak s/a/b/ a.rs", - "sed -e \"$S\" a.rs", - "sed k a.rs", - ] { - assert!(parse(command).unrecognized().is_some(), "{command:?}"); - } - for command in [ - "sed -i.bak s/a/b/ a.rs", - "sed -i 'bk/*' s/a/b/ a.rs", - "sed --follow-symlinks -i s/a/b/ a.rs", - "sed -i s/a/b/ README.md", - ] { - assert!(parse(command).unrecognized().is_none(), "{command:?}"); - } - } - - #[test] - fn refuses_in_place_edits_script_files_and_writing_or_running_commands() { - for command in [ - "sed -i 's/a/b/' a.rs", - "sed -I '' 's/a/b/' a.rs", - "sed --in 's/a/b/' a.rs", - "sed --fi=script.sed a.rs", - "sed --exp='w out' p a.rs", - "sed 'w out' -e p a.rs", - "sed -ni 'p' a.rs", - "sed --in-place=.bak 's/a/b/' a.rs", - "sed -f script.sed a.rs", - "sed --file=script.sed a.rs", - "sed 's/a/b/w out' a.rs", - "sed 's/a/b/e' a.rs", - "sed -n 'w out' a.rs", - "sed '1r /etc/passwd' a.rs", - "sed 'e date' a.rs", - "sed -e", - "sed --expression", - "sed", - ] { - assert!(!is_safe(command), "{command:?}"); - } - } - - #[test] - fn reads_the_files_past_the_script() { - for command in ["sed -n 1p a.rs", "sed -e 1p -- a.rs"] { - let parsed = parse(command); - - assert!(parsed.segments[0].is_safe(), "{command:?}"); - assert_eq!( - parsed.reads, - [resolve("a.rs", &Workspace::new(Path::new("/repo")))], - "{command:?}" - ); - } - } - } - - mod sed_script { - use super::*; - - #[test] - fn names_the_files_a_script_reads_and_writes_and_whether_it_runs_a_command() { - let effects = |reads: &[&str], writes: &[&str], runs| SedScript { - reads: reads.iter().map(|&path| path.to_owned()).collect(), - writes: writes.iter().map(|&path| path.to_owned()).collect(), - runs, - }; - for (script, expected) in [ - ("p", effects(&[], &[], false)), - ("$!{N;s/\\n/ /};/^#/d", effects(&[], &[], false)), - ("2,+3p;3,~4d;\\,x,Ip;0~2p", effects(&[], &[], false)), - ("1,5d", effects(&[], &[], false)), - (":a;N;ba", effects(&[], &[], false)), - ("l 40;$q5", effects(&[], &[], false)), - ("y/abc/xyz/", effects(&[], &[], false)), - ("a\\\ntext r x\n1p", effects(&[], &[], false)), - ("# r x\np", effects(&[], &[], false)), - ("1r /etc/hosts", effects(&["/etc/hosts"], &[], false)), - ("/x/ R notes\n$w out", effects(&["notes"], &["out"], false)), - ("/a/,/b/w out", effects(&[], &["out"], false)), - ("0~2 W even", effects(&[], &["even"], false)), - ("s/a/b/gw log", effects(&[], &["log"], false)), - ("s|a|b|e", effects(&[], &[], true)), - ("1e date", effects(&[], &[], true)), - ] { - assert_eq!(sed_script(script, Dialect::Gnu), Ok(expected), "{script:?}"); - } - } - - #[test] - fn takes_only_the_commands_bsd_sed_has_for_a_bsd_script() { - let expected = SedScript { - reads: vec!["in".to_owned()], - writes: vec!["out".to_owned(), "log".to_owned()], - runs: false, - }; - - assert_eq!( - sed_script("1r in\n$w out\ns/a/b/w log", Dialect::Bsd), - Ok(expected) - ); - for script in [ - "R in", - "W out", - "1e date", - "s/a/b/e", - "F", - "z", - "Q", - "README.md", - ] { - assert_eq!(sed_script(script, Dialect::Bsd), Err(vec![]), "{script:?}"); - } - } - - #[test] - fn refuses_a_script_it_cannot_read_keeping_the_files_it_writes_before() { - for (script, writes) in [ - ("k", &[][..]), - ("s/a/b", &[]), - ("r", &[]), - ("1r ", &[]), - ("pq", &[]), - ("s/a/b/x", &[]), - ("/x", &[]), - ("1,p", &[]), - ("w locked\nk", &["locked"]), - ("1r in\ns/a/b/w log\n$k\nw out", &["log"]), - ] { - let writes = writes.iter().map(|&path| path.to_owned()).collect(); - - assert_eq!(sed_script(script, Dialect::Gnu), Err(writes), "{script:?}"); - } - } - } - - mod reader_operands { - use super::*; - - #[test] - fn flag_a_reader_word_whose_files_are_unknown() { - for command in [ - "cat $F", - r#"cat "$X/.ssh/id_rsa""#, - "/bin/cat $F", - "grep -r x $DIR", - "head -n $N a", - "cat x[", - ] { - assert!(parse(command).segments[0].reads_anything(), "{command:?}"); - } - for command in ["cat a", "cat *.rs", "echo $F", "cp $F b"] { - assert!(!parse(command).segments[0].reads_anything(), "{command:?}"); - } - } - - #[test] - fn flag_a_read_after_a_change_the_walk_cannot_spell() { - for (command, anything) in [ - ("cd $D; cat a", true), - ("HOME=d cd; cat a", true), - ("cd; cat a", false), - ("pushd -n $D; cat ~1/a", true), - ("pushd -n $D; cat a", false), - ("pushd -n src; cat ~1/a", false), - ("pushd -n +1; cat ~1/a", false), - ] { - let parsed = parse(command); - - let last = parsed.segments.last().unwrap(); - - assert_eq!(last.reads_anything(), anything, "{command:?}"); - } - } - - #[test] - fn keeps_recursive_roots_for_each_ambiguous_expansion_order() { - for (reader, written, expanded, start, end, source, root) in [ - ( - "grep", - "-e PRIVATE -* pats", - "-e PRIVATE -f -R pats", - 2, - 4, - 2, - ".", - ), - ( - "grep", - "-e PRIVATE -* pats", - "-e PRIVATE -R -f pats", - 2, - 4, - 2, - ".", - ), - ( - "ls", - "-D vault -* 80", - "-D vault -w -R 80", - 2, - 4, - 2, - "vault", - ), - ( - "ls", - "-D vault -* 80", - "-D vault -R -w 80", - 2, - 4, - 2, - "vault", - ), - ("find", "* )", "vault ( -name )", 0, 3, 0, "."), - ("find", "* )", "( -name vault )", 0, 3, 0, "."), - ( - "ls", - "--format *", - "--format -R long vault", - 1, - 4, - 1, - "vault", - ), - ( - "ls", - "--format *", - "--format long -R vault", - 1, - 4, - 1, - "vault", - ), - ( - "grep", - "-r -e needle -? patterns/* src", - "-r -e needle -f patterns/z-vault patterns/a-patterns src", - 4, - 6, - 4, - "patterns/z-vault", - ), - ( - "grep", - "-r -e needle -? patterns/* src", - "-r -e needle -f patterns/a-patterns patterns/z-vault src", - 4, - 6, - 4, - "patterns/z-vault", - ), - ( - "grep", - "-e PRIVATE -[f] pats -* more", - "-e PRIVATE -f pats -f -R more", - 4, - 6, - 4, - ".", - ), - ] { - let written: Vec<_> = written.split_whitespace().map(Some).collect(); - let expanded: Vec<_> = expanded.split_whitespace().map(Some).collect(); - let args = reader_args(reader, &written); - - let origins: Vec<_> = (0..expanded.len()) - .map(|index| match index { - _ if index < start => Some(index), - _ if index < end => None, - _ => Some(index + source + 1 - end), - }) - .collect(); - - let (paths, unknown) = expanded_reader_paths( - reader, - &args, - &expanded, - &origins, - &[(start..end, source)], - ); - - assert!(unknown, "{reader} {expanded:?}"); - assert!( - paths.iter().any(|operand| operand.read - == ReaderPath { - path: root.to_owned(), - recursive: true, - }), - "{reader} {expanded:?}: {paths:?}" - ); - } - } - - #[test] - fn read_each_literal_operand_and_each_file_a_glob_matches() { - let cwd_dir = canonical_tempdir(); - let cwd = cwd_dir.path(); - std::fs::create_dir(cwd.join("src")).unwrap(); - std::fs::write(cwd.join("src/main.rs"), "").unwrap(); - let workspace = Workspace::new(cwd); - - let command = CommandTarget::parse("grep -r x src/*.rs docs", &workspace); - - let segment = &command.segments[0]; - assert!(!segment.is_safe()); - let at = |name: &str| resolve(name, &workspace); - let tree = |name: &str| ReaderPath { - path: at(name), - recursive: true, - }; - assert_eq!(segment.reads(), [tree("docs"), tree("src/*.rs")]); - let main = Path::new("src").join("main.rs"); - assert_eq!(command.reads, [at("docs"), at(&main.to_string_lossy())]); - } - } - - mod reader_args { - use super::*; - - // cspell:ignore drecurse rfpatterns - #[test] - fn distinguishes_recursive_operands_from_exact_auxiliary_files() { - for (reader, args, expected) in [ - ( - "grep", - "-r -f patterns src", - &[("patterns", false), ("src", true)][..], - ), - ( - "grep", - "-R --exclude-from=skip -e pat src", - &[("skip", false), ("src", true)], - ), - ("grep", "-d recurse pat src", &[("src", true)]), - ("grep", "-drecurse pat src", &[("src", true)]), - ("grep", "--directories=rec pat src", &[("src", true)]), - ("grep", "--dereference-rec pat src", &[("src", true)]), - ("grep", "--rec pat src", &[("src", true)]), - ("grep", "pat src -r", &[("-r", false), ("src", true)]), - ("grep", "-d skip pat src", &[("src", false)]), - ("grep", "-e -r src", &[("src", false)]), - ("grep", "-r pat -", &[]), - ("grep", "-r pat", &[(".", true)]), - ("grep", "-rfpatterns", &[(".", true), ("patterns", false)]), - ("grep", "-- -r src", &[("src", false)]), - ("ls", "-laR src", &[("src", true)]), - ("ls", "--rec", &[(".", true)]), - ("ls", "-- -R", &[("-R", false)]), - ( - "find", - "-L src -name needle", - &[("needle", false), ("src", true)], - ), - ("find", "-name needle", &[(".", true), ("needle", false)]), - ( - "find", - "-HL src -name needle", - &[("needle", false), ("src", true)], - ), - ("find", "-f src", &[("src", true)]), - ("find", "-D search -O3 src", &[("src", true)]), - ("find", "", &[(".", true)]), - ] { - let values: Vec<_> = args.split_whitespace().map(Some).collect(); - - let read = reader_args(reader, &values); - - let mut actual: Vec<_> = read - .files - .iter() - .map(|(_, file)| (file.path.as_str(), file.recursive)) - .collect(); - actual.sort_unstable(); - actual.dedup(); - assert_eq!(actual, expected, "{reader} {args}"); - assert!(!read.unknown, "{reader} {args}"); - } - } - - /// The files `reader` reads from the literal words of `args`. - fn files(reader: &str, args: &str) -> Vec { - let values: Vec> = args.split(' ').map(Some).collect(); - let read = reader_args(reader, &values); - assert!(!read.unknown, "{reader} {args}"); - read.files.into_iter().map(|(_, file)| file.path).collect() - } - - #[test] - fn reads_option_values_as_values() { - for (reader, args) in [ - ("tail", "-20 x"), - ("tail", "-n 20 x"), - ("tail", "-n20 x"), - ("tail", "--lines=20 x"), - ("tail", "--lines 20 x"), - ("tail", "-fn 20 x"), - ("head", "-c 5 x"), - ("head", "-5 x"), - ("grep", "-A 5 pat x"), - ("grep", "-nA5 pat x"), - ("grep", "-3 pat x"), - ("grep", "--color=auto -m 1 pat x"), - ("grep", "-e pat x"), - ("grep", "--regexp=pat -- x"), - ("grep", "--reg=pat x"), - ("grep", "--regex pat x"), - ("grep", "--binary pat x"), - ("grep", "--exclude=*.md pat x"), - ("tail", "--lin 20 x"), - ("ls", "--sort=size x"), - ("sed", "-i.bak s/a/b/ x"), - ("wc", "-l --total=never x"), - ("cat", "-n - x"), - ] { - assert_eq!(files(reader, args), ["x"], "{reader} {args}"); - } - } - - #[test] - fn counts_each_file_a_gnu_or_bsd_build_reads_in_either_option_order() { - for (reader, args, expected) in [ - ("ls", "-I target x", &["target", "x"][..]), - ("ls", "-D %s x", &["%s", "x"]), - ("sed", "-n -l 80 1p x", &["1p", "x"]), - ("sed", "-i .bak s/a/b/ x", &["s/a/b/", "x"]), - ("grep", "--context 3 pat x", &["pat", "x"]), - ("tail", "x -n 5", &["x", "-n", "5"]), - ("grep", "pat x -e y", &["pat", "x", "-e", "y"]), - ("cat", "x -n", &["x", "-n"]), - ] { - assert_eq!(files(reader, args), expected, "{reader} {args}"); - } - } - - #[test] - fn counts_a_value_that_names_a_file_and_every_operand_after_a_given_pattern() { - for (reader, args, expected) in [ - ("grep", "-f patterns.txt x", &["patterns.txt", "x"][..]), - ("grep", "-fpats --file more y", &["pats", "more", "y"]), - ("grep", "--exclude-from=skip pat x", &["skip", "x"]), - ("sed", "-f script.sed x", &["script.sed", "x"]), - ("find", ". -name x", &[".", "x"]), - ] { - assert_eq!(files(reader, args), expected, "{reader} {args}"); - } - } - - #[test] - fn reads_an_option_it_cannot_place_as_naming_a_file() { - for (reader, args, expected) in [ - ("tail", "-z 20 x", &["20", "x"][..]), - ("grep", "--unknown=f pat x", &["f", "pat", "x"]), - ("grep", "--ex=a pat x", &["a", "pat", "x"]), - ("tail", "-n", &[]), - ] { - assert_eq!(files(reader, args), expected, "{reader} {args}"); - } - } - - #[test] - fn leaves_what_it_reads_unknown_for_a_file_list_or_a_word_that_expands() { - for (reader, values) in [ - ("wc", &[Some("--files0-from=list")][..]), - ("find", &[Some("-files0-from"), Some("list")]), - ("head", &[Some("-n"), None, Some("x")]), - ("grep", &[None, Some("x")]), - ] { - assert!(reader_args(reader, values).unknown, "{reader} {values:?}"); - } - } - } - - mod expand_glob { - use super::*; - - #[test] - fn lists_what_bash_would_and_skips_dot_names_unless_a_name_asks_for_them() { - let cwd_dir = canonical_tempdir(); - let cwd = cwd_dir.path(); - for dir in ["src/sub", ".ssh", "docs"] { - std::fs::create_dir_all(cwd.join(dir)).unwrap(); - } - for file in [ - "src/a.rs", - "src/b.rs", - "src/c.md", - "src/sub/d.rs", - ".ssh/id_rsa", - ".ssh/id_ed25519", - "docs/id_x", - ".env", - ] { - std::fs::write(cwd.join(file), "").unwrap(); - } - let expand = |pattern: &str| { - let mut paths: Vec = expand_glob(pattern, cwd) - .unwrap() - .iter() - .map(|path| slash_separated(Path::new(path))) - .collect(); - paths.sort(); - paths - }; - let root = slash_separated(cwd); - - assert_eq!(expand("src/*.rs"), ["src/a.rs", "src/b.rs"]); - assert_eq!(expand("./src/*/*.rs"), ["./src/sub/d.rs"]); - assert_eq!(expand("*/id_*"), ["docs/id_x"]); - assert_eq!(expand(".*/id_*"), [".ssh/id_ed25519", ".ssh/id_rsa"]); - assert_eq!( - expand("{.ssh,docs}/id_[er]*"), - [".ssh/id_ed25519", ".ssh/id_rsa"] - ); - assert_eq!(expand("?env"), [""; 0]); - assert_eq!(expand("missing/*"), [""; 0]); - assert_eq!( - expand(&format!("{root}/.ss?/id_rsa")), - [format!("{root}/.ssh/id_rsa")] - ); - } - - #[test] - fn gives_up_past_the_match_cap_or_on_a_pattern_globset_rejects() { - let cwd_dir = canonical_tempdir(); - let cwd = cwd_dir.path(); - for i in 0..MAX_GLOB_MATCHES { - std::fs::write(cwd.join(format!("{i}.txt")), "").unwrap(); - } - assert_eq!( - expand_glob("*.txt", cwd).map(|paths| paths.len()), - Some(MAX_GLOB_MATCHES) - ); - - std::fs::write(cwd.join("last.txt"), "").unwrap(); - - assert_eq!(expand_glob("*.txt", cwd), None); - assert_eq!(expand_glob("[", cwd), None); - } - - #[cfg(target_os = "linux")] - #[test] - fn gives_up_on_a_file_name_that_is_not_utf8() { - use std::os::unix::ffi::OsStrExt as _; - - let cwd_dir = canonical_tempdir(); - - let cwd = cwd_dir.path(); - std::fs::write(cwd.join(std::ffi::OsStr::from_bytes(b"a\xff")), "").unwrap(); - - assert_eq!(expand_glob("*", cwd), None); } } } diff --git a/crates/ox-core/src/permission/shell/invocation.rs b/crates/ox-core/src/permission/shell/invocation.rs new file mode 100644 index 000000000..1c6caf435 --- /dev/null +++ b/crates/ox-core/src/permission/shell/invocation.rs @@ -0,0 +1,452 @@ +//! Reads nested shell invocations and the environment their scripts inherit. + +use super::word::{Word, expand_tilde}; +use super::wrapper::{Fed, Unwrapped, assigned}; +use super::{Operand, ShellWord}; + +/// 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. +pub(super) const SHELLS: &[&str] = &[ + "ash", "bash", "csh", "dash", "fish", "hush", "ksh", "mksh", "pwsh", "runuser", "sh", "su", + "tcsh", "zsh", +]; + +// ── Shell ── + +/// What a shell runs, as [`shell_run`] reads its arguments. +pub(super) 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. +pub(super) struct ShellArgs<'w> { + pub(super) run: ShellRun<'w>, + /// The startup files a long option names, which an interactive shell sources, each with the + /// index of its argument. + pub(super) startup: Vec<(usize, &'w str)>, +} + +/// A variable a shell reads as it starts, which names a startup file or a directory of them. +pub(super) struct StartupVariable { + pub(super) 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. + pub(super) 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. +pub(super) const HOME_VARIABLE: StartupVariable = StartupVariable::directory("HOME"); + +/// Variables used by a leading tilde or an expansion such as `$PWD`. Where a command sets one, +/// the analysis cannot resolve those expansions from its own environment. +pub(super) 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. +pub(super) 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)] +pub(super) 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. + pub(super) 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. + pub(super) anything: bool, +} + +/// What a program inherits from the rest of its simple command. +pub(super) struct Inherited<'c> { + /// What an `xargs` or `find` around the command passes it. + pub(super) fed: &'c Fed, + /// Where the program's words start among the command's, which `fed` indexes. + pub(super) 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. + pub(super) 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. + pub(super) 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`. + pub(super) 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. + pub(super) 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`]. + pub(super) fn sets_dir_variable(&self) -> bool { + sets_dir_variable(&self.variables) + } +} + +/// Returns whether `variables` set one of the [`DIR_VARIABLES`]. +pub(super) fn sets_dir_variable(variables: &[(String, Option)]) -> bool { + variables + .iter() + .any(|(name, _)| DIR_VARIABLES.contains(&name.as_str())) +} + +/// Returns whether `text` may expand a tilde or one of the [`DIR_VARIABLES`]. +pub(super) 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. +pub(super) 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. +pub(super) 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. + pub(super) 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. +pub(super) 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`. +pub(super) 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. +pub(super) 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. +pub(super) 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. +pub(super) 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. +pub(super) struct SuArgs<'w> { + /// The words it passes its user's shell after the user name. + pub(super) 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. + pub(super) 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. +pub(super) 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. +pub(super) 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. +pub(super) 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() + }) +} diff --git a/crates/ox-core/src/permission/shell/pattern.rs b/crates/ox-core/src/permission/shell/pattern.rs new file mode 100644 index 000000000..dd8ddeba4 --- /dev/null +++ b/crates/ox-core/src/permission/shell/pattern.rs @@ -0,0 +1,141 @@ +//! Parses the words an `exec` permission pattern matches. + +use anyhow::{Result, bail, ensure}; +use tree_sitter::Node; + +use super::PatternWord; +use super::walk::{command_parts, parse_tree, splits_words_like_bash, words}; +use super::word::assignment_word; + +const NAMES_NO_COMMAND: &str = "it must name a command, such as `cargo test *`"; + +const ONE_COMMAND: &str = "it must be one simple command, such as `cargo test *`. \ + Write a pattern for each command of a chain or pipeline."; + +const HOLDS_REDIRECTION: &str = "it holds a redirection. \ + Leave it out, since a pattern matches a command \ + with its redirections set aside, \ + and an `edit` pattern covers the file it writes."; + +/// Parses an `exec` pattern into its words: one simple command, optionally led by assignments, +/// whose words are each literal or a lone unquoted `*`. +pub(super) fn parse(pattern: &str) -> Result> { + let tree = parse_tree(pattern); + let root = tree.root_node(); + ensure!(!root.has_error(), "it does not parse as a shell command"); + ensure!( + splits_words_like_bash(root, pattern), + "the shell parser `ox` uses would split its words where bash does not. \ + Write it without escaped blanks or line continuations between words." + ); + let mut cursor = root.walk(); + let nodes: Vec> = root.named_children(&mut cursor).collect(); + let command = match nodes.as_slice() { + [] => bail!("{NAMES_NO_COMMAND}"), + [command] => *command, + [..] => bail!("{ONE_COMMAND}"), + }; + match command.kind() { + "command" => {} + "redirected_statement" => bail!("{HOLDS_REDIRECTION}"), + "variable_assignment" => bail!("{NAMES_NO_COMMAND}"), + _ => bail!("{ONE_COMMAND}"), + } + + let mut parsed = Vec::new(); + let mut nodes = Vec::new(); + let mut names_command = false; + for child in command_parts(command) { + match child.kind() { + "variable_assignment" => { + let word = assignment_word(child, pattern); + ensure!( + word.literal, + "the assignment {:?} expands at run time. \ + Quote its value to match it as written.", + word.text + ); + parsed.push(PatternWord::Literal(word.text)); + } + "file_redirect" | "herestring_redirect" | "heredoc_redirect" => { + bail!("{HOLDS_REDIRECTION}"); + } + "command_name" => { + names_command = true; + match child.named_child(0) { + Some(name) => nodes.push(name), + None => bail!("{NAMES_NO_COMMAND}"), + } + } + _ => nodes.push(child), + } + } + for word in words(&nodes, pattern) { + let written = &pattern[word.span.clone()]; + if written == "*" { + parsed.push(PatternWord::Any); + continue; + } + let Some(value) = word.value else { + bail!("the word {written:?} expands at run time. Quote it to match it as written."); + }; + if word.glob { + bail!( + "the word {written:?} holds a glob, \ + but only a `*` standing alone as a word is a wildcard. \ + Write a pattern for each command it should cover, \ + or quote the word to match it as written." + ); + } + parsed.push(PatternWord::Literal(value)); + } + ensure!(names_command, "{NAMES_NO_COMMAND}"); + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::util::path::local_testing::abs; + use crate::util::path::testing::HOME_VAR; + + mod parse { + use super::*; + + fn pattern(raw: &str) -> Vec { + parse(raw).unwrap() + } + + fn literal(text: &str) -> PatternWord { + PatternWord::Literal(text.to_owned()) + } + + #[test] + fn unquotes_each_word_and_reads_a_lone_star_as_a_wildcard() { + temp_env::with_var(HOME_VAR, Some(abs("/home/u")), || { + let home = PathBuf::from(abs("/home/u")).join("x"); + assert_eq!( + pattern(r#"GIT_PAGER=cat git commit -m "a b" '*' \* ~/x *"#), + [ + literal("GIT_PAGER=cat"), + literal("git"), + literal("commit"), + literal("-m"), + literal("a b"), + literal("*"), + literal("*"), + literal(&home.to_string_lossy()), + PatternWord::Any, + ] + ); + }); + assert_eq!(pattern("*"), [PatternWord::Any]); + assert_eq!( + pattern("git * --force"), + [literal("git"), PatternWord::Any, literal("--force")] + ); + } + } +} diff --git a/crates/ox-core/src/permission/shell/reader.rs b/crates/ox-core/src/permission/shell/reader.rs new file mode 100644 index 000000000..120a33c68 --- /dev/null +++ b/crates/ox-core/src/permission/shell/reader.rs @@ -0,0 +1,1967 @@ +//! Classifies read-only commands and resolves the files their arguments read. + +use std::iter::Peekable; +use std::ops::Range; +use std::path::{Component, Path, PathBuf}; +use std::str::Chars; +use std::sync::LazyLock; + +use anyhow::Result; +use regex::Regex; + +use super::walk::FIND_ACTIONS; +use super::word::Word; +use super::wrapper::{Value, sets_only_tracing_options}; +use super::{Operand, ReaderPath}; + +/// Commands whose file operands the gate weighs against `read` rules, whether or not they run as +/// [known-safe](safe_operands) reads. +pub(super) 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; + +/// 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") +}); + +// ── Known-Safe Commands ── + +/// The operators and the closing `]` of a `test` or `[` call. +const TEST_OPERATORS: &[&str] = &["!", "!=", "(", ")", "<", "=", "==", ">", "]"]; + +/// The words that may name a file read by the shipped known-safe read-only command `name`, or +/// `None` when `name` is no such command or `args` take it out of the safe set. Every argument must +/// be literal and free of globs, so each file it reads is one the gate can check. Every word of a +/// `test` or `[` call other than an option or a [`TEST_OPERATORS`] word counts as a file, so a +/// string it compares reads as one too. +pub(super) fn safe_operands(name: &str, args: &[Word]) -> Option> { + let values = args.iter().map(Word::literal).collect::>>()?; + let files = || { + let known: Vec> = values.iter().copied().map(Some).collect(); + let operands = reader_args(name, &known); + (!operands.unknown).then(|| operands.reads(None).collect()) + }; + match name { + ":" | "echo" | "export" | "false" | "printf" | "pwd" | "true" => Some(Vec::new()), + "set" => sets_only_tracing_options(args).then(Vec::new), + "[" | "test" => Some( + values + .iter() + .filter(|value| !value.starts_with('-') && !TEST_OPERATORS.contains(value)) + .map(|&value| Operand::possible(ReaderPath::exact(value.to_owned()))) + .collect(), + ), + "cat" | "head" | "ls" | "tail" | "wc" => files(), + "find" if !values.iter().any(|value| FIND_ACTIONS.contains(value)) => files(), + "grep" if !values.iter().any(|value| is_recursive_grep(value)) => files(), + "sed" => sed_files(&values).map(|files| files.into_iter().map(Operand::certain).collect()), + _ => None, + } +} + +/// Returns whether a `grep` argument makes it read every file under a directory. `--directories` +/// and `-d` count whatever their value, since grep accepts `rec` for `recurse`. +fn is_recursive_grep(arg: &str) -> bool { + match long_option(arg) { + Some(name) => ["dereference-recursive", "directories", "recursive"] + .iter() + .any(|option| option.starts_with(name)), + None => arg.starts_with('-') && arg.contains(['d', 'r', 'R']), + } +} + +/// The files a `sed` call reads, or `None` when it may edit a file in place, read a script file, or +/// run a script [`SAFE_SED_SCRIPT`] does not accept. An option after an operand is `None` too, +/// since BSD `sed` reads it as a file and may then take the operand before it as the script. A `--` +/// there is a file to BSD and ends the options for GNU, which read the same script. +fn sed_files(values: &[&str]) -> Option> { + let mut scripts = Vec::new(); + let mut files = Vec::new(); + let mut args = values.iter().copied(); + while let Some(arg) = args.next() { + if !files.is_empty() && arg.starts_with('-') && !matches!(arg, "-" | "--") { + return None; + } + if arg == "--" { + if !files.is_empty() { + files.push(arg); + } + files.extend(args.by_ref()); + } else if let Some(name) = long_option(arg) { + if ["file", "in-place"] + .iter() + .any(|option| option.starts_with(name)) + { + return None; + } + if "expression".starts_with(name) { + match arg.split_once('=') { + Some((_, script)) => scripts.push(script), + None => scripts.push(args.next()?), + } + } + } else if let Some(cluster) = arg.strip_prefix('-').filter(|c| !c.is_empty()) { + if cluster.contains(['I', 'f', 'i']) { + return None; + } + if let Some(at) = cluster.find('e') { + let attached = &cluster[at + 1..]; + scripts.push(if attached.is_empty() { + args.next()? + } else { + attached + }); + } + } else { + files.push(arg); + } + } + if scripts.is_empty() && !files.is_empty() { + scripts.push(files.remove(0)); + } + let safe = !scripts.is_empty() + && scripts + .iter() + .all(|script| SAFE_SED_SCRIPT.is_match(script)); + safe.then(|| files.into_iter().map(str::to_owned).collect()) +} + +/// The files a `sed` script reads and writes and whether it runs a command. It follows the +/// commands of the `dialect` build: `r` and GNU's `R` read the file named by the rest of the line, +/// `w`, GNU's `W`, and the `s///w` flag write one, and GNU's `e` and `s///e` flag run a command. +/// GNU's commands cover BSD's, so a BSD script parses the same in GNU. Like [`SAFE_SED_SCRIPT`], it +/// ends a regular expression at the first unescaped delimiter. +/// +/// # Errors +/// +/// For a script the parser cannot read, which may do anything, it returns the files written by +/// the commands before the unreadable part, since both builds open them while compiling the script. +pub(super) fn sed_script(script: &str, dialect: Dialect) -> Result> { + let mut parsed = SedScript::default(); + match read_sed_script(script, dialect, &mut parsed) { + Some(()) => Ok(parsed), + None => Err(parsed.writes), + } +} + +/// Adds what `script` does to `parsed`, as [`sed_script`] reads it, stopping with `None` at the +/// first part it cannot read. +fn read_sed_script(script: &str, dialect: Dialect, parsed: &mut SedScript) -> Option<()> { + let gnu = dialect == Dialect::Gnu; + let mut chars = script.chars().peekable(); + loop { + while chars.next_if(|c| c.is_whitespace() || *c == ';').is_some() {} + let Some(&next) = chars.peek() else { + return Some(()); + }; + if next == '#' { + rest_of_line(&mut chars); + continue; + } + if sed_address(&mut chars)? { + skip_blanks(&mut chars); + if chars.next_if_eq(&',').is_some() { + skip_blanks(&mut chars); + if chars.next_if(|c| matches!(c, '+' | '~')).is_some() { + skip_number(&mut chars)?; + } else if !sed_address(&mut chars)? { + return None; + } + } + } + skip_blanks(&mut chars); + while chars.next_if_eq(&'!').is_some() { + skip_blanks(&mut chars); + } + match chars.next()? { + 'e' | 'F' | 'L' | 'Q' | 'R' | 'T' | 'v' | 'W' | 'z' if !gnu => return None, + '{' => continue, + '}' | '=' | 'd' | 'D' | 'F' | 'g' | 'G' | 'h' | 'H' | 'n' | 'N' | 'p' | 'P' | 'x' + | 'z' => {} + 'l' | 'L' | 'q' | 'Q' => { + skip_blanks(&mut chars); + while chars.next_if(char::is_ascii_digit).is_some() {} + } + ':' | 'b' | 't' | 'T' | 'v' => { + while chars.next_if(|c| !matches!(c, ';' | '\n')).is_some() {} + } + 'a' | 'i' | 'c' => text_lines(&mut chars), + command @ ('r' | 'R' | 'w' | 'W') => { + let file = file_name(&mut chars)?; + if matches!(command, 'r' | 'R') { + parsed.reads.push(file); + } else { + parsed.writes.push(file); + } + continue; + } + 'e' => { + rest_of_line(&mut chars); + parsed.runs = true; + continue; + } + 's' => { + let delimiter = sed_delimiter(&mut chars)?; + sed_text(&mut chars, delimiter)?; + sed_text(&mut chars, delimiter)?; + loop { + match chars.peek() { + Some('g' | 'p' | 'i' | 'I' | 'm' | 'M' | '0'..='9') => { + chars.next(); + } + Some('e') if gnu => { + chars.next(); + parsed.runs = true; + } + Some('w') => { + chars.next(); + parsed.writes.push(file_name(&mut chars)?); + break; + } + _ => break, + } + } + } + 'y' => { + let delimiter = sed_delimiter(&mut chars)?; + sed_text(&mut chars, delimiter)?; + sed_text(&mut chars, delimiter)?; + } + _ => return None, + } + skip_blanks(&mut chars); + if chars + .peek() + .is_some_and(|c| !matches!(c, ';' | '\n' | '}' | '#')) + { + return None; + } + } +} + +/// What a `sed` script does beyond printing, deleting, substituting, and moving text. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct SedScript { + pub(super) reads: Vec, + pub(super) writes: Vec, + pub(super) runs: bool, +} + +type SedChars<'a> = Peekable>; + +fn skip_blanks(chars: &mut SedChars<'_>) { + while chars.next_if(|c| matches!(c, ' ' | '\t')).is_some() {} +} + +fn rest_of_line(chars: &mut SedChars<'_>) -> String { + let mut line = String::new(); + while let Some(c) = chars.next_if(|c| *c != '\n') { + line.push(c); + } + line +} + +fn skip_number(chars: &mut SedChars<'_>) -> Option<()> { + chars.next_if(char::is_ascii_digit)?; + while chars.next_if(char::is_ascii_digit).is_some() {} + Some(()) +} + +/// The non-empty file name a command takes: the rest of the line after leading blanks. +fn file_name(chars: &mut SedChars<'_>) -> Option { + skip_blanks(chars); + Some(rest_of_line(chars)).filter(|file| !file.is_empty()) +} + +/// Consumes the text of an `a`, `i`, or `c` command: the rest of the line, continued onto the next +/// one while a line ends in an unescaped backslash. +fn text_lines(chars: &mut SedChars<'_>) { + loop { + let line = rest_of_line(chars); + let trailing = line.chars().rev().take_while(|c| *c == '\\').count(); + if trailing % 2 == 0 || chars.next().is_none() { + return; + } + } +} + +/// Consumes one address, returning whether there was one: a line number, possibly with `~step`, +/// `$`, or a regular expression between slashes or after `\` with its own delimiter, with its +/// `I` and `M` flags. +fn sed_address(chars: &mut SedChars<'_>) -> Option { + match chars.peek() { + Some('0'..='9') => { + skip_number(chars)?; + if chars.next_if_eq(&'~').is_some() { + skip_number(chars)?; + } + } + Some('$') => { + chars.next(); + } + Some('/') => { + chars.next(); + sed_text(chars, '/')?; + while chars.next_if(|c| matches!(c, 'I' | 'M')).is_some() {} + } + Some('\\') => { + chars.next(); + let delimiter = sed_delimiter(chars)?; + sed_text(chars, delimiter)?; + while chars.next_if(|c| matches!(c, 'I' | 'M')).is_some() {} + } + _ => return Some(false), + } + Some(true) +} + +fn sed_delimiter(chars: &mut SedChars<'_>) -> Option { + chars.next().filter(|c| !matches!(c, '\n' | '\\')) +} + +/// Consumes text up to and including the unescaped `delimiter`. +fn sed_text(chars: &mut SedChars<'_>, delimiter: char) -> Option<()> { + loop { + match chars.next()? { + '\\' => { + chars.next()?; + } + c if c == delimiter => return Some(()), + _ => {} + } + } +} + +/// The name of a long option such as `--name` or `--name=value`, which GNU tools accept abbreviated +/// to any prefix that names one option. +fn long_option(arg: &str) -> Option<&str> { + let option = arg.strip_prefix("--")?; + let name = option.split_once('=').map_or(option, |(name, _)| name); + (!name.is_empty()).then_some(name) +} + +// ── Readers ── + +/// The paths reader `name` may read, expanding whole words before assigning their argument roles. +/// A multi-match expansion that can change roles leaves the reads unknown and keeps every possible +/// named operand. Only the words before it, and the literal words after it that are certain with +/// each glob read as one word, as an expanding word is, stay certain. Glob match ordering is not +/// assumed, since bash's locale can change it. +pub(super) fn reader_operands(name: &str, args: &[Word], cwd: &Path) -> (Vec, bool) { + let written: Vec<_> = args.iter().map(|word| word.value.as_deref()).collect(); + let written = reader_args(name, &written); + let mut anything = written.unknown; + let mut values = Vec::new(); + let mut origins = Vec::new(); + let mut groups = Vec::new(); + for (index, word) in args.iter().enumerate() { + if let Some(pattern) = word.value.as_deref().filter(|_| word.glob) { + match expand_glob(pattern, cwd) { + Some(matches) if !matches.is_empty() => { + if matches.len() > 1 { + groups.push((values.len()..values.len() + matches.len(), index)); + } + origins.extend(matches.iter().map(|_| None)); + values.extend(matches.into_iter().map(Some)); + continue; + } + None => anything = true, + Some(_) => {} + } + } + origins.push((!word.glob).then_some(index)); + values.push(word.value.clone()); + } + let values: Vec<_> = values.iter().map(Option::as_deref).collect(); + let (files, unknown) = expanded_reader_paths(name, &written, &values, &origins, &groups); + (files, anything || unknown) +} + +/// The reads of `values`, the expanded words of a reader whose `written` words a glob expanded +/// into, where `origins` names the written word of each value that is not a glob match and each +/// of `groups` spans the matches of a multi-match glob with the index of its written word. +fn expanded_reader_paths( + name: &str, + written: &ReaderArgs, + values: &[Option<&str>], + origins: &[Option], + groups: &[(Range, usize)], +) -> (Vec, bool) { + let grammar = reader_grammar(name); + let expanded = reader_args(name, values); + let ambiguous = groups + .iter() + .find(|(range, source)| { + !written.operands.contains(source) + || range.clone().any(|index| { + !expanded.operands.contains(&index) + || values[index].is_some_and(|value| { + value.starts_with('-') && expanded.options_at(values, range.start) + || grammar.operands == OperandKind::Find + && grammar.find_expression(value) + }) + }) + }) + .map(|(range, _)| range.start); + let anything = written.unknown || expanded.unknown || ambiguous.is_some(); + let late_literals: Vec = ambiguous.map_or_else(Vec::new, |start| { + origins[start..].iter().flatten().copied().collect() + }); + let mut files: Vec = expanded + .reads(ambiguous) + .chain(written.files.iter().map(|(index, path)| Operand { + read: path.clone(), + certain: index.is_some_and(|index| { + late_literals.contains(&index) && written.opened.contains(&index) + }), + })) + .collect(); + if let Some(start) = ambiguous { + let recursive = files.iter().any(|operand| operand.read.recursive) + || grammar.may_recurse(values, start, &expanded); + for (index, value) in values.iter().enumerate() { + if index < start && expanded.fixed_values.contains(&index) { + continue; + } + if let Some(path) = value.filter(|path| { + !path.is_empty() && (grammar.operands != OperandKind::Stream || *path != "-") + }) { + files.push(Operand::possible(ReaderPath { + path: path.to_owned(), + recursive, + })); + } + } + let consumes_operand = expanded.options_at(values, start) + && values[start..].iter().flatten().any(|value| { + reader_args(name, &[Some(value), Some("")]) + .option_values + .contains(&1) + }); + let expression = grammar.operands == OperandKind::Find + && values[start..] + .iter() + .flatten() + .any(|value| grammar.find_expression(value)); + if recursive && (consumes_operand || expression) { + files.push(Operand::possible(ReaderPath { + path: ".".to_owned(), + recursive: true, + })); + } + } + files.sort_unstable_by(|left, right| left.read.path.cmp(&right.read.path)); + files.dedup_by(|next, current| { + let same = next.read.path == current.read.path; + if same { + current.read.recursive |= next.read.recursive; + current.certain |= next.certain; + } + same + }); + (files, anything) +} + +/// What one of [`READERS`] reads, from its arguments. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct ReaderArgs { + /// Each path with the index of its word, or no index for an implicit cwd: each operand but a + /// leading pattern or script, and each value of an option that names a file. + files: Vec<(Option, ReaderPath)>, + /// Words that supply an option's value in every supported argument order and build. + fixed_values: Vec, + /// Words that are ordinary file operands in every supported argument order and build. + operands: Vec, + /// Words that some supported argument order or build opens as a file. + opened: Vec, + /// Whether some supported argument order or build searches the implicit cwd. + searches_cwd: bool, + pub(super) script: ScriptArgs, + /// Words that supply an option's value in any supported argument order or build. + option_values: Vec, + /// Whether it may read a file the arguments do not name: a word expands, or an option names a + /// file that lists the files to read. + unknown: bool, +} + +/// Where a reader takes its pattern or script from, and whether it rewrites its files. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct ScriptArgs { + /// Each word that any supported argument order or build takes as the pattern or script. + pub(super) words: Vec, + /// Whether some supported argument order or build reads the pattern or script from a file. + pub(super) from_file: bool, + /// Whether some supported argument order or build rewrites its file operands in place. + pub(super) in_place: bool, + /// Whether some supported argument order or build rewrites a symlink's target in place. + pub(super) follows_links: bool, + /// Each backup suffix some supported argument order or build gives an in-place rewrite. + pub(super) backups: Vec, +} + +/// The backup suffix one build gives an in-place rewrite, from the word at `index`. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Backup { + pub(super) dialect: Dialect, + pub(super) index: usize, + /// The suffix, or `None` when its word expands. + pub(super) suffix: Option, +} + +impl Backup { + /// Where this build moves `file` before rewriting it, unless the suffix expands. GNU's build + /// replaces each `*` in the suffix with `file` as written, while BSD's, which has no template, + /// and GNU's for a suffix without `*` append the suffix. + pub(super) fn path(&self, file: &str) -> Option { + let suffix = self.suffix.as_deref()?; + Some(if self.dialect == Dialect::Gnu && suffix.contains('*') { + suffix.replace('*', file) + } else { + format!("{file}{suffix}") + }) + } +} + +/// The word at `index`, with its value or `None` when it expands, that some argument order of the +/// `dialect` build takes as a reader's pattern or script. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct ScriptWord { + pub(super) dialect: Dialect, + index: usize, + pub(super) value: Option, +} + +impl ReaderArgs { + /// Each path in `files`, certain when its word is `opened` and comes before the first match + /// of an `ambiguous` glob, whose matches may shift the roles of later words, or when it is an + /// implicit cwd some order or build searches and no glob is ambiguous. + fn reads(&self, ambiguous: Option) -> impl Iterator + '_ { + self.files.iter().map(move |(index, read)| Operand { + read: read.clone(), + certain: match index { + Some(index) => { + self.opened.contains(index) && ambiguous.is_none_or(|start| *index < start) + } + None => self.searches_cwd && ambiguous.is_none(), + }, + }) + } + + fn options_at(&self, values: &[Option<&str>], index: usize) -> bool { + !values[..index] + .iter() + .enumerate() + .any(|(index, value)| *value == Some("--") && !self.option_values.contains(&index)) + } +} + +/// Reads the arguments of reader `name`, given each word's value or `None` for one that expands. +/// The gate cannot tell whether a GNU or a BSD build of `name` runs, or whether getopt stops +/// reading options at the first operand, as BSD and `POSIXLY_CORRECT` make it, so it counts each +/// file any of them would read as one it reads. A word that expands may split into any words, so +/// it leaves what the reader reads unknown wherever it stands, as does `find -files0-from`, which +/// reads the paths to search from a file. +pub(super) fn reader_args(name: &str, values: &[Option<&str>]) -> ReaderArgs { + let grammar = reader_grammar(name); + let mut args = ReaderArgs { + fixed_values: (0..values.len()).collect(), + operands: (0..values.len()).collect(), + unknown: values.iter().any(Option::is_none) + || name == "find" && values.contains(&Some("-files0-from")), + ..ReaderArgs::default() + }; + let orders: &[bool] = if grammar.getopt { + &[true, false] + } else { + &[true] + }; + for dialect in [Dialect::Gnu, Dialect::Bsd] { + for &permute in orders { + let read = grammar.read(dialect, permute, values); + for word in read.script.words { + if !args.script.words.contains(&word) { + args.script.words.push(word); + } + } + args.script.from_file |= read.script.from_file; + args.script.in_place |= read.script.in_place; + args.script.follows_links |= read.script.follows_links; + args.script.backups.extend(read.script.backups); + args.fixed_values + .retain(|index| read.option_values.contains(index)); + args.operands.retain(|index| read.operands.contains(index)); + args.opened.extend(read.opened); + args.searches_cwd |= read.searches_cwd; + args.files.extend(read.files); + args.option_values.extend(read.option_values); + args.unknown |= read.unknown; + } + } + args.files.sort_unstable(); + args.files.dedup_by(|next, current| { + if next.0 != current.0 || next.1.path != current.1.path { + return false; + } + current.1.recursive |= next.1.recursive; + true + }); + args +} + +/// A build of the readers, whose option parsing and `sed` commands can differ. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Dialect { + /// GNU, as on Linux. + Gnu, + /// BSD, as macOS ships. + Bsd, +} + +/// A reader's option grammar, matching both its GNU and its BSD build. It lists the options that +/// take a value in either build, so a missing one reads its value as a file, which can only make +/// the gate more cautious. It also lists, as a [`switch`], each option whose full name abbreviates +/// a listed one, since getopt takes an exact name before a prefix. An option one build rejects may +/// stay listed, since the reader then exits before reading. +struct ReaderGrammar { + options: &'static [ReaderOpt], + /// Whether it reads its options with getopt, which may stop at the first operand. + getopt: bool, + /// Whether `-NUM` sets a count, as in `tail -20`. + numeric: bool, + /// Whether the first operand is a pattern or script unless an option gives one. + pattern: bool, + operands: OperandKind, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum OperandKind { + Stream, + Directory, + Find, +} + +impl ReaderGrammar { + const NONE: Self = Self { + options: &[], + getopt: true, + numeric: false, + pattern: false, + operands: OperandKind::Stream, + }; + + /// The files the `dialect` build reads from `values`, taking options after operands until `--` + /// when it may `permute` them. A short option that takes a value takes the rest of its cluster + /// or the next word, and a long one its `=` value or the next word. A long option the grammar + /// does not list counts its `=` value as a file, and since it may be one that gives the + /// pattern, the leading operand then counts as a file too. + fn read(&self, dialect: Dialect, permute: bool, values: &[Option<&str>]) -> ReaderArgs { + let mut args = ReaderArgs::default(); + let mut operands = Vec::new(); + let mut pattern_given = false; + let mut recursive = self.operands == OperandKind::Find; + let mut take = |args: &mut ReaderArgs, role: Role, index: usize, value: Option<&str>| { + pattern_given |= matches!(role, Role::Pattern | Role::PatternFile); + args.script.from_file |= matches!(role, Role::PatternFile); + args.script.in_place |= matches!(role, Role::InPlace); + args.script.follows_links |= matches!(role, Role::FollowsLinks); + if matches!(role, Role::InPlace) + && value != Some("") + && (value.is_some() || values[index].is_none()) + { + args.script.backups.push(Backup { + dialect, + index, + suffix: value.map(str::to_owned), + }); + } + if matches!(role, Role::Pattern) { + args.script.words.push(ScriptWord { + dialect, + index, + value: value.map(str::to_owned), + }); + } + recursive |= role.recurses(value); + if value.is_some() { + args.option_values.push(index); + } + args.unknown |= matches!(role, Role::FileList); + match (role, value) { + (Role::File | Role::PatternFile | Role::Directory | Role::FileList, Some(file)) + if !file.is_empty() => + { + args.files.push(( + Some(index), + ReaderPath { + path: file.to_owned(), + recursive: matches!(role, Role::Directory), + }, + )); + } + _ => {} + } + }; + let mut unplaced = false; + let mut i = 0; + let mut options = true; + while let Some(&value) = values.get(i) { + let index = i; + i += 1; + let Some(arg) = value.filter(|arg| options && arg.starts_with('-') && *arg != "-") + else { + options &= permute; + if value != Some("") { + operands.push((index, value)); + } + continue; + }; + if arg == "--" { + options = false; + } else if let Some(long) = arg.strip_prefix("--") { + let (name, attached) = long + .split_once('=') + .map_or((long, None), |(name, value)| (name, Some(value))); + let Some(option) = self.long(name) else { + unplaced = true; + take(&mut args, Role::File, index, attached); + continue; + }; + let (index, value) = option.argument(dialect, index, attached, values, &mut i); + take(&mut args, option.role, index, value); + } else { + let cluster = &arg[1..]; + if self.numeric && cluster.starts_with(|c: char| c.is_ascii_digit()) { + continue; + } + for (offset, c) in cluster.char_indices() { + let Some(option) = self.short(c) else { + continue; + }; + if option.value(dialect) == Value::None { + take(&mut args, option.role, index, None); + continue; + } + let rest = &cluster[offset + c.len_utf8()..]; + let attached = (!rest.is_empty()).then_some(rest); + let (index, value) = option.argument(dialect, index, attached, values, &mut i); + take(&mut args, option.role, index, value); + break; + } + } + } + let leading_pattern = self.pattern && !pattern_given && !unplaced; + self.place_operands(args, dialect, values, &operands, leading_pattern, recursive) + } + + /// Completes `args` with the `operands` [`Self::read`] found among `values`, the first of them + /// the pattern or script when `leading_pattern` holds, and each a tree when `recursive` does. + fn place_operands( + &self, + mut args: ReaderArgs, + dialect: Dialect, + values: &[Option<&str>], + operands: &[(usize, Option<&str>)], + leading_pattern: bool, + recursive: bool, + ) -> ReaderArgs { + let skip = usize::from(leading_pattern); + if let Some(&(index, value)) = operands.first().filter(|_| leading_pattern) { + args.script.words.push(ScriptWord { + dialect, + index, + value: value.map(str::to_owned), + }); + } + let tree_given = args.files.iter().any(|(_, path)| path.recursive); + let paths = self.operand_paths( + values, + operands.iter().copied().skip(skip), + recursive, + tree_given, + ); + args.operands + .extend(paths.iter().filter_map(|(index, _)| *index)); + args.files.extend(paths); + let tree_end = self.tree_end(values); + args.opened = args + .files + .iter() + .filter_map(|(index, _)| index.filter(|index| *index < tree_end)) + .collect(); + args.searches_cwd = args.files.iter().any(|(index, _)| index.is_none()); + args.fixed_values.clone_from(&args.option_values); + args + } + + /// Where the trees end and, for `find`, its expression starts, whose operands `find` never + /// opens, such as the value of `-name`. + fn tree_end(&self, values: &[Option<&str>]) -> usize { + if self.operands == OperandKind::Find { + values + .iter() + .position(|value| value.is_some_and(|value| self.find_expression(value))) + .unwrap_or(values.len()) + } else { + values.len() + } + } + + fn may_recurse(&self, values: &[Option<&str>], ambiguous: usize, args: &ReaderArgs) -> bool { + if !args.options_at(values, ambiguous) { + return false; + } + let recurses = |values: &[Option<&str>]| { + [Dialect::Gnu, Dialect::Bsd].into_iter().any(|dialect| { + self.read(dialect, true, values) + .files + .iter() + .any(|(_, path)| path.recursive) + }) + }; + for (index, value) in values.iter().enumerate() { + if index < ambiguous && args.fixed_values.contains(&index) { + continue; + } + let next = if index + 1 < ambiguous { + values[index + 1] + } else { + values[ambiguous..] + .iter() + .copied() + .flatten() + .find(|value| !value.is_empty() && "recurse".starts_with(value)) + }; + let probe = [*value, next]; + let probe = if next.is_some() { + &probe[..] + } else { + &probe[..1] + }; + if recurses(probe) { + return true; + } + } + false + } + + fn operand_paths<'a>( + &self, + values: &[Option<&str>], + operands: impl Iterator)>, + recursive: bool, + tree_given: bool, + ) -> Vec<(Option, ReaderPath)> { + let operands: Vec<_> = operands.collect(); + let has_operand = !operands.is_empty(); + let tree_end = self.tree_end(values); + let stdin = self.operands == OperandKind::Stream; + let mut files: Vec<_> = operands + .into_iter() + .filter_map(move |(index, operand)| { + let path = operand?; + if stdin && path == "-" { + return None; + } + Some(( + Some(index), + ReaderPath { + path: path.to_owned(), + recursive: recursive && index < tree_end, + }, + )) + }) + .collect(); + if recursive + && (self.operands == OperandKind::Find || !has_operand) + && !tree_given + && !files.iter().any(|(_, path)| path.recursive) + { + files.push(( + None, + ReaderPath { + path: ".".to_owned(), + recursive: true, + }, + )); + } + files + } + + fn find_expression(&self, arg: &str) -> bool { + if matches!(arg, "(" | "!") { + return true; + } + let Some(cluster) = arg.strip_prefix('-').filter(|_| arg != "--") else { + return false; + }; + for c in cluster.chars() { + let Some(option) = self.short(c) else { + return true; + }; + if option.gnu != Value::None || option.bsd != Value::None { + return false; + } + } + false + } + + /// The long option `--name`, or the one listed option `name` abbreviates, as getopt accepts. An + /// abbreviation getopt finds ambiguous among options the grammar leaves out makes the reader + /// exit before reading. + fn long(&self, name: &str) -> Option<&ReaderOpt> { + let longs = || { + self.options + .iter() + .filter_map(|option| Some((option.name.strip_prefix("--")?, option))) + }; + if let Some((_, option)) = longs().find(|(long, _)| *long == name) { + return Some(option); + } + let mut abbreviated = longs().filter(|(long, _)| long.starts_with(name)); + match (abbreviated.next(), abbreviated.next()) { + (Some((_, option)), None) => Some(option), + _ => None, + } + } + + /// The single-letter option `-c`. + fn short(&self, c: char) -> Option<&ReaderOpt> { + self.options.iter().find(|option| { + option + .name + .strip_prefix('-') + .is_some_and(|letter| letter.chars().eq([c])) + }) + } +} + +/// The option grammar of reader `name`. Every operand of `find`'s expression counts as a file. +fn reader_grammar(name: &str) -> &'static ReaderGrammar { + match name { + "find" => &FIND_GRAMMAR, + "grep" => &GREP_GRAMMAR, + "head" => &HEAD_GRAMMAR, + "ls" => &LS_GRAMMAR, + "sed" => &SED_GRAMMAR, + "tail" => &TAIL_GRAMMAR, + "wc" => &WC_GRAMMAR, + _ => &ReaderGrammar::NONE, + } +} + +const FIND_GRAMMAR: ReaderGrammar = ReaderGrammar { + options: &[ + takes("-D"), + switch("-E"), + switch("-H"), + switch("-L"), + takes("-O"), + switch("-P"), + switch("-X"), + switch("-d"), + takes("-f").with(Role::Directory), + switch("-s"), + switch("-x"), + ], + getopt: false, + operands: OperandKind::Find, + ..ReaderGrammar::NONE +}; + +const GREP_GRAMMAR: ReaderGrammar = ReaderGrammar { + options: &[ + takes("-A"), + takes("-B"), + takes("-C"), + takes("-D"), + takes("-d").with(Role::Directories), + takes("-e").with(Role::Pattern), + takes("-f").with(Role::PatternFile), + takes("-m"), + switch("-r").with(Role::Recursive), + switch("-R").with(Role::Recursive), + takes("--after-context"), + takes("--before-context"), + takes("--binary-files"), + switch("--binary"), + attached("--color"), + attached("--colour"), + takes("--context").bsd(Value::Optional), + takes("--devices"), + switch("--dereference-recursive").with(Role::Recursive), + takes("--directories").with(Role::Directories), + takes("--exclude"), + takes("--exclude-dir"), + takes("--exclude-from").with(Role::File), + takes("--file").with(Role::PatternFile), + takes("--group-separator"), + takes("--include"), + takes("--include-dir"), + takes("--label"), + takes("--max-count"), + takes("--regexp").with(Role::Pattern), + switch("--recursive").with(Role::Recursive), + ], + numeric: true, + pattern: true, + ..ReaderGrammar::NONE +}; + +const HEAD_GRAMMAR: ReaderGrammar = ReaderGrammar { + options: &[takes("-c"), takes("-n"), takes("--bytes"), takes("--lines")], + numeric: true, + ..ReaderGrammar::NONE +}; + +const LS_GRAMMAR: ReaderGrammar = ReaderGrammar { + options: &[ + takes("-D").bsd_only(), + takes("-I").gnu_only(), + switch("-R").with(Role::Recursive), + takes("-T").gnu_only(), + takes("-w").gnu_only(), + takes("--block-size"), + attached("--color"), + takes("--format"), + takes("--hide"), + attached("--hyperlink"), + takes("--ignore"), + takes("--indicator-style"), + takes("--quoting-style"), + switch("--recursive").with(Role::Recursive), + takes("--sort"), + takes("--tabsize"), + takes("--time"), + takes("--time-style"), + takes("--width"), + ], + operands: OperandKind::Directory, + ..ReaderGrammar::NONE +}; + +// Mirrors GNU sed 4.10, listing every long option it takes. +const SED_GRAMMAR: ReaderGrammar = ReaderGrammar { + options: &[ + takes("-e").with(Role::Pattern), + takes("-f").with(Role::PatternFile), + takes("-I").with(Role::InPlace), + attached("-i").bsd(Value::Required).with(Role::InPlace), + takes("-l").gnu_only(), + switch("--binary"), + switch("--debug"), + takes("--expression").with(Role::Pattern), + takes("--file").with(Role::PatternFile), + switch("--follow-symlinks").with(Role::FollowsLinks), + switch("--help"), + attached("--in-place").with(Role::InPlace), + takes("--line-length"), + switch("--null-data"), + switch("--posix"), + switch("--quiet"), + switch("--regexp-extended"), + switch("--sandbox"), + switch("--separate"), + switch("--silent"), + switch("--unbuffered"), + switch("--version"), + switch("--zero-terminated"), + ], + pattern: true, + ..ReaderGrammar::NONE +}; + +const TAIL_GRAMMAR: ReaderGrammar = ReaderGrammar { + options: &[ + takes("-b"), + takes("-c"), + takes("-n"), + takes("-s"), + takes("--blocks"), + takes("--bytes"), + attached("--follow"), + takes("--lines"), + takes("--max-unchanged-stats"), + takes("--pid"), + takes("--sleep-interval"), + ], + numeric: true, + ..ReaderGrammar::NONE +}; + +const WC_GRAMMAR: ReaderGrammar = ReaderGrammar { + options: &[ + takes("--files0-from").with(Role::FileList), + takes("--libxo"), + takes("--total"), + ], + ..ReaderGrammar::NONE +}; + +/// One reader option, which takes a value in either build unless it is a [`switch`]. +#[derive(Clone, Copy)] +struct ReaderOpt { + name: &'static str, + /// What the GNU build takes: [`Value::Required`] for a value attached or in the next word, + /// [`Value::Optional`] for one only attached, or [`Value::None`] for none. + gnu: Value, + /// What the BSD build takes, in the same terms. + bsd: Value, + role: Role, +} + +impl ReaderOpt { + const fn with(self, role: Role) -> Self { + Self { role, ..self } + } + + /// This option taking `value` in the BSD build. + const fn bsd(self, value: Value) -> Self { + Self { bsd: value, ..self } + } + + /// This option as a flag in the BSD build. + const fn gnu_only(self) -> Self { + self.bsd(Value::None) + } + + /// This option as a flag in the GNU build. + const fn bsd_only(self) -> Self { + Self { + gnu: Value::None, + ..self + } + } + + const fn value(&self, dialect: Dialect) -> Value { + match dialect { + Dialect::Gnu => self.gnu, + Dialect::Bsd => self.bsd, + } + } + + fn argument<'a>( + &self, + dialect: Dialect, + index: usize, + attached: Option<&'a str>, + values: &[Option<&'a str>], + next: &mut usize, + ) -> (usize, Option<&'a str>) { + match (self.value(dialect), attached) { + (Value::None, _) | (Value::Optional, None) => (index, None), + (_, Some(value)) => (index, Some(value)), + (Value::Required, None) => match values.get(*next) { + Some(&value) => { + let index = *next; + *next += 1; + (index, value) + } + None => (index, None), + }, + } + } +} + +const fn takes(name: &'static str) -> ReaderOpt { + ReaderOpt { + name, + gnu: Value::Required, + bsd: Value::Required, + role: Role::Value, + } +} + +const fn attached(name: &'static str) -> ReaderOpt { + ReaderOpt { + gnu: Value::Optional, + bsd: Value::Optional, + ..takes(name) + } +} + +const fn switch(name: &'static str) -> ReaderOpt { + ReaderOpt { + gnu: Value::None, + bsd: Value::None, + ..takes(name) + } +} + +/// What a reader option's value names. +#[derive(Clone, Copy)] +enum Role { + /// Neither a file nor the pattern, such as a count. + Value, + /// A file it reads. + File, + /// The pattern or script, so no operand is one. + Pattern, + /// A file it reads the pattern or script from, so no operand is one. + PatternFile, + /// A file that lists the files it reads. + FileList, + /// A suffix for the backup of each file it rewrites in place, given or not. + InPlace, + /// A flag that rewrites a symlink's target in place. + FollowsLinks, + /// A directory supplied through a `find -f` option. + Directory, + /// A flag that recursively traverses directory operands. + Recursive, + /// A value selecting whether `grep` traverses directories. + Directories, +} + +impl Role { + fn recurses(self, value: Option<&str>) -> bool { + match self { + Self::Recursive => true, + Self::Directories => { + value.is_some_and(|value| !value.is_empty() && "recurse".starts_with(value)) + } + _ => false, + } + } +} + +/// The filesystem paths matching `pattern` in `cwd`, including brace alternatives. Each component +/// with `*`, `?`, `[`, or `{` matches its directory's entries, skipping dot names unless the +/// component starts with `.` or includes a brace. This does not emit unmatched lexical brace words. +/// `None` when one name matches more than [`MAX_GLOB_MATCHES`] files, fails to compile, or meets a +/// file whose name is not UTF-8. +fn expand_glob(pattern: &str, cwd: &Path) -> Option> { + let mut paths = vec![PathBuf::new()]; + for component in Path::new(pattern).components() { + let name = component.as_os_str().to_string_lossy(); + if !matches!(component, Component::Normal(_)) || !name.contains(['*', '?', '[', '{']) { + for path in &mut paths { + path.push(component); + } + continue; + } + let glob = globset::GlobBuilder::new(&name) + .literal_separator(true) + .build() + .ok()? + .compile_matcher(); + let dotted = name.starts_with('.') || name.contains('{'); + let mut matched = Vec::new(); + for dir in &paths { + let Ok(entries) = std::fs::read_dir(cwd.join(dir)) else { + continue; + }; + for entry in entries.flatten() { + let entry_name = entry.file_name(); + let entry_name = entry_name.to_str()?; + if (dotted || !entry_name.starts_with('.')) && glob.is_match(entry_name) { + matched.push(dir.join(entry_name)); + if matched.len() > MAX_GLOB_MATCHES { + return None; + } + } + } + } + paths = matched; + } + Some( + paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::permission::Workspace; + use crate::permission::shell::CommandTarget; + use crate::permission::shell::local_testing::{is_safe, parse, resolve}; + use crate::util::path::local_testing::canonical_tempdir; + use crate::util::path::slash_separated; + + mod safe_operands { + use super::*; + + #[test] + fn names_every_word_that_may_be_a_file() { + let command = CommandTarget::parse( + "grep -fpats --file=more -e x -- -y src", + &Workspace::new(Path::new("/repo")), + ); + let at = |name: &str| resolve(name, &Workspace::new(Path::new("/repo"))); + assert!(command.segments[0].is_safe()); + assert_eq!(command.reads, [at("pats"), at("more"), at("-y"), at("src")]); + assert!(!is_safe("wc --files0-from=list")); + let echo = parse("echo done"); + assert!(echo.segments[0].is_safe()); + assert_eq!(echo.reads, []); + } + + #[test] + fn admits_only_shipped_commands_with_literal_arguments() { + for command in [ + "cat a.rs", + "head -n 5 a", + "ls -la src", + "wc -l a", + "pwd", + "true", + "echo 'a $b'", + ] { + assert!(is_safe(command), "{command:?}"); + } + for command in [ + "cat *.rs", + "cat $F", + "cat {a,b}", + "echo $HOME *", + "echo $TOKEN", + "echo \"${TOKEN}\"", + "echo *", + "rm a", + "./cat a", + "git status", + ] { + assert!(!is_safe(command), "{command:?}"); + } + } + + #[test] + fn admits_builtins_that_only_print_test_files_or_trace() { + let at = + |name: &str| ReaderPath::exact(resolve(name, &Workspace::new(Path::new("/repo")))); + for (command, reads) in [ + (": done", &[][..]), + ("printf '%s\\n' a", &[]), + ("export RUST_LOG=debug", &[]), + ("set -eux -o pipefail", &[]), + ("[ -f Cargo.toml ]", &[at("Cargo.toml")]), + ("test ! -d /etc -a x = y", &[at("/etc"), at("x"), at("y")]), + ] { + let parsed = parse(command); + + assert!(parsed.segments[0].is_safe(), "{command:?}"); + assert_eq!(parsed.segments[0].reads(), reads, "{command:?}"); + assert!(parsed.unrecognized().is_none(), "{command:?}"); + } + for command in ["set -o posix", "set -a", "[ -f $F ]"] { + assert!(!is_safe(command), "{command:?}"); + } + } + + #[test] + fn guards_find_and_grep() { + assert!(is_safe("find . -name '*.rs' -type f")); + for action in FIND_ACTIONS { + assert!(!is_safe(&format!("find . {action} x")), "{action}"); + } + assert!(is_safe("grep -n TODO src/main.rs")); + for command in [ + "grep -r TODO .", + "grep -Rn TODO .", + "grep --recursive TODO .", + "grep -d recurse TODO .", + "grep --directories=recurse TODO .", + "grep --rec TODO .", + "grep --dir=rec TODO .", + "grep -d rec TODO .", + ] { + assert!(!is_safe(command), "{command:?}"); + } + } + } + + mod sed_files { + use super::*; + + #[test] + fn admits_scripts_that_only_print_or_rewrite_text() { + for command in [ + "sed -n '1,20p' a.rs", + "sed 's/foo/bar/g' a.rs", + "sed -e '/^$/d' -e 's/a\\/b/c/2' a.rs", + "sed --expression='$p' a.rs", + "sed --expression '$p' a.rs", + "sed --quiet 1p a.rs", + "sed -es/a/b/ a.rs", + "sed --exp=1p a.rs", + "sed 1p -- a.rs", + "sed -ne '3p;5q' a.rs", + "sed -n -- '1!G;h;$p' a.rs", + ] { + assert!(is_safe(command), "{command:?}"); + } + } + + #[test] + fn leaves_a_call_unrecognized_when_its_script_or_backup_is_unbounded() { + for command in [ + "sed -i $X s/a/b/ a.rs", + "sed -I \"$X\" s/a/b/ a.rs", + "sed -i bk/* s/a/b/ a.rs", + "sed --follow-symlinks -i.bak s/a/b/ a.rs", + "sed -e \"$S\" a.rs", + "sed k a.rs", + ] { + assert!(parse(command).unrecognized().is_some(), "{command:?}"); + } + for command in [ + "sed -i.bak s/a/b/ a.rs", + "sed -i 'bk/*' s/a/b/ a.rs", + "sed --follow-symlinks -i s/a/b/ a.rs", + "sed -i s/a/b/ README.md", + ] { + assert!(parse(command).unrecognized().is_none(), "{command:?}"); + } + } + + #[test] + fn refuses_in_place_edits_script_files_and_writing_or_running_commands() { + for command in [ + "sed -i 's/a/b/' a.rs", + "sed -I '' 's/a/b/' a.rs", + "sed --in 's/a/b/' a.rs", + "sed --fi=script.sed a.rs", + "sed --exp='w out' p a.rs", + "sed 'w out' -e p a.rs", + "sed -ni 'p' a.rs", + "sed --in-place=.bak 's/a/b/' a.rs", + "sed -f script.sed a.rs", + "sed --file=script.sed a.rs", + "sed 's/a/b/w out' a.rs", + "sed 's/a/b/e' a.rs", + "sed -n 'w out' a.rs", + "sed '1r /etc/passwd' a.rs", + "sed 'e date' a.rs", + "sed -e", + "sed --expression", + "sed", + ] { + assert!(!is_safe(command), "{command:?}"); + } + } + + #[test] + fn reads_the_files_past_the_script() { + for command in ["sed -n 1p a.rs", "sed -e 1p -- a.rs"] { + let parsed = parse(command); + + assert!(parsed.segments[0].is_safe(), "{command:?}"); + assert_eq!( + parsed.reads, + [resolve("a.rs", &Workspace::new(Path::new("/repo")))], + "{command:?}" + ); + } + } + } + + mod sed_script { + use super::*; + + #[test] + fn names_the_files_a_script_reads_and_writes_and_whether_it_runs_a_command() { + let effects = |reads: &[&str], writes: &[&str], runs| SedScript { + reads: reads.iter().map(|&path| path.to_owned()).collect(), + writes: writes.iter().map(|&path| path.to_owned()).collect(), + runs, + }; + for (script, expected) in [ + ("p", effects(&[], &[], false)), + ("$!{N;s/\\n/ /};/^#/d", effects(&[], &[], false)), + ("2,+3p;3,~4d;\\,x,Ip;0~2p", effects(&[], &[], false)), + ("1,5d", effects(&[], &[], false)), + (":a;N;ba", effects(&[], &[], false)), + ("l 40;$q5", effects(&[], &[], false)), + ("y/abc/xyz/", effects(&[], &[], false)), + ("a\\\ntext r x\n1p", effects(&[], &[], false)), + ("# r x\np", effects(&[], &[], false)), + ("1r /etc/hosts", effects(&["/etc/hosts"], &[], false)), + ("/x/ R notes\n$w out", effects(&["notes"], &["out"], false)), + ("/a/,/b/w out", effects(&[], &["out"], false)), + ("0~2 W even", effects(&[], &["even"], false)), + ("s/a/b/gw log", effects(&[], &["log"], false)), + ("s|a|b|e", effects(&[], &[], true)), + ("1e date", effects(&[], &[], true)), + ] { + assert_eq!(sed_script(script, Dialect::Gnu), Ok(expected), "{script:?}"); + } + } + + #[test] + fn takes_only_the_commands_bsd_sed_has_for_a_bsd_script() { + let expected = SedScript { + reads: vec!["in".to_owned()], + writes: vec!["out".to_owned(), "log".to_owned()], + runs: false, + }; + + assert_eq!( + sed_script("1r in\n$w out\ns/a/b/w log", Dialect::Bsd), + Ok(expected) + ); + for script in [ + "R in", + "W out", + "1e date", + "s/a/b/e", + "F", + "z", + "Q", + "README.md", + ] { + assert_eq!(sed_script(script, Dialect::Bsd), Err(vec![]), "{script:?}"); + } + } + + #[test] + fn refuses_a_script_it_cannot_read_keeping_the_files_it_writes_before() { + for (script, writes) in [ + ("k", &[][..]), + ("s/a/b", &[]), + ("r", &[]), + ("1r ", &[]), + ("pq", &[]), + ("s/a/b/x", &[]), + ("/x", &[]), + ("1,p", &[]), + ("w locked\nk", &["locked"]), + ("1r in\ns/a/b/w log\n$k\nw out", &["log"]), + ] { + let writes = writes.iter().map(|&path| path.to_owned()).collect(); + + assert_eq!(sed_script(script, Dialect::Gnu), Err(writes), "{script:?}"); + } + } + } + + mod reader_operands { + use super::*; + + #[test] + fn flag_a_reader_word_whose_files_are_unknown() { + for command in [ + "cat $F", + r#"cat "$X/.ssh/id_rsa""#, + "/bin/cat $F", + "grep -r x $DIR", + "head -n $N a", + "cat x[", + ] { + assert!(parse(command).segments[0].reads_anything(), "{command:?}"); + } + for command in ["cat a", "cat *.rs", "echo $F", "cp $F b"] { + assert!(!parse(command).segments[0].reads_anything(), "{command:?}"); + } + } + + #[test] + fn flag_a_read_after_a_change_the_walk_cannot_spell() { + for (command, anything) in [ + ("cd $D; cat a", true), + ("HOME=d cd; cat a", true), + ("cd; cat a", false), + ("pushd -n $D; cat ~1/a", true), + ("pushd -n $D; cat a", false), + ("pushd -n src; cat ~1/a", false), + ("pushd -n +1; cat ~1/a", false), + ] { + let parsed = parse(command); + + let last = parsed.segments.last().unwrap(); + + assert_eq!(last.reads_anything(), anything, "{command:?}"); + } + } + + #[test] + fn keeps_recursive_roots_for_each_ambiguous_expansion_order() { + for (reader, written, expanded, start, end, source, root) in [ + ( + "grep", + "-e PRIVATE -* pats", + "-e PRIVATE -f -R pats", + 2, + 4, + 2, + ".", + ), + ( + "grep", + "-e PRIVATE -* pats", + "-e PRIVATE -R -f pats", + 2, + 4, + 2, + ".", + ), + ( + "ls", + "-D vault -* 80", + "-D vault -w -R 80", + 2, + 4, + 2, + "vault", + ), + ( + "ls", + "-D vault -* 80", + "-D vault -R -w 80", + 2, + 4, + 2, + "vault", + ), + ("find", "* )", "vault ( -name )", 0, 3, 0, "."), + ("find", "* )", "( -name vault )", 0, 3, 0, "."), + ( + "ls", + "--format *", + "--format -R long vault", + 1, + 4, + 1, + "vault", + ), + ( + "ls", + "--format *", + "--format long -R vault", + 1, + 4, + 1, + "vault", + ), + ( + "grep", + "-r -e needle -? patterns/* src", + "-r -e needle -f patterns/z-vault patterns/a-patterns src", + 4, + 6, + 4, + "patterns/z-vault", + ), + ( + "grep", + "-r -e needle -? patterns/* src", + "-r -e needle -f patterns/a-patterns patterns/z-vault src", + 4, + 6, + 4, + "patterns/z-vault", + ), + ( + "grep", + "-e PRIVATE -[f] pats -* more", + "-e PRIVATE -f pats -f -R more", + 4, + 6, + 4, + ".", + ), + ] { + let written: Vec<_> = written.split_whitespace().map(Some).collect(); + let expanded: Vec<_> = expanded.split_whitespace().map(Some).collect(); + let args = reader_args(reader, &written); + + let origins: Vec<_> = (0..expanded.len()) + .map(|index| match index { + _ if index < start => Some(index), + _ if index < end => None, + _ => Some(index + source + 1 - end), + }) + .collect(); + + let (paths, unknown) = expanded_reader_paths( + reader, + &args, + &expanded, + &origins, + &[(start..end, source)], + ); + + assert!(unknown, "{reader} {expanded:?}"); + assert!( + paths.iter().any(|operand| operand.read + == ReaderPath { + path: root.to_owned(), + recursive: true, + }), + "{reader} {expanded:?}: {paths:?}" + ); + } + } + + #[test] + fn read_each_literal_operand_and_each_file_a_glob_matches() { + let cwd_dir = canonical_tempdir(); + let cwd = cwd_dir.path(); + std::fs::create_dir(cwd.join("src")).unwrap(); + std::fs::write(cwd.join("src/main.rs"), "").unwrap(); + let workspace = Workspace::new(cwd); + + let command = CommandTarget::parse("grep -r x src/*.rs docs", &workspace); + + let segment = &command.segments[0]; + assert!(!segment.is_safe()); + let at = |name: &str| resolve(name, &workspace); + let tree = |name: &str| ReaderPath { + path: at(name), + recursive: true, + }; + assert_eq!(segment.reads(), [tree("docs"), tree("src/*.rs")]); + let main = Path::new("src").join("main.rs"); + assert_eq!(command.reads, [at("docs"), at(&main.to_string_lossy())]); + } + } + + mod reader_args { + use super::*; + + // cspell:ignore drecurse rfpatterns + #[test] + fn distinguishes_recursive_operands_from_exact_auxiliary_files() { + for (reader, args, expected) in [ + ( + "grep", + "-r -f patterns src", + &[("patterns", false), ("src", true)][..], + ), + ( + "grep", + "-R --exclude-from=skip -e pat src", + &[("skip", false), ("src", true)], + ), + ("grep", "-d recurse pat src", &[("src", true)]), + ("grep", "-drecurse pat src", &[("src", true)]), + ("grep", "--directories=rec pat src", &[("src", true)]), + ("grep", "--dereference-rec pat src", &[("src", true)]), + ("grep", "--rec pat src", &[("src", true)]), + ("grep", "pat src -r", &[("-r", false), ("src", true)]), + ("grep", "-d skip pat src", &[("src", false)]), + ("grep", "-e -r src", &[("src", false)]), + ("grep", "-r pat -", &[]), + ("grep", "-r pat", &[(".", true)]), + ("grep", "-rfpatterns", &[(".", true), ("patterns", false)]), + ("grep", "-- -r src", &[("src", false)]), + ("ls", "-laR src", &[("src", true)]), + ("ls", "--rec", &[(".", true)]), + ("ls", "-- -R", &[("-R", false)]), + ( + "find", + "-L src -name needle", + &[("needle", false), ("src", true)], + ), + ("find", "-name needle", &[(".", true), ("needle", false)]), + ( + "find", + "-HL src -name needle", + &[("needle", false), ("src", true)], + ), + ("find", "-f src", &[("src", true)]), + ("find", "-D search -O3 src", &[("src", true)]), + ("find", "", &[(".", true)]), + ] { + let values: Vec<_> = args.split_whitespace().map(Some).collect(); + + let read = reader_args(reader, &values); + + let mut actual: Vec<_> = read + .files + .iter() + .map(|(_, file)| (file.path.as_str(), file.recursive)) + .collect(); + actual.sort_unstable(); + actual.dedup(); + assert_eq!(actual, expected, "{reader} {args}"); + assert!(!read.unknown, "{reader} {args}"); + } + } + + /// The files `reader` reads from the literal words of `args`. + fn files(reader: &str, args: &str) -> Vec { + let values: Vec> = args.split(' ').map(Some).collect(); + let read = reader_args(reader, &values); + assert!(!read.unknown, "{reader} {args}"); + read.files.into_iter().map(|(_, file)| file.path).collect() + } + + #[test] + fn reads_option_values_as_values() { + for (reader, args) in [ + ("tail", "-20 x"), + ("tail", "-n 20 x"), + ("tail", "-n20 x"), + ("tail", "--lines=20 x"), + ("tail", "--lines 20 x"), + ("tail", "-fn 20 x"), + ("head", "-c 5 x"), + ("head", "-5 x"), + ("grep", "-A 5 pat x"), + ("grep", "-nA5 pat x"), + ("grep", "-3 pat x"), + ("grep", "--color=auto -m 1 pat x"), + ("grep", "-e pat x"), + ("grep", "--regexp=pat -- x"), + ("grep", "--reg=pat x"), + ("grep", "--regex pat x"), + ("grep", "--binary pat x"), + ("grep", "--exclude=*.md pat x"), + ("tail", "--lin 20 x"), + ("ls", "--sort=size x"), + ("sed", "-i.bak s/a/b/ x"), + ("wc", "-l --total=never x"), + ("cat", "-n - x"), + ] { + assert_eq!(files(reader, args), ["x"], "{reader} {args}"); + } + } + + #[test] + fn counts_each_file_a_gnu_or_bsd_build_reads_in_either_option_order() { + for (reader, args, expected) in [ + ("ls", "-I target x", &["target", "x"][..]), + ("ls", "-D %s x", &["%s", "x"]), + ("sed", "-n -l 80 1p x", &["1p", "x"]), + ("sed", "-i .bak s/a/b/ x", &["s/a/b/", "x"]), + ("grep", "--context 3 pat x", &["pat", "x"]), + ("tail", "x -n 5", &["x", "-n", "5"]), + ("grep", "pat x -e y", &["pat", "x", "-e", "y"]), + ("cat", "x -n", &["x", "-n"]), + ] { + assert_eq!(files(reader, args), expected, "{reader} {args}"); + } + } + + #[test] + fn counts_a_value_that_names_a_file_and_every_operand_after_a_given_pattern() { + for (reader, args, expected) in [ + ("grep", "-f patterns.txt x", &["patterns.txt", "x"][..]), + ("grep", "-fpats --file more y", &["pats", "more", "y"]), + ("grep", "--exclude-from=skip pat x", &["skip", "x"]), + ("sed", "-f script.sed x", &["script.sed", "x"]), + ("find", ". -name x", &[".", "x"]), + ] { + assert_eq!(files(reader, args), expected, "{reader} {args}"); + } + } + + #[test] + fn reads_an_option_it_cannot_place_as_naming_a_file() { + for (reader, args, expected) in [ + ("tail", "-z 20 x", &["20", "x"][..]), + ("grep", "--unknown=f pat x", &["f", "pat", "x"]), + ("grep", "--ex=a pat x", &["a", "pat", "x"]), + ("tail", "-n", &[]), + ] { + assert_eq!(files(reader, args), expected, "{reader} {args}"); + } + } + + #[test] + fn leaves_what_it_reads_unknown_for_a_file_list_or_a_word_that_expands() { + for (reader, values) in [ + ("wc", &[Some("--files0-from=list")][..]), + ("find", &[Some("-files0-from"), Some("list")]), + ("head", &[Some("-n"), None, Some("x")]), + ("grep", &[None, Some("x")]), + ] { + assert!(reader_args(reader, values).unknown, "{reader} {values:?}"); + } + } + } + + mod expand_glob { + use super::*; + + #[test] + fn lists_what_bash_would_and_skips_dot_names_unless_a_name_asks_for_them() { + let cwd_dir = canonical_tempdir(); + let cwd = cwd_dir.path(); + for dir in ["src/sub", ".ssh", "docs"] { + std::fs::create_dir_all(cwd.join(dir)).unwrap(); + } + for file in [ + "src/a.rs", + "src/b.rs", + "src/c.md", + "src/sub/d.rs", + ".ssh/id_rsa", + ".ssh/id_ed25519", + "docs/id_x", + ".env", + ] { + std::fs::write(cwd.join(file), "").unwrap(); + } + let expand = |pattern: &str| { + let mut paths: Vec = expand_glob(pattern, cwd) + .unwrap() + .iter() + .map(|path| slash_separated(Path::new(path))) + .collect(); + paths.sort(); + paths + }; + let root = slash_separated(cwd); + + assert_eq!(expand("src/*.rs"), ["src/a.rs", "src/b.rs"]); + assert_eq!(expand("./src/*/*.rs"), ["./src/sub/d.rs"]); + assert_eq!(expand("*/id_*"), ["docs/id_x"]); + assert_eq!(expand(".*/id_*"), [".ssh/id_ed25519", ".ssh/id_rsa"]); + assert_eq!( + expand("{.ssh,docs}/id_[er]*"), + [".ssh/id_ed25519", ".ssh/id_rsa"] + ); + assert_eq!(expand("?env"), [""; 0]); + assert_eq!(expand("missing/*"), [""; 0]); + assert_eq!( + expand(&format!("{root}/.ss?/id_rsa")), + [format!("{root}/.ssh/id_rsa")] + ); + } + + #[test] + fn gives_up_past_the_match_cap_or_on_a_pattern_globset_rejects() { + let cwd_dir = canonical_tempdir(); + let cwd = cwd_dir.path(); + for i in 0..MAX_GLOB_MATCHES { + std::fs::write(cwd.join(format!("{i}.txt")), "").unwrap(); + } + assert_eq!( + expand_glob("*.txt", cwd).map(|paths| paths.len()), + Some(MAX_GLOB_MATCHES) + ); + + std::fs::write(cwd.join("last.txt"), "").unwrap(); + + assert_eq!(expand_glob("*.txt", cwd), None); + assert_eq!(expand_glob("[", cwd), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn gives_up_on_a_file_name_that_is_not_utf8() { + use std::os::unix::ffi::OsStrExt as _; + let cwd_dir = canonical_tempdir(); + + let cwd = cwd_dir.path(); + std::fs::write(cwd.join(std::ffi::OsStr::from_bytes(b"a\xff")), "").unwrap(); + + assert_eq!(expand_glob("*", cwd), None); + } + } +} diff --git a/crates/ox-core/src/permission/shell/walk.rs b/crates/ox-core/src/permission/shell/walk.rs new file mode 100644 index 000000000..27c8a5f7c --- /dev/null +++ b/crates/ox-core/src/permission/shell/walk.rs @@ -0,0 +1,2160 @@ +//! Walks bash syntax to collect commands, reads, writes, and unresolved constructs. + +use std::path::{Component, Path, PathBuf}; +use std::{iter, mem}; + +use tree_sitter::{Node, Parser, Tree}; + +use super::invocation::{ + BASH_GRAMMAR, DIR_VARIABLES, HOME_VARIABLE, Inherited, POSIX_SHELL_GRAMMAR, SHELLS, ShellReads, + ShellRun, StartupVariable, ZSH_GRAMMAR, command_option_script, env_name, expanded_file, + reads_dir_variable, sets_dir_variable, shell_grammar, shell_run, sourced_file, su_args, +}; +use super::reader::{ + Dialect, READERS, SedScript, reader_args, reader_operands, safe_operands, sed_script, +}; +use super::word::{Word, assignment_word, expand_tilde, home, stack_tilde}; +use super::wrapper::{ + Assignments, DEFAULT_REPLACE, Fed, INPUT_WORD, Kind, Placeholder, RUNUSER, SAFE_VARIABLES, + Unwrapped, WRAPPERS, assigned, builtin_options, declared_name, is_local_name, literal_script, + may_test_a_subscript, sets_only_tracing_options, +}; +use super::{Operand, ReaderPath, Segment, ShellWord}; +use crate::permission::{PathResolver, PathTarget, Workspace, Zone, fold_name}; + +/// 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. +pub(super) const DIRECTORY_CHANGERS: &[&str] = &["cd", "popd", "pushd"]; + +/// 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. +pub(super) const MAX_CONSTRUCT_CHARS: usize = 40; + +/// `find` primaries that run a command or write a file. +pub(super) const FIND_ACTIONS: &[&str] = &[ + "-delete", "-exec", "-execdir", "-fls", "-fprint", "-fprint0", "-fprintf", "-ok", "-okdir", +]; + +/// `find` primaries that run the command after them. +const FIND_RUNNERS: &[&str] = &["-exec", "-execdir", "-ok", "-okdir"]; + +// ── 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. +pub(super) struct Walk<'a> { + pub(super) workspace: &'a Workspace, + pub(super) paths: PathResolver<'a>, + /// Whether the environment `bash` starts with sets a variable. + pub(super) inherited: &'a dyn Fn(&str) -> bool, + pub(super) segments: Vec, + pub(super) writes: Vec, + pub(super) reads: Vec, + /// The first directory change, as [`construct`] cuts it for a prompt. + pub(super) dir_change: Option, + pub(super) 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`. + pub(super) 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. + pub(super) leaves_workspace: bool, + /// How many loop bodies enclose the node the walk is at, where a directory change may run more + /// than once. + pub(super) repeats: usize, + /// Whether some redirection, reader operand, script that `source` or a shell runs, or program + /// spelled with a directory names a relative path. + pub(super) relative_path: bool, + /// Whether any part of the command expands a variable at run time, wherever the value ends up. + pub(super) expands: bool, + /// The first construct the walk does not recognize, as [`construct`] cuts it for a prompt. + pub(super) unrecognized: Option, +} + +/// The directories the `cd` and `pushd` commands of a command may have entered. +#[derive(Default)] +pub(super) 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, +} + +impl Walk<'_> { + pub(super) 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) + } + + /// 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); + } + } + } + + 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); + } + } + + /// 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; + } + + /// 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); + } + } + + /// 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); + } + } + } + + 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); + } + + /// 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 + .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); + } + + /// 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) + } + + /// `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 + } + + /// 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) + } + + /// 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); + } + + /// 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); + } + + /// 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), + } + } + } + + /// 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(); + } + _ => {} + } + } + 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 + .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); + } + } + } + (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 + } + 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); + } + 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); + } + } + } + + /// 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)); + } + } + (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, + } + } + pinned |= sets_unsafe || name != wrapper.name || wrapper.kind != Kind::Transparent; + if primary && !pinned { + unwrapped.named = at; + } + } + 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`. +pub(super) 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. +pub(super) 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. +pub(super) 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)] +pub(super) 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. + pub(super) 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. +pub(super) 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. +pub(super) 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, + } +} + +pub(super) 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`. +pub(super) 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() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use tree_sitter::Language; + + use super::*; + use crate::permission::shell::CommandTarget; + use crate::permission::shell::local_testing::{parse, resolve}; + use crate::util::path::local_testing::{abs, canonical_tempdir}; + + /// Each write's path relative to `cwd`, or as requested outside it. + fn writes(command: &str, cwd: &Path) -> Vec { + spelled(CommandTarget::parse(command, &Workspace::new(cwd)).writes) + } + + /// Each certain read's path relative to `cwd`, or as requested outside it. + fn reads(command: &str, cwd: &Path) -> Vec { + spelled(CommandTarget::parse(command, &Workspace::new(cwd)).reads) + } + + fn spelled(targets: Vec) -> Vec { + targets + .into_iter() + .map(|target| target.relative.unwrap_or(target.requested)) + .collect() + } + + mod visit { + + use super::*; + + #[test] + fn leaves_unrecognized_only_the_grammar_kinds_that_evaluate_arithmetic_or_define_a_function() + { + let language: Language = tree_sitter_bash::LANGUAGE.into(); + let kinds: BTreeSet<&str> = (0..language.node_kind_count()) + .filter_map(|id| u16::try_from(id).ok()) + .filter(|&id| language.node_kind_is_named(id) && language.node_kind_is_visible(id)) + .filter_map(|id| language.node_kind_for_id(id)) + .collect(); + let weighed = [ + "array", + "command", + "declaration_command", + "expansion", + "file_redirect", + "for_statement", + "redirected_statement", + "simple_expansion", + "test_command", + "unset_command", + "variable_assignment", + "while_statement", + ]; + + let unknown: Vec<&&str> = WALKED_KINDS + .iter() + .chain(&weighed) + .filter(|kind| !kinds.contains(**kind)) + .collect(); + let unrecognized: Vec<&str> = kinds + .into_iter() + .filter(|kind| !WALKED_KINDS.contains(kind) && !weighed.contains(kind)) + .collect(); + + assert!(unknown.is_empty(), "{unknown:?}"); + assert_eq!( + unrecognized, + [ + "arithmetic_expansion", + "binary_expression", + "c_style_for_statement", + "function_definition", + "parenthesized_expression", + "postfix_expression", + "subscript", + "ternary_expression", + "unary_expression", + ] + ); + } + + #[test] + fn withholds_safety_from_every_segment_once_anything_expands_a_variable() { + let safety = |command: &str| -> Vec { + parse(command) + .segments + .iter() + .map(Segment::is_safe) + .collect() + }; + for (literal, expanding) in [ + ("cat <<< 'key=$TOKEN'", "cat <<< \"key=$TOKEN\""), + ("cat <<< TOKEN", "cat <<< ${TOKEN}"), + ("cat <<'EOF'\n$TOKEN\nEOF", "cat < out.txt 2>> log &> all >| clobber >& both", cwd), + ["out.txt", "log", "all", "clobber", "both"] + ); + let outside = abs("/etc/hosts"); + assert_eq!( + CommandTarget::parse(&format!("echo a > {outside}"), &Workspace::new(cwd)).writes, + [resolve(&outside, &Workspace::new(cwd))] + ); + } + + #[test] + fn counts_each_file_an_input_redirection_reads() { + let cwd_dir = canonical_tempdir(); + let cwd = cwd_dir.path(); + + let target = + CommandTarget::parse("sort < in.txt 3< more.txt > out.txt", &Workspace::new(cwd)); + + assert_eq!(spelled(target.reads.clone()), ["in.txt", "more.txt"]); + assert_eq!(spelled(target.writes.clone()), ["out.txt"]); + assert!(target.unrecognized().is_none()); + assert!( + parse("cd x && cat < in.txt").unrecognized().is_some(), + "a relative input after a cd may name another file" + ); + } + + #[test] + fn counts_a_read_write_redirection_as_both() { + let cwd_dir = canonical_tempdir(); + let cwd = cwd_dir.path(); + for (command, read, written) in [ + ("cat <> key", "key", &["key"][..]), + ("cat 3<>key", "key", &["key"]), + ("sort <>in.txt > out.txt", "in.txt", &["in.txt", "out.txt"]), + ("cat <\\\n>key", "key", &["key"]), + ("cat <\\\n> key", "key", &["key"]), + ("echo a\\\\<>key", "key", &["key"]), + ] { + let target = CommandTarget::parse(command, &Workspace::new(cwd)); + + assert_eq!(spelled(target.reads.clone()), [read], "{command:?}"); + assert_eq!(spelled(target.writes.clone()), written, "{command:?}"); + } + } + + #[test] + fn skips_heredocs_descriptors_and_devices() { + let cwd_dir = canonical_tempdir(); + let cwd = cwd_dir.path(); + for command in [ + "cat <<< x", + "cat <&1", + "make 3>&-", + "make <&3", + "make > /dev/null 2> /dev/stderr < /dev/null", + ] { + assert!(writes(command, cwd).is_empty(), "{command:?}"); + assert!(reads(command, cwd).is_empty(), "{command:?}"); + assert!( + CommandTarget::parse(command, &Workspace::new(cwd)) + .unrecognized() + .is_none(), + "{command:?}" + ); + } + } + + #[test] + fn to_a_target_it_cannot_place_is_unrecognized() { + for command in ["echo > \"$OUT\"", "echo > *.txt"] { + assert!(parse(command).unrecognized().is_some(), "{command:?}"); + } + } + } + + mod dir_target { + use super::*; + + /// The words of the first simple command in `command`. + fn command_words(command: &str) -> Vec { + fn first_command(node: Node<'_>) -> Option> { + if node.kind() == "command" { + return Some(node); + } + let mut cursor = node.walk(); + node.named_children(&mut cursor).find_map(first_command) + } + let tree = parse_tree(command); + let node = first_command(tree.root_node()).unwrap(); + let parts: Vec> = command_parts(node) + .into_iter() + .map(|part| match part.kind() { + "command_name" => part.named_child(0).unwrap(), + _ => part, + }) + .collect(); + words(&parts, command) + } + + #[test] + fn reads_where_a_change_goes_and_what_pushd_n_stacks() { + for (command, expected) in [ + ("cd", DirTarget::Home), + ("cd -P -- src", DirTarget::Path("src")), + ("pushd src", DirTarget::Path("src")), + ("pushd -n", DirTarget::Stay(Stacked::Nothing)), + ("pushd -n +1", DirTarget::Stay(Stacked::Nothing)), + ("pushd -n src", DirTarget::Stay(Stacked::Path("src"))), + ("pushd -n -- -x", DirTarget::Stay(Stacked::Path("-x"))), + ("pushd -n \"$D\"", DirTarget::Stay(Stacked::Unknown)), + ("pushd -n ~1", DirTarget::Stay(Stacked::Unknown)), + ("pushd \"$D\"", DirTarget::Unknown), + ("cd -", DirTarget::Unknown), + ("popd", DirTarget::Unknown), + ] { + let words = command_words(command); + + let target = dir_target(words[0].literal().unwrap(), &words[1..]); + + assert_eq!(target, expected, "{command:?}"); + } + } + } +} diff --git a/crates/ox-core/src/permission/shell/word.rs b/crates/ox-core/src/permission/shell/word.rs new file mode 100644 index 000000000..0c39f95b8 --- /dev/null +++ b/crates/ox-core/src/permission/shell/word.rs @@ -0,0 +1,470 @@ +//! Reads shell words with quote removal, expansion, and literal-value tracking. + +use std::iter::Peekable; +use std::ops::Range; +use std::str::Chars; + +use tree_sitter::Node; + +use super::ShellWord; +use super::walk::Gap; +use super::wrapper::is_identifier; +use crate::util::path::expand_user; + +/// One word of a simple command. +pub(super) struct Word { + /// Where the word is written in the source. + pub(super) span: Range, + /// The value the shell gives the word after tilde expansion and quote removal, or `None` when + /// part of it expands at run time. + pub(super) value: Option, + /// Whether an unquoted `*`, `?`, `[`, or `{` leaves the word to pathname or brace expansion. + pub(super) glob: bool, + /// Whether bash may expand the word into other than one word, as [`splits`] decides. + pub(super) splits: bool, +} + +impl Word { + /// The word bash reads from `parts`, which the grammar may split one word into with no blank + /// between them, as in `=="x"`. + pub(super) fn new(parts: &[Node<'_>], src: &str) -> Self { + let (value, glob) = unquote_parts(parts.iter().copied(), src, true) + .filter(|(value, _)| pwd_bounded(parts[0], src, value)) + .unzip(); + Self { + span: parts[0].start_byte()..parts[parts.len() - 1].end_byte(), + value, + glob: glob.unwrap_or(false), + splits: parts.iter().any(|&part| splits(part, src)), + } + } + + /// The literal word an anonymous token spells, such as a declaration's keyword or a test's + /// bracket. + pub(super) fn token(node: Node<'_>, src: &str) -> Self { + Self { + span: node.byte_range(), + value: Some(src[node.byte_range()].to_owned()), + glob: false, + splits: false, + } + } + + /// The word's value when it is known and leaves nothing to pathname or brace expansion. + pub(super) fn literal(&self) -> Option<&str> { + self.value.as_deref().filter(|_| !self.glob) + } + + pub(super) fn shell_word(&self, src: &str) -> ShellWord { + ShellWord { + text: self + .value + .clone() + .unwrap_or_else(|| src[self.span.clone()].to_owned()), + literal: self.literal().is_some(), + } + } +} + +/// A leading `NAME=VALUE` assignment as a word, with its value unquoted and a leading `~` expanded. +/// Neither pathname nor brace expansion applies to an assignment, so only a value or a subscript +/// that expands keeps it from being literal. +pub(super) fn assignment_word(node: Node<'_>, src: &str) -> ShellWord { + let written = || ShellWord { + text: src[node.byte_range()].to_owned(), + literal: false, + }; + if node + .child_by_field_name("name") + .is_none_or(|name| name.kind() != "variable_name") + { + return written(); + } + let Some(value) = node.child_by_field_name("value") else { + return ShellWord { + text: src[node.byte_range()].to_owned(), + literal: true, + }; + }; + match unquote(value, src, true).filter(|(unquoted, _)| pwd_bounded(value, src, unquoted)) { + Some((unquoted, _)) => ShellWord { + text: format!("{}{unquoted}", &src[node.start_byte()..value.start_byte()]), + literal: true, + }, + None => written(), + } +} + +/// `node`'s value and whether it globs, for [`Word`]. Tilde expansion and a [`DirVariable`] +/// expansion apply to a `leading` word alone. +fn unquote(node: Node<'_>, src: &str, leading: bool) -> Option<(String, bool)> { + let text = &src[node.byte_range()]; + match node.kind() { + "word" => unquote_word(text, leading), + "!" | "!=" | "=" | "==" | "=~" | "number" | "test_operator" | "variable_name" => { + Some((text.to_owned(), false)) + } + "variable_assignment" => { + let word = assignment_word(node, src); + word.literal.then_some((word.text, false)) + } + "raw_string" => Some((text.get(1..text.len() - 1)?.to_owned(), false)), + "ansi_c_string" => Some((unquote_ansi_c(text.get(2..text.len() - 1)?)?, false)), + "simple_expansion" | "expansion" if leading => { + Some((dir_prefix(node, src)?.0.value()?, false)) + } + "string" => { + let dir = dir_prefix(node, src).filter(|_| leading); + let mut cursor = node.walk(); + if !node + .named_children(&mut cursor) + .skip(usize::from(dir.is_some())) + .all(|part| part.kind() == "string_content") + { + return None; + } + let (prefix, start) = match dir { + Some((dir, end)) => (dir.value()?, end), + None => (String::new(), node.start_byte() + 1), + }; + let inner = src.get(start..node.end_byte() - 1)?; + Some((prefix + &unquote_double(inner), false)) + } + "concatenation" => { + let mut cursor = node.walk(); + unquote_parts(node.children(&mut cursor), src, leading) + } + _ => None, + } +} + +/// The value and glob flag of the one word `parts` spell in order, each read as [`unquote`] reads +/// it and joined by the [`Gap::value`] between them, or `None` when any part has none. Adjacent +/// unquoted parts are read as one, with the escapes and line continuations between them, so a +/// tilde prefix spans them. A leading tilde expands only when its unquoted run holds the `/` that +/// ends the prefix or ends the word, since bash leaves a prefix with a quoted character as written. +fn unquote_parts<'t>( + parts: impl IntoIterator>, + src: &str, + leading: bool, +) -> Option<(String, bool)> { + let parts: Vec> = parts.into_iter().collect(); + let mut value = String::new(); + let mut glob = false; + let mut previous: Option> = None; + let mut runs = parts + .chunk_by(|left, right| left.kind() == "word" && right.kind() == "word") + .peekable(); + while let Some(run) = runs.next() { + let (first, last) = (run[0], run[run.len() - 1]); + if let Some(previous) = previous { + value.push_str(&Gap::value(&src[previous.end_byte()..first.start_byte()])); + } + let leading = leading && previous.is_none(); + let (text, run_glob) = if first.kind() == "word" { + let text = &src[first.start_byte()..last.end_byte()]; + let quoted_prefix = + text.starts_with('~') && !text.contains('/') && runs.peek().is_some(); + unquote_word(text, leading && !quoted_prefix)? + } else { + unquote(first, src, leading)? + }; + value.push_str(&text); + glob |= run_glob; + previous = Some(last); + } + Some((value, glob)) +} + +/// A variable that names a directory. The analysis resolves its plain expansion, such as `$HOME` +/// or `${PWD}`, at the start of a word, and `PWD`'s only where [`pwd_bounded`] holds. +#[derive(Clone, Copy)] +enum DirVariable { + /// `HOME`, which bash reads from the environment as the analysis does. + Home, + /// `PWD`, which bash keeps current across directory changes, so it names the working + /// directory as `.` does. + Pwd, +} + +impl DirVariable { + fn parse(text: &str) -> Option { + match text { + "$HOME" | "${HOME}" => Some(Self::Home), + "$PWD" | "${PWD}" => Some(Self::Pwd), + _ => None, + } + } + + /// The directory the variable expands to, or `None` without a home directory. + fn value(self) -> Option { + match self { + Self::Home => home(), + Self::Pwd => Some(".".to_owned()), + } + } +} + +/// The [`DirVariable`] whose plain expansion starts the word part `node`, bare or first in a +/// double-quoted string, with the byte where that expansion ends. +fn dir_prefix(node: Node<'_>, src: &str) -> Option<(DirVariable, usize)> { + match node.kind() { + "simple_expansion" | "expansion" => Some(( + DirVariable::parse(&src[node.byte_range()])?, + node.end_byte(), + )), + "string" => node + .named_child(0) + .filter(|part| { + part.start_byte() == node.start_byte() + 1 + && matches!(part.kind(), "simple_expansion" | "expansion") + }) + .and_then(|part| dir_prefix(part, src)), + "concatenation" => dir_prefix(node.child(0)?, src), + _ => None, + } +} + +/// Returns whether `value`, the word that the part `first` leads, names what bash expands it to. +/// The `.` a leading `$PWD` stands for does only when the expansion ends the word or a `/` +/// follows it, as bash requires of `~+`, since `$PWD.log` names a sibling of the directory. +fn pwd_bounded(first: Node<'_>, src: &str, value: &str) -> bool { + !matches!(dir_prefix(first, src), Some((DirVariable::Pwd, _))) + || value == "." + || value.starts_with("./") +} + +/// The home directory a leading `~` expands to, or `None` without one. +pub(super) fn home() -> Option { + Some(expand_user("~").ok()?.to_string_lossy().into_owned()) +} + +/// An unquoted word's value, with backslash escapes removed and the tilde [`tilde_at`] finds +/// expanded by [`expand_tilde`]. +fn unquote_word(text: &str, leading: bool) -> Option<(String, bool)> { + let mut value = String::with_capacity(text.len()); + let mut glob = false; + let mut chars = text.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => value.extend(chars.next().filter(|&next| next != '\n')), + '*' | '?' | '[' | '{' => { + glob = true; + value.push(c); + } + _ => value.push(c), + } + } + let joined = text.replace("\\\n", ""); + if leading && let Some(at) = tilde_at(&joined) { + let tail = &joined[at + 1..]; + let name = &tail[..tail.find('/').unwrap_or(tail.len())]; + if name.contains(['*', '?', '[', '{']) { + return None; + } + if !name.contains('\\') { + value = value[..at].to_owned() + &expand_tilde(&value[at..])?; + } + } + Some((value, glob)) +} + +/// Where the tilde bash may expand in `word` stands: leading it, or after the first `=` of a word +/// that reads as an assignment, as in `env BASH_ENV=~/x`. +fn tilde_at(word: &str) -> Option { + if word.starts_with('~') { + return Some(0); + } + let (name, value) = word.split_once('=')?; + (is_identifier(name) && value.starts_with('~')).then_some(name.len() + 1) +} + +/// `path` with a leading tilde prefix expanded as bash expands it, or `None` for a user the +/// analysis cannot find. A [`stack_tilde`] stays as written, since the directory it names depends +/// on the changes before it, which [`super::walk::Walk::spellings`] weighs. +pub(super) fn expand_tilde(path: &str) -> Option { + let Some(tail) = path.strip_prefix('~') else { + return Some(path.to_owned()); + }; + let name = &tail[..tail.find('/').unwrap_or(tail.len())]; + Some(match name { + "" => expand_user(path).ok()?.to_string_lossy().into_owned(), + "+" => format!(".{}", &path[2..]), + _ if stack_tilde(path).is_some() => path.to_owned(), + _ => user_home(name)? + &path[1 + name.len()..], + }) +} + +/// The rest of `path` after a leading tilde that names a directory on the stack: `~-`, the +/// previous directory, or `~N`, `~+N`, or `~-N`, the stack's Nth entry. Each names the working +/// directory, a directory a `cd` or `pushd` entered, or one a `pushd -n` stacked, or else stays +/// as written, since `ox` starts `bash` without `OLDPWD` and with only the working directory on +/// the stack. +pub(super) fn stack_tilde(path: &str) -> Option<&str> { + let tail = path.strip_prefix('~')?; + let end = tail.find('/').unwrap_or(tail.len()); + let (name, rest) = tail.split_at(end); + let index = name.strip_prefix(['+', '-']).unwrap_or(name); + let stack = name == "-" || !index.is_empty() && index.chars().all(|c| c.is_ascii_digit()); + stack.then_some(rest) +} + +/// The home directory of the user `name`, as bash's `~name` finds it. +#[cfg(unix)] +fn user_home(name: &str) -> Option { + let user = nix::unistd::User::from_name(name).ok()??; + Some(user.dir.to_string_lossy().into_owned()) +} + +/// The home directory of the user `name`, which the analysis does not look up off Unix. +#[cfg(not(unix))] +fn user_home(_name: &str) -> Option { + None +} + +/// Returns whether bash may expand the word part `node` into other than one word: by splitting an +/// unquoted expansion or substitution, by pathname expansion, or by brace expansion, which any +/// unquoted `{` but the literal `{}` may open. +fn splits(node: Node<'_>, src: &str) -> bool { + match node.kind() { + "arithmetic_expansion" + | "brace_expression" + | "command_substitution" + | "expansion" + | "simple_expansion" => true, + "concatenation" => { + let mut cursor = node.walk(); + node.children(&mut cursor).any(|part| splits(part, src)) + } + "word" => { + let mut chars = src[node.byte_range()].chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => { + chars.next(); + } + '*' | '?' | '[' => return true, + '{' if chars.peek() != Some(&'}') => return true, + _ => {} + } + } + false + } + _ => false, + } +} + +/// A double-quoted string's value: a backslash escapes only `$`, `` ` ``, `"`, `\`, and newlines. +fn unquote_double(inner: &str) -> String { + let mut value = String::with_capacity(inner.len()); + let mut chars = inner.chars().peekable(); + while let Some(c) = chars.next() { + match (c, chars.peek()) { + ('\\', Some(&'\n')) => _ = chars.next(), + ('\\', Some(&next @ ('$' | '`' | '"' | '\\'))) => { + value.push(next); + chars.next(); + } + _ => value.push(c), + } + } + value +} + +/// A `$'...'` string's value, with its escapes decoded as bash does, or `None` when an escape names +/// no character, names a lone byte past ASCII, or names NUL, where bash cuts the word short. +fn unquote_ansi_c(inner: &str) -> Option { + let mut value = String::with_capacity(inner.len()); + let mut chars = inner.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\\' { + value.push(c); + continue; + } + let escape = chars.next()?; + let decoded = match escape { + 'a' => '\u{7}', + 'b' => '\u{8}', + 'e' | 'E' => '\u{1b}', + 'f' => '\u{c}', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'v' => '\u{b}', + '\\' | '\'' | '"' | '?' => escape, + '0'..='7' => { + let first = escape.to_digit(8)?; + let code = take_digits(&mut chars, 8, 2) + .map_or(first, |(rest, width)| first * 8_u32.pow(width) + rest); + char::from_u32(code & 0xff).filter(char::is_ascii)? + } + 'x' => char::from_u32(take_digits(&mut chars, 16, 2)?.0).filter(char::is_ascii)?, + 'u' => char::from_u32(take_digits(&mut chars, 16, 4)?.0)?, + 'U' => char::from_u32(take_digits(&mut chars, 16, 8)?.0)?, + 'c' => char::from_u32(u32::from(chars.next()?) & 0x1f)?, + other => { + value.push('\\'); + other + } + }; + if decoded == '\0' { + return None; + } + value.push(decoded); + } + Some(value) +} + +/// Up to `max` digits in `radix` from the front of `chars`, as their value and their count, or +/// `None` when none leads. +fn take_digits(chars: &mut Peekable>, radix: u32, max: usize) -> Option<(u32, u32)> { + let mut taken = None; + for _ in 0..max { + let Some(digit) = chars.peek().and_then(|c| c.to_digit(radix)) else { + break; + }; + chars.next(); + let (value, count) = taken.unwrap_or((0, 0)); + taken = Some((value * radix + digit, count + 1)); + } + taken +} + +#[cfg(test)] +mod tests { + use super::*; + + mod unquote_ansi_c { + use super::*; + + #[test] + fn decodes_each_escape_bash_does() { + for (inner, value) in [ + ("push", "push"), + (r"pu\x73h", "push"), + (r"\162m", "rm"), + (r"\55rf", "-rf"), + (r"\0101", "\u{8}1"), + (r"\u002drf", "-rf"), + (r"\U0000002drf", "-rf"), + (r"\u00e9", "\u{e9}"), + (r"a\tb\n\r\v\f\a\b", "a\tb\n\r\u{b}\u{c}\u{7}\u{8}"), + (r"\e[0m\E", "\u{1b}[0m\u{1b}"), + (r#"\'\"\\\?"#, r#"'"\?"#), + (r"\cA", "\u{1}"), + (r"\q", r"\q"), + ] { + assert_eq!(unquote_ansi_c(inner).as_deref(), Some(value), "{inner:?}"); + } + } + + #[test] + fn declines_an_escape_that_names_no_ascii_character_or_nul() { + for inner in [ + r"\x80", r"\200", r"\xZ", r"\0", r"\400", r"a\x00b", r"\c", "a\\", + ] { + assert_eq!(unquote_ansi_c(inner), None, "{inner:?}"); + } + } + } +} diff --git a/crates/ox-core/src/permission/shell/wrapper.rs b/crates/ox-core/src/permission/shell/wrapper.rs new file mode 100644 index 000000000..6e5fc175c --- /dev/null +++ b/crates/ox-core/src/permission/shell/wrapper.rs @@ -0,0 +1,779 @@ +//! Reads transparent wrappers, their option effects, and the input they feed commands. +use anyhow::Result; + +use super::word::Word; + +/// 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. +pub(super) 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", +]; + +/// Wrappers that run a command given after their own options, so rules see through them. +pub(super) 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. +pub(super) const DEFAULT_REPLACE: &str = "{}"; + +/// The word that stands for the arguments `xargs` reads from its input. +pub(super) const INPUT_WORD: &str = ""; + +/// `runuser`'s options, which run the command after them directly when `-u` names the user. +pub(super) 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 +}; + +// ── Wrapper ── + +/// A wrapper's option grammar and what running a command through it changes. +pub(super) struct Wrapper { + pub(super) 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, + pub(super) assignments: Assignments, + pub(super) 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. + pub(super) 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)] +pub(super) 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)] +pub(super) 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)] +pub(super) 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. + 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)] +pub(super) struct Invocation { + /// Where the command starts among the arguments, or past them all when it runs none. + pub(super) start: usize, + pub(super) elsewhere: bool, + /// The placeholder that input words replace, when an option sets one. + pub(super) replace: Option, + /// The files an option names for the wrapper to read. + pub(super) reads: Vec, + /// Whether an option hands the wrapper a command line it splits itself. + pub(super) splits: bool, + /// The arguments the wrapper takes as `NAME=VALUE` assignments. + pub(super) 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)] +pub(super) struct Placeholder { + pub(super) text: String, + /// Whether only a word that is exactly `text` is replaced. + pub(super) whole: bool, +} + +/// What the wrappers leading a simple command leave to run, as indices into its words. +#[derive(Default)] +pub(super) struct Unwrapped { + /// Where the words an allow rule must name start. + pub(super) 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. + pub(super) starts: Vec, + pub(super) fed: Fed, + /// The files wrapper options read. + pub(super) reads: Vec, + /// The `NAME=VALUE` words that wrappers such as `env` take as assignments, or may take past an + /// option the analysis cannot read. + pub(super) assignments: Vec, +} + +/// The words an `xargs` or `find` passes a command, as indices into its words. +#[derive(Default)] +pub(super) struct Fed { + /// Whether an `xargs` appends its input words to the command. + pub(super) appends: bool, + /// The placeholders `xargs` or `find` replaces with its input words or found paths, each with + /// where its command starts. + pub(super) replaces: Vec<(usize, Placeholder)>, +} + +impl Fed { + /// What reaches the command whose words start at `offset` among these words. + pub(super) 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. + pub(super) fn passes_input(&self) -> bool { + self.appends || !self.replaces.is_empty() + } + + /// Returns whether input words replace part of `text`, the word at `index`. + pub(super) 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. +pub(super) 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 `_`. +pub(super) 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. +pub(super) 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. +pub(super) 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. +pub(super) 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::>>() + .map(|values| values.join(" ")) +} + +/// The variables a builtin's options name and the operands after them, read past option clusters +/// where each letter in `valued` takes a value, and each in `naming` takes a variable's name. +/// `None` when an option holds a letter in `refused`, whose effect the analysis does not follow, or +/// a word that expands may be an option or a name. A word written from a letter or `_`, as an +/// assignment with an array value is, cannot expand to an option. `--` ends the options. +pub(super) fn builtin_options<'w>( + args: &'w [Word], + src: &str, + valued: &str, + naming: &str, + refused: &str, +) -> Option<(Vec<&'w str>, &'w [Word])> { + let mut names = Vec::new(); + let mut at = 0; + while let Some(word) = args.get(at) { + let Some(value) = word.value.as_deref() else { + src[word.span.clone()] + .starts_with(|c: char| c == '_' || c.is_ascii_alphabetic()) + .then_some(())?; + break; + }; + let Some(cluster) = value + .strip_prefix('-') + .filter(|cluster| !cluster.is_empty()) + else { + break; + }; + at += 1; + if cluster == "-" { + break; + } + for (offset, letter) in cluster.char_indices() { + if refused.contains(letter) { + return None; + } + if !valued.contains(letter) && !naming.contains(letter) { + continue; + } + let attached = &cluster[offset + letter.len_utf8()..]; + let value = if attached.is_empty() { + at += 1; + args.get(at - 1).map(|word| word.value.as_deref()) + } else { + Some(Some(attached)) + }; + if naming.contains(letter) { + names.push(value.flatten()?); + } + break; + } + } + Some((names, &args[at.min(args.len())..])) +} + +/// Returns whether `args` only turn on or off the `set` options that make a script stop on errors +/// or trace what it runs: `e`, `u`, `v`, and `x` as letters, and `errexit`, `nounset`, `pipefail`, +/// `verbose`, and `xtrace` by name after `-o` or `+o`. +pub(super) fn sets_only_tracing_options(args: &[Word]) -> bool { + let mut values = args.iter().map(|word| word.value.as_deref()); + let mut any = false; + while let Some(value) = values.next() { + let Some(cluster) = value + .and_then(|value| value.strip_prefix(['-', '+'])) + .filter(|cluster| !cluster.is_empty()) + else { + return false; + }; + let (letters, named) = cluster + .strip_suffix('o') + .map_or((cluster, false), |letters| (letters, true)); + if !letters.chars().all(|letter| "euvx".contains(letter)) { + return false; + } + if named + && !values.next().flatten().is_some_and(|name| { + ["errexit", "nounset", "pipefail", "verbose", "xtrace"].contains(&name) + }) + { + return false; + } + any = true; + } + any +} + +/// Returns whether `test` or `[` may evaluate a word as a variable's name with `-v` or `-R`, whose +/// subscript runs any substitution in it: a word that is or may expand to either operator, followed +/// by one that expands or holds a `[`. +pub(super) fn may_test_a_subscript(args: &[Word]) -> bool { + args.windows(2).any(|pair| { + pair[0] + .value + .as_deref() + .is_none_or(|operator| matches!(operator, "-v" | "-R")) + && pair[1] + .value + .as_deref() + .is_none_or(|name| name.contains('[')) + }) +} diff --git a/docs/design/permissions.md b/docs/design/permissions.md index c75ba137a..715767e4b 100644 --- a/docs/design/permissions.md +++ b/docs/design/permissions.md @@ -138,7 +138,13 @@ The `--no-tui` REPL and `-p` headless runs share `run_op`, which passes each `Ev - `crates/ox-core/src/permission.rs`: `Mode`, `PermissionClass`, `Workspace`, `Access::for_path`, `Access::for_command`, `Verdict`, `Basis`, `Policy::verdict`, `Policy::config_fix`, and `ReadFilter`. - `crates/ox-core/src/permission/defaults.rs`: the shipped groups and their rules, `ShippedTier`, and the `[permission.defaults]` names. - `crates/ox-core/src/permission/rule.rs`: `Rule`, the patterns of each class, `MatchDiscipline`, and `Rule::for_call`. -- `crates/ox-core/src/permission/shell.rs`: `CommandTarget::parse`, the wrapper grammars, the recognized syntax, the known-safe commands, the reader grammars, and the forced recursive `rm` check. +- `crates/ox-core/src/permission/shell.rs`: the command target, segment and word contracts, the pattern parser entry point, and the forced recursive `rm` check. +- `crates/ox-core/src/permission/shell/invocation.rs`: nested shell arguments, startup files, and inherited variables. +- `crates/ox-core/src/permission/shell/pattern.rs`: the `exec` pattern grammar. +- `crates/ox-core/src/permission/shell/reader.rs`: known-safe commands, reader operands, globs, and `sed` scripts. +- `crates/ox-core/src/permission/shell/walk.rs`: recognized syntax and the commands, reads, and writes the walk collects. +- `crates/ox-core/src/permission/shell/word.rs`: quote removal, expansions, and literal words. +- `crates/ox-core/src/permission/shell/wrapper.rs`: wrapper option grammars and the input they pass commands. - `crates/ox-core/src/protocol.rs`: `Event::ApprovalRequested`, `Event::SessionRulesUpdated`, `Event::ModeChanged`, `Op::Resolve`, `Op::SetMode`, and `ApprovalDecision`. - `crates/ox-core/src/tool.rs`: the `Tool` trait, `ToolSpec`, `ToolCtx`, and `Prepared`. - `crates/ox-core/src/tool/file.rs`: `read_validated` and `open_gated`, the size and type checks on the opened file. From 42a7cc29dd33dd1ba3afb36dc2e2681b58df48c3 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 30 Oct 2026 22:11:30 +0800 Subject: [PATCH 2/4] docs(permission): describe directory variables after shell split --- crates/ox-core/src/permission/shell/walk.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ox-core/src/permission/shell/walk.rs b/crates/ox-core/src/permission/shell/walk.rs index 27c8a5f7c..d2b3b71b4 100644 --- a/crates/ox-core/src/permission/shell/walk.rs +++ b/crates/ox-core/src/permission/shell/walk.rs @@ -368,7 +368,7 @@ impl Walk<'_> { } /// Returns whether the environment sets one of the [`DIR_VARIABLES`], so the analysis cannot - /// resolve a tilde or a [`DirVariable`] from its own. + /// resolve a tilde or directory variable from its own. fn sets_dir_variable(&self) -> bool { sets_dir_variable(&self.environment) } From 6db03bd79dbfd5aa25024556ad1e10bda84f3653 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 30 Oct 2026 22:54:15 +0800 Subject: [PATCH 3/4] test(permission): cover reachable shell analysis edges --- crates/ox-core/src/permission/shell.rs | 6 ++++++ crates/ox-core/src/permission/shell/reader.rs | 1 + crates/ox-core/src/permission/shell/walk.rs | 15 +++++++++++---- crates/ox-core/src/permission/shell/wrapper.rs | 3 ++- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/ox-core/src/permission/shell.rs b/crates/ox-core/src/permission/shell.rs index 74bfb7c9a..417d6128c 100644 --- a/crates/ox-core/src/permission/shell.rs +++ b/crates/ox-core/src/permission/shell.rs @@ -392,12 +392,17 @@ mod tests { #[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"]), @@ -1195,6 +1200,7 @@ mod tests { ("echo $((1 + 2)); let x=1", "$((1 + 2))"), ("[ a > b ] && ls", "[ a > b ]"), ("if true; then ls", "if true; then ls"), + ("if true; then echo ok;", "if true; then echo ok;"), ("f() {\n ls\n}", "f() {..."), (long.as_str(), cut.as_str()), ] { diff --git a/crates/ox-core/src/permission/shell/reader.rs b/crates/ox-core/src/permission/shell/reader.rs index 120a33c68..edd5615ae 100644 --- a/crates/ox-core/src/permission/shell/reader.rs +++ b/crates/ox-core/src/permission/shell/reader.rs @@ -1555,6 +1555,7 @@ mod tests { fn flag_a_reader_word_whose_files_are_unknown() { for command in [ "cat $F", + "cat ~*/x", r#"cat "$X/.ssh/id_rsa""#, "/bin/cat $F", "grep -r x $DIR", diff --git a/crates/ox-core/src/permission/shell/walk.rs b/crates/ox-core/src/permission/shell/walk.rs index d2b3b71b4..ec8fe78c1 100644 --- a/crates/ox-core/src/permission/shell/walk.rs +++ b/crates/ox-core/src/permission/shell/walk.rs @@ -2031,10 +2031,17 @@ mod tests { fn counts_each_file_an_output_redirection_writes() { let cwd_dir = canonical_tempdir(); let cwd = cwd_dir.path(); - assert_eq!( - writes("echo a > out.txt 2>> log &> all >| clobber >& both", cwd), - ["out.txt", "log", "all", "clobber", "both"] - ); + + for (command, expected) in [ + ("> out.txt", &["out.txt"][..]), + ( + "echo a > out.txt 2>> log &> all >| clobber >& both", + &["out.txt", "log", "all", "clobber", "both"], + ), + ] { + assert_eq!(writes(command, cwd), expected, "{command:?}"); + } + let outside = abs("/etc/hosts"); assert_eq!( CommandTarget::parse(&format!("echo a > {outside}"), &Workspace::new(cwd)).writes, diff --git a/crates/ox-core/src/permission/shell/wrapper.rs b/crates/ox-core/src/permission/shell/wrapper.rs index 6e5fc175c..38c024f61 100644 --- a/crates/ox-core/src/permission/shell/wrapper.rs +++ b/crates/ox-core/src/permission/shell/wrapper.rs @@ -1,4 +1,5 @@ -//! Reads transparent wrappers, their option effects, and the input they feed commands. +//! Reads command wrappers, their option effects, and the input they feed commands. + use anyhow::Result; use super::word::Word; From 651e8eb95882c4120bf10127651a0e8fd34358c7 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 1 Nov 2026 17:19:33 +0800 Subject: [PATCH 4/4] style(permission): order reader dialect derives --- crates/ox-core/src/permission/shell/reader.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ox-core/src/permission/shell/reader.rs b/crates/ox-core/src/permission/shell/reader.rs index edd5615ae..9ac992f9b 100644 --- a/crates/ox-core/src/permission/shell/reader.rs +++ b/crates/ox-core/src/permission/shell/reader.rs @@ -621,7 +621,7 @@ pub(super) fn reader_args(name: &str, values: &[Option<&str>]) -> ReaderArgs { } /// A build of the readers, whose option parsing and `sed` commands can differ. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum Dialect { /// GNU, as on Linux. Gnu,