diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 80b22d9e2..ca69f32c9 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -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 { @@ -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(), @@ -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 @@ -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()) @@ -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::() { // exec N>&M duplicates fd M to fd N let target_entry = self.exec_fd_alias_target(target_fd); @@ -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(()); @@ -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; @@ -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(()) } diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index 613d866a8..8c7bfd909 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -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?; } diff --git a/crates/bashkit/tests/integration/closed_fd_tests.rs b/crates/bashkit/tests/integration/closed_fd_tests.rs new file mode 100644 index 000000000..bd8a2973d --- /dev/null +++ b/crates/bashkit/tests/integration/closed_fd_tests.rs @@ -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); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 422410653..524424d26 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -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; diff --git a/crates/bashkit/tests/spec_cases/bash/closed-fd-write.test.sh b/crates/bashkit/tests/spec_cases/bash/closed-fd-write.test.sh new file mode 100644 index 000000000..2ee3ea60d --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/closed-fd-write.test.sh @@ -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 diff --git a/knowledge/log.md b/knowledge/log.md index 4244f1213..3d5d8e5e4 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -7,6 +7,7 @@ * **Feature**: jq `$__loc__` (`{"file":"","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%. diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 5066e5b58..de21f3fa3 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -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` |