use crate::error::Result;
use crate::parser::Parser;
use crate::parser::{
AssignmentValue, Command, CommandList, CompoundCommand, Redirect, RedirectKind, Script,
SimpleCommand, Word, WordPart,
};
pub const MAX_ANALYSIS_NODES: usize = 4096;
pub const COMMAND_WRAPPERS: &[&str] = &[
"command", "doas", "env", "exec", "nice", "nohup", "setsid", "stdbuf", "sudo", "timeout",
"xargs",
];
pub fn is_command_wrapper(name: &str) -> bool {
COMMAND_WRAPPERS.contains(&name)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandContext {
Direct,
Substitution,
FunctionBody,
}
impl CommandContext {
pub fn as_str(self) -> &'static str {
match self {
CommandContext::Direct => "direct",
CommandContext::Substitution => "substitution",
CommandContext::FunctionBody => "function_body",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RedirectMode {
Read,
Write,
Append,
}
impl RedirectMode {
pub fn as_str(self) -> &'static str {
match self {
RedirectMode::Read => "read",
RedirectMode::Write => "write",
RedirectMode::Append => "append",
}
}
pub fn is_write(self) -> bool {
matches!(self, RedirectMode::Write | RedirectMode::Append)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AnalyzedCommand {
pub name: Option<String>,
pub args: Vec<Option<String>>,
pub context: CommandContext,
pub assignments: Vec<String>,
}
impl AnalyzedCommand {
pub fn is_assignment_only(&self) -> bool {
self.name.is_none() && self.args.is_empty() && !self.assignments.is_empty()
}
pub fn literal_args(&self) -> Option<Vec<&str>> {
self.args
.iter()
.map(|a| a.as_deref())
.collect::<Option<Vec<_>>>()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AnalyzedRedirect {
pub path: Option<String>,
pub mode: RedirectMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct ScriptAnalysis {
pub commands: Vec<AnalyzedCommand>,
pub redirects: Vec<AnalyzedRedirect>,
pub functions: Vec<String>,
pub has_dynamic_commands: bool,
pub has_command_substitution: bool,
pub has_interpreter_reentry: bool,
pub truncated: bool,
}
impl ScriptAnalysis {
pub fn command_names(&self) -> Vec<&str> {
let mut seen = Vec::new();
for cmd in &self.commands {
if let Some(name) = cmd.name.as_deref()
&& !seen.contains(&name)
{
seen.push(name);
}
}
seen
}
pub fn commands_named<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a AnalyzedCommand> {
let name = name.to_owned();
self.commands
.iter()
.filter(move |c| c.name.as_deref() == Some(name.as_str()))
}
pub fn is_opaque(&self) -> bool {
self.has_dynamic_commands || self.has_interpreter_reentry || self.truncated
}
pub fn command_wrappers(&self) -> Vec<&str> {
self.command_names()
.into_iter()
.filter(|name| is_command_wrapper(name))
.collect()
}
}
pub fn analyze(script: &str) -> Result<ScriptAnalysis> {
reject_reserved_control_bytes(script)?;
let ast = Parser::new(script).parse()?;
let analysis = analyze_ast(&ast);
validate_command_names(script, &analysis)?;
Ok(analysis)
}
pub fn analyze_with_limits(
script: &str,
max_depth: usize,
max_fuel: usize,
) -> Result<ScriptAnalysis> {
reject_reserved_control_bytes(script)?;
let ast = Parser::with_limits(script, max_depth, max_fuel).parse()?;
let analysis = analyze_ast(&ast);
validate_command_names(script, &analysis)?;
Ok(analysis)
}
fn reject_reserved_control_bytes(script: &str) -> Result<()> {
if script
.chars()
.any(|ch| matches!(ch, '\0' | '\x01' | '\x02' | '\u{1e}' | '\u{1f}'))
{
return Err(crate::Error::parse(
"reserved control byte cannot be analyzed",
));
}
Ok(())
}
fn validate_command_names(script: &str, analysis: &ScriptAnalysis) -> Result<()> {
if script.contains("$'") {
return Ok(());
}
for name in analysis
.commands
.iter()
.filter_map(|command| command.name.as_deref())
{
let mut source = script.chars();
if !name.chars().all(|wanted| source.any(|ch| ch == wanted)) {
return Err(crate::Error::parse(
"analysis produced a command name not derived from the script",
));
}
}
Ok(())
}
pub(crate) fn analyze_ast(script: &Script) -> ScriptAnalysis {
let mut walker = Walker::default();
walker.walk_commands(&script.commands, CommandContext::Direct);
walker.out
}
fn is_interpreter_reentry(name: Option<&str>) -> bool {
matches!(
name,
Some("eval") | Some("source") | Some(".") | Some("bash") | Some("sh")
)
}
fn literal_word(word: &Word) -> Option<String> {
let mut out = String::new();
for part in &word.parts {
match part {
WordPart::Literal(s) => out.push_str(s),
_ => return None,
}
}
Some(out)
}
#[derive(Default)]
struct Walker {
out: ScriptAnalysis,
nodes: usize,
}
impl Walker {
fn budget(&mut self) -> bool {
if self.nodes >= MAX_ANALYSIS_NODES {
self.out.truncated = true;
return false;
}
self.nodes += 1;
true
}
fn walk_commands(&mut self, commands: &[Command], ctx: CommandContext) {
for command in commands {
self.walk_command(command, ctx);
}
}
fn walk_command(&mut self, command: &Command, ctx: CommandContext) {
match command {
Command::Simple(simple) => self.walk_simple(simple, ctx),
Command::Pipeline(pipeline) => self.walk_commands(&pipeline.commands, ctx),
Command::List(list) => self.walk_list(list, ctx),
Command::Compound(compound, redirects) => {
self.walk_compound(compound, ctx);
self.walk_redirects(redirects, ctx);
}
Command::Function(def) => {
if !self.out.functions.iter().any(|f| f == &def.name) {
self.out.functions.push(def.name.clone());
}
self.walk_command(&def.body, CommandContext::FunctionBody);
}
}
}
fn walk_list(&mut self, list: &CommandList, ctx: CommandContext) {
self.walk_command(&list.first, ctx);
for (_, command) in &list.rest {
self.walk_command(command, ctx);
}
}
fn walk_compound(&mut self, compound: &CompoundCommand, ctx: CommandContext) {
match compound {
CompoundCommand::If(cmd) => {
self.walk_commands(&cmd.condition, ctx);
self.walk_commands(&cmd.then_branch, ctx);
for (condition, body) in &cmd.elif_branches {
self.walk_commands(condition, ctx);
self.walk_commands(body, ctx);
}
if let Some(else_branch) = &cmd.else_branch {
self.walk_commands(else_branch, ctx);
}
}
CompoundCommand::For(cmd) => {
if let Some(words) = &cmd.words {
self.walk_words(words, ctx);
}
self.walk_commands(&cmd.body, ctx);
}
CompoundCommand::ArithmeticFor(cmd) => self.walk_commands(&cmd.body, ctx),
CompoundCommand::While(cmd) => {
self.walk_commands(&cmd.condition, ctx);
self.walk_commands(&cmd.body, ctx);
}
CompoundCommand::Until(cmd) => {
self.walk_commands(&cmd.condition, ctx);
self.walk_commands(&cmd.body, ctx);
}
CompoundCommand::Case(cmd) => {
self.walk_word_parts(&cmd.word, ctx);
for item in &cmd.cases {
self.walk_words(&item.patterns, ctx);
self.walk_commands(&item.commands, ctx);
}
}
CompoundCommand::Select(cmd) => {
self.walk_words(&cmd.words, ctx);
self.walk_commands(&cmd.body, ctx);
}
CompoundCommand::Subshell(commands) | CompoundCommand::BraceGroup(commands) => {
self.walk_commands(commands, ctx)
}
CompoundCommand::Arithmetic(_) => {}
CompoundCommand::Time(cmd) => {
if let Some(format) = &cmd.format {
self.walk_word_parts(format, ctx);
}
if let Some(output) = &cmd.output {
self.walk_word_parts(output, ctx);
}
if let Some(command) = &cmd.command {
self.walk_command(command, ctx);
}
}
CompoundCommand::Conditional(words) => self.walk_words(words, ctx),
CompoundCommand::Coproc(cmd) => self.walk_command(&cmd.body, ctx),
}
}
fn walk_simple(&mut self, simple: &SimpleCommand, ctx: CommandContext) {
self.walk_word_parts(&simple.name, ctx);
for arg in &simple.args {
self.walk_word_parts(arg, ctx);
}
for assignment in &simple.assignments {
match &assignment.value {
AssignmentValue::Scalar(word) => self.walk_word_parts(word, ctx),
AssignmentValue::Array(words) => self.walk_words(words, ctx),
}
}
if !self.budget() {
return;
}
let mut name = literal_word(&simple.name);
let empty_name = name.as_deref() == Some("");
if empty_name {
name = None;
}
if name.is_none() && !empty_name {
self.out.has_dynamic_commands = true;
}
if is_interpreter_reentry(name.as_deref()) {
self.out.has_interpreter_reentry = true;
}
self.out.commands.push(AnalyzedCommand {
name,
args: simple.args.iter().map(literal_word).collect(),
context: ctx,
assignments: simple.assignments.iter().map(|a| a.name.clone()).collect(),
});
self.walk_redirects(&simple.redirects, ctx);
}
fn walk_redirects(&mut self, redirects: &[Redirect], ctx: CommandContext) {
for redirect in redirects {
let mode = match redirect.kind {
RedirectKind::Output | RedirectKind::Clobber | RedirectKind::OutputBoth => {
RedirectMode::Write
}
RedirectKind::Append => RedirectMode::Append,
RedirectKind::Input => RedirectMode::Read,
RedirectKind::DupOutput
| RedirectKind::DupInput
| RedirectKind::HereDoc
| RedirectKind::HereDocStrip
| RedirectKind::HereString => {
self.walk_word_parts(&redirect.target, ctx);
continue;
}
};
self.walk_word_parts(&redirect.target, ctx);
if !self.budget() {
return;
}
self.out.redirects.push(AnalyzedRedirect {
path: literal_word(&redirect.target),
mode,
});
}
}
fn walk_words(&mut self, words: &[Word], ctx: CommandContext) {
for word in words {
self.walk_word_parts(word, ctx);
}
}
fn walk_word_parts(&mut self, word: &Word, ctx: CommandContext) {
let nested = if ctx == CommandContext::FunctionBody {
CommandContext::FunctionBody
} else {
CommandContext::Substitution
};
for part in &word.parts {
match part {
WordPart::CommandSubstitution(commands) => {
self.out.has_command_substitution = true;
self.walk_commands(commands, nested);
}
WordPart::ProcessSubstitution { commands, .. } => {
self.out.has_command_substitution = true;
self.walk_commands(commands, nested);
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn a(script: &str) -> ScriptAnalysis {
analyze(script).expect("script should parse")
}
fn names(script: &str) -> Vec<String> {
a(script)
.commands
.iter()
.map(|c| c.name.clone().unwrap_or_else(|| "<dynamic>".into()))
.collect()
}
#[test]
fn simple_command() {
let analysis = a("echo hello world");
assert_eq!(analysis.commands.len(), 1);
assert_eq!(analysis.commands[0].name.as_deref(), Some("echo"));
assert_eq!(
analysis.commands[0].literal_args(),
Some(vec!["hello", "world"])
);
assert_eq!(analysis.commands[0].context, CommandContext::Direct);
assert!(!analysis.is_opaque());
}
#[test]
fn quoted_and_concatenated_words_are_literal() {
let analysis = a(r#"eidos record update "rec 1" 'a'b"#);
assert_eq!(
analysis.commands[0].literal_args(),
Some(vec!["record", "update", "rec 1", "ab"])
);
}
#[test]
fn pipeline_and_list_operators() {
assert_eq!(names("a | b && c || d ; e"), ["a", "b", "c", "d", "e"]);
}
#[test]
fn negated_pipeline() {
assert_eq!(names("! grep -q x file"), ["grep"]);
}
#[test]
fn compound_commands() {
assert_eq!(
names("if t1; then t2; elif t3; then t4; else t5; fi"),
["t1", "t2", "t3", "t4", "t5"]
);
assert_eq!(names("while c; do b; done"), ["c", "b"]);
assert_eq!(names("until c; do b; done"), ["c", "b"]);
assert_eq!(names("for i in 1 2; do b; done"), ["b"]);
assert_eq!(names("for ((i=0;i<2;i++)); do b; done"), ["b"]);
assert_eq!(names("case x in a) b ;; *) c ;; esac"), ["b", "c"]);
assert_eq!(names("( sub )"), ["sub"]);
assert_eq!(names("{ grp; }"), ["grp"]);
}
#[test]
fn command_substitution_is_walked_and_tagged() {
let analysis = a("echo $(rm -rf /data)");
assert_eq!(names("echo $(rm -rf /data)"), ["rm", "echo"]);
assert!(analysis.has_command_substitution);
assert_eq!(analysis.commands[0].context, CommandContext::Substitution);
assert_eq!(
analysis.commands[0].literal_args(),
Some(vec!["-rf", "/data"])
);
assert_eq!(analysis.commands[1].context, CommandContext::Direct);
}
#[test]
fn backtick_substitution_is_walked() {
let analysis = a("echo `curl http://x`");
assert!(analysis.has_command_substitution);
assert!(analysis.command_names().contains(&"curl"));
}
#[test]
fn process_substitution_is_walked() {
let analysis = a("diff <(cat a) <(cat b)");
assert!(analysis.has_command_substitution);
assert_eq!(analysis.command_names(), ["cat", "diff"]);
}
#[test]
fn substitution_in_redirect_target_is_walked() {
let analysis = a("echo hi > $(mktemp)");
assert!(analysis.command_names().contains(&"mktemp"));
assert_eq!(analysis.redirects[0].path, None);
}
#[test]
fn substitution_in_assignment_value_is_walked() {
let analysis = a("out=$(rm -rf /) echo hi");
assert_eq!(analysis.command_names(), ["rm", "echo"]);
assert_eq!(analysis.commands[1].assignments, ["out"]);
}
#[test]
fn dynamic_command_name_is_null_and_flagged() {
let analysis = a("$cmd --force");
assert_eq!(analysis.commands[0].name, None);
assert!(analysis.has_dynamic_commands);
assert!(analysis.is_opaque());
}
#[test]
fn substituted_command_name_is_dynamic() {
let analysis = a("$(echo rm) -rf /");
assert!(analysis.has_dynamic_commands);
assert!(analysis.is_opaque());
assert_eq!(analysis.command_names(), ["echo"]);
}
#[test]
fn dynamic_arguments_are_null() {
let analysis = a(r#"rm "$target" /tmp/fixed"#);
assert_eq!(
analysis.commands[0].args,
vec![None, Some("/tmp/fixed".into())]
);
assert_eq!(analysis.commands[0].literal_args(), None);
assert!(!analysis.has_dynamic_commands);
}
#[test]
fn partial_literals_are_not_reconstructed() {
let analysis = a("cat /tmp/$name.txt");
assert_eq!(analysis.commands[0].args, vec![None]);
}
#[test]
fn command_wrappers_are_reported_but_not_opaque() {
let analysis = a("command cargo --version");
assert_eq!(analysis.command_wrappers(), ["command"]);
assert!(!analysis.is_opaque());
assert_eq!(
analysis.commands[0].args,
[Some("cargo".into()), Some("--version".into())]
);
}
#[test]
fn every_published_wrapper_is_recognized() {
for name in COMMAND_WRAPPERS {
assert!(is_command_wrapper(name), "{name} should be a wrapper");
assert_eq!(
a(&format!("{name} cargo --version")).command_wrappers(),
[*name]
);
}
}
#[test]
fn non_wrappers_are_not_reported() {
assert!(!is_command_wrapper("echo"));
assert!(a("echo command").command_wrappers().is_empty());
assert!(!is_command_wrapper("find"));
}
#[test]
fn time_keyword_needs_no_wrapper_entry() {
assert_eq!(a("time cargo --version").command_names(), ["cargo"]);
assert!(!is_command_wrapper("time"));
}
#[test]
fn command_wrappers_are_deduplicated_in_first_seen_order() {
let analysis = a("env A=1 cargo build; xargs rm; env B=2 ls");
assert_eq!(analysis.command_wrappers(), ["env", "xargs"]);
}
#[test]
fn eval_and_source_are_flagged() {
for script in ["eval \"$x\"", "source /tmp/x.sh", ". /tmp/x.sh"] {
let analysis = a(script);
assert!(
analysis.has_interpreter_reentry,
"{script} should set has_interpreter_reentry"
);
assert!(analysis.is_opaque());
}
assert!(!a("echo eval").has_interpreter_reentry);
}
#[test]
fn nested_shell_is_flagged() {
for script in [
"bash -c 'rm -rf /'",
"sh -c \"$payload\"",
"bash --noprofile -c 'x'",
"bash script.sh",
"sh /tmp/setup.sh",
"bash $flags",
] {
assert!(
a(script).has_interpreter_reentry,
"{script} should set has_interpreter_reentry"
);
assert!(a(script).is_opaque(), "{script} should be opaque");
}
assert!(!a("echo bash -c hi").has_interpreter_reentry);
assert!(!a("cat /usr/bin/bash").has_interpreter_reentry);
}
#[test]
fn function_definitions_are_recorded_and_tagged() {
let analysis = a("cleanup() { rm -rf /data; }\necho done");
assert_eq!(analysis.functions, ["cleanup"]);
assert_eq!(analysis.commands[0].name.as_deref(), Some("rm"));
assert_eq!(analysis.commands[0].context, CommandContext::FunctionBody);
assert_eq!(analysis.commands[1].context, CommandContext::Direct);
}
#[test]
fn substitution_inside_function_body_stays_function_body() {
let analysis = a("f() { echo $(rm -rf /); }");
assert!(
analysis
.commands
.iter()
.all(|c| c.context == CommandContext::FunctionBody),
"{:?}",
analysis.commands
);
}
#[test]
fn redirect_modes() {
let analysis = a("cat < in > out 2>> err");
let modes: Vec<_> = analysis.redirects.iter().map(|r| r.mode).collect();
assert_eq!(
modes,
[
RedirectMode::Read,
RedirectMode::Write,
RedirectMode::Append
]
);
assert_eq!(
analysis
.redirects
.iter()
.map(|r| r.path.clone().unwrap())
.collect::<Vec<_>>(),
["in", "out", "err"]
);
assert!(RedirectMode::Write.is_write());
assert!(!RedirectMode::Read.is_write());
}
#[test]
fn clobber_and_both_are_writes() {
let analysis = a("echo x >| a; echo y &> b");
assert!(analysis.redirects.iter().all(|r| r.mode.is_write()));
}
#[test]
fn fd_dup_and_heredoc_are_not_file_targets() {
let analysis = a("cmd 2>&1");
assert!(analysis.redirects.is_empty());
let analysis = a("cat <<EOF\nbody\nEOF\n");
assert!(analysis.redirects.is_empty());
let analysis = a("cat <<< hello");
assert!(analysis.redirects.is_empty());
}
#[test]
fn compound_redirects_are_recorded() {
let analysis = a("{ echo a; } > out");
assert_eq!(analysis.redirects[0].path.as_deref(), Some("out"));
}
#[test]
fn assignments_are_recorded_without_values() {
let analysis = a("FOO=1 BAR=2 env");
assert_eq!(analysis.commands[0].assignments, ["FOO", "BAR"]);
}
#[test]
fn bare_assignment_names_no_command_and_is_not_dynamic() {
let analysis = a("FOO=1");
assert_eq!(analysis.commands[0].name, None);
assert_eq!(analysis.commands[0].assignments, ["FOO"]);
assert!(analysis.commands[0].is_assignment_only());
assert!(!analysis.has_dynamic_commands);
assert!(!analysis.is_opaque());
}
#[test]
fn prefix_assignment_command_is_not_assignment_only() {
let analysis = a("FOO=1 env");
assert!(!analysis.commands[0].is_assignment_only());
assert_eq!(analysis.commands[0].name.as_deref(), Some("env"));
}
#[test]
fn array_assignment_values_are_walked() {
let analysis = a("arr=(a $(id) c)");
assert!(analysis.command_names().contains(&"id"));
}
#[test]
fn command_names_are_deduplicated_in_order() {
let analysis = a("echo a; cat b; echo c");
assert_eq!(analysis.command_names(), ["echo", "cat"]);
}
#[test]
fn commands_named_filters() {
let analysis = a("eidos record update 1; echo x; eidos doc get 2");
let found: Vec<_> = analysis
.commands_named("eidos")
.map(|c| c.args[0].clone().unwrap())
.collect();
assert_eq!(found, ["record", "doc"]);
}
#[test]
fn parse_error_is_an_error_not_empty_output() {
assert!(analyze("if true; then").is_err());
assert!(analyze("echo 'unterminated").is_err());
}
#[test]
fn empty_script_is_empty_analysis() {
let analysis = a("");
assert!(analysis.commands.is_empty());
assert!(!analysis.is_opaque());
}
#[test]
fn comments_only_is_empty_analysis() {
assert!(a("# nothing here\n").commands.is_empty());
}
#[test]
fn budget_truncates_and_flags() {
let script = "echo x;".repeat(MAX_ANALYSIS_NODES + 50);
let analysis = a(&script);
assert!(analysis.truncated);
assert!(analysis.is_opaque());
assert_eq!(analysis.commands.len(), MAX_ANALYSIS_NODES);
}
#[test]
fn limits_are_honored() {
assert!(analyze_with_limits("echo hello", 100, 2).is_err());
assert!(analyze_with_limits("echo hello", 100, 100_000).is_ok());
}
#[test]
fn analysis_is_serializable() {
let analysis = a("echo hi > out");
let json = serde_json::to_string(&analysis).expect("serializable");
assert!(json.contains("\"direct\""));
assert!(json.contains("\"write\""));
let round: ScriptAnalysis = serde_json::from_str(&json).expect("deserializable");
assert_eq!(round, analysis);
}
#[test]
fn context_and_mode_names_are_stable() {
assert_eq!(CommandContext::Direct.as_str(), "direct");
assert_eq!(CommandContext::Substitution.as_str(), "substitution");
assert_eq!(CommandContext::FunctionBody.as_str(), "function_body");
assert_eq!(RedirectMode::Read.as_str(), "read");
assert_eq!(RedirectMode::Write.as_str(), "write");
assert_eq!(RedirectMode::Append.as_str(), "append");
}
}