use std::borrow::Cow;
use std::ops::Range;
use super::heredoc::{HeredocBodies, blank_spans};
use super::heredoc_line::{line_names, line_reads_as_data};
use super::shell_lex::QuoteScan;
use super::{is_evaluator, split_shell_segments};
pub(crate) const MAX_SUBSTITUTION_DEPTH: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Substitution {
Closed(String),
Unclosed(String),
}
impl Substitution {
pub(crate) fn text(&self) -> &str {
match self {
Self::Closed(text) | Self::Unclosed(text) => text,
}
}
}
pub(super) fn paren_substitution_live_at(scan: &QuoteScan, bytes: &[u8], i: usize) -> Option<bool> {
if bytes.get(i + 1).copied() != Some(b'(') {
return None;
}
match bytes[i] {
b'$' => Some(!scan.balanced || scan.allows_substitution(i)),
b'<' | b'>' => Some(!scan.balanced || scan.is_unquoted(i)),
_ => None,
}
}
pub(super) fn segment_substitutions(segment: &str) -> Vec<Substitution> {
segment_substitution_spans(segment)
.into_iter()
.map(|(_, body)| body)
.collect()
}
pub(crate) fn segment_substitution_spans(segment: &str) -> Vec<(Range<usize>, Substitution)> {
let scan = QuoteScan::new(segment);
scan_bodies(
segment,
scan.balanced,
|bytes, i| paren_substitution_live_at(&scan, bytes, i),
|i| !scan.balanced || scan.allows_substitution(i),
)
}
fn scan_bodies(
text: &str,
honour_escapes: bool,
paren_live: impl Fn(&[u8], usize) -> Option<bool>,
backtick_live: impl Fn(usize) -> bool,
) -> Vec<(Range<usize>, Substitution)> {
let bytes = text.as_bytes();
let mut found = Vec::new();
let mut i = 0;
while i < bytes.len() {
if honour_escapes && matches!(bytes[i], b'$' | b'<' | b'>' | b'`') && escaped(bytes, i) {
i += 1;
continue;
}
if let Some(live) = paren_live(bytes, i) {
if !live {
i += 1;
continue;
}
let (mut paren, mut j) = (1usize, i + 2);
while j < bytes.len() && paren > 0 {
match bytes[j] {
b'(' => paren += 1,
b')' => paren -= 1,
_ => {}
}
j += 1;
}
if paren != 0 {
found.push((
i..text.len(),
Substitution::Unclosed(text[i + 2..].to_string()),
));
return found;
}
found.push((i..j, Substitution::Closed(text[i + 2..j - 1].to_string())));
i = j;
continue;
}
if bytes[i] == b'`' && backtick_live(i) {
let Some(len) = text[i + 1..].find('`') else {
found.push((
i..text.len(),
Substitution::Unclosed(text[i + 1..].to_string()),
));
return found;
};
let body = Substitution::Closed(text[i + 1..i + 1 + len].to_string());
found.push((i..i + len + 2, body));
i += len + 2;
continue;
}
i += 1;
}
found
}
fn escaped(bytes: &[u8], i: usize) -> bool {
bytes[..i].iter().rev().take_while(|b| **b == b'\\').count() % 2 == 1
}
pub(crate) fn command_substitutions(command: &str) -> Vec<Substitution> {
let heredocs = HeredocBodies::scan(command);
let spans: Vec<(usize, usize)> = heredocs.data().iter().map(|b| b.span).collect();
let argv_text = blank_spans(command, &spans);
let found: Vec<Substitution> = split_shell_segments(&argv_text)
.iter()
.flat_map(|segment| segment_substitutions(segment.trim()))
.collect();
with_expanded(found, command, heredocs.expanding())
}
fn with_expanded(
mut found: Vec<Substitution>,
command: &str,
expanding: &[(usize, usize)],
) -> Vec<Substitution> {
for &(start, end) in expanding {
let text = join_continuations(&command[start..end]);
let bodies = scan_bodies(
&text,
true,
|bytes, i| (bytes[i] == b'$' && bytes.get(i + 1) == Some(&b'(')).then_some(true),
|_| true,
);
for (_, body) in bodies {
if !found.contains(&body) {
found.push(body);
}
}
}
found
}
fn join_continuations(body: &str) -> Cow<'_, str> {
if !body.contains("\\\n") {
return Cow::Borrowed(body);
}
let bytes = body.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut run = 0usize;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
run += 1;
if run % 2 == 1 && bytes.get(i + 1) == Some(&b'\n') {
run = 0;
i += 2;
continue;
}
} else {
run = 0;
}
out.push(bytes[i]);
i += 1;
}
Cow::Owned(String::from_utf8_lossy(&out).into_owned())
}
fn unescape_expanding_body(body: &str) -> String {
let mut out = String::with_capacity(body.len());
let mut chars = body.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.peek() {
Some('\\' | '$' | '`') => {
out.extend(chars.next());
continue;
}
Some('\n') => {
chars.next();
continue;
}
_ => {}
}
}
out.push(c);
}
out
}
pub(crate) const HEREDOC_RUNNERS: &[&str] = &["parallel", "sudo", "doas", "su", "runuser"];
fn names_a_runner(line: &str, runners: &[&str]) -> bool {
line_names(line, |word| runners.contains(&word))
}
pub(crate) fn shell_run_heredoc_bodies(command: &str) -> Vec<String> {
HeredocBodies::scan(command)
.bodies()
.iter()
.filter(|body| !line_reads_as_data(&command[body.operator_line.0..body.operator_line.1]))
.map(|body| {
let text = &command[body.span.0..body.span.1];
if body.expands {
unescape_expanding_body(text)
} else {
text.to_string()
}
})
.collect()
}
pub(crate) fn heredoc_expansions(command: &str) -> Option<Vec<Substitution>> {
let mut found = Vec::new();
collect_expansions(command, 0, &mut found).then_some(found)
}
fn collect_expansions(command: &str, depth: usize, found: &mut Vec<Substitution>) -> bool {
if depth > MAX_SUBSTITUTION_DEPTH {
return false;
}
let heredocs = HeredocBodies::scan(command);
for body in with_expanded(Vec::new(), command, heredocs.expanding()) {
if !found.contains(&body) {
found.push(body);
}
}
shell_run_heredoc_bodies(command)
.iter()
.all(|body| collect_expansions(body, depth + 1, found))
}
pub(crate) fn blank_inert_heredocs(command: &str, runners: &[&str]) -> (String, Vec<Substitution>) {
let heredocs = HeredocBodies::scan(command);
let spans: Vec<(usize, usize)> = heredocs
.data()
.iter()
.filter(|b| {
let line = &command[b.operator_line.0..b.operator_line.1];
!runs_as_code(line) && !names_a_runner(line, runners)
})
.map(|b| b.span)
.collect();
(
blank_spans(command, &spans),
with_expanded(Vec::new(), command, heredocs.expanding()),
)
}
pub(crate) fn without_inert_heredoc_bodies(command: &str) -> String {
let inert: Vec<(usize, usize)> = HeredocBodies::scan(command)
.data()
.iter()
.filter(|b| !runs_as_code(&command[b.operator_line.0..b.operator_line.1]))
.map(|b| b.span)
.collect();
blank_spans(command, &inert)
}
fn runs_as_code(line: &str) -> bool {
line_names(line, |word| is_evaluator(&word.to_ascii_lowercase()))
}
#[cfg(test)]
mod tests {
use super::*;
fn closed(texts: &[&str]) -> Vec<Substitution> {
texts
.iter()
.map(|t| Substitution::Closed((*t).to_string()))
.collect()
}
#[test]
fn substitutions_list_each_live_body_once() {
let cases: &[(&str, Vec<Substitution>)] = &[
("echo \"$(pm2 jlist)\"", closed(&["pm2 jlist"])),
("echo `date` $(a $(b))", closed(&["date", "a $(b)"])),
("diff <(a) >(b)", closed(&["a", "b"])),
("echo '$(a)' \"<(b)\"", Vec::new()),
("echo $(a", vec![Substitution::Unclosed("a".to_string())]),
(r#"echo \$(a) "\`b\`" \\$(c)"#, closed(&["c"])),
];
for (segment, want) in cases {
assert_eq!(&segment_substitutions(segment), want, "{segment:?}");
}
}
#[test]
fn substitution_spans_cover_opener_and_close_8931() {
let segment = "cat x$(a b)/y `c` \"$(d)\" $(e";
let spans: Vec<&str> = segment_substitution_spans(segment)
.into_iter()
.map(|(range, _)| &segment[range])
.collect();
assert_eq!(spans, ["$(a b)", "`c`", "$(d)", "$(e"]);
}
#[test]
fn substitutions_read_an_expanding_heredoc_body_only() {
assert_eq!(
command_substitutions("cat <<EOF\nit's '$(a)' `b`\nEOF"),
closed(&["a", "b"])
);
assert!(command_substitutions("cat <<'EOF'\n$(a)\nEOF").is_empty());
assert_eq!(command_substitutions("bash -c 'echo $(a)'"), closed(&["a"]));
}
#[test]
fn substitutions_read_a_shell_run_expanding_heredoc_body_9155() {
assert_eq!(
command_substitutions("bash <<X\necho '$(a)' `b` $(c)\nX"),
closed(&["b", "c", "a"])
);
assert!(command_substitutions("bash <<'X'\necho '$(a)'\nX").is_empty());
}
#[test]
fn blank_inert_heredocs_returns_every_expanding_body_9155() {
for kept in [
"python3 - <<PY\nprint('$(a)')\nPY",
"bash <<X\necho '$(a)'\nX",
] {
let (argv, found) = blank_inert_heredocs(kept, &[]);
assert_eq!(argv, kept);
assert_eq!(found, closed(&["a"]), "{kept:?}");
}
let (_, found) = blank_inert_heredocs("python3 - <<'PY'\nprint('$(a)')\nPY", &[]);
assert!(found.is_empty());
let (argv, found) = blank_inert_heredocs("cat <<EOF\n'$(a)'\nEOF", &[]);
assert!(!argv.contains("$(a)"));
assert_eq!(found, closed(&["a"]));
}
#[test]
fn substitutions_join_a_line_continuation_in_an_expanding_body_9155() {
for command in [
"cat <<X\n'$\\\n(a)'\nX",
"python3 - <<PY\nprint('$\\\n(a)')\nPY",
] {
assert_eq!(
command_substitutions(command),
closed(&["a"]),
"{command:?}"
);
}
for command in [
"cat <<X\n'$\\\\\n(a)'\nX",
"python3 - <<PY\nprint('a' \\\n 'b')\nPY",
"cat <<'X'\n'$\\\n(a)'\nX",
] {
assert!(command_substitutions(command).is_empty(), "{command:?}");
}
}
#[test]
fn shell_run_heredoc_bodies_are_read_as_whole_commands_9155() {
assert_eq!(
shell_run_heredoc_bodies("bash <<'O'\nbash <<I\nx\nI\nO"),
["bash <<I\nx\nI\n"]
);
assert_eq!(
shell_run_heredoc_bodies("sudo -s <<O\necho \\$(a) \\\\ \\q\nO"),
["echo $(a) \\ \\q\n"]
);
assert!(shell_run_heredoc_bodies("cat <<'O'\nbash <<I\nx\nI\nO").is_empty());
}
#[test]
fn shell_run_heredoc_bodies_include_an_unterminated_body_9180() {
assert_eq!(
shell_run_heredoc_bodies("bash <<'O'\ncat <<I\nx\nI"),
["cat <<I\nx\nI"]
);
assert_eq!(shell_run_heredoc_bodies("(sudo -s<<'O'\nx\nO\n)"), ["x\n"]);
assert!(shell_run_heredoc_bodies("cat <<'O'\nbash <<I\nx\nI").is_empty());
}
#[test]
fn heredoc_expansions_reach_a_nested_body_9180() {
assert_eq!(
heredoc_expansions("bash <<'O'\ncat <<I\n'$(a)'\nI\nO"),
Some(closed(&["a"]))
);
assert_eq!(
heredoc_expansions("bash <<'O'\ncat <<'I'\n'$(a)'\nI\nO"),
Some(Vec::new())
);
}
#[test]
fn inert_heredoc_bodies_leave_the_argv_text() {
let inert = "git commit -F - <<'EOF'\npm2 jlist\nEOF";
assert!(!without_inert_heredoc_bodies(inert).contains("pm2"));
for kept in [
"ssh host <<'EOF'\npm2 jlist\nEOF",
"/usr/bin/python3 - <<'EOF'\npm2 jlist\nEOF",
"bash <<'EOF'\npm2 jlist\nEOF",
] {
assert_eq!(without_inert_heredoc_bodies(kept), kept);
}
}
}