2026-10-07 22:23:21 +02:00
|
|
|
//! Conservative detection of shell syntax in commands run without a shell.
|
2026-10-07 12:12:13 +02:00
|
|
|
|
|
|
|
|
use std::iter::Peekable;
|
|
|
|
|
use std::str::CharIndices;
|
|
|
|
|
|
2026-10-07 22:23:21 +02:00
|
|
|
/// Detects the variable assignment at the start of a command like
|
|
|
|
|
/// `OMP_NUM_THREADS=8 my_command`.
|
|
|
|
|
pub fn starts_with_assignment(command: &str) -> bool {
|
|
|
|
|
let Some(token) = tokenize(command).next() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let mut bytes = token.bytes();
|
|
|
|
|
if !bytes
|
|
|
|
|
.next()
|
|
|
|
|
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
for byte in bytes {
|
|
|
|
|
if byte == b'=' {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if !byte.is_ascii_alphanumeric() && byte != b'_' {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 12:12:13 +02:00
|
|
|
/// 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 {
|
2026-10-07 22:23:21 +02:00
|
|
|
use super::{first_unquoted_operator, starts_with_assignment, tokenize};
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detects_leading_assignments() {
|
|
|
|
|
assert!(starts_with_assignment("A=1 command"));
|
|
|
|
|
assert!(starts_with_assignment("A=$HOME command"));
|
|
|
|
|
assert!(starts_with_assignment("A= command"));
|
|
|
|
|
assert!(starts_with_assignment("_A2='hello world' B=2 command"));
|
|
|
|
|
assert!(starts_with_assignment("A=1"));
|
|
|
|
|
assert!(starts_with_assignment(" # comment\n A=1 command"));
|
|
|
|
|
|
|
|
|
|
assert!(!starts_with_assignment(""));
|
|
|
|
|
assert!(!starts_with_assignment("# A=1 command"));
|
|
|
|
|
assert!(!starts_with_assignment("command A=1"));
|
|
|
|
|
assert!(!starts_with_assignment("env A=1 command"));
|
|
|
|
|
assert!(!starts_with_assignment("'A=1' command"));
|
|
|
|
|
assert!(!starts_with_assignment("\"A\"=1 command"));
|
|
|
|
|
assert!(!starts_with_assignment(r"A\=1 command"));
|
|
|
|
|
assert!(!starts_with_assignment("./A=1 command"));
|
|
|
|
|
assert!(!starts_with_assignment("1A=1 command"));
|
|
|
|
|
assert!(!starts_with_assignment("A-B=1 command"));
|
|
|
|
|
assert!(!starts_with_assignment("=1 command"));
|
|
|
|
|
assert!(!starts_with_assignment("A"));
|
|
|
|
|
}
|
2026-10-07 12:12:13 +02:00
|
|
|
|
|
|
|
|
#[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:?}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|