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
4 changes: 2 additions & 2 deletions crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |

---
Expand Down
86 changes: 84 additions & 2 deletions crates/bashkit/src/builtins/vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,26 @@ pub struct Shift;
#[async_trait]
impl Builtin for Shift {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
// 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::<i64>() {
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
Expand All @@ -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<String, String>,
mode: Option<char>,
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<String, String>, 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;

Expand Down Expand Up @@ -485,12 +559,16 @@ impl Builtin for Shopt {
}

let mut mode: Option<char> = None; // 's'=set, 'u'=unset, 'q'=query, 'p'=print
let mut set_o = false; // -o: act on `set -o` options
let mut opts: Vec<String> = 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(
Expand All @@ -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
Expand Down
52 changes: 37 additions & 15 deletions crates/bashkit/src/interpreter/expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,21 +198,43 @@ impl Interpreter {
match part {
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);
// 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('/') {
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);
Expand Down
20 changes: 12 additions & 8 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -9997,14 +9997,15 @@ 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
// bit cache can stay authoritative on the hot path. The scan covers
// ~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 } => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -10064,6 +10065,9 @@ impl Interpreter {
}
}
}
if shift_failed {
result.exit_code = 1;
}
}

/// Resolve a path relative to cwd, normalizing `.` and `..` components.
Expand Down
52 changes: 52 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/shell-misc-gaps.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
### 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_quoted_is_literal
echo "~" '~' "~/x"
### expect
~ ~ ~/x
### 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
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), 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 `\<newline>` as a continuation. Still open: single quotes inside an unquoted `${x:-'d'}` operand are not removed.
Expand Down
Loading