bashkit 0.16.0

Awesomely fast virtual sandbox with bash and file system
Documentation
use bashkit::{
    Bash, Error, ExecutionLimits, ExecutionProfile, ExecutionProfileName, LimitExceeded,
};
use std::path::Path;
use std::process::Command;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};

fn strip_timing(stderr: &str) -> String {
    stderr
        .lines()
        .filter(|line| {
            !line.is_empty()
                && !line.starts_with("real")
                && !line.starts_with("user")
                && !line.starts_with("sys")
        })
        .map(|line| format!("{line}\n"))
        .collect()
}

#[tokio::test]
async fn reserved_word_wraps_pipeline_group_and_function() {
    let mut bash = Bash::new();
    let result = bash
        .exec("set -o pipefail; f() { echo function; echo problem >&2; return 7; }; time { f | cat; }")
        .await
        .unwrap();

    assert_eq!(result.stdout, "function\n");
    assert_eq!(strip_timing(&result.stderr), "problem\n");
    assert_eq!(result.exit_code, 7);
}

#[tokio::test(start_paused = true)]
async fn posix_report_uses_virtual_monotonic_time_without_fake_cpu() {
    let mut bash = Bash::new();
    let result = bash.exec("time -p sleep 1.25").await.unwrap();

    assert_eq!(result.stdout, "");
    assert_eq!(
        result.stderr,
        "real 1.25\nuser unavailable\nsys unavailable\n"
    );
    assert_eq!(result.exit_code, 0);
}

#[tokio::test(start_paused = true)]
async fn gnu_format_reports_truthful_fields_and_bashkit_counters() {
    let mut bash = Bash::new();
    let result = bash
        .exec("time -f 'elapsed=%e status=%x cpu=%U rss=%M commands=%{commands} loops=%{loops} work=%{work_units}' -- sh -c 'for i in 1 2 3; do :; done; exit 4'")
        .await
        .unwrap();

    assert_eq!(result.stdout, "");
    assert_eq!(
        result.stderr,
        "elapsed=0.00 status=4 cpu=unavailable rss=unavailable commands=7 loops=3 work=18\n"
    );
    assert_eq!(result.exit_code, 4);
}

#[tokio::test(start_paused = true)]
async fn verbose_report_contains_only_available_or_explicitly_unavailable_data() {
    let mut bash = Bash::new();
    let result = bash.exec("time -v sleep 0.2").await.unwrap();

    assert!(result.stderr.contains("Elapsed (wall clock) time: 0.20\n"));
    assert!(result.stderr.contains("User CPU time: unavailable\n"));
    assert!(result.stderr.contains("System CPU time: unavailable\n"));
    assert!(
        result
            .stderr
            .contains("Maximum resident set size: unavailable\n")
    );
    assert!(result.stderr.contains("Bashkit commands: 1\n"));
    assert!(result.stderr.contains("Exit status: 0\n"));
}

#[tokio::test(start_paused = true)]
async fn output_and_append_use_vfs_without_redirecting_wrapped_streams() {
    let mut bash = Bash::new();
    let first = bash
        .exec("time -f 'first:%e' -o /tmp/time.txt sh -c 'echo out; echo err >&2; exit 3'")
        .await
        .unwrap();
    assert_eq!(first.stdout, "out\n");
    assert_eq!(first.stderr, "err\n");
    assert_eq!(first.exit_code, 3);

    let second = bash
        .exec("time -f 'second:%x' -a -o /tmp/time.txt true")
        .await
        .unwrap();
    assert_eq!(second.exit_code, 0);
    assert_eq!(
        bash.fs()
            .read_file(Path::new("/tmp/time.txt"))
            .await
            .unwrap(),
        b"first:0.00\nsecond:0\n"
    );
}

#[tokio::test]
async fn output_error_is_deterministic_and_does_not_hide_command_output() {
    let mut bash = Bash::new();
    let result = bash
        .exec("time -o /missing/report.txt sh -c 'echo ran; exit 9'")
        .await
        .unwrap();

    assert_eq!(result.stdout, "ran\n");
    assert_eq!(
        result.stderr,
        "time: cannot write output file '/missing/report.txt'\n"
    );
    assert_eq!(result.exit_code, 1);
}

#[tokio::test]
async fn malformed_options_and_formats_never_run_the_wrapped_command() {
    let mut bash = Bash::new();
    let bad_option = bash.exec("time --wat echo leaked").await.unwrap();
    assert_eq!(bad_option.stdout, "");
    assert_eq!(bad_option.stderr, "time: unrecognized option '--wat'\n");
    assert_eq!(bad_option.exit_code, 2);

    let bad_format = bash.exec("time -f '%Q' echo leaked").await.unwrap();
    assert_eq!(bad_format.stdout, "");
    assert_eq!(bad_format.stderr, "time: unsupported format field '%Q'\n");
    assert_eq!(bad_format.exit_code, 2);
}

#[tokio::test(start_paused = true)]
async fn hardened_profile_coarsens_elapsed_time() {
    let mut bash = Bash::builder()
        .profile(ExecutionProfile::named(ExecutionProfileName::Hardened))
        .build();
    let result = bash.exec("time -f '%e' sleep 0.149").await.unwrap();

    assert_eq!(result.stderr, "0.10\n");
}

#[tokio::test]
async fn time_does_not_refresh_resource_budget() {
    let limits = ExecutionLimits::new().max_commands(100).max_work_units(4);
    let mut bash = Bash::builder().limits(limits).build();
    let result = bash.exec("time echo $(echo nested)").await;

    assert!(matches!(
        result,
        Err(Error::ResourceLimit(LimitExceeded::ExecutionBudget(_)))
    ));
}

#[tokio::test(start_paused = true)]
async fn wrapped_timeout_status_and_elapsed_time_are_reported() {
    let mut bash = Bash::new();
    let result = bash
        .exec("time -f 'elapsed=%e status=%x' timeout 0.1 sleep 5")
        .await
        .unwrap();

    assert_eq!(result.stderr, "elapsed=0.10 status=124\n");
    assert_eq!(result.exit_code, 124);
}

#[tokio::test]
async fn output_file_does_not_bypass_report_size_limit() {
    let limits = ExecutionLimits::new().max_stderr_bytes(32);
    let mut bash = Bash::builder().limits(limits).build();
    bash.fs()
        .write_file(Path::new("/tmp/report"), b"sentinel\n")
        .await
        .unwrap();

    let result = bash
        .exec("time -o /tmp/report -f '0123456789012345678901234567890123456789' true")
        .await
        .unwrap();
    assert_eq!(result.exit_code, 1);
    assert_eq!(
        bash.fs().read_file(Path::new("/tmp/report")).await.unwrap(),
        b"sentinel\n"
    );
}

#[tokio::test]
async fn streaming_emits_wrapped_output_and_the_final_report_once() {
    let chunks = Arc::new(Mutex::new((String::new(), String::new())));
    let sink = Arc::clone(&chunks);
    let mut bash = Bash::new();
    let result = bash
        .exec_streaming(
            "time -f 'status=%x' sh -c 'echo out; echo err >&2; exit 3'",
            Box::new(move |stdout, stderr| {
                let mut chunks = sink.lock().unwrap();
                chunks.0.push_str(&stdout.to_string());
                chunks.1.push_str(&stderr.to_string());
            }),
        )
        .await
        .unwrap();

    assert_eq!(result.exit_code, 3);
    let chunks = chunks.lock().unwrap();
    assert_eq!(chunks.0, "out\n");
    assert_eq!(chunks.1, "err\nstatus=3\n");
}

#[tokio::test]
async fn time_obeys_pre_cancelled_request_and_writes_no_report() {
    let mut bash = Bash::new();
    bash.cancellation_token().store(true, Ordering::Relaxed);
    let result = bash.exec("time -o /tmp/report true").await;

    assert_eq!(result.unwrap_err().to_string(), "execution cancelled");
    assert!(!bash.fs().exists(Path::new("/tmp/report")).await.unwrap());
}

#[tokio::test]
async fn time_errexit_uses_wrapped_status() {
    let mut bash = Bash::new();
    let result = bash.exec("set -e; time false; echo leaked").await.unwrap();

    assert_eq!(result.stdout, "");
    assert_eq!(result.exit_code, 1);
}

#[tokio::test(start_paused = true)]
async fn nested_time_and_compound_redirection_keep_report_boundaries() {
    let mut bash = Bash::new();
    let nested = bash
        .exec("time -f 'outer:%x' time -f 'inner:%x' sleep 0.1")
        .await
        .unwrap();
    assert_eq!(nested.stderr, "inner:0\nouter:0\n");

    let redirected = bash
        .exec("time -f 'report:%x' { echo visible; echo wrapped >&2; } 2>/tmp/all")
        .await
        .unwrap();
    assert_eq!(redirected.stdout, "visible\n");
    assert_eq!(redirected.stderr, "report:0\n");
    assert_eq!(
        bash.fs().read_file(Path::new("/tmp/all")).await.unwrap(),
        b"wrapped\n"
    );

    let outer = bash
        .exec("{ time -f 'outer:%x' true; } 2>/tmp/outer")
        .await
        .unwrap();
    assert_eq!(outer.stderr, "");
    assert_eq!(
        bash.fs().read_file(Path::new("/tmp/outer")).await.unwrap(),
        b"outer:0\n"
    );
}

#[tokio::test]
async fn invalid_format_preserves_existing_output_file_atomically() {
    let mut bash = Bash::new();
    bash.fs()
        .write_file(Path::new("/tmp/report"), b"sentinel\n")
        .await
        .unwrap();

    let result = bash
        .exec("time -o /tmp/report -f '%Q' echo must-not-run")
        .await
        .unwrap();
    assert_eq!(result.exit_code, 2);
    assert_eq!(result.stdout, "");
    assert_eq!(
        bash.fs().read_file(Path::new("/tmp/report")).await.unwrap(),
        b"sentinel\n"
    );

    let valid = bash
        .exec("time -o /tmp/report -f 'replacement:%x' true")
        .await
        .unwrap();
    assert_eq!(valid.exit_code, 0);
    assert_eq!(
        bash.fs().read_file(Path::new("/tmp/report")).await.unwrap(),
        b"replacement:0\n"
    );
}

#[tokio::test]
async fn failed_atomic_replace_cleans_temporary_report() {
    let mut bash = Bash::new();
    bash.fs()
        .mkdir(Path::new("/tmp/report-dir"), false)
        .await
        .unwrap();

    let result = bash
        .exec("time -o /tmp/report-dir -f '%x' true")
        .await
        .unwrap();
    assert_eq!(result.exit_code, 1);
    assert_eq!(
        result.stderr,
        "time: cannot write output file '/tmp/report-dir'\n"
    );
    assert!(
        bash.fs()
            .stat(Path::new("/tmp/report-dir"))
            .await
            .unwrap()
            .file_type
            .is_dir()
    );
    assert!(
        bash.fs()
            .read_dir(Path::new("/tmp"))
            .await
            .unwrap()
            .iter()
            .all(|entry| !entry.name.starts_with(".bashkit-time-"))
    );
}

#[tokio::test]
async fn reserved_word_matches_real_bash_stream_and_status_semantics() {
    let script = "set -o pipefail; f() { echo out; echo err >&2; return 6; }; time f | cat";
    let host = Command::new("bash")
        .args(["--noprofile", "--norc", "-c", script])
        .output()
        .expect("real bash must be available for compatibility tests");

    let mut bash = Bash::new();
    let result = bash.exec(script).await.unwrap();
    assert_eq!(result.stdout.as_bytes(), host.stdout);
    assert_eq!(strip_timing(&result.stderr), "err\n");
    assert!(String::from_utf8_lossy(&host.stderr).contains("err\n"));
    assert_eq!(result.exit_code, host.status.code().unwrap());
}

#[tokio::test]
async fn gnu_time_matches_wrapped_stream_and_status_when_available() {
    let version = Command::new("/usr/bin/time").arg("--version").output();
    let Ok(version) = version else { return };
    if !String::from_utf8_lossy(&version.stdout).contains("GNU time") {
        return;
    }
    let host = Command::new("/usr/bin/time")
        .args([
            "-f",
            "status=%x",
            "sh",
            "-c",
            "echo out; echo err >&2; exit 6",
        ])
        .output()
        .unwrap();

    let mut bash = Bash::new();
    let result = bash
        .exec("time -f 'status=%x' -- sh -c 'echo out; echo err >&2; exit 6'")
        .await
        .unwrap();
    assert_eq!(result.stdout.as_bytes(), host.stdout);
    assert_eq!(result.stderr.as_bytes(), host.stderr);
    assert_eq!(result.exit_code, host.status.code().unwrap());
}