diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 3a77a34fa..edc1e552e 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -2743,9 +2743,19 @@ impl<'a> Parser<'a> { /// If the last word is a single literal `{identifier}`, pop it and return the name. /// Used for `exec {var}>file` / `exec {var}>&-` syntax. fn pop_fd_var(words: &mut Vec) -> Option { - if let Some(last) = words.last() - && last.parts.len() == 1 - && let WordPart::Literal(ref s) = last.parts[0] + // `{arr[i]}` lexes as several literal parts, because `[` opens a glob + // bracket, so the name is read from the parts joined rather than from + // a single one. + let joined = words.last().and_then(|last| { + last.parts + .iter() + .map(|part| match part { + WordPart::Literal(text) => Some(text.as_str()), + _ => None, + }) + .collect::>() + }); + if let Some(ref s) = joined && s.starts_with('{') && s.ends_with('}') && s.len() > 2 diff --git a/crates/bashkit/tests/integration/fdvar_array_tests.rs b/crates/bashkit/tests/integration/fdvar_array_tests.rs new file mode 100644 index 000000000..7d396f9a9 --- /dev/null +++ b/crates/bashkit/tests/integration/fdvar_array_tests.rs @@ -0,0 +1,40 @@ +//! `{arr[i]}` as the fd-variable of a redirect. +//! +//! The name lexes as several literal parts, because `[` opens a glob bracket, +//! so a redirect written that way used to parse as a command named +//! `{arr[1]}` and fail with `command not found`. + +use bashkit::Bash; + +async fn run(script: &str) -> bashkit::ExecResult { + Bash::builder().build().exec(script).await.unwrap() +} + +#[tokio::test] +async fn an_array_element_can_hold_the_fd_of_an_exec_redirect() { + let r = run("declare -a A\nA[1]=7\nexec {A[1]}> o.txt\necho hi >&\"${A[1]}\"\nexec {A[1]}>&-\ncat o.txt").await; + assert_eq!(r.stderr.to_string(), ""); + assert_eq!(r.stdout.to_string(), "hi\n"); +} + +#[tokio::test] +async fn closing_an_array_held_fd_is_not_a_command() { + let r = run("declare -a A\nA[1]=7\nexec {A[1]}>&-\necho \"rc=$?\"").await; + assert_eq!(r.stderr.to_string(), ""); + assert_eq!(r.stdout.to_string(), "rc=0\n"); +} + +#[tokio::test] +async fn a_subscript_can_be_a_variable() { + let r = run("declare -a A\ni=1\nA[1]=7\nexec {A[i]}> o.txt\necho hi >&\"${A[i]}\"\nexec {A[i]}>&-\ncat o.txt").await; + assert_eq!(r.stderr.to_string(), ""); + assert_eq!(r.stdout.to_string(), "hi\n"); +} + +/// A word that only looks like one is still a command. +#[tokio::test] +async fn a_brace_word_that_is_not_an_fd_var_stays_a_command() { + let r = run("{A-1}> o.txt").await; + let stderr = r.stderr.to_string(); + assert!(stderr.contains("command not found"), "stderr: {stderr}"); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index c781b5591..0874631c6 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -64,6 +64,7 @@ pub mod exec_options_tests; pub mod execution_budget_tests; pub mod execution_capability_tests; pub mod execution_profile_tests; +pub mod fdvar_array_tests; pub mod filesystem_security_conformance_tests; pub mod final_env_tests; pub mod find_multi_path_tests; diff --git a/crates/bashkit/tests/spec_cases/bash/fdvar-array.test.sh b/crates/bashkit/tests/spec_cases/bash/fdvar-array.test.sh index 86efe9693..7c756ced6 100644 --- a/crates/bashkit/tests/spec_cases/bash/fdvar-array.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/fdvar-array.test.sh @@ -19,3 +19,14 @@ echo "rc=$?" ### expect rc=0 ### end + +### fd_var_array_subscript_opens_and_writes +declare -a A +A[1]=7 +exec {A[1]}> o.txt +echo hi >&"${A[1]}" +exec {A[1]}>&- +cat o.txt +### expect +hi +### end diff --git a/knowledge/log.md b/knowledge/log.md index 4b4158e75..692862607 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -3,6 +3,7 @@ ## 2026-10-07 * **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**: `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**: jq `implode` maps invalid code points to U+FFFD, `strptime`/`fromdate` fail with jq's `date "X" does not match format "F"`, and `@base64d` accepts unpadded input and non-zero trailing bits with jq's error texts.