Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/bashkit/src/builtins/vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ impl Builtin for Set {
while i < ctx.args.len() {
let arg = &ctx.args[i];
if arg == "--" {
// Everything after `--` becomes positional parameters.
// Encode as unit-separator-delimited string for the interpreter
// to pick up (same pattern as _SHIFT_COUNT).
let positional: Vec<&str> = ctx.args[i + 1..].iter().map(|s| s.as_str()).collect();
ctx.variables
.insert("_SET_POSITIONAL".to_string(), positional.join("\x1F"));
break;
} else if (arg.starts_with('-') || arg.starts_with('+'))
&& arg.len() > 1
Expand Down
68 changes: 66 additions & 2 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -663,12 +663,54 @@ impl Interpreter {
CompoundCommand::While(while_cmd) => self.execute_while(while_cmd).await,
CompoundCommand::Until(until_cmd) => self.execute_until(until_cmd).await,
CompoundCommand::Subshell(commands) => {
// Subshells run in isolated variable scope
// Subshells run in fully isolated scope: variables, arrays,
// functions, cwd, traps, positional params, and options are
// all snapshot/restored so mutations don't leak to the parent.
let saved_vars = self.variables.clone();
let saved_arrays = self.arrays.clone();
let saved_assoc = self.assoc_arrays.clone();
let saved_functions = self.functions.clone();
let saved_cwd = self.cwd.clone();
let saved_traps = self.traps.clone();
let saved_call_stack = self.call_stack.clone();
let saved_exit = self.last_exit_code;
let result = self.execute_command_sequence(commands).await;
let saved_options = self.options.clone();

let mut result = self.execute_command_sequence(commands).await;

// Fire EXIT trap set inside the subshell before restoring parent state
if let Some(trap_cmd) = self.traps.get("EXIT").cloned() {
// Only fire if the subshell set its own EXIT trap (different from parent)
let parent_had_same = saved_traps.get("EXIT") == Some(&trap_cmd);
if !parent_had_same {
if let Ok(trap_script) = Parser::new(&trap_cmd).parse() {
let emit_before = self.output_emit_count;
if let Ok(ref mut res) = result {
if let Ok(trap_result) =
self.execute_command_sequence(&trap_script.commands).await
{
self.maybe_emit_output(
&trap_result.stdout,
&trap_result.stderr,
emit_before,
);
res.stdout.push_str(&trap_result.stdout);
res.stderr.push_str(&trap_result.stderr);
}
}
}
}
}

self.variables = saved_vars;
self.arrays = saved_arrays;
self.assoc_arrays = saved_assoc;
self.functions = saved_functions;
self.cwd = saved_cwd;
self.traps = saved_traps;
self.call_stack = saved_call_stack;
self.last_exit_code = saved_exit;
self.options = saved_options;
result
}
CompoundCommand::BraceGroup(commands) => self.execute_command_sequence(commands).await,
Expand Down Expand Up @@ -3586,6 +3628,28 @@ impl Interpreter {
}
}

// Post-process: `set --` replaces positional parameters
if let Some(positional_str) = self.variables.remove("_SET_POSITIONAL") {
let new_positional: Vec<String> = if positional_str.is_empty() {
Vec::new()
} else {
positional_str
.split('\x1F')
.map(|s| s.to_string())
.collect()
};
if let Some(frame) = self.call_stack.last_mut() {
frame.positional = new_positional;
} else {
// No call frame yet (top-level script) — push one
self.call_stack.push(CallFrame {
name: String::new(),
locals: HashMap::new(),
positional: new_positional,
});
}
}

// Handle output redirections
return self.apply_redirections(result, &command.redirects).await;
}
Expand Down
4 changes: 0 additions & 4 deletions crates/bashkit/tests/spec_cases/bash/subshell.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ original

### subshell_function_isolation
# Functions defined in subshell don't leak
### skip: TODO function definitions in subshell leak to parent scope
( f() { echo inside; }; f )
f 2>/dev/null
echo status=$?
Expand Down Expand Up @@ -73,7 +72,6 @@ world

### subshell_cd_isolation
# cd in subshell doesn't affect parent
### skip: TODO cd in subshell leaks to parent (VFS model)
original=$(pwd)
( cd / )
test "$(pwd)" = "$original" && echo isolated
Expand All @@ -83,7 +81,6 @@ isolated

### subshell_traps_isolated
# Traps in subshell don't leak to parent
### skip: TODO trap in subshell not isolated
trap 'echo parent' EXIT
( trap 'echo child' EXIT )
trap - EXIT
Expand Down Expand Up @@ -125,7 +122,6 @@ third

### subshell_preserves_positional
# Positional params in subshell don't leak
### skip: TODO positional params in subshell leak to parent
set -- a b c
( set -- x y; echo "$@" )
echo "$@"
Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/tests/spec_cases/bash/word-split.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ echo "$*"

### ws_elision_space
# Unquoted whitespace-only var is elided
### skip: TODO word splitting does not elide whitespace-only expansions yet
s1=' '
set -- $s1
echo $#
Expand Down
Loading