#[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 = rest.strip_prefix('>').unwrap_or(rest);
let rest = rest.strip_prefix('|').unwrap_or(rest);
if let Some(after) = rest.strip_prefix('&') {
if after == "-" || (!after.is_empty() && after.chars().all(|c| c.is_ascii_digit())) {
return RedirectRole::FileDescriptor;
}
return if after.is_empty() {
RedirectRole::TargetFollows
} else {
RedirectRole::Target(after)
};
}
if rest.is_empty() {
RedirectRole::TargetFollows
} else {
RedirectRole::Target(rest)
}
}
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 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 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()
}
#[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"));
assert_eq!(redirect_role(">&out.txt"), RedirectRole::Target("out.txt"));
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/");
}
}