Skip to content

Commit f92e1af

Browse files
authored
fix(interpreter): reset last_exit_code in VFS subprocess isolation (#850)
Subprocess isolation in execute_script_content did not reset last_exit_code or nounset_error, causing VFS script subprocesses to inherit stale parent state. In real bash, a subprocess starts with $?=0. This caused set -euo pipefail scripts to see a non-zero $? from the parent shell, leading to false errexit failures. Closes #842
1 parent 79a4c34 commit f92e1af

File tree

4 files changed

+96
-2
lines changed

4 files changed

+96
-2
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4013,14 +4013,18 @@ impl Interpreter {
40134013
let saved_aliases = self.aliases.clone();
40144014
let saved_coproc = self.coproc_buffers.clone();
40154015

4016-
// Child only sees exported variables (env), not all shell variables
4016+
// Child only sees exported variables (env), not all shell variables.
4017+
// Reset last_exit_code so $? starts at 0 (matches real bash subprocess).
4018+
// Clear nounset_error to prevent parent expansion errors from leaking.
40174019
self.variables = self.env.clone();
40184020
self.arrays.clear();
40194021
self.assoc_arrays.clear();
40204022
self.functions.clear();
40214023
self.traps.clear();
40224024
self.aliases.clear();
40234025
self.coproc_buffers.clear();
4026+
self.last_exit_code = 0;
4027+
self.nounset_error = None;
40244028

40254029
// Push call frame: $0 = script name, $1..N = args
40264030
self.call_stack = vec![CallFrame {

crates/bashkit/tests/script_execution_tests.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,92 @@ async fn declare_f_no_args_lists_all_functions() {
409409
assert!(result.stdout.contains("foo"));
410410
assert!(result.stdout.contains("bar"));
411411
}
412+
413+
/// Issue #842: $? should be 0 at start of VFS script subprocess, even when
414+
/// parent had non-zero exit code. Subprocess isolation must reset last_exit_code
415+
/// to match real bash subprocess behavior.
416+
#[tokio::test]
417+
async fn exec_vfs_script_initial_exit_code_is_zero() {
418+
let mut bash = Bash::new();
419+
let fs = bash.fs();
420+
421+
fs.write_file(Path::new("/check_exit.sh"), b"#!/bin/bash\necho $?\n")
422+
.await
423+
.unwrap();
424+
fs.chmod(Path::new("/check_exit.sh"), 0o755).await.unwrap();
425+
426+
// Parent has non-zero exit code, then run VFS script
427+
let result = bash.exec("false; /check_exit.sh").await.unwrap();
428+
assert_eq!(
429+
result.stdout.trim(),
430+
"0",
431+
"VFS script subprocess should start with $?=0 (like real bash); got stdout={:?}",
432+
result.stdout
433+
);
434+
}
435+
436+
/// Issue #842: `set -euo pipefail` in VFS script should not trigger false failure
437+
/// even after a prior non-zero exit code in the parent shell.
438+
#[tokio::test]
439+
async fn exec_vfs_script_set_e_after_prior_failure() {
440+
let mut bash = Bash::new();
441+
let fs = bash.fs();
442+
443+
fs.write_file(
444+
Path::new("/test.sh"),
445+
br#"#!/bin/bash
446+
set -euo pipefail
447+
MY_VAR="hello"
448+
echo "${MY_VAR}"
449+
"#,
450+
)
451+
.await
452+
.unwrap();
453+
fs.chmod(Path::new("/test.sh"), 0o755).await.unwrap();
454+
455+
let result = bash.exec("false; /test.sh").await.unwrap();
456+
assert_eq!(
457+
result.exit_code, 0,
458+
"VFS script with set -e should succeed after prior failure; stdout={:?}, stderr={:?}",
459+
result.stdout, result.stderr
460+
);
461+
assert_eq!(result.stdout.trim(), "hello");
462+
}
463+
464+
/// Issue #842: Nested VFS scripts with set -e and command substitution.
465+
#[tokio::test]
466+
async fn exec_vfs_script_set_e_nested_scripts() {
467+
let mut bash = Bash::new();
468+
let fs = bash.fs();
469+
470+
fs.write_file(
471+
Path::new("/inner.sh"),
472+
br#"#!/bin/bash
473+
set -euo pipefail
474+
echo "inner_output"
475+
"#,
476+
)
477+
.await
478+
.unwrap();
479+
fs.chmod(Path::new("/inner.sh"), 0o755).await.unwrap();
480+
481+
fs.write_file(
482+
Path::new("/outer.sh"),
483+
br#"#!/bin/bash
484+
set -euo pipefail
485+
RESULT="$(/inner.sh)"
486+
echo "${RESULT}"
487+
"#,
488+
)
489+
.await
490+
.unwrap();
491+
fs.chmod(Path::new("/outer.sh"), 0o755).await.unwrap();
492+
493+
let result = bash.exec("/outer.sh").await.unwrap();
494+
assert_eq!(
495+
result.exit_code, 0,
496+
"Nested VFS scripts with set -e should work; stdout={:?}, stderr={:?}",
497+
result.stdout, result.stderr
498+
);
499+
assert_eq!(result.stdout.trim(), "inner_output");
500+
}

specs/006-threat-model.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,7 @@ This section maps former vulnerability IDs to the new threat ID scheme and track
12071207
| TM-ISO-021 | EXIT trap leaks across `exec()` calls | Cross-invocation interference — trap from exec N fires in exec N+1 | Reset traps in `reset_for_execution()` |
12081208
| TM-ISO-022 | `$?` leaks across `exec()` calls | State pollution — exit code from previous exec visible to next exec | Reset `last_exit_code` in `reset_for_execution()` |
12091209
| TM-ISO-023 | `set -e` leaks across `exec()` calls | Unexpected abort — shell options from previous exec affect next exec | Reset shell options in `reset_for_execution()` |
1210+
| TM-ISO-024 | `$?` leaks into VFS subprocess | Parent `last_exit_code` visible inside VFS script subprocess, causing false `set -e` failures | Reset `last_exit_code = 0` and `nounset_error = None` in `execute_script_content` subprocess isolation |
12101211
| TM-INT-007 | `/dev/urandom` empty with `head -c` | Weak randomness — `head -c 16 /dev/urandom` returns empty string | Fix virtual device pipe handling in head builtin |
12111212
| TM-DOS-044 | Nested `$()` stack overflow (regression) | Process crash (SIGABRT) at depth ~50 despite #492 fix | Interpreter execution path may need separate depth tracking from lexer fix |
12121213

supply-chain/config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ version = "1.1.0"
207207
criteria = "safe-to-deploy"
208208

209209
[[exemptions.cmake]]
210-
version = "0.1.57"
210+
version = "0.1.58"
211211
criteria = "safe-to-deploy"
212212

213213
[[exemptions.cobs]]

0 commit comments

Comments
 (0)