//! Conservative detection of shell syntax in commands run without a shell. use std::iter::Peekable; use std::str::CharIndices; /// 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 } /// 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, 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")); } #[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:?}"); } } }