wasmsh-runtime 0.7.0

Shared shell runtime core for wasmsh (standalone and Pyodide builds)
Documentation
mod common;

use common::{get_exit, get_stderr, get_stdout};
use wasmsh_protocol::HostCommand;
use wasmsh_runtime::{ExternalCommandResult, WorkerRuntime};

fn install_hostcat(rt: &mut WorkerRuntime) {
    rt.set_external_handler(Box::new(|name, _argv, stdin| {
        if name != "hostcat" {
            return None;
        }
        let stdout = stdin
            .map(|mut stdin| {
                let mut out = Vec::new();
                let mut buffer = [0u8; 4096];
                loop {
                    match stdin.read_chunk(&mut buffer) {
                        Ok(0) => break,
                        Ok(read) => out.extend_from_slice(&buffer[..read]),
                        Err(_) => return Vec::new(),
                    }
                }
                out
            })
            .unwrap_or_default();
        Some(ExternalCommandResult {
            stdout,
            stderr: Vec::new(),
            status: 0,
        })
    }));
}

#[test]
fn builtin_path_uses_same_io_redirection_model() {
    let mut rt = WorkerRuntime::new();
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "printf hello > /out.txt; cat /out.txt".into(),
    });

    assert_eq!(get_stdout(&events), "hello");
}

#[test]
fn utility_path_uses_same_io_redirection_model() {
    let mut rt = WorkerRuntime::new();
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });
    rt.handle_command(HostCommand::WriteFile {
        path: "/in.txt".into(),
        data: b"input\n".to_vec(),
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "cat < /in.txt".into(),
    });

    assert_eq!(get_stdout(&events), "input\n");
}

#[test]
fn external_path_uses_same_io_redirection_model() {
    let mut rt = WorkerRuntime::new();
    install_hostcat(&mut rt);
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });
    rt.handle_command(HostCommand::WriteFile {
        path: "/in.txt".into(),
        data: b"input\n".to_vec(),
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "hostcat < /in.txt".into(),
    });

    assert_eq!(get_stdout(&events), "input\n");
}

#[test]
fn function_path_uses_same_io_redirection_model() {
    let mut rt = WorkerRuntime::new();
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });
    rt.handle_command(HostCommand::WriteFile {
        path: "/in.txt".into(),
        data: b"input\n".to_vec(),
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "f(){ cat; }\nf < /in.txt > /out.txt; cat /out.txt".into(),
    });

    assert_eq!(get_stdout(&events), "input\n");
}

#[test]
fn mixed_pipeline_external_and_file_redirection_share_io_model() {
    let mut rt = WorkerRuntime::new();
    install_hostcat(&mut rt);
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "printf hi | hostcat > /out.txt; cat /out.txt".into(),
    });

    assert_eq!(get_stdout(&events), "hi");
}

#[test]
fn function_shadowing_utility_takes_precedence() {
    let mut rt = WorkerRuntime::new();
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });
    rt.handle_command(HostCommand::WriteFile {
        path: "/in.txt".into(),
        data: b"utility\n".to_vec(),
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "cat(){ printf function; }\ncat < /in.txt > /out.txt; cat /out.txt".into(),
    });

    assert_eq!(get_stdout(&events), "function");
}

#[test]
fn builtin_keyword_bypasses_function_shadowing() {
    let mut rt = WorkerRuntime::new();
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "printf(){ echo function; }\nprintf > /fn.txt; builtin printf builtin > /builtin.txt; cat /fn.txt; cat /builtin.txt".into(),
    });

    assert_eq!(get_stdout(&events), "function\nbuiltin");
}

#[test]
fn nounset_builtin_expansion_surfaces_error_through_vm_subset_path() {
    let mut rt = WorkerRuntime::new();
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "set -u; echo $UNSET_VAR".into(),
    });

    assert_eq!(get_stdout(&events), "");
    assert!(
        get_stderr(&events).contains("UNSET_VAR: unbound variable"),
        "stderr = {:?}",
        get_stderr(&events)
    );
    assert_eq!(get_exit(&events), 1);
}

#[test]
fn nounset_assignment_expansion_surfaces_error_through_vm_subset_path() {
    let mut rt = WorkerRuntime::new();
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "set -u; FOO=$UNSET_VAR".into(),
    });

    assert!(
        get_stderr(&events).contains("UNSET_VAR: unbound variable"),
        "stderr = {:?}",
        get_stderr(&events)
    );
    assert_eq!(get_exit(&events), 1);
}

#[test]
fn source_uses_redirected_io_and_preserves_shell_state() {
    let mut rt = WorkerRuntime::new();
    rt.handle_command(HostCommand::Init {
        step_budget: 0,
        allowed_hosts: vec![],
    });
    rt.handle_command(HostCommand::WriteFile {
        path: "/lib.sh".into(),
        data: b"echo sourced\nX=loaded\n".to_vec(),
    });

    let events = rt.handle_command(HostCommand::Run {
        input: "source /lib.sh > /out.txt; cat /out.txt; echo $X".into(),
    });

    assert_eq!(get_stdout(&events), "sourced\nloaded\n");
}