a8e1f636f6
- Applied #[must_use] annotations across minimizer and process APIs to flag ignored return values. - Replaced direct serde_json field reads with typed Value helper calls in several filters. - Simplified filter parsing with chained if-let guards, rfind checks, and related cleanup. - Updated minimizer tests and fixtures to build outputs via write! and fmt::Write.
439 lines
13 KiB
Rust
439 lines
13 KiB
Rust
//! Structural analysis of a shell command using `brush-parser`.
|
|
//!
|
|
//! The minimizer must not corrupt downstream parsing or stitch together
|
|
//! segments that emit interleaved output. This module parses the full
|
|
//! command with the same shell parser the vendored brush runtime uses and
|
|
//! classifies it into one of a few shapes the engine can reason about.
|
|
//!
|
|
//! ## Decisions encoded here
|
|
//!
|
|
//! - **Pipes are opaque.** Any `foo | bar` pipeline is marked as `Piped`
|
|
//! regardless of what `bar` is. A user piping through `awk`, `jq`, `rg`, or
|
|
//! any other consumer is almost certainly parsing the output; rewriting it
|
|
//! would be a correctness bug. The engine falls back to passthrough.
|
|
//! - **Safe chains are segmented, not rewritten whole.** Top-level simple
|
|
//! commands joined only by `&&` and `;` may be split into `ChainSegment`s for
|
|
//! the segmented engine path, but the whole-buffer minimizer still treats the
|
|
//! combined chain as opaque.
|
|
//! - **Other compound commands are opaque.** `a || b`, background jobs, and
|
|
//! compound shell syntax such as subshells or function definitions are left
|
|
//! unchanged.
|
|
//! - **Single simple commands** are safe for the whole-buffer path; the engine
|
|
//! dispatches them through `detect.rs` as before.
|
|
//!
|
|
//! When the command fails to parse (syntax error, unsupported construct),
|
|
//! we return `Unsupported` and the engine passes through.
|
|
|
|
use brush_parser::{
|
|
ParserOptions, SourceInfo,
|
|
ast::{
|
|
AndOr, Command, CommandPrefixOrSuffixItem, CompoundListItem, IoFileRedirectTarget,
|
|
IoRedirect, Pipeline, Program, SeparatorOperator, Word,
|
|
},
|
|
};
|
|
|
|
/// One segment of a safe `&&` / `;` chain.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ChainSegment {
|
|
pub command: String,
|
|
pub program: String,
|
|
pub run_if_previous_succeeded: bool,
|
|
pub suppress_errexit: bool,
|
|
}
|
|
|
|
/// Outcome of analyzing a raw command string.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum CommandPlan {
|
|
/// Exactly one simple command. `program` is the leading word (without
|
|
/// arguments), verbatim from the parsed AST.
|
|
Single { program: String },
|
|
/// The command contains at least one `|` pipeline. We intentionally do
|
|
/// NOT identify upstream / downstream programs here — any pipe defeats
|
|
/// safe minimization for this engine.
|
|
Piped,
|
|
/// Top-level simple commands joined by `&&` and/or `;`. These can be
|
|
/// minimized segment-by-segment, but not as one combined buffer.
|
|
Chain { segments: Vec<ChainSegment> },
|
|
/// The command has multiple segments joined by `||`, `&`, or other
|
|
/// unsupported shell syntax. This shape is left unchanged; the minimizer
|
|
/// only rewrites whole simple command output.
|
|
Compound,
|
|
/// Parse failed, a compound shell construct (for loops, subshells, etc.)
|
|
/// was encountered, or the command was empty.
|
|
Unsupported,
|
|
}
|
|
|
|
/// Parse `command` with `brush-parser` and classify its structure.
|
|
#[must_use]
|
|
pub fn analyze(command: &str) -> CommandPlan {
|
|
let trimmed = command.trim();
|
|
if trimmed.is_empty() {
|
|
return CommandPlan::Unsupported;
|
|
}
|
|
|
|
let options = ParserOptions::default();
|
|
let source_info = SourceInfo::default();
|
|
let reader = std::io::Cursor::new(command.as_bytes());
|
|
let mut parser = brush_parser::Parser::new(reader, &options, &source_info);
|
|
|
|
let Ok(program) = parser.parse_program() else {
|
|
return CommandPlan::Unsupported;
|
|
};
|
|
|
|
classify(&program)
|
|
}
|
|
|
|
fn classify(program: &Program) -> CommandPlan {
|
|
if let Some(chain) = classify_chain(program) {
|
|
return chain;
|
|
}
|
|
|
|
// Count separator-separated top-level items across all complete_commands.
|
|
let items: Vec<&CompoundListItem> = program
|
|
.complete_commands
|
|
.iter()
|
|
.flat_map(|cl| cl.0.iter())
|
|
.collect();
|
|
|
|
if items.is_empty() {
|
|
return CommandPlan::Unsupported;
|
|
}
|
|
|
|
if items.len() > 1 {
|
|
// `a ; b` or `a & b` produces multiple compound list items.
|
|
return CommandPlan::Compound;
|
|
}
|
|
|
|
// Exactly one CompoundListItem: check the separator and the AndOrList.
|
|
let CompoundListItem(and_or, separator) = items[0];
|
|
|
|
// Async separator (`&`) backgrounds the command; treat as compound since
|
|
// the parent shell's stdout is the foreground command's — we don't know
|
|
// which one we're capturing. Conservative bail.
|
|
if matches!(separator, SeparatorOperator::Async) {
|
|
return CommandPlan::Compound;
|
|
}
|
|
|
|
// AndOrList.additional holds the `&&` / `||` continuations.
|
|
if !and_or.additional.is_empty() {
|
|
return CommandPlan::Compound;
|
|
}
|
|
|
|
// Only a single pipeline at this point.
|
|
classify_pipeline(&and_or.first).unwrap_or(CommandPlan::Unsupported)
|
|
}
|
|
|
|
fn classify_chain(program: &Program) -> Option<CommandPlan> {
|
|
let items: Vec<&CompoundListItem> = program
|
|
.complete_commands
|
|
.iter()
|
|
.flat_map(|cl| cl.0.iter())
|
|
.collect();
|
|
|
|
if items.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut segments = Vec::new();
|
|
let mut run_if_previous_succeeded = false;
|
|
|
|
for (item_index, item) in items.iter().enumerate() {
|
|
if matches!(item.1, SeparatorOperator::Async) {
|
|
return None;
|
|
}
|
|
|
|
let is_last_item = item_index + 1 == items.len();
|
|
let mut pipeline = &item.0.first;
|
|
let mut additional = item.0.additional.iter().peekable();
|
|
|
|
loop {
|
|
let (command, program) = simple_segment(pipeline)?;
|
|
|
|
let suppress_errexit = additional
|
|
.peek()
|
|
.is_some_and(|and_or| matches!(and_or, AndOr::And(_)));
|
|
segments.push(ChainSegment {
|
|
command,
|
|
program,
|
|
run_if_previous_succeeded,
|
|
suppress_errexit,
|
|
});
|
|
|
|
let Some(and_or) = additional.next() else {
|
|
run_if_previous_succeeded = false;
|
|
break;
|
|
};
|
|
|
|
match and_or {
|
|
AndOr::And(next_pipeline) => {
|
|
run_if_previous_succeeded = true;
|
|
pipeline = next_pipeline;
|
|
},
|
|
AndOr::Or(_) => return None,
|
|
}
|
|
}
|
|
|
|
if !is_last_item {
|
|
run_if_previous_succeeded = false;
|
|
}
|
|
}
|
|
|
|
(segments.len() >= 2).then_some(CommandPlan::Chain { segments })
|
|
}
|
|
|
|
fn word_has_command_substitution(word: &Word) -> bool {
|
|
word.value.contains("$(") || word.value.contains('`')
|
|
}
|
|
|
|
fn command_prefix_or_suffix_item_is_safe(item: &CommandPrefixOrSuffixItem) -> bool {
|
|
match item {
|
|
CommandPrefixOrSuffixItem::IoRedirect(io) => io_redirect_is_safe(io),
|
|
CommandPrefixOrSuffixItem::Word(word) => !word_has_command_substitution(word),
|
|
CommandPrefixOrSuffixItem::AssignmentWord(_, word) => !word_has_command_substitution(word),
|
|
CommandPrefixOrSuffixItem::ProcessSubstitution(..) => false,
|
|
}
|
|
}
|
|
|
|
fn io_redirect_is_safe(io: &IoRedirect) -> bool {
|
|
match io {
|
|
IoRedirect::File(_, _, target) => match target {
|
|
IoFileRedirectTarget::Filename(word) | IoFileRedirectTarget::Duplicate(word) => {
|
|
!word_has_command_substitution(word)
|
|
},
|
|
IoFileRedirectTarget::Fd(_) => true,
|
|
IoFileRedirectTarget::ProcessSubstitution(..) => false,
|
|
},
|
|
IoRedirect::HereDocument(_, here_doc) => {
|
|
!word_has_command_substitution(&here_doc.here_end)
|
|
&& !word_has_command_substitution(&here_doc.doc)
|
|
},
|
|
IoRedirect::HereString(_, word) => !word_has_command_substitution(word),
|
|
IoRedirect::OutputAndError(word, _) => !word_has_command_substitution(word),
|
|
}
|
|
}
|
|
|
|
fn simple_segment(pipeline: &Pipeline) -> Option<(String, String)> {
|
|
if pipeline.timed.is_some() || pipeline.bang || pipeline.seq.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// For multi-stage pipes inside a chain segment, identify the segment by its
|
|
// first stage's program. The downstream per-segment minimizer::apply will
|
|
// detect the pipeline at runtime via plan::CommandPlan::Piped and pass it
|
|
// through unchanged — so a piped segment is safely captured but never
|
|
// rewritten. This keeps the chain decomposable when even one inner stage
|
|
// uses a pipe (e.g. `ls | head -10 && git status`).
|
|
let first = pipeline.seq.first()?;
|
|
match first {
|
|
Command::Simple(simple) => {
|
|
if simple.prefix.as_ref().is_some_and(|prefix| {
|
|
prefix
|
|
.0
|
|
.iter()
|
|
.any(|item| !command_prefix_or_suffix_item_is_safe(item))
|
|
}) {
|
|
return None;
|
|
}
|
|
if simple.suffix.as_ref().is_some_and(|suffix| {
|
|
suffix
|
|
.0
|
|
.iter()
|
|
.any(|item| !command_prefix_or_suffix_item_is_safe(item))
|
|
}) {
|
|
return None;
|
|
}
|
|
|
|
let program_word = simple.word_or_name.as_ref()?;
|
|
if word_has_command_substitution(program_word) {
|
|
return None;
|
|
}
|
|
let program = program_word.to_string();
|
|
if program.trim().is_empty() {
|
|
return None;
|
|
}
|
|
Some((pipeline.to_string(), program))
|
|
},
|
|
// Compound shell syntax (if / for / while / subshell / { ... }) is
|
|
// not something the minimizer should touch.
|
|
Command::Compound(..) | Command::Function(_) | Command::ExtendedTest(..) => None,
|
|
}
|
|
}
|
|
|
|
fn classify_pipeline(pipeline: &Pipeline) -> Option<CommandPlan> {
|
|
if pipeline.seq.len() > 1 {
|
|
return Some(CommandPlan::Piped);
|
|
}
|
|
let single = pipeline.seq.first()?;
|
|
match single {
|
|
Command::Simple(simple) => {
|
|
let program_word = simple.word_or_name.as_ref()?;
|
|
let program_text = program_word.to_string();
|
|
if program_text.trim().is_empty() {
|
|
return None;
|
|
}
|
|
Some(CommandPlan::Single { program: program_text })
|
|
},
|
|
// Compound shell syntax (if / for / while / subshell / { ... }) is
|
|
// not something the minimizer should touch.
|
|
Command::Compound(..) | Command::Function(_) | Command::ExtendedTest(..) => {
|
|
Some(CommandPlan::Compound)
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn program_of(plan: CommandPlan) -> Option<String> {
|
|
match plan {
|
|
CommandPlan::Single { program } => Some(program),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn chain_of(plan: CommandPlan) -> Option<Vec<ChainSegment>> {
|
|
match plan {
|
|
CommandPlan::Chain { segments } => Some(segments),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn assert_not_chain(command: &str) {
|
|
assert!(
|
|
!matches!(analyze(command), CommandPlan::Chain { .. }),
|
|
"{command:?} unexpectedly classified as Chain"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn single_simple_command() {
|
|
let plan = analyze("git status --short");
|
|
assert_eq!(program_of(plan), Some("git".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn env_prefix_is_still_single() {
|
|
// env assignments are prefix, the program is `git`.
|
|
let plan = analyze("FOO=1 git status");
|
|
assert!(matches!(plan, CommandPlan::Single { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn safe_and_chain_is_segmented() {
|
|
let plan = analyze("git diff --stat && git diff --name-only");
|
|
assert_eq!(
|
|
chain_of(plan),
|
|
Some(vec![
|
|
ChainSegment {
|
|
command: "git diff --stat".to_string(),
|
|
program: "git".to_string(),
|
|
run_if_previous_succeeded: false,
|
|
suppress_errexit: true,
|
|
},
|
|
ChainSegment {
|
|
command: "git diff --name-only".to_string(),
|
|
program: "git".to_string(),
|
|
run_if_previous_succeeded: true,
|
|
suppress_errexit: false,
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn safe_sequence_chain_is_segmented() {
|
|
let plan = analyze("git status ; bun test");
|
|
assert_eq!(
|
|
chain_of(plan),
|
|
Some(vec![
|
|
ChainSegment {
|
|
command: "git status".to_string(),
|
|
program: "git".to_string(),
|
|
run_if_previous_succeeded: false,
|
|
suppress_errexit: false,
|
|
},
|
|
ChainSegment {
|
|
command: "bun test".to_string(),
|
|
program: "bun".to_string(),
|
|
run_if_previous_succeeded: false,
|
|
suppress_errexit: false,
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_chain_is_segmented() {
|
|
let plan = analyze("false && echo no ; echo yes");
|
|
assert_eq!(
|
|
chain_of(plan),
|
|
Some(vec![
|
|
ChainSegment {
|
|
command: "false".to_string(),
|
|
program: "false".to_string(),
|
|
run_if_previous_succeeded: false,
|
|
suppress_errexit: true,
|
|
},
|
|
ChainSegment {
|
|
command: "echo no".to_string(),
|
|
program: "echo".to_string(),
|
|
run_if_previous_succeeded: true,
|
|
suppress_errexit: false,
|
|
},
|
|
ChainSegment {
|
|
command: "echo yes".to_string(),
|
|
program: "echo".to_string(),
|
|
run_if_previous_succeeded: false,
|
|
suppress_errexit: false,
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn chain_with_piped_segment_is_segmented() {
|
|
// A chain that contains a piped segment (`ls | head -5`) must still be
|
|
// classified as Chain so the segmented runner can decompose it. The
|
|
// piped segment is identified by its first stage's program; the
|
|
// per-segment minimizer::apply will treat that segment as Piped at
|
|
// runtime and pass it through unchanged.
|
|
let plan = analyze("ls -lh *.txt | head -5 && git status --short");
|
|
let segments = chain_of(plan).expect("expected Chain");
|
|
assert_eq!(segments.len(), 2);
|
|
assert_eq!(segments[0].program, "ls");
|
|
assert_eq!(segments[1].program, "git");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unsafe_chain_segments() {
|
|
for command in [
|
|
"echo $(pwd) ; git status",
|
|
"echo `pwd` ; git status",
|
|
"cat <(printf hi) ; git status",
|
|
"git status > >(cat) ; bun test",
|
|
"! git status ; bun test",
|
|
] {
|
|
assert_not_chain(command);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_legacy_opaque_shapes() {
|
|
assert_eq!(analyze("foo || bar"), CommandPlan::Compound);
|
|
assert_eq!(analyze("git status | cat"), CommandPlan::Piped);
|
|
assert_eq!(analyze("sleep 1 &"), CommandPlan::Compound);
|
|
assert_eq!(analyze("(cd foo && make)"), CommandPlan::Compound);
|
|
assert_eq!(analyze("{ echo hi; }"), CommandPlan::Compound);
|
|
assert_eq!(analyze("f() { echo hi; }"), CommandPlan::Compound);
|
|
assert_eq!(analyze("[[ -f foo ]]"), CommandPlan::Compound);
|
|
assert_eq!(analyze("a && && b"), CommandPlan::Unsupported);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_is_unsupported() {
|
|
assert_eq!(analyze(""), CommandPlan::Unsupported);
|
|
assert_eq!(analyze(" "), CommandPlan::Unsupported);
|
|
}
|
|
}
|