Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
82a203e
test(spec): import 1,044 bash-verified cases from the BashBox test suite
chaliy Oct 7, 2026
81dcea8
fix(arithmetic): bash grammar evaluator, errors abort the line
chaliy Oct 7, 2026
fa25efd
fix(declare): shared declaration engine, shallow-binding locals
chaliy Oct 7, 2026
031473a
fix(expansion): scalars as arrays, [[ -v ]], read name validation
chaliy Oct 7, 2026
53f4ad1
fix(set): bash option set, $-, BASH_SUBSHELL, PIPESTATUS, subshell env
chaliy Oct 7, 2026
c549536
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 7, 2026
0f95526
chore(bench): parallel_execution results after declaration engine merge
chaliy Oct 7, 2026
0bf639c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 7, 2026
b975907
fix(arrays): brace and pathname expansion for array-literal words
chaliy Oct 7, 2026
5d9aba9
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 7, 2026
86af5c0
test(arrays): array-literal brace expansion stays within entry budget
chaliy Oct 7, 2026
4186495
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 7, 2026
3a5aca5
test(bindings): division by zero aborts the line like bash
chaliy Oct 7, 2026
764d799
fix(interpreter): keep function calls off the builtin dispatch frame
chaliy Oct 7, 2026
a9b257f
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 7, 2026
ff72480
test(python): negative exponent aborts the line like bash
chaliy Oct 7, 2026
071d84d
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 7, 2026
7d3d3f3
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 7, 2026
321697f
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 7, 2026
77def5f
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 7, 2026
be3c36c
Merge origin/main into claude/project-thread-eafupr-bb-vars
chaliy Oct 7, 2026
b5b8196
Merge origin/main into claude/project-thread-eafupr-bb-vars
chaliy Oct 8, 2026
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
19 changes: 9 additions & 10 deletions crates/bashkit-js/__test__/security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,21 +959,20 @@ test("BB: arithmetic overflow (TM-DOS-029)", (t) => {
test("BB: division by zero does not crash (TM-DOS-029)", (t) => {
const bash = new Bash();
const r = bash.executeSync("echo $((1 / 0)) 2>&1");
// Reports like bash. Key security property: no crash or panic.
t.is(typeof r.exitCode, "number", "division by zero must not crash");
t.not(r.exitCode, 0, "division by zero fails");
t.is(r.stdout, "", "no value is printed");
t.true(r.stderr.includes("division by 0"), "the error is reported");
// Like bash: an expansion error aborts the line with exit 1.
// Key security property: interpreter must not crash or panic.
t.is(r.exitCode, 1, "division by zero must fail, not crash");
t.is(r.stdout, "", "the aborted echo prints nothing");
t.true(r.stderr.includes("division by 0"));
});

test("BB: modulo by zero does not crash (TM-DOS-029)", (t) => {
const bash = new Bash();
const r = bash.executeSync("echo $((1 % 0)) 2>&1");
// Same as div-by-zero: reports and fails.
t.is(typeof r.exitCode, "number", "modulo by zero must not crash");
t.not(r.exitCode, 0, "modulo by zero fails");
t.is(r.stdout, "", "no value is printed");
t.true(r.stderr.includes("division by 0"), "the error is reported");
// Same as div-by-zero: the line aborts with exit 1, like bash.
t.is(r.exitCode, 1, "modulo by zero must fail, not crash");
t.is(r.stdout, "", "the aborted echo prints nothing");
t.true(r.stderr.includes("division by 0"));
});

test("BB: negative exponent", (t) => {
Expand Down
16 changes: 9 additions & 7 deletions crates/bashkit-python/tests/_security_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,10 @@ def test_tm_dos_029_arithmetic_overflow_does_not_crash():


def test_tm_dos_029_division_by_zero_does_not_crash():
"""TM-DOS-029: division by zero must report like bash, not panic."""
"""TM-DOS-029: division by zero must return an error value, not panic."""
r = Bash().execute_sync("echo $((1 / 0)) 2>&1")
assert isinstance(r.exit_code, int)
assert r.exit_code != 0
# Like bash: the expansion error aborts the line with exit 1.
assert r.exit_code == 1
assert r.stdout == ""
assert "division by 0" in r.stderr
_assert_sanitized_error(r.stderr)
Expand All @@ -265,8 +265,8 @@ def test_tm_dos_029_division_by_zero_does_not_crash():
def test_tm_dos_029_modulo_by_zero_does_not_crash():
"""TM-DOS-029: modulo by zero must not unwind through the binding."""
r = Bash().execute_sync("echo $((1 % 0)) 2>&1")
assert isinstance(r.exit_code, int)
assert r.exit_code != 0
# Like bash: the expansion error aborts the line with exit 1.
assert r.exit_code == 1
assert r.stdout == ""
assert "division by 0" in r.stderr
_assert_sanitized_error(r.stderr)
Expand All @@ -275,8 +275,10 @@ def test_tm_dos_029_modulo_by_zero_does_not_crash():
def test_tm_dos_029_negative_exponent_does_not_crash():
"""TM-DOS-029: unsupported arithmetic should stay bounded."""
r = Bash().execute_sync("echo $((2 ** -1)) 2>&1")
assert isinstance(r.exit_code, int)
assert r.stdout.strip() == "0"
# Like bash: a negative exponent is an error that aborts the line.
assert r.exit_code == 1
assert r.stdout == ""
assert "exponent less than 0" in r.stderr
_assert_sanitized_error(r.stderr)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Criterion Parallel Execution Benchmark

## System Information

- **Moniker**: `vm-linux-x86_64`
- **Hostname**: vm
- **OS**: linux
- **Architecture**: x86_64
- **CPUs**: 4
- **Timestamp**: 1791401002

## Workload Comparison (50 sessions)

| Benchmark | Time |
|-----------|------|
| workload_types/light_sequential | 5.9398 ms |
| workload_types/light_parallel | 4.4518 ms |
| workload_types/medium_sequential | 21.632 ms |
| workload_types/medium_parallel | 17.490 ms |
| workload_types/heavy_sequential | 65.949 ms |
| workload_types/heavy_parallel | 49.142 ms |

## Parallel Scaling (medium workload)

| Benchmark | Time |
|-----------|------|
| parallel_scaling/medium_seq/10 | 4.3466 ms |
| parallel_scaling/medium_par/10 | 2.8989 ms |
| parallel_scaling/shared_fs/10 | 2.1865 ms |
| parallel_scaling/medium_seq/50 | 22.751 ms |
| parallel_scaling/medium_par/50 | 14.542 ms |
| parallel_scaling/shared_fs/50 | 9.2122 ms |
| parallel_scaling/medium_seq/100 | 48.931 ms |
| parallel_scaling/medium_par/100 | 28.244 ms |
| parallel_scaling/shared_fs/100 | 18.105 ms |
| parallel_scaling/medium_seq/200 | 101.58 ms |
| parallel_scaling/medium_par/200 | 61.706 ms |
| parallel_scaling/shared_fs/200 | 34.450 ms |
| parallel_scaling/medium_seq/500 | 221.65 ms |
| parallel_scaling/medium_par/500 | 103.14 ms |
| parallel_scaling/shared_fs/500 | 99.824 ms |
| parallel_scaling/medium_seq/1000 | 416.20 ms |
| parallel_scaling/medium_par/1000 | 286.09 ms |
| parallel_scaling/shared_fs/1000 | 211.56 ms |

## Single Operations

| Benchmark | Time |
|-----------|------|
| single_bash_new | 68.289 µs |
| single_echo | 83.043 µs |
| single_file_write_read | 128.29 µs |
| single_grep | 115.58 µs |
| single_awk | 121.48 µs |
| single_sed | 114.34 µs |
| single_light_script | 110.54 µs |
| single_medium_script | 470.18 µs |
| single_heavy_script | 1.3484 ms |

## Speedup Summary

| Workload | Sequential | Parallel | Speedup |
|----------|-----------|----------|---------|
| light | 5.940 ms | 4.452 ms | **1.33x** |
| medium | 21.632 ms | 17.490 ms | **1.24x** |
| heavy | 65.949 ms | 49.142 ms | **1.34x** |

| Sessions | Sequential | Parallel | Shared FS | Par Speedup |
|----------|-----------|----------|-----------|-------------|
| 10 | 4.347 ms | 2.899 ms | 2.187 ms | **1.50x** |
| 50 | 22.751 ms | 14.542 ms | 9.212 ms | **1.56x** |
| 100 | 48.931 ms | 28.244 ms | 18.105 ms | **1.73x** |
| 200 | 101.580 ms | 61.706 ms | 34.450 ms | **1.65x** |
| 500 | 221.650 ms | 103.140 ms | 99.824 ms | **2.15x** |
| 1000 | 416.200 ms | 286.090 ms | 211.560 ms | **1.45x** |

## Notes

Branch `claude/project-thread-eafupr-bb-vars` (declaration engine, shallow-binding
locals, `env` as `Arc`) after merging main 8d47605. Host load average was ~8 on
4 CPUs during the run, so single runs swing about 25%. An interleaved A/B run of
the main and branch bench binaries (two rounds each) put every `workload_types`
average within 3% of main (light_seq 5.67 vs 5.46 ms, medium_par 15.3 vs 15.8 ms,
heavy_seq 60.7 vs 61.7 ms, heavy_par 42.1 vs 41.7 ms): no regression beyond noise.
14 changes: 14 additions & 0 deletions crates/bashkit/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,11 @@ impl Walker {
self.out.has_command_substitution = true;
self.out.has_dynamic_commands = true;
}
WordPart::CompoundAssignment { elements, .. } => {
for element in elements {
self.walk_word_parts(element, ctx);
}
}
_ => {}
}
}
Expand Down Expand Up @@ -706,6 +711,15 @@ mod tests {
assert_eq!(analysis.commands[1].assignments, ["out"]);
}

#[test]
fn substitution_in_declared_compound_array_is_walked() {
// `declare -a x=(...)` keeps its elements in a CompoundAssignment
// part; a substitution inside must still reach the permission gate.
let analysis = a("declare -a x=(a $(rm -rf /data) [3]=`curl http://x`)");
assert!(analysis.has_command_substitution);
assert_eq!(analysis.command_names(), ["rm", "curl", "declare"]);
}

#[test]
fn dynamic_command_name_is_null_and_flagged() {
let analysis = a("$cmd --force");
Expand Down
1 change: 1 addition & 0 deletions crates/bashkit/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ pub use trap::Trap;
pub use tree::Tree;
pub use truncate::Truncate;
pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset};
pub(crate) use vars::{SET_O_OPTIONS, dollar_dash};
pub use verify::Verify;
#[cfg(feature = "terminal")]
pub use vi::Vi;
Expand Down
7 changes: 5 additions & 2 deletions crates/bashkit/src/builtins/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ pub struct Cd;
#[async_trait]
impl Builtin for Cd {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let target = ctx
let Some(target) = ctx
.args
.first()
.map(|s| s.as_str())
.or_else(|| ctx.variables.get("HOME").map(|s| s.as_str()))
.or_else(|| ctx.env.get("HOME").map(|s| s.as_str()))
.unwrap_or("/home/user");
else {
// Bare `cd` with HOME unset fails, as in bash.
return Ok(ExecResult::err("bash: cd: HOME not set\n", 1));
};

let new_path = if target.starts_with('/') {
PathBuf::from(target)
Expand Down
70 changes: 64 additions & 6 deletions crates/bashkit/src/builtins/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@
//! waits for a line typed on the terminal: `-p` prints the prompt there,
//! `-s` turns echo off, `-n N` returns after N characters, `-t SECS` gives
//! up with status 142. Outside a terminal no stdin is EOF (status 1).
//!
//! Options are read only before the first name (bash's getopt), so in
//! `read a -r` the `-r` is a name. Names are checked as they are assigned:
//! the ones before an invalid name get their fields, the rest are left
//! alone, and read fails with status 1. `-N n` reads exactly n characters
//! with no delimiter and no field splitting.

use async_trait::async_trait;

use super::{Builtin, BuiltinSideEffect, Context};
use crate::error::Result;
use crate::interpreter::{ExecResult, is_internal_variable};
use crate::interpreter::{ExecResult, is_internal_variable, is_valid_var_name};

/// read builtin - read a line of input into variables
pub struct Read;
Expand All @@ -26,13 +32,17 @@ impl Builtin for Read {
let mut array_mode = false; // -a: read into array
let mut delimiter = None::<char>; // -d: custom delimiter
let mut nchars = None::<usize>; // -n: read N chars
let mut exact_nchars = false; // -N: exactly N chars, no splitting
let mut prompt = None::<String>; // -p prompt
let mut silent = false; // -s: no echo (terminal input only)
let mut timeout = None::<f64>; // -t SECS (terminal input only)
let mut var_args = Vec::new();
let mut args_iter = ctx.args.iter();
let mut names_started = false;
while let Some(arg) = args_iter.next() {
if arg.starts_with('-') && arg.len() > 1 {
if !names_started && arg == "--" {
names_started = true;
} else if !names_started && arg.starts_with('-') && arg.len() > 1 {
let mut chars = arg[1..].chars();
while let Some(flag) = chars.next() {
match flag {
Expand All @@ -49,7 +59,8 @@ impl Builtin for Read {
delimiter = delim_str.chars().next();
break;
}
'n' => {
'n' | 'N' => {
exact_nchars = flag == 'N';
let rest: String = chars.collect();
let n_str = if rest.is_empty() {
args_iter.next().map(|s| s.as_str()).unwrap_or("0")
Expand Down Expand Up @@ -92,9 +103,16 @@ impl Builtin for Read {
}
}
} else {
names_started = true;
var_args.push(arg.as_str());
}
}
if array_mode
&& let Some(name) = var_args.first()
&& !valid_read_name(name, true)
{
return Ok(ExecResult::err(invalid_name(name), 1));
}
#[cfg(feature = "terminal")]
if input.is_none()
&& let Some(tty) = ctx.execution_extension::<crate::terminal::Tty>()
Expand Down Expand Up @@ -150,6 +168,10 @@ impl Builtin for Read {
};
let mut result = ExecResult::err("", 1);
for var_name in &var_names {
if !valid_read_name(var_name, false) {
result.stderr = invalid_name(var_name).into();
break;
}
if is_internal_variable(var_name) {
continue;
}
Expand All @@ -165,16 +187,32 @@ impl Builtin for Read {
// One record up to the delimiter (newline by default) or N chars.
// Data that ends before the delimiter is still assigned, but the
// status is 1, as in bash.
let (line, terminated) =
read_record(&input, delimiter.unwrap_or('\n'), nchars, raw_mode, ifs);
// -N ignores the delimiter (consumed_len uses NUL the same way).
let delim = if exact_nchars {
'\0'
} else {
delimiter.unwrap_or('\n')
};
let (line, terminated) = read_record(&input, delim, nchars, raw_mode, ifs);
let status = if terminated { 0 } else { 1 };

struct ReadField<'a> {
text: &'a str,
start: usize,
}

let words: Vec<ReadField<'_>> = if ifs.is_empty() {
let words: Vec<ReadField<'_>> = if exact_nchars {
// -N: the characters read are one field, delimiters and IFS
// included.
if line.is_empty() {
Vec::new()
} else {
vec![ReadField {
text: &line,
start: 0,
}]
}
} else if ifs.is_empty() {
// Empty IFS means no word splitting
vec![ReadField {
text: &line,
Expand Down Expand Up @@ -325,6 +363,11 @@ impl Builtin for Read {
let mut result = ExecResult::ok(String::new());
result.exit_code = status;
for (i, var_name) in var_names.iter().enumerate() {
if !valid_read_name(var_name, false) {
result.stderr = invalid_name(var_name).into();
result.exit_code = 1;
break;
}
// THREAT[TM-INJ-009]: Block internal variable prefix injection via read
if is_internal_variable(var_name) {
continue;
Expand Down Expand Up @@ -362,6 +405,21 @@ impl Builtin for Read {
}
}

/// A name `read` can assign: an identifier, or `name[subscript]` unless
/// reading into an array (`-a`).
fn valid_read_name(name: &str, array: bool) -> bool {
match name.find('[') {
Some(b) if !array => {
is_valid_var_name(&name[..b]) && name.ends_with(']') && name.len() > b + 2
}
_ => is_valid_var_name(name),
}
}

fn invalid_name(name: &str) -> String {
format!("bash: read: `{name}': not a valid identifier\n")
}

/// Bytes of `input` one `read` with these `args` consumes from a shared
/// stdin (pipe feeding a loop): the record plus its delimiter, following
/// `-r`, `-d`, `-n` and `\<newline>` continuation like [`read_record`].
Expand Down
Loading
Loading