use bashkit::{
Bash, ExecutionLimits, FileSystem, FileSystemExt, FsLimits, InMemoryFs, MemoryLimits,
OverlayFs, SessionLimits, TraceEventDetails, TraceEventKind, TraceMode,
};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
mod resource_exhaustion {
use super::*;
#[tokio::test]
async fn threat_infinite_commands_blocked() {
let limits = ExecutionLimits::new().max_commands(10);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("true; true; true; true; true; true; true; true; true; true; true; true; true; true; true; true; true; true; true; true")
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("command") && err.contains("exceeded"),
"Expected command limit error, got: {}",
err
);
}
#[tokio::test]
async fn fd_exhaustion_blocked() {
let limits = ExecutionLimits::new().max_file_descriptors(2);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("exec 3>/tmp/fd3; exec 4>/tmp/fd4; exec 5>/tmp/fd5")
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("file descriptors") && err.contains("exceeded"),
"Expected fd limit error, got: {}",
err
);
}
#[tokio::test]
async fn fd_limit_allows_reuse_and_close() {
let limits = ExecutionLimits::new().max_file_descriptors(1);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("exec 3>/tmp/a; exec 3>/tmp/b; exec 3>&-; exec 4>/tmp/c; echo ok >&4; cat /tmp/c")
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "ok");
}
#[tokio::test]
async fn fd_limit_rejects_negative_fd_var_output() {
let limits = ExecutionLimits::new().max_file_descriptors(1);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("v=-1; exec {v}>/tmp/neg-out").await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("invalid file descriptor"),
"Expected invalid file descriptor error, got: {}",
err
);
}
#[tokio::test]
async fn fd_limit_rejects_negative_fd_var_input() {
let limits = ExecutionLimits::new().max_file_descriptors(1);
let mut bash = Bash::builder().limits(limits).build();
bash.exec("echo hi >/tmp/neg-in").await.unwrap();
let result = bash.exec("v=-1; exec {v}</tmp/neg-in").await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("invalid file descriptor"),
"Expected invalid file descriptor error, got: {}",
err
);
}
#[tokio::test]
async fn exec_recovers_after_command_limit() {
let limits = ExecutionLimits::new().max_commands(10);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("true; true; true; true; true; true; true; true; true; true; true; true")
.await;
assert!(result.is_err());
let result = bash.exec("echo hello").await.unwrap();
assert_eq!(result.stdout.trim(), "hello");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn exec_recovers_after_loop_limit() {
let limits = ExecutionLimits::new()
.max_loop_iterations(5)
.max_total_loop_iterations(10)
.max_commands(10000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("for i in 1 2 3 4 5; do true; done; for i in 1 2 3 4 5 6; do true; done")
.await;
assert!(result.is_err());
let result = bash.exec("for i in 1 2 3; do echo $i; done").await.unwrap();
assert!(result.stdout.contains("1"));
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_infinite_loop_blocked() {
let limits = ExecutionLimits::new()
.max_loop_iterations(5)
.max_commands(1000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done")
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("loop") && err.contains("exceeded"),
"Expected loop limit error, got: {}",
err
);
}
#[tokio::test]
async fn threat_stack_overflow_blocked() {
let limits = ExecutionLimits::new()
.max_function_depth(5)
.max_commands(1000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec(
r#"
recurse() {
echo "depth"
recurse
}
recurse
"#,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("function") && err.contains("exceeded"),
"Expected function depth error, got: {}",
err
);
}
#[tokio::test]
async fn threat_while_true_blocked() {
let limits = ExecutionLimits::new()
.max_loop_iterations(10)
.max_commands(1000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("i=0; while [ $i -lt 100 ]; do i=$((i+1)); done")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn threat_cpu_exhaustion_timeout() {
let limits = ExecutionLimits::new()
.timeout(Duration::from_millis(100))
.max_commands(1_000_000)
.max_loop_iterations(1_000_000);
let mut bash = Bash::builder().limits(limits).build();
let start = std::time::Instant::now();
let _ = bash
.exec("for i in $(seq 1 1000000); do echo $i; done")
.await;
let elapsed = start.elapsed();
assert!(elapsed < Duration::from_secs(300));
}
}
mod sandbox_escape {
use super::*;
#[tokio::test]
async fn threat_path_traversal_blocked() {
let mut bash = Bash::new();
let result = bash.exec("cat ../../../etc/passwd").await.unwrap();
assert!(result.exit_code != 0 || result.stdout.is_empty());
assert!(!result.stdout.contains("root:"));
}
#[tokio::test]
async fn threat_etc_passwd_blocked() {
let mut bash = Bash::new();
let result = bash.exec("cat /etc/passwd").await.unwrap();
assert!(result.exit_code != 0);
assert!(!result.stdout.contains("root:"));
}
#[tokio::test]
async fn threat_proc_access_blocked() {
let mut bash = Bash::new();
let result = bash.exec("cat /proc/self/environ").await.unwrap();
assert!(result.exit_code != 0);
}
#[tokio::test]
async fn threat_eval_is_safe_in_sandbox() {
let mut bash = Bash::new();
let result = bash.exec("eval echo test").await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_exec_not_available() {
let mut bash = Bash::new();
let result = bash.exec("exec /bin/bash").await.unwrap();
assert_eq!(result.exit_code, 127);
}
#[tokio::test]
async fn threat_exec_argument_quote_injection_blocked() {
let mut bash = Bash::new();
let result = bash
.exec("exec echo \"foo' ; touch /tmp/exec_injected #\"")
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "foo' ; touch /tmp/exec_injected #");
let check = bash.exec("cat /tmp/exec_injected").await.unwrap();
assert_ne!(
check.exit_code, 0,
"injected touch must not execute through exec argv quoting"
);
}
#[tokio::test]
async fn threat_external_commands_blocked() {
let mut bash = Bash::new();
if let Ok(r) = bash.exec("/bin/ls").await {
assert!(r.exit_code != 0);
}
if let Ok(r) = bash.exec("./malicious").await {
assert!(r.exit_code != 0);
}
}
#[tokio::test]
async fn threat_symlink_escape_blocked() {
let mut bash = Bash::new();
let result = bash.exec("cat /tmp/symlink_to_etc").await.unwrap();
assert!(result.exit_code != 0);
}
}
mod injection_attacks {
use super::*;
#[tokio::test]
async fn threat_semicolon_in_variable_safe() {
let mut bash = Bash::new();
bash.exec("safe=harmless").await.unwrap();
let result = bash.exec("echo $safe").await.unwrap();
assert_eq!(result.stdout.trim(), "harmless");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_command_sub_in_single_quotes() {
let mut bash = Bash::new();
let result = bash.exec("echo '$(whoami)'").await.unwrap();
assert!(result.stdout.contains("$(whoami)"));
assert!(!result.stdout.contains("sandbox"));
}
#[tokio::test]
async fn threat_backticks_in_single_quotes() {
let mut bash = Bash::new();
let result = bash.exec("echo '`hostname`'").await.unwrap();
assert!(result.stdout.contains("`hostname`"));
assert!(!result.stdout.contains("bashkit-sandbox"));
}
#[tokio::test]
async fn threat_eval_is_sandboxed() {
let mut bash = Bash::new();
let result = bash.exec("eval echo test").await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_null_byte_in_path() {
let mut bash = Bash::new();
let result = bash.exec("cat '/tmp/file'").await.unwrap();
assert!(result.exit_code == 0 || result.exit_code == 1);
}
#[tokio::test]
async fn threat_pipe_in_quotes() {
let mut bash = Bash::new();
let result = bash.exec("echo '| cat /etc/passwd'").await.unwrap();
assert!(result.stdout.contains("| cat /etc/passwd"));
}
#[tokio::test]
async fn threat_redirect_in_quotes() {
let mut bash = Bash::new();
let result = bash.exec("echo '> /tmp/pwned'").await.unwrap();
assert!(result.stdout.contains("> /tmp/pwned"));
}
}
mod information_disclosure {
use super::*;
#[tokio::test]
async fn threat_hostname_hardcoded() {
let mut bash = Bash::new();
let result = bash.exec("hostname").await.unwrap();
assert_eq!(result.stdout.trim(), "bashkit-sandbox");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_hostname_cannot_set() {
let mut bash = Bash::new();
let result = bash.exec("hostname evil.attacker.com").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("cannot set"));
}
#[tokio::test]
async fn threat_uname_hardcoded() {
let mut bash = Bash::new();
let result = bash.exec("uname -a").await.unwrap();
assert!(result.stdout.contains("bashkit-sandbox"));
assert!(result.stdout.contains("Linux"));
assert!(!result.stdout.contains("Ubuntu"));
assert!(!result.stdout.contains("Debian"));
}
#[tokio::test]
async fn threat_uname_nodename_hardcoded() {
let mut bash = Bash::new();
let result = bash.exec("uname -n").await.unwrap();
assert_eq!(result.stdout.trim(), "bashkit-sandbox");
}
#[tokio::test]
async fn threat_whoami_hardcoded() {
let mut bash = Bash::new();
let result = bash.exec("whoami").await.unwrap();
assert_eq!(result.stdout.trim(), "sandbox");
}
#[tokio::test]
async fn threat_id_hardcoded() {
let mut bash = Bash::new();
let result = bash.exec("id").await.unwrap();
assert!(result.stdout.contains("uid=1000"));
assert!(result.stdout.contains("sandbox"));
let result = bash.exec("id -u").await.unwrap();
assert_eq!(result.stdout.trim(), "1000");
let result = bash.exec("id -g").await.unwrap();
assert_eq!(result.stdout.trim(), "1000");
}
#[tokio::test]
async fn threat_env_vars_isolated() {
let mut bash = Bash::new();
let result = bash.exec("echo $DATABASE_URL").await.unwrap();
assert!(result.stdout.trim().is_empty());
let result = bash.exec("echo $AWS_SECRET_ACCESS_KEY").await.unwrap();
assert!(result.stdout.trim().is_empty());
let result = bash.exec("echo $API_KEY").await.unwrap();
assert!(result.stdout.trim().is_empty());
}
#[tokio::test]
async fn threat_env_vars_explicit_only() {
let mut bash = Bash::builder().env("ALLOWED_VAR", "allowed_value").build();
let result = bash.exec("echo $ALLOWED_VAR").await.unwrap();
assert_eq!(result.stdout.trim(), "allowed_value");
let result = bash.exec("echo $PATH").await.unwrap();
assert!(result.stdout.trim().is_empty());
}
#[tokio::test]
async fn threat_proc_environ_blocked() {
let mut bash = Bash::new();
let result = bash.exec("cat /proc/self/environ").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stdout.is_empty());
}
}
mod network_security {
use super::*;
#[tokio::test]
async fn threat_network_commands_not_builtin() {
let mut bash = Bash::new();
let result = bash.exec("curl https://evil.com").await;
if let Ok(r) = result {
assert!(r.exit_code != 0);
}
let result = bash.exec("wget https://evil.com").await;
if let Ok(r) = result {
assert!(r.exit_code != 0);
}
}
}
mod session_isolation {
use super::*;
use bashkit::InMemoryFs;
use std::sync::Arc;
#[tokio::test]
async fn threat_isolation_fs_isolation() {
let fs_a = Arc::new(InMemoryFs::new());
let fs_b = Arc::new(InMemoryFs::new());
let mut tenant_a = Bash::builder().fs(fs_a).build();
let mut tenant_b = Bash::builder().fs(fs_b).build();
tenant_a
.exec("echo 'SECRET_A' > /tmp/secret.txt")
.await
.unwrap();
let result = tenant_b.exec("cat /tmp/secret.txt").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(!result.stdout.contains("SECRET_A"));
}
#[tokio::test]
async fn threat_isolation_variable_isolation() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a.exec("SECRET=password123").await.unwrap();
let result = tenant_b.exec("echo $SECRET").await.unwrap();
assert!(result.stdout.trim().is_empty());
}
#[tokio::test]
async fn threat_isolation_function_isolation() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a.exec("steal() { echo 'stolen'; }").await.unwrap();
let result = tenant_b.exec("steal").await.unwrap();
assert_eq!(result.exit_code, 127);
assert!(!result.stdout.contains("stolen"));
assert!(result.stderr.contains("command not found"));
}
#[tokio::test]
async fn threat_isolation_limits_isolation() {
let limits_strict = ExecutionLimits::new().max_commands(5);
let limits_relaxed = ExecutionLimits::new().max_commands(100);
let mut tenant_strict = Bash::builder().limits(limits_strict).build();
let mut tenant_relaxed = Bash::builder().limits(limits_relaxed).build();
let result = tenant_strict
.exec("true; true; true; true; true; true; true")
.await;
assert!(result.is_err());
let result = tenant_relaxed
.exec("true; true; true; true; true; true; true")
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn threat_isolation_alias_isolation() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a
.exec("alias secret_cmd='echo LEAKED'")
.await
.unwrap();
let result = tenant_b.exec("secret_cmd").await.unwrap();
assert_eq!(result.exit_code, 127);
assert!(!result.stdout.contains("LEAKED"));
}
#[tokio::test]
async fn alias_expansion_persists_across_exec_calls() {
let mut bash = Bash::new();
bash.exec("shopt -s expand_aliases").await.unwrap();
bash.exec("alias ll='echo alias_worked'").await.unwrap();
let result = bash.exec("ll").await.unwrap();
assert_eq!(
result.exit_code, 0,
"alias should resolve: {}",
result.stderr
);
assert_eq!(result.stdout.trim(), "alias_worked");
}
#[tokio::test]
async fn threat_isolation_trap_isolation() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a.exec("trap 'echo TRAP_LEAKED' EXIT").await.unwrap();
let result = tenant_b.exec("true").await.unwrap();
assert!(!result.stdout.contains("TRAP_LEAKED"));
}
#[tokio::test]
async fn threat_isolation_shell_options_isolation() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a.exec("set -e").await.unwrap();
tenant_a.exec("set -o pipefail").await.unwrap();
let result = tenant_b.exec("false; echo STILL_RUNNING").await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("STILL_RUNNING"));
}
#[tokio::test]
async fn threat_isolation_export_isolation() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a.exec("export DB_PASSWORD=s3cret").await.unwrap();
let result = tenant_b.exec("echo \"[$DB_PASSWORD]\"").await.unwrap();
assert_eq!(result.stdout.trim(), "[]");
}
#[tokio::test]
async fn threat_isolation_array_isolation() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a
.exec("SECRET_ARR=(one two three); declare -A SECRET_MAP; SECRET_MAP[key]=val")
.await
.unwrap();
let result = tenant_b
.exec("echo \"${SECRET_ARR[0]}\" \"${SECRET_MAP[key]}\"")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "");
}
#[tokio::test]
async fn threat_isolation_cwd_isolation() {
let fs_a = Arc::new(InMemoryFs::new());
let mut tenant_a = Bash::builder().fs(fs_a).build();
let mut tenant_b = Bash::new();
tenant_a
.exec("mkdir -p /opt/secret && cd /opt/secret")
.await
.unwrap();
let result = tenant_b.exec("pwd").await.unwrap();
assert!(!result.stdout.contains("/opt/secret"));
}
#[tokio::test]
async fn threat_isolation_exit_code_isolation() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a.exec("false").await.unwrap();
let result = tenant_b.exec("echo $?").await.unwrap();
assert_eq!(result.stdout.trim(), "0");
}
#[tokio::test]
async fn threat_isolation_concurrent_isolation() {
use tokio::task::JoinSet;
let mut tasks = JoinSet::new();
for i in 0..10 {
tasks.spawn(async move {
let mut bash = Bash::new();
let secret = format!("TENANT_{}_SECRET", i);
bash.exec(&format!("MY_SECRET={}", secret)).await.unwrap();
let result = bash.exec("echo $MY_SECRET").await.unwrap();
assert_eq!(result.stdout.trim(), secret);
for j in 0..10 {
if j != i {
let other = format!("TENANT_{}_SECRET", j);
let probe = bash.exec("echo $MY_SECRET").await.unwrap();
assert!(!probe.stdout.contains(&other));
}
}
});
}
while let Some(result) = tasks.join_next().await {
result.unwrap();
}
}
#[tokio::test]
async fn threat_isolation_concurrent_fs_isolation() {
use tokio::task::JoinSet;
let mut tasks = JoinSet::new();
for i in 0..10 {
tasks.spawn(async move {
let fs = Arc::new(InMemoryFs::new());
let mut bash = Bash::builder().fs(fs).build();
let secret = format!("FS_SECRET_{}", i);
bash.exec(&format!("echo '{}' > /tmp/data.txt", secret))
.await
.unwrap();
let result = bash.exec("cat /tmp/data.txt").await.unwrap();
assert!(
result.stdout.contains(&secret),
"Tenant {} should see its own secret",
i
);
for j in 0..10 {
if j != i {
let other_secret = format!("FS_SECRET_{}", j);
assert!(
!result.stdout.contains(&other_secret),
"Tenant {} saw tenant {}'s data!",
i,
j
);
}
}
});
}
while let Some(result) = tasks.join_next().await {
result.unwrap();
}
}
#[tokio::test]
async fn threat_isolation_snapshot_isolation() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a.exec("SNAPSHOT_SECRET=before").await.unwrap();
let snapshot = tenant_a.shell_state();
tenant_a.exec("SNAPSHOT_SECRET=after").await.unwrap();
tenant_a.restore_shell_state(&snapshot);
let result = tenant_b.exec("echo \"[$SNAPSHOT_SECRET]\"").await.unwrap();
assert_eq!(result.stdout.trim(), "[]");
}
#[tokio::test]
async fn threat_isolation_adversarial_probing() {
let mut victim = Bash::new();
victim.exec("API_KEY=sk-live-12345").await.unwrap();
victim
.exec("export DATABASE_URL=postgres://secret@db/prod")
.await
.unwrap();
let mut attacker = Bash::new();
let probe_script = r#"
echo "$API_KEY"
echo "$DATABASE_URL"
echo "$AWS_SECRET_ACCESS_KEY"
echo "$GITHUB_TOKEN"
echo "$PASSWORD"
echo "$SECRET"
echo "$PRIVATE_KEY"
"#;
let result = attacker.exec(probe_script).await.unwrap();
assert!(!result.stdout.contains("sk-live"));
assert!(!result.stdout.contains("postgres://"));
let result = attacker
.exec("env 2>/dev/null; printenv 2>/dev/null")
.await
.unwrap();
assert!(!result.stdout.contains("sk-live"));
assert!(!result.stdout.contains("postgres://"));
}
#[tokio::test]
async fn threat_isolation_proc_probing() {
let mut bash = Bash::new();
let probes = vec![
"cat /proc/self/environ 2>/dev/null",
"cat /proc/self/cmdline 2>/dev/null",
"cat /proc/1/environ 2>/dev/null",
"ls /proc 2>/dev/null",
"cat /etc/passwd 2>/dev/null",
"cat /etc/shadow 2>/dev/null",
];
for probe in probes {
let result = bash.exec(probe).await.unwrap();
assert!(
result.stdout.trim().is_empty() || result.exit_code != 0,
"Probe '{}' returned unexpected data: {}",
probe,
result.stdout
);
}
}
#[tokio::test]
async fn threat_isolation_jq_env_cross_session() {
let mut tenant_a = Bash::new();
let mut tenant_b = Bash::new();
tenant_a
.exec("export JQ_SECRET=tenant_a_secret")
.await
.unwrap();
let result = tenant_b
.exec("jq -n 'env.JQ_SECRET // \"none\"'")
.await
.unwrap();
assert!(
!result.stdout.contains("tenant_a_secret"),
"jq leaked cross-session env: {}",
result.stdout
);
}
#[tokio::test]
async fn threat_isolation_subshell_isolation() {
let mut bash = Bash::new();
bash.exec("OUTER=original").await.unwrap();
bash.exec("(OUTER=mutated; INNER=leaked)").await.unwrap();
let result = bash.exec("echo $OUTER $INNER").await.unwrap();
assert_eq!(result.stdout.trim(), "original");
let mut other = Bash::new();
let result = other.exec("echo \"[$OUTER][$INNER]\"").await.unwrap();
assert_eq!(result.stdout.trim(), "[][]");
}
}
mod edge_cases {
use super::*;
#[tokio::test]
async fn threat_empty_script() {
let mut bash = Bash::new();
let result = bash.exec("").await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_whitespace_script() {
let mut bash = Bash::new();
let result = bash.exec(" \n\t\n ").await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_comment_only_script() {
let mut bash = Bash::new();
let result = bash
.exec("# This is a comment\n# Another comment")
.await
.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_long_line() {
let mut bash = Bash::new();
let long_arg = "a".repeat(10000);
let result = bash.exec(&format!("echo {}", long_arg)).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.len() >= 10000);
}
#[tokio::test]
async fn threat_nested_command_sub() {
let limits = ExecutionLimits::new()
.max_commands(100)
.max_function_depth(50);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("echo $(echo $(echo $(echo hello)))").await;
let result = result.expect("4-level command substitution should succeed");
assert_eq!(
result.stdout.trim(),
"hello",
"nested command sub should produce 'hello'"
);
}
#[tokio::test]
async fn threat_deep_subshell_nesting_blocked() {
let limits = ExecutionLimits::new()
.max_commands(100)
.max_function_depth(50)
.max_ast_depth(20);
let mut bash = Bash::builder().limits(limits).build();
let script = format!("{}echo hello{}", "(".repeat(200), ")".repeat(200),);
let result = bash.exec(&script).await;
match result {
Ok(_) => {} Err(e) => {
let err = e.to_string();
assert!(
err.contains("nesting") || err.contains("depth") || err.contains("fuel"),
"Expected depth/nesting/fuel error, got: {}",
err
);
}
}
}
#[tokio::test]
async fn threat_deep_arithmetic_nesting_safe() {
let mut bash = Bash::new();
let depth = 500;
let script = format!("echo $(({} 1 {}))", "(".repeat(depth), ")".repeat(depth),);
let result = bash.exec(&script).await;
match result {
Ok(r) => {
let output = r.stdout.trim();
assert!(!output.is_empty(), "should produce output, not crash");
}
Err(_) => {
}
}
}
#[tokio::test]
async fn threat_special_variable_names() {
let mut bash = Bash::new();
let result = bash.exec("echo $?").await.unwrap(); assert!(result.exit_code == 0);
let result = bash.exec("echo $$").await.unwrap(); assert!(result.exit_code == 0);
let result = bash.exec("echo $#").await.unwrap(); assert!(result.exit_code == 0);
}
#[tokio::test]
async fn command_not_found_exit_code() {
let mut bash = Bash::new();
let result = bash.exec("nonexistent_command").await.unwrap();
assert_eq!(result.exit_code, 127);
assert!(
result.stderr.contains("command not found"),
"stderr should contain 'command not found', got: {}",
result.stderr
);
assert!(
result.stderr.contains("nonexistent_command"),
"stderr should contain the command name, got: {}",
result.stderr
);
}
#[tokio::test]
async fn command_not_found_continues_script() {
let mut bash = Bash::new();
let result = bash.exec("unknown_cmd; echo after").await.unwrap();
assert!(result.stdout.contains("after"));
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn command_not_found_stderr_format() {
let mut bash = Bash::new();
let result = bash.exec("rsync").await.unwrap();
assert_eq!(result.exit_code, 127);
assert!(
result.stderr.starts_with("bash: rsync: command not found"),
"stderr should match bash format, got: {}",
result.stderr
);
}
#[tokio::test]
async fn command_not_found_various_commands() {
let mut bash = Bash::new();
for cmd in &["apt", "yum", "docker", "vim", "nano", "rsync"] {
let result = bash.exec(cmd).await.unwrap();
assert_eq!(
result.exit_code, 127,
"{} should return exit 127, got {}",
cmd, result.exit_code
);
assert!(
result.stderr.contains("command not found"),
"{} stderr should contain 'command not found', got: {}",
cmd,
result.stderr
);
}
}
#[tokio::test]
async fn command_not_found_exit_status_variable() {
let mut bash = Bash::new();
let result = bash.exec("nonexistent; echo $?").await.unwrap();
assert!(result.stdout.contains("127"));
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn command_not_found_in_pipeline() {
let mut bash = Bash::new();
let result = bash.exec("echo hello | nonexistent_filter").await.unwrap();
assert_eq!(result.exit_code, 127);
}
#[tokio::test]
async fn command_not_found_in_conditional() {
let mut bash = Bash::new();
let result = bash
.exec("if nonexistent_cmd; then echo yes; else echo no; fi")
.await
.unwrap();
assert!(result.stdout.contains("no"));
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn command_not_found_or_operator() {
let mut bash = Bash::new();
let result = bash.exec("nonexistent || echo fallback").await.unwrap();
assert!(result.stdout.contains("fallback"));
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn command_not_found_and_operator() {
let mut bash = Bash::new();
let result = bash.exec("nonexistent && echo success").await.unwrap();
assert!(!result.stdout.contains("success"));
assert_eq!(result.exit_code, 127);
}
#[tokio::test]
async fn builtins_still_work() {
let mut bash = Bash::new();
let result = bash.exec("echo hello").await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello"));
let result = bash.exec("pwd").await.unwrap();
assert_eq!(result.exit_code, 0);
let result = bash.exec("true").await.unwrap();
assert_eq!(result.exit_code, 0);
let result = bash.exec("false").await.unwrap();
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn command_not_found_in_subshell() {
let mut bash = Bash::new();
let result = bash.exec("(nonexistent_cmd)").await.unwrap();
assert_eq!(result.exit_code, 127);
assert!(result.stderr.contains("command not found"));
}
#[tokio::test]
async fn command_not_found_in_substitution() {
let mut bash = Bash::new();
let result = bash.exec("echo \"output: $(nonexistent)\"").await.unwrap();
assert!(result.stdout.contains("output:"));
assert_eq!(result.exit_code, 0);
}
}
#[cfg(feature = "python")]
mod python_security {
use super::*;
use bashkit::PythonLimits;
fn bash_with_python() -> Bash {
Bash::builder()
.python_with_limits(PythonLimits::default())
.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1")
.build()
}
#[tokio::test]
async fn threat_python_infinite_loop() {
let mut bash = bash_with_python();
let result = bash.exec("python3 -c \"while True: pass\"").await.unwrap();
assert_ne!(result.exit_code, 0, "Infinite loop should not succeed");
}
#[tokio::test]
async fn threat_python_memory_exhaustion() {
let mut bash = bash_with_python();
let result = bash
.exec("python3 -c \"x = [0] * 100000000\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "Memory bomb should not succeed");
}
#[tokio::test]
async fn threat_python_recursion_bomb() {
let mut bash = bash_with_python();
let result = bash.exec("python3 -c \"def r(): r()\nr()\"").await.unwrap();
assert_ne!(result.exit_code, 0, "Recursion bomb should not succeed");
assert!(
result.stderr.contains("RecursionError") || result.stderr.contains("recursion"),
"Should get recursion error, got: {}",
result.stderr
);
}
#[tokio::test]
async fn threat_python_no_os_operations() {
let mut bash = bash_with_python();
let result = bash
.exec("python3 -c \"import os\nos.system('echo hacked')\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "os.system should fail");
assert!(
!result.stdout.contains("hacked"),
"Should not execute shell via os.system"
);
let result = bash
.exec("python3 -c \"import subprocess\nsubprocess.run(['echo', 'hacked'])\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "subprocess.run should fail");
assert!(
!result.stdout.contains("hacked"),
"Should not execute shell via subprocess"
);
}
#[tokio::test]
async fn threat_python_no_filesystem() {
let mut bash = bash_with_python();
let result = bash
.exec("python3 -c \"f = open('/etc/passwd')\nprint(f.read())\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "host file open should fail");
assert!(
!result.stdout.contains("root:"),
"Should not read real /etc/passwd"
);
}
#[tokio::test]
async fn threat_python_error_isolation() {
let mut bash = bash_with_python();
let result = bash.exec("python3 -c \"1/0\"").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(
result.stderr.contains("ZeroDivisionError"),
"Error should be on stderr"
);
}
#[tokio::test]
async fn threat_python_syntax_error_exit() {
let mut bash = bash_with_python();
let result = bash.exec("python3 -c \"if\"").await.unwrap();
assert_ne!(result.exit_code, 0, "Syntax error should fail");
assert!(
result.stderr.contains("SyntaxError") || result.stderr.contains("Error"),
"Should get syntax error, got: {}",
result.stderr
);
}
#[tokio::test]
async fn threat_python_exit_code_propagation() {
let mut bash = bash_with_python();
let result = bash
.exec("python3 -c \"print('ok')\"\necho $?")
.await
.unwrap();
assert!(result.stdout.contains("0"), "Success should give exit 0");
let result = bash
.exec("python3 -c \"1/0\" 2>/dev/null\necho $?")
.await
.unwrap();
assert!(result.stdout.contains("1"), "Error should give exit 1");
}
#[tokio::test]
async fn threat_python_empty_code() {
let mut bash = bash_with_python();
let result = bash.exec("python3 -c \"\"").await.unwrap();
assert_ne!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_python_pipeline_error_handling() {
let mut bash = bash_with_python();
let result = bash
.exec("python3 -c \"1/0\" 2>/dev/null | cat")
.await
.unwrap();
assert!(
!result.stdout.contains("ZeroDivisionError"),
"Error should not be on stdout in pipeline"
);
}
#[tokio::test]
async fn threat_python_subst_captures_stdout() {
let mut bash = bash_with_python();
let result = bash
.exec("result=$(python3 -c \"print(42)\")\necho $result")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "42");
}
#[tokio::test]
async fn threat_python_no_shell_exec() {
let mut bash = bash_with_python();
let result = bash
.exec("python3 -c \"__import__('os').system('echo hacked')\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "Shell exec via __import__ should fail");
assert!(
!result.stdout.contains("hacked"),
"Should not execute shell command"
);
}
#[tokio::test]
async fn threat_python_unknown_options() {
let mut bash = bash_with_python();
let result = bash.exec("python3 -X import_all").await.unwrap();
assert_ne!(result.exit_code, 0);
}
#[tokio::test]
async fn threat_python_respects_bash_limits() {
let limits = ExecutionLimits::new().max_commands(5);
let mut bash = Bash::builder()
.python()
.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1")
.limits(limits)
.build();
let result = bash.exec("python3 -c \"print('ok')\"").await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "ok\n");
}
#[tokio::test]
async fn threat_python_vfs_no_real_fs() {
let mut bash = bash_with_python();
let result = bash
.exec(
"python3 -c \"from pathlib import Path\ntry:\n Path('/etc/passwd').read_text()\n print('LEAKED')\nexcept FileNotFoundError:\n print('safe')\"",
)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert!(
result.stdout.contains("safe"),
"Should not access real /etc/passwd"
);
assert!(
!result.stdout.contains("LEAKED"),
"Must not leak real filesystem"
);
}
#[tokio::test]
async fn threat_python_vfs_write_sandboxed() {
let mut bash = bash_with_python();
let result = bash
.exec(
"python3 -c \"from pathlib import Path\n_ = Path('/tmp/sandbox_test.txt').write_text('test')\nprint(Path('/tmp/sandbox_test.txt').read_text())\"",
)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "test\n");
}
#[tokio::test]
async fn threat_python_vfs_path_traversal() {
let mut bash = bash_with_python();
let result = bash
.exec(
"python3 -c \"from pathlib import Path\ntry:\n Path('/tmp/../../../etc/passwd').read_text()\n print('ESCAPED')\nexcept FileNotFoundError:\n print('blocked')\"",
)
.await
.unwrap();
assert!(
!result.stdout.contains("ESCAPED"),
"Path traversal must not escape VFS"
);
}
#[tokio::test]
async fn threat_python_vfs_bash_python_isolation() {
let mut bash = bash_with_python();
let result = bash
.exec(
"echo 'from bash' > /tmp/shared.txt\npython3 -c \"from pathlib import Path\nprint(Path('/tmp/shared.txt').read_text().strip())\"",
)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "from bash\n");
}
#[tokio::test]
async fn threat_python_vfs_error_handling() {
let mut bash = bash_with_python();
let result = bash
.exec("python3 -c \"from pathlib import Path\nPath('/nonexistent').read_text()\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "Reading missing file should fail");
assert!(
result.stderr.contains("FileNotFoundError"),
"Should get FileNotFoundError, got: {}",
result.stderr
);
}
#[tokio::test]
async fn threat_python_vfs_no_network() {
let mut bash = bash_with_python();
let result = bash
.exec("python3 -c \"import socket\nsocket.socket()\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "socket should not be available");
}
#[tokio::test]
async fn threat_python_vfs_mkdir_sandboxed() {
let mut bash = bash_with_python();
let result = bash
.exec(
"python3 -c \"from pathlib import Path\nPath('/tmp/pydir').mkdir()\nprint(Path('/tmp/pydir').is_dir())\"",
)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "True\n");
}
}
#[cfg(feature = "python")]
mod python_security_regressions {
use super::*;
use bashkit::PythonLimits;
fn bash_with_python() -> Bash {
Bash::builder()
.python_with_limits(PythonLimits::default())
.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1")
.build()
}
#[tokio::test]
async fn threat_python_deep_nesting_parser() {
let mut bash = bash_with_python();
let depth = 25;
let code = format!(
"python3 -c \"x = {}1{}\"",
"(".repeat(depth),
",)".repeat(depth)
);
let result = bash.exec(&code).await.unwrap();
assert_eq!(
result.exit_code, 0,
"25 levels of nesting should be within parser depth budget"
);
}
#[tokio::test]
async fn threat_python_nesting_at_guard_boundary() {
let mut bash = bash_with_python();
let depth = 40;
let code = format!(
"python3 -c \"{}x = 1{}\"",
"if True:\n ".repeat(depth),
""
);
let result = bash.exec(&code).await.unwrap();
if result.exit_code != 0 {
assert!(
!result.stderr.is_empty(),
"Should get a parse error, not silent failure"
);
}
}
#[tokio::test]
async fn threat_python_pow_exhaustion() {
let limits = PythonLimits::default().max_memory(1024 * 1024); let mut bash = Bash::builder()
.python_with_limits(limits)
.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1")
.build();
let result = bash
.exec("python3 -c \"x = 2 ** 1000000\ny = 2 ** 1000000\nz = x * y\"")
.await
.unwrap();
assert_ne!(
result.exit_code, 0,
"Large exponentiation chain should be blocked by memory limit"
);
}
#[tokio::test]
async fn threat_python_division_edge_cases() {
let mut bash = bash_with_python();
let result = bash.exec("python3 -c \"x = 1 // 0\"").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("ZeroDivisionError"));
let result = bash.exec("python3 -c \"x = 1 % 0\"").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("ZeroDivisionError"));
}
}
mod nesting_depth_security {
use super::*;
#[tokio::test]
async fn positive_moderate_subshell_nesting() {
let mut bash = Bash::new();
let result = bash.exec("(echo ok)").await.unwrap();
assert_eq!(result.stdout.trim(), "ok");
}
#[tokio::test]
async fn positive_moderate_command_sub_nesting() {
let mut bash = Bash::new();
let result = bash
.exec("echo $(echo $(echo $(echo $(echo nested))))")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "nested");
}
#[tokio::test]
async fn positive_moderate_arithmetic_nesting() {
let mut bash = Bash::new();
let depth = 20;
let script = format!("echo $(({} 42 {}))", "(".repeat(depth), ")".repeat(depth),);
let result = bash.exec(&script).await.unwrap();
assert_eq!(result.stdout.trim(), "42");
}
#[tokio::test]
async fn positive_arithmetic_operators_nested() {
let mut bash = Bash::new();
let result = bash.exec("echo $(( ((((2+3)))) ))").await.unwrap();
assert_eq!(result.stdout.trim(), "5");
}
#[tokio::test]
async fn positive_compound_nesting() {
let mut bash = Bash::builder()
.limits(ExecutionLimits::new().max_commands(1000))
.build();
let script = r#"
if true; then
if true; then
if true; then
if true; then
if true; then
echo deep
fi
fi
fi
fi
fi
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.stdout.trim(), "deep");
}
#[tokio::test]
async fn negative_deep_subshells_blocked() {
let limits = ExecutionLimits::new().max_ast_depth(10);
let mut bash = Bash::builder().limits(limits).build();
let script = format!("{}echo hello{}", "(".repeat(200), ")".repeat(200),);
let result = bash.exec(&script).await;
match result {
Ok(r) => {
assert!(
!r.stdout.contains("hello"),
"200-level nesting with max_ast_depth=10 should not execute inner echo"
);
}
Err(e) => {
let err = e.to_string();
assert!(
err.contains("nesting") || err.contains("depth") || err.contains("fuel"),
"Expected depth error, got: {}",
err
);
}
}
}
#[tokio::test]
async fn negative_deep_if_nesting_blocked() {
let limits = ExecutionLimits::new().max_ast_depth(5);
let mut bash = Bash::builder().limits(limits).build();
let mut script = String::new();
for _ in 0..20 {
script.push_str("if true; then ");
}
script.push_str("echo deep; ");
for _ in 0..20 {
script.push_str("fi; ");
}
let result = bash.exec(&script).await;
assert!(
result.is_err(),
"20-level if with max_ast_depth=5 must fail"
);
}
#[tokio::test]
async fn negative_extreme_arithmetic_nesting_safe() {
let mut bash = Bash::new();
let depth = 1000;
let script = format!("echo $(({} 7 {}))", "(".repeat(depth), ")".repeat(depth),);
let result = bash.exec(&script).await;
if let Ok(r) = result {
assert!(!r.stdout.trim().is_empty(), "should produce output");
}
}
#[tokio::test]
async fn negative_command_sub_inherits_depth_limit() {
let limits = ExecutionLimits::new().max_ast_depth(5).max_commands(1000);
let mut bash = Bash::builder().limits(limits).build();
let inner_depth = 50;
let inner = format!(
"{}echo x{}",
"(".repeat(inner_depth),
")".repeat(inner_depth),
);
let script = format!("echo $({})", inner);
let result = bash.exec(&script).await;
match result {
Ok(r) => {
assert!(
!r.stdout.contains("x") || r.exit_code == 0,
"deep nesting in command sub should not produce 'x'"
);
}
Err(e) => {
let err = e.to_string();
assert!(
err.contains("nesting") || err.contains("depth") || err.contains("fuel"),
"Expected depth error, got: {}",
err
);
}
}
}
#[tokio::test]
async fn negative_command_sub_inherits_fuel_limit() {
let limits = ExecutionLimits::new()
.max_parser_operations(50)
.max_commands(1000);
let mut bash = Bash::builder().limits(limits).build();
let inner_cmds: Vec<&str> = (0..100).map(|_| "true").collect();
let script = format!("echo $({})", inner_cmds.join("; "));
let result = bash.exec(&script).await;
match result {
Ok(r) => {
assert_eq!(
r.stdout.trim(),
"",
"inner parse should fail with limited fuel"
);
}
Err(_) => {
}
}
}
#[tokio::test]
async fn misconfig_huge_ast_depth_still_safe() {
let limits = ExecutionLimits::new()
.max_ast_depth(1_000_000) .max_commands(10_000);
let mut bash = Bash::builder().limits(limits).build();
let mut script = String::new();
for _ in 0..150 {
script.push_str("if true; then ");
}
script.push_str("echo deep; ");
for _ in 0..150 {
script.push_str("fi; ");
}
let result = bash.exec(&script).await;
match result {
Ok(r) => {
assert!(
!r.stdout.contains("deep"),
"150-level nesting should be blocked by hard cap"
);
}
Err(e) => {
let err = e.to_string();
assert!(
err.contains("fuel") || err.contains("nesting") || err.contains("depth"),
"Expected fuel/depth error, got: {}",
err
);
}
}
}
#[tokio::test]
async fn misconfig_tiny_ast_depth_rejects_compounds() {
let limits = ExecutionLimits::new().max_ast_depth(1);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("if true; then if true; then echo x; fi; fi")
.await;
assert!(
result.is_err(),
"max_ast_depth=1 should reject deeply nested compound commands"
);
}
#[tokio::test]
async fn misconfig_huge_fuel_still_bounded_by_input() {
let limits = ExecutionLimits::new()
.max_parser_operations(1_000_000_000)
.max_input_bytes(1000); let mut bash = Bash::builder().limits(limits).build();
let script = "echo ".to_string() + &"x".repeat(2000);
let result = bash.exec(&script).await;
assert!(
result.is_err(),
"input exceeding max_input_bytes must be rejected"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("too large") || err.contains("input"),
"Expected input size error, got: {}",
err
);
}
#[tokio::test]
async fn misconfig_long_timeout_still_command_limited() {
let limits = ExecutionLimits::new()
.timeout(std::time::Duration::from_secs(3600)) .max_commands(10);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("true; true; true; true; true; true; true; true; true; true; true; true")
.await;
assert!(
result.is_err(),
"command limit should trigger before 1hr timeout"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("command") && err.contains("exceeded"),
"Expected command limit error, got: {}",
err
);
}
#[tokio::test]
async fn regression_monty_112_arithmetic_parens() {
let mut bash = Bash::new();
let depth = 3000;
let script = format!("echo $(({} 1 {}))", "(".repeat(depth), ")".repeat(depth),);
let result = bash.exec(&script).await;
assert!(result.is_ok() || result.is_err(), "must not crash");
}
#[tokio::test]
async fn regression_monty_112_subshell_nesting() {
let mut bash = Bash::new();
let depth = 500;
let script = format!("{}echo hello{}", "(".repeat(depth), ")".repeat(depth),);
let result = bash.exec(&script).await;
match result {
Ok(r) => {
assert!(
!r.stdout.contains("hello"),
"500-level subshells should not execute inner echo"
);
}
Err(e) => {
let err = e.to_string();
assert!(
err.contains("nesting") || err.contains("depth") || err.contains("fuel"),
"Expected depth/fuel error, got: {}",
err
);
}
}
}
#[tokio::test]
async fn regression_mixed_nesting_safe() {
let limits = ExecutionLimits::new().max_ast_depth(10).max_commands(1000);
let mut bash = Bash::builder().limits(limits).build();
let outer = "(((((";
let outer_close = ")))))";
let inner_depth = 50;
let inner = format!(
"{}echo x{}",
"(".repeat(inner_depth),
")".repeat(inner_depth),
);
let script = format!("{}echo $({}){}", outer, inner, outer_close);
let result = bash.exec(&script).await;
match result {
Ok(r) => {
assert!(
!r.stdout.contains("x"),
"inner deep nesting should not succeed"
);
}
Err(e) => {
let err = e.to_string();
assert!(
err.contains("nesting")
|| err.contains("depth")
|| err.contains("fuel")
|| err.contains("unexpected token"),
"Expected depth or syntax error, got: {}",
err
);
}
}
}
#[tokio::test]
async fn negative_chained_command_subs_share_budget() {
let limits = ExecutionLimits::new().max_ast_depth(15).max_commands(1000);
let mut bash = Bash::builder().limits(limits).build();
let script =
"echo $( ( ( ( ( echo $( ( ( ( ( echo $( ( ( ( ( echo ok ) ) ) ) ) ) ) ) ) ) ) ) ) ) )";
let result = bash.exec(script).await;
match result {
Ok(_) | Err(_) => {} }
}
}
mod builtin_parser_depth {
use super::*;
#[tokio::test]
async fn threat_awk_deep_paren_nesting_safe() {
let mut bash = Bash::new();
let depth = 200;
let open = "(".repeat(depth);
let close = ")".repeat(depth);
let script = format!(r#"echo "1" | awk '{{print {open}1{close}}}'"#);
let result = bash.exec(&script).await;
if let Ok(r) = result {
assert!(
r.exit_code != 0 || r.stderr.contains("nesting"),
"deep awk nesting should fail gracefully"
);
}
}
#[tokio::test]
async fn threat_awk_deep_unary_nesting_safe() {
let mut bash = Bash::new();
let depth = 200;
let prefix = "- ".repeat(depth);
let script = format!(r#"echo "1" | awk '{{print {prefix}1}}'"#);
let result = bash.exec(&script).await;
if let Ok(r) = result {
assert!(
r.exit_code != 0 || r.stderr.contains("nesting"),
"deep awk unary nesting should fail gracefully"
);
}
}
#[cfg(feature = "jq")]
#[tokio::test]
async fn threat_jq_deep_json_nesting_safe() {
let mut bash = Bash::new();
let depth = 200;
let open = "[".repeat(depth);
let close = "]".repeat(depth);
let json = format!("{open}1{close}");
let script = format!(r#"echo '{json}' | jq '.'"#);
let result = bash.exec(&script).await;
if let Ok(r) = result {
assert!(
r.exit_code != 0 || r.stderr.contains("nesting"),
"deep JSON nesting should fail gracefully"
);
}
}
#[tokio::test]
async fn threat_awk_moderate_nesting_works() {
let mut bash = Bash::new();
let script = r#"echo "1" | awk '{print (((((1 + 2)))))}'"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.stdout.trim(), "3");
}
#[cfg(feature = "jq")]
#[tokio::test]
async fn threat_jq_moderate_nesting_works() {
let mut bash = Bash::new();
let script = r#"echo '[[[[1]]]]' | jq '.[0][0][0][0]'"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.stdout.trim(), "1");
}
}
mod nested_loop_security {
use bashkit::{Bash, ExecutionLimits};
#[tokio::test]
async fn threat_nested_loop_multiplication_blocked() {
let limits = ExecutionLimits::new()
.max_loop_iterations(1000)
.max_total_loop_iterations(5000)
.max_commands(100_000);
let mut bash = Bash::builder().limits(limits).build();
let script = r#"
count=0
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100; do
for j in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100; do
count=$((count + 1))
done
done
echo $count
"#;
let result = bash.exec(script).await;
assert!(
result.is_err(),
"Nested 100x100 loops should hit total limit of 5000"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("loop iterations exceeded"),
"Expected loop limit error, got: {}",
err
);
}
#[tokio::test]
async fn threat_sequential_loops_within_budget() {
let limits = ExecutionLimits::new()
.max_loop_iterations(100)
.max_total_loop_iterations(200)
.max_commands(100_000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("for i in 1 2 3 4 5; do echo $i; done; for j in 1 2 3 4 5; do echo $j; done")
.await
.unwrap();
assert_eq!(result.exit_code, 0);
}
}
mod path_validation_security {
use bashkit::{Bash, FileSystem, FsLimits, InMemoryFs};
use std::path::Path;
use std::sync::Arc;
#[tokio::test]
async fn threat_deep_directory_nesting_blocked() {
let limits = FsLimits::new().max_path_depth(5);
let fs = Arc::new(InMemoryFs::with_limits(limits));
let mut bash = Bash::builder().fs(fs).build();
let result = bash.exec("mkdir -p /a/b/c/d/e").await.unwrap();
assert_eq!(result.exit_code, 0);
let result = bash.exec("mkdir -p /a/b/c/d/e/f").await.unwrap();
assert_ne!(result.exit_code, 0);
assert!(result.stderr.contains("path too deep"));
}
#[tokio::test]
async fn threat_deep_path_write_blocked() {
let limits = FsLimits::new().max_path_depth(3);
let fs = Arc::new(InMemoryFs::with_limits(limits));
fs.mkdir(Path::new("/a/b"), true).await.unwrap();
fs.write_file(Path::new("/a/b/c"), b"ok").await.unwrap();
let result = fs.write_file(Path::new("/a/b/c/d"), b"fail").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("path too deep"));
}
#[tokio::test]
async fn threat_long_filename_blocked() {
let limits = FsLimits::new().max_filename_length(20);
let fs = Arc::new(InMemoryFs::with_limits(limits));
let mut bash = Bash::builder().fs(fs).build();
let result = bash.exec("echo ok > /tmp/short.txt").await.unwrap();
assert_eq!(result.exit_code, 0);
let long_name = "a".repeat(21);
let result = bash
.exec(&format!("echo fail > /tmp/{}", long_name))
.await
.unwrap();
assert_ne!(result.exit_code, 0);
assert!(result.stderr.contains("filename too long"));
}
#[tokio::test]
async fn threat_long_path_blocked() {
let limits = FsLimits::new().max_path_length(30);
let fs = Arc::new(InMemoryFs::with_limits(limits));
fs.write_file(Path::new("/tmp/ok.txt"), b"ok")
.await
.unwrap();
let long_path = format!("/tmp/{}", "x".repeat(30));
let result = fs.write_file(Path::new(&long_path), b"fail").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("path too long"));
}
#[tokio::test]
async fn threat_control_char_filename_rejected() {
let fs = InMemoryFs::new();
let result = fs.write_file(Path::new("/tmp/file\nname"), b"bad").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unsafe character"));
let result = fs.write_file(Path::new("/tmp/file\tname"), b"bad").await;
assert!(result.is_err());
}
#[tokio::test]
async fn threat_bidi_override_filename_rejected() {
let fs = InMemoryFs::new();
let result = fs
.write_file(Path::new("/tmp/file\u{202E}name"), b"bad")
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("bidi override"), "Error: {}", err);
}
#[tokio::test]
async fn threat_normal_unicode_filename_ok() {
let fs = InMemoryFs::new();
fs.write_file(Path::new("/tmp/café.txt"), b"ok")
.await
.unwrap();
fs.write_file(Path::new("/tmp/文件.txt"), b"ok")
.await
.unwrap();
}
#[tokio::test]
async fn threat_deep_nesting_script_blocked() {
let limits = FsLimits::new().max_path_depth(5);
let fs = Arc::new(InMemoryFs::with_limits(limits));
let mut bash = Bash::builder().fs(fs).build();
let result = bash.exec("mkdir -p /a/b/c/d/e/f").await.unwrap();
assert_ne!(
result.exit_code, 0,
"mkdir -p for depth 6 should fail with max_path_depth=5, stderr: {}",
result.stderr
);
}
}
mod archive_security {
use super::*;
use bashkit::{FsLimits, InMemoryFs};
use std::sync::Arc;
#[tokio::test]
async fn threat_gzip_bomb_blocked() {
let limits = FsLimits::new().max_file_size(1_000);
let fs = Arc::new(InMemoryFs::with_limits(limits));
let mut bash = Bash::builder().fs(fs).build();
let result = bash
.exec("echo 'small data' > /tmp/test.txt && gzip /tmp/test.txt")
.await
.unwrap();
assert_eq!(result.exit_code, 0, "gzip of small file should work");
let result = bash.exec("gunzip /tmp/test.txt.gz").await.unwrap();
assert_eq!(
result.exit_code, 0,
"gunzip of small file should work: {}",
result.stderr
);
}
#[tokio::test]
async fn threat_tar_bomb_many_files_blocked() {
let limits = FsLimits::new().max_file_count(30);
let fs = Arc::new(InMemoryFs::with_limits(limits));
let mut bash = Bash::builder().fs(fs).build();
let result = bash
.exec(
r#"
mkdir -p /tmp/src
for i in $(seq 1 10); do echo "file $i" > /tmp/src/f$i.txt; done
tar -cf /tmp/archive.tar -C /tmp/src .
mkdir -p /tmp/dst
"#,
)
.await
.unwrap();
assert_eq!(
result.exit_code, 0,
"Creating archive should work: {}",
result.stderr
);
let result = bash
.exec("tar -xf /tmp/archive.tar -C /tmp/dst")
.await
.unwrap();
let _ = result;
}
#[tokio::test]
async fn threat_tar_path_traversal_blocked() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"
mkdir -p /tmp/src
echo "normal" > /tmp/src/safe.txt
cd /tmp/src && tar -cf /tmp/test.tar safe.txt
"#,
)
.await
.unwrap();
assert_eq!(result.exit_code, 0, "tar create: {}", result.stderr);
let result = bash
.exec(
r#"
mkdir -p /tmp/dst
cd /tmp/dst && tar -xf /tmp/test.tar
cat /tmp/dst/safe.txt
"#,
)
.await
.unwrap();
assert_eq!(result.exit_code, 0, "tar extract: {}", result.stderr);
assert!(result.stdout.contains("normal"));
let result = bash.exec("cat /etc/passwd").await.unwrap();
assert_ne!(result.exit_code, 0, "VFS should not have /etc/passwd");
}
#[tokio::test]
async fn threat_tar_large_file_blocked() {
let limits = FsLimits::new().max_file_size(10_000);
let fs = Arc::new(InMemoryFs::with_limits(limits));
let mut bash = Bash::builder().fs(fs).build();
let result = bash
.exec(
r#"
mkdir -p /tmp/src
echo "small" > /tmp/src/ok.txt
cd /tmp/src && tar -cf /tmp/test.tar ok.txt
"#,
)
.await
.unwrap();
assert_eq!(
result.exit_code, 0,
"Archiving should work: {}",
result.stderr
);
let result = bash
.exec("mkdir -p /tmp/dst && cd /tmp/dst && tar -xf /tmp/test.tar")
.await
.unwrap();
assert_eq!(
result.exit_code, 0,
"Extraction within limits: {}",
result.stderr
);
}
#[tokio::test]
async fn threat_gzip_respects_fs_limits() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"
echo "test data for compression" > /tmp/data.txt
gzip /tmp/data.txt
gunzip /tmp/data.txt.gz
cat /tmp/data.txt
"#,
)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert!(
result.stdout.contains("test data for compression"),
"Roundtrip should preserve data: {}",
result.stdout
);
}
}
mod variable_namespace_injection {
use bashkit::Bash;
async fn exec(script: &str) -> bashkit::ExecResult {
let mut bash = Bash::builder().build();
bash.exec(script).await.unwrap()
}
const INTERNAL_PREFIXES: &[&str] = &[
"_NAMEREF_x",
"_READONLY_x",
"_UPPER_x",
"_LOWER_x",
"_ARRAY_READ_x",
"_EVAL_CMD",
"_SHIFT_COUNT",
"_SET_POSITIONAL",
];
#[tokio::test]
async fn tm_inj_009_local_rejects_internal_prefixes() {
for prefix in INTERNAL_PREFIXES {
let result = exec(&format!(
"myfn() {{ local {prefix}=injected; echo ${{{prefix}:-blocked}}; }}; myfn"
))
.await;
assert!(
result.stdout.contains("blocked"),
"local should block {prefix}: got {:?}",
result.stdout
);
}
}
#[tokio::test]
async fn tm_inj_009_local_allows_normal_vars() {
let result = exec("myfn() { local MY_VAR=hello; echo $MY_VAR; }; myfn").await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello"));
}
#[tokio::test]
async fn tm_inj_009_printf_v_rejects_internal_prefixes() {
for prefix in INTERNAL_PREFIXES {
let result = exec(&format!(
"printf -v {prefix} injected; echo ${{{prefix}:-blocked}}"
))
.await;
assert!(
result.stdout.contains("blocked"),
"printf -v should block {prefix}: got {:?}",
result.stdout
);
}
}
#[tokio::test]
async fn tm_inj_009_printf_v_allows_normal_vars() {
let result = exec("printf -v MY_VAR 'hello'; echo $MY_VAR").await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello"));
}
#[tokio::test]
async fn tm_inj_009_read_rejects_internal_prefixes() {
for prefix in INTERNAL_PREFIXES {
let result = exec(&format!(
"echo injected | read {prefix}; echo ${{{prefix}:-blocked}}"
))
.await;
assert!(
result.stdout.contains("blocked"),
"read should block {prefix}: got {:?}",
result.stdout
);
}
}
#[tokio::test]
async fn tm_inj_009_read_allows_normal_vars() {
let result = exec("echo hello | read MY_VAR; echo $MY_VAR").await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello"));
}
#[tokio::test]
async fn tm_inj_009_read_array_rejects_internal_prefixes() {
let result = exec("echo 'a b c' | read -a _NAMEREF_x; echo ${_NAMEREF_x:-blocked}").await;
assert!(
result.stdout.contains("blocked"),
"read -a should block _NAMEREF_x: got {:?}",
result.stdout
);
}
#[tokio::test]
async fn tm_inj_018_dotenv_rejects_internal_prefixes() {
let result = exec(
r#"
echo '_NAMEREF_x=injected' > /tmp/.env
echo '_READONLY_y=injected' >> /tmp/.env
echo 'NORMAL=ok' >> /tmp/.env
dotenv /tmp/.env
echo ${_NAMEREF_x:-blocked1} ${_READONLY_y:-blocked2} $NORMAL
"#,
)
.await;
assert!(
result.stdout.contains("blocked1"),
"dotenv should block _NAMEREF_x: got {:?}",
result.stdout
);
assert!(
result.stdout.contains("blocked2"),
"dotenv should block _READONLY_y: got {:?}",
result.stdout
);
assert!(
result.stdout.contains("ok"),
"dotenv should allow normal vars: got {:?}",
result.stdout
);
}
#[tokio::test]
async fn tm_inj_011_cyclic_nameref_two_node_does_not_hang() {
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
exec(
r#"
declare -n a=b
declare -n b=a
echo "result=${a:-cycle_broken}"
echo "exit=$?"
"#,
),
)
.await
.expect("cyclic nameref must not hang");
assert!(
result.stdout.contains("result=cycle_broken"),
"cycle resolved to a value instead of breaking: {:?}",
result.stdout
);
assert!(
result.stdout.contains("exit=0"),
"cycle handling caused a non-zero exit: {:?}",
result.stdout
);
}
#[tokio::test]
async fn tm_inj_011_cyclic_nameref_three_node_does_not_hang() {
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
exec(
r#"
declare -n a=b
declare -n b=c
declare -n c=a
echo "result=${a:-cycle_broken}"
"#,
),
)
.await
.expect("cyclic nameref must not hang");
assert!(
result.stdout.contains("result=cycle_broken"),
"3-node cycle resolved to a value: {:?}",
result.stdout
);
}
#[tokio::test]
async fn tm_inj_011_self_referencing_nameref_does_not_hang() {
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
exec(
r#"
declare -n a=a
echo "result=${a:-cycle_broken}"
"#,
),
)
.await
.expect("self-ref nameref must not hang");
assert!(
result.stdout.contains("result=cycle_broken"),
"self-ref resolved to a value: {:?}",
result.stdout
);
}
#[tokio::test]
async fn tm_dos_090_malformed_nameref_array_target_does_not_panic() {
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
exec(
r#"
arr=(zero one)
declare -n elem='arr[1]'
echo "elem=${elem[0]}"
declare -n ref='['
echo "before"
echo "${ref[0]}"
declare -n ref='a['
echo "${ref[0]}"
echo "after"
"#,
),
)
.await
.expect("malformed nameref target must not panic or hang");
assert_eq!(result.exit_code, 0);
assert!(
result.stdout.contains("elem=one")
&& result.stdout.contains("before")
&& result.stdout.contains("after"),
"malformed nameref target should expand safely: {:?}",
result.stdout
);
}
#[tokio::test]
async fn tm_inj_cross_builtin_no_state_corruption() {
let result = exec(
r#"
myfn() { local _READONLY_FOO=1; }
myfn
FOO=bar
echo $FOO
"#,
)
.await;
assert_eq!(result.exit_code, 0);
assert!(
result.stdout.contains("bar"),
"Cross-builtin injection should not affect state: got {:?}",
result.stdout
);
}
}
mod overlay_limit_accounting {
use super::*;
fn make_lower() -> Arc<InMemoryFs> {
Arc::new(InMemoryFs::new())
}
#[tokio::test]
async fn tm_dos_035_combined_byte_limit() {
let lower = make_lower();
lower
.write_file(Path::new("/tmp/big.txt"), &[b'A'; 80])
.await
.unwrap();
let limits = FsLimits::new().max_total_bytes(100);
let overlay = OverlayFs::with_limits(lower, limits);
let result = overlay
.write_file(Path::new("/tmp/extra.txt"), &[b'B'; 30])
.await;
assert!(
result.is_err(),
"TM-DOS-035: write should fail when combined usage exceeds limit"
);
}
#[tokio::test]
async fn tm_dos_035_combined_file_count_limit() {
let lower = make_lower();
lower
.write_file(Path::new("/tmp/existing.txt"), b"data")
.await
.unwrap();
let temp = OverlayFs::new(lower.clone());
let base_count = temp.usage().file_count;
let limits = FsLimits::new().max_file_count(base_count + 1);
let overlay = OverlayFs::with_limits(lower, limits);
overlay
.write_file(Path::new("/tmp/new1.txt"), b"ok")
.await
.unwrap();
let result = overlay
.write_file(Path::new("/tmp/new2.txt"), b"fail")
.await;
assert!(
result.is_err(),
"TM-DOS-035: file count limit must include lower layer"
);
}
#[tokio::test]
async fn tm_dos_036_no_double_count_on_override() {
let lower = make_lower();
lower
.write_file(Path::new("/tmp/file.txt"), &[b'L'; 100])
.await
.unwrap();
let overlay = OverlayFs::new(lower);
let before = overlay.usage();
overlay
.write_file(Path::new("/tmp/file.txt"), &[b'U'; 50])
.await
.unwrap();
let after = overlay.usage();
assert_eq!(
after.file_count, before.file_count,
"TM-DOS-036: overridden file should not increase count"
);
assert_eq!(
after.total_bytes,
before.total_bytes - 50,
"TM-DOS-036: bytes should reflect upper size, not sum"
);
}
#[tokio::test]
async fn tm_dos_036_whiteout_deducts_usage() {
let lower = make_lower();
lower
.write_file(Path::new("/tmp/gone.txt"), &[b'X'; 200])
.await
.unwrap();
let overlay = OverlayFs::new(lower);
let before = overlay.usage();
overlay
.remove(Path::new("/tmp/gone.txt"), false)
.await
.unwrap();
let after = overlay.usage();
assert_eq!(
after.total_bytes,
before.total_bytes - 200,
"TM-DOS-036: whited-out file bytes should be deducted"
);
assert_eq!(
after.file_count,
before.file_count - 1,
"TM-DOS-036: whited-out file should be deducted from count"
);
}
#[tokio::test]
async fn tm_dos_037_chmod_file_cow_checks_limits() {
let lower = make_lower();
lower
.write_file(Path::new("/tmp/big.txt"), &[b'X'; 5000])
.await
.unwrap();
let limits = FsLimits::new().max_total_bytes(1000);
let overlay = OverlayFs::with_limits(lower, limits);
let result = overlay.chmod(Path::new("/tmp/big.txt"), 0o755).await;
assert!(
result.is_err(),
"TM-DOS-037: chmod CoW should fail when content exceeds write limits"
);
}
#[tokio::test]
async fn tm_dos_037_chmod_dir_cow_checks_limits() {
let lower = make_lower();
for i in 0..10 {
lower
.mkdir(Path::new(&format!("/d{}", i)), true)
.await
.unwrap();
}
let temp = OverlayFs::new(lower.clone());
let base_dirs = temp.usage().dir_count;
let limits = FsLimits::new().max_dir_count(base_dirs);
let overlay = OverlayFs::with_limits(lower, limits);
let result = overlay.chmod(Path::new("/d0"), 0o755).await;
assert!(
result.is_err(),
"TM-DOS-037: chmod dir CoW should fail when dir count at limit"
);
}
#[tokio::test]
async fn tm_dos_037_mkdir_checks_dir_limits() {
let lower = make_lower();
let temp = OverlayFs::new(lower.clone());
let base_dirs = temp.usage().dir_count;
let limits = FsLimits::new().max_dir_count(base_dirs + 1);
let overlay = OverlayFs::with_limits(lower, limits);
overlay.mkdir(Path::new("/newdir"), false).await.unwrap();
let result = overlay.mkdir(Path::new("/newdir2"), false).await;
assert!(
result.is_err(),
"TM-DOS-037: mkdir should fail when dir count exceeds limit"
);
}
#[tokio::test]
async fn tm_dos_037_recursive_mkdir_counts_all_new_dirs() {
let lower = make_lower();
let temp = OverlayFs::new(lower.clone());
let base_dirs = temp.usage().dir_count;
let limits = FsLimits::new().max_dir_count(base_dirs + 1);
let overlay = OverlayFs::with_limits(lower, limits);
let result = overlay.mkdir(Path::new("/a/b/c"), true).await;
assert!(
result.is_err(),
"TM-DOS-037: recursive mkdir should fail when total new dirs exceed limit"
);
}
#[tokio::test]
async fn tm_dos_038_recursive_delete_hides_all_children() {
let lower = make_lower();
lower.mkdir(Path::new("/data"), true).await.unwrap();
lower
.write_file(Path::new("/data/a.txt"), b"aaa")
.await
.unwrap();
lower
.write_file(Path::new("/data/b.txt"), b"bbb")
.await
.unwrap();
lower.mkdir(Path::new("/data/sub"), true).await.unwrap();
lower
.write_file(Path::new("/data/sub/c.txt"), b"ccc")
.await
.unwrap();
let overlay = OverlayFs::new(lower);
overlay.remove(Path::new("/data"), true).await.unwrap();
assert!(
!overlay.exists(Path::new("/data")).await.unwrap(),
"TM-DOS-038: directory itself should be hidden"
);
assert!(
!overlay.exists(Path::new("/data/a.txt")).await.unwrap(),
"TM-DOS-038: child file should be hidden"
);
assert!(
!overlay.exists(Path::new("/data/sub/c.txt")).await.unwrap(),
"TM-DOS-038: nested child should be hidden"
);
assert!(overlay.read_file(Path::new("/data/a.txt")).await.is_err());
assert!(
overlay
.read_file(Path::new("/data/sub/c.txt"))
.await
.is_err()
);
}
#[tokio::test]
async fn tm_dos_038_recursive_delete_deducts_all_bytes() {
let lower = make_lower();
lower.mkdir(Path::new("/stuff"), true).await.unwrap();
lower
.write_file(Path::new("/stuff/x.txt"), &[b'X'; 100])
.await
.unwrap();
lower
.write_file(Path::new("/stuff/y.txt"), &[b'Y'; 200])
.await
.unwrap();
lower.mkdir(Path::new("/stuff/deep"), true).await.unwrap();
lower
.write_file(Path::new("/stuff/deep/z.txt"), &[b'Z'; 50])
.await
.unwrap();
let overlay = OverlayFs::new(lower);
let before = overlay.usage();
overlay.remove(Path::new("/stuff"), true).await.unwrap();
let after = overlay.usage();
assert_eq!(
after.total_bytes,
before.total_bytes - 350,
"TM-DOS-038: should deduct all child file bytes (100+200+50)"
);
assert_eq!(
after.file_count,
before.file_count - 3,
"TM-DOS-038: should deduct all child file counts"
);
}
#[tokio::test]
async fn tm_dos_boundary_exact_limit() {
let lower = make_lower();
lower
.write_file(Path::new("/tmp/lower.txt"), &[b'A'; 50])
.await
.unwrap();
let limits = FsLimits::new().max_total_bytes(100);
let overlay = OverlayFs::with_limits(lower, limits);
overlay
.write_file(Path::new("/tmp/upper.txt"), &[b'B'; 49])
.await
.unwrap();
let result = overlay
.write_file(Path::new("/tmp/over.txt"), &[b'C'; 2])
.await;
assert!(result.is_err(), "boundary: 99 + 2 = 101 > 100 should fail");
}
#[tokio::test]
async fn tm_dos_cow_accumulation_via_chmod() {
let lower = make_lower();
for i in 0..5 {
lower
.write_file(Path::new(&format!("/tmp/f{}.txt", i)), &[b'A'; 100])
.await
.unwrap();
}
let temp = OverlayFs::new(lower.clone());
let base = temp.usage().total_bytes;
let limits = FsLimits::new().max_total_bytes(base + 500);
let overlay = OverlayFs::with_limits(lower, limits);
let before = overlay.usage();
for i in 0..5 {
let path = format!("/tmp/f{}.txt", i);
overlay.chmod(Path::new(&path), 0o755).await.unwrap();
}
let after = overlay.usage();
assert_eq!(
after.total_bytes, before.total_bytes,
"CoW chmod should not change total bytes"
);
assert_eq!(
after.file_count, before.file_count,
"CoW chmod should not change file count"
);
}
}
mod yaml_template_depth {
use super::*;
fn bash() -> Bash {
Bash::builder().build()
}
#[tokio::test]
async fn tm_dos_051_yaml_depth_bomb_maps() {
let mut bash = bash();
let mut yaml = String::new();
for i in 0..200 {
let indent = " ".repeat(i);
yaml.push_str(&format!("{indent}level{i}:\n"));
}
let last_indent = " ".repeat(200);
yaml.push_str(&format!("{last_indent}value: deep\n"));
let cmd = format!("yaml get level0.level1.level2 - <<'YAML_EOF'\n{yaml}YAML_EOF");
let result = bash.exec(&cmd).await.unwrap();
let output = format!("{}{}", result.stdout, result.stderr);
assert!(
output.contains("depth exceeded") || result.exit_code != 0 || output.contains("ERROR"),
"TM-DOS-051: deeply nested YAML should produce clean error, got: stdout={:?} stderr={:?}",
result.stdout,
result.stderr
);
}
#[tokio::test]
async fn tm_dos_051_yaml_depth_bomb_lists() {
let mut bash = bash();
let mut yaml = String::new();
for i in 0..200 {
let indent = " ".repeat(i);
yaml.push_str(&format!("{indent}-\n"));
}
let last_indent = " ".repeat(200);
yaml.push_str(&format!("{last_indent}- leaf\n"));
let cmd = format!("yaml get . - <<'YAML_EOF'\n{yaml}YAML_EOF");
let result = bash.exec(&cmd).await.unwrap();
let output = format!("{}{}", result.stdout, result.stderr);
assert!(
output.contains("depth exceeded") || result.exit_code != 0 || output.contains("ERROR"),
"TM-DOS-051: deeply nested YAML list should produce clean error"
);
}
#[tokio::test]
async fn tm_dos_051_yaml_normal_nesting_works() {
let mut bash = bash();
let script = r#"
cat > /tmp/test.yaml << 'EOF'
a:
b:
c:
d:
e: deep_value
EOF
yaml get -r a.b.c.d.e /tmp/test.yaml
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(
result.exit_code, 0,
"yaml get should succeed: stderr={:?} stdout={:?}",
result.stderr, result.stdout
);
assert!(
result.stdout.trim() == "deep_value",
"Normal 5-level nesting should work: got {:?}",
result.stdout
);
}
#[tokio::test]
async fn tm_dos_052_template_if_depth_bomb() {
let mut bash = bash();
let mut template = String::new();
for _ in 0..200 {
template.push_str("{{#if x}}");
}
template.push_str("deep");
for _ in 0..200 {
template.push_str("{{/if}}");
}
let script = format!(
r#"echo '{template}' > /tmp/tpl.txt
echo '{{"x": true}}' > /tmp/data.json
template render /tmp/tpl.txt -d /tmp/data.json"#
);
let result = bash.exec(&script).await.unwrap();
let output = format!("{}{}", result.stdout, result.stderr);
assert!(
output.contains("depth exceeded") || result.exit_code != 0,
"TM-DOS-052: deeply nested #if should produce clean error, got: stdout={:?} stderr={:?}",
result.stdout,
result.stderr
);
}
#[tokio::test]
async fn tm_dos_052_template_normal_nesting_works() {
let mut bash = bash();
let script = r#"
cat > /tmp/tpl.txt << 'TPLEOF'
{{#if x}}hello from template{{/if}}
TPLEOF
cat > /tmp/data.json << 'JSONEOF'
{"x": true}
JSONEOF
template render /tmp/tpl.txt -d /tmp/data.json
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(
result.exit_code, 0,
"template render should succeed: stderr={:?} stdout={:?}",
result.stderr, result.stdout
);
assert!(
result.stdout.contains("hello from template"),
"Normal template should work: got {:?}",
result.stdout
);
}
}
mod session_limits {
use super::*;
#[tokio::test]
async fn tm_dos_059_cumulative_command_limit() {
let session = SessionLimits::new()
.max_total_commands(15)
.max_exec_calls(100);
let limits = ExecutionLimits::new().max_commands(10_000);
let mut bash = Bash::builder()
.limits(limits)
.session_limits(session)
.build();
let script = "echo 1; echo 2; echo 3; echo 4; echo 5";
let r1 = bash.exec(script).await.unwrap();
assert_eq!(r1.exit_code, 0, "first batch should succeed");
let r2 = bash.exec(script).await.unwrap();
assert_eq!(r2.exit_code, 0, "second batch should succeed");
let mut hit_limit = false;
for _ in 0..5 {
match bash.exec(script).await {
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("session"),
"error should mention session: {msg}"
);
hit_limit = true;
break;
}
Ok(r) if r.exit_code != 0 => {
hit_limit = true;
break;
}
Ok(_) => {} }
}
assert!(hit_limit, "should eventually hit session command limit");
}
#[tokio::test]
async fn tm_dos_059_exec_call_count_limit() {
let session = SessionLimits::new()
.max_exec_calls(3)
.max_total_commands(u64::MAX);
let limits = ExecutionLimits::new();
let mut bash = Bash::builder()
.limits(limits)
.session_limits(session)
.build();
for i in 0..3 {
let r = bash.exec("echo ok").await.unwrap();
assert_eq!(r.exit_code, 0, "exec call {} should succeed", i + 1);
}
let r = bash.exec("echo should_fail").await;
assert!(
r.is_err(),
"4th exec call should fail due to session exec limit"
);
let msg = r.unwrap_err().to_string();
assert!(
msg.contains("session") && msg.contains("exec"),
"error should mention session exec limit: {msg}"
);
}
#[tokio::test]
async fn tm_dos_059_parse_errors_count_toward_exec_call_limit() {
let session = SessionLimits::new()
.max_exec_calls(1)
.max_total_commands(u64::MAX);
let mut bash = Bash::builder().session_limits(session).build();
let parse_err = bash.exec("if").await;
assert!(parse_err.is_err(), "malformed script should fail parsing");
let limit_err = bash.exec("echo should_fail").await;
assert!(
limit_err.is_err(),
"second exec() should fail because parse errors consume exec budget"
);
let msg = limit_err.unwrap_err().to_string();
assert!(
msg.contains("session") && msg.contains("exec"),
"error should mention session exec limit: {msg}"
);
}
#[tokio::test]
async fn tm_dos_059_counter_persistence() {
let session = SessionLimits::new()
.max_total_commands(20)
.max_exec_calls(100);
let limits = ExecutionLimits::new().max_commands(10_000);
let mut bash = Bash::builder()
.limits(limits)
.session_limits(session)
.build();
let mut hit_limit = false;
for i in 0..20 {
match bash.exec("echo a; echo b; echo c").await {
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("session"),
"error should mention session: {msg}"
);
hit_limit = true;
assert!(
i >= 3,
"should succeed for at least a few calls before limit"
);
break;
}
Ok(r) if r.exit_code != 0 => {
hit_limit = true;
break;
}
Ok(_) => {}
}
}
assert!(hit_limit, "should eventually hit session command limit");
}
#[tokio::test]
async fn tm_dos_059_per_exec_limits_still_work() {
let session = SessionLimits::unlimited();
let limits = ExecutionLimits::new().max_commands(3);
let mut bash = Bash::builder()
.limits(limits)
.session_limits(session)
.build();
let r = bash.exec("echo 1; echo 2; echo 3; echo 4; echo 5").await;
match r {
Ok(result) => assert_ne!(
result.exit_code, 0,
"per-exec command limit should still work"
),
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("command") || msg.contains("limit"),
"error should be about command limit: {msg}"
);
}
}
}
#[tokio::test]
async fn tm_dos_059_builder_api() {
let session = SessionLimits::new()
.max_total_commands(50)
.max_exec_calls(10);
let mut bash = Bash::builder().session_limits(session).build();
let r = bash.exec("echo hello").await.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.stdout.contains("hello"));
}
#[tokio::test]
async fn tm_dos_059_default_safety() {
let defaults = SessionLimits::default();
assert!(
defaults.max_total_commands > 0,
"default max_total_commands should be non-zero"
);
assert!(
defaults.max_exec_calls > 0,
"default max_exec_calls should be non-zero"
);
assert!(
defaults.max_total_commands < u64::MAX,
"default max_total_commands should be finite"
);
assert!(
defaults.max_exec_calls < u64::MAX,
"default max_exec_calls should be finite"
);
assert_eq!(defaults.max_total_commands, 100_000);
assert_eq!(defaults.max_exec_calls, 1_000);
}
#[tokio::test]
async fn tm_dos_059_unlimited() {
let unlimited = SessionLimits::unlimited();
assert_eq!(unlimited.max_total_commands, u64::MAX);
assert_eq!(unlimited.max_exec_calls, u64::MAX);
}
#[test]
fn tm_dos_059_reset_preserves_session_counters() {
use bashkit::ExecutionCounters;
let limits = ExecutionLimits::new().max_commands(10_000);
let mut counters = ExecutionCounters::new();
for _ in 0..5 {
counters.tick_command(&limits).unwrap();
}
counters.tick_exec_call();
assert_eq!(counters.session_commands, 5);
assert_eq!(counters.session_exec_calls, 1);
counters.reset_for_execution();
assert_eq!(
counters.session_commands, 5,
"session_commands must persist"
);
assert_eq!(
counters.session_exec_calls, 1,
"session_exec_calls must persist"
);
assert_eq!(counters.commands, 0);
}
}
mod memory_limits {
use super::*;
#[tokio::test]
async fn tm_dos_060_variable_count_bomb() {
let mem = MemoryLimits::new().max_variable_count(50);
let limits = ExecutionLimits::new()
.max_commands(10_000)
.max_loop_iterations(10_000);
let mut bash = Bash::builder()
.limits(limits)
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
for i in $(seq 1 100); do
eval "var_$i=hello"
done
echo "done"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn tm_dos_060_variable_size_bomb() {
let mem = MemoryLimits::new().max_total_variable_bytes(1000);
let limits = ExecutionLimits::new();
let mut bash = Bash::builder()
.limits(limits)
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
big=$(printf '%0500s' | tr ' ' 'A')
echo ${#big}
big2=$(printf '%0500s' | tr ' ' 'B')
echo ${#big2}
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
let lines: Vec<&str> = result.stdout.trim().lines().collect();
assert!(!lines.is_empty(), "should have produced some output");
}
#[tokio::test]
async fn tm_dos_060_local_assignment_respects_budget() {
let mem = MemoryLimits::new().max_variable_count(2);
let mut bash = Bash::builder()
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
local a=1 b=2 c=3
printf "%s\n" "${a:-unset}" "${b:-unset}" "${c:-unset}"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
let lines: Vec<&str> = result.stdout.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "1");
assert_eq!(lines[2], "unset");
}
#[tokio::test]
async fn tm_dos_060_function_local_assignment_respects_budget() {
let mem = MemoryLimits::new().max_variable_count(2);
let mut bash = Bash::builder()
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
f() {
local a=1 b=2
printf "%s\n" "${a:-unset}" "${b:-unset}"
}
f
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
let lines: Vec<&str> = result.stdout.lines().collect();
assert_eq!(lines, vec!["1", "unset"]);
}
#[tokio::test]
async fn tm_dos_060_local_compound_array_respects_budget() {
let mem = MemoryLimits::new().max_array_entries(1);
let mut bash = Bash::builder()
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
local arr=(a b c)
printf "%s\n" "${#arr[@]}"
f() {
local inner=(x y z)
printf "%s\n" "${#inner[@]}"
}
f
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
let counts: Vec<usize> = result
.stdout
.lines()
.map(|line| line.parse::<usize>().unwrap())
.collect();
assert_eq!(counts, vec![0, 0]);
}
#[tokio::test]
async fn tm_inf_023_bare_local_declarations_shadow_global_arrays() {
let mut bash = Bash::builder()
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
scalar_shadow=(GLOBAL_INDEXED)
declare -A indexed_shadow=([k]=GLOBAL_ASSOC)
assoc_shadow=(GLOBAL_INDEXED)
f() {
local scalar_shadow
local -a indexed_shadow
local -A assoc_shadow
printf "scalar_star=<%s>\n" "${scalar_shadow[*]}"
printf "scalar_zero=<%s>\n" "${scalar_shadow[0]}"
printf "indexed_star=<%s>\n" "${indexed_shadow[*]}"
printf "indexed_key=<%s>\n" "${indexed_shadow[k]}"
printf "assoc_star=<%s>\n" "${assoc_shadow[*]}"
printf "assoc_zero=<%s>\n" "${assoc_shadow[0]}"
}
f
printf "after_scalar=<%s>\n" "${scalar_shadow[*]}"
printf "after_indexed=<%s>\n" "${indexed_shadow[*]}"
printf "after_assoc=<%s>\n" "${assoc_shadow[*]}"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
let lines: Vec<&str> = result.stdout.lines().collect();
assert_eq!(
lines,
vec![
"scalar_star=<>",
"scalar_zero=<>",
"indexed_star=<>",
"indexed_key=<>",
"assoc_star=<>",
"assoc_zero=<>",
"after_scalar=<GLOBAL_INDEXED>",
"after_indexed=<GLOBAL_ASSOC>",
"after_assoc=<GLOBAL_INDEXED>",
]
);
}
#[tokio::test]
async fn tm_dos_060_funcname_restore_preserves_array_budget() {
let mem = MemoryLimits::new().max_array_entries(3);
let mut bash = Bash::builder()
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
arr[0]=a
arr[1]=b
arr[2]=c
f() { :; }
f
extra[0]=x
printf "%s %s\n" "${#arr[@]}" "${#extra[@]}"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "3 0");
}
#[tokio::test]
async fn tm_dos_060_local_array_shadow_preserves_array_budget() {
let mem = MemoryLimits::new().max_array_entries(3);
let mut bash = Bash::builder()
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
arr=(a b c)
f() {
local arr=(x)
printf "inside=%s\n" "${#arr[@]}"
}
f
printf "outside=%s:%s\n" "${#arr[@]}" "${arr[*]}"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(
result.stdout.lines().collect::<Vec<_>>(),
vec!["inside=0", "outside=3:a b c"]
);
}
#[tokio::test]
async fn tm_dos_060_local_assoc_array_shadow_preserves_array_budget() {
let mem = MemoryLimits::new().max_array_entries(3);
let mut bash = Bash::builder()
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
declare -A map
map[a]=1
map[b]=2
map[c]=3
f() {
local -A map=([x]=9)
printf "inside=%s\n" "${#map[@]}"
}
f
printf "outside=%s:%s\n" "${#map[@]}" "${map[a]}${map[b]}${map[c]}"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(
result.stdout.lines().collect::<Vec<_>>(),
vec!["inside=0", "outside=3:123"]
);
}
#[tokio::test]
async fn tm_dos_060_repeated_local_array_shadow_does_not_drift_budget() {
let mem = MemoryLimits::new().max_array_entries(3);
let mut bash = Bash::builder()
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
f() {
local arr=(a b c)
printf "first=%s\n" "${#arr[@]}"
local arr=(x)
printf "second=%s\n" "${#arr[@]}"
}
f
printf "after=%s\n" "${#arr[@]}"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(
result.stdout.lines().collect::<Vec<_>>(),
vec!["first=3", "second=1", "after=0"]
);
}
#[tokio::test]
async fn tm_dos_060_funcname_user_mutation_does_not_leak_budget() {
let mem = MemoryLimits::new().max_array_entries(5);
let mut bash = Bash::builder()
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
f() { FUNCNAME[7]=injected; }
f
f
f
arr=(a b c d e)
printf "n=%s\n" "${#arr[@]}"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "n=5");
}
#[tokio::test]
async fn tm_inf_023_function_local_arrays_do_not_leak() {
let mut bash = Bash::builder()
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
outer=(global value)
declare -A outer_map=([k]=global)
f() {
local -a outer=(token value)
local -a secret_arr=(hidden token)
local -A outer_map=([k]=secret)
local -A secret_map=([k]=hidden)
printf "inside_outer=%s\n" "${outer[*]}"
printf "inside_secret_arr=%s\n" "${secret_arr[*]}"
printf "inside_outer_map=%s\n" "${outer_map[*]}"
printf "inside_secret_map=%s\n" "${secret_map[*]}"
}
f
printf "outside_outer=%s\n" "${outer[*]}"
printf "outside_secret_arr=%s\n" "${secret_arr[*]}"
printf "outside_outer_map=%s\n" "${outer_map[*]}"
printf "outside_secret_map=%s\n" "${secret_map[*]}"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
let lines: Vec<&str> = result.stdout.lines().collect();
assert_eq!(
lines,
vec![
"inside_outer=token value",
"inside_secret_arr=hidden token",
"inside_outer_map=secret",
"inside_secret_map=hidden",
"outside_outer=global value",
"outside_secret_arr=",
"outside_outer_map=global",
"outside_secret_map=",
]
);
}
#[tokio::test]
async fn tm_dos_060_array_entry_bomb() {
let mem = MemoryLimits::new().max_array_entries(50);
let limits = ExecutionLimits::new()
.max_commands(10_000)
.max_loop_iterations(10_000);
let mut bash = Bash::builder()
.limits(limits)
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
for i in $(seq 1 100); do
arr[$i]=hello
done
echo "done"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn tm_dos_060_function_count_bomb() {
let mem = MemoryLimits::new().max_function_count(10);
let limits = ExecutionLimits::new()
.max_commands(10_000)
.max_loop_iterations(10_000);
let mut bash = Bash::builder()
.limits(limits)
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
for i in $(seq 1 50); do
eval "func_$i() { echo $i; }"
done
echo "done"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn tm_dos_060_normal_script_unaffected() {
let mut bash = Bash::builder().build();
let script = r#"
name="hello"
count=42
arr=(one two three)
declare -A map=([key1]=val1 [key2]=val2)
greet() { echo "Hello $1"; }
greet "world"
echo "$name $count ${arr[1]}"
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("Hello world"));
assert!(result.stdout.contains("hello 42 two"));
}
#[test]
fn tm_dos_060_default_safety() {
let defaults = MemoryLimits::default();
assert_eq!(defaults.max_variable_count, 10_000);
assert_eq!(defaults.max_total_variable_bytes, 10_000_000);
assert_eq!(defaults.max_array_entries, 100_000);
assert_eq!(defaults.max_function_count, 1_000);
assert_eq!(defaults.max_function_body_bytes, 1_000_000);
}
#[test]
fn tm_dos_060_unlimited() {
let unlimited = MemoryLimits::unlimited();
assert_eq!(unlimited.max_variable_count, usize::MAX);
assert_eq!(unlimited.max_total_variable_bytes, usize::MAX);
assert_eq!(unlimited.max_array_entries, usize::MAX);
assert_eq!(unlimited.max_function_count, usize::MAX);
assert_eq!(unlimited.max_function_body_bytes, usize::MAX);
}
#[tokio::test]
async fn tm_dos_060_builder_api() {
let mem = MemoryLimits::new()
.max_variable_count(500)
.max_total_variable_bytes(50_000)
.max_array_entries(1000)
.max_function_count(50)
.max_function_body_bytes(10_000);
let mut bash = Bash::builder().memory_limits(mem).build();
let result = bash.exec("echo hello").await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello"));
}
#[tokio::test]
async fn tm_dos_060_cross_instance_isolation() {
let mem = MemoryLimits::new().max_variable_count(20);
let limits = ExecutionLimits::new()
.max_commands(10_000)
.max_loop_iterations(10_000);
let mut bash1 = Bash::builder()
.limits(limits.clone())
.memory_limits(mem.clone())
.session_limits(SessionLimits::unlimited())
.build();
let mut bash2 = Bash::builder()
.limits(limits)
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
for i in $(seq 1 15); do
eval "x_$i=test"
done
echo "done"
"#;
let r1 = bash1.exec(script).await.unwrap();
let r2 = bash2.exec(script).await.unwrap();
assert_eq!(r1.exit_code, 0);
assert_eq!(r2.exit_code, 0);
}
}
mod tm_inf_018_date {
use super::*;
#[tokio::test]
async fn fixed_epoch_freezes_date() {
let mut bash = Bash::builder().fixed_epoch(1_700_000_000).build();
let r = bash.exec("date +%s").await.unwrap();
assert_eq!(r.exit_code, 0);
assert_eq!(r.stdout.trim(), "1700000000");
}
#[tokio::test]
async fn epoch_offset_shifts_real_clock() {
let offset = 365_i64 * 24 * 3600; let host_before = chrono::Utc::now().timestamp();
let mut bash = Bash::builder().epoch_offset(offset).build();
let r = bash.exec("date +%s").await.unwrap();
assert_eq!(r.exit_code, 0);
let observed: i64 = r.stdout.trim().parse().unwrap();
let delta = observed - (host_before + offset);
assert!(
(-2..=2).contains(&delta),
"offset clock drifted: observed={observed}, expected≈{}, delta={delta}",
host_before + offset
);
}
#[tokio::test]
async fn fixed_epoch_forces_utc_for_timezone_formats() {
let mut bash = Bash::builder().fixed_epoch(1_700_000_000).build();
let r = bash.exec("date +%z").await.unwrap();
assert_eq!(r.exit_code, 0);
assert_eq!(r.stdout.trim(), "+0000");
}
#[tokio::test]
async fn epoch_offset_forces_utc_for_rfc2822() {
let mut bash = Bash::builder().epoch_offset(86_400).build();
let r = bash.exec("date -R").await.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.stdout.trim_end().ends_with(" +0000"));
}
#[tokio::test]
async fn last_builder_call_wins_offset_after_fixed() {
let mut bash = Bash::builder().fixed_epoch(0).epoch_offset(0).build();
let r = bash.exec("date +%s").await.unwrap();
let observed: i64 = r.stdout.trim().parse().unwrap();
let now = chrono::Utc::now().timestamp();
assert!(
(observed - now).abs() < 60,
"epoch_offset(0) did not override fixed_epoch(0): observed={observed}, real={now}"
);
}
#[tokio::test]
async fn last_builder_call_wins_fixed_after_offset() {
let mut bash = Bash::builder()
.epoch_offset(99_999)
.fixed_epoch(1_700_000_000)
.build();
let r = bash.exec("date +%s").await.unwrap();
assert_eq!(r.stdout.trim(), "1700000000");
}
}
mod trace_events {
use super::*;
#[tokio::test]
async fn trace_off_produces_no_events() {
let mut bash = Bash::builder().trace_mode(TraceMode::Off).build();
let r = bash.exec("echo hello; echo world").await.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.events.is_empty(), "Off mode should produce no events");
}
#[tokio::test]
async fn trace_full_records_command_start_and_exit() {
let mut bash = Bash::builder().trace_mode(TraceMode::Full).build();
let r = bash.exec("echo hello").await.unwrap();
assert_eq!(r.exit_code, 0);
assert!(
r.events.len() >= 2,
"Full mode should record at least start+exit, got {}",
r.events.len()
);
assert_eq!(r.events[0].kind, TraceEventKind::CommandStart);
if let TraceEventDetails::CommandStart { command, argv, .. } = &r.events[0].details {
assert_eq!(command, "echo");
assert_eq!(argv, &["hello"]);
} else {
panic!("expected CommandStart details");
}
assert_eq!(r.events[1].kind, TraceEventKind::CommandExit);
if let TraceEventDetails::CommandExit {
command, exit_code, ..
} = &r.events[1].details
{
assert_eq!(command, "echo");
assert_eq!(*exit_code, 0);
} else {
panic!("expected CommandExit details");
}
}
#[tokio::test]
async fn trace_full_multiple_commands() {
let mut bash = Bash::builder().trace_mode(TraceMode::Full).build();
let r = bash.exec("echo a; echo b; echo c").await.unwrap();
assert_eq!(r.exit_code, 0);
let starts: Vec<_> = r
.events
.iter()
.filter(|e| e.kind == TraceEventKind::CommandStart)
.collect();
let exits: Vec<_> = r
.events
.iter()
.filter(|e| e.kind == TraceEventKind::CommandExit)
.collect();
assert_eq!(starts.len(), 3, "3 commands should have 3 starts");
assert_eq!(exits.len(), 3, "3 commands should have 3 exits");
}
#[tokio::test]
async fn trace_full_seq_numbers_monotonic() {
let mut bash = Bash::builder().trace_mode(TraceMode::Full).build();
let r = bash.exec("echo a; echo b").await.unwrap();
assert!(r.events.len() >= 4);
for i in 1..r.events.len() {
assert!(
r.events[i].seq > r.events[i - 1].seq,
"seq numbers should be strictly monotonic"
);
}
}
#[tokio::test]
async fn trace_redacted_scrubs_secrets_in_argv() {
let mut bash = Bash::builder().trace_mode(TraceMode::Redacted).build();
let r = bash
.exec(r#"printf "%s\n" -H "Authorization: Bearer secret123" "https://api.example.com""#)
.await
.unwrap();
assert_eq!(r.exit_code, 0);
assert!(!r.events.is_empty());
for event in &r.events {
if let TraceEventDetails::CommandStart { argv, .. } = &event.details {
for arg in argv {
assert!(
!arg.contains("secret123"),
"Redacted mode should not leak secrets: {arg}"
);
}
}
}
}
#[tokio::test]
async fn trace_failed_exec_does_not_leak_events_to_next_exec() {
let mut bash = Bash::builder().trace_mode(TraceMode::Full).build();
let failed = bash.exec(r#"grep -E "(" tenant-a-private-arg"#).await;
assert!(failed.is_err(), "invalid regex should fail execution");
let r = bash.exec("echo tenant-b").await.unwrap();
assert_eq!(r.exit_code, 0);
for event in &r.events {
if let TraceEventDetails::CommandStart { command, argv, .. } = &event.details {
assert_ne!(
command, "grep",
"stale failed command leaked into next exec"
);
assert!(
argv.iter().all(|arg| !arg.contains("tenant-a-private-arg")),
"stale failed argv leaked into next exec: {argv:?}" );
}
}
}
#[tokio::test]
async fn trace_redacted_scrubs_env_secrets() {
let mut bash = Bash::builder().trace_mode(TraceMode::Redacted).build();
let r = bash
.exec(r#"printf "%s" "API_KEY=supersecret""#)
.await
.unwrap();
assert_eq!(r.exit_code, 0);
for event in &r.events {
if let TraceEventDetails::CommandStart { argv, .. } = &event.details {
for arg in argv {
assert!(
!arg.contains("supersecret"),
"Redacted mode should scrub env secrets: {arg}"
);
}
}
}
}
#[tokio::test]
async fn trace_full_does_not_scrub() {
let mut bash = Bash::builder().trace_mode(TraceMode::Full).build();
let r = bash
.exec(r#"printf "%s" "API_KEY=supersecret""#)
.await
.unwrap();
assert_eq!(r.exit_code, 0);
let mut found_secret = false;
for event in &r.events {
if let TraceEventDetails::CommandStart { argv, .. } = &event.details {
for arg in argv {
if arg.contains("supersecret") {
found_secret = true;
}
}
}
}
assert!(found_secret, "Full mode should preserve raw secrets");
}
#[tokio::test]
async fn trace_callback_receives_events() {
use std::sync::{Arc, Mutex};
let count = Arc::new(Mutex::new(0u32));
let count_clone = count.clone();
let mut bash = Bash::builder()
.trace_mode(TraceMode::Full)
.on_trace_event(Box::new(move |_event| {
*count_clone.lock().unwrap() += 1;
}))
.build();
let r = bash.exec("echo hello").await.unwrap();
assert_eq!(r.exit_code, 0);
let cb_count = *count.lock().unwrap();
assert!(
cb_count >= 2,
"callback should fire for at least start+exit, got {cb_count}"
);
assert_eq!(r.events.len() as u32, cb_count);
}
#[tokio::test]
async fn trace_off_zero_overhead() {
let mut bash = Bash::builder().trace_mode(TraceMode::Off).build();
let r = bash
.exec("for i in $(seq 1 100); do echo $i; done")
.await
.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.events.is_empty());
}
#[tokio::test]
async fn trace_records_function_calls() {
let mut bash = Bash::builder().trace_mode(TraceMode::Full).build();
let r = bash.exec("myfn() { echo inside; }; myfn").await.unwrap();
assert_eq!(r.exit_code, 0);
let fn_starts: Vec<_> = r
.events
.iter()
.filter(|e| {
e.kind == TraceEventKind::CommandStart
&& matches!(&e.details, TraceEventDetails::CommandStart { command, .. } if command == "myfn")
})
.collect();
assert!(
!fn_starts.is_empty(),
"should record CommandStart for function call"
);
}
#[tokio::test]
async fn trace_records_command_not_found() {
let mut bash = Bash::builder().trace_mode(TraceMode::Full).build();
let r = bash.exec("nonexistent_command_xyz").await.unwrap();
assert_ne!(r.exit_code, 0);
let exits: Vec<_> = r
.events
.iter()
.filter(|e| {
e.kind == TraceEventKind::CommandExit
&& matches!(&e.details, TraceEventDetails::CommandExit { exit_code, .. } if exit_code == &127)
})
.collect();
assert!(
!exits.is_empty(),
"should record CommandExit with code 127 for not-found"
);
}
#[tokio::test]
async fn arithmetic_malformed_brace_length_no_panic() {
let mut bash = Bash::new();
let r = bash.exec("echo $((0 + ${#[}))").await.unwrap();
assert_eq!(r.exit_code, 0);
}
#[tokio::test]
async fn arithmetic_malformed_brace_length_utf8_no_panic() {
let mut bash = Bash::new();
let script = "echo $((${#rg[禧))";
let r = bash.exec(script).await;
let _ = r;
}
}
#[cfg(feature = "typescript")]
mod typescript_security {
use super::*;
use bashkit::TypeScriptLimits;
use std::time::Duration;
fn bash_with_ts() -> Bash {
Bash::builder()
.typescript_with_limits(TypeScriptLimits::default())
.build()
}
fn bash_with_ts_tight() -> Bash {
Bash::builder()
.typescript_with_limits(
TypeScriptLimits::default()
.max_duration(Duration::from_secs(3))
.max_memory(4 * 1024 * 1024)
.max_allocations(50_000)
.max_stack_depth(100),
)
.build()
}
#[tokio::test]
async fn threat_ts_infinite_loop() {
let mut bash = bash_with_ts_tight();
let result = bash.exec("ts -c \"while (true) {}\"").await.unwrap();
assert_ne!(result.exit_code, 0, "Infinite loop should not succeed");
}
#[tokio::test]
async fn threat_ts_memory_exhaustion() {
let mut bash = bash_with_ts_tight();
let result = bash
.exec("ts -c \"const arr: number[] = []; while (true) { arr.push(1); }\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "Memory bomb should not succeed");
}
#[tokio::test]
async fn threat_ts_stack_overflow() {
let mut bash = bash_with_ts_tight();
let result = bash
.exec("ts -c \"const f = (): number => f(); f()\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "Stack overflow should not succeed");
}
#[tokio::test]
async fn threat_ts_vfs_no_real_fs() {
let mut bash = bash_with_ts();
let result = bash
.exec("ts -c \"const c = await readFile('/etc/passwd'); console.log(c)\"")
.await
.unwrap();
assert!(
!result.stdout.contains("root:"),
"Should not read real /etc/passwd"
);
}
#[tokio::test]
async fn threat_ts_vfs_path_traversal() {
let mut bash = bash_with_ts();
let result = bash
.exec("ts -c \"const c = await readFile('/tmp/../../../etc/passwd'); console.log(c)\"")
.await
.unwrap();
assert!(
!result.stdout.contains("root:"),
"Path traversal must not escape VFS"
);
}
#[tokio::test]
async fn threat_ts_error_isolation() {
let mut bash = bash_with_ts();
let result = bash
.exec("ts -c \"throw new Error('test')\"")
.await
.unwrap();
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn threat_ts_no_shell_exec() {
let mut bash = bash_with_ts();
let result = bash
.exec("ts -c \"console.log(process.env.HOME)\"")
.await
.unwrap();
assert_ne!(result.exit_code, 0, "process.env should not exist");
assert!(
!result.stdout.contains("/home"),
"Should not access env vars via process"
);
}
#[tokio::test]
async fn threat_ts_optin_not_default() {
let mut bash = Bash::builder().build();
let result = bash.exec("ts -c \"console.log('hi')\"").await.unwrap();
assert_ne!(
result.exit_code, 0,
"ts should not be available without .typescript()"
);
}
}
mod zapcode_inspired_adversarial {
use super::*;
#[tokio::test]
async fn sparse_array_huge_index() {
let mem = MemoryLimits::new().max_array_entries(100);
let limits = ExecutionLimits::new().max_commands(1_000);
let mut bash = Bash::builder()
.limits(limits)
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let result = bash
.exec("declare -a arr; arr[999999999]=x; echo ${#arr[@]}")
.await
.unwrap();
assert_eq!(result.exit_code, 0);
let count: usize = result.stdout.trim().parse().unwrap_or(0);
assert!(
count <= 100,
"Sparse index must not cause mass allocation, got count={}",
count
);
}
#[tokio::test]
async fn sparse_array_extreme_negative_index() {
let limits = ExecutionLimits::new().max_commands(1_000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("declare -a arr=(a b c); echo \"${arr[-999999999]}\"")
.await
.unwrap();
assert_eq!(result.exit_code, 0);
let out = result.stdout.trim();
assert!(
out.is_empty() || ["a", "b", "c"].contains(&out),
"Extreme negative index should return empty or valid element, got: {:?}",
out
);
}
#[tokio::test]
async fn array_entry_exhaustion_under_load() {
let mem = MemoryLimits::new().max_array_entries(100);
let limits = ExecutionLimits::new()
.max_commands(500_000)
.max_loop_iterations(500_000)
.max_total_loop_iterations(500_000);
let mut bash = Bash::builder()
.limits(limits)
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
declare -a arr
i=0
while [ $i -lt 200 ]; do
arr[$i]=x
i=$((i+1))
done
echo ${#arr[@]}
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
let count: usize = result.stdout.trim().parse().unwrap_or(0);
assert!(
count <= 100,
"Array entries should be capped at max_array_entries, got {}",
count
);
}
#[tokio::test]
async fn array_assignment_word_split_respects_array_entry_limit() {
let mem = MemoryLimits::new().max_array_entries(100);
let limits = ExecutionLimits::new().max_commands(10_000);
let mut bash = Bash::builder()
.limits(limits)
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
parts=""
i=0
while [ $i -lt 200 ]; do
parts="$parts x"
i=$((i+1))
done
arr=($parts)
echo ${#arr[@]}
"#;
let result = bash.exec(script).await;
let err_msg = match &result {
Err(e) => e.to_string(),
Ok(r) => r.stderr.clone(),
};
assert!(
result.is_err(),
"should error on word-split limit, got: {err_msg}"
);
assert!(
err_msg.contains("word split") || err_msg.contains("limit"),
"error should mention word-split limit, got: {err_msg}"
);
}
#[tokio::test]
async fn brace_expansion_bomb_printf() {
let limits = ExecutionLimits::new()
.max_commands(1_000)
.max_stdout_bytes(1_000_000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("printf '%0.s-' {1..999999999}").await;
match result {
Ok(r) => {
assert!(
r.stdout.len() <= 1_000_000,
"Brace expansion bomb produced {} bytes — should be capped",
r.stdout.len()
);
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("brace")
|| msg.contains("range")
|| msg.contains("too large")
|| msg.contains("exceeded")
|| msg.contains("budget"),
"Expected brace/range limit error, got: {}",
msg
);
}
}
}
#[tokio::test]
async fn brace_expansion_comma_recursion_bomb() {
let limits = ExecutionLimits::new()
.max_commands(1_000)
.max_stdout_bytes(1_000_000);
let mut bash = Bash::builder().limits(limits).build();
let script = format!("echo {}", "{a,b}".repeat(50_000));
let result = bash.exec(&script).await;
match result {
Ok(r) => {
assert!(
r.stdout.len() <= 1_000_000,
"comma-list brace bomb produced {} bytes — should be capped",
r.stdout.len()
);
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("brace")
|| msg.contains("exceeded")
|| msg.contains("budget")
|| msg.contains("too large"),
"Expected a limit error, got: {}",
msg
);
}
}
}
#[tokio::test]
async fn parameter_expansion_replacement_bomb() {
let mem = MemoryLimits::new().max_total_variable_bytes(100_000);
let limits = ExecutionLimits::new()
.max_commands(50_000)
.max_loop_iterations(50_000)
.max_total_loop_iterations(50_000)
.max_stdout_bytes(1_000_000);
let mut bash = Bash::builder()
.limits(limits)
.memory_limits(mem)
.session_limits(SessionLimits::unlimited())
.build();
let script = r#"
x=$(printf 'a%.0s' {1..10000})
echo "${x//a/$(printf 'b%.0s' {1..1000})}"
"#;
let result = bash.exec(script).await;
match result {
Ok(r) => {
assert!(
r.stdout.len() <= 1_000_000,
"Expansion bomb produced {} bytes of stdout — should be capped",
r.stdout.len()
);
}
Err(_) => {
}
}
}
}