From 938dc328842fdcd69028111c5e7de31c1c5a9287 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 17:34:46 +0000 Subject: [PATCH 1/2] fix(builtins): shift status, ~+/~- tilde prefixes, shopt -o `shift N` past `$#` now fails with status 1 without shifting, and bad counts report bash's errors. `~+` and `~-` expand to PWD and OLDPWD. `shopt -o` acts on `set -o` option names. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/docs/compatibility.md | 4 +- crates/bashkit/src/builtins/vars.rs | 86 ++++++++++++++++++- crates/bashkit/src/interpreter/expansion.rs | 47 +++++++--- crates/bashkit/src/interpreter/mod.rs | 20 +++-- .../spec_cases/bash/shell-misc-gaps.test.sh | 46 ++++++++++ knowledge/log.md | 1 + 6 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 9653e404b..0046f09ae 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -183,7 +183,7 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and | `caller` | `[FRAME]` | Display call stack frame | | `mapfile` | `-n`, `-O`, `-s`, `-t`, `-d` | Read lines into array | | `readarray` | `-n`, `-O`, `-s`, `-t`, `-d` | Alias for mapfile | -| `shopt` | `-s`, `-u`, `-q` | Shell options | +| `shopt` | `-s`, `-u`, `-q`, `-p`, `-o` | Shell options; `-o` acts on `set -o` names | | `seq` | `[FIRST [INCR]] LAST` | Print number sequence | | `tac` | (none) | Reverse file lines | | `rev` | (none) | Reverse characters per line | @@ -318,7 +318,7 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and | `*`, `?` | ✅ | `*.txt` | Glob patterns | | `[abc]` | ✅ | `[0-9]` | Bracket globs | | `{a,b,c}` | ✅ | `{1..5}` | Brace expansion | -| `~` | ✅ | `~/file` | Tilde expansion | +| `~` | ✅ | `~/file`, `~+`, `~-` | Tilde expansion: HOME, PWD, OLDPWD | | `<(cmd)` | ✅ | `diff <(a) <(b)` | Process substitution | --- diff --git a/crates/bashkit/src/builtins/vars.rs b/crates/bashkit/src/builtins/vars.rs index 9b3c2cf12..aa146e583 100644 --- a/crates/bashkit/src/builtins/vars.rs +++ b/crates/bashkit/src/builtins/vars.rs @@ -245,8 +245,26 @@ pub struct Shift; #[async_trait] impl Builtin for Shift { async fn execute(&self, ctx: Context<'_>) -> Result { - // Number of positions to shift (default 1) - let n: usize = ctx.args.first().and_then(|s| s.parse().ok()).unwrap_or(1); + // Number of positions to shift (default 1). A count above `$#` is + // applied by the interpreter, which fails it with status 1. + let n: usize = match ctx.args.first() { + None => 1, + Some(arg) => match arg.parse::() { + Ok(v) if v >= 0 => usize::try_from(v).unwrap_or(usize::MAX), + Ok(_) => { + return Ok(ExecResult::err( + format!("bash: shift: {arg}: shift count out of range\n"), + 1, + )); + } + Err(_) => { + return Ok(ExecResult::err( + format!("bash: shift: {arg}: numeric argument required\n"), + 1, + )); + } + }, + }; let mut result = ExecResult::ok(String::new()); result @@ -256,6 +274,62 @@ impl Builtin for Shift { } } +impl Shopt { + /// `shopt -o`: the same operations on `set -o` option names. + fn set_o_options( + variables: &mut std::collections::HashMap, + mode: Option, + opts: &[String], + ) -> ExecResult { + let mut names: Vec<(&str, &'static str)> = Vec::new(); + if opts.is_empty() { + names.extend(SET_O_OPTIONS.iter().copied()); + } + for opt in opts { + match option_name_to_var(opt) { + Some(var) => names.push((opt.as_str(), var)), + None => { + return ExecResult::err( + format!("bash: shopt: {opt}: invalid option name\n"), + 1, + ); + } + } + } + let on = |vars: &std::collections::HashMap, var: &str| { + vars.get(var).is_some_and(|v| v == "1") + }; + let mut output = String::new(); + let mut all_on = true; + for (name, var) in names { + match mode { + Some('s') => { + variables.insert(var.to_string(), "1".to_string()); + } + Some('u') => { + variables.remove(var); + } + Some('q') => all_on &= on(variables, var), + Some('p') => { + let flag = if on(variables, var) { "-o" } else { "+o" }; + output.push_str(&format!("set {flag} {name}\n")); + } + _ => { + let enabled = on(variables, var); + all_on &= enabled; + let state = if enabled { "on" } else { "off" }; + output.push_str(&format!("{name:<15}\t{state}\n")); + } + } + } + let mut result = ExecResult::ok(output); + if matches!(mode, Some('q')) || (mode.is_none() && !opts.is_empty()) { + result.exit_code = i32::from(!all_on); + } + result + } +} + /// local builtin - declare local variables in functions pub struct Local; @@ -485,12 +559,16 @@ impl Builtin for Shopt { } let mut mode: Option = None; // 's'=set, 'u'=unset, 'q'=query, 'p'=print + let mut set_o = false; // -o: act on `set -o` options let mut opts: Vec = Vec::new(); for arg in ctx.args { if arg.starts_with('-') && opts.is_empty() { for ch in arg.chars().skip(1) { match ch { + 'o' => set_o = true, + // -p combines with -s/-u/-q as a print request. + 'p' if mode.is_some() => {} 's' | 'u' | 'q' | 'p' => mode = Some(ch), _ => { return Ok(ExecResult::err( @@ -505,6 +583,10 @@ impl Builtin for Shopt { } } + if set_o { + return Ok(Self::set_o_options(ctx.variables, mode, &opts)); + } + match mode { Some('s') => { // Set options diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index 6eb8dc095..dae5c3daf 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -199,20 +199,39 @@ impl Interpreter { WordPart::Literal(s) => { // Tilde expansion: ~ at start of word expands to $HOME if is_first_part && s.starts_with('~') { - let home = self - .env - .get("HOME") - .or_else(|| self.scoped.variables.get("HOME")) - .cloned() - .unwrap_or_else(|| "/home/user".to_string()); - - if s == "~" { - result.push_str(&home); - } else if s.starts_with("~/") { - result.push_str(&home); - result.push_str(&s[1..]); - } else { - result.push_str(s); + // Tilde prefix runs to the first `/`: `~` is HOME, + // `~+` PWD, `~-` OLDPWD (literal when unset). + let (prefix, rest) = match s.find('/') { + Some(i) => (&s[1..i], &s[i..]), + None => (&s[1..], ""), + }; + let lookup = |name: &str| { + self.scoped + .variables + .get(name) + .or_else(|| self.env.get(name)) + .cloned() + }; + let dir = match prefix { + "" => Some( + self.env + .get("HOME") + .or_else(|| self.scoped.variables.get("HOME")) + .cloned() + .unwrap_or_else(|| "/home/user".to_string()), + ), + "+" => Some( + lookup("PWD").unwrap_or_else(|| self.cwd.display().to_string()), + ), + "-" => lookup("OLDPWD"), + _ => None, + }; + match dir { + Some(dir) => { + result.push_str(&dir); + result.push_str(rest); + } + None => result.push_str(s), } } else { result.push_str(s); diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 279ee296a..ecf1b4004 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -7481,7 +7481,7 @@ impl Interpreter { .unwrap_or(u64::MAX), )?; - self.apply_builtin_side_effects(&result).await; + self.apply_builtin_side_effects(&mut result).await; // Sync successful export operands into env so subprocess isolation can see them. // Keep syncing even if export returned nonzero for other args (bash-compatible). @@ -9997,7 +9997,7 @@ impl Interpreter { } /// Process structured side effects from builtin execution. - async fn apply_builtin_side_effects(&mut self, result: &ExecResult) { + async fn apply_builtin_side_effects(&mut self, result: &mut ExecResult) { // Builtins that mutate SHOPT_* directly via `ctx.variables` (e.g. the // `set -e` / `set +u` paths in the `set` builtin) don't update the // cached `flags` bitfield. Resync once after every builtin so the @@ -10005,6 +10005,7 @@ impl Interpreter { // ~10 SHOPT_* entries — cheaper than threading a structured "shopt // changed" channel through every builtin. self.refresh_shopt_flags(); + let mut shift_failed = false; for effect in &result.side_effects { match effect { builtins::BuiltinSideEffect::SetArray { name, elements } => { @@ -10028,12 +10029,12 @@ impl Interpreter { self.arrays_mut().remove(name); } builtins::BuiltinSideEffect::ShiftPositional(n) => { - if let Some(frame) = self.call_stack.last_mut() { - if *n <= frame.positional.len() { - frame.positional.drain(..*n); - } else { - frame.positional.clear(); - } + // bash: a count above `$#` shifts nothing and fails. + let len = self.call_stack.last().map_or(0, |f| f.positional.len()); + if *n > len { + shift_failed = true; + } else if let Some(frame) = self.call_stack.last_mut() { + frame.positional.drain(..*n); } } builtins::BuiltinSideEffect::SetPositional(new_positional) => { @@ -10064,6 +10065,9 @@ impl Interpreter { } } } + if shift_failed { + result.exit_code = 1; + } } /// Resolve a path relative to cwd, normalizing `.` and `..` components. diff --git a/crates/bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh b/crates/bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh new file mode 100644 index 000000000..d5b39ca16 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh @@ -0,0 +1,46 @@ +### shift_count_errors +set -- a b +shift 3; echo "rc=$? n=$#" +shift abc 2>/dev/null; echo "rc=$? n=$#" +shift -1 2>/dev/null; echo "rc=$? n=$#" +shift 2; echo "rc=$? n=$#" +shift; echo "rc=$? n=$#" +### expect +rc=1 n=2 +rc=1 n=2 +rc=1 n=2 +rc=0 n=0 +rc=1 n=0 +### end + +### tilde_plus_minus +cd /tmp; cd / +echo ~+ ~- ~+/x ~-/y +### expect +/ /tmp //x /tmp/y +### end + +### tilde_minus_unset_is_literal +unset OLDPWD +echo ~- ~unknownuser_zz +### expect +~- ~unknownuser_zz +### end + +### shopt_o_set_options +shopt -o noglob +shopt -os noglob; shopt -o noglob; echo "rc=$?" +shopt -ou noglob +shopt -po errexit +shopt -oq pipefail; echo "q=$?" +set -o pipefail; shopt -oq pipefail; echo "q=$?" +shopt -o bogus 2>/dev/null; echo "bad=$?" +### expect +noglob off +noglob on +rc=0 +set +o errexit +q=1 +q=0 +bad=1 +### end diff --git a/knowledge/log.md b/knowledge/log.md index 196d2b0cc..db215130b 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -23,6 +23,7 @@ * **Feature**: `make` builtin, a GNU make 4.3 subset run as an execution-plan driver: assignments, conditionals, `define`, `include`, explicit/pattern/static/suffix rules, target-specific variables, special targets and the common functions incl. `$(shell)`/`$(wildcard)`. Recipes run as `sh -c` in the sandbox shell; output and make's messages stream in order (new `PlanStep::Capture`/`Emit`). 19 spec cases match real GNU make 4.3. Gaps L-MAKE-001..003 (no `$(eval)`/`$(file)`, no built-in or chained implicit rules or `vpath`, sequential `-j`, `SHELL` ignored, MAKELEVEL cap 4); caps TM-DOS-126. `make` left the "build tools are not available" hint list. * **Security**: A script that re-runs itself through `sh`/`bash` crashed the whole process with a stack overflow (TM-DOS-125); nothing bounded nested child shells. They now count against `max_function_depth` plus their own cap of 8, measured as what fits a 2 MiB debug-build stack (about 15 bare `sh` levels fit; a level costs several function calls). Found while bounding `make` recursion. * **Fix**: awk follows POSIX `opt_nls`: a newline after the `)` of an `if`/`for`/`while` header, after `else`/`do`, `&&`, `||` or `,` continues the statement (it became `;`, so `for (...)⏎ body` failed with `unexpected character: ;`, the most frequent awk error in gap telemetry, 22 replayed scripts). `do body; while (...)` accepts the `;`. A regex rule pattern can be an operand (`/=/ && !/^#/`, `/^$/ || /^;/`), and a bare `/re/` in an expression is `$0 ~ /re/` (it was the truthy pattern text, so `if (/^\[/)` matched every line). +* **Fix**: Shell gaps from the pseudo-linus oracle list. `shift N` past `$#` shifts nothing and returns 1 (it cleared the arguments and returned 0); `shift abc` and `shift -1` fail with bash's messages. `~+` and `~-` expand to `PWD`/`OLDPWD` (`~-` stays literal when `OLDPWD` is unset). `shopt -o` (with `-s`/`-u`/`-q`/`-p`) works on `set -o` option names. * **Feature**: Missing everyday commands: `arch`, `sum`, `shasum`, `egrep`, `fgrep`, `link`, `unlink`, `chgrp`, `nohup`, `nice`, `flock`, `getconf`, `tty`, `sync`, `hostid`, `groups`, `logname`, `users`, `who`, `uptime`, `free`. Output matches GNU coreutils/util-linux where the answer is not host identity (spec `system-commands.test.sh` checked against real bash); identity answers (`free`, `uptime`, `groups`, `hostid`) are virtual and agree with `nproc` and `/proc/meminfo`. `nohup`/`nice`/`flock` just run their command: there are no signals, priorities or competing lock holders inside one session. `/bin/bash` and `/bin/sh` now exist in the root filesystem, and `type`/`command -V` report interpreter-dispatched builtins (`builtin`, `command`, `let`, `typeset`, `exec`, `getopts`, `declare`) instead of "not found". * **Fix**: `cmd N>file` with N≥3 used to send the command's stdout into the file, so `( flock -n 9 && ... ) 9>lock` printed nothing. Such redirects now open fd N only: stdout is untouched, the file is created/truncated, and writes to `>&N` inside a compound command or function land in it. * **Fix**: A backslash escape right after a quoted segment broke lexing: `'a'\''b'` (the standard way to put a quote inside single quotes, as in `sh -c '...'\''...'\''...'`) failed with "unterminated single quote", and `"a"\$x`, `'a'\ b`, `"g"\*` kept the backslash. The quote-continuation reader now treats `\c` as a quoted `c` and `\` as a continuation. Still open: single quotes inside an unquoted `${x:-'d'}` operand are not removed. From af0673cffee270bc0dc7d28d486e8aed9de89e13 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 17:40:35 +0000 Subject: [PATCH 2/2] fix(interpreter): keep a quoted tilde literal Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- crates/bashkit/src/interpreter/expansion.rs | 5 ++++- .../bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh | 6 ++++++ knowledge/log.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index dae5c3daf..4686880e5 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -198,7 +198,10 @@ impl Interpreter { match part { WordPart::Literal(s) => { // Tilde expansion: ~ at start of word expands to $HOME - if is_first_part && s.starts_with('~') { + // A fully quoted word (`"~"`, `'~'`) keeps its tilde. + let tilde_ok = !word.quoted + || (word.has_unquoted_glob && word.part_quoted.first() == Some(&false)); + if is_first_part && tilde_ok && s.starts_with('~') { // Tilde prefix runs to the first `/`: `~` is HOME, // `~+` PWD, `~-` OLDPWD (literal when unset). let (prefix, rest) = match s.find('/') { diff --git a/crates/bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh b/crates/bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh index d5b39ca16..3fd8dda4e 100644 --- a/crates/bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh @@ -20,6 +20,12 @@ echo ~+ ~- ~+/x ~-/y / /tmp //x /tmp/y ### end +### tilde_quoted_is_literal +echo "~" '~' "~/x" +### expect +~ ~ ~/x +### end + ### tilde_minus_unset_is_literal unset OLDPWD echo ~- ~unknownuser_zz diff --git a/knowledge/log.md b/knowledge/log.md index db215130b..e0ceed45c 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -23,7 +23,7 @@ * **Feature**: `make` builtin, a GNU make 4.3 subset run as an execution-plan driver: assignments, conditionals, `define`, `include`, explicit/pattern/static/suffix rules, target-specific variables, special targets and the common functions incl. `$(shell)`/`$(wildcard)`. Recipes run as `sh -c` in the sandbox shell; output and make's messages stream in order (new `PlanStep::Capture`/`Emit`). 19 spec cases match real GNU make 4.3. Gaps L-MAKE-001..003 (no `$(eval)`/`$(file)`, no built-in or chained implicit rules or `vpath`, sequential `-j`, `SHELL` ignored, MAKELEVEL cap 4); caps TM-DOS-126. `make` left the "build tools are not available" hint list. * **Security**: A script that re-runs itself through `sh`/`bash` crashed the whole process with a stack overflow (TM-DOS-125); nothing bounded nested child shells. They now count against `max_function_depth` plus their own cap of 8, measured as what fits a 2 MiB debug-build stack (about 15 bare `sh` levels fit; a level costs several function calls). Found while bounding `make` recursion. * **Fix**: awk follows POSIX `opt_nls`: a newline after the `)` of an `if`/`for`/`while` header, after `else`/`do`, `&&`, `||` or `,` continues the statement (it became `;`, so `for (...)⏎ body` failed with `unexpected character: ;`, the most frequent awk error in gap telemetry, 22 replayed scripts). `do body; while (...)` accepts the `;`. A regex rule pattern can be an operand (`/=/ && !/^#/`, `/^$/ || /^;/`), and a bare `/re/` in an expression is `$0 ~ /re/` (it was the truthy pattern text, so `if (/^\[/)` matched every line). -* **Fix**: Shell gaps from the pseudo-linus oracle list. `shift N` past `$#` shifts nothing and returns 1 (it cleared the arguments and returned 0); `shift abc` and `shift -1` fail with bash's messages. `~+` and `~-` expand to `PWD`/`OLDPWD` (`~-` stays literal when `OLDPWD` is unset). `shopt -o` (with `-s`/`-u`/`-q`/`-p`) works on `set -o` option names. +* **Fix**: Shell gaps from the pseudo-linus oracle list. `shift N` past `$#` shifts nothing and returns 1 (it cleared the arguments and returned 0); `shift abc` and `shift -1` fail with bash's messages. `~+` and `~-` expand to `PWD`/`OLDPWD` (`~-` stays literal when `OLDPWD` is unset), and a quoted `"~"` stays literal. `shopt -o` (with `-s`/`-u`/`-q`/`-p`) works on `set -o` option names. * **Feature**: Missing everyday commands: `arch`, `sum`, `shasum`, `egrep`, `fgrep`, `link`, `unlink`, `chgrp`, `nohup`, `nice`, `flock`, `getconf`, `tty`, `sync`, `hostid`, `groups`, `logname`, `users`, `who`, `uptime`, `free`. Output matches GNU coreutils/util-linux where the answer is not host identity (spec `system-commands.test.sh` checked against real bash); identity answers (`free`, `uptime`, `groups`, `hostid`) are virtual and agree with `nproc` and `/proc/meminfo`. `nohup`/`nice`/`flock` just run their command: there are no signals, priorities or competing lock holders inside one session. `/bin/bash` and `/bin/sh` now exist in the root filesystem, and `type`/`command -V` report interpreter-dispatched builtins (`builtin`, `command`, `let`, `typeset`, `exec`, `getopts`, `declare`) instead of "not found". * **Fix**: `cmd N>file` with N≥3 used to send the command's stdout into the file, so `( flock -n 9 && ... ) 9>lock` printed nothing. Such redirects now open fd N only: stdout is untouched, the file is created/truncated, and writes to `>&N` inside a compound command or function land in it. * **Fix**: A backslash escape right after a quoted segment broke lexing: `'a'\''b'` (the standard way to put a quote inside single quotes, as in `sh -c '...'\''...'\''...'`) failed with "unterminated single quote", and `"a"\$x`, `'a'\ b`, `"g"\*` kept the backslash. The quote-continuation reader now treats `\c` as a quoted `c` and `\` as a continuation. Still open: single quotes inside an unquoted `${x:-'d'}` operand are not removed.