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
63 changes: 58 additions & 5 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,17 @@ pub struct Interpreter {
/// a function or a sourced script, so it needs to tell the two apart from
/// a plain top-level command.
source_depth: usize,
/// Bytes already accumulated by the command sequence that is running, as
/// of the command about to run. A redirect `exec` installs applies from
/// there on, so these mark where routing starts.
sequence_accum: (usize, usize),
/// `sequence_accum` as it was when `exec` last installed a target for
/// fd 1 or 2: output written before that keeps going to the caller.
exec_install_mark: (usize, usize),
/// Name of the last simple command run, for a diagnostic raised after it
/// returned: a write to a closed descriptor is reported at the subshell
/// boundary, where the offending command is no longer in hand.
last_command_name: String,
/// HTTP client for network builtins (curl, wget)
#[cfg(feature = "http_client")]
http_client: Option<Arc<crate::network::HttpClient>>,
Expand Down Expand Up @@ -1997,6 +2008,9 @@ impl Interpreter {
current_line: 1,
lineno_unset: false,
source_depth: 0,
last_command_name: String::new(),
sequence_accum: (0, 0),
exec_install_mark: (0, 0),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
Expand Down Expand Up @@ -2149,6 +2163,9 @@ impl Interpreter {
env: self.env.clone(),
scoped: self.scoped.clone(),
source_depth: self.source_depth,
last_command_name: self.last_command_name.clone(),
sequence_accum: self.sequence_accum,
exec_install_mark: self.exec_install_mark,
flags: self.flags,
cwd: self.cwd.clone(),
last_exit_code: self.last_exit_code,
Expand Down Expand Up @@ -3272,8 +3289,13 @@ impl Interpreter {
}
self.check_cancelled()?;
if top_level {
self.route_exec_output(&mut result, emitted_before, Self::command_name(command))
.await?;
self.route_exec_output(
&mut result,
emitted_before,
(0, 0),
Self::command_name(command),
)
.await?;
}
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
if top_level {
Expand Down Expand Up @@ -3531,6 +3553,9 @@ impl Interpreter {
self.check_cancelled()?;
// Update current line for $LINENO
self.current_line = Self::command_line(command);
if let Some(name) = Self::command_name(command) {
self.last_command_name = name.to_string();
}

// `set -n`: read commands without running them for the rest of
// this exec (bash does the same in scripts; nothing can undo it,
Expand Down Expand Up @@ -3734,7 +3759,21 @@ impl Interpreter {
let saved_exit = self.last_exit_code;
let saved_coproc = self.coproc_buffers.clone();

let emitted_before = (
self.output_stream_stdout_bytes,
self.output_stream_stderr_bytes,
);
let mut result = self.execute_command_sequence(commands).await;
// An `exec` redirect set inside the subshell applies to the
// subshell's own output. It has to be routed here, while the
// subshell's fd table is still current: the outermost loop
// sees the parent's table, restored just below. What the
// subshell wrote before the `exec` stays with the caller.
let keep_floor = self.exec_install_mark;
if let Ok(ref mut res) = result {
self.route_exec_output(res, emitted_before, keep_floor, None)
.await?;
}

// Fire EXIT trap set inside the subshell before restoring parent state
if let Some(trap_cmd) = self.scoped.traps.get("EXIT").cloned() {
Expand Down Expand Up @@ -5798,6 +5837,7 @@ impl Interpreter {

for command in commands {
let emit_before = self.output_emit_count;
self.sequence_accum = (stdout.len(), stderr.len());
let result = self.execute_command(command).await?;
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
stdout.append(&result.stdout);
Expand Down Expand Up @@ -6413,6 +6453,7 @@ impl Interpreter {
exit_code_from_conditional_context = false;
} else {
let emit_before = self.output_emit_count;
self.sequence_accum = (0, 0);
// A non-final `&&`/`||` element runs with errexit ignored.
let conditional = list
.rest
Expand Down Expand Up @@ -6508,6 +6549,7 @@ impl Interpreter {
exit_code_from_conditional_context = false;
} else {
let emit_before = self.output_emit_count;
self.sequence_accum = (stdout.len(), stderr.len());
let followed_by_conditional_op =
list.rest.get(i + 1).is_some_and(|(op, cmd)| {
!Self::is_empty_sentinel(cmd)
Expand Down Expand Up @@ -7364,6 +7406,9 @@ impl Interpreter {
args: &[String],
redirects: &[Redirect],
) -> Result<ExecResult> {
// Whatever the enclosing sequence has written so far belongs to the
// caller; only what follows this `exec` goes to its target.
self.exec_install_mark = self.sequence_accum;
if !args.is_empty() {
// Security: never reconstruct shell source from argv.
// Execute argv directly to avoid quote/parse injection.
Expand Down Expand Up @@ -7416,13 +7461,16 @@ impl Interpreter {
&mut self,
result: &mut ExecResult,
emitted_before: (usize, usize),
keep_floor: (usize, usize),
command_name: Option<&str>,
) -> Result<()> {
if !self.exec_fd_table.contains_key(&1) && !self.exec_fd_table.contains_key(&2) {
return Ok(());
}
let streamed_out = self.output_stream_stdout_bytes - emitted_before.0;
let streamed_err = self.output_stream_stderr_bytes - emitted_before.1;
// Bytes to leave with the caller: what a streaming caller already saw,
// or what was written before the `exec` installed its target.
let streamed_out = (self.output_stream_stdout_bytes - emitted_before.0).max(keep_floor.0);
let streamed_err = (self.output_stream_stderr_bytes - emitted_before.1).max(keep_floor.1);
let take = |data: &mut crate::StreamData, keep: usize| {
let keep = keep.min(data.len());
let rest = crate::StreamData::from(&data.as_bytes()[keep..]);
Expand Down Expand Up @@ -7453,7 +7501,12 @@ impl Interpreter {
}
if write_failed {
// bash: the write fails and the command reports it, naming itself.
let who = command_name.map(|n| format!("{n}: ")).unwrap_or_default();
let who = command_name.unwrap_or(&self.last_command_name);
let who = if who.is_empty() {
String::new()
} else {
format!("{who}: ")
};
let msg = format!(
"bash: line {}: {who}write error: Bad file descriptor\n",
self.current_line
Expand Down
48 changes: 48 additions & 0 deletions crates/bashkit/tests/integration/closed_fd_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,51 @@ async fn closing_stderr_alone_still_lets_stdout_through() {
assert_eq!(r.stderr.to_string(), "");
assert_eq!(r.exit_code, 1);
}

/// An `exec` redirect set inside a subshell applies to the subshell's own
/// output. It used to apply to nothing at all.
#[tokio::test]
async fn exec_inside_a_subshell_redirects_the_subshell() {
let mut bash = Bash::builder().build();
let r = bash
.exec("( exec > log.txt; echo inside )\necho sep\ncat log.txt")
.await
.unwrap();
assert_eq!(r.stdout.to_string(), "sep\ninside\n");
}

#[tokio::test]
async fn output_before_the_subshell_exec_still_reaches_the_caller() {
let mut bash = Bash::builder().build();
let r = bash
.exec("( echo pre; exec > log.txt; echo post )\necho sep\ncat log.txt")
.await
.unwrap();
assert_eq!(r.stdout.to_string(), "pre\nsep\npost\n");
}

#[tokio::test]
async fn a_subshell_can_redirect_its_stderr_with_exec() {
let mut bash = Bash::builder().build();
let r = bash
.exec("( exec 2>err.txt; ls /nope )\ncat err.txt")
.await
.unwrap();
assert_eq!(r.stderr.to_string(), "");
assert_eq!(
r.stdout.to_string(),
"ls: cannot access '/nope': No such file or directory\n"
);
}

#[tokio::test]
async fn closing_stdout_inside_a_subshell_names_the_failing_command() {
let mut bash = Bash::builder().build();
let r = bash.exec("( exec 1>&- ; echo x )").await.unwrap();
assert_eq!(r.stdout.to_string(), "");
assert_eq!(
r.stderr.to_string(),
"bash: line 1: echo: write error: Bad file descriptor\n"
);
assert_eq!(r.exit_code, 1);
}
26 changes: 26 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/closed-fd-write.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,29 @@ echo "st=$?"
x
st=1
### end

### exec_inside_a_subshell_redirects_the_subshell
( exec > log.txt; echo inside )
echo sep
cat log.txt
### expect
sep
inside
### end

### output_before_the_subshell_exec_reaches_the_caller
( echo pre; exec > log.txt; echo post )
echo sep
cat log.txt
### expect
pre
sep
post
### end

### a_subshell_can_redirect_its_stderr_with_exec
( exec 2>err.txt; ls /nope )
cat err.txt
### expect
ls: cannot access '/nope': No such file or directory
### end
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
dash on Debian) are out of the corpus too.
* **Test**: Debian-oracle scoreboard. `just debian-oracle` / `.github/workflows/debian-oracle.yml` run the pseudo-linus bench (about 9,600 cases recorded on real Debian, fetched at a pinned commit because parts of its corpus derive from GPL suites) and fail when a tool drops below `scripts/debian-oracle/floors.tsv`. See operations/testing "Debian-Oracle Scoreboard".
* **Fix**: `{arr[i]}` works as a redirect's fd variable in every position, `exec {A[1]}> file` and `exec {A[1]}>&-` alike. The name lexes as several literal parts because `[` opens a glob bracket, so the redirect parsed as a command called `{A[1]}` and failed with `command not found`.
* **Fix**: `exec` inside a subshell redirects the subshell's output. `( exec > log.txt; echo inside )` wrote to the caller's stdout and left `log.txt` empty, because only the outermost command loop routed the exec fd table and the subshell's table was restored before control got there. Routing now happens at the subshell boundary, with what the subshell wrote before its `exec` kept for the caller, and a write to a descriptor the subshell closed names the command that tried.
* **Fix**: `grep` follows GNU grep 3.11. Prefixes print as `FILE:LINE:BYTE:` (context lines with `-`), group separators appear between non-adjacent groups and files whenever a context option is given (`-NUM`, `--group-separator`, `--no-group-separator`), trailing context after `-m` is printed in full, `-o` skips empty matches and is POSIX leftmost-longest (`grep -oE 'ab|abcd'` prints `abcd`), `-b -o` gives each match's offset. New `grep_pattern` translates GNU BRE/ERE (brackets, intervals, `\< \> \b \w`, literal leading `*` in BRE, dropped leading ERE operator with GNU's warning, glibc error messages) onto awk's leftmost-longest `AwkRegex`; back-references work in BRE and ERE via fancy-regex (TM-DOS-025 cap). Options parse like getopt_long (unique prefixes such as `--no-gr`, `-T`, `-d`, `--label`, `-` for stdin, `-R` follows symlinks), an empty `-f` file matches nothing, a directory operand without `-r` reports `Is a directory`, invalid UTF-8 output turns the file binary, `\r` is line data. Debian-oracle grep score 60.5% to 96.8% (185 cases); grep's probes in the regex set all pass except two raw-`\x01` patterns the shell drops. Remaining misses are readdir order (L-GREP-003); L-GREP-002 lists the other gaps. See foundations/builtins "grep".

* **Fix**: builtins reject the arguments bash rejects. `return` outside a function or a sourced script reports `can only \`return' from a function or sourced script`, exits 2 and the script carries on (it used to end the script with status 0); `return N` inside a sourced script is now `source`'s status; `unset -v` insists the name is one it could have assigned (`arr[`, `a-b`, `1bad` are errors, `a[0]` is fine) while plain `unset` and `unset -f` still take any word; and `read -t`/`-n` report `invalid timeout specification` / `invalid number` instead of silently ignoring the option.
Expand Down
2 changes: 1 addition & 1 deletion knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance`

| ID | Limitation | Why | Evidence |
|----|------------|-----|----------|
| L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution. fd redirects work; `exec >log 2>&1` routes the shell's output per top-level command, so output written earlier in the same top-level command (not streamed yet) also lands in the log, and output under the redirect reaches a streaming caller only when that command ends. `exec N>&-` closes fd 1 or 2 for the rest of the script: later writes to it are dropped, the command reports `write error: Bad file descriptor` naming itself, and its status is 1. An `exec` redirect inside a subshell or a `bash -c` child does not take effect, because only the outermost command loop routes the exec fd table. `exec &>file` and `exec >&word` are ignored | True process replace would break sandbox containment; there is no real fd 1 to swap, the shell's output is a value returned to the caller | TM-ESC-005, `exec-redirect-shell-output.test.sh` |
| L-PROC-001 | `exec` does not replace the process; `exec cmd` runs cmd then stops execution. fd redirects work; `exec >log 2>&1` routes the shell's output per top-level command, so output written earlier in the same top-level command (not streamed yet) also lands in the log, and output under the redirect reaches a streaming caller only when that command ends. `exec N>&-` closes fd 1 or 2 for the rest of the script: later writes to it are dropped, the command reports `write error: Bad file descriptor` naming itself, and its status is 1. An `exec` redirect inside a subshell applies to the subshell's own output, routed at the subshell boundary; output the subshell wrote before the `exec` still reaches the caller. Inside a `bash -c` child it still does not take effect. `exec &>file` and `exec >&word` are ignored | True process replace would break sandbox containment; there is no real fd 1 to swap, the shell's output is a value returned to the caller | TM-ESC-005, `exec-redirect-shell-output.test.sh` |
| L-PROC-002 | Background jobs run concurrently (`jobs`, `wait -n`, `kill`, `ps`, `pgrep`), but cannot be stopped or resumed: `kill -STOP/-CONT` are ignored, there is no `suspend`/Ctrl-Z, `bg` is a no-op and `fg` just waits; jobs end when `exec()` returns | No terminal process groups in a virtual shell; a job must not outlive the call that owns it (TM-DOS-122) | `l_proc_002_no_job_control` |
| L-PROC-003 | No process spawning; external commands run as builtins | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` |
| L-PROC-004 | `bash`/`sh` child shells nest at most 8 deep (and count against `max_function_depth`); deeper nesting fails `maximum nesting depth exceeded` | Child shells run in-process on one stack; unbounded nesting crashed the process (TM-DOS-125) | `l_proc_004_child_shell_depth` |
Expand Down
Loading