bashkit 0.18.0

Awesomely fast virtual sandbox with bash and file system
Documentation
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

use bashkit::{Bash, ExecOptions, ExecResult, ExecutionEvent, ExecutionLimits};

#[tokio::test]
async fn host_call_suspends_and_resumes_the_live_execution() {
    let bash = Bash::builder().host_call_builtin("lookup").build();
    let mut execution = bash.start_execution(
        "export REGION=us-east; cd /tmp; printf 'before\\n'; lookup alice; printf 'after\\n'",
    );

    let request = match execution.next_event().await.unwrap() {
        ExecutionEvent::HostCall(request) => request,
        ExecutionEvent::Complete(_) => panic!("execution completed before its host call"),
    };
    assert_eq!(request.command(), "lookup");
    assert_eq!(request.args(), &["alice"]);
    assert_eq!(request.cwd().to_string_lossy(), "/tmp");
    assert_eq!(
        request.env().get("REGION").map(String::as_str),
        Some("us-east")
    );
    assert_eq!(request.stdin(), None);

    execution
        .resume(request.id(), ExecResult::ok("found: alice\n".to_string()))
        .unwrap();

    let result = match execution.next_event().await.unwrap() {
        ExecutionEvent::Complete(result) => result,
        ExecutionEvent::HostCall(_) => panic!("unexpected second host call"),
    };
    assert_eq!(result.stdout, "before\nfound: alice\nafter\n");
    assert_eq!(result.exit_code, 0);
}

#[tokio::test]
async fn host_call_receives_pipeline_stdin_and_propagates_failure() {
    let bash = Bash::builder().host_call_builtin("remote").build();
    let mut execution = bash.start_execution("printf payload | remote value");

    let request = match execution.next_event().await.unwrap() {
        ExecutionEvent::HostCall(request) => request,
        ExecutionEvent::Complete(_) => panic!("execution completed before its host call"),
    };
    assert_eq!(request.args(), &["value"]);
    assert_eq!(request.stdin().unwrap(), "payload");
    execution
        .resume(
            request.id(),
            ExecResult::err("remote: unavailable\n".to_string(), 23),
        )
        .unwrap();

    let result = match execution.next_event().await.unwrap() {
        ExecutionEvent::Complete(result) => result,
        ExecutionEvent::HostCall(_) => panic!("unexpected second host call"),
    };
    assert_eq!(result.stderr, "remote: unavailable\n");
    assert_eq!(result.exit_code, 23);
}

#[tokio::test]
async fn resume_rejects_an_unknown_request_without_losing_the_pending_call() {
    let bash = Bash::builder().host_call_builtin("lookup").build();
    let mut execution = bash.start_execution("lookup alice");

    let request = match execution.next_event().await.unwrap() {
        ExecutionEvent::HostCall(request) => request,
        ExecutionEvent::Complete(_) => panic!("execution completed before its host call"),
    };
    execution
        .resume(request.id(), ExecResult::ok("ok\n".to_string()))
        .unwrap();
    let error = execution
        .resume(request.id(), ExecResult::ok(String::new()))
        .unwrap_err();
    assert!(error.to_string().contains("unknown host-call request"));
    let result = match execution.next_event().await.unwrap() {
        ExecutionEvent::Complete(result) => result,
        ExecutionEvent::HostCall(_) => panic!("unexpected second host call"),
    };
    assert_eq!(result.stdout, "ok\n");
}

#[tokio::test]
async fn ordinary_exec_fails_host_call_builtin_without_a_driver() {
    let mut bash = Bash::builder().host_call_builtin("lookup").build();
    let result = bash.exec("lookup alice").await.unwrap();

    assert_eq!(result.exit_code, 1);
    assert_eq!(
        result.stderr,
        "lookup: host-call builtin requires Bash::start_execution\n"
    );
}

#[tokio::test]
async fn execution_can_resume_multiple_host_calls() {
    let bash = Bash::builder().host_call_builtin("lookup").build();
    let mut execution = bash.start_execution("lookup one; lookup two");

    for expected in ["one", "two"] {
        let request = match execution.next_event().await.unwrap() {
            ExecutionEvent::HostCall(request) => request,
            ExecutionEvent::Complete(_) => panic!("execution completed before all host calls"),
        };
        assert_eq!(request.args(), &[expected]);
        execution
            .resume(request.id(), ExecResult::ok(format!("{expected}!\n")))
            .unwrap();
    }

    let result = match execution.next_event().await.unwrap() {
        ExecutionEvent::Complete(result) => result,
        ExecutionEvent::HostCall(_) => panic!("unexpected third host call"),
    };
    assert_eq!(result.stdout, "one!\ntwo!\n");
}

#[tokio::test(start_paused = true)]
async fn suspended_host_call_remains_inside_the_execution_timeout() {
    let bash = Bash::builder()
        .host_call_builtin("lookup")
        .limits(ExecutionLimits::new().timeout(Duration::from_secs(2)))
        .build();
    let mut execution =
        bash.start_execution_with_options("lookup alice", ExecOptions::new().stdin("unused"));

    let request = match execution.next_event().await.unwrap() {
        ExecutionEvent::HostCall(request) => request,
        ExecutionEvent::Complete(_) => panic!("execution completed before its host call"),
    };

    // The host never polls the handle across the deadline: the driver has to
    // notice on its own. THREAT[TM-DOS-098].
    tokio::time::advance(Duration::from_secs(3)).await;
    tokio::task::yield_now().await;

    let resume_error = execution
        .resume(request.id(), ExecResult::ok("too late\n"))
        .unwrap_err();
    assert!(
        resume_error.to_string().contains("no longer active"),
        "unexpected resume error: {resume_error}"
    );

    let error = execution.next_event().await.unwrap_err();
    assert!(error.to_string().contains("timeout"), "unexpected: {error}");
    // A timed-out run drops its session rather than parking it in the handle.
    assert!(execution.into_bash().is_err());
}

/// The driver runs the script while the host is off doing something else, and
/// stops for good when the host walks away. Both halves of THREAT[TM-DOS-098].
#[tokio::test(start_paused = true)]
async fn the_driver_runs_while_the_host_is_away_and_stops_when_it_leaves() {
    let ticks = Arc::new(AtomicUsize::new(0));
    let counter = Arc::clone(&ticks);
    let bash = Bash::builder().host_call_builtin("lookup").build();
    let mut execution = bash.start_execution_with_options(
        "lookup alice; for i in 1 2 3 4 5 6; do echo tick; sleep 5; done",
        ExecOptions::new().streaming(Box::new(move |stdout, _stderr| {
            counter.fetch_add(
                stdout.text_lossy().matches("tick").count(),
                Ordering::SeqCst,
            );
        })),
    );

    let request = match execution.next_event().await.unwrap() {
        ExecutionEvent::HostCall(request) => request,
        ExecutionEvent::Complete(_) => panic!("execution completed before its host call"),
    };
    assert_eq!(request.args(), &["alice"]);
    execution
        .resume(request.id(), ExecResult::ok("ok\n"))
        .unwrap();

    // The host does not poll the handle again; only an independent driver can
    // move the loop forward.
    for _ in 0..3 {
        tokio::time::advance(Duration::from_secs(5)).await;
        tokio::task::yield_now().await;
    }
    let before = ticks.load(Ordering::SeqCst);
    assert!(
        before > 0,
        "execution did not advance while the host was away"
    );

    drop(execution);
    for _ in 0..8 {
        tokio::time::advance(Duration::from_secs(5)).await;
        tokio::task::yield_now().await;
    }
    assert_eq!(
        ticks.load(Ordering::SeqCst),
        before,
        "dropped handle left the script running in the background"
    );
}

#[tokio::test]
async fn a_second_event_after_completion_is_rejected() {
    let bash = Bash::builder().host_call_builtin("lookup").build();
    let mut execution = bash.start_execution("echo done");
    assert!(matches!(
        execution.next_event().await.unwrap(),
        ExecutionEvent::Complete(_)
    ));
    let error = execution.next_event().await.unwrap_err();
    assert!(
        error.to_string().contains("already completed"),
        "unexpected: {error}"
    );
}

#[tokio::test]
async fn completed_execution_returns_the_reusable_bash_session() {
    let bash = Bash::builder().host_call_builtin("lookup").build();
    let mut execution = bash.start_execution("lookup alice");
    let request = match execution.next_event().await.unwrap() {
        ExecutionEvent::HostCall(request) => request,
        ExecutionEvent::Complete(_) => panic!("execution completed before its host call"),
    };
    execution = match execution.into_bash() {
        Ok(_) => panic!("active execution returned its Bash session"),
        Err(execution) => execution,
    };
    execution
        .resume(request.id(), ExecResult::ok("ok\n"))
        .unwrap();
    assert!(matches!(
        execution.next_event().await.unwrap(),
        ExecutionEvent::Complete(_)
    ));

    let mut bash = execution.into_bash().unwrap();
    let result = bash.exec("echo reused").await.unwrap();
    assert_eq!(result.stdout, "reused\n");
}