use super::backend::{SpawnRequest, spawn};
use super::wait::{WaitMode, wait_child_status, wait_process};
use super::*;
use std::os::fd::{FromRawFd, RawFd};
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
fn signal_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
extern "C" fn test_signal_handler(_: i32) {}
fn wait_pid_for_test(pid: libc::pid_t) -> i32 {
wait_child_status(|| wait_process(pid, WaitMode::Block))
}
struct PausedProcessGroup {
pid: libc::pid_t,
}
impl PausedProcessGroup {
fn spawn() -> Self {
let pid = unsafe { libc::fork() };
assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
if pid == 0 {
unsafe {
libc::signal(libc::SIGTERM, libc::SIG_DFL);
if libc::setpgid(0, 0) != 0 {
libc::_exit(127);
}
loop {
libc::pause();
}
}
}
let rc = unsafe { libc::setpgid(pid, pid) };
assert!(
rc == 0,
"setpgid({pid}, {pid}) failed: {}",
io::Error::last_os_error()
);
Self { pid }
}
fn pid(&self) -> libc::pid_t {
self.pid
}
}
impl Drop for PausedProcessGroup {
fn drop(&mut self) {
unsafe {
libc::kill(self.pid, libc::SIGTERM);
loop {
let waited = libc::waitpid(self.pid, std::ptr::null_mut(), 0);
if waited == self.pid {
break;
}
let err = io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EINTR) {
break;
}
}
}
}
}
fn foreground_pgrp(tty: FileDescriptor) -> libc::pid_t {
let pgid = unsafe { libc::tcgetpgrp(tty.into_raw_fd()) };
assert!(
pgid >= 0,
"tcgetpgrp failed: {}",
io::Error::last_os_error()
);
pgid
}
fn set_foreground_pgrp(tty: FileDescriptor, pgid: libc::pid_t) {
let rc = unsafe { libc::tcsetpgrp(tty.into_raw_fd(), pgid) };
assert!(
rc == 0,
"tcsetpgrp({pgid}) failed: {}",
io::Error::last_os_error()
);
}
fn run_claim_foreground_previous_owner_helper() {
let tty = FileDescriptor::STDIN;
assert!(
tty.is_terminal(),
"helper stdin should be a controlling tty"
);
let shell_pgrp = unsafe { libc::getpgrp() };
assert!(shell_pgrp > 0, "getpgrp failed");
let old_ttou = unsafe { libc::signal(libc::SIGTTOU, libc::SIG_IGN) };
let owner = PausedProcessGroup::spawn();
let job = PausedProcessGroup::spawn();
assert_ne!(owner.pid(), shell_pgrp);
assert_ne!(job.pid(), shell_pgrp);
set_foreground_pgrp(tty, owner.pid());
assert_eq!(foreground_pgrp(tty), owner.pid());
let mut runtime = UnixRuntime::new();
let guard = runtime
.claim_foreground(ProcessHandle::new(job.pid() as u64), tty)
.expect("claim_foreground should claim the job pgrp");
assert_eq!(foreground_pgrp(tty), job.pid());
runtime
.release_foreground(guard)
.expect("release_foreground should restore the previous pgrp");
assert_eq!(foreground_pgrp(tty), owner.pid());
unsafe {
libc::signal(libc::SIGTTOU, old_ttou);
}
}
#[test]
fn claim_foreground_restores_previous_terminal_owner() {
const HELPER_ENV: &str = "MXSH_CLAIM_FOREGROUND_PREVIOUS_OWNER_HELPER";
if std::env::var_os(HELPER_ENV).is_some() {
run_claim_foreground_previous_owner_helper();
return;
}
let mut master_fd: RawFd = -1;
let mut slave_fd: RawFd = -1;
let rc = unsafe {
libc::openpty(
&mut master_fd,
&mut slave_fd,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
assert_eq!(rc, 0, "openpty failed: {}", io::Error::last_os_error());
let master = unsafe { std::fs::File::from_raw_fd(master_fd) };
let mut command = Command::new(std::env::current_exe().expect("current test executable"));
command
.arg("claim_foreground_restores_previous_terminal_owner")
.arg("--nocapture")
.env(HELPER_ENV, "1")
.stdin(unsafe { Stdio::from_raw_fd(slave_fd) });
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
if libc::ioctl(0, libc::TIOCSCTTY.into(), 0) == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let output = command.output().expect("failed to spawn foreground helper");
drop(master);
assert!(
output.status.success(),
"foreground helper exited with {:?}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn string_stdio_in_provides_fd() {
let input = StringStdioIn::new("line1\nline2\n");
let fd = input.fd();
let line1 = fd.read_line().unwrap();
assert_eq!(line1, Some("line1".to_string()));
let line2 = fd.read_line().unwrap();
assert_eq!(line2, Some("line2".to_string()));
let eof = fd.read_line().unwrap();
assert_eq!(eof, None);
input.join();
println!("StringStdioIn provides readable fd");
}
#[test]
fn string_stdio_out_collects_output() {
let output = StringStdioOut::new();
let fd = output.fd();
fd.write_str("hello").unwrap();
fd.write_line(" world").unwrap();
let collected = output.collect();
assert_eq!(collected, "hello world\n");
println!("StringStdioOut collects output from fd");
}
#[test]
fn string_stdio_in_large_input() {
let big = "x".repeat(200_000) + "\n";
let input = StringStdioIn::new(&big);
let fd = input.fd();
let line = fd.read_line().unwrap();
assert_eq!(line, Some("x".repeat(200_000)));
input.join();
println!("StringStdioIn handles input larger than pipe buffer");
}
#[test]
fn string_stdio_out_large_output() {
let output = StringStdioOut::new();
let fd = output.fd();
let big = "y".repeat(200_000);
fd.write_str(&big).unwrap();
let collected = output.collect();
assert_eq!(collected.len(), 200_000);
println!("StringStdioOut handles output larger than pipe buffer");
}
#[test]
fn os_pipe_read_write() {
let pipe = OsPipe::new().unwrap();
pipe.write_fd.write_line("test line").unwrap();
pipe.write_fd.close();
let line = pipe.read_fd.read_line().unwrap();
assert_eq!(line, Some("test line".to_string()));
let eof = pipe.read_fd.read_line().unwrap();
assert_eq!(eof, None);
pipe.read_fd.close();
println!("OsPipe read/write works");
}
#[test]
fn read_line_fd_shares_buffer_with_dup() {
let pipe = OsPipe::new().unwrap();
pipe.write_fd.write_str("first\nsecond\n").unwrap();
pipe.write_fd.close();
let dup = pipe.read_fd.dup().unwrap();
let first = pipe.read_fd.read_line().unwrap();
let second = dup.read_line().unwrap();
let eof = pipe.read_fd.read_line().unwrap();
assert_eq!(first, Some("first".to_string()));
assert_eq!(second, Some("second".to_string()));
assert_eq!(eof, None);
dup.close();
pipe.read_fd.close();
}
#[test]
fn read_line_fd_leaves_following_bytes_on_fd() {
let pipe = OsPipe::new().unwrap();
pipe.write_fd.write_str("first\nsecond\n").unwrap();
pipe.write_fd.close();
let first = pipe.read_fd.read_line().unwrap();
let remainder = pipe.read_fd.read_all();
assert_eq!(first, Some("first".to_string()));
assert_eq!(remainder, "second\n");
pipe.read_fd.close();
}
#[test]
fn write_all_fd_reports_closed_fd() {
let pipe = OsPipe::new().unwrap();
pipe.write_fd.close();
let err = pipe.write_fd.write_all(b"test").unwrap_err();
assert_eq!(err.raw_os_error(), Some(libc::EBADF));
pipe.read_fd.close();
}
#[test]
fn read_line_fd_retries_after_eintr() {
let _guard = signal_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let pipe = OsPipe::new().unwrap();
let reader_thread = unsafe { libc::pthread_self() };
let writer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(20));
unsafe {
libc::pthread_kill(reader_thread, libc::SIGUSR1);
}
std::thread::sleep(Duration::from_millis(20));
pipe.write_fd.write_line("after-signal").unwrap();
pipe.write_fd.close();
});
let old_handler = unsafe {
libc::signal(
libc::SIGUSR1,
test_signal_handler as *const () as libc::sighandler_t,
)
};
let line = pipe.read_fd.read_line().unwrap();
unsafe {
libc::signal(libc::SIGUSR1, old_handler);
}
assert_eq!(line, Some("after-signal".to_string()));
pipe.read_fd.close();
writer.join().unwrap();
}
#[test]
fn spawn_echo() {
let pipe = OsPipe::new().unwrap();
let pid = spawn(SpawnRequest {
program: "echo",
argv: &["echo".to_string(), "hello".to_string()],
env: &[],
cwd: Path::new("/"),
create_process_group: false,
join_process_group: None,
passed_fds: &[],
signal_plan: &ChildSignalPlan::default(),
stdio: SpawnStdio {
stdout_fd: pipe.write_fd,
..SpawnStdio::default()
},
fd_actions: &[
FdAction::Close(pipe.read_fd),
FdAction::Close(pipe.write_fd),
],
})
.unwrap();
pipe.write_fd.close();
let output = pipe.read_fd.read_all();
let code = wait_pid_for_test(pid);
assert_eq!(code, 0);
assert_eq!(output.trim(), "hello");
println!("posix_spawn echo: output={output:?}");
}
#[test]
fn spawn_false_returns_nonzero() {
let pid = spawn(SpawnRequest {
program: "false",
argv: &["false".to_string()],
env: &[],
cwd: Path::new("/"),
create_process_group: false,
join_process_group: None,
passed_fds: &[],
signal_plan: &ChildSignalPlan::default(),
stdio: SpawnStdio::default(),
fd_actions: &[],
})
.unwrap();
let code = wait_pid_for_test(pid);
assert_ne!(code, 0);
println!("posix_spawn false returns {code}");
}
#[test]
fn spawn_nonexistent_fails() {
let result = spawn(SpawnRequest {
program: "/nonexistent_binary_12345",
argv: &["/nonexistent_binary_12345".to_string()],
env: &[],
cwd: Path::new("/"),
create_process_group: false,
join_process_group: None,
passed_fds: &[],
signal_plan: &ChildSignalPlan::default(),
stdio: SpawnStdio::default(),
fd_actions: &[],
});
assert!(result.is_err());
println!("spawn nonexistent fails: {}", result.unwrap_err());
}
#[test]
fn spawn_rejects_nul_in_argv() {
let result = spawn(SpawnRequest {
program: "echo",
argv: &["echo\0bad".to_string()],
env: &[],
cwd: Path::new("/"),
create_process_group: false,
join_process_group: None,
passed_fds: &[],
signal_plan: &ChildSignalPlan::default(),
stdio: SpawnStdio::default(),
fd_actions: &[],
});
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn spawn_rejects_nul_in_env() {
let result = spawn(SpawnRequest {
program: "echo",
argv: &["echo".to_string()],
env: &[("K".to_string(), "V\0bad".to_string())],
cwd: Path::new("/"),
create_process_group: false,
join_process_group: None,
passed_fds: &[],
signal_plan: &ChildSignalPlan::default(),
stdio: SpawnStdio::default(),
fd_actions: &[],
});
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn wait_pid_reports_error_for_non_child() {
assert_eq!(wait_pid_for_test(libc::pid_t::MAX), 128);
}
#[test]
fn wait_child_status_normalizes_runtime_exit_status() {
let mut events = [ProcessEvent::Exited(-1)].into_iter();
assert_eq!(
wait_child_status(|| Ok(events.next().expect("event should be queued"))),
255
);
let mut events = [ProcessEvent::Exited(513)].into_iter();
assert_eq!(
wait_child_status(|| Ok(events.next().expect("event should be queued"))),
1
);
}
#[test]
fn spawn_with_string_stdio() {
let input = StringStdioIn::new("hello from stdin\n");
let output = StringStdioOut::new();
let pid = spawn(SpawnRequest {
program: "cat",
argv: &["cat".to_string()],
env: &[],
cwd: Path::new("/"),
create_process_group: false,
join_process_group: None,
passed_fds: &[],
signal_plan: &ChildSignalPlan::default(),
stdio: SpawnStdio {
stdin_fd: input.fd(),
stdout_fd: output.fd(),
..SpawnStdio::default()
},
fd_actions: &[],
})
.unwrap();
let collected = output.collect();
input.join();
let code = wait_pid_for_test(pid);
assert_eq!(code, 0);
assert_eq!(collected.trim(), "hello from stdin");
println!("spawn with StringStdio works: {collected:?}");
}
#[test]
fn spawn_pipeline() {
let pipe1 = OsPipe::new().unwrap();
let pipe2 = OsPipe::new().unwrap();
let pid1 = spawn(SpawnRequest {
program: "/bin/echo",
argv: &["echo".to_string(), "hello world".to_string()],
env: &[("PATH".to_string(), "/bin:/usr/bin".to_string())],
cwd: Path::new("/"),
create_process_group: false,
join_process_group: None,
passed_fds: &[],
signal_plan: &ChildSignalPlan::default(),
stdio: SpawnStdio {
stdout_fd: pipe1.write_fd,
..SpawnStdio::default()
},
fd_actions: &[
FdAction::Close(pipe1.read_fd),
FdAction::Close(pipe1.write_fd),
FdAction::Close(pipe2.read_fd),
FdAction::Close(pipe2.write_fd),
],
})
.unwrap();
let pid2 = spawn(SpawnRequest {
program: "/usr/bin/tr",
argv: &["tr".to_string(), "a-z".to_string(), "A-Z".to_string()],
env: &[("PATH".to_string(), "/bin:/usr/bin".to_string())],
cwd: Path::new("/"),
create_process_group: false,
join_process_group: None,
passed_fds: &[],
signal_plan: &ChildSignalPlan::default(),
stdio: SpawnStdio {
stdin_fd: pipe1.read_fd,
stdout_fd: pipe2.write_fd,
..SpawnStdio::default()
},
fd_actions: &[
FdAction::Close(pipe1.read_fd),
FdAction::Close(pipe1.write_fd),
FdAction::Close(pipe2.read_fd),
FdAction::Close(pipe2.write_fd),
],
})
.unwrap();
pipe1.read_fd.close();
pipe1.write_fd.close();
pipe2.write_fd.close();
let output = pipe2.read_fd.read_all();
let code1 = wait_pid_for_test(pid1);
let code2 = wait_pid_for_test(pid2);
assert_eq!(code1, 0);
assert_eq!(code2, 0);
assert_eq!(output.trim(), "HELLO WORLD");
println!("pipeline: {output:?}");
}
#[test]
fn resolve_command_path_finds_known_command() {
let runtime = UnixRuntime::new();
let cwd = std::env::current_dir().expect("current dir");
let path = runtime
.resolve_command_path("sh", "/bin:/usr/bin", &cwd)
.expect("sh should resolve");
assert!(path.is_absolute());
assert!(path.ends_with("sh"));
}
#[test]
fn resolve_command_path_reports_missing_command() {
let runtime = UnixRuntime::new();
let cwd = std::env::current_dir().expect("current dir");
let err = runtime
.resolve_command_path("definitely-missing-mxsh-command", "/bin:/usr/bin", &cwd)
.expect_err("missing command should fail");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn resolve_command_path_reports_permission_denied_for_non_executable_file() {
let runtime = UnixRuntime::new();
let path = std::env::temp_dir().join(format!(
"mxsh-resolve-noexec-{}-{}",
std::process::id(),
std::thread::current().name().unwrap_or("unnamed")
));
std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&path, perms).unwrap();
let err = runtime
.resolve_command_path(
path.to_str().expect("temp path should be utf-8"),
"/bin:/usr/bin",
Path::new("/"),
)
.expect_err("non-executable path should fail");
let _ = std::fs::remove_file(&path);
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn resolve_command_path_accepts_explicit_relative_path() {
let runtime = UnixRuntime::new();
let dir = std::env::temp_dir().join(format!("mxsh-resolve-rel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("run-me");
std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
let resolved = runtime
.resolve_command_path("./run-me", "/bin:/usr/bin", &dir)
.expect("explicit relative path should resolve");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
assert_eq!(resolved, path);
}
#[test]
fn resolve_command_path_uses_cwd_for_relative_path_entries() {
let runtime = UnixRuntime::new();
let dir = std::env::temp_dir().join(format!("mxsh-resolve-path-entry-{}", std::process::id()));
std::fs::create_dir_all(dir.join("bin")).unwrap();
let path = dir.join("bin").join("run-me");
std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
let resolved = runtime
.resolve_command_path("run-me", "bin:/bin:/usr/bin", &dir)
.expect("relative PATH entry should resolve from cwd");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(resolved, path);
}
#[test]
fn resolve_command_path_continues_past_non_executable_candidates() {
let runtime = UnixRuntime::new();
let base = std::env::temp_dir().join(format!(
"mxsh-resolve-nonexec-shadow-{}",
std::process::id()
));
let nonexec_dir = base.join("nonexec");
let exec_dir = base.join("exec");
std::fs::create_dir_all(&nonexec_dir).unwrap();
std::fs::create_dir_all(&exec_dir).unwrap();
let nonexec = nonexec_dir.join("tool");
let executable = exec_dir.join("tool");
std::fs::write(&nonexec, "#!/bin/sh\nexit 1\n").unwrap();
std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
let mut perms = std::fs::metadata(&executable).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&executable, perms).unwrap();
let path_var = format!("{}:{}", nonexec_dir.display(), exec_dir.display());
let resolved = runtime
.resolve_command_path("tool", &path_var, Path::new("/"))
.expect("later executable should not be shadowed by a non-executable file");
let _ = std::fs::remove_dir_all(&base);
assert_eq!(resolved, executable);
}
#[test]
fn non_exec_runtimes_report_exec_replace_as_unsupported() {
let in_memory = InMemoryRuntime::new();
let err = in_memory
.exec_replace("echo", &["echo".to_string()], &[], Path::new("/"))
.expect_err("in-memory runtime should not support exec");
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
let deterministic = DeterministicRuntime::new();
let err = deterministic
.exec_replace("echo", &["echo".to_string()], &[], Path::new("/"))
.expect_err("deterministic runtime should not support exec");
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
let command = ExternalCommand {
program: "echo".to_string(),
argv: vec!["echo".to_string()],
env: Vec::new(),
cwd: Path::new("/").to_path_buf(),
create_process_group: false,
join_process_group: None,
passed_fds: Vec::new(),
signal_plan: ChildSignalPlan::default(),
};
let err = in_memory
.exec_replace_command(&command, SpawnStdio::default(), &[])
.expect_err("in-memory runtime should not support planned exec");
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
let err = deterministic
.exec_replace_command(&command, SpawnStdio::default(), &[])
.expect_err("deterministic runtime should not support planned exec");
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
}