mxsh 0.2.0

Embeddable POSIX-style shell parser and runtime
Documentation
#![cfg(all(
    feature = "cli",
    feature = "embed",
    feature = "test-support",
    feature = "unix-runtime"
))]

mod support;

use std::time::Duration;

use support::run_shell_with_timeout;

const SHELL_TIMEOUT: Duration = Duration::from_secs(5);

fn assert_process_global_script_succeeds(script: &str, context: &str) {
    let output = run_shell_with_timeout("mxsh", &["-s"], script, SHELL_TIMEOUT);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "{context}; stdout:\n{stdout}\nstderr:\n{stderr}"
    );
}

fn assert_ulimit_restored_after_isolated_execution(option: &str, low_value: &str) {
    let script = format!(
        "\
old=$(ulimit {option})
(ulimit {option} {low_value})
after_subshell=$(ulimit {option})
from_substitution=$(ulimit {option} {low_value}; ulimit {option})
after_substitution=$(ulimit {option})
ulimit {option} {low_value} | cat >/dev/null
after_pipeline=$(ulimit {option})
printf 'old=%s after_subshell=%s after_substitution=%s after_pipeline=%s captured=%s\\n' \
    \"$old\" \"$after_subshell\" \"$after_substitution\" \"$after_pipeline\" \"$from_substitution\"
test \"$old\" = \"$after_subshell\"
test \"$old\" = \"$after_substitution\"
test \"$old\" = \"$after_pipeline\"
"
    );

    assert_process_global_script_succeeds(
        &script,
        &format!("ulimit {option} should be restored after isolated execution"),
    );
}

#[test]
fn exec_in_subshell_does_not_replace_parent_shell() {
    let script = "(exec /bin/echo hi)\necho after\n";
    let output = run_shell_with_timeout("mxsh", &["-s"], script, SHELL_TIMEOUT);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "mxsh failed with status {:?}\nstderr:\n{}",
        output.status.code(),
        stderr
    );
    assert_eq!(stdout, "hi\nafter\n");
}

#[test]
fn failed_exec_in_subshell_stops_only_the_subshell() {
    let script = "(exec definitely-not-mxsh-command; echo bad)\necho after\n";
    let output = run_shell_with_timeout("mxsh", &["-s"], script, SHELL_TIMEOUT);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "parent shell should continue after failed subshell exec; stderr:\n{stderr}"
    );
    assert_eq!(stdout, "after\n");
    assert!(
        stderr.contains("definitely-not-mxsh-command"),
        "stderr should report the failed exec lookup:\n{stderr}"
    );
}

#[test]
fn command_substitution_exec_does_not_replace_parent_shell() {
    let script = "VALUE=$(exec /bin/echo hi)\nprintf '<%s>\\n' \"$VALUE\"\necho after\n";
    let output = run_shell_with_timeout("mxsh", &["-s"], script, SHELL_TIMEOUT);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "mxsh failed with status {:?}\nstderr:\n{}",
        output.status.code(),
        stderr
    );
    assert_eq!(stdout, "<hi>\nafter\n");
}

#[test]
fn umask_changes_in_subshells_and_command_substitutions_do_not_leak() {
    let script = "\
old=$(umask)
(umask 077)
after_subshell=$(umask)
from_substitution=$(umask 077)
after_substitution=$(umask)
printf 'old=%s after_subshell=%s after_substitution=%s captured=%s\\n' \
    \"$old\" \"$after_subshell\" \"$after_substitution\" \"$from_substitution\"
test \"$old\" = \"$after_subshell\"
test \"$old\" = \"$after_substitution\"
";
    let output = run_shell_with_timeout("mxsh", &["-s"], script, SHELL_TIMEOUT);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "umask should be restored after isolated execution; stdout:\n{stdout}\nstderr:\n{stderr}"
    );
}

#[test]
fn ulimit_changes_in_isolated_execution_do_not_leak() {
    assert_ulimit_restored_after_isolated_execution("-n", "32");
    assert_ulimit_restored_after_isolated_execution("-f", "0");
}

#[test]
fn process_global_changes_through_local_functions_do_not_leak() {
    let script = "\
old=$(ulimit -n)
(f() { ulimit -n 32; }; f)
after_subshell=$(ulimit -n)
from_substitution=$(f() { ulimit -n 32; ulimit -n; }; f)
after_substitution=$(ulimit -n)
printf 'old=%s after_subshell=%s after_substitution=%s captured=%s\\n' \
    \"$old\" \"$after_subshell\" \"$after_substitution\" \"$from_substitution\"
test \"$old\" = \"$after_subshell\"
test \"$old\" = \"$after_substitution\"
test \"$from_substitution\" = 32
";
    assert_process_global_script_succeeds(
        script,
        "process-global changes through locally defined functions should be restored",
    );
}

#[test]
fn nested_process_global_pipeline_in_command_substitution_does_not_deadlock() {
    let script = "\
old=$(ulimit -n)
from_substitution=$(ulimit -n 32; ulimit -n 32 | cat >/dev/null; ulimit -n)
after_substitution=$(ulimit -n)
printf 'old=%s after_substitution=%s captured=%s\\n' \
    \"$old\" \"$after_substitution\" \"$from_substitution\"
test \"$old\" = \"$after_substitution\"
test \"$from_substitution\" = 32
";
    assert_process_global_script_succeeds(
        script,
        "nested process-global pipeline in command substitution should not deadlock",
    );
}

#[test]
fn hard_ulimit_changes_in_isolated_execution_do_not_leak() {
    assert_ulimit_restored_after_isolated_execution("-SHn", "32");
    assert_ulimit_restored_after_isolated_execution("-SHf", "0");
}

#[test]
fn trap_in_subshell_does_not_steal_parent_signal_trap() {
    let script = "\
trap 'echo parent-trap' INT
(trap 'echo child-trap' INT)
kill -INT $$
echo after
";
    let output = run_shell_with_timeout("mxsh", &["-s"], script, SHELL_TIMEOUT);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "mxsh failed with status {:?}\nstderr:\n{}",
        output.status.code(),
        stderr
    );
    assert_eq!(stdout, "parent-trap\nafter\n");
}