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
48 changes: 44 additions & 4 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3242,7 +3242,8 @@ impl Interpreter {
}
self.check_cancelled()?;
if top_level {
self.route_exec_output(&mut result, emitted_before).await?;
self.route_exec_output(&mut result, emitted_before, Self::command_name(command))
.await?;
}
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
if top_level {
Expand Down Expand Up @@ -3424,6 +3425,17 @@ impl Interpreter {
}

/// Get the source line number from a command's span
/// The name a simple command would report itself under in an error.
fn command_name(command: &Command) -> Option<&str> {
let Command::Simple(c) = command else {
return None;
};
match c.name.parts.first()? {
WordPart::Literal(name) => Some(name.as_str()),
_ => None,
}
}

fn command_line(command: &Command) -> usize {
match command {
Command::Simple(c) => c.span.line(),
Expand Down Expand Up @@ -5562,6 +5574,8 @@ enum FdTarget {
AppendFile(PathBuf, String),
/// Discard (/dev/null).
DevNull,
/// Closed by `exec N>&-`: a write to it fails.
Closed,
}

/// Route fd1/fd2/fd3+ content to their targets. Extracted from the async
Expand Down Expand Up @@ -5599,7 +5613,8 @@ fn route_fd_table_content(
err.append(data);
}
}
FdTarget::DevNull => {}
// A closed descriptor accepts nothing.
FdTarget::DevNull | FdTarget::Closed => {}
FdTarget::WriteFile(p, d) => {
let entry = fw
.entry(p.clone())
Expand Down Expand Up @@ -7432,8 +7447,15 @@ impl Interpreter {
continue;
};
if target == "-" || target == "&-" {
// exec N>&- closes the fd
self.exec_fd_table.remove(&fd);
// `exec N>&-` closes the fd. For 1 and 2 the entry stays
// so a later write reports a closed descriptor instead
// of reaching the terminal.
if fd == 1 || fd == 2 {
self.ensure_persistent_fd_capacity(fd)?;
self.exec_fd_table.insert(fd, FdTarget::Closed);
} else {
self.exec_fd_table.remove(&fd);
}
} else if let Ok(target_fd) = target.parse::<i32>() {
// exec N>&M duplicates fd M to fd N
let target_entry = self.exec_fd_alias_target(target_fd);
Expand Down Expand Up @@ -7467,6 +7489,7 @@ impl Interpreter {
&mut self,
result: &mut ExecResult,
emitted_before: (usize, usize),
command_name: Option<&str>,
) -> Result<()> {
if !self.exec_fd_table.contains_key(&1) && !self.exec_fd_table.contains_key(&2) {
return Ok(());
Expand All @@ -7486,6 +7509,7 @@ impl Interpreter {
if let Some(target) = self.exec_fd_table.get(&2).cloned() {
moved.push((take(&mut result.stderr, streamed_err), target));
}
let mut write_failed = false;
for (data, target) in moved {
if data.is_empty() {
continue;
Expand All @@ -7494,11 +7518,27 @@ impl Interpreter {
FdTarget::Stdout => result.stdout.append(&data),
FdTarget::Stderr => result.stderr.append(&data),
FdTarget::DevNull => {}
FdTarget::Closed => write_failed = true,
FdTarget::WriteFile(path, _) | FdTarget::AppendFile(path, _) => {
self.fs.append_file(&path, data.as_bytes()).await?;
}
}
}
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 msg = format!(
"bash: line {}: {who}write error: Bad file descriptor\n",
self.current_line
);
if !matches!(self.exec_fd_table.get(&2), Some(FdTarget::Closed)) {
result
.stderr
.append(&crate::StreamData::from(msg.as_bytes()));
}
result.exit_code = 1;
self.last_exit_code = 1;
}
Ok(())
}

Expand Down
3 changes: 2 additions & 1 deletion crates/bashkit/src/interpreter/redirection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,8 @@ impl Interpreter {
FdTarget::Stderr if passthrough => self.exec_passthrough.1.append(&data),
FdTarget::Stdout => result.stdout.append(&data),
FdTarget::Stderr => result.stderr.append(&data),
FdTarget::DevNull => {}
// A closed descriptor accepts nothing.
FdTarget::DevNull | FdTarget::Closed => {}
FdTarget::WriteFile(path, _) | FdTarget::AppendFile(path, _) => {
self.fs.append_file(path, data.as_bytes()).await?;
}
Expand Down
49 changes: 49 additions & 0 deletions crates/bashkit/tests/integration/closed_fd_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//! Writes to a descriptor closed with `exec N>&-`.
//!
//! Bash keeps the descriptor closed and every later write to it fails with
//! `write error: Bad file descriptor`, named after the command that tried,
//! and the command's status is 1. The output is dropped, not re-routed.

use bashkit::Bash;

async fn run(script: &str) -> bashkit::ExecResult {
Bash::builder().build().exec(script).await.unwrap()
}

#[tokio::test]
async fn writing_to_a_closed_stdout_reports_and_fails() {
let r = run("exec 1>&-\necho x").await;
assert_eq!(r.stdout.to_string(), "");
assert_eq!(
r.stderr.to_string(),
"bash: line 2: echo: write error: Bad file descriptor\n"
);
assert_eq!(r.exit_code, 1);
}

#[tokio::test]
async fn the_failing_command_names_itself() {
let r = run("exec 1>&-\nprintf 'hi\\n'").await;
assert_eq!(r.stdout.to_string(), "");
assert_eq!(
r.stderr.to_string(),
"bash: line 2: printf: write error: Bad file descriptor\n"
);
assert_eq!(r.exit_code, 1);
}

#[tokio::test]
async fn a_closed_stderr_swallows_the_report_but_keeps_the_status() {
let r = run("exec 1>&- 2>&-\necho x").await;
assert_eq!(r.stdout.to_string(), "");
assert_eq!(r.stderr.to_string(), "");
assert_eq!(r.exit_code, 1);
}

#[tokio::test]
async fn closing_stderr_alone_still_lets_stdout_through() {
let r = run("exec 2>&-\necho x\necho y >&2").await;
assert_eq!(r.stdout.to_string(), "x\n");
assert_eq!(r.stderr.to_string(), "");
assert_eq!(r.exit_code, 1);
}
1 change: 1 addition & 0 deletions crates/bashkit/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub mod byte_range_panic_tests;
pub mod byte_stream_tests;
pub mod cancellation_tests;
pub mod close_brace_word_tests;
pub mod closed_fd_tests;
pub mod cmdsub_quote_test;
pub mod command_resolver_tests;
pub mod competitor_regression_tests;
Expand Down
15 changes: 15 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/closed-fd-write.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
### write_to_closed_stdout_drops_the_output_and_fails
### exit_code: 1
exec 1>&- 2>&-
echo x
### end

### closing_stderr_alone_keeps_stdout_and_fails_the_stderr_write
exec 2>&-
echo x
echo y >&2
echo "st=$?"
### expect
x
st=1
### end
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* **Feature**: jq `$__loc__` (`{"file":"<top-level>","line":N}`), including `{$__loc__}` shorthand and string interpolation. jaq has no `$__loc__`, so the filter text is rewritten before compiling (`builtins/jq/loc.rs` skips strings and comments).
* **Feature**: jq `-a`/`--ascii-output` (escape non-ASCII, quoted even with `-r`), `--raw-output0` (NUL after each output, NUL in a string is an error), `--seq` (RS before each output, RS read as a separator) and `--stream` (inputs become `tostream` path events, also with `-n`/`-s`). `-a` was accepted and ignored before.
* **Fix**: jq `halt`, `halt_error`, `stderr` and `debug` work like jq. `halt` ends the jq command (not the host) with its code and skips the remaining inputs; `halt_error` prints a string as is or other values as JSON to stderr; `stderr`/`debug` write to the command's stderr in order with its errors (capped buffer) instead of the host logger. TM-INF-023 updated: the guard is now that the run loop never calls `jaq_core::unwrap_valr`.
* **Fix**: `exec 1>&-` / `exec 2>&-` really close the descriptor. A later write to it is dropped, the command reports `bash: line N: echo: write error: Bad file descriptor` naming itself, and its status is 1; a closed fd 2 swallows the report but keeps the status. Bashkit used to print the output and succeed. L-PROC-001 now also records that an `exec` redirect inside a subshell or `bash -c` child does not take effect.
* **Fix**: jq builtins follow jq 1.7: `join` prints null as "" and fails on arrays/objects, `ltrimstr`/`rtrimstr` pass non-strings through, `scan` with capture groups yields the groups, `match` reports an unmatched group (offset -1, string null) so `capture` gives null, `from_entries` takes `key`/`Key`/`name`/`Name` and `value`/`Value`, object keys must be strings, `tostream`/`fromstream`/`truncate_stream`/`IN` are defined, DEL prints as `\u007f`. Debian-oracle jq score 79.9% to 84.0%.
* **Fix**: jq assignments through null work like jq: `null | .a.b.c = 1`, `.[2] = 1`, `.a += 1` and slice updates create the containers (they failed with `Cannot iterate over null`); `|= empty` on a missing path keeps null; an index past the end pads the array with null; fractional update indexes truncate; jq's error texts for negative and too-large indexes and for iterating a scalar. Debian-oracle jq score 75.8% to 79.9%.
* **Fix**: sed follows GNU on character escapes, `-z`, unreadable inputs and long loops. `\xHH`, `\dNNN`, `\oNNN`, `\cX` work in regexes, replacements and `y` and always produce a literal character (`s/z/\x26/` inserts `&`); `\t`/`\n` work inside bracket expressions (`s/^[ \t]*//`). Under `-z`, `N`/`G`/`H` join with NUL, `D`/`P` split on it, and `M` anchors match around it. An unreadable input file is reported and skipped (exit 2 at the end). The loop budget now counts per input line read, so `$!N;P;D` no longer stopped after 1000 lines. Debian-oracle sed score 94.2% to 99.4%.
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 {var}>file` (allocate an fd into `var`) is not implemented | 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 or a `bash -c` child does not take effect, because only the outermost command loop routes the exec fd table. `exec {var}>file` (allocate an fd into `var`) is not implemented | 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