From 1150a57dcec567473ce6aeb8b01ed0f1497a39a0 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 22:10:59 +0000 Subject: [PATCH] fix(parser): accept {arr[i]} as an fd-variable redirect exec {COPROC[1]}>&- closed nothing and ran {COPROC[1]} as a command, because the fd-variable name had to be a plain identifier. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/interpreter/mod.rs | 8 +++---- crates/bashkit/src/parser/mod.rs | 5 ++++- .../tests/spec_cases/bash/fdvar-array.test.sh | 21 +++++++++++++++++++ knowledge/log.md | 1 + 4 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/fdvar-array.test.sh diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 76f827df7..d0af87736 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -7392,10 +7392,10 @@ impl Interpreter { for redirect in redirects { // Resolve fd from either explicit fd or {var} fd-variable syntax let resolved_fd_var: Option = redirect.fd_var.as_ref().and_then(|var_name| { - self.scoped - .variables - .get(var_name) - .and_then(|val| val.parse::().ok()) + self.expand_name_or_array_element(var_name) + .trim() + .parse::() + .ok() }); match redirect.kind { RedirectKind::Input => { diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 4d01b14e2..3a77a34fa 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -2749,9 +2749,12 @@ impl<'a> Parser<'a> { && s.starts_with('{') && s.ends_with('}') && s.len() > 2 + // `{fd}` or `{arr[i]}` (a coproc hands its descriptors out as + // `${NAME[0]}` / `${NAME[1]}`, and `exec {NAME[1]}>&-` closes one). && s[1..s.len() - 1] .chars() - .all(|c| c.is_alphanumeric() || c == '_') + .all(|c| c.is_alphanumeric() || matches!(c, '_' | '[' | ']')) + && s[1..s.len() - 1].starts_with(|c: char| c.is_alphabetic() || c == '_') { let var_name = s[1..s.len() - 1].to_string(); words.pop(); diff --git a/crates/bashkit/tests/spec_cases/bash/fdvar-array.test.sh b/crates/bashkit/tests/spec_cases/bash/fdvar-array.test.sh new file mode 100644 index 000000000..86efe9693 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/fdvar-array.test.sh @@ -0,0 +1,21 @@ +### fd_var_array_subscript_closes +exec {fd}>/tmp/fv.txt +echo one >&$fd +exec {fd}>&- +cat /tmp/fv.txt +coproc UP { echo from_coproc; } +exec {UP[1]}>&- +read -r line <&"${UP[0]}" +echo "[$line]" +### expect +one +[from_coproc] +### end + +### fd_var_array_is_not_a_command +coproc C { echo x; } +exec {C[1]}>&- 2>&1 +echo "rc=$?" +### expect +rc=0 +### end diff --git a/knowledge/log.md b/knowledge/log.md index 25f79e3a6..9526bbb06 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -22,6 +22,7 @@ * **Fix**: parameter expansion and `mapfile` gaps from the Debian oracle. `${v~}`/`${v~~}` toggle case (also per array element). `\/` in a `${v/pat/rep}` replacement is a plain `/`. Unquoted `${!prefix@}`, `${!ref}`, `${v:off:len}`, `${a[@]:o:n}` and `${v@op}` now undergo field splitting like other expansions, so `for v in ${!cfg_@}` loops per name. `mapfile` gained `-d DELIM` (`''` is NUL), `-n`, `-s`, `-O` (keeps other elements) and grouped options; `-u` other than 0 and `-C`/`-c` exit 2 (L-MAPFILE-001). * **Fix**: `[[ a < b ]]` and `[[ a > b ]]` compare strings instead of being swallowed as redirections (the conditional grammar has no redirects), so `[[ a < B ]]` is false in byte order like bash. `shopt` listings pad the name to 15 columns and separate the state with a tab, matching bash. * **Fix**: arithmetic evaluation reports errors instead of yielding 0. `$((1/0))` and `$((08))` now print bash's `line N: EXPR: division by 0 (error token is "0")` / `value too great for base` and abort the command with status 1; `(( ))` and `let` print the same with their `((: ` / `let: ` prefix and fail without aborting the script. A new `arith_error` channel on the interpreter carries the failure out of the `&self` evaluator, reported where `set -u` errors are. Error lines say `bash: line N:` because the interpreter does not track the running script's path (see architecture). +* **Fix**: `{name[i]}` is accepted as an fd-variable redirect, so `exec {COPROC[1]}>&-` closes that descriptor instead of running `{COPROC[1]}` as a command. Fd-variable values resolve through array elements, which is how a coproc hands its descriptors out. * **Decision**: CPython's workload priority is agents running `python3` for scripted logic (processing a file, transforming data). Low-use stdlib modules are no longer shipped: `unittest`, `doctest`, profilers, `compileall`/`zipapp`, `dbm`/`shelve`, `plistlib`, `wave`, `netrc`, `cmd`, `tty`/`pty`, and `bz2`/`lzma`/`compression.zstd` (no C codec in the guest anyway). Stdlib zip 3.58 to 3.16 MB. L-CPY-009 updated. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#workload-priority). * **Feature**: `yes` and `seq` stream into pipelines. As a producer stage they write chunks into the pipe as they go (`Context::stdout_stream`), so `yes | head -1` and `seq 1000000 | head -1` stop after the first chunk with `PIPESTATUS` `141 0` instead of running to the output cap and printing a truncation notice. Step 2 of the roadmap (streaming builtin I/O), starting with the generators. * **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x <