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 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_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 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("ssh").await.unwrap();
assert_eq!(result.exit_code, 127);
assert!(
result.stderr.starts_with("bash: ssh: 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 &["ssh", "apt", "yum", "docker", "vim", "nano"] {
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())
.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, "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().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())
.build()
}
#[tokio::test]
async fn threat_python_deep_nesting_parser() {
let mut bash = bash_with_python();
let depth = 30;
let code = format!(
"python3 -c \"x = {}1{}\"",
"(".repeat(depth),
",)".repeat(depth)
);
let result = bash.exec(&code).await.unwrap();
assert_eq!(
result.exit_code, 0,
"30 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).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_zero_ast_depth_rejects_compounds() {
let limits = ExecutionLimits::new().max_ast_depth(0);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("if true; then echo x; fi").await;
assert!(
result.is_err(),
"max_ast_depth=0 should reject any compound command"
);
}
#[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"),
"Expected depth 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"
);
}
}
#[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");
}
#[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(20);
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_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_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_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_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 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_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);
}
}