#[cfg(feature = "jq")]
use bashkit::ExecOptions;
use bashkit::{
Bash, Builtin, BuiltinContext, Error, ExecResult, ExecutionBudget, ExecutionLimits,
LimitExceeded, async_trait,
};
use std::sync::{Arc, Mutex};
fn assert_budget_exhausted(result: bashkit::Result<bashkit::ExecResult>) {
assert!(
matches!(
result,
Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_)))
),
"expected shared execution budget exhaustion, got {result:?}"
);
}
#[tokio::test]
async fn nested_command_substitutions_cannot_refresh_work_budget() {
let limits = ExecutionLimits::new()
.max_commands(100)
.max_work_units(6)
.max_aggregate_input_bytes(1_000);
let mut bash = Bash::builder().limits(limits).build();
assert_budget_exhausted(bash.exec("echo $(echo $(echo $(echo nested)))").await);
}
#[tokio::test]
async fn mixed_pipeline_consumers_share_aggregate_input_budget() {
let limits = ExecutionLimits::new()
.max_commands(100)
.max_work_units(10_000)
.max_aggregate_input_bytes(80);
let mut bash = Bash::builder().limits(limits).build();
assert_budget_exhausted(
bash.exec("printf 12345678901234567890 | cat | awk '{print}' | rg 1")
.await,
);
}
#[tokio::test]
async fn a_poisoned_budget_stops_later_pipeline_stages() {
let limits = ExecutionLimits::new()
.max_commands(100)
.max_work_units(8)
.max_aggregate_input_bytes(10_000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("printf first | awk '{print}'; echo must-not-run")
.await;
assert_budget_exhausted(result);
}
#[tokio::test]
async fn separate_host_requests_receive_fresh_budgets() {
let limits = ExecutionLimits::new()
.max_work_units(8)
.max_aggregate_input_bytes(1_000);
let mut bash = Bash::builder().limits(limits).build();
assert_eq!(bash.exec("true").await.unwrap().exit_code, 0);
assert_eq!(bash.exec("true").await.unwrap().exit_code, 0);
}
#[derive(Clone)]
struct CaptureBoundary(Arc<Mutex<Option<ExecutionBudget>>>);
#[async_trait]
impl Builtin for CaptureBoundary {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
*self.0.lock().unwrap() = ctx
.execution_budget()
.and_then(|budget| budget.try_with(Clone::clone).ok());
Ok(ExecResult::ok("captured\n"))
}
}
#[tokio::test]
async fn completed_request_rejects_late_work() {
let captured = Arc::new(Mutex::new(None));
let mut bash = Bash::builder()
.builtin("capture", Box::new(CaptureBoundary(captured.clone())))
.build();
assert_eq!(bash.exec("capture").await.unwrap().stdout, "captured\n");
let old = captured.lock().unwrap().take().unwrap();
assert_eq!(
old.check().unwrap_err().to_string(),
"execution budget exhausted: request closed"
);
assert_eq!(
old.consume_work(1).unwrap_err().to_string(),
"execution budget exhausted: request closed"
);
assert_eq!(bash.exec("echo reused").await.unwrap().stdout, "reused\n");
assert_eq!(
old.check().unwrap_err().to_string(),
"execution budget exhausted: request closed"
);
}
#[tokio::test]
async fn concurrent_requests_have_independent_boundaries() {
let left = Arc::new(Mutex::new(None));
let right = Arc::new(Mutex::new(None));
let mut left_bash = Bash::builder()
.builtin("capture", Box::new(CaptureBoundary(left.clone())))
.build();
let mut right_bash = Bash::builder()
.builtin("capture", Box::new(CaptureBoundary(right.clone())))
.build();
let (left_result, right_result) = tokio::join!(
left_bash.exec("capture; echo left"),
right_bash.exec("capture; echo right")
);
assert_eq!(left_result.unwrap().stdout, "captured\nleft\n");
assert_eq!(right_result.unwrap().stdout, "captured\nright\n");
let left = left.lock().unwrap().take().unwrap();
let right = right.lock().unwrap().take().unwrap();
assert_eq!(
left.check().unwrap_err().to_string(),
"execution budget exhausted: request closed"
);
assert_eq!(
right.check().unwrap_err().to_string(),
"execution budget exhausted: request closed"
);
}
#[cfg(feature = "jq")]
#[tokio::test]
async fn jq_generator_consumes_shared_work_budget() {
let limits = ExecutionLimits::new()
.max_work_units(500)
.max_aggregate_input_bytes(100_000);
let mut bash = Bash::builder().limits(limits).build();
assert_budget_exhausted(bash.exec("jq -n 'range(0; 10000)'").await);
}
#[cfg(feature = "jq")]
#[tokio::test]
async fn jq_control_normalization_respects_live_memory_budget() {
let limits = ExecutionLimits::new()
.max_work_units(10_000)
.max_aggregate_input_bytes(10_000)
.max_live_intermediate_bytes(8);
let mut bash = Bash::builder().limits(limits).build();
assert_budget_exhausted(
bash.exec_with_options("jq -c .", ExecOptions::new().stdin("\"a\n\""))
.await,
);
}
#[cfg(feature = "python")]
#[tokio::test]
async fn repeated_python_entries_share_runtime_admission_budget() {
let limits = ExecutionLimits::new()
.max_work_units(1_500_000)
.max_aggregate_input_bytes(100_000);
let mut bash = Bash::builder()
.limits(limits)
.python()
.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1")
.build();
assert_budget_exhausted(
bash.exec("python -c 'print(1)'; python -c 'print(2)'")
.await,
);
}
#[cfg(feature = "typescript")]
#[tokio::test]
async fn repeated_typescript_entries_share_runtime_admission_budget() {
let limits = ExecutionLimits::new()
.max_work_units(1_500_000)
.max_aggregate_input_bytes(100_000);
let mut bash = Bash::builder().limits(limits).typescript().build();
assert_budget_exhausted(bash.exec("ts -c '1 + 1'; ts -c '2 + 2'").await);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn sqlite_steps_consume_shared_work_budget() {
let limits = ExecutionLimits::new()
.max_work_units(600)
.max_aggregate_input_bytes(100_000);
let mut bash = Bash::builder()
.limits(limits)
.sqlite()
.env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1")
.build();
let sql = "SELECT 1;".repeat(400);
assert_budget_exhausted(bash.exec(&format!("sqlite :memory: '{sql}'")).await);
}