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
31 changes: 31 additions & 0 deletions crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -491,3 +491,34 @@ test("multiple independent instances do not share state", () => {
assert.equal(b.executeSync("echo [${SHARED-none}]").stdout, "[none]\n");
assert.equal(b.exists("/marker.txt"), false);
});

// Independent brush-web reproduction: cumulative reads must not exhaust the
// retained array-entry budget or corrupt later arrays in the same instance.
test("mapfile: repeated large reads preserve session arrays", async () => {
const bash = new Bash();
const r = await bash.execute(`
seq 50000 > /tmp/s
for i in 1 2 3; do
mapfile -t a < /tmp/s
echo "mapfile=$? n=\${#a[@]}"
done
b=(x y z); echo "b=\${#b[@]}"
`);
assert.equal(r.exitCode, 0);
assert.equal(r.stderr, "");
assert.equal(r.stdout, "mapfile=0 n=50000\n".repeat(3) + "b=3\n");
const next = await bash.execute("c=(still works); echo \"${c[*]}\"");
assert.equal(next.exitCode, 0);
assert.equal(next.stdout, "still works\n");
});

test("mapfile: entry exhaustion fails visibly and the session recovers", async () => {
const bash = new Bash();
await assert.rejects(
bash.execute("seq 50001 > /tmp/rows; seq 50000 >> /tmp/rows; mapfile -t a < /tmp/rows"),
/array entry limit \(100000\) exceeded/,
);
const next = await bash.execute("unset a; b=(x y z); echo ${#b[@]}");
assert.equal(next.exitCode, 0);
assert.equal(next.stdout, "3\n");
});
7 changes: 7 additions & 0 deletions crates/bashkit/src/builtins/mapfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
//!
//! Mutates arrays via [`BuiltinSideEffect::SetIndexedArray`](super::BuiltinSideEffect).
//!
//! Decision: debug-stack arrays are interpreter-owned, uncharged metadata.
//! Like bash, reject them as destinations rather than refunding their entries
//! as user-array capacity when clearing the destination.
//!
//! Decision: `-u FD` other than 0 and the `-C`/`-c` callback are rejected with
//! status 2 instead of being ignored; reading the wrong input silently would
//! be worse than a clear error (see L-MAPFILE-001 in operations/limitations).
Expand Down Expand Up @@ -101,6 +105,9 @@ impl Builtin for Mapfile {
Ok(o) => o,
Err(msg) => return Ok(ExecResult::err(format!("mapfile: {msg}\n"), 2)),
};
if matches!(o.name.as_str(), "FUNCNAME" | "BASH_SOURCE" | "BASH_LINENO") {
return Ok(ExecResult::err(String::new(), 1));
}
let input = ctx.stdin.map(|stdin| &**stdin).unwrap_or("");

let mut result = ExecResult::ok(String::new());
Expand Down
69 changes: 39 additions & 30 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
// validating string contents. This is safe because we check for non-empty strings.
#![allow(clippy::unwrap_used)]

// Decision: builtin array mutations use the same retained entry/byte accounting
// as assignments. Clearing refunds both charges; indexed merges check each live
// element in place. Refused entry/byte writes fail the host execution visibly.

// Decision: function definition filenames share Arc<str> storage and live in
// ScopedState beside functions. Charge filename/key bytes per function before
// admission; replace/refund them with the definition so forks, rollback and
Expand Down Expand Up @@ -11593,18 +11597,13 @@ impl Interpreter {
// THREAT[TM-DOS-114]: `unset arr` must return the array's entry
// slots and bytes to the budget. Without this a set/unset cycle
// drifts until a healthy script is refused.
let released = self
.arrays_mut()
.remove(name)
.map_or((0, 0), |arr| (arr.len(), Self::indexed_array_bytes(&arr)));
self.remove_indexed_array(name);
let released_assoc = self
.assoc_arrays_mut()
.remove(name)
.map_or((0, 0), |arr| (arr.len(), Self::assoc_array_bytes(&arr)));
self.memory_budget
.record_array_remove(released.0 + released_assoc.0);
self.memory_budget
.release_array_bytes(released.1 + released_assoc.1);
self.memory_budget.record_array_remove(released_assoc.0);
self.memory_budget.release_array_bytes(released_assoc.1);
self.clear_var_attrs(name);
self.remove_nameref(name);
}
Expand Down Expand Up @@ -12469,10 +12468,14 @@ impl Interpreter {
builtins::BuiltinSideEffect::SetIndexedArray { name, entries } => {
// Merges into the existing array; mapfile sends RemoveArray
// first unless `-O` asked to keep the other elements.
let mut arr = self.arrays_mut().remove(name).unwrap_or_default();
arr.extend(entries.iter().cloned());
if !arr.is_empty() {
self.insert_array_checked(name.clone(), arr);
// Keep retained storage visible to admission so overwrites
// refund their old values and refused writes preserve them.
if !entries.is_empty() && self.env.contains_key(name) {
self.env_mut().remove(name);
}
for (idx, value) in entries {
self.set_indexed_element_checked(name, *idx, value.clone());
self.check_cancelled()?;
}
}
builtins::BuiltinSideEffect::SignalSelf(signal) => {
Expand All @@ -12482,7 +12485,7 @@ impl Interpreter {
self.builtin_discard = true;
}
builtins::BuiltinSideEffect::RemoveArray(name) => {
self.arrays_mut().remove(name);
self.remove_indexed_array(name);
}
builtins::BuiltinSideEffect::ShiftPositional(n) => {
// bash: a count above `$#` shifts nothing and fails.
Expand Down Expand Up @@ -14017,6 +14020,16 @@ impl Interpreter {
}
}

/// Removing retained storage must release both session budget charges.
/// Shared by unset and builtin replacement, including empty mapfile input.
fn remove_indexed_array(&mut self, name: &str) {
if let Some(arr) = self.arrays_mut().remove(name) {
self.memory_budget.record_array_remove(arr.len());
self.memory_budget
.release_array_bytes(Self::indexed_array_bytes(&arr));
}
}

/// Bytes charged for an indexed array's contents. Indexed keys are machine
/// integers already covered by the entry count, so only values are charged.
fn indexed_array_bytes(arr: &HashMap<usize, String>) -> usize {
Expand All @@ -14032,22 +14045,20 @@ impl Interpreter {
/// Charge an array write against the entry count and the shared
/// retained-byte budget. Returns `false` when the write must be skipped.
///
/// THREAT[TM-DOS-114]: an over-budget *byte* write records the first
/// rejection so execution fails visibly rather than dropping the value.
/// Hitting `max_array_entries` keeps its established silent-skip behaviour —
/// that counter is a separate contract (TM-DOS-060) with its own tests, and
/// widening it here would change unrelated semantics.
/// THREAT[TM-DOS-060] THREAT[TM-DOS-114]: entry and byte refusals
/// record the first rejection so execution fails visibly rather than
/// silently dropping values and reporting success.
fn admit_array_write(
&mut self,
new_entries: usize,
added_bytes: usize,
removed_bytes: usize,
) -> bool {
if self
if let Err(error) = self
.memory_budget
.check_array_entries(new_entries, &self.memory_limits)
.is_err()
{
self.memory_limit_error.get_or_insert(error);
return false;
}
if let Err(error) =
Expand Down Expand Up @@ -14079,12 +14090,11 @@ impl Interpreter {
.map_or((0, 0), |a| (a.len(), Self::indexed_array_bytes(a)));
let net = new_entries.saturating_sub(old_entries);
let new_bytes = Self::indexed_array_bytes(&arr);
if net > 0
&& self
.memory_budget
.check_array_entries(net, &self.memory_limits)
.is_err()
if let Err(error) = self
.memory_budget
.check_array_entries(net, &self.memory_limits)
{
self.memory_limit_error.get_or_insert(error);
return false;
}
if let Err(error) =
Expand Down Expand Up @@ -14113,12 +14123,11 @@ impl Interpreter {
.map_or((0, 0), |a| (a.len(), Self::assoc_array_bytes(a)));
let net = new_entries.saturating_sub(old_entries);
let new_bytes = Self::assoc_array_bytes(&arr);
if net > 0
&& self
.memory_budget
.check_array_entries(net, &self.memory_limits)
.is_err()
if let Err(error) = self
.memory_budget
.check_array_entries(net, &self.memory_limits)
{
self.memory_limit_error.get_or_insert(error);
return false;
}
if let Err(error) =
Expand Down
157 changes: 156 additions & 1 deletion crates/bashkit/tests/integration/array_budget_security_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@
//! visible error instead of dropping the write.

use bashkit::{Bash, ExecutionLimits, MemoryLimits};
use std::time::Duration;

// Resource ceilings must trip before deadlines under instrumented builds.
const CAP_TEST_TIMEOUT: Duration = Duration::from_secs(600);

/// 64 KiB of headroom: enough for a handful of small entries, far below the
/// megabyte-scale values these tests park in arrays.
fn bounded() -> Bash {
Bash::builder()
.memory_limits(MemoryLimits::new().max_total_variable_bytes(64 * 1024))
.limits(ExecutionLimits::new())
.limits(ExecutionLimits::new().timeout(CAP_TEST_TIMEOUT))
.build()
}

Expand Down Expand Up @@ -167,3 +171,154 @@ async fn default_limits_admit_a_realistic_array_workload() {
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert_eq!(result.stdout, "2000 row-2000\n");
}

/// Repeated replacement must charge retained state, not cumulative read volume.
#[tokio::test]
async fn mapfile_replacement_releases_entries_and_bytes() {
for options in ["-t", "-t -O 0"] {
let mut bash = Bash::builder()
.memory_limits(
MemoryLimits::new()
.max_array_entries(4)
.max_total_variable_bytes(4096),
)
.build();
let script = format!(
"export a=old; for i in {{1..12}}; do mapfile {options} a <<<'one\ntwo\nthree'; echo ${{#a[@]}}; done; b=(ok); echo ${{b[0]}}; if env | grep -q '^a='; then echo leaked; fi"
);
let result = bash.exec(&script).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, format!("{}ok\n", "3\n".repeat(12)));
let result = bash
.exec("mapfile -t a <<<'last'; b=(x y); echo ${a[*]} ${#b[@]}")
.await
.unwrap();
assert_eq!(result.stdout, "last 2\n");
}
}

#[tokio::test]
async fn mapfile_empty_replacement_releases_budget() {
let mut bash = Bash::builder()
.memory_limits(MemoryLimits::new().max_array_entries(3))
.limits(ExecutionLimits::new().timeout(CAP_TEST_TIMEOUT))
.build();
let result = bash
.exec("a=(one two three); mapfile -t a </dev/null; b=(x y z); echo ${#a[@]} ${#b[@]}")
.await
.unwrap();
assert_eq!(result.stdout, "0 3\n");
}

#[tokio::test]
async fn mapfile_entry_refusal_is_visible_and_recoverable() {
for script in [
"mapfile -t a <<<'one\ntwo\nthree\nfour'",
"a=(old); mapfile -t -O 1 a <<<'one\ntwo\nthree'",
"read -ra a <<<'one two three four'",
"a=(one two three); b[0]=four",
"a=(one two three); declare -A b; b[key]=four",
] {
let mut bash = Bash::builder()
.memory_limits(MemoryLimits::new().max_array_entries(3))
.limits(ExecutionLimits::new().timeout(CAP_TEST_TIMEOUT))
.build();
let error = bash
.exec(script)
.await
.expect_err("entry refusal must be visible");
assert!(
error.to_string().contains("array entry limit (3)"),
"{error}"
);
let result = bash
.exec("unset a b; c=(x y z); echo ${#c[@]}")
.await
.unwrap();
assert_eq!(result.stdout, "3\n", "after {script}");
}
}

#[tokio::test]
async fn mapfile_origin_merges_without_losing_old_entries_on_refusal() {
let mut bash = Bash::builder()
.memory_limits(MemoryLimits::new().max_array_entries(3))
.limits(ExecutionLimits::new().timeout(CAP_TEST_TIMEOUT))
.build();
bash.exec("a=(one two three)").await.unwrap();
let result = bash
.exec("mapfile -t -O 1 a <<<'new'; echo ${a[*]}")
.await
.unwrap();
assert_eq!(result.stdout, "one new three\n");
assert!(bash.exec("mapfile -t -O 3 a <<<'extra'").await.is_err());
let result = bash
.exec("echo ${a[*]}; unset a; b=(x y z); echo ${#b[@]}")
.await
.unwrap();
assert_eq!(result.stdout, "one new three\n3\n");
}

#[tokio::test]
async fn mapfile_replacement_releases_large_values() {
let mut bash = bounded();
let line = "a".repeat(24_000);
bash.fs()
.write_file(
std::path::Path::new("/large"),
format!("{line}\n{line}\n").as_bytes(),
)
.await
.unwrap();
for _ in 0..4 {
let result = bash
.exec("mapfile -t a </large; echo ${#a[@]} ${#a[0]}")
.await
.unwrap();
assert_eq!(result.stdout, "2 24000\n");
}
assert!(bash.exec("mapfile -t -O 2 a </large").await.is_err());
let result = bash
.exec("echo ${#a[@]}; unset a; mapfile -t b </large; echo ${#b[@]}")
.await
.unwrap();
assert_eq!(result.stdout, "2\n2\n");
}

/// The independent brush-web reproduction, with default limits.
#[tokio::test]
async fn repeated_large_mapfile_does_not_corrupt_session_arrays() {
let mut bash = Bash::new();
let result = bash.exec("seq 50000 > /tmp/s; for i in 1 2 3; do mapfile -t a < /tmp/s; echo \"mapfile=$? n=${#a[@]}\"; done; b=(x y z); echo \"b=${#b[@]}\"").await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(
result.stdout,
"mapfile=0 n=50000\nmapfile=0 n=50000\nmapfile=0 n=50000\nb=3\n"
);
let result = bash.exec("c=(still works); echo ${c[*]}").await.unwrap();
assert_eq!(result.stdout, "still works\n");
}

/// Debug-stack metadata is uncharged: mapfile must not refund it as user data.
#[tokio::test]
async fn mapfile_cannot_refund_interpreter_metadata() {
for name in ["FUNCNAME", "BASH_SOURCE", "BASH_LINENO"] {
for options in ["-t", "-t -O 0"] {
let mut bash = Bash::builder()
.memory_limits(MemoryLimits::new().max_array_entries(3))
.limits(ExecutionLimits::new().timeout(CAP_TEST_TIMEOUT))
.build();
let script =
format!("a=(one two three); f() {{ mapfile {options} {name} </dev/null; }}; f");
let result = bash.exec(&script).await.unwrap();
assert_eq!(result.exit_code, 1, "{script}");
let error = bash.exec("b[0]=extra").await.unwrap_err();
assert!(error.to_string().contains("array entry limit (3)"));
let result = bash
.exec("unset a; b=(x y z); echo ${#b[@]}")
.await
.unwrap();
assert_eq!(result.stdout, "3\n");
}
}
}
Loading
Loading