use bashkit::{Bash, ExecutionLimits, MemoryLimits};
use std::time::Duration;
const CAP_TEST_TIMEOUT: Duration = Duration::from_secs(600);
fn bounded() -> Bash {
Bash::builder()
.memory_limits(MemoryLimits::new().max_total_variable_bytes(64 * 1024))
.limits(ExecutionLimits::new().timeout(CAP_TEST_TIMEOUT))
.build()
}
fn filler(len: usize) -> String {
format!("'{}'", "a".repeat(len))
}
fn assert_budget_error(error: bashkit::Error) {
let rendered = error.to_string();
assert!(
rendered.contains("variable byte limit"),
"unexpected diagnostic: {rendered}"
);
}
#[tokio::test]
async fn indexed_element_assignment_over_budget_fails() {
let error = bounded()
.exec(&format!("v={}; a[1]=$v; echo ${{#a[1]}}", filler(40_000)))
.await
.expect_err("over-budget array element must fail execution");
assert_budget_error(error);
}
#[tokio::test]
async fn many_small_entries_aggregate_against_the_budget() {
let error = bounded()
.exec("v=$(printf 'a%.0s' {1..900}); for i in {1..200}; do a[$i]=$v; done; echo ok")
.await
.expect_err("aggregate array bytes must fail execution");
assert_budget_error(error);
}
#[tokio::test]
async fn associative_element_assignment_over_budget_fails() {
let error = bounded()
.exec(&format!(
"declare -A m; v={}; m[k]=$v; echo done",
filler(40_000)
))
.await
.expect_err("over-budget assoc entry must fail execution");
assert_budget_error(error);
}
#[tokio::test]
async fn array_append_over_budget_fails() {
let error = bounded()
.exec("v=$(printf 'b%.0s' {1..2000}); a[0]=start; for i in {1..60}; do a[0]+=$v; done; echo ok")
.await
.expect_err("over-budget array append must fail execution");
assert_budget_error(error);
}
#[tokio::test]
async fn bulk_array_assignment_over_budget_fails() {
let error = bounded()
.exec("v=$(printf 'c%.0s' {1..9000}); a=($v $v $v $v $v $v $v $v $v $v); echo ok")
.await
.expect_err("over-budget bulk array assignment must fail execution");
assert_budget_error(error);
}
#[tokio::test]
async fn unset_and_replacement_release_array_bytes() {
let mut bash = bounded();
let filled = bash
.exec("v=$(printf 'd%.0s' {1..8000}); for i in {1..6}; do a[$i]=$v; done; echo ${#a[6]}")
.await
.expect("6 x 8000 bytes fits inside 64 KiB");
assert_eq!(filled.exit_code, 0, "stderr: {}", filled.stderr);
assert_eq!(filled.stdout, "8000\n");
let reused = bash
.exec("unset a; v=$(printf 'e%.0s' {1..8000}); for i in {1..6}; do a[$i]=$v; done; echo ${#a[6]}")
.await
.expect("bytes released by unset must be reusable");
assert_eq!(reused.exit_code, 0, "stderr: {}", reused.stderr);
assert_eq!(reused.stdout, "8000\n");
let per_element = bash
.exec("for i in {1..6}; do unset 'a[$i]'; done; v=$(printf 'f%.0s' {1..8000}); for i in {1..6}; do a[$i]=$v; done; echo ${#a[6]}")
.await
.expect("bytes released by element unset must be reusable");
assert_eq!(per_element.exit_code, 0, "stderr: {}", per_element.stderr);
assert_eq!(per_element.stdout, "8000\n");
}
#[tokio::test]
async fn arrays_below_the_budget_are_unaffected() {
let mut bash = bounded();
let result = bash
.exec(
"a=(one two three); a[5]=five; declare -A m; m[key]=value; m[other]=thing; \
echo \"${a[0]} ${a[2]} ${a[5]} ${m[key]} ${m[other]} ${#a[@]}\"",
)
.await
.unwrap();
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert_eq!(result.stdout, "one three five value thing 4\n");
}
#[tokio::test]
async fn local_array_bytes_are_released_on_scope_pop() {
let mut bash = bounded();
let result = bash
.exec(
"v=$(printf 'g%.0s' {1..8000}); \
f() { local -a inner; for i in {1..5}; do inner[$i]=$v; done; echo ${#inner[5]}; }; \
for round in {1..6}; do f; done",
)
.await
.expect("local array bytes must be released when each frame pops");
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert_eq!(result.stdout, "8000\n".repeat(6));
}
#[tokio::test]
async fn default_limits_admit_a_realistic_array_workload() {
let mut bash = Bash::new();
let result = bash
.exec("for i in {1..2000}; do a[$i]=\"row-$i\"; done; echo ${#a[@]} ${a[2000]}")
.await
.unwrap();
assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
assert_eq!(result.stdout, "2000 row-2000\n");
}
#[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");
}
#[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");
}
#[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");
}
}
}