#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TokenizeError {
UnbalancedQuoting,
}
impl TokenizeError {
pub(crate) fn reason(self) -> &'static str {
match self {
Self::UnbalancedQuoting => {
"a command this guard cannot parse (unbalanced `'`/`\"` quoting)"
}
}
}
}
pub(crate) fn tokenize(segment: &str) -> Result<Vec<String>, TokenizeError> {
shlex::split(segment).ok_or(TokenizeError::UnbalancedQuoting)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RedirectRole<'a> {
None,
FileDescriptor,
TargetFollows,
Target(&'a str),
}
pub(crate) fn redirect_role(token: &str) -> RedirectRole<'_> {
let Some((descriptor, rest)) = token.split_once('>') else {
return RedirectRole::None;
};
if !(descriptor.is_empty()
|| descriptor == "&"
|| descriptor.chars().all(|c| c.is_ascii_digit()))
{
return RedirectRole::None;
}
let rest = strip_clobber(rest.strip_prefix('>').unwrap_or(rest));
let target = match rest.strip_prefix('&') {
Some(after) if strip_clobber(after) != after => strip_clobber(after),
Some(after) if is_descriptor_word(after) => return RedirectRole::FileDescriptor,
Some(after) => after,
None => rest,
};
if target.is_empty() {
RedirectRole::TargetFollows
} else {
RedirectRole::Target(target)
}
}
fn strip_clobber(rest: &str) -> &str {
rest.strip_prefix(['|', '!']).unwrap_or(rest)
}
pub(crate) fn is_clobber_bar(bytes: &[u8], i: usize) -> bool {
let before = |back: usize| i.checked_sub(back).and_then(|at| bytes.get(at));
bytes.get(i) == Some(&b'|')
&& (before(1) == Some(&b'>') || (before(1) == Some(&b'&') && before(2) == Some(&b'>')))
}
pub(crate) fn is_descriptor_word(word: &str) -> bool {
let digits = word.strip_suffix('-').unwrap_or(word);
word == "-" || (!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()))
}
const INLINE_PROGRAM_INTERPRETERS: &[&str] = &[
"python", "python3", "perl", "ruby", "node", "deno", "php", "sh", "bash", "zsh", "dash",
];
const INLINE_PROGRAM_FLAGS: &[&str] = &["-c", "-e", "-r", "--eval", "--command"];
const AWK_PROGRAMS: &[&str] = &["awk", "gawk", "nawk", "mawk"];
const AWK_VALUE_FLAGS: &[&str] = &["-v", "-F", "--assign", "--field-separator"];
const SED_PROGRAMS: &[&str] = &["sed", "gsed", "ssed"];
const SED_EXPRESSION_FLAGS: &[&str] = &["-e", "--expression"];
const SQL_CLIENTS: &[&str] = &["mysql", "mariadb"];
const SQL_STATEMENT_FLAGS: &[&str] = &["-e", "--execute"];
const SQL_SHELL_ESCAPES: &[&str] = &["system", "pager", "edit"];
fn sql_reaches_no_shell(statement: &str) -> bool {
let lower = statement.to_ascii_lowercase();
!lower.contains('\\') && !SQL_SHELL_ESCAPES.iter().any(|w| lower.contains(w))
}
const PROGRAM_FILE_FLAGS: &[&str] = &["-f", "--file"];
pub(crate) fn program_text_indices(program: &str, argv: &[String], start: usize) -> Vec<usize> {
let rest_start = start + 1;
let Some(rest) = argv.get(rest_start..) else {
return Vec::new();
};
if SQL_CLIENTS.contains(&program) {
return rest
.iter()
.enumerate()
.filter(|(_, token)| SQL_STATEMENT_FLAGS.contains(&token.as_str()))
.map(|(offset, _)| rest_start + offset + 1)
.filter(|&index| argv.get(index).is_some_and(|s| sql_reaches_no_shell(s)))
.collect();
}
if INLINE_PROGRAM_INTERPRETERS.contains(&program) {
return rest
.iter()
.enumerate()
.filter(|(_, token)| INLINE_PROGRAM_FLAGS.contains(&token.as_str()))
.map(|(offset, _)| rest_start + offset + 1)
.filter(|index| *index < argv.len())
.collect();
}
let is_awk = AWK_PROGRAMS.contains(&program);
if !is_awk && !SED_PROGRAMS.contains(&program) {
return Vec::new();
}
if rest.iter().any(|t| loads_a_program_file(t)) {
return Vec::new();
}
let expressions: Vec<usize> = rest
.iter()
.enumerate()
.filter(|(_, token)| SED_EXPRESSION_FLAGS.contains(&token.as_str()))
.map(|(offset, _)| rest_start + offset + 1)
.filter(|index| *index < argv.len())
.collect();
if !expressions.is_empty() {
return expressions;
}
first_operand(rest, is_awk)
.map(|offset| vec![rest_start + offset])
.unwrap_or_default()
}
fn loads_a_program_file(token: &str) -> bool {
PROGRAM_FILE_FLAGS.contains(&token) || token.starts_with("--file=") || token.starts_with("-f")
}
fn first_operand(rest: &[String], is_awk: bool) -> Option<usize> {
let mut i = 0;
while let Some(token) = rest.get(i) {
if is_awk && AWK_VALUE_FLAGS.contains(&token.as_str()) {
i += 2;
} else if (!is_awk && token.is_empty()) || (token.len() > 1 && token.starts_with('-')) {
i += 1;
} else {
return Some(i);
}
}
None
}
pub(crate) fn has_regex_quantifier(word: &str) -> bool {
word.as_bytes()
.windows(2)
.any(|pair| pair[0] == b'.' && pair[1] == b'*')
}
pub(crate) fn without_glob_metacharacters(word: &str) -> String {
word.chars()
.filter(|c| !matches!(c, '*' | '?' | '[' | ']'))
.collect()
}
const TEXT_PRINTERS: &[&str] = &["printf", "echo"];
pub(crate) fn prose_write_indices(segment: &str, argv: &[String]) -> Vec<usize> {
let Some(start) = crate::commands::hook_rewrite::strip_wrapper_prefix(argv) else {
return Vec::new();
};
let program = argv
.get(start)
.map_or("", |t| t.rsplit('/').next().unwrap_or(t));
if !TEXT_PRINTERS.contains(&program) || runs_a_nested_command(segment) {
return Vec::new();
}
let (mut prose, mut writes_a_file, mut target_next) = (Vec::new(), false, false);
for (index, token) in argv.iter().enumerate().skip(start + 1) {
if std::mem::take(&mut target_next) {
continue;
}
let is_word = token.chars().any(char::is_whitespace);
let role = if is_word {
RedirectRole::None
} else {
redirect_role(token)
};
if role != RedirectRole::None {
let descriptor = token.split_once('>').map_or("", |(d, _)| d);
let names_a_file =
matches!(role, RedirectRole::Target(_) | RedirectRole::TargetFollows);
writes_a_file |= names_a_file && matches!(descriptor, "" | "1" | "&");
target_next = role == RedirectRole::TargetFollows;
continue;
}
if !is_word
&& let Some((descriptor, rest)) = token.split_once('<')
&& descriptor.chars().all(|c| c.is_ascii_digit())
{
target_next = rest.trim_start_matches(['<', '&']).is_empty();
continue;
}
if program == "printf" && token.starts_with("-v") {
return Vec::new();
}
prose.push(index);
}
if writes_a_file { prose } else { Vec::new() }
}
fn runs_a_nested_command(segment: &str) -> bool {
let scan = super::shell_lex::QuoteScan::new(segment);
!scan.balanced
|| segment.bytes().enumerate().any(|(i, b)| {
(matches!(b, b'$' | b'`') && scan.allows_substitution(i))
|| (b == b'(' && scan.is_unquoted(i))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn words(command: &str) -> Vec<String> {
tokenize(command).expect("lexes")
}
#[test]
fn bash_tokens_reports_unbalanced_quoting() {
assert_eq!(
tokenize("awk '{print} terraform.tfvars"),
Err(TokenizeError::UnbalancedQuoting)
);
assert!(
TokenizeError::UnbalancedQuoting
.reason()
.contains("unbalanced")
);
assert_eq!(tokenize("ls -la").as_deref().map(<[String]>::len), Ok(2));
}
#[test]
fn bash_tokens_reads_every_redirect_spelling() {
assert_eq!(redirect_role("2>&1"), RedirectRole::FileDescriptor);
assert_eq!(redirect_role(">&2"), RedirectRole::FileDescriptor);
assert_eq!(redirect_role("2>&-"), RedirectRole::FileDescriptor);
assert_eq!(redirect_role(">"), RedirectRole::TargetFollows);
assert_eq!(redirect_role(">>"), RedirectRole::TargetFollows);
assert_eq!(redirect_role("2>"), RedirectRole::TargetFollows);
assert_eq!(redirect_role(">out.txt"), RedirectRole::Target("out.txt"));
assert_eq!(redirect_role(">>out.txt"), RedirectRole::Target("out.txt"));
assert_eq!(redirect_role("&>out.txt"), RedirectRole::Target("out.txt"));
assert_eq!(redirect_role(">&out.txt"), RedirectRole::Target("out.txt"));
for op in [
">|", ">!", ">>|", ">>!", "2>|", "2>!", "&>|", "&>!", ">&|", ">&!",
] {
assert_eq!(
redirect_role(&format!("{op}/dev/tty")),
RedirectRole::Target("/dev/tty"),
"{op}"
);
assert_eq!(redirect_role(op), RedirectRole::TargetFollows, "{op}");
}
assert_eq!(redirect_role(">&!2"), RedirectRole::Target("2"));
assert_eq!(redirect_role("2>&1-"), RedirectRole::FileDescriptor);
for (text, at) in [("a >|b", 3), ("a >>|b", 4), ("a >&|b", 4), ("a &>|b", 4)] {
assert!(is_clobber_bar(text.as_bytes(), at), "{text}");
}
for (text, at) in [("a | b", 2), ("a 2>&1|b", 6), ("|b", 0), ("a &|b", 3)] {
assert!(!is_clobber_bar(text.as_bytes(), at), "{text}");
}
assert_eq!(redirect_role("-rf"), RedirectRole::None);
assert_eq!(redirect_role("->"), RedirectRole::None);
}
#[test]
fn bash_tokens_finds_the_sed_expression() {
let argv = words("sed -i '' 's/^.*$/x/' patch.sh");
assert_eq!(program_text_indices("sed", &argv, 0), vec![3]);
let flagged = words("sed -e 's|.*foo|bar|' -e 'd' patch.sh");
assert_eq!(program_text_indices("sed", &flagged, 0), vec![2, 4]);
let awk = words("awk '/.*[Oo]pen/ {print}' spec.md");
assert_eq!(program_text_indices("awk", &awk, 0), vec![1]);
let inline = words("python3 -c 'import re'");
assert_eq!(program_text_indices("python3", &inline, 0), vec![2]);
let empty_awk = words("awk '' .env");
assert_eq!(program_text_indices("awk", &empty_awk, 0), vec![1]);
}
#[test]
fn bash_tokens_withdraws_on_a_program_file_flag() {
let awk = words("awk -f prog.awk .env");
assert!(program_text_indices("awk", &awk, 0).is_empty());
let sed = words("sed -f script.sed .env");
assert!(program_text_indices("sed", &sed, 0).is_empty());
let plain = words("cat .env");
assert!(program_text_indices("cat", &plain, 0).is_empty());
}
#[test]
fn bash_tokens_reads_a_quantifier_as_regex() {
for regex in [".*", ".*?", ".*?pen", "r.*?,", "PASSAGE.*"] {
assert!(has_regex_quantifier(regex), "{regex} carries a quantifier");
}
for name in [".env", ".netrc", "id_rsa", "secrets", ".", "", ".?nv", ".?"] {
assert!(!has_regex_quantifier(name), "{name} carries none");
}
assert!(has_regex_quantifier(".env.*"));
assert_eq!(without_glob_metacharacters(".env.*"), ".env.");
assert_eq!(without_glob_metacharacters(".*?pen"), ".pen");
assert_eq!(without_glob_metacharacters("/.*[Oo]pen/"), "/.Oopen/");
}
#[test]
fn bash_tokens_reads_prose_a_printer_writes_to_a_file() {
let prose = |c: &str| prose_write_indices(c, &words(c));
assert_eq!(prose("printf '%s\\n' 'a `.env` b' > out.md"), vec![1, 2]);
assert_eq!(prose("echo -n a .env >> out.md"), vec![1, 2, 3]);
assert_eq!(prose("FOO=1 echo a 2>&1 >out.md < in.txt"), vec![2]);
assert_eq!(prose("echo a &> out.md"), vec![1]);
assert_eq!(prose("printf '%s' '> a .env' > out.md"), vec![1, 2]);
for command in [
"echo a .env",
"echo a .env 2> err.md",
"cat .env > out.md",
"printf -v F .env > out.md",
"echo \"$(cat .env)\" > out.md",
"echo \"`cat .env`\" > out.md",
"echo .env > >(cat)",
] {
assert!(prose(command).is_empty(), "no prose in `{command}`");
}
}
}