#![cfg(all(feature = "cli", feature = "test-support", feature = "unix-runtime"))]
mod support;
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, MutexGuard};
use libc::{RLIM_INFINITY, RLIMIT_NOFILE, getrlimit, rlim_t, rlimit, setrlimit};
use mxsh::ShellBuilder;
use mxsh::embed::{StdioConfig, TraceEvent};
use mxsh::policy::CommandOverride;
use mxsh::runtime::fd::{FileDescriptor, OsPipe};
use mxsh::runtime::testing::{InMemoryRuntime, StringStdioIn, StringStdioOut};
use mxsh::runtime::unix::UnixRuntime;
use support::{run_shell, run_shell_with_timeout, shell_program, shell_quote, temp_path};
static REFACTOR_REGRESSIONS_LOCK: Mutex<()> = Mutex::new(());
static EMBEDDED_CLI_SIGNAL_HITS: AtomicUsize = AtomicUsize::new(0);
const HIGH_AMBIENT_FD: i32 = 1050;
fn suite_lock() -> MutexGuard<'static, ()> {
REFACTOR_REGRESSIONS_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
extern "C" fn embedded_cli_signal_handler(_: i32) {
EMBEDDED_CLI_SIGNAL_HITS.fetch_add(1, Ordering::Relaxed);
}
struct SignalHandlerGuard {
signal: i32,
previous: libc::sighandler_t,
}
impl SignalHandlerGuard {
fn install(signal: i32, handler: extern "C" fn(i32)) -> Self {
let previous = unsafe { libc::signal(signal, handler as *const () as libc::sighandler_t) };
assert_ne!(
previous,
libc::SIG_ERR,
"test signal handler should install"
);
Self { signal, previous }
}
}
impl Drop for SignalHandlerGuard {
fn drop(&mut self) {
unsafe { libc::signal(self.signal, self.previous) };
}
}
struct AmbientFdGuard {
target_fd: i32,
saved_fd: Option<i32>,
saved_flags: Option<i32>,
}
impl AmbientFdGuard {
fn install(target_fd: i32, source_fd: i32) -> Self {
let saved_fd = unsafe { libc::dup(target_fd) };
let (saved_fd, saved_flags) = if saved_fd >= 0 {
let flags = unsafe { libc::fcntl(target_fd, libc::F_GETFD) };
assert!(flags >= 0, "existing fd flags should be readable");
(Some(saved_fd), Some(flags))
} else {
let err = std::io::Error::last_os_error();
assert_eq!(
err.raw_os_error(),
Some(libc::EBADF),
"target ambient fd should either exist or be invalid"
);
(None, None)
};
assert!(
unsafe { libc::dup2(source_fd, target_fd) } >= 0,
"ambient fd should install via dup2"
);
let flags = unsafe { libc::fcntl(target_fd, libc::F_GETFD) };
assert!(flags >= 0, "installed ambient fd flags should be readable");
assert!(
unsafe { libc::fcntl(target_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } >= 0,
"ambient fd should be inheritable until mxsh closes it"
);
Self {
target_fd,
saved_fd,
saved_flags,
}
}
}
impl Drop for AmbientFdGuard {
fn drop(&mut self) {
match self.saved_fd {
Some(saved_fd) => {
assert!(
unsafe { libc::dup2(saved_fd, self.target_fd) } >= 0,
"original ambient fd should restore via dup2"
);
if let Some(saved_flags) = self.saved_flags {
assert!(
unsafe { libc::fcntl(self.target_fd, libc::F_SETFD, saved_flags) } >= 0,
"restored ambient fd flags should restore"
);
}
unsafe {
libc::close(saved_fd);
}
}
None => unsafe {
libc::close(self.target_fd);
},
}
}
}
struct ResourceLimitGuard {
previous: rlimit,
}
impl ResourceLimitGuard {
fn raise_nofile_soft_at_least(minimum: u64) -> Option<Self> {
let mut previous = rlimit {
rlim_cur: 0,
rlim_max: 0,
};
assert!(
unsafe { getrlimit(RLIMIT_NOFILE as _, &mut previous) } == 0,
"RLIMIT_NOFILE should be readable"
);
let minimum = minimum as rlim_t;
if previous.rlim_cur >= minimum {
return Some(Self { previous });
}
if previous.rlim_max != RLIM_INFINITY && previous.rlim_max < minimum {
eprintln!(
"skipping high-fd regression: RLIMIT_NOFILE hard limit {} is below {}",
previous.rlim_max, minimum
);
return None;
}
let raised = rlimit {
rlim_cur: minimum,
rlim_max: previous.rlim_max,
};
assert!(
unsafe { setrlimit(RLIMIT_NOFILE as _, &raised) } == 0,
"RLIMIT_NOFILE should be raiseable for high-fd regression"
);
Some(Self { previous })
}
}
impl Drop for ResourceLimitGuard {
fn drop(&mut self) {
assert!(
unsafe { setrlimit(RLIMIT_NOFILE as _, &self.previous) } == 0,
"RLIMIT_NOFILE should restore after high-fd regression"
);
}
}
fn raise_nofile_for_high_ambient_fd() -> Option<ResourceLimitGuard> {
ResourceLimitGuard::raise_nofile_soft_at_least((HIGH_AMBIENT_FD + 1) as u64)
}
fn run_session_with_invalid_stdout(script: &str) -> i32 {
let mut session = ShellBuilder::new()
.stdio(StdioConfig {
stdout: FileDescriptor::INVALID,
..StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = UnixRuntime::new();
session.run(&mut runtime, script).status
}
fn ambient_output_file(test_name: &str) -> (PathBuf, PathBuf, File) {
let dir = temp_path(test_name);
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let out = dir.join("ambient.out");
let ambient_file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&out)
.expect("ambient fd target should be writable");
(dir, out, ambient_file)
}
fn add_inherited_fd(command: &mut Command, child_fd: i32, source_fd: i32) {
unsafe {
command.pre_exec(move || {
if libc::dup2(source_fd, child_fd) < 0 {
return Err(std::io::Error::last_os_error());
}
let flags = libc::fcntl(child_fd, libc::F_GETFD);
if flags < 0 {
return Err(std::io::Error::last_os_error());
}
if libc::fcntl(child_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[test]
fn expansion_context_regressions_match_posix_field_rules() {
let _guard = suite_lock();
let dir = temp_path("refactor-expansion");
let home = dir.join("home");
fs::create_dir_all(&home).expect("home should be creatable");
fs::write(dir.join("a"), "").expect("glob a");
fs::write(dir.join("b"), "").expect("glob b");
let script = format!(
"\
cd {}
x='echo hi'
$x there
EMPTY=
$EMPTY $(echo side >&2)
for f in *; do printf '<%s>\\n' \"$f\"; done
HOME={}
echo hi >~/out
X=\"~\"
printf 'assign=<%s> redir=<%s>\\n' \"$X\" \"$(cat {}/out)\"
",
shell_quote(dir.to_str().expect("temp path is utf8")),
shell_quote(home.to_str().expect("home path is utf8")),
shell_quote(home.to_str().expect("home path is utf8")),
);
let output = run_shell("mxsh", &["-c", &script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"hi there\n<a>\n<b>\n<home>\nassign=<~> redir=<hi>\n"
);
assert!(String::from_utf8_lossy(&output.stderr).contains("side"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn structured_expansion_preserves_nested_special_fields_and_arithmetic_text() {
let _guard = suite_lock();
let script = "\
set -- a b
unset x X
printf '<%s>\\n' \"${x:-\"$@\"}\"
x=1
printf '[%s]\\n' \"${x:+\"$@\"}\"
printf 'arith=%s\\n' \"$(( ${X:-2} + $(printf 3) ))\"
printf 'count=%s star=%s at=%s\\n' \"${#}\" \"${#*}\" \"${#@}\"
cat <<EOF
foo\\
bar
EOF
";
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"<a>\n<b>\n[a]\n[b]\narith=5\ncount=2 star=2 at=2\nfoobar\n"
);
}
#[test]
fn field_splitting_matches_mixed_ifs_empty_field_rules() {
let _guard = suite_lock();
let script = "\
IFS=', '
X=' a, b ,'
set -- $X
printf '<%s>\\n' \"$@\"
IFS=,
X='a,,b,'
set -- $X
printf '[%s]\\n' \"$@\"
";
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"<a>\n<b>\n[a]\n[]\n[b]\n"
);
}
#[test]
fn literal_command_and_argument_words_do_not_field_split_on_ifs() {
let _guard = suite_lock();
let script = "\
IFS=i
printf 'cmd-ok\\n'
IFS=,
set -- a,b
printf 'literal-set=%s:<%s>:<%s>\\n' \"$#\" \"$1\" \"${2-unset}\"
printf 'literal-arg=<%s>\\n' a,b
X=a,b
set -- ${X}
printf 'param-only=%s:<%s>:<%s>:<%s>\\n' \"$#\" \"$1\" \"$2\" \"${3-unset}\"
set -- pre${X}suf
printf 'param-mixed=%s:<%s>:<%s>:<%s>\\n' \"$#\" \"$1\" \"$2\" \"${3-unset}\"
set -- $(printf 'a,b'),lit
printf 'command-mixed=%s:<%s>:<%s>:<%s>\\n' \"$#\" \"$1\" \"$2\" \"${3-unset}\"
IFS=5
set -- a$((2 + 3))b
printf 'arith-mixed=%s:<%s>:<%s>:<%s>\\n' \"$#\" \"$1\" \"$2\" \"${3-unset}\"
IFS=,
unset Y
set -- ${Y:-a,}b
printf 'default-mixed=%s:<%s>:<%s>:<%s>\\n' \"$#\" \"$1\" \"$2\" \"${3-unset}\"
set -- ${Y:-\"a,\"}b
printf 'quoted-default=%s:<%s>:<%s>\\n' \"$#\" \"$1\" \"${2-unset}\"
set -- ${Y:-}b
printf 'empty-default-suffix=%s:<%s>:<%s>\\n' \"$#\" \"$1\" \"${2-unset}\"
set -- ${Y:-}
printf 'empty-default-only=%s:<%s>\\n' \"$#\" \"${1-unset}\"
set -- ${Y:-\"\"}
printf 'quoted-empty-default=%s:<%s>\\n' \"$#\" \"${1-unset}\"
";
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"cmd-ok\n\
literal-set=1:<a,b>:<unset>\n\
literal-arg=<a,b>\n\
param-only=2:<a>:<b>:<unset>\n\
param-mixed=2:<prea>:<bsuf>:<unset>\n\
command-mixed=2:<a>:<b,lit>:<unset>\n\
arith-mixed=2:<a>:<b>:<unset>\n\
default-mixed=2:<a>:<b>:<unset>\n\
quoted-default=1:<a,b>:<unset>\n\
empty-default-suffix=1:<b>:<unset>\n\
empty-default-only=0:<unset>\n\
quoted-empty-default=1:<>\n"
);
}
#[test]
fn parameter_replacement_words_preserve_inner_quote_masks() {
let _guard = suite_lock();
let dir = temp_path("refactor-parameter-quotes");
let home = dir.join("home");
fs::create_dir_all(&home).expect("home should be creatable");
fs::write(dir.join("a"), "").expect("glob target should exist");
let script = format!(
"\
cd {}
HOME={}
unset x
set -- ${{x:-\"*\"}}
printf 'quoted-glob=%s:<%s>\\n' \"$#\" \"$1\"
set -- ${{x:-\"a b\"}}
printf 'quoted-space=%s:<%s>:<%s>\\n' \"$#\" \"$1\" \"${{2-unset}}\"
set -- ${{x:-~}} ${{x:-\"~\"}}
printf 'tilde=<%s>:<%s>\\n' \"$1\" \"$2\"
x=set
set -- ${{x+\"*\"}}
printf 'quoted-plus=%s:<%s>\\n' \"$#\" \"$1\"
unset x
set -- ${{x:=\"*\"}}
printf 'quoted-equal=%s:<%s>:x=<%s>\\n' \"$#\" \"$1\" \"$x\"
",
shell_quote(dir.to_str().expect("temp path is utf8")),
shell_quote(home.to_str().expect("home path is utf8")),
);
let output = run_shell("mxsh", &["-c", &script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
format!(
"quoted-glob=1:<*>\n\
quoted-space=1:<a b>:<unset>\n\
tilde=<{}>:<~>\n\
quoted-plus=1:<*>\n\
quoted-equal=1:<*>:x=<*>\n",
home.display()
)
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn quoted_dollar_at_in_replacement_words_preserves_positional_fields() {
let _guard = suite_lock();
let script = "\
set -- a b
unset x
printf '<%s>\\n' \"${x:-\"$@\"}\"
x=1
printf '[%s]\\n' \"${x:+\"$@\"}\"
unset x
printf '{%s}\\n' \"${x:=\"$@\"}\"
printf 'x=<%s>\\n' \"$x\"
unset x
printf 'p<%s>\\n' p\"${x:-\"$@\"}\"s
x=1
printf 'q<%s>\\n' p\"${x:+\"$@\"}\"s
";
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"<a>\n<b>\n[a]\n[b]\n{a}\n{b}\nx=<a b>\np<pa>\np<bs>\nq<pa>\nq<bs>\n"
);
}
#[test]
fn exit_trap_status_finalizes_subshell_and_command_substitution_status() {
let _guard = suite_lock();
let script = "\
(trap 'exit 7' EXIT)
echo sub=$?
x=$(trap 'exit 8' EXIT)
echo cmd=$?
";
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "sub=7\ncmd=8\n");
}
#[test]
fn shell_pipeline_stages_do_not_deadlock_on_large_intermediate_output() {
let _guard = suite_lock();
let payload = "x".repeat(256 * 1024);
let script = format!(
"printf '%s' {} | read captured\necho ok\n",
shell_quote(&payload)
);
let output = run_shell_with_timeout(
"mxsh",
&["-c", &script],
"",
std::time::Duration::from_secs(5),
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "ok\n");
}
#[test]
fn shell_to_shell_pipeline_consumers_run_concurrently() {
let _guard = suite_lock();
let output = run_shell_with_timeout(
"mxsh",
&[
"-c",
"while :; do echo x; done | { read x; printf 'got=%s\\n' \"$x\"; }\necho after",
],
"",
std::time::Duration::from_secs(5),
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "got=x\nafter\n");
}
#[test]
fn shell_pipeline_heredoc_command_substitution_does_not_deadlock() {
let _guard = suite_lock();
let script = "\
f_echo() { printf '%s\\n' \"$@\"; }
: && : | cat << EOF
$(f_echo alpha | cat)
EOF
echo after
";
let output = run_shell_with_timeout(
"mxsh",
&["-c", script],
"",
std::time::Duration::from_secs(5),
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "alpha\nafter\n");
}
#[test]
fn function_pipeline_inside_subshell_does_not_deadlock() {
let _guard = suite_lock();
let script = "\
f_echo() { printf '%s\\n' \"$@\"; }
f_wrap() { { printf '%s\\n' \"$1\"; } | cat; }
: && ( f_echo alpha ; f_wrap alpha && printf '%s\\n' done )
echo after
";
let output = run_shell_with_timeout(
"mxsh",
&["-c", script],
"",
std::time::Duration::from_secs(5),
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"alpha\nalpha\ndone\nafter\n"
);
}
#[test]
fn builtin_pipeline_producers_stop_on_broken_pipe() {
let _guard = suite_lock();
let output = run_shell_with_timeout(
"mxsh",
&["-c", "while :; do echo x; done | head -n1; echo after"],
"",
std::time::Duration::from_secs(5),
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "x\nafter\n");
}
#[test]
fn output_builtin_write_failures_are_command_failures() {
let _guard = suite_lock();
assert_eq!(
run_session_with_invalid_stdout("trap 'echo cleanup' EXIT; trap"),
1
);
assert_eq!(run_session_with_invalid_stdout("set -o"), 1);
let closed_stdout_cases = [
("echo", "echo hi >&-; echo status=$?"),
("printf", "printf hi >&-; echo status=$?"),
("pwd", "pwd >&-; echo status=$?"),
("alias", "alias ll='echo ll'; alias ll >&-; echo status=$?"),
("command", "command -v echo >&-; echo status=$?"),
("type", "type echo >&-; echo status=$?"),
("export", "command export -p >&-; echo status=$?"),
(
"readonly",
"readonly X=1; command readonly -p >&-; echo status=$?",
),
("set", "command set -o >&-; echo status=$?"),
("times", "command times >&-; echo status=$?"),
("trap", "trap : EXIT; command trap >&-; echo status=$?"),
("umask", "umask >&-; echo status=$?"),
("builtin", "builtin >&-; echo status=$?"),
("ulimit", "ulimit >&-; echo status=$?"),
(
"jobs",
"sleep 1 & jobs >&-; status=$?; wait; echo status=$status",
),
];
for (name, script) in closed_stdout_cases {
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(
output.status.code(),
Some(0),
"{name} should preserve shell execution after a regular builtin write failure"
);
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"status=1\n",
"{name} should return failure when stdout is closed"
);
}
}
#[test]
fn interactive_expansion_errors_do_not_force_shell_exit() {
let _guard = suite_lock();
let input = StringStdioIn::new("set -u\necho \"$MISSING\"\necho alive\nexit\n");
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut session = ShellBuilder::new()
.interactive(true)
.history_appender(|_| {})
.stdio(StdioConfig {
stdin: input.fd(),
stdout: stdout.fd(),
stderr: stderr.fd(),
})
.new_session()
.expect("interactive session should build");
let mut runtime = InMemoryRuntime::new();
let outcome = session
.run_interactive(&mut runtime)
.expect("interactive shell should run");
input.join();
assert_eq!(outcome.exit_code, Some(0));
assert!(stdout.collect().contains("alive\n"));
assert!(stderr.collect().contains("MISSING: parameter not set"));
}
#[test]
fn alias_recheck_and_completeness_use_current_aliases() {
let _guard = suite_lock();
let trailing_blank = run_shell(
"mxsh",
&["-c", "alias foo='bar '\nalias bar='echo ok'\nfoo\n"],
"",
);
assert_eq!(trailing_blank.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&trailing_blank.stdout), "ok\n");
let streaming = run_shell_with_timeout(
"mxsh",
&[],
"alias wrap='if true; then '\nwrap echo streamed\nfi\n",
std::time::Duration::from_secs(5),
);
assert_eq!(streaming.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&streaming.stdout), "streamed\n");
}
#[test]
fn cyclic_aliases_do_not_abort_process() {
let _guard = suite_lock();
let output = run_shell_with_timeout(
"mxsh",
&[],
"alias a=b\nalias b=a\na\n",
std::time::Duration::from_secs(5),
);
assert_eq!(output.status.code(), Some(127));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert!(String::from_utf8_lossy(&output.stderr).contains("a"));
}
#[test]
fn command_status_and_control_flow_regressions_are_preserved() {
let _guard = suite_lock();
let script = "\
set -e
if false; then echo bad-if; fi
false && echo bad-and
! true
echo errexit-ok
set +e
f(){ if return 7; then echo bad-return; fi; }
f
echo if-return=$?
g(){ while return 8; do echo bad-loop; done; }
g
echo while-return=$?
h(){ ! return 9; }
h
echo bang-return=$?
";
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"errexit-ok\nif-return=7\nwhile-return=8\nbang-return=9\n"
);
}
#[test]
fn eval_preserves_ordinary_status_and_control_flow() {
let _guard = suite_lock();
let handled = run_shell(
"mxsh",
&["-c", "eval false || echo handled; echo after"],
"",
);
assert_eq!(handled.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&handled.stdout), "handled\nafter\n");
let returned = run_shell(
"mxsh",
&["-c", "f(){ eval 'return 7'; echo bad; }; f; echo status=$?"],
"",
);
assert_eq!(returned.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&returned.stdout), "status=7\n");
let dir = temp_path("eval-dot-return");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let lib = dir.join("lib.sh");
fs::write(&lib, "eval 'return 9'\necho bad\n").expect("dot script should be writable");
let script = format!(
". {}; echo dot=$?",
shell_quote(lib.to_str().expect("dot script path should be utf8"))
);
let dot_returned = run_shell("mxsh", &["-c", &script], "");
assert_eq!(dot_returned.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&dot_returned.stdout), "dot=9\n");
let _ = fs::remove_dir_all(&dir);
let syntax_error = run_shell(
"mxsh",
&["-c", "eval 'if true; then echo bad'; echo after"],
"",
);
assert_ne!(syntax_error.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&syntax_error.stdout), "");
}
#[test]
fn background_contexts_keep_logical_identity_and_drop_inherited_exit_traps() {
let _guard = suite_lock();
let output = run_shell(
"mxsh",
&[
"-c",
"trap 'echo cleanup' EXIT; echo parent=$$; echo bg=$$ & wait; echo after",
],
"",
);
assert_eq!(output.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines.len(),
4,
"stdout should contain one cleanup: {stdout:?}"
);
assert!(lines[0].starts_with("parent="));
assert_eq!(
lines[1],
lines[0].replacen("parent=", "bg=", 1),
"background $$ should match the invoking shell"
);
assert_eq!(lines[2], "after");
assert_eq!(lines[3], "cleanup");
}
#[test]
fn embedded_wait_unknown_pid_does_not_reap_host_children() {
let _guard = suite_lock();
let mut host_child = Command::new("/bin/sleep")
.arg("2")
.spawn()
.expect("host child should spawn");
let pid = host_child.id();
let stdout = StringStdioOut::new();
let mut session = ShellBuilder::new()
.stdio(StdioConfig {
stdout: stdout.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = UnixRuntime::new();
let outcome = session.run(&mut runtime, &format!("wait {pid}; echo status=$?"));
let host_status = host_child
.try_wait()
.expect("host child should still be owned by the host");
assert_eq!(outcome.status, 0);
assert_eq!(stdout.collect(), "status=127\n");
assert_eq!(host_status, None, "shell wait must not reap host children");
let _ = host_child.kill();
let _ = host_child.wait();
}
#[test]
fn cli_entrypoint_option_boundaries_match_posix_forms() {
let _guard = suite_lock();
let stdin_script = "printf 'one=<%s> two=<%s>\\n' \"$1\" \"${2-unset}\"\n";
let dash_s = run_shell("mxsh", &["-s", "arg1"], stdin_script);
assert_eq!(dash_s.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&dash_s.stdout),
"one=<arg1> two=<unset>\n"
);
let bare_double_dash = run_shell("mxsh", &["--"], "echo stdin-ok\n");
assert_eq!(bare_double_dash.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&bare_double_dash.stdout),
"stdin-ok\n"
);
let dash_c_positionals = run_shell(
"mxsh",
&[
"-c",
"printf 'zero=<%s> one=<%s>\\n' \"$0\" \"${1-unset}\"",
"--",
"arg",
],
"",
);
assert_eq!(dash_c_positionals.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&dash_c_positionals.stdout),
"zero=<--> one=<arg>\n"
);
let dash_c_stops_option_parsing = run_shell("mxsh", &["-c", "echo should-run", "-n"], "");
assert_eq!(dash_c_stops_option_parsing.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&dash_c_stops_option_parsing.stdout),
"should-run\n"
);
}
#[test]
fn command_builtin_still_honors_unspecified_utility_policy() {
let _guard = suite_lock();
let dir = temp_path("refactor-command-policy");
fs::create_dir_all(&dir).expect("temp command dir should be creatable");
let source = dir.join("source");
fs::write(&source, "#!/bin/sh\necho bypass\n").expect("source command should be writable");
let mut perms = fs::metadata(&source)
.expect("source command metadata")
.permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
fs::set_permissions(&source, perms).expect("source command permissions");
let output = run_shell(
"mxsh",
&[
"-c",
&format!(
"PATH={} command source",
shell_quote(dir.to_str().expect("temp path is utf8"))
),
],
"",
);
assert_eq!(output.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert!(
String::from_utf8_lossy(&output.stderr).contains("undefined"),
"stderr should report the command-policy failure"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn assignment_only_commands_use_last_substitution_status_in_redirects() {
let _guard = suite_lock();
let dir = temp_path("refactor-assignment-status");
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let output = run_shell(
"mxsh",
&[
"-c",
&format!(
"cd {}; A=$(exit 3) >$(printf out; exit 7); echo status=$?",
shell_quote(dir.to_str().expect("temp path is utf8")),
),
],
"",
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "status=7\n");
assert!(
dir.join("out").exists(),
"redirect command substitution should still select the target"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn assignment_words_expand_left_to_right() {
let _guard = suite_lock();
let cases = [
("assignment-only", "unset A B; A=1 B=$A; echo \"B=$B\""),
(
"external environment",
"unset A B; A=1 B=$A env | grep '^B='",
),
];
for (context, script) in cases {
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0), "{context}");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"B=1\n",
"{context}"
);
}
}
#[test]
fn assignment_only_commands_use_arithmetic_command_substitution_status() {
let _guard = suite_lock();
let status_output = run_shell(
"mxsh",
&[
"-c",
"A=$(( $(printf 2; exit 7) + 3 )); echo status=$? A=$A",
],
"",
);
assert_eq!(status_output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&status_output.stdout),
"status=7 A=5\n"
);
let errexit_output = run_shell(
"mxsh",
&["-c", "set -e; A=$(( $(printf 2; exit 7) + 3 )); echo after"],
"",
);
assert_eq!(errexit_output.status.code(), Some(7));
assert_eq!(String::from_utf8_lossy(&errexit_output.stdout), "");
}
#[test]
fn assignment_only_redirection_status_overrides_assignment_status() {
let _guard = suite_lock();
let dir = temp_path("refactor-assignment-redirection-status");
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let quoted_dir = shell_quote(dir.to_str().expect("temp path is utf8"));
let output = run_shell(
"mxsh",
&[
"-c",
&format!(
"cd {quoted_dir}; A=$(exit 3) >$(printf out; exit 7); printf 'status=%s A=<%s>\\n' \"$?\" \"$A\"",
),
],
"",
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "status=7 A=<>\n");
assert!(
dir.join("out").exists(),
"redirect command substitution should still select the target"
);
let errexit_output = run_shell(
"mxsh",
&[
"-c",
&format!("cd {quoted_dir}; set -e; A=$(exit 3) >$(printf out; exit 7); echo after"),
],
"",
);
assert_eq!(errexit_output.status.code(), Some(7));
assert_eq!(String::from_utf8_lossy(&errexit_output.stdout), "");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn assignment_only_redirections_use_arithmetic_command_substitution_status() {
let _guard = suite_lock();
let dir = temp_path("refactor-arithmetic-redirection-status");
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let output = run_shell(
"mxsh",
&[
"-c",
&format!(
"cd {}; >$(( $(printf 2; exit 7) + 3 )); echo status=$?",
shell_quote(dir.to_str().expect("temp path is utf8")),
),
],
"",
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "status=7\n");
assert!(
dir.join("5").exists(),
"redirect arithmetic command substitution should still select the target"
);
let heredoc_output = run_shell(
"mxsh",
&[
"-c",
"A=1 <<EOF\n$(( $(printf 2; exit 7) + 3 ))\nEOF\necho status=$? A=$A",
],
"",
);
assert_eq!(heredoc_output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&heredoc_output.stdout),
"status=7 A=1\n"
);
let heredoc_errexit_output = run_shell(
"mxsh",
&[
"-c",
"set -e\nA=1 <<EOF\n$(( $(printf 2; exit 7) + 3 ))\nEOF\necho after",
],
"",
);
assert_eq!(heredoc_errexit_output.status.code(), Some(7));
assert_eq!(String::from_utf8_lossy(&heredoc_errexit_output.stdout), "");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn traps_and_exit_finalization_run_in_child_contexts() {
let _guard = suite_lock();
let script = "\
(trap 'echo subexit' EXIT)
echo \"$(trap 'echo cmdexit' EXIT)\"
trap 'echo cleanup' EXIT
trap 0
trap 'echo replayed' EXIT
saved=$(trap)
trap - EXIT
eval \"$saved\"
";
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"subexit\ncmdexit\nreplayed\n"
);
}
#[test]
fn ignored_traps_are_inherited_by_external_commands() {
let _guard = suite_lock();
let script = "\
trap '' PIPE
/bin/sh -c 'kill -PIPE $$; echo pipe-survived'
(trap '' INT; /bin/sh -c 'kill -INT $$; echo int-survived')
";
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"pipe-survived\nint-survived\n"
);
}
#[test]
fn simple_command_transactions_cover_exec_assignments_and_declarations() {
let _guard = suite_lock();
let dir = temp_path("refactor-transaction");
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let out = dir.join("out");
let script = format!(
"\
cd {}
exec >out
echo redirected
Y='a b'
export X=$Y
printf 'X=<%s> b=<%s>\\n' \"$X\" \"${{b-unset}}\"
readonly R=$Y
printf 'R=<%s> b=<%s>\\n' \"$R\" \"${{b-unset}}\"
b=still-mutable
printf 'b-after=<%s>\\n' \"$b\"
unset Z
export Z
env | grep '^Z='
echo export-status=$?
printf 'Z-minus=<%s> Z-plus=<%s>\\n' \"${{Z-isunset}}\" \"${{Z+isset}}\"
",
shell_quote(dir.to_str().expect("temp path is utf8")),
);
let output = run_shell("mxsh", &["-c", &script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert_eq!(
fs::read_to_string(&out).expect("redirected output should exist"),
"redirected\nX=<a b> b=<unset>\nR=<a b> b=<unset>\nb-after=<still-mutable>\nexport-status=1\nZ-minus=<isunset> Z-plus=<>\n"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn jobs_wait_and_background_pid_own_the_spawned_process() {
let _guard = suite_lock();
let dir = temp_path("refactor-background");
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let script = format!(
"\
cd {}
false & wait
echo wait=$?
/bin/sh -c 'trap \"exit 0\" TERM; sleep 1; echo done >done' &
p=$!
kill \"$p\"
wait \"$p\" 2>/dev/null
wait 999999 2>/dev/null
echo unknown=$?
sleep 2
test -e done && echo leaked
",
shell_quote(dir.to_str().expect("temp path is utf8")),
);
let output = run_shell_with_timeout(
"mxsh",
&["-c", &script],
"",
std::time::Duration::from_secs(5),
);
assert_eq!(output.status.code(), Some(1));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"wait=0\nunknown=127\n"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn killing_background_machine_pid_terminates_foreground_child() {
let _guard = suite_lock();
let dir = temp_path("refactor-background-machine");
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let script = format!(
"\
cd {}
f() {{
/bin/sh -c 'echo ready >ready; trap \"exit 0\" TERM; sleep 1; echo done >done'
}}
f &
p=$!
while ! test -e ready; do sleep 0.01; done
kill \"$p\"
wait \"$p\" 2>/dev/null
sleep 2
if test -e done; then echo leaked; else echo contained; fi
",
shell_quote(dir.to_str().expect("temp path is utf8")),
);
let output = run_shell_with_timeout(
"mxsh",
&["-c", &script],
"",
std::time::Duration::from_secs(5),
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "contained\n");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn child_launch_fd_contracts_cover_ambient_shared_and_closed_fds() {
let _guard = suite_lock();
let (dir, ambient_out, ambient_file) = ambient_output_file("refactor-fds");
let mut ambient_cmd = Command::new(shell_program("mxsh"));
ambient_cmd.arg("-c").arg(
"echo builtin >&3; \
/bin/sh -c 'echo external >&3'; \
/bin/sh -c 'echo leaked >&3' 3>&- 2>/dev/null; \
echo marker >&3",
);
let ambient_raw_fd = ambient_file.as_raw_fd();
add_inherited_fd(&mut ambient_cmd, 3, ambient_raw_fd);
let ambient_output = ambient_cmd
.output()
.expect("ambient fd child should execute");
drop(ambient_file);
assert_eq!(ambient_output.status.code(), Some(0));
assert_eq!(
fs::read_to_string(&ambient_out).expect("ambient fd output should be readable"),
"builtin\nexternal\nmarker\n"
);
let shared_pipe = OsPipe::new().expect("shared fd pipe should be creatable");
let shared_write = shared_pipe
.write_fd
.dup()
.expect("shared write fd should duplicate");
let shared_write_fd = shared_write.as_i32();
assert!(
shared_write_fd > 3,
"shared write fd should not overlap the first inherited child fd"
);
let stdout = StringStdioOut::new();
let mut session = ShellBuilder::new()
.stdio(StdioConfig {
stdout: stdout.fd(),
..StdioConfig::default()
})
.inherited_fd(3, shared_write)
.inherited_fd(shared_write_fd, shared_write)
.new_session()
.expect("session with shared inherited fds should build");
let mut runtime = UnixRuntime::new();
let script = format!(
"/bin/sh -c 'printf three >&3; printf four >&{shared_write_fd}'; \
/usr/bin/true >&-; echo closed=$?"
);
let outcome = session.run(&mut runtime, &script);
shared_pipe.write_fd.close();
shared_write.close();
let shared_output = shared_pipe.read_fd.read_all();
assert_eq!(outcome.status, 0);
assert_eq!(shared_output, "threefour");
assert_eq!(stdout.collect(), "closed=0\n");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn duplicated_stdio_redirections_do_not_leak_source_fds() {
let _guard = suite_lock();
let external_probe = r#"/bin/sh -c 'i=3; while [ "$i" -lt 128 ]; do /bin/sh -c '"'"': >&$1'"'"' sh "$i" 2>/dev/null && echo fd=$i; i=$((i + 1)); done; exit 0'"#;
for (without_redirect, with_redirect) in [
(external_probe.to_string(), format!("{external_probe} 2>&1")),
(
format!("exec {external_probe}"),
format!("exec {external_probe} 2>&1"),
),
] {
let baseline = run_shell_with_timeout(
"mxsh",
&["-c", &without_redirect],
"",
std::time::Duration::from_secs(5),
);
let output = run_shell_with_timeout(
"mxsh",
&["-c", &with_redirect],
"",
std::time::Duration::from_secs(5),
);
assert_eq!(baseline.status.code(), Some(0), "{without_redirect}");
assert_eq!(output.status.code(), Some(0), "{with_redirect}");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&baseline.stdout),
"{with_redirect}"
);
assert_eq!(
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&baseline.stderr),
"{with_redirect}"
);
}
}
fn assert_background_inherited_fd_round_trips(child_fd: i32) {
let pipe = OsPipe::new().expect("background inherited fd pipe should be creatable");
let inherited_write = pipe
.write_fd
.dup()
.expect("background inherited fd should duplicate");
let mut session = ShellBuilder::new()
.inherited_fd(child_fd, inherited_write)
.new_session()
.expect("session with inherited fd should build");
let mut runtime = UnixRuntime::new();
let script =
format!("echo foreground >&{child_fd}; f(){{ echo background >&{child_fd}; }}; f & wait");
let outcome = session.run(&mut runtime, &script);
pipe.write_fd.close();
inherited_write.close();
assert_eq!(outcome.status, 0);
assert_eq!(pipe.read_fd.read_all(), "foreground\nbackground\n");
}
#[test]
fn background_machine_jobs_preserve_inherited_fd_mappings() {
let _guard = suite_lock();
assert_background_inherited_fd_round_trips(3);
}
#[test]
fn background_machine_payload_fd_does_not_shadow_inherited_fd_255() {
let _guard = suite_lock();
let Some(_limit_guard) = ResourceLimitGuard::raise_nofile_soft_at_least(256) else {
return;
};
assert_background_inherited_fd_round_trips(255);
}
#[test]
fn embedded_sessions_close_untracked_ambient_fds_before_external_exec() {
let _guard = suite_lock();
let (dir, out, ambient_file) = ambient_output_file("refactor-embedded-ambient");
let _ambient_fd = AmbientFdGuard::install(63, ambient_file.as_raw_fd());
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut session = ShellBuilder::new()
.stdio(StdioConfig {
stdout: stdout.fd(),
stderr: stderr.fd(),
..StdioConfig::default()
})
.new_session()
.expect("embedded session should build");
let mut runtime = UnixRuntime::new();
let outcome = session.run(
&mut runtime,
"echo builtin >&63; /bin/sh -c 'echo external >&63' 2>/dev/null; echo done",
);
assert_eq!(outcome.status, 0);
assert_eq!(stdout.collect(), "done\n");
assert_eq!(
fs::read_to_string(&out).expect("ambient fd output should be readable"),
""
);
let _ = stderr.collect();
let _ = fs::remove_dir_all(dir);
}
#[test]
fn embedded_sessions_close_high_numbered_untracked_ambient_fds_before_external_exec() {
let _guard = suite_lock();
let Some(_limit_guard) = raise_nofile_for_high_ambient_fd() else {
return;
};
let (dir, out, ambient_file) = ambient_output_file("refactor-embedded-high-ambient");
let _ambient_fd = AmbientFdGuard::install(HIGH_AMBIENT_FD, ambient_file.as_raw_fd());
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut session = ShellBuilder::new()
.stdio(StdioConfig {
stdout: stdout.fd(),
stderr: stderr.fd(),
..StdioConfig::default()
})
.new_session()
.expect("embedded session should build");
let mut runtime = UnixRuntime::new();
let script = format!(
"echo builtin >&{HIGH_AMBIENT_FD}; \
/bin/sh -c 'echo external >&{HIGH_AMBIENT_FD}' 2>/dev/null; \
/bin/sh -c 'echo pipeline >&{HIGH_AMBIENT_FD}' 2>/dev/null | /bin/cat; \
echo done"
);
let outcome = session.run(&mut runtime, &script);
assert_eq!(outcome.status, 0);
assert_eq!(stdout.collect(), "done\n");
assert_eq!(
fs::read_to_string(&out).expect("ambient fd output should be readable"),
""
);
let _ = stderr.collect();
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cli_imports_intentional_ambient_fds_for_builtins_and_external_commands() {
let _guard = suite_lock();
let (dir, out, ambient_file) = ambient_output_file("refactor-cli-ambient-import");
let source_fd = ambient_file.as_raw_fd();
let target_fd = 63;
let mut command = Command::new(shell_program("mxsh"));
command.args(["-c", "echo builtin >&63; /bin/sh -c 'echo external >&63'"]);
add_inherited_fd(&mut command, target_fd, source_fd);
let output = command.output().expect("mxsh cli should run");
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert_eq!(String::from_utf8_lossy(&output.stderr), "");
assert_eq!(
fs::read_to_string(&out).expect("ambient fd output should be readable"),
"builtin\nexternal\n"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cli_imports_high_numbered_intentional_ambient_fds() {
let _guard = suite_lock();
let Some(_limit_guard) = raise_nofile_for_high_ambient_fd() else {
return;
};
let (dir, out, ambient_file) = ambient_output_file("refactor-cli-high-ambient-import");
let source_fd = ambient_file.as_raw_fd();
let mut command = Command::new(shell_program("mxsh"));
let script =
format!("echo builtin >&{HIGH_AMBIENT_FD}; /bin/sh -c 'echo external >&{HIGH_AMBIENT_FD}'");
command.args(["-c", &script]);
add_inherited_fd(&mut command, HIGH_AMBIENT_FD, source_fd);
let output = command.output().expect("mxsh cli should run");
assert_eq!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert_eq!(String::from_utf8_lossy(&output.stderr), "");
assert_eq!(
fs::read_to_string(&out).expect("ambient fd output should be readable"),
"builtin\nexternal\n"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn multi_tenant_run_cli_does_not_import_ambient_fds() {
let _guard = suite_lock();
let (dir, out, ambient_file) = ambient_output_file("refactor-multi-tenant-cli-ambient");
let target_fd = 63;
let _ambient_fd = AmbientFdGuard::install(target_fd, ambient_file.as_raw_fd());
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut shell = ShellBuilder::new()
.multi_tenant()
.stdio(StdioConfig {
stdout: stdout.fd(),
stderr: stderr.fd(),
..StdioConfig::default()
})
.build(UnixRuntime::new())
.expect("multi-tenant shell should build");
let script = format!(
"echo builtin >&{target_fd}; \
/bin/sh -c 'echo external >&{target_fd}' 2>/dev/null; \
echo done"
);
let argv = vec!["mxsh".to_string(), "-c".to_string(), script];
let outcome = shell.run_cli(&argv);
assert_eq!(outcome.status, 0);
assert_eq!(stdout.collect(), "done\n");
assert_eq!(
fs::read_to_string(&out).expect("ambient fd output should be readable"),
""
);
let _ = stderr.collect();
let _ = fs::remove_dir_all(dir);
}
#[test]
fn multi_tenant_run_cli_preserves_explicit_inherited_fds() {
let _guard = suite_lock();
let (dir, out, inherited_file) = ambient_output_file("refactor-multi-tenant-cli-explicit-fd");
let target_fd = 63;
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut shell = ShellBuilder::new()
.multi_tenant()
.inherited_fd(target_fd, FileDescriptor::new(inherited_file.as_raw_fd()))
.stdio(StdioConfig {
stdout: stdout.fd(),
stderr: stderr.fd(),
..StdioConfig::default()
})
.build(UnixRuntime::new())
.expect("multi-tenant shell with inherited fd should build");
let script = format!("echo builtin >&{target_fd}; /bin/sh -c 'echo external >&{target_fd}'");
let argv = vec!["mxsh".to_string(), "-c".to_string(), script];
let outcome = shell.run_cli(&argv);
assert_eq!(outcome.status, 0);
assert_eq!(stdout.collect(), "");
assert_eq!(stderr.collect(), "");
assert_eq!(
fs::read_to_string(&out).expect("explicit fd output should be readable"),
"builtin\nexternal\n"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn embedded_external_pipelines_close_untracked_ambient_fds() {
let _guard = suite_lock();
let (dir, out, ambient_file) = ambient_output_file("refactor-pipeline-ambient");
let _ambient_fd = AmbientFdGuard::install(63, ambient_file.as_raw_fd());
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut session = ShellBuilder::new()
.stdio(StdioConfig {
stdout: stdout.fd(),
stderr: stderr.fd(),
..StdioConfig::default()
})
.new_session()
.expect("embedded session should build");
let mut runtime = UnixRuntime::new();
let outcome = session.run(
&mut runtime,
"/bin/sh -c 'echo external >&63' | /bin/cat; echo done",
);
assert_eq!(outcome.status, 0);
assert_eq!(stdout.collect(), "done\n");
assert_eq!(
fs::read_to_string(&out).expect("ambient fd output should be readable"),
""
);
let _ = stderr.collect();
let _ = fs::remove_dir_all(dir);
}
#[test]
fn functions_and_dot_scripts_have_explicit_frames_and_redirects() {
let _guard = suite_lock();
let dir = temp_path("refactor-frames");
fs::create_dir_all(&dir).expect("temp dir should be creatable");
fs::write(dir.join("lib.sh"), "echo lib1=$1 lib2=$2\nreturn 7\n").expect("write lib");
let script = format!(
"\
cd {}
set -- outer
f() {{ echo zero=$0 one=$1; }} >func.out
f arg
. ./lib.sh A B
echo dot=$? after=$1
cat func.out
",
shell_quote(dir.to_str().expect("temp path is utf8")),
);
let output = run_shell("mxsh", &["-c", &script, "script0"], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"lib1=A lib2=B\ndot=7 after=outer\nzero=script0 one=arg\n"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cd_cdpath_and_option_regressions() {
let _guard = suite_lock();
let dir = temp_path("refactor-cd-cdpath-options");
let cdpath_root = dir.join("cdpath");
fs::create_dir_all(cdpath_root.join("target")).expect("cdpath target");
let script = format!(
"\
CDPATH={}
cd target >/dev/null
printf 'cdpath:%s:%s\\n' \"$?\" \"$PWD\"
cd -P / >/dev/null
printf 'physical:%s:%s\\n' \"$?\" \"$PWD\"
cd -L / >/dev/null
printf 'logical:%s:%s\\n' \"$?\" \"$PWD\"
",
shell_quote(cdpath_root.to_str().expect("cdpath is utf8")),
);
let output = run_shell("mxsh", &["-c", &script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
format!(
"cdpath:0:{}\nphysical:0:/\nlogical:0:/\n",
cdpath_root.join("target").display(),
)
);
assert_eq!(String::from_utf8_lossy(&output.stderr), "");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn path_cli_and_builtin_boundary_regressions() {
let _guard = suite_lock();
let dir = temp_path("refactor-path");
let cdpath_root = dir.join("cdpath");
let logical = dir.join("link");
fs::create_dir_all(cdpath_root.join("target")).expect("cdpath target");
fs::create_dir_all(dir.join("real").join("sub")).expect("real dir");
std::os::unix::fs::symlink(dir.join("real"), &logical).expect("symlink");
let script = format!(
"\
CDPATH={}
cd target >/dev/null
pwd
cd -P / >/dev/null
cd {}
cd sub/..
pwd
set -
set +
command
command -- echo command-ok
printf '%d:%b\\n' 42 'a\\nb'
printf '[%5s:%04d:%.4x]\\n' x 7 15
set -- a b
shift 0
printf 'shift0=%s/%s\\n' \"$1\" \"$2\"
",
shell_quote(cdpath_root.to_str().expect("cdpath is utf8")),
shell_quote(logical.to_str().expect("link is utf8")),
);
let output = run_shell("mxsh", &["-c", &script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
format!(
"{}\n{}\ncommand-ok\n42:a\nb\n[ x:0007:000f]\n",
cdpath_root.join("target").display(),
logical.display(),
) + "shift0=a/b\n"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn printf_supports_posix_float_conversions() {
let _guard = suite_lock();
let output = run_shell(
"mxsh",
&[
"-c",
"printf '[%.2f][%.2e][%.3g][%.3G][%#.0f][%#.0e][%#.3g]\\n' 3.14159 3.14159 12345 12345 2 2 2",
],
"",
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"[3.14][3.14e+00][1.23e+04][1.23E+04][2.][2.e+00][2.00]\n"
);
}
#[test]
fn printf_octal_escapes_emit_raw_bytes() {
let _guard = suite_lock();
let escaped_arg = run_shell("mxsh", &["-c", "printf '%b' '\\0300'"], "");
assert_eq!(escaped_arg.status.code(), Some(0));
assert_eq!(escaped_arg.stdout, vec![0xc0]);
let escaped_format = run_shell("mxsh", &["-c", "printf '\\0300'"], "");
assert_eq!(escaped_format.status.code(), Some(0));
assert_eq!(escaped_format.stdout, vec![0xc0]);
}
#[test]
fn printf_numeric_operands_reject_invalid_and_accept_quoted_characters() {
let _guard = suite_lock();
let invalid = run_shell(
"mxsh",
&["-c", "printf '%d\\n' nope; printf 'status=%s\\n' \"$?\""],
"",
);
assert_eq!(invalid.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&invalid.stdout), "0\nstatus=1\n");
assert!(
String::from_utf8_lossy(&invalid.stderr).contains("printf: nope: invalid number"),
"stderr should report the invalid numeric operand"
);
let quoted_char = run_shell(
"mxsh",
&["-c", "printf '%d\\n' \"'A\"; printf 'status=%s\\n' \"$?\""],
"",
);
assert_eq!(quoted_char.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy("ed_char.stdout),
"65\nstatus=0\n"
);
assert_eq!(String::from_utf8_lossy("ed_char.stderr), "");
let hex_float = run_shell(
"mxsh",
&["-c", "printf '%f\\n' 0x1p2; printf 'status=%s\\n' \"$?\""],
"",
);
assert_eq!(hex_float.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&hex_float.stdout),
"4.000000\nstatus=0\n"
);
assert_eq!(String::from_utf8_lossy(&hex_float.stderr), "");
}
#[test]
fn builtin_cleanup_covers_remaining_bug_reproducers() {
let _guard = suite_lock();
let trap = run_shell("mxsh", &["-c", "trap 'echo bad' 999; echo after"], "");
assert_eq!(trap.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&trap.stdout), "");
assert!(
String::from_utf8_lossy(&trap.stderr).contains("invalid signal specification"),
"invalid numeric trap operands should be diagnosed without panicking"
);
let identifiers = run_shell(
"mxsh",
&[
"-c",
"read 1BAD <<EOF\nx\nEOF\necho read=$?\nfor 1BAD in x; do echo body; done",
],
"",
);
assert_eq!(identifiers.status.code(), Some(2));
assert_eq!(String::from_utf8_lossy(&identifiers.stdout), "read=1\n");
let identifier_stderr = String::from_utf8_lossy(&identifiers.stderr);
assert!(identifier_stderr.contains("read: 1BAD: invalid identifier"));
assert!(identifier_stderr.contains("for: 1BAD: invalid identifier"));
let export_identifier = run_shell("mxsh", &["-c", "export 1BAD=x; echo after"], "");
assert_eq!(export_identifier.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&export_identifier.stdout), "");
assert!(String::from_utf8_lossy(&export_identifier.stderr).contains("invalid identifier"));
let shift_invalid = run_shell("mxsh", &["-c", "set -- a b; shift x; echo after"], "");
assert_eq!(shift_invalid.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&shift_invalid.stdout), "");
assert!(String::from_utf8_lossy(&shift_invalid.stderr).contains("invalid count"));
let shift_negative = run_shell("mxsh", &["-c", "set -- a b; shift -1; echo after"], "");
assert_eq!(shift_negative.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&shift_negative.stdout), "");
assert!(String::from_utf8_lossy(&shift_negative.stderr).contains("invalid count"));
let readonly_pwd = run_shell(
"mxsh",
&[
"-c",
"old=$PWD; readonly PWD; cd /tmp; printf 'status=%s pwd=%s real=%s\\n' \"$?\" \"$PWD\" \"$(/bin/pwd)\"",
],
"",
);
assert_eq!(readonly_pwd.status.code(), Some(0));
let readonly_pwd_stdout = String::from_utf8_lossy(&readonly_pwd.stdout);
assert!(
readonly_pwd_stdout.contains("status=1"),
"cd should fail when readonly PWD cannot be updated"
);
assert!(String::from_utf8_lossy(&readonly_pwd.stderr).contains("PWD: readonly variable"));
let umask = run_shell(
"mxsh",
&[
"-c",
"old=$(umask); umask u=rwx,go=rx; printf 'mask=%s\\n' \"$(umask)\"; umask \"$old\"",
],
"",
);
assert_eq!(umask.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&umask.stdout), "mask=0022\n");
let dir = temp_path("refactor-ulimit-fsize");
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let ulimit = run_shell(
"mxsh",
&[
"-c",
&format!(
"cd {}; ulimit -f 1; /usr/bin/printf xx >out; wc -c <out",
shell_quote(dir.to_str().expect("temp path is utf8")),
),
],
"",
);
assert_eq!(ulimit.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&ulimit.stdout).trim(), "2");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn noexec_parses_but_does_not_execute_commands() {
let _guard = suite_lock();
let cli_option = run_shell("mxsh", &["-n", "-c", "echo bad"], "");
assert_eq!(cli_option.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&cli_option.stdout), "");
let set_option = run_shell("mxsh", &["-c", "set -n; echo bad"], "");
assert_eq!(set_option.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&set_option.stdout), "");
let short_circuit_rhs = run_shell("mxsh", &["-c", "set -n && echo bad"], "");
assert_eq!(short_circuit_rhs.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&short_circuit_rhs.stdout), "");
let syntax_error = run_shell("mxsh", &["-n", "-c", "if true; then echo bad"], "");
assert_ne!(syntax_error.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&syntax_error.stdout), "");
}
#[test]
fn special_builtin_errors_abort_non_interactive_scripts() {
let _guard = suite_lock();
let cases = [
"export X=1 >/no/such/dir/out; echo after",
"readonly X=1; X=2; echo after",
"readonly X=1; X=2 export Y=3; echo after",
"readonly X=1; export X=2; echo after",
". /no/such/file; echo after",
"set -Z; echo after",
"export -Z; echo after",
"exit 0 2; echo after",
"f(){ return 0 2; echo in-function; }; f; echo after",
"return; echo after",
"break; echo after",
"continue; echo after",
"break 1 2; echo after",
"continue 1 2; echo after",
"shift 1 2; echo after",
"readonly X=1; X=2 eval false; echo after",
];
for script in cases {
let output = run_shell("mxsh", &["-c", script], "");
assert_ne!(
output.status.code(),
Some(0),
"script should fail: {script}"
);
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"",
"script should abort before later stdout: {script}"
);
}
let dir = temp_path("dot-syntax-abort");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let bad_dot_script = dir.join("bad.sh");
fs::write(&bad_dot_script, "if true; then echo bad\n").expect("dot script should be writable");
let script = format!(
". {}; echo after",
shell_quote(
bad_dot_script
.to_str()
.expect("dot script path should be utf8")
)
);
let output = run_shell("mxsh", &["-c", &script], "");
assert_ne!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn readonly_for_control_assignment_aborts_non_interactive_script() {
let _guard = suite_lock();
let output = run_shell(
"mxsh",
&[
"-c",
"readonly i=old; for i in new; do echo \"loop=$i\"; done; echo after",
],
"",
);
assert_ne!(output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&output.stdout), "");
assert!(
String::from_utf8_lossy(&output.stderr).contains("i: readonly variable"),
"stderr should report the readonly control variable assignment"
);
}
#[test]
fn parameter_pattern_and_arithmetic_edge_regressions() {
let _guard = suite_lock();
let parameter_assignment = run_shell(
"mxsh",
&["-c", "set -- a; echo one=${2:=x}; echo after"],
"",
);
assert_eq!(parameter_assignment.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(¶meter_assignment.stdout), "");
assert!(
String::from_utf8_lossy(¶meter_assignment.stderr).contains("cannot assign"),
"stderr should report the failed positional assignment"
);
let expansion = run_shell(
"mxsh",
&[
"-c",
"X=abc; U=é; printf '<%s>\\n' \"${X%*}\" \"${X%\"*c\"}\" \"${#U}\"",
],
"",
);
assert_eq!(expansion.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&expansion.stdout),
"<abc>\n<abc>\n<1>\n"
);
let arithmetic = run_shell(
"mxsh",
&[
"-c",
"X=0; printf '%s:%s\\n' \"$((0 && (X=1)))\" \"$X\"; \
printf '%s:%s\\n' \"$((1 || (X=2)))\" \"$X\"; \
Y=1+2; printf 'expr=%s oct=%s\\n' \"$((Y))\" \"$((010))\"",
],
"",
);
assert_eq!(arithmetic.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&arithmetic.stdout),
"0:0\n1:0\nexpr=3 oct=8\n"
);
let readonly_arithmetic = run_shell(
"mxsh",
&["-c", "readonly R=1; echo $((R=2)); echo after"],
"",
);
assert_ne!(readonly_arithmetic.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&readonly_arithmetic.stdout), "");
}
#[test]
fn cyclic_arithmetic_variable_values_fail_without_crashing() {
let _guard = suite_lock();
for script in [
"X=X+1; echo $((X)); echo after",
"X=Y+1; Y=X+1; echo $((X)); echo after",
"X=X+1; echo $((X+=1)); echo after",
] {
let output = run_shell_with_timeout(
"mxsh",
&["-c", script],
"",
std::time::Duration::from_secs(5),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
matches!(output.status.code(), Some(code) if code != 0),
"script should fail with an exit status rather than a signal: {script}; status={:?}; stderr={stderr}",
output.status
);
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"",
"script should abort before later stdout: {script}"
);
assert!(
stderr.contains("arithmetic expansion"),
"stderr should identify arithmetic expansion failure: {stderr}"
);
assert!(
stderr.contains("recursive arithmetic variable"),
"stderr should identify the recursive variable chain: {stderr}"
);
}
let acyclic = run_shell("mxsh", &["-c", "X=Y+1; Y=2; echo $((X))"], "");
assert_eq!(acyclic.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&acyclic.stdout), "3\n");
let assignment = run_shell("mxsh", &["-c", "X=X+1; echo $((X=2)); echo X=$X"], "");
assert_eq!(assignment.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&assignment.stdout), "2\nX=2\n");
}
#[test]
fn arithmetic_variable_values_use_full_arithmetic_grammar() {
let _guard = suite_lock();
for (script, expected) in [
("Y=2; X=Y; echo $((X)); echo after", "2\nafter\n"),
("X=0x10; echo $((X)); echo after", "16\nafter\n"),
("X=~0; echo $((X)); echo after", "-1\nafter\n"),
] {
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(
output.status.code(),
Some(0),
"script should succeed: {script}; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout), expected);
}
let invalid_octal = run_shell(
"mxsh",
&["-c", "X=010; echo $((X)); X=08; echo $((X)); echo after"],
"",
);
assert_ne!(invalid_octal.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&invalid_octal.stdout), "8\n");
assert!(
String::from_utf8_lossy(&invalid_octal.stderr).contains("arithmetic expansion"),
"stderr should report the arithmetic expansion failure"
);
}
#[test]
fn unary_minus_of_minimum_integer_does_not_panic() {
let _guard = suite_lock();
let output = run_shell(
"mxsh",
&["-c", "X=-9223372036854775808; echo $((-X)); echo after"],
"",
);
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"-9223372036854775808\nafter\n"
);
assert_eq!(String::from_utf8_lossy(&output.stderr), "");
}
#[test]
fn portable_external_background_jobs_do_not_need_host_extensions() {
let _guard = suite_lock();
let mut session = shell_builder_with_host_extensions()
.new_session()
.expect("session with host extensions should build");
let mut runtime = UnixRuntime::new();
let outcome = session.run(&mut runtime, "/bin/sleep 0 & wait");
assert_eq!(outcome.status, 0);
}
fn shell_builder_with_host_extensions() -> ShellBuilder {
ShellBuilder::new()
.register_builtin("host", |context, _args| {
let _ = context.write_stdout_line("host-builtin");
0
})
.register_command_override(
"override",
CommandOverride::new(|context, _args| {
let _ = context.write_stdout_line("host-override");
0
}),
)
.register_command_not_found_handler(|context, _args| {
let _ = context.write_stdout_line("host-not-found");
0
})
}
#[test]
fn background_machine_jobs_do_not_need_unused_host_extensions() {
let _guard = suite_lock();
let stdout = StringStdioOut::new();
let mut session = shell_builder_with_host_extensions()
.stdio(StdioConfig {
stdout: stdout.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session with host extensions should build");
let mut runtime = UnixRuntime::new();
let outcome = session.run(&mut runtime, "f(){ echo background-ok; }\nf & wait");
assert_eq!(outcome.status, 0);
assert_eq!(stdout.collect(), "background-ok\n");
}
#[test]
fn background_machine_jobs_fail_closed_for_host_extension_names() {
let _guard = suite_lock();
let dir = temp_path("refactor-background-host-extension");
fs::create_dir_all(&dir).expect("temp command dir should be creatable");
for name in ["host", "override"] {
let path = dir.join(name);
fs::write(&path, format!("#!/bin/sh\necho path-{name}\n"))
.expect("path command should be writable");
let mut perms = fs::metadata(&path)
.expect("path command metadata")
.permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
fs::set_permissions(&path, perms).expect("path command permissions");
}
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut session = shell_builder_with_host_extensions()
.stdio(StdioConfig {
stdout: stdout.fd(),
stderr: stderr.fd(),
..StdioConfig::default()
})
.new_session()
.expect("session with host extensions should build");
let mut runtime = UnixRuntime::new();
let script = format!(
"\
PATH={}
host & p=$!
wait \"$p\"
echo host=$?
override & p=$!
wait \"$p\"
echo override=$?
missing & p=$!
wait \"$p\"
echo missing=$?
",
shell_quote(dir.to_str().expect("temp path is utf8")),
);
let outcome = session.run(&mut runtime, &script);
assert_eq!(outcome.status, 0);
let stdout = stdout.collect();
assert_eq!(stdout, "host=1\noverride=1\nmissing=127\n");
assert!(!stdout.contains("path-host"), "PATH fallback must not run");
assert!(
!stdout.contains("path-override"),
"PATH fallback must not run"
);
let stderr = stderr.collect();
assert!(
stderr.contains("host: The behavior of this command is undefined."),
"host builtin should fail closed, stderr was {stderr:?}"
);
assert!(
stderr.contains("override: The behavior of this command is undefined."),
"host override should fail closed, stderr was {stderr:?}"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn cli_run_finished_trace_uses_exit_trap_status() {
let _guard = suite_lock();
let mut shell = ShellBuilder::new()
.build(UnixRuntime::new())
.expect("shell should build");
let argv = vec![
"mxsh".to_string(),
"-c".to_string(),
"trap 'exit 7' EXIT; true".to_string(),
];
let outcome = shell.run_cli(&argv);
assert_eq!(outcome.status, 7);
assert_eq!(outcome.exit_code, Some(7));
assert!(matches!(
outcome.trace.last(),
Some(TraceEvent::RunFinished { status: 7, .. })
));
}
#[test]
fn embedded_cli_respects_disabled_signal_management_for_traps() {
let _guard = suite_lock();
EMBEDDED_CLI_SIGNAL_HITS.store(0, Ordering::Relaxed);
let _signal_guard = SignalHandlerGuard::install(libc::SIGUSR2, embedded_cli_signal_handler);
let stdout = StringStdioOut::new();
let stderr = StringStdioOut::new();
let mut shell = ShellBuilder::new()
.manage_signals(false)
.stdio(StdioConfig {
stdout: stdout.fd(),
stderr: stderr.fd(),
..StdioConfig::default()
})
.build(UnixRuntime::new())
.expect("shell should build");
let argv = vec![
"mxsh".to_string(),
"-c".to_string(),
"trap 'echo mxsh-trap' USR2; /bin/kill -USR2 $$; :".to_string(),
];
let outcome = shell.run_cli(&argv);
assert_eq!(outcome.status, 0);
assert_eq!(EMBEDDED_CLI_SIGNAL_HITS.load(Ordering::Relaxed), 1);
assert_eq!(stdout.collect(), "");
assert_eq!(stderr.collect(), "");
}
#[test]
fn embedded_cli_does_not_clear_other_sessions_pending_traps() {
let _guard = suite_lock();
let stdout = StringStdioOut::new();
let mut trapped = ShellBuilder::new()
.manage_signals(true)
.stdio(StdioConfig {
stdout: stdout.fd(),
..StdioConfig::default()
})
.build(UnixRuntime::new())
.expect("trapped shell should build");
let trap_outcome = trapped.run("trap 'echo queued-trap' USR1");
assert_eq!(trap_outcome.status, 0);
assert_eq!(unsafe { libc::kill(libc::getpid(), libc::SIGUSR1) }, 0);
let mut cli = ShellBuilder::new()
.build(UnixRuntime::new())
.expect("cli shell should build");
let argv = vec!["mxsh".to_string(), "-c".to_string(), ":".to_string()];
let cli_status = cli.run_cli(&argv).status;
let flush_status = trapped.run(":").status;
let clear_status = trapped.run("trap - USR1").status;
let output = stdout.collect();
assert_eq!(cli_status, 0);
assert_eq!(flush_status, 0);
assert_eq!(clear_status, 0);
assert_eq!(output, "queued-trap\n");
}
#[test]
fn dropped_embedded_sessions_release_trap_registration_slots() {
let _guard = suite_lock();
EMBEDDED_CLI_SIGNAL_HITS.store(0, Ordering::Relaxed);
let _signal_guard = SignalHandlerGuard::install(libc::SIGINT, embedded_cli_signal_handler);
for idx in 0..70 {
if idx % 2 == 0 {
let mut shell = ShellBuilder::new()
.manage_signals(true)
.build(UnixRuntime::new())
.expect("shell should build");
assert_eq!(shell.run("trap ':' INT").status, 0);
} else {
let mut session = ShellBuilder::new()
.manage_signals(true)
.new_session()
.expect("session should build");
let mut runtime = UnixRuntime::new();
assert_eq!(session.run(&mut runtime, "trap ':' INT").status, 0);
}
}
let stdout = StringStdioOut::new();
let mut shell = ShellBuilder::new()
.manage_signals(true)
.stdio(StdioConfig {
stdout: stdout.fd(),
..StdioConfig::default()
})
.build(UnixRuntime::new())
.expect("shell should build after dropped sessions");
assert_eq!(shell.run("trap 'echo int' INT").status, 0);
assert_eq!(unsafe { libc::kill(libc::getpid(), libc::SIGINT) }, 0);
assert_eq!(shell.run(":").status, 0);
assert_eq!(stdout.collect(), "int\n");
}
#[test]
fn dropped_embedded_sessions_restore_managed_trap_handlers() {
let _guard = suite_lock();
EMBEDDED_CLI_SIGNAL_HITS.store(0, Ordering::Relaxed);
let _signal_guard = SignalHandlerGuard::install(libc::SIGUSR2, embedded_cli_signal_handler);
{
let mut shell = ShellBuilder::new()
.manage_signals(true)
.build(UnixRuntime::new())
.expect("shell should build");
assert_eq!(shell.run("trap ':' USR2").status, 0);
}
assert_eq!(unsafe { libc::kill(libc::getpid(), libc::SIGUSR2) }, 0);
std::thread::sleep(std::time::Duration::from_millis(10));
assert_eq!(EMBEDDED_CLI_SIGNAL_HITS.load(Ordering::Relaxed), 1);
}
#[test]
fn getopts_state_and_allexport_do_not_use_user_visible_private_vars() {
let _guard = suite_lock();
let script = "\
readonly __MXSH_GETOPTS_CURSOR=1
set -- -ab
getopts ab o
printf '1:%s:%s\\n' \"$o\" \"$OPTIND\"
getopts ab o
printf '2:%s:%s\\n' \"$o\" \"$OPTIND\"
set -a
for X in y; do env | grep '^X='; done
read Z <<EOF
z
EOF
env | grep '^Z='
unset A
: ${A:=q}
env | grep '^A='
";
let output = run_shell("mxsh", &["-c", script], "");
assert_eq!(output.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"1:a:1\n2:b:2\nX=y\nZ=z\nA=q\n"
);
}
#[test]
fn allexport_assignment_reproducers_export_to_children() {
let _guard = suite_lock();
let assert_stdout = |script: &str, stdin: &str, expected: &str| {
let output = run_shell("mxsh", &["-c", script], stdin);
assert_eq!(output.status.code(), Some(0), "script: {script}");
assert_eq!(String::from_utf8_lossy(&output.stdout), expected);
assert_eq!(
String::from_utf8_lossy(&output.stderr),
"",
"script: {script}"
);
};
assert_stdout("set -a; for X in y; do env | grep '^X='; done", "", "X=y\n");
assert_stdout("set -a; read X; env | grep '^X='", "y\n", "X=y\n");
assert_stdout("set -a; unset X; : ${X:=y}; env | grep '^X='", "", "X=y\n");
assert_stdout("set -a; unset X; : $((X=3)); env | grep '^X='", "", "X=3\n");
let readonly_output = run_shell(
"mxsh",
&["-c", "readonly X=1; echo $((X=2)); echo \"X=$X status=$?\""],
"",
);
assert_ne!(readonly_output.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&readonly_output.stdout), "");
assert!(
String::from_utf8_lossy(&readonly_output.stderr).contains("X: readonly variable"),
"stderr should report the readonly arithmetic assignment"
);
}
#[test]
fn cli_rejects_non_utf8_argv_and_ignores_non_utf8_environment() {
let _guard = suite_lock();
let status = Command::new(shell_program("mxsh"))
.arg("-c")
.arg("true")
.arg(OsString::from_vec(vec![0xff]))
.status()
.expect("non-utf8 argv process should run");
assert_eq!(status.code(), Some(2));
let output = Command::new(shell_program("mxsh"))
.arg("-c")
.arg("true")
.env_clear()
.env(
OsString::from_vec(b"BAD".to_vec()),
OsString::from_vec(vec![0xff]),
)
.output()
.expect("non-utf8 env process should run");
assert_eq!(output.status.code(), Some(0));
assert!(String::from_utf8_lossy(&output.stderr).contains("non-UTF-8"));
}
#[test]
fn embedded_exit_traps_finalize_run_outcomes() {
let _guard = suite_lock();
let stdout = StringStdioOut::new();
let mut session = ShellBuilder::new()
.stdio(mxsh::embed::StdioConfig {
stdout: stdout.fd(),
..mxsh::embed::StdioConfig::default()
})
.new_session()
.expect("session should build");
let mut runtime = UnixRuntime::new();
let outcome = session.run(&mut runtime, "trap 'echo cleanup; exit 3' EXIT; exit 7");
assert_eq!(outcome.status, 3);
assert_eq!(outcome.exit_code, Some(3));
assert_eq!(stdout.collect(), "cleanup\n");
}
#[test]
fn exec_and_external_launch_share_script_fallback_and_fd_plan() {
let _guard = suite_lock();
let dir = temp_path("refactor-exec-fallback");
fs::create_dir_all(&dir).expect("temp dir should be creatable");
let plain = dir.join("plain");
fs::write(&plain, "echo fallback:$1\n").expect("plain script should be writable");
let mut perms = fs::metadata(&plain)
.expect("plain script metadata")
.permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
fs::set_permissions(&plain, perms).expect("plain script permissions");
let external = run_shell(
"mxsh",
&[
"-c",
&format!(
"PATH={}; plain external",
shell_quote(dir.to_str().expect("temp path is utf8")),
),
],
"",
);
assert_eq!(external.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&external.stdout),
"fallback:external\n"
);
let exec_replace = run_shell(
"mxsh",
&[
"-c",
&format!(
"PATH={}; exec plain replacement",
shell_quote(dir.to_str().expect("temp path is utf8")),
),
],
"",
);
assert_eq!(exec_replace.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&exec_replace.stdout),
"fallback:replacement\n"
);
let redirected = dir.join("exec.out");
let exec_redir = run_shell(
"mxsh",
&[
"-c",
&format!(
"PATH={}; exec plain redirected >{}",
shell_quote(dir.to_str().expect("temp path is utf8")),
shell_quote(redirected.to_str().expect("redirect path is utf8")),
),
],
"",
);
assert_eq!(exec_redir.status.code(), Some(0));
assert_eq!(String::from_utf8_lossy(&exec_redir.stdout), "");
assert_eq!(
fs::read_to_string(&redirected).expect("exec redirection output"),
"fallback:redirected\n"
);
let exec_pwd = run_shell(
"mxsh",
&[
"-c",
&format!(
"cd {}; exec /bin/pwd",
shell_quote(dir.to_str().expect("temp path is utf8")),
),
],
"",
);
assert_eq!(exec_pwd.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&exec_pwd.stdout),
format!("{}\n", dir.display())
);
let exec_ignored_signal = run_shell(
"mxsh",
&[
"-c",
"trap '' PIPE; exec /bin/sh -c 'kill -PIPE $$; echo pipe-survived'",
],
"",
);
assert_eq!(exec_ignored_signal.status.code(), Some(0));
assert_eq!(
String::from_utf8_lossy(&exec_ignored_signal.stdout),
"pipe-survived\n"
);
let _ = fs::remove_dir_all(dir);
}