use super::policy::Direction;
use crate::utils::paths::{absolutize, expand_home};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Origin {
TopLevel,
Substitution,
Subshell,
HeredocBody,
}
impl Origin {
pub(super) fn note(self) -> &'static str {
match self {
Origin::TopLevel => "",
Origin::Substitution => "inside command substitution",
Origin::Subshell => "inside subshell",
Origin::HeredocBody => "inside heredoc body",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Word {
pub text: String,
pub quoted: bool,
}
#[derive(Debug, Clone)]
pub(super) struct Command {
pub text: String,
pub words: Vec<Word>,
pub origin: Origin,
pub redirects: Vec<(String, Direction)>,
pub cwd: PathBuf,
pub cwd_unknown: bool,
}
impl Command {
fn writes_paths(&self) -> bool {
self.words
.first()
.map(|w| write_effect_command(&w.text))
.unwrap_or(false)
}
pub(super) fn path_targets(&self) -> Vec<(String, Direction)> {
let mut out: Vec<(String, Direction)> = Vec::new();
out.extend(self.redirects.iter().cloned());
let write = self.writes_paths();
for word in &self.words {
if !path_shape_or_existing(&word.text, &self.cwd) {
continue;
}
if self.redirects.iter().any(|(p, _)| p == &word.text) {
continue;
}
let dir = if write {
Direction::Write
} else {
Direction::Read
};
out.push((word.text.clone(), dir));
}
out
}
pub(super) fn absolute_text(&self) -> Option<String> {
if self.cwd_unknown {
return None;
}
let mut changed = false;
let mut parts: Vec<String> = Vec::with_capacity(self.words.len());
for (idx, word) in self.words.iter().enumerate() {
let absolutizable = idx > 0
&& !word.text.starts_with('-')
&& !word.text.contains(['*', '?', '[', '$', '`'])
&& !word.text.is_empty();
if absolutizable {
let abs = absolutize(&word.text, &self.cwd);
let abs = abs.to_string_lossy().to_string();
if abs != word.text {
changed = true;
parts.push(abs);
continue;
}
}
parts.push(word.text.clone());
}
changed.then(|| parts.join(" "))
}
}
#[derive(Debug, Clone, Default)]
pub(super) struct Scan {
pub commands: Vec<Command>,
pub nested: Vec<Command>,
pub indirect: Vec<String>,
pub complete: bool,
}
impl Scan {
pub(super) fn all_commands(&self) -> impl Iterator<Item = &Command> {
self.commands.iter().chain(self.nested.iter())
}
}
pub(super) fn scan(input: &str) -> Scan {
let (stripped, heredocs) = strip_heredocs(input);
let mut scanner = Scanner::new(&stripped);
let mut commands = scanner.parse_statements(0, Origin::TopLevel);
let complete = scanner.complete;
let mut nested: Vec<Command> = std::mem::take(&mut scanner.nested);
let mut scanned_body: Vec<Command> = Vec::new();
for body in &heredocs {
if !body.interpolating {
continue;
}
let mut inner = Scanner::new(&body.text);
let mut found = inner.parse_statements(0, Origin::HeredocBody);
found.extend(inner.nested);
scanned_body.extend(found);
}
apply_cd_tracking(&mut commands, &mut nested);
nested.extend(scanned_body);
Scan {
commands,
nested,
indirect: scanner.indirect,
complete,
}
}
fn apply_cd_tracking(top: &mut [Command], nested: &mut [Command]) {
let mut cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut unknown = false;
for cmd in top.iter_mut() {
cmd.cwd = cwd.clone();
cmd.cwd_unknown = unknown;
if let Some(effect) = cd_effect(cmd) {
match effect {
Some(target) => {
let home = expand_home(&target);
cwd = absolutize(&home, &cwd);
unknown = false;
}
None => unknown = true,
}
}
}
for cmd in nested.iter_mut() {
cmd.cwd = cwd.clone();
cmd.cwd_unknown = unknown;
}
}
fn cd_effect(cmd: &Command) -> Option<Option<String>> {
let first = cmd.words.first()?;
if first.text != "cd" && first.text != "pushd" {
return None;
}
Some(match cmd.words.get(1) {
Some(w) if !w.text.contains('$') && !w.text.contains('`') && w.text != "-" => {
Some(w.text.clone())
}
_ => None,
})
}
#[derive(Debug, Clone)]
struct Heredoc {
text: String,
interpolating: bool,
}
fn strip_heredocs(input: &str) -> (String, Vec<Heredoc>) {
let mut out_lines: Vec<String> = Vec::new();
let mut bodies: Vec<Heredoc> = Vec::new();
let mut lines = input.lines().peekable();
while let Some(line) = lines.next() {
let pending = heredoc_delimiters(line);
out_lines.push(line.to_string());
if pending.is_empty() {
continue;
}
for (delimiter, strip_tabs, quoted) in pending {
let mut body = String::new();
for next in lines.by_ref() {
let probe = if strip_tabs {
next.trim_start_matches('\t')
} else {
next
};
if probe.trim_end() == delimiter {
break;
}
body.push_str(next);
body.push('\n');
}
if !quoted {
bodies.push(Heredoc {
text: body,
interpolating: true,
});
}
}
}
(out_lines.join("\n"), bodies)
}
fn heredoc_delimiters(line: &str) -> Vec<(String, bool, bool)> {
let chars: Vec<char> = line.chars().collect();
let mut out = Vec::new();
let mut i = 0usize;
let mut quote: Option<char> = None;
while i < chars.len() {
let c = chars[i];
match quote {
Some(q) => {
if c == q {
quote = None;
}
i += 1;
continue;
}
None => {
if c == '\'' || c == '"' {
quote = Some(c);
i += 1;
continue;
}
if c == '\\' {
i += 2;
continue;
}
}
}
if c == '<' && chars.get(i + 1) == Some(&'<') {
if chars.get(i + 2) == Some(&'<') {
i += 3;
continue;
}
let strip_tabs = chars.get(i + 2) == Some(&'-');
let mut j = if strip_tabs { i + 3 } else { i + 2 };
while chars.get(j).is_some_and(|c| c.is_whitespace()) {
j += 1;
}
let quoted = chars.get(j).is_some_and(|c| *c == '\'' || *c == '"');
let (word, next) = read_shell_word(&chars, j);
if !word.is_empty() {
out.push((word, strip_tabs, quoted));
}
i = next.max(i + 2);
continue;
}
i += 1;
}
out
}
fn read_shell_word(chars: &[char], start: usize) -> (String, usize) {
let mut text = String::new();
let mut i = start;
let mut quote: Option<char> = None;
while i < chars.len() {
let c = chars[i];
match quote {
Some(q) => {
if c == q {
quote = None;
} else if c == '\\' && q == '"' {
if let Some(next) = chars.get(i + 1) {
text.push(*next);
i += 2;
continue;
}
} else {
text.push(c);
}
i += 1;
}
None => {
if c.is_whitespace() || ";&|<>()".contains(c) {
break;
}
if c == '\'' || c == '"' {
quote = Some(c);
i += 1;
continue;
}
if c == '\\'
&& let Some(next) = chars.get(i + 1)
{
text.push(*next);
i += 2;
continue;
}
text.push(c);
i += 1;
}
}
}
(text, i)
}
struct Scanner {
chars: Vec<char>,
i: usize,
nested: Vec<Command>,
indirect: Vec<String>,
complete: bool,
}
impl Scanner {
fn new(input: &str) -> Self {
Scanner {
chars: input.chars().collect(),
i: 0,
nested: Vec::new(),
indirect: Vec::new(),
complete: true,
}
}
#[allow(unused_assignments)]
fn parse_statements(&mut self, depth: usize, origin: Origin) -> Vec<Command> {
let mut out: Vec<Command> = Vec::new();
let mut words: Vec<Word> = Vec::new();
let mut redirects: Vec<(String, Direction)> = Vec::new();
let mut cur = String::new();
let mut cur_quoted = false;
let mut quote: Option<char> = None;
let mut pending_redirect: Option<Direction> = None;
macro_rules! flush_word {
() => {
if !cur.is_empty() || cur_quoted {
let text = std::mem::take(&mut cur);
let word = Word {
text,
quoted: cur_quoted,
};
cur_quoted = false;
if let Some(dir) = pending_redirect.take() {
redirects.push((word.text.clone(), dir));
}
words.push(word);
}
};
}
macro_rules! flush_command {
() => {
flush_word!();
pending_redirect = None;
if !words.is_empty() {
let words = std::mem::take(&mut words);
if let Some(evidence) = detect_wrapper(&words) {
self.indirect.push(evidence);
}
let redirects = std::mem::take(&mut redirects);
out.push(build_command(words, redirects, origin));
}
};
}
while self.i < self.chars.len() {
let c = self.chars[self.i];
if let Some(q) = quote {
match c {
'\\' if q == '"' => {
if let Some(next) = self.chars.get(self.i + 1) {
cur.push(*next);
self.i += 2;
continue;
}
self.i += 1;
}
_ if c == q => {
quote = None;
cur_quoted = true;
self.i += 1;
}
'$' if q == '"' && self.chars.get(self.i + 1) == Some(&'(') => {
self.read_substitution();
}
'`' if q == '"' => self.read_backtick(),
_ => {
cur.push(c);
self.i += 1;
}
}
continue;
}
match c {
'\'' | '"' => {
quote = Some(c);
cur_quoted = true;
self.i += 1;
}
'\\' => {
if let Some(next) = self.chars.get(self.i + 1) {
cur.push(*next);
self.i += 2;
} else {
self.i += 1;
}
}
' ' | '\t' | '\r' => {
flush_word!();
self.i += 1;
}
'\n' => {
flush_command!();
self.i += 1;
}
';' | '&' | '|' => {
flush_command!();
self.i += 1;
while self
.chars
.get(self.i)
.is_some_and(|c| matches!(c, ';' | '&' | '|'))
{
self.i += 1;
}
}
'(' => {
flush_word!();
self.i += 1;
let inner = self.parse_statements(depth + 1, Origin::Subshell);
out.extend(inner);
}
')' => {
flush_command!();
self.i += 1;
if depth > 0 {
self.complete = true;
return out;
}
self.complete = false;
}
'>' | '<' => {
if !cur.is_empty() && cur.chars().all(|c| c.is_ascii_digit()) {
cur.clear();
}
flush_word!();
let dir = if c == '>' {
Direction::Write
} else {
Direction::Read
};
self.i += 1;
while self
.chars
.get(self.i)
.is_some_and(|c| matches!(c, '>' | '<' | '&'))
{
self.i += 1;
}
pending_redirect = Some(dir);
}
'$' if self.chars.get(self.i + 1) == Some(&'(') => {
self.read_substitution();
cur.push_str("$(...)");
}
'`' => {
self.read_backtick();
cur.push_str("`...`");
}
_ => {
cur.push(c);
self.i += 1;
}
}
}
flush_command!();
self.complete = self.complete && quote.is_none();
out
}
fn read_substitution(&mut self) {
self.i += 1;
debug_assert_eq!(self.chars.get(self.i), Some(&'('));
let start = self.i + 1;
let mut depth = 1usize;
let mut j = start;
let mut quote: Option<char> = None;
while j < self.chars.len() {
let c = self.chars[j];
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => match c {
'\'' | '"' => quote = Some(c),
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
},
}
j += 1;
}
if depth != 0 {
self.complete = false;
self.i = self.chars.len();
return;
}
let inner: String = self.chars[start..j].iter().collect();
let mut sub = Scanner::new(&inner);
let mut found = sub.parse_statements(0, Origin::Substitution);
found.extend(sub.nested);
self.nested.extend(found);
self.indirect.extend(sub.indirect);
self.i = j + 1;
}
fn read_backtick(&mut self) {
let start = self.i + 1;
let mut j = start;
while j < self.chars.len() && self.chars[j] != '`' {
if self.chars[j] == '\\' {
j += 1;
}
j += 1;
}
if j >= self.chars.len() {
self.complete = false;
self.i = self.chars.len();
return;
}
let inner: String = self.chars[start..j].iter().collect();
let mut sub = Scanner::new(&inner);
let mut found = sub.parse_statements(0, Origin::Substitution);
found.extend(sub.nested);
self.nested.extend(found);
self.indirect.extend(sub.indirect);
self.i = j + 1;
}
}
fn build_command(words: Vec<Word>, redirects: Vec<(String, Direction)>, origin: Origin) -> Command {
let words = strip_env_prefix(words);
let text = words
.iter()
.map(|w| w.text.as_str())
.collect::<Vec<_>>()
.join(" ");
Command {
text,
words,
origin,
redirects,
cwd: PathBuf::from("."),
cwd_unknown: false,
}
}
fn strip_env_prefix(words: Vec<Word>) -> Vec<Word> {
let mut idx = 0usize;
for word in &words {
if is_env_assignment(&word.text) {
idx += 1;
} else {
break;
}
}
if idx == 0 {
words
} else {
words[idx..].to_vec()
}
}
fn is_env_assignment(text: &str) -> bool {
let Some((name, _)) = text.split_once('=') else {
return false;
};
!name.is_empty()
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn detect_wrapper(words: &[Word]) -> Option<String> {
let first = words.first()?.text.as_str();
let second = words.get(1).map(|w| w.text.as_str()).unwrap_or("");
match first {
"bash" | "sh" | "zsh" | "dash" | "ksh" | "fish" | "csh" | "tcsh" | "ash" | "mksh"
| "pwsh" | "powershell" => Some(format!("{first} (shell wrapper)")),
"eval" => Some("eval".to_string()),
"sudo" | "doas" | "su" | "runuser" => Some(format!("{first} (privilege wrapper)")),
"xargs" | "parallel" => Some(format!("{first} (argument fan-out)")),
"ssh" | "nsenter" | "unshare" | "chroot" | "systemd-run" | "script" | "watch" => {
Some(format!("{first} (remote/wrapped execution)"))
}
"docker" | "podman" | "kubectl" => Some(format!("{first} (container exec)")),
"python" | "python3" | "node" | "ruby" | "perl" | "php" | "lua" | "deno" => {
Some(format!("{first} (interpreter)"))
}
"awk" | "gawk" | "sed" => Some(format!("{first} (script body)")),
"find"
if words
.iter()
.any(|w| matches!(w.text.as_str(), "-exec" | "-execdir" | "-ok" | "-okdir")) =>
{
Some("find -exec".to_string())
}
"git" if second == "alias" => Some("git alias".to_string()),
_ => None,
}
}
pub(super) fn unwrap_transparent(text: &str) -> Option<String> {
let mut rest = text.trim().to_string();
loop {
let words: Vec<String> = rest.split_whitespace().map(str::to_string).collect();
let first = words.first()?;
let skip = match first.as_str() {
"env" | "nohup" | "command" | "builtin" | "exec" | "time" | "setsid" | "stdbuf"
| "unbuffer" => skip_transparent_prefix(&words),
"nice" | "ionice" => skip_leading_options(&words, &["-n", "-c", "-p", "--adjustment"]),
"timeout" => {
let after = skip_leading_options(&words, &["-s", "-k", "--signal", "--kill-after"]);
let drop = usize::from(after < words.len());
(after + drop).min(words.len())
}
"flock" => {
let after = skip_leading_options(&words, &["-w", "-E", "--timeout"]);
(after + 1).min(words.len())
}
_ => return Some(rest),
};
if skip == 0 || skip >= words.len() {
return None;
}
rest = words[skip..].join(" ");
}
}
fn skip_transparent_prefix(words: &[String]) -> usize {
let mut idx = 1usize;
while idx < words.len() {
let w = &words[idx];
if w.starts_with('-') || is_env_assignment(w) {
idx += 1;
continue;
}
break;
}
idx
}
fn skip_leading_options(words: &[String], valued: &[&str]) -> usize {
let mut idx = 1usize;
while idx < words.len() {
let w = words[idx].as_str();
if !w.starts_with('-') {
break;
}
idx += 1;
if valued.contains(&w) && idx < words.len() {
idx += 1;
}
}
idx
}
fn looks_like_path(text: &str) -> bool {
if text.is_empty() || text.starts_with('-') {
return false;
}
if text.starts_with('/')
|| text.starts_with("./")
|| text.starts_with("../")
|| text.contains('/')
|| text.contains('\\')
|| text.starts_with('~')
|| text.starts_with("$HOME")
|| text.starts_with("${HOME}")
{
return true;
}
text.starts_with('.') && text != "." && text != ".."
}
pub(super) fn path_shape_or_existing(text: &str, cwd: &Path) -> bool {
if looks_like_path(text) {
return true;
}
if text.is_empty() || text.starts_with('-') || text.contains(['$', '*', '?', '`']) {
return false;
}
cwd.join(text).exists()
}
fn write_effect_command(name: &str) -> bool {
matches!(
name,
"rm" | "mv"
| "cp"
| "ln"
| "install"
| "touch"
| "mkdir"
| "rmdir"
| "tee"
| "truncate"
| "chmod"
| "chown"
| "chgrp"
| "dd"
| "shred"
| "unlink"
| "rsync"
| "sed"
| "perl"
| "git"
| "npm"
| "pnpm"
| "yarn"
| "cargo"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(scan: &Scan) -> Vec<String> {
scan.all_commands().map(|c| c.text.clone()).collect()
}
#[test]
fn splits_on_operators() {
let scan = scan("cd /repo && npm install x; rm -rf y | tee z");
let all = texts(&scan);
assert!(all.contains(&"cd /repo".to_string()));
assert!(all.contains(&"npm install x".to_string()));
assert!(all.contains(&"rm -rf y".to_string()));
assert!(all.contains(&"tee z".to_string()));
assert!(scan.complete);
}
#[test]
fn quotes_do_not_split() {
let scan = scan("echo 'a && b' \"c | d\"");
let all = texts(&scan);
assert_eq!(all, vec!["echo a && b c | d".to_string()]);
}
#[test]
fn command_substitution_is_evaluated() {
let scan = scan("echo $(rm -rf foo)");
assert!(texts(&scan).contains(&"rm -rf foo".to_string()));
let nested = scan
.nested
.iter()
.find(|c| c.text == "rm -rf foo")
.expect("nested command");
assert_eq!(nested.origin, Origin::Substitution);
}
#[test]
fn backtick_is_evaluated() {
let scan = scan("echo `rm -rf foo`");
assert!(texts(&scan).contains(&"rm -rf foo".to_string()));
}
#[test]
fn subshell_is_evaluated() {
let scan = scan("(cd /tmp && rm x)");
assert!(texts(&scan).contains(&"rm x".to_string()));
}
#[test]
fn redirects_are_write_targets() {
let scan = scan("echo hi > .env");
let cmd = scan.commands.first().expect("command");
assert_eq!(cmd.redirects, vec![(".env".to_string(), Direction::Write)]);
let targets = cmd.path_targets();
assert!(targets.contains(&(".env".to_string(), Direction::Write)));
}
#[test]
fn env_prefix_is_stripped() {
let scan = scan("FOO=1 BAR=2 rm -rf x");
assert_eq!(texts(&scan), vec!["rm -rf x".to_string()]);
}
#[test]
fn cd_moves_working_directory() {
let scan = scan("cd /tmp && rm a.txt");
let rm = scan
.commands
.iter()
.find(|c| c.text.starts_with("rm"))
.expect("rm");
assert_eq!(rm.cwd, PathBuf::from("/tmp"));
assert!(!rm.cwd_unknown);
assert_eq!(rm.absolute_text().as_deref(), Some("rm /tmp/a.txt"));
}
#[test]
fn nonliteral_cd_marks_unknown() {
let scan = scan("cd \"$DIR\" && rm a.txt");
let rm = scan
.commands
.iter()
.find(|c| c.text.starts_with("rm"))
.expect("rm");
assert!(rm.cwd_unknown);
assert!(rm.absolute_text().is_none());
}
#[test]
fn indirect_wrappers_are_reported() {
for input in [
"bash -c 'rm -rf /'",
"sudo rm -rf /",
"eval \"rm -rf /\"",
"xargs rm",
"find . -exec rm {} ;",
"python -c 'import os'",
] {
let scan = scan(input);
assert!(!scan.indirect.is_empty(), "expected indirect for {input}");
}
}
#[test]
fn transparent_wrappers_unwrap() {
assert_eq!(
unwrap_transparent("timeout 10 npm test").as_deref(),
Some("npm test")
);
assert_eq!(
unwrap_transparent("env FOO=1 npm test").as_deref(),
Some("npm test")
);
assert_eq!(
unwrap_transparent("nohup cargo build").as_deref(),
Some("cargo build")
);
assert_eq!(
unwrap_transparent("sudo rm -rf /").as_deref(),
Some("sudo rm -rf /")
);
}
#[test]
fn heredoc_body_is_split_out() {
let scan = scan("cat <<EOF\nhello $(rm -rf x)\nEOF\nnpm test");
assert!(texts(&scan).contains(&"rm -rf x".to_string()));
assert!(texts(&scan).contains(&"npm test".to_string()));
}
#[test]
fn quoted_heredoc_body_is_literal() {
let scan = scan("cat <<'EOF'\n$(rm -rf x)\nEOF\n");
assert!(!texts(&scan).contains(&"rm -rf x".to_string()));
}
#[test]
fn unterminated_quote_marks_incomplete() {
let scan = scan("echo 'oops");
assert!(!scan.complete);
}
#[test]
fn path_shapes() {
assert!(looks_like_path("src/a.rs"));
assert!(looks_like_path(".env"));
assert!(looks_like_path("~/.ssh/config"));
assert!(looks_like_path("/etc/passwd"));
assert!(!looks_like_path("origin"));
assert!(!looks_like_path("-rf"));
assert!(!looks_like_path("status"));
}
}