diff --git a/README.md b/README.md index 8e24014..da51fc9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/main.rs b/src/main.rs index 9fe6e5c..0b3bdd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::("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); diff --git a/src/shell_syntax.rs b/src/shell_syntax.rs new file mode 100644 index 0000000..caba560 --- /dev/null +++ b/src/shell_syntax.rs @@ -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 { + Tokens { + command, + chars: command.char_indices().peekable(), + } +} + +struct Tokens<'a> { + command: &'a str, + chars: Peekable>, +} + +impl<'a> Iterator for Tokens<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option { + // 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::>(), + 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::>(), + 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::>(), + expected, + "{command:?}" + ); + assert_eq!(first_unquoted_operator(command), None, "{command:?}"); + } + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 529f84b..b4574e9 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -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()