From 609b82f2120e94061dae0dc5d11c16d080dd8988 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 26 Mar 2026 22:52:39 +0000 Subject: [PATCH 1/2] fix(interpreter): fire EXIT trap inside command substitution subshell EXIT traps set inside $() now fire and their output is captured by the substitution, matching real bash behavior. Trap state is snapshot/restored so traps don't leak to the parent shell. Closes #806 --- crates/bashkit/src/interpreter/mod.rs | 24 ++++++++++++ .../spec_cases/bash/command-subst.test.sh | 38 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 872e02f8..6e51c813 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -5795,12 +5795,36 @@ impl Interpreter { "maximum nesting depth exceeded in command substitution".to_string(), )); } + // Command substitution runs in a subshell: snapshot trap state + // so EXIT traps set inside $() fire and don't leak to parent. + let saved_traps = self.traps.clone(); let mut stdout = String::new(); for cmd in commands { let cmd_result = self.execute_command(cmd).await?; stdout.push_str(&cmd_result.stdout); self.last_exit_code = cmd_result.exit_code; } + // Fire EXIT trap set inside the command substitution + if let Some(trap_cmd) = self.traps.get("EXIT").cloned() { + let parent_had_same = saved_traps.get("EXIT") == Some(&trap_cmd); + if !parent_had_same { + if let Ok(trap_script) = Parser::with_limits( + &trap_cmd, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ) + .parse() + { + if let Ok(trap_result) = + self.execute_command_sequence(&trap_script.commands).await + { + stdout.push_str(&trap_result.stdout); + } + } + } + } + // Restore parent trap state + self.traps = saved_traps; self.counters.pop_function(); self.subst_generation += 1; let trimmed = stdout.trim_end_matches('\n'); diff --git a/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh b/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh index 38210a8d..3342a7bf 100644 --- a/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh @@ -185,3 +185,41 @@ echo "$result" ### expect (x)(y)(z) ### end + +### subst_exit_trap_captured +# EXIT trap output should be captured inside $(), not leak to parent +result="$(trap 'echo TRAPPED' EXIT; echo hello)" +echo "captured=[${result}]" +### expect +captured=[hello +TRAPPED] +### end + +### subst_exit_trap_with_explicit_exit +# EXIT trap fires on explicit exit inside $() +result="$(trap 'echo CLEANUP' EXIT; echo data; exit 0)" +echo "captured=[${result}]" +### expect +captured=[data +CLEANUP] +### end + +### subst_exit_trap_no_leak +# Trap output must not leak to parent stdout +out="$(trap 'echo INSIDE' EXIT; echo body)" +echo "out=[${out}]" +### expect +out=[body +INSIDE] +### end + +### subst_exit_trap_isolation +# EXIT trap in $() should not affect parent traps +trap 'echo PARENT' EXIT +result="$(trap 'echo CHILD' EXIT; echo inner)" +echo "result=[${result}]" +trap - EXIT +### expect +result=[inner +CHILD] +### end From 92b8e28973214ec6570ef21cd7e5418a4a49ee6c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 26 Mar 2026 23:04:50 +0000 Subject: [PATCH 2/2] style: collapse nested ifs per clippy suggestion --- crates/bashkit/src/interpreter/mod.rs | 29 +++++++++++---------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 6e51c813..915b1d26 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -5805,23 +5805,18 @@ impl Interpreter { self.last_exit_code = cmd_result.exit_code; } // Fire EXIT trap set inside the command substitution - if let Some(trap_cmd) = self.traps.get("EXIT").cloned() { - let parent_had_same = saved_traps.get("EXIT") == Some(&trap_cmd); - if !parent_had_same { - if let Ok(trap_script) = Parser::with_limits( - &trap_cmd, - self.limits.max_ast_depth, - self.limits.max_parser_operations, - ) - .parse() - { - if let Ok(trap_result) = - self.execute_command_sequence(&trap_script.commands).await - { - stdout.push_str(&trap_result.stdout); - } - } - } + if let Some(trap_cmd) = self.traps.get("EXIT").cloned() + && saved_traps.get("EXIT") != Some(&trap_cmd) + && let Ok(trap_script) = Parser::with_limits( + &trap_cmd, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ) + .parse() + && let Ok(trap_result) = + self.execute_command_sequence(&trap_script.commands).await + { + stdout.push_str(&trap_result.stdout); } // Restore parent trap state self.traps = saved_traps;