use super::{parse, ArgMeta, SimpleCommand, Statement};
struct ShellCDef {
name: &'static str,
pre_c_positional: usize,
bool_flags: &'static [&'static str],
value_flags: &'static [&'static str],
}
static SHELL_C_WRAPPERS: &[ShellCDef] = &[
ShellCDef {
name: "bash",
pre_c_positional: 0,
bool_flags: &[
"--norc",
"--noprofile",
"-l",
"--login",
"-i",
"-x",
"-e",
"-v",
"--posix",
"--version",
"--help",
],
value_flags: &["-O", "+O", "--rcfile", "--init-file", "-o"],
},
ShellCDef {
name: "sh",
pre_c_positional: 0,
bool_flags: &["-e", "-x", "-i", "-v", "--version", "--help"],
value_flags: &[],
},
ShellCDef {
name: "zsh",
pre_c_positional: 0,
bool_flags: &[
"-l",
"--login",
"-i",
"-x",
"-e",
"-v",
"--rcs",
"--norcs",
"--no-rcs",
"--globalrcs",
"--no-globalrcs",
"--version",
"--help",
],
value_flags: &[],
},
ShellCDef {
name: "dash",
pre_c_positional: 0,
bool_flags: &["-e", "-x", "-i", "-v", "--version", "--help"],
value_flags: &[],
},
ShellCDef {
name: "ash",
pre_c_positional: 0,
bool_flags: &["-e", "-x", "-i", "-v", "--help"],
value_flags: &[],
},
ShellCDef {
name: "ksh",
pre_c_positional: 0,
bool_flags: &["-l", "-i", "-x", "-e", "-v", "--version", "--help"],
value_flags: &[],
},
ShellCDef {
name: "sg",
pre_c_positional: 1, bool_flags: &["--version", "--help", "-V", "-h"],
value_flags: &[],
},
];
fn wrapper_basename(name: &str) -> &str {
name.rsplit('/').next().unwrap_or(name)
}
fn find_shell_c_def(name: &str) -> Option<&'static ShellCDef> {
let basename = wrapper_basename(name);
SHELL_C_WRAPPERS.iter().find(|w| w.name == basename)
}
pub(crate) fn unwrap_shell_c(cmd: &SimpleCommand) -> Option<Statement> {
let cmd_name = cmd.name.as_deref()?;
let def = find_shell_c_def(cmd_name)?;
let argv = &cmd.argv;
let mut i = def.pre_c_positional;
while i < argv.len() {
let token = argv[i].text.as_str();
if def.bool_flags.contains(&token) {
i += 1;
continue;
}
if def.value_flags.contains(&token) {
i += 2;
continue;
}
if def
.value_flags
.iter()
.any(|f| token.starts_with(f) && token.as_bytes().get(f.len()) == Some(&b'='))
{
i += 1;
continue;
}
break;
}
if i >= argv.len() {
return None;
}
let next = argv[i].text.as_str();
if next == "-c" {
let Some(c_arg) = argv.get(i + 1) else {
return Some(Statement::Opaque(
"shell-c -c flag with no string".to_string(),
));
};
match c_arg.meta {
ArgMeta::RawString | ArgMeta::SafeString => {
match parse(&c_arg.text) {
Ok(Statement::Opaque(_)) => {
Some(Statement::Opaque("shell-c inner parse opaque".to_string()))
}
Ok(stmt) => Some(stmt),
Err(err) => Some(Statement::Opaque(format!(
"shell-c inner parse setup failed: {err}"
))),
}
}
ArgMeta::UnsafeString => Some(Statement::Opaque(
"shell-c string arg not safely re-parseable: UnsafeString".to_string(),
)),
ArgMeta::PlainWord => Some(Statement::Opaque(
"shell-c string arg not safely re-parseable: PlainWord".to_string(),
)),
}
} else {
Some(Statement::Opaque(
"shell-c wrapper with non-c positional argument; cannot analyze".to_string(),
))
}
}
pub(crate) fn is_covered_shell_c_wrapper(leaf: &Statement) -> bool {
match leaf {
Statement::SimpleCommand(cmd) => match unwrap_shell_c(cmd) {
None | Some(Statement::Opaque(_)) => false,
Some(_) => true,
},
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::{Arg, ArgMeta, SimpleCommand, Statement};
fn mk_cmd(name: &str, argv: Vec<Arg>) -> SimpleCommand {
SimpleCommand {
name: Some(name.to_string()),
argv,
redirects: vec![],
assignments: vec![],
embedded_substitutions: vec![],
}
}
fn arg(text: &str, meta: ArgMeta) -> Arg {
Arg {
text: text.to_string(),
meta,
}
}
#[test]
fn basename_bash_recognized() {
assert!(find_shell_c_def("bash").is_some());
}
#[test]
fn basename_sh_recognized() {
assert!(find_shell_c_def("sh").is_some());
}
#[test]
fn basename_zsh_recognized() {
assert!(find_shell_c_def("zsh").is_some());
}
#[test]
fn basename_dash_recognized() {
assert!(find_shell_c_def("dash").is_some());
}
#[test]
fn basename_ash_recognized() {
assert!(find_shell_c_def("ash").is_some());
}
#[test]
fn basename_ksh_recognized() {
assert!(find_shell_c_def("ksh").is_some());
}
#[test]
fn basename_sg_recognized() {
assert!(find_shell_c_def("sg").is_some());
}
#[test]
fn basename_absolute_path() {
assert!(find_shell_c_def("/usr/bin/bash").is_some());
}
#[test]
fn basename_relative_path() {
assert!(find_shell_c_def("./sh").is_some());
}
#[test]
fn non_wrapper_cat() {
assert!(find_shell_c_def("cat").is_none());
}
#[test]
fn non_wrapper_timeout() {
assert!(find_shell_c_def("timeout").is_none());
}
#[test]
fn non_wrapper_python() {
assert!(find_shell_c_def("python").is_none());
}
#[test]
fn sg_skips_groupname_then_finds_c() {
let cmd = mk_cmd(
"sg",
vec![
arg("docker", ArgMeta::PlainWord),
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::SafeString),
],
);
assert!(matches!(
unwrap_shell_c(&cmd),
Some(Statement::SimpleCommand(_))
));
}
#[test]
fn bash_does_not_skip_argv0() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::SafeString),
],
);
assert!(matches!(
unwrap_shell_c(&cmd),
Some(Statement::SimpleCommand(_))
));
}
#[test]
fn bash_norc_noprofile_consumed() {
let cmd = mk_cmd(
"bash",
vec![
arg("--norc", ArgMeta::PlainWord),
arg("--noprofile", ArgMeta::PlainWord),
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::SafeString),
],
);
assert!(matches!(
unwrap_shell_c(&cmd),
Some(Statement::SimpleCommand(_))
));
}
#[test]
fn bash_login_flag_consumed() {
let cmd = mk_cmd(
"bash",
vec![
arg("-l", ArgMeta::PlainWord),
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::SafeString),
],
);
assert!(matches!(
unwrap_shell_c(&cmd),
Some(Statement::SimpleCommand(_))
));
}
#[test]
fn bash_posix_flag_consumed() {
let cmd = mk_cmd(
"bash",
vec![
arg("--posix", ArgMeta::PlainWord),
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::SafeString),
],
);
assert!(matches!(
unwrap_shell_c(&cmd),
Some(Statement::SimpleCommand(_))
));
}
#[test]
#[allow(non_snake_case)]
fn bash_dash_O_with_value_consumed() {
let cmd = mk_cmd(
"bash",
vec![
arg("-O", ArgMeta::PlainWord),
arg("extglob", ArgMeta::PlainWord),
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::SafeString),
],
);
assert!(matches!(
unwrap_shell_c(&cmd),
Some(Statement::SimpleCommand(_))
));
}
#[test]
fn bash_dash_o_with_value_consumed() {
let cmd = mk_cmd(
"bash",
vec![
arg("-o", ArgMeta::PlainWord),
arg("posix", ArgMeta::PlainWord),
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::SafeString),
],
);
assert!(matches!(
unwrap_shell_c(&cmd),
Some(Statement::SimpleCommand(_))
));
}
#[test]
fn bash_rcfile_equals_form_consumed() {
let cmd = mk_cmd(
"bash",
vec![
arg("--rcfile=/tmp/my", ArgMeta::PlainWord),
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::SafeString),
],
);
assert!(matches!(
unwrap_shell_c(&cmd),
Some(Statement::SimpleCommand(_))
));
}
#[test]
fn bash_version_returns_none() {
let cmd = mk_cmd("bash", vec![arg("--version", ArgMeta::PlainWord)]);
assert!(unwrap_shell_c(&cmd).is_none());
}
#[test]
fn bash_help_returns_none() {
let cmd = mk_cmd("bash", vec![arg("--help", ArgMeta::PlainWord)]);
assert!(unwrap_shell_c(&cmd).is_none());
}
#[test]
fn sg_groupname_only_returns_none() {
let cmd = mk_cmd("sg", vec![arg("docker", ArgMeta::PlainWord)]);
assert!(unwrap_shell_c(&cmd).is_none());
}
#[test]
fn sg_docker_version_returns_none() {
let cmd = mk_cmd(
"sg",
vec![
arg("docker", ArgMeta::PlainWord),
arg("--version", ArgMeta::PlainWord),
],
);
assert!(unwrap_shell_c(&cmd).is_none());
}
#[test]
fn bash_script_path_returns_opaque() {
let cmd = mk_cmd("bash", vec![arg("script.sh", ArgMeta::PlainWord)]);
match unwrap_shell_c(&cmd) {
Some(Statement::Opaque(reason)) => {
assert!(reason.contains("non-c positional"), "{reason}");
}
other => panic!("expected Opaque, got {:?}", other),
}
}
#[test]
fn bash_home_script_path_returns_opaque() {
let cmd = mk_cmd("bash", vec![arg("~/foo.sh", ArgMeta::PlainWord)]);
assert!(matches!(unwrap_shell_c(&cmd), Some(Statement::Opaque(_))));
}
#[test]
fn sg_docker_bareword_command_returns_opaque() {
let cmd = mk_cmd(
"sg",
vec![
arg("docker", ArgMeta::PlainWord),
arg("rm", ArgMeta::PlainWord),
],
);
assert!(matches!(unwrap_shell_c(&cmd), Some(Statement::Opaque(_))));
}
#[test]
fn sg_docker_destructive_positional_returns_opaque() {
let cmd = mk_cmd(
"sg",
vec![
arg("docker", ArgMeta::PlainWord),
arg("rm", ArgMeta::PlainWord),
arg("-rf", ArgMeta::PlainWord),
arg("/", ArgMeta::PlainWord),
],
);
assert!(matches!(unwrap_shell_c(&cmd), Some(Statement::Opaque(_))));
}
#[test]
fn bash_x_then_script_returns_opaque() {
let cmd = mk_cmd(
"bash",
vec![
arg("-x", ArgMeta::PlainWord),
arg("script.sh", ArgMeta::PlainWord),
],
);
assert!(matches!(unwrap_shell_c(&cmd), Some(Statement::Opaque(_))));
}
#[test]
fn bash_c_with_no_string_arg_returns_opaque() {
let cmd = mk_cmd("bash", vec![arg("-c", ArgMeta::PlainWord)]);
match unwrap_shell_c(&cmd) {
Some(Statement::Opaque(reason)) => {
assert!(reason.contains("no string"), "{reason}");
}
other => panic!("expected Opaque, got {:?}", other),
}
}
#[test]
fn sg_docker_c_with_no_string_arg_returns_opaque() {
let cmd = mk_cmd(
"sg",
vec![
arg("docker", ArgMeta::PlainWord),
arg("-c", ArgMeta::PlainWord),
],
);
assert!(matches!(unwrap_shell_c(&cmd), Some(Statement::Opaque(_))));
}
#[test]
fn rawstring_re_parses_to_simple_command() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::RawString),
],
);
match unwrap_shell_c(&cmd) {
Some(Statement::SimpleCommand(sc)) => {
assert_eq!(sc.name.as_deref(), Some("docker"));
}
other => panic!("expected SimpleCommand, got {:?}", other),
}
}
#[test]
fn safestring_re_parses_to_simple_command() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::SafeString),
],
);
assert!(matches!(
unwrap_shell_c(&cmd),
Some(Statement::SimpleCommand(_))
));
}
#[test]
fn unsafestring_returns_opaque() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("rm $TARGET", ArgMeta::UnsafeString),
],
);
match unwrap_shell_c(&cmd) {
Some(Statement::Opaque(reason)) => {
assert!(reason.contains("UnsafeString"), "{reason}");
}
other => panic!("expected Opaque, got {:?}", other),
}
}
#[test]
fn plainword_c_arg_returns_opaque() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("docker", ArgMeta::PlainWord),
],
);
match unwrap_shell_c(&cmd) {
Some(Statement::Opaque(reason)) => {
assert!(reason.contains("PlainWord"), "{reason}");
}
other => panic!("expected Opaque, got {:?}", other),
}
}
#[test]
fn empty_rawstring_returns_opaque_via_parse_opaque() {
let cmd = mk_cmd(
"bash",
vec![arg("-c", ArgMeta::PlainWord), arg("", ArgMeta::SafeString)],
);
match unwrap_shell_c(&cmd) {
Some(Statement::Opaque(reason)) => {
assert!(reason.contains("inner parse opaque"), "{reason}");
}
other => panic!("expected Opaque, got {:?}", other),
}
}
#[test]
fn rawstring_pipeline_returns_pipeline_statement() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("curl http://example.com | sh", ArgMeta::RawString),
],
);
assert!(matches!(unwrap_shell_c(&cmd), Some(Statement::Pipeline(_))));
}
#[test]
fn rawstring_list_returns_list_statement() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("docker ps && echo done", ArgMeta::RawString),
],
);
assert!(matches!(unwrap_shell_c(&cmd), Some(Statement::List(_))));
}
#[test]
fn rawstring_subshell_returns_subshell_statement() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("(docker ps)", ArgMeta::RawString),
],
);
assert!(matches!(unwrap_shell_c(&cmd), Some(Statement::Subshell(_))));
}
#[test]
fn rawstring_simple_with_command_substitution_has_embedded_subs() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("echo $(docker ps)", ArgMeta::RawString),
],
);
match unwrap_shell_c(&cmd) {
Some(Statement::SimpleCommand(sc)) => {
assert_eq!(sc.name.as_deref(), Some("echo"));
assert_eq!(sc.embedded_substitutions.len(), 1);
}
other => panic!("expected SimpleCommand, got {:?}", other),
}
}
#[test]
fn cat_dash_c_not_a_wrapper_match() {
let cmd = mk_cmd(
"cat",
vec![
arg("-c", ArgMeta::PlainWord),
arg("foo", ArgMeta::PlainWord),
],
);
assert!(unwrap_shell_c(&cmd).is_none());
}
#[test]
fn docker_dash_c_not_a_wrapper_match() {
let cmd = mk_cmd(
"docker",
vec![
arg("-c", ArgMeta::PlainWord),
arg("foo", ArgMeta::PlainWord),
],
);
assert!(unwrap_shell_c(&cmd).is_none());
}
#[test]
fn covered_bash_c_rawstring_docker_ps() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("docker ps", ArgMeta::RawString),
],
);
assert!(is_covered_shell_c_wrapper(&Statement::SimpleCommand(cmd)));
}
#[test]
fn covered_bash_c_pipeline() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("curl | sh", ArgMeta::RawString),
],
);
assert!(is_covered_shell_c_wrapper(&Statement::SimpleCommand(cmd)));
}
#[test]
fn not_covered_bash_c_unsafestring() {
let cmd = mk_cmd(
"bash",
vec![
arg("-c", ArgMeta::PlainWord),
arg("rm $VAR", ArgMeta::UnsafeString),
],
);
assert!(!is_covered_shell_c_wrapper(&Statement::SimpleCommand(cmd)));
}
#[test]
fn not_covered_bash_version_diagnostic() {
let cmd = mk_cmd("bash", vec![arg("--version", ArgMeta::PlainWord)]);
assert!(!is_covered_shell_c_wrapper(&Statement::SimpleCommand(cmd)));
}
#[test]
fn not_covered_bash_interactive() {
let cmd = mk_cmd(
"bash",
vec![
arg("-i", ArgMeta::PlainWord),
arg("--rcfile", ArgMeta::PlainWord),
arg("/tmp/payload", ArgMeta::PlainWord),
],
);
assert!(!is_covered_shell_c_wrapper(&Statement::SimpleCommand(cmd)));
}
#[test]
fn not_covered_sg_docker_non_c_positional() {
let cmd = mk_cmd(
"sg",
vec![
arg("docker", ArgMeta::PlainWord),
arg("rm", ArgMeta::PlainWord),
arg("-rf", ArgMeta::PlainWord),
arg("/", ArgMeta::PlainWord),
],
);
assert!(!is_covered_shell_c_wrapper(&Statement::SimpleCommand(cmd)));
}
}