const SHELLS: &[&str] = &["sh", "bash", "zsh", "dash", "ksh", "ash"];
pub(super) fn is_opaque_shell(cmd: &str) -> bool {
segments(cmd)
.iter()
.any(|(seg, piped)| segment_is_opaque(seg, *piped))
}
fn segments(cmd: &str) -> Vec<(String, bool)> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut piped = false;
let mut chars = cmd.chars().peekable();
while let Some(c) = chars.next() {
match quote {
Some(q) => {
cur.push(c);
if c == '\\' && q == '"' {
cur.extend(chars.next());
} else if c == q {
quote = None;
}
}
None => match c {
'"' | '\'' => {
quote = Some(c);
cur.push(c);
}
'\\' => {
cur.push(c);
cur.extend(chars.next());
}
';' | '&' | '|' | '\n' => {
let mut run = String::from(c);
while let Some(&n) = chars.peek() {
if matches!(n, ';' | '&' | '|') {
run.push(n);
chars.next();
} else {
break;
}
}
out.push((std::mem::take(&mut cur), piped));
piped = run == "|" || run == "|&";
}
_ => cur.push(c),
},
}
}
out.push((cur, piped));
out
}
fn segment_is_opaque(seg: &str, piped: bool) -> bool {
let words: Vec<&str> = seg
.split_whitespace()
.map(|w| w.trim_start_matches(['(', '{']))
.filter(|w| !w.is_empty())
.skip_while(|w| is_env_assignment(w))
.collect();
let Some(head) = words.first().map(|w| basename(w).to_ascii_lowercase()) else {
return false;
};
if head == "eval" {
return true;
}
if !SHELLS.contains(&head.as_str()) {
return false;
}
let rest = &words[1..];
if piped && reads_script_from_stdin(rest) {
return true;
}
rest.iter()
.position(|w| is_c_flag(w))
.and_then(|i| rest.get(i + 1))
.is_some_and(|body| {
!body.starts_with('\'') && {
let b = body.trim_start_matches('"');
b.starts_with('$') || b.starts_with('`')
}
})
}
fn reads_script_from_stdin(args: &[&str]) -> bool {
let (mut s_flag, mut operand) = (false, false);
let mut it = args.iter();
while let Some(w) = it.next() {
if *w == "--" {
operand = it.next().is_some();
break;
}
if is_c_flag(w) {
return false;
}
if w.starts_with('-') || w.starts_with('+') {
if matches!(*w, "-o" | "+o" | "-O" | "+O") {
it.next();
}
s_flag |= w.starts_with('-') && !w.starts_with("--") && w[1..].contains('s');
} else {
operand = true;
break;
}
}
s_flag || !operand
}
fn is_c_flag(w: &str) -> bool {
w.starts_with('-') && !w.starts_with("--") && w[1..].contains('c')
}
fn is_env_assignment(w: &str) -> bool {
w.split_once('=').is_some_and(|(k, _)| {
!k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
})
}
fn basename(w: &str) -> &str {
w.rsplit('/').next().unwrap_or(w)
}