use super::heredoc::{HEREDOC_DELIMITER_REASON, HeredocBodies};
use super::shell_lex::DASH_C_SHELLS;
const BREAKS: &[u8] = b";&|()<>`";
const COMMAND_STARTS: &[u8] = b";&|(`";
const DATA_READERS: &[&str] = &["cat", "tee", "git", "gh", "tm", "echo", "printf", "true"];
const OTHER_SHELLS: &[&str] = &[
".", "source", "eval", "csh", "tcsh", "mksh", "yash", "rbash",
];
pub(crate) const SHADOWED_READER_REASON: &str = "this command defines a shell function or \
alias named like a program the guard reads a here-document body as data for (`cat`, `tee`, \
`git`, `gh`, `tm`, `echo`, `printf`, `true`), so a body handed to that name may run as shell \
source (#9180). Use the program under its own name, or give the function another name.";
struct Word {
text: String,
raw: String,
program: bool,
assignment: bool,
literal: bool,
depth: usize,
closed: bool,
quoted: bool,
}
pub(super) fn line_runs_a_shell(line: &str) -> bool {
let words = operator_words(line);
words.iter().enumerate().any(|(at, word)| {
is_shell(&word.text)
|| (word.program && (is_parameter(&word.text) || OTHER_SHELLS.contains(&&*word.text)))
|| (word.program && git_injects_config(&words, at))
})
}
pub(super) fn line_reads_as_data(line: &str) -> bool {
let words = operator_words(line);
!expansion_holds_a_paren(line.as_bytes())
&& !words.iter().any(|word| word.quoted)
&& words
.iter()
.enumerate()
.filter(|(_, word)| word.program && !word.assignment)
.all(|(at, word)| {
word.literal
&& DATA_READERS.contains(&&*word.text)
&& reader_keeps_trust(&words, at)
})
}
fn expansion_holds_a_paren(line: &[u8]) -> bool {
let mut at = 0;
while let Some(start) = line[at..].windows(2).position(|w| w == b"${") {
let mut depth = 0usize;
for &byte in &line[at + start + 1..] {
match byte {
b'{' => depth += 1,
b'}' => depth -= 1,
b'(' | b')' | b'`' | b'"' | b'\'' | b'\\' => return true,
_ => {}
}
if depth == 0 {
break;
}
}
at += start + 2;
}
false
}
const GIT_VALUE_OPTIONS: &[&str] = &[
"-C",
"--git-dir",
"--work-tree",
"--namespace",
"--super-prefix",
"--attr-source",
];
const GIT_DATA_SUBCOMMANDS: &[&str] = &[
"commit",
"tag",
"notes",
"apply",
"am",
"hash-object",
"update-ref",
"update-index",
"cat-file",
"mktree",
"mktag",
"check-ignore",
"check-attr",
"check-mailmap",
"interpret-trailers",
"stripspace",
"fast-import",
"patch-id",
"mailinfo",
];
const GH_DATA_SUBCOMMANDS: &[&str] = &[
"api", "gist", "issue", "label", "pr", "project", "release", "repo", "run", "search", "secret",
"variable", "workflow",
];
fn reader_keeps_trust(words: &[Word], at: usize) -> bool {
let bare =
|w: &Word| !w.closed && matches!(w.raw.split_once('=').map(|(_, v)| v), Some("" | "$"));
let prefix_ok = prefix(words, at).all(bare);
let mut args = arguments(words, at);
match words[at].text.as_str() {
"git" => {
prefix_ok
&& !git_injects_config(words, at)
&& git_subcommand(words, at).is_some_and(|s| GIT_DATA_SUBCOMMANDS.contains(&s))
}
"gh" => {
prefix_ok
&& args
.next()
.is_some_and(|w| GH_DATA_SUBCOMMANDS.contains(&&*w.raw))
}
"tm" => prefix_ok,
_ => true,
}
}
fn git_injects_config(words: &[Word], at: usize) -> bool {
words[at].text == "git"
&& (prefix(words, at)
.any(|w| w.raw.starts_with("GIT_CONFIG") || w.raw.starts_with("GIT_EXEC_PATH"))
|| arguments(words, at).any(|w| {
["-c", "--config", "--exec-path"]
.iter()
.any(|p| w.raw.starts_with(p))
|| w.raw.to_ascii_lowercase().contains("alias.")
}))
}
fn git_subcommand(words: &[Word], at: usize) -> Option<&str> {
let mut args = arguments(words, at);
while let Some(word) = args.next() {
if GIT_VALUE_OPTIONS.contains(&&*word.raw) {
args.next();
} else if !word.raw.starts_with('-') {
return Some(&word.raw);
}
}
None
}
fn prefix(words: &[Word], at: usize) -> impl Iterator<Item = &Word> {
let depth = words[at].depth;
words[..at]
.iter()
.rev()
.filter(move |w| w.depth <= depth)
.take_while(|w| w.program && w.assignment)
}
fn arguments(words: &[Word], at: usize) -> impl Iterator<Item = &Word> {
words[at + 1..].iter().take_while(|w| !w.program)
}
pub(super) fn line_names(line: &str, names: impl Fn(&str) -> bool) -> bool {
operator_words(line).iter().any(|word| names(&word.text))
}
fn is_parameter(word: &str) -> bool {
word.len() > 1 && word.starts_with('$')
}
fn is_shell(word: &str) -> bool {
let bare = word.trim_end_matches(|c: char| c.is_ascii_digit() || matches!(c, '.' | '-'));
DASH_C_SHELLS.contains(&word) || (!bare.is_empty() && DASH_C_SHELLS.contains(&bare))
}
fn operator_words(line: &str) -> Vec<Word> {
let mut words = Vec::new();
let mut word = Vec::new();
let mut program = true;
let mut glued = false;
let mut after_close = false;
let mut in_tick = false;
let mut parens: Vec<Option<ValueOpen>> = Vec::new();
let mut tick_value: Option<ValueOpen> = None;
let mut escaped = false;
let bytes = line.as_bytes();
let is_quote = |b: &u8| matches!(b, b'\'' | b'"' | b'\\');
let in_value = |word: &[u8], program: bool| {
program && strip_assignment(&String::from_utf8_lossy(word)).is_some()
};
let flush = |words: &mut Vec<Word>,
word: &mut Vec<u8>,
program: &mut bool,
glued: &mut bool,
depth: usize| {
if word.is_empty() {
return;
}
let text = String::from_utf8_lossy(word).into_owned();
word.clear();
let assignment = strip_assignment(&text);
let value = assignment.unwrap_or(&text);
let base = value.rsplit('/').next().unwrap_or(value).to_string();
words.push(Word {
literal: !*glued && !text.contains('$'),
raw: text.clone(),
text: base,
program: *program,
assignment: assignment.is_some(),
depth,
closed: false,
quoted: false,
});
*glued = false;
if assignment.is_none() {
*program = false;
}
};
for (i, &byte) in bytes.iter().enumerate() {
let depth = usize::from(tick_value.is_some()) + parens.iter().flatten().count();
match byte {
b'\'' | b'"' | b'\\' => escaped |= byte != b'"',
b' ' | b'\t' | b'\n' | b'\r' => {
flush(&mut words, &mut word, &mut program, &mut glued, depth);
after_close = false;
escaped = false;
}
b'`' if !in_tick => {
in_tick = true;
let takes_value = strip_assignment(&String::from_utf8_lossy(&word)) == Some("");
let opens_value = in_value(&word, program);
glued |= !word.is_empty() && !takes_value;
if word.is_empty() && program && !after_close {
word.push(b'`');
glued = true;
}
flush(&mut words, &mut word, &mut program, &mut glued, depth);
tick_value = opens_value.then(|| (words.len() - 1, i, escaped));
program = true;
after_close = false;
escaped = false;
}
_ if BREAKS.contains(&byte) => {
let opens_value = byte == b'(' && in_value(&word, program);
let escaped_open = std::mem::take(&mut escaped);
flush(&mut words, &mut word, &mut program, &mut glued, depth);
let closes = match byte {
b'`' => {
in_tick = false;
tick_value.take()
}
b'(' => {
parens.push(opens_value.then(|| (words.len() - 1, i, escaped_open)));
None
}
b')' => parens.pop().flatten(),
_ => None,
};
if let Some((at, open, _)) = closes {
words[at].closed = true;
words[at].quoted = bytes[open..i].iter().any(is_quote);
program = true;
}
after_close = matches!(byte, b')' | b'`');
if COMMAND_STARTS.contains(&byte) && byte != b'`' {
program = true;
}
}
_ => {
glued |= word.is_empty() && after_close;
after_close = false;
word.push(byte);
}
}
}
let depth = usize::from(tick_value.is_some()) + parens.iter().flatten().count();
flush(&mut words, &mut word, &mut program, &mut glued, depth);
let skip = delimiter_quotes(bytes);
for (at, open, escaped_open) in parens.into_iter().flatten().chain(tick_value) {
let loose = (open..bytes.len()).any(|k| is_quote(&bytes[k]) && !skip[k]);
words[at].quoted |= escaped_open || loose;
}
words
}
type ValueOpen = (usize, usize, bool);
fn delimiter_quotes(line: &[u8]) -> Vec<bool> {
let mut skip = vec![false; line.len()];
let name = |s: &[u8]| {
!s.is_empty()
&& s.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
};
let mut i = 0;
while i + 1 < line.len() {
let here = line[i] == b'<'
&& line[i + 1] == b'<'
&& line.get(i + 2) != Some(&b'<')
&& (i == 0 || line[i - 1] != b'<');
if !here {
i += 1;
continue;
}
let mut start = i + 2;
start += usize::from(line.get(start) == Some(&b'-'));
while matches!(line.get(start), Some(b' ' | b'\t')) {
start += 1;
}
let end = line[start..]
.iter()
.position(|b| b.is_ascii_whitespace() || BREAKS.contains(b))
.map_or(line.len(), |n| start + n);
let plain = match &line[start..end] {
[b'\'', inner @ .., b'\''] | [b'"', inner @ .., b'"'] => name(inner),
[b'\\', inner @ ..] => name(inner),
_ => false,
};
if plain {
for at in start..end {
skip[at] = matches!(line[at], b'\'' | b'"' | b'\\');
}
}
i = end.max(i + 2);
}
skip
}
fn strip_assignment(word: &str) -> Option<&str> {
let (name, value) = word.split_once('=')?;
let mut chars = name.chars();
let first = chars.next()?;
let valid = (first.is_ascii_alphabetic() || first == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
valid.then_some(value)
}
pub(super) fn heredoc_refusal(command: &str) -> Option<&'static str> {
if HeredocBodies::scan(command).is_unscannable() {
return Some(HEREDOC_DELIMITER_REASON);
}
let shadows = command.contains("<<")
&& defined_names(command)
.iter()
.any(|name| DATA_READERS.contains(&name.as_str()));
shadows.then_some(SHADOWED_READER_REASON)
}
fn defined_names(command: &str) -> Vec<String> {
let plain: Vec<u8> = command
.replace("\\\n", "")
.bytes()
.filter(|b| !matches!(b, b'\'' | b'"' | b'\\'))
.collect();
let mut tokens: Vec<&[u8]> = Vec::new();
let mut start = None;
for (i, &byte) in plain.iter().enumerate() {
let single = b";&|(){}<>`".contains(&byte);
if single || byte.is_ascii_whitespace() {
if let Some(s) = start.take() {
tokens.push(&plain[s..i]);
}
if single {
tokens.push(&plain[i..=i]);
}
} else if start.is_none() {
start = Some(i);
}
}
if let Some(s) = start {
tokens.push(&plain[s..]);
}
let separator = |t: &[u8]| t.len() == 1 && b";&|(){}<>`".contains(&t[0]);
let body = |t: Option<&&[u8]>| {
t.is_some_and(|t| {
matches!(
*t,
b"{" | b"(" | b"[[" | b"if" | b"for" | b"while" | b"until" | b"case" | b"select"
)
})
};
let mut names = Vec::new();
for (i, token) in tokens.iter().enumerate() {
let next = tokens.get(i + 1);
if !separator(token)
&& next == Some(&&b"("[..])
&& tokens.get(i + 2) == Some(&&b")"[..])
&& body(tokens.get(i + 3))
{
names.push(*token);
} else if *token == b"function" && next.is_some_and(|t| !separator(t)) {
names.extend(next);
} else if *token == b"alias" {
let words = tokens[i + 1..].iter().take_while(|t| !separator(t));
names
.extend(words.filter_map(|w| w.iter().position(|b| *b == b'=').map(|at| &w[..at])));
}
}
names
.into_iter()
.map(|name| {
let name = String::from_utf8_lossy(name);
name.rsplit('/').next().unwrap_or(&name).to_string()
})
.collect()
}
#[cfg(test)]
#[path = "data_reader_tests.rs"]
mod data_reader_tests;
#[cfg(test)]
#[path = "unclosed_value_substitution_tests.rs"]
mod unclosed_value_substitution_tests;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operator_lines_name_a_glued_or_grouped_shell_9180() {
for line in [
"bash<<'O'",
"(bash <<'O'",
"true;bash <<'O'",
"true&&bash <<'O'",
"echo x|bash <<'O'",
"x=$(bash <<'O'",
"`bash <<'O'",
"\"bash\" <<'O'",
"b'a'sh <<'O'",
"\\bash <<'O'",
"/bin/bash5.2 <<'O'",
"X=1 bash<<'O'",
"$SHELL <<'O'",
"FOO=1 \"${SHELL}\" <<'O'",
"{ sh <<'O'",
] {
assert!(line_runs_a_shell(line), "{line:?}");
}
}
#[test]
fn operator_lines_keep_a_data_reader_as_data_9180() {
for line in [
"cat <<'EOF'",
"git commit -F - <<'EOF'",
"cat > \"$OUT\" <<'EOF'",
"python3 - \"$ARG\" <<'PY'",
"cat <<EOF >>$LOG",
"tee notes.md <<'EOF'",
"x=$(cat <<'EOF'",
"git commit -m \"$(cat <<'EOF'",
] {
assert!(!line_runs_a_shell(line), "{line:?}");
}
}
#[test]
fn operator_lines_read_as_data_only_for_a_literal_reader_9180() {
for data in [
"cat <<'EOF'",
"git commit -F - <<'EOF'",
"gh pr create --body-file - <<'EOF'",
"cat <<'EOF' | git commit -F -",
"x=$(cat <<'EOF'",
"x=`cat <<'EOF'",
"git commit -m \"$(cat <<'EOF'",
"echo \"$(cat <<EOF",
"cat > \"$OUT\" <<'EOF'",
"tee notes.md <<'EOF'",
] {
assert!(line_reads_as_data(data), "{data:?}");
}
for shell_run in [
"$(printf bas)h <<'O'",
"b$X <<'O'",
"f <<'O'",
". /dev/stdin <<'O'",
"source /dev/stdin <<'O'",
"tcsh <<'O'",
"/bin/csh <<'O'",
"eval \"$(cat <<'O'",
"`printf ba`sh <<'O'",
"ba`printf sh` <<'O'",
"python3 - <<'PY'",
"sudo -s <<'O'",
"cat <<'EOF' | bash",
] {
assert!(!line_reads_as_data(shell_run), "{shell_run:?}");
}
}
#[test]
fn shadowing_a_data_reader_is_refused_9180() {
for refused in [
"cat(){ bash; }\ncat <<'O'\nx\nO",
"function git { bash; }; git <<'O'\nx\nO",
"alias cat=bash\ncat <<'O'\nx\nO",
"c\\\nat () { bash; }; cat <<'O'\nx\nO",
"'tee'() ( bash ); tee <<'O'\nx\nO",
] {
assert_eq!(
heredoc_refusal(refused),
Some(SHADOWED_READER_REASON),
"{refused:?}"
);
}
for allowed in [
"f(){ bash; }\nf <<'O'\nx\nO",
"cat(){ bash; }; cat x",
"cat > a.py <<'EOF'\ndef cat():\n pass\nEOF",
"cat <<'EOF'\nprint()\nEOF",
] {
assert_eq!(heredoc_refusal(allowed), None, "{allowed:?}");
}
}
}