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
16 changes: 13 additions & 3 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Word>) -> Option<String> {
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::<Option<String>>()
});
if let Some(ref s) = joined
&& s.starts_with('{')
&& s.ends_with('}')
&& s.len() > 2
Expand Down
40 changes: 40 additions & 0 deletions crates/bashkit/tests/integration/fdvar_array_tests.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
1 change: 1 addition & 0 deletions crates/bashkit/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/fdvar-array.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading