Require an explicit shell choice for shell operators

This commit is contained in:
David Peter committed 2026-10-07 12:12:13 +02:00
1 parent 4a9e066b41
commit be4d5dfb18
4 files changed
+282 -2

No files matched your search

+4
View File
@@ -114,6 +114,10 @@ Shell syntax such as pipes, redirections, environment-variable expansion, `*`, a
This avoids shell startup overhead and the noise from correcting for it, especially for fast commands
(< 5 ms).
Commands containing unquoted, standalone shell operators such as `&&` or `>` require an explicit
choice: use `-S` / `--shell=default` to interpret shell syntax, or `-N` / `--shell=none` to pass
the operators as literal arguments.
To enable shell syntax, use `-S` (an alias for `--shell=default`). This selects `sh`
on Unix (resolved through `PATH`) or `cmd.exe` on Windows:
```sh
+24 -2
View File
@@ -11,9 +11,9 @@ use cli::get_cli_arguments;
use command::Commands;
use error::ConsoleOutputError;
use export::ExportManager;
use options::Options;
use options::{ExecutorKind, Options};
use anyhow::Result;
use anyhow::{bail, Result};
use colored::*;
pub mod benchmark;
@@ -27,6 +27,7 @@ pub mod outlier_detection;
pub mod output;
pub mod parameter;
pub mod quantity;
pub mod shell_syntax;
pub mod timer;
pub mod util;
@@ -40,6 +41,27 @@ fn run() -> Result<()> {
let commands = Commands::from_cli_arguments(&cli_arguments)?;
options.validate_against_command_list(&commands)?;
if matches!(options.executor_kind, ExecutorKind::Raw)
&& !cli_arguments.get_flag("no-shell")
&& cli_arguments.get_one::<String>("shell").is_none()
{
for command in commands.iter() {
let command_line = command.get_command_line();
if let Some(operator) = shell_syntax::first_unquoted_operator(&command_line) {
bail!(
concat!(
"Command '{command_line}' contains unquoted shell syntax ('{operator}').\n",
"Explicitly choose how to execute it:\n",
" -S / --shell=default Interpret shell syntax.\n",
" -N / --shell=none Run directly, passing '{operator}' as a literal argument.",
),
command_line = command_line,
operator = operator,
);
}
}
}
let export_manager = ExportManager::from_cli_arguments(&cli_arguments, options.metrics[0])?;
let mut scheduler = Scheduler::new(&commands, &options, &export_manager);
+218
View File
@@ -0,0 +1,218 @@
//! Conservative detection of shell operators in commands run without a shell.
use std::iter::Peekable;
use std::str::CharIndices;
/// Find the first standalone operator that is neither quoted nor escaped.
///
/// Operators attached to another word (such as `hello>out`), expansions, and
/// other shell syntax are deliberately outside the scope of this check.
pub fn first_unquoted_operator(command: &str) -> Option<&'static str> {
tokenize(command).find_map(|token| {
["&&", "||", "|", ";", ">", ">>", "<"]
.iter()
.copied()
.find(|operator| {
// An escaped newline joins lines without quoting either side.
// All other quotes and escapes remain in the token, preventing
// it from matching an operator.
token
.split("\\\n")
.flat_map(str::bytes)
.eq(operator.bytes())
})
})
}
/// Split words while preserving their original quoting and escaping.
///
/// Word boundaries and comments follow the same rules as `shell_words`, which
/// parses commands for direct execution. This tokenizer neither expands shell
/// expressions nor validates balanced quotes; command parsing handles errors.
fn tokenize(command: &str) -> impl Iterator<Item = &str> {
Tokens {
command,
chars: command.char_indices().peekable(),
}
}
struct Tokens<'a> {
command: &'a str,
chars: Peekable<CharIndices<'a>>,
}
impl<'a> Iterator for Tokens<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<Self::Item> {
// Whitespace, comments, and line continuations cannot start a word.
let start = loop {
let &(index, character) = self.chars.peek()?;
match character {
' ' | '\t' | '\n' => {
self.chars.next();
}
'#' => {
for (_, character) in self.chars.by_ref() {
if character == '\n' {
break;
}
}
}
'\\' if self.chars.clone().nth(1).map(|(_, c)| c) == Some('\n') => {
self.chars.next();
self.chars.next();
}
_ => break index,
}
};
let mut quote = None;
while let Some((index, character)) = self.chars.next() {
match (quote, character) {
(Some('\''), '\'') | (Some('"'), '"') => quote = None,
(Some('\''), _) => {}
(_, '\\') => {
// Within double quotes, skipping a non-special escaped
// character is also safe: it cannot end the quoted word.
self.chars.next();
}
(Some('"'), _) => {}
(None, '\'' | '"') => quote = Some(character),
(None, ' ' | '\t' | '\n') => return Some(&self.command[start..index]),
_ => {}
}
}
Some(&self.command[start..])
}
}
#[cfg(test)]
mod tests {
use super::{first_unquoted_operator, tokenize};
#[test]
fn tokenizer_preserves_words_quotes_and_escapes() {
for (command, expected) in [
("", vec![]),
(" \t\n", vec![]),
(" echo\thello\nworld ", vec!["echo", "hello", "world"]),
(
r#"echo '' "" 'hello world'"#,
vec!["echo", "''", "\"\"", "'hello world'"],
),
(
r#"echo pre'quoted part'"more"post"#,
vec!["echo", r#"pre'quoted part'"more"post"#],
),
(
r"echo hello\ world \&\&",
vec!["echo", r"hello\ world", r"\&\&"],
),
(r#"echo "a\" b" 'a\'"#, vec!["echo", r#""a\" b""#, r"'a\'"]),
("echo hello>out", vec!["echo", "hello>out"]),
(
"echo café\t&&\n日本語",
vec!["echo", "café", "&&", "日本語"],
),
("echo a\r\u{a0}b", vec!["echo", "a\r\u{a0}b"]),
] {
assert_eq!(
tokenize(command).collect::<Vec<_>>(),
expected,
"{command:?}"
);
}
}
#[test]
fn tokenizer_handles_comments_and_continuations() {
for (command, expected) in [
("# ignored &&", vec![]),
(
"echo # ignored &&\nnext | cat",
vec!["echo", "next", "|", "cat"],
),
(
r##"echo a#b \#c "#d" '#e'"##,
vec!["echo", "a#b", r"\#c", "\"#d\"", "'#e'"],
),
("\\\n echo \\\n&& more", vec!["echo", "&&", "more"]),
("echo hello\\\nworld", vec!["echo", "hello\\\nworld"]),
("\\\n# comment\necho", vec!["echo"]),
] {
assert_eq!(
tokenize(command).collect::<Vec<_>>(),
expected,
"{command:?}"
);
}
}
#[test]
fn finds_supported_operators_in_command_order() {
for operator in ["&&", "||", "|", ";", ">", ">>", "<"] {
let command = format!("echo café\t{operator}\n日本語");
assert_eq!(first_unquoted_operator(&command), Some(operator));
assert_eq!(first_unquoted_operator(operator), Some(operator));
}
assert_eq!(first_unquoted_operator("echo a >> out && next"), Some(">>"));
assert_eq!(first_unquoted_operator("echo \\\n&& next"), Some("&&"));
assert_eq!(first_unquoted_operator("echo &\\\n& next"), Some("&&"));
assert_eq!(first_unquoted_operator("echo &&\\\n next"), Some("&&"));
assert_eq!(first_unquoted_operator("echo # &&\nnext | cat"), Some("|"));
assert_eq!(first_unquoted_operator(r#"echo "&&" || next"#), Some("||"));
assert_eq!(first_unquoted_operator(r"echo \&\& | cat"), Some("|"));
}
#[test]
fn ignores_quoted_escaped_and_attached_operators() {
for command in [
r#"echo "&&""#,
"echo '&&'",
r"echo \&\&",
r"echo &\&",
r#"echo '&'"&""#,
r#"echo ""&&"#,
"echo &&''",
r#"python -c "print(1 > 0)""#,
"sh -c 'true && false'",
r#"sh -c "echo 'a | b' && false""#,
r#"sh -c 'echo "a | b" && false'"#,
r#"echo "escaped \" && still quoted""#,
"echo hello>out",
"true&&false",
"echo a;",
"echo 2> out",
"echo >>> out",
"echo $VARIABLE *.rs ~/path NAME=value",
"echo # && ignored",
"echo foo#bar",
"echo\u{a0}&&",
"echo &&\r",
] {
assert_eq!(first_unquoted_operator(command), None, "{command:?}");
}
}
#[test]
fn handles_empty_and_malformed_commands_without_panicking() {
for (command, expected) in [
("", vec![]),
("\\\n", vec![]),
("\\", vec!["\\"]),
(r"echo \", vec!["echo", r"\"]),
("echo 'unclosed &&", vec!["echo", "'unclosed &&"]),
("echo \"unclosed &&", vec!["echo", "\"unclosed &&"]),
("echo \"unclosed\\", vec!["echo", "\"unclosed\\"]),
] {
assert_eq!(
tokenize(command).collect::<Vec<_>>(),
expected,
"{command:?}"
);
assert_eq!(first_unquoted_operator(command), None, "{command:?}");
}
}
}
+36
View File
@@ -266,6 +266,42 @@ fn fails_with_duplicate_parameter_names() {
.stderr(predicate::str::contains("Duplicate parameter names: x"));
}
#[test]
fn requires_explicit_shell_for_shell_syntax() {
let directory = tempfile::tempdir().unwrap();
let assert = hyperfine()
.current_dir(directory.path())
.arg("rm -rf cache/ && ./my_command")
.assert()
.failure();
insta::assert_snapshot!(String::from_utf8_lossy(&assert.get_output().stderr), @"
Error: Command 'rm -rf cache/ && ./my_command' contains unquoted shell syntax ('&&').
Explicitly choose how to execute it:
-S / --shell=default Interpret shell syntax.
-N / --shell=none Run directly, passing '&&' as a literal argument.
");
hyperfine()
.arg("--show-output")
.arg("--command-name=benchmark")
.args(["--shell=default", "--runs=1", "echo before && echo after"])
.assert()
.success()
.stdout(predicate::str::contains("before"))
.stdout(predicate::str::contains("after"))
.stdout(predicate::str::contains("&&").not());
hyperfine()
.arg("--show-output")
.arg("--command-name=benchmark")
.args(["--shell=none", "--runs=1", "echo before && echo after"])
.assert()
.success()
.stdout(predicate::str::contains("before && echo after"));
}
#[test]
fn fails_for_unknown_command() {
hyperfine()