use super::*;
use crate::ast::{Position, Range};
use crate::builtin::{BuiltinProperties, RegisteredBuiltin};
use crate::policy::ShellSecurityPolicy;
use crate::sys;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
macro_rules! word_string {
($($tt:tt)*) => {
crate::ast::Word::String(crate::ast::StringWord { $($tt)* })
};
}
macro_rules! word_parameter {
($($tt:tt)*) => {
crate::ast::Word::Parameter(crate::ast::ParameterExpansion {
$($tt)*
range: Range::default(),
})
};
}
macro_rules! word_command {
($($tt:tt)*) => {
crate::ast::Word::Command(crate::ast::CommandSubstitution { $($tt)* })
};
}
macro_rules! word_list {
($($tt:tt)*) => {
crate::ast::Word::List(crate::ast::WordList { $($tt)* })
};
}
macro_rules! simple_command {
($($tt:tt)*) => {
SimpleCommand {
$($tt)*
range: Range::default(),
}
};
}
macro_rules! program {
(body: $body:expr $(,)?) => {
crate::ast::Program {
body: $body,
range: Range::default(),
}
};
}
#[derive(Clone, Default)]
struct ExecCaptureRuntime {
cwd: Arc<Mutex<Option<PathBuf>>>,
}
#[derive(Clone, Default)]
struct SpawnCaptureRuntime {
resolved: Arc<Mutex<Vec<(String, String, PathBuf)>>>,
spawned: Arc<Mutex<Vec<sys::ExternalCommand>>>,
}
impl SpawnCaptureRuntime {
fn resolved_commands(&self) -> Vec<(String, String, PathBuf)> {
self.resolved
.lock()
.unwrap_or_else(|err| err.into_inner())
.clone()
}
fn spawned_commands(&self) -> Vec<sys::ExternalCommand> {
self.spawned
.lock()
.unwrap_or_else(|err| err.into_inner())
.clone()
}
}
fn env_value<'a>(env: &'a [(String, String)], name: &str) -> Option<&'a str> {
env.iter()
.find_map(|(key, value)| (key == name).then_some(value.as_str()))
}
impl Runtime for ExecCaptureRuntime {
type ForegroundGuard = ();
fn fork(&self) -> Result<Self, io::Error> {
Ok(self.clone())
}
fn spawn_external_command(
&mut self,
_command: &sys::ExternalCommand,
_stdio: sys::SpawnStdio,
_close_fds: &[sys::FileDescriptor],
_mode: sys::SpawnMode,
) -> Result<sys::SpawnedProcess, io::Error> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"spawn unavailable in exec capture runtime",
))
}
fn wait_process(
&mut self,
_process: sys::ProcessHandle,
_mode: sys::WaitMode,
) -> Result<sys::ProcessEvent, io::Error> {
Ok(sys::ProcessEvent::Exited(0))
}
fn signal_process_group(
&mut self,
_process: sys::ProcessHandle,
_signal: sys::RuntimeSignal,
) -> Result<(), io::Error> {
Ok(())
}
fn claim_foreground(
&mut self,
_process: sys::ProcessHandle,
_tty: sys::FileDescriptor,
) -> Result<Self::ForegroundGuard, io::Error> {
Ok(())
}
fn release_foreground(&mut self, _guard: Self::ForegroundGuard) -> Result<(), io::Error> {
Ok(())
}
fn exec_replace(
&self,
_program: &str,
_argv: &[String],
_env: &[(String, String)],
cwd: &Path,
) -> Result<(), io::Error> {
*self.cwd.lock().unwrap() = Some(cwd.to_path_buf());
Err(io::Error::new(
io::ErrorKind::Unsupported,
"exec unavailable in exec capture runtime",
))
}
fn exec_replace_command(
&self,
command: &sys::ExternalCommand,
_stdio: sys::SpawnStdio,
_close_fds: &[sys::FileDescriptor],
) -> Result<(), io::Error> {
self.exec_replace(&command.program, &command.argv, &command.env, &command.cwd)
}
fn resolve_command_path(
&self,
program: &str,
_path_var: &str,
cwd: &Path,
) -> Result<PathBuf, io::Error> {
Ok(cwd.join(program))
}
}
impl Runtime for SpawnCaptureRuntime {
type ForegroundGuard = ();
fn fork(&self) -> Result<Self, io::Error> {
Ok(self.clone())
}
fn spawn_external_command(
&mut self,
command: &sys::ExternalCommand,
_stdio: sys::SpawnStdio,
_close_fds: &[sys::FileDescriptor],
_mode: sys::SpawnMode,
) -> Result<sys::SpawnedProcess, io::Error> {
self.spawned
.lock()
.unwrap_or_else(|err| err.into_inner())
.push(command.clone());
Ok(sys::SpawnedProcess {
handle: sys::ProcessHandle::new(1),
display_pid: Some(1),
})
}
fn wait_process(
&mut self,
_process: sys::ProcessHandle,
_mode: sys::WaitMode,
) -> Result<sys::ProcessEvent, io::Error> {
Ok(sys::ProcessEvent::Exited(0))
}
fn signal_process_group(
&mut self,
_process: sys::ProcessHandle,
_signal: sys::RuntimeSignal,
) -> Result<(), io::Error> {
Ok(())
}
fn claim_foreground(
&mut self,
_process: sys::ProcessHandle,
_tty: sys::FileDescriptor,
) -> Result<Self::ForegroundGuard, io::Error> {
Ok(())
}
fn release_foreground(&mut self, _guard: Self::ForegroundGuard) -> Result<(), io::Error> {
Ok(())
}
fn resolve_command_path(
&self,
program: &str,
path_var: &str,
cwd: &Path,
) -> Result<PathBuf, io::Error> {
self.resolved
.lock()
.unwrap_or_else(|err| err.into_inner())
.push((program.to_string(), path_var.to_string(), cwd.to_path_buf()));
Ok(PathBuf::from(format!("/resolved/{program}")))
}
fn exec_replace(
&self,
_program: &str,
_argv: &[String],
_env: &[(String, String)],
_cwd: &Path,
) -> Result<(), io::Error> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"exec unavailable in spawn capture runtime",
))
}
}
fn trap_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn run_and_capture(input: &str) -> (i32, ShellState) {
run_and_capture_with_setup(input, |_| {})
}
fn run_and_capture_with_setup(
input: &str,
setup: impl FnOnce(&mut ShellState),
) -> (i32, ShellState) {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let mut state = ShellState::new();
state.populate_env();
setup(&mut state);
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, input);
state.last_status = status;
(status, state)
}
fn run_and_capture_with_stdin(input: &str, stdin: &str) -> (i32, ShellState) {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdin = sys::StringStdioIn::new(stdin);
let mut state = ShellState::new();
state.populate_env();
state.stdin_fd = stdin.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, input);
state.last_status = status;
stdin.join();
(status, state)
}
fn run_and_capture_output(input: &str) -> (i32, String, ShellState) {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, input);
state.last_status = status;
let output = stdout.collect();
(status, output, state)
}
fn run_cli_script_and_capture_output(input: &str) -> (i32, String, ShellState) {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let argv = vec!["mxsh".to_string(), "-c".to_string(), input.to_string()];
let mut state = ShellState::new();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let code = run_with_state(&argv, &mut state, &mut runtime);
let output = stdout.collect();
(code, output, state)
}
fn run_and_capture_output_and_error(input: &str) -> (i32, String, String, ShellState) {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
state.stderr_fd = stderr.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, input);
state.last_status = status;
let output = stdout.collect();
let error = stderr.collect();
(status, output, error, state)
}
fn temp_path(label: &str) -> PathBuf {
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
std::env::temp_dir().join(format!(
"mxsh-{label}-{}-{}",
std::process::id(),
NEXT_ID.fetch_add(1, Ordering::Relaxed)
))
}
#[test]
fn simple_assignment() {
let (_, state) = run_and_capture("FOO=bar");
assert_eq!(state.env_get("FOO"), Some("bar"));
}
#[test]
fn parameter_expansion() {
let (_, state) = run_and_capture("X=hello; Y=${X}world");
assert_eq!(state.env_get("Y"), Some("helloworld"));
}
#[test]
fn parameter_default() {
let (_, state) = run_and_capture("Y=${UNSET:-default}");
assert_eq!(state.env_get("Y"), Some("default"));
}
#[test]
fn if_true() {
let (status, _) = run_and_capture("if true; then true; fi");
assert_eq!(status, 0);
}
#[test]
fn if_false_else() {
let (status, _) = run_and_capture("if false; then true; else false; fi");
assert_eq!(status, 1);
}
#[test]
fn for_loop() {
let (_, state) = run_and_capture("R=; for x in a b c; do R=${R}${x}; done");
assert_eq!(state.env_get("R"), Some("abc"));
}
#[test]
fn while_loop() {
let (_, state) = run_and_capture("I=0; while [ $I -lt 3 ]; do I=$((I+1)); done");
assert_eq!(state.env_get("I"), Some("3"));
}
#[test]
fn arithmetic_expansion() {
let (_, state) = run_and_capture("X=$((2 + 3 * 4))");
assert_eq!(state.env_get("X"), Some("14"));
}
#[test]
fn function_definition_and_call() {
let (_, state) = run_and_capture("greet() { R=hello; }; greet");
assert_eq!(state.env_get("R"), Some("hello"));
}
#[test]
fn and_or_list() {
let (status, _) = run_and_capture("true && true");
assert_eq!(status, 0);
let (status, _) = run_and_capture("true && false");
assert_eq!(status, 1);
let (status, _) = run_and_capture("false || true");
assert_eq!(status, 0);
}
#[test]
fn case_clause() {
let (_, state) = run_and_capture("X=hello; case $X in hello) R=matched;; *) R=nope;; esac");
assert_eq!(state.env_get("R"), Some("matched"));
}
#[test]
fn case_wildcard() {
let (_, state) = run_and_capture("X=world; case $X in hello) R=no;; *) R=yes;; esac");
assert_eq!(state.env_get("R"), Some("yes"));
}
#[test]
fn case_quoted_wildcard_is_literal() {
let (_, state) = run_and_capture(r#"case x in "*") R=match;; *) R=other;; esac"#);
assert_eq!(state.env_get("R"), Some("other"));
}
#[test]
fn case_quoted_parameter_pattern_is_literal() {
let (_, state) = run_and_capture(r#"P='*'; case x in "$P") R=match;; *) R=other;; esac"#);
assert_eq!(state.env_get("R"), Some("other"));
}
#[test]
fn case_unquoted_parameter_pattern_still_globs() {
let (_, state) = run_and_capture(r#"P='*'; case x in $P) R=match;; *) R=other;; esac"#);
assert_eq!(state.env_get("R"), Some("match"));
}
#[test]
fn break_in_loop() {
let (_, state) =
run_and_capture("I=0; while true; do I=$((I+1)); if [ $I -ge 3 ]; then break; fi; done");
assert_eq!(state.env_get("I"), Some("3"));
}
#[test]
fn pattern_match_basic() {
assert!(shell_expand::pattern_match("hello", "hello"));
assert!(!shell_expand::pattern_match("hello", "world"));
assert!(shell_expand::pattern_match("*", "anything"));
assert!(shell_expand::pattern_match("h*o", "hello"));
assert!(shell_expand::pattern_match("h?llo", "hello"));
assert!(!shell_expand::pattern_match("h?llo", "hllo"));
}
#[test]
fn pattern_match_question_matches_non_ascii_character() {
assert!(shell_expand::pattern_match("?", "\u{e9}"));
}
#[test]
fn pattern_match_large_literal_does_not_recurse_per_character() {
let text = "a".repeat(200_000);
assert!(shell_expand::pattern_match(&text, &text));
assert!(!shell_expand::pattern_match(&format!("{text}b"), &text));
}
#[test]
fn strip_suffix_test() {
assert_eq!(
shell_expand::strip_suffix("hello.tar.gz", ".gz", false),
"hello.tar"
);
assert_eq!(
shell_expand::strip_suffix("hello.tar.gz", ".*", true),
"hello"
);
assert_eq!(
shell_expand::strip_suffix("hello.tar.gz", ".*", false),
"hello.tar"
);
}
#[test]
fn strip_suffix_non_ascii_value() {
assert_eq!(shell_expand::strip_suffix("caf\u{e9}", "?", false), "caf");
}
#[test]
fn strip_prefix_test() {
assert_eq!(
shell_expand::strip_prefix("/usr/local/bin", "*/", false),
"usr/local/bin"
);
assert_eq!(
shell_expand::strip_prefix("/usr/local/bin", "*/", true),
"bin"
);
}
#[test]
fn strip_prefix_non_ascii_value() {
assert_eq!(
shell_expand::strip_prefix("\u{e9}clair", "?", false),
"clair"
);
}
#[test]
fn strip_prefix_and_suffix_treat_escaped_metacharacters_as_literals() {
assert_eq!(shell_expand::strip_prefix("a*b", "a\\*", false), "b");
assert_eq!(shell_expand::strip_suffix("a*b", "\\*b", false), "a");
}
#[test]
fn parameter_pattern_removal_non_ascii_value() {
let script = format!("X={}; Y=${{X#?}}; Z=${{X%?}}", "\u{e9}");
let (_, state) = run_and_capture(&script);
assert_eq!(state.env_get("Y"), Some(""));
assert_eq!(state.env_get("Z"), Some(""));
}
#[test]
fn tilde_expansion() {
let mut state = ShellState::new();
state.env_set("HOME", "/home/test".to_string(), 0);
assert_eq!(
shell_expand::expand_tilde(&state, "~/foo"),
"/home/test/foo"
);
assert_eq!(shell_expand::expand_tilde(&state, "~"), "/home/test");
assert_eq!(shell_expand::expand_tilde(&state, "/no/tilde"), "/no/tilde");
}
#[test]
fn in_memory_subshell_does_not_leak_state() {
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(&mut state, &mut runtime, "X=outer; ( X=inner );");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("outer"));
}
#[test]
fn in_memory_pipeline_uses_stage_io() {
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("emit", |argv, _env, _cwd, stdio| {
let payload = if argv.len() > 1 {
argv[1..].join(" ")
} else {
String::new()
};
let _ = stdio.stdout_fd.write_line(&payload);
0
});
runtime.register_command("upper", |_argv, _env, _cwd, stdio| {
let input = stdio.stdin_fd.dup().expect("dup upper stdin").read_all();
let _ = stdio.stdout_fd.write_str(&input.to_ascii_uppercase());
0
});
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
let status = run_string(&mut state, &mut runtime, "emit hello world | upper");
assert_eq!(status, 0);
assert_eq!(output.collect(), "HELLO WORLD\n");
}
#[test]
fn in_memory_pipeline_restores_state_between_stages() {
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"f() { X=inner; }; X=outer; f | :;",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("outer"));
}
#[test]
fn in_memory_pipeline_command_substitutions_do_not_clobber_parent_last_status() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("noop", |_argv, _env, _cwd, _stdio| 0);
runtime.register_command("emit", |argv, _env, _cwd, stdio| {
let payload = if argv.len() > 1 {
argv[1..].join(" ")
} else {
String::new()
};
let _ = stdio.stdout_fd.write_line(&payload);
0
});
runtime.register_command("cat", |_argv, _env, _cwd, stdio| {
let input = stdio.stdin_fd.dup().expect("dup cat stdin").read_all();
let _ = stdio.stdout_fd.write_str(&input);
0
});
let status = run_string(
&mut state,
&mut runtime,
"false; noop \"$(emit warmup)\" | emit \"$(emit $? | cat)\"",
);
assert_eq!(status, 0);
assert_eq!(output.collect(), "1\n");
assert_eq!(state.last_status, 0);
}
#[test]
fn in_memory_pipeline_assignment_prefix_reaches_external_stage_env() {
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("env", |_argv, env, _cwd, stdio| {
for (name, value) in env {
let _ = stdio.stdout_fd.write_line(&format!("{name}={value}"));
}
0
});
runtime.register_command("grep", |argv, _env, _cwd, stdio| {
let needle = argv.get(1).map(String::as_str).unwrap_or_default();
let input = stdio.stdin_fd.dup().expect("dup grep stdin").read_all();
let mut matched = false;
for line in input.lines() {
let is_match = match needle {
"^A=1$" => line == "A=1",
_ => line == needle,
};
if is_match {
matched = true;
let _ = stdio.stdout_fd.write_line(line);
}
}
i32::from(!matched)
});
let status = run_string(&mut state, &mut runtime, "A=1 env | grep '^A=1$'");
assert_eq!(status, 0);
assert_eq!(output.collect(), "A=1\n");
assert_eq!(state.env_get("A"), None);
}
#[test]
fn execution_builder_classifies_pipeline_before_execution() {
let mut parser = crate::parser::Parser::from_string("emit hi | f | upper\n");
let program = parser.parse_program().expect("script should parse");
let AndOrList::Pipeline(pipeline) = &program.body[0].and_or_list else {
panic!("expected parsed pipeline");
};
let mut state = ShellState::new();
state.populate_env();
state.set_function(
"f".to_string(),
AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: ":".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: Vec::new(),
assignments: Vec::new(),
}),
);
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("emit", |_argv, _env, _cwd, _stdio| 0);
runtime.register_command("upper", |_argv, _env, _cwd, _stdio| 0);
let pipeline = build_pipeline_execution(&mut state, &mut runtime, pipeline);
assert!(matches!(
pipeline.stages(),
[
ExecPipelineStage::PreparedExternal(_),
ExecPipelineStage::SimpleCommand(_),
ExecPipelineStage::PreparedExternal(_)
]
));
}
#[test]
fn execution_builder_uses_live_state_between_command_lists() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, "f() { echo live; }\nf | cat\n");
assert_eq!(status, 0);
assert_eq!(stdout.collect(), "live\n");
}
#[test]
fn execution_builder_keeps_brace_group_body_live_between_command_lists() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"{ f() { echo brace; }\nf | cat\n}\n",
);
assert_eq!(status, 0);
assert_eq!(stdout.collect(), "brace\n");
}
#[test]
fn execution_builder_keeps_if_body_live_between_command_lists() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"if true; then f() { echo branch; }\nf | cat\nfi\n",
);
assert_eq!(status, 0);
assert_eq!(stdout.collect(), "branch\n");
}
#[test]
fn execution_builder_short_circuits_without_classifying_skipped_branch() {
let (status, output, error, _) =
run_and_capture_output_and_error("true || definitelymissingcommand\n");
assert_eq!(status, 0);
assert_eq!(output, "");
assert_eq!(error, "");
}
#[test]
fn exec_command_with_stdio_reports_dup_failure() {
let mut state = ShellState::new();
state.populate_env();
let stderr = sys::StringStdioOut::new();
state.stderr_fd = stderr.fd();
let invalid_fd = sys::FileDescriptor::new(i32::MAX);
let command = AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: ":".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: Vec::new(),
assignments: Vec::new(),
});
let mut runtime = sys::InMemoryRuntime::new();
let stderr_fd = state.stderr_fd;
let status = super::command::run_command_with_stdio(
&mut state,
&mut runtime,
&command,
invalid_fd,
sys::FileDescriptor::INVALID,
stderr_fd,
);
assert_eq!(status, 1);
assert!(stderr.collect().contains("failed to duplicate stdin"));
}
#[test]
fn subshell_reports_dup_failure() {
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stderr_fd = stderr.fd();
state.stdin_fd = sys::FileDescriptor::new(i32::MAX);
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(&mut state, &mut runtime, "( : )");
assert_eq!(status, 1);
assert!(
stderr
.collect()
.contains("failed to duplicate subshell stdio")
);
}
#[test]
fn execution_builder_skips_function_definition_when_and_or_is_short_circuited() {
let (status, _, _, state) =
run_and_capture_output_and_error("true || f() { echo should_not_run; }\nf\n");
assert_eq!(status, 127);
assert!(!state.definition_store.functions.contains_key("f"));
}
#[test]
fn execution_builder_builds_and_executes_function_definition_on_taken_and_or_branch() {
let (status, output, _, state) =
run_and_capture_output_and_error("false || f() { echo from_branch; }\nf\n");
assert_eq!(status, 0);
assert_eq!(output, "from_branch\n");
assert!(state.definition_store.functions.contains_key("f"));
}
#[test]
fn execution_builder_builds_right_branch_after_left_side_effects() {
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("emit", |argv, _env, _cwd, stdio| {
let payload = if argv.len() > 1 {
argv[1..].join(" ")
} else {
String::new()
};
let _ = stdio.stdout_fd.write_line(&payload);
0
});
runtime.register_command("upper", |_argv, _env, _cwd, stdio| {
let input = stdio.stdin_fd.dup().expect("dup upper stdin").read_all();
let _ = stdio.stdout_fd.write_str(&input.to_ascii_uppercase());
0
});
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let status = run_string(&mut state, &mut runtime, "f() { upper; } && emit hi | f\n");
assert_eq!(status, 0);
assert_eq!(stdout.collect(), "HI\n");
}
#[test]
fn execution_builder_keeps_function_body_lazy_until_invocation() {
let (status, output, error, state) =
run_and_capture_output_and_error("f() { definitelymissingcommand | cat; }\necho ok\n");
assert_eq!(status, 0);
assert_eq!(output, "ok\n");
assert_eq!(error, "");
assert!(state.definition_store.functions.contains_key("f"));
}
#[test]
fn here_document_with_expansion() {
let mut state = ShellState::new();
state.populate_env();
state.env_set("X", "world".to_string(), 0);
let script = "cat <<EOF\nhello $X\nEOF\n";
let mut parser = crate::parser::Parser::from_string(script);
let prog = parser.parse_program().unwrap();
let command = &prog.body[0].and_or_list;
let redir = match command {
AndOrList::Pipeline(Pipeline { commands, .. }) => match &commands[0] {
AstCommand::Simple(sc) => &sc.io_redirects[0],
other => panic!("expected simple command, got {other:?}"),
},
other => panic!("expected pipeline, got {other:?}"),
};
assert!(redir.here_document_expand);
let mut result = String::new();
let mut runtime = sys::InMemoryRuntime::new();
for w in &redir.here_document {
result.push_str(&shell_expand::expand_here_document_word(
&mut state,
&mut runtime,
w,
));
result.push('\n');
}
assert_eq!(
result, "hello world\n",
"here-doc should expand variables: got {result:?}"
);
}
#[test]
fn here_document_quoted_delimiter() {
let mut state = ShellState::new();
state.populate_env();
state.env_set("X", "world".to_string(), 0);
let mut parser = crate::parser::Parser::from_string("cat <<'EOF'\nhello $X\nEOF\n");
let prog = parser.parse_program().unwrap();
let command = &prog.body[0].and_or_list;
let redir = match command {
AndOrList::Pipeline(Pipeline { commands, .. }) => match &commands[0] {
AstCommand::Simple(sc) => &sc.io_redirects[0],
other => panic!("expected simple command, got {other:?}"),
},
other => panic!("expected pipeline, got {other:?}"),
};
assert!(!redir.here_document_expand);
let mut result = String::new();
let mut runtime = sys::InMemoryRuntime::new();
for w in &redir.here_document {
result.push_str(&shell_expand::expand_here_document_word(
&mut state,
&mut runtime,
w,
));
result.push('\n');
}
assert_eq!(
result, "hello $X\n",
"quoted here-doc should NOT expand variables: got {result:?}"
);
}
#[test]
fn here_document_backslash_quoted_delimiter() {
let mut state = ShellState::new();
state.populate_env();
state.env_set("X", "world".to_string(), 0);
let mut parser = crate::parser::Parser::from_string("cat <<\\EOF\nhello $X\nEOF\n");
let prog = parser.parse_program().unwrap();
let command = &prog.body[0].and_or_list;
let redir = match command {
AndOrList::Pipeline(Pipeline { commands, .. }) => match &commands[0] {
AstCommand::Simple(sc) => &sc.io_redirects[0],
other => panic!("expected simple command, got {other:?}"),
},
other => panic!("expected pipeline, got {other:?}"),
};
assert!(!redir.here_document_expand);
let mut result = String::new();
let mut runtime = sys::InMemoryRuntime::new();
for w in &redir.here_document {
result.push_str(&shell_expand::expand_here_document_word(
&mut state,
&mut runtime,
w,
));
result.push('\n');
}
assert_eq!(
result, "hello $X\n",
"backslash-quoted here-doc should NOT expand variables: got {result:?}"
);
}
#[test]
fn here_document_parameter_syntax_delimiter_runs_end_to_end() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, "/bin/cat <<$EOF\nhello\n$EOF\n");
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output, "hello\n");
}
#[test]
fn stale_inherited_fd_entries_do_not_abort_external_launch() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let stale_pipe = sys::OsPipe::new().expect("stale fd fixture pipe");
let stale_child_fd = stale_pipe.read_fd.as_i32();
state.set_inherited_fd(stale_child_fd, stale_pipe.read_fd);
stale_pipe.read_fd.close();
stale_pipe.write_fd.close();
let status = run_string(&mut state, &mut runtime, "/bin/cat <<EOF\nhello\nEOF\n");
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output, "hello\n");
}
#[test]
fn programmatic_literal_heredoc_uses_explicit_mode_at_runtime() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
state.env_set("X", "world".to_string(), 0);
let mut runtime = sys::UnixRuntime::new();
let prog = program! {
body: vec![crate::ast::CommandList {
and_or_list: crate::ast::AndOrList::Pipeline(crate::ast::Pipeline {
commands: vec![AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: "/bin/cat".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: vec![crate::ast::IoRedirect {
io_number: None,
op: crate::ast::IoRedirectOp::DLess,
name: word_string! {
value: "EOF".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
},
here_document: vec![word_list! {
children: vec![
word_string! {
value: "hello ".to_string(),
single_quoted: false,
split_fields: false,
source: None,
range: Range::default(),
},
word_parameter! {
name: "X".to_string(),
op: crate::ast::ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
},
],
double_quoted: false,
range: Range::default(),
}],
here_document_expand: false,
range: Range::default(),
}],
assignments: Vec::new(),
})],
bang: false,
range: Default::default(),
}),
ampersand: false,
range: Default::default(),
}],
};
let status = run_program(&mut state, &mut runtime, &prog);
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output, "hello $X\n");
}
#[test]
fn here_document_restores_original_stdin_fd() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdin = sys::StringStdioIn::new("unused\n");
let stdout = sys::StringStdioOut::new();
let original_stdin = stdin.fd();
let mut state = ShellState::new();
state.populate_env();
state.stdin_fd = original_stdin;
state.stdout_fd = stdout.fd();
state.env_set("X", "world".to_string(), 0);
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"/bin/cat <<'EOF'\nhello $X\nEOF\n",
);
let output = stdout.collect();
stdin.join();
assert_eq!(status, 0);
assert_eq!(output, "hello $X\n");
assert_eq!(state.stdin_fd, original_stdin);
}
#[test]
fn large_here_document_feeds_command_without_pipe_deadlock() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let body = "x".repeat(2 * 1024 * 1024);
let expected_stdin = format!("{body}END\n");
let expected_len = expected_stdin.len();
let expected_stdin_for_command = expected_stdin.clone();
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("verify-stdin", move |_argv, _env, _cwd, stdio| {
let input = stdio
.stdin_fd
.dup()
.expect("dup verify-stdin stdin")
.read_to_string()
.expect("read verify-stdin stdin");
let matches = input == expected_stdin_for_command;
let _ = stdio
.stdout_fd
.write_line(&format!("len={} match={matches}", input.len()));
i32::from(!matches)
});
let script = format!("verify-stdin <<'EOF'\n{body}END\nEOF\n");
let status = run_string(&mut state, &mut runtime, &script);
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output, format!("len={expected_len} match=true\n"));
}
#[test]
fn output_redirection_restores_original_stdout_fd() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let original_stdout = stdout.fd();
let path = temp_path("redirect-stdout");
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = original_stdout;
let mut runtime = sys::UnixRuntime::new();
let script = format!("echo file > {}\necho shell\n", path.display());
let status = run_string(&mut state, &mut runtime, &script);
let restored_stdout = state.stdout_fd;
if restored_stdout != original_stdout {
restored_stdout.close();
}
let output = stdout.collect();
let redirected = std::fs::read_to_string(&path).expect("read redirected output");
assert_eq!(status, 0);
assert_eq!(restored_stdout, original_stdout);
assert_eq!(output, "shell\n");
assert_eq!(redirected, "file\n");
let _ = std::fs::remove_file(path);
}
#[test]
fn symlink_redirect_target_is_rejected_when_policy_disallows_symlinks() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let dir = temp_path("redirect-symlink");
std::fs::create_dir_all(&dir).expect("create temp dir");
let target = dir.join("target");
std::fs::write(&target, b"original\n").expect("write target");
let link = dir.join("link");
std::os::unix::fs::symlink(&target, &link).expect("create symlink");
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stderr_fd = stderr.fd();
Arc::make_mut(&mut state.definition).security_policy =
ShellSecurityPolicy::permissive().with_redirect_target_symlinks(false);
let mut runtime = sys::InMemoryRuntime::new();
let script = format!("echo changed > {}\n", link.display());
let status = run_string(&mut state, &mut runtime, &script);
assert_eq!(status, 1);
assert_eq!(
std::fs::read_to_string(&target).expect("target should still exist"),
"original\n"
);
assert!(!stderr.collect().is_empty(), "expected redirect error");
let _ = std::fs::remove_file(link);
let _ = std::fs::remove_file(target);
let _ = std::fs::remove_dir(dir);
}
#[test]
fn programmatic_literal_heredoc_preserves_backquoted_command_substitution_at_runtime() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let prog = program! {
body: vec![crate::ast::CommandList {
and_or_list: crate::ast::AndOrList::Pipeline(crate::ast::Pipeline {
commands: vec![AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: "/bin/cat".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: vec![crate::ast::IoRedirect {
io_number: None,
op: crate::ast::IoRedirectOp::DLess,
name: word_string! {
value: "EOF".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
},
here_document: vec![word_command! {
program: program! {
body: vec![crate::ast::CommandList {
and_or_list: crate::ast::AndOrList::Pipeline(
crate::ast::Pipeline {
commands: vec![AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: "date".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: Vec::new(),
assignments: Vec::new(),
})],
bang: false,
range: Default::default(),
},
),
ampersand: false,
range: Default::default(),
}],
},
source: None,
back_quoted: true,
range: Range::default(),
}],
here_document_expand: false,
range: Range::default(),
}],
assignments: Vec::new(),
})],
bang: false,
range: Default::default(),
}),
ampersand: false,
range: Default::default(),
}],
};
let status = run_program(&mut state, &mut runtime, &prog);
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output, "`date`\n");
}
#[test]
fn special_parameter_bang_uses_last_background_pid() {
let mut state = ShellState::new();
state.job_table.last_bg_pid = Some(4242);
assert_eq!(
shell_expand::get_parameter_value(&state, "!"),
Some("4242".to_string())
);
}
#[test]
fn machine_payload_restores_shell_state() {
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("emit", |argv, _env, _cwd, stdio| {
if let Some(text) = argv.get(1) {
let _ = stdio.stdout_fd.write_line(text);
}
0
});
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
state.env_set("MSG", "hello".to_string(), 0);
let payload = BackgroundMachinePayload {
definition: state.definition.portable_background_checkpoint(),
state: state.background_checkpoint(),
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: "emit".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: vec![word_parameter! {
name: "MSG".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
}],
io_redirects: Vec::new(),
assignments: Vec::new(),
})],
bang: false,
range: Default::default(),
}),
command_id: "00000000-0000-4000-8000-000000000000".to_string(),
};
let payload_text = serde_json::to_string(&payload).expect("payload should serialize");
let status = exec::run_machine_payload(&mut state, &mut runtime, &payload_text);
assert_eq!(status, 0);
assert_eq!(output.collect(), "hello\n");
}
#[test]
fn exit_trap_runs_before_shell_termination() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let argv = vec![
"mxsh".to_string(),
"-c".to_string(),
"trap 'TRAP_RAN=1' EXIT".to_string(),
];
let mut state = ShellState::new();
let mut runtime = sys::UnixRuntime::new();
let code = run_with_state(&argv, &mut state, &mut runtime);
assert_eq!(code, 0);
assert_eq!(state.env_get("TRAP_RAN"), Some("1"));
}
#[test]
fn exit_trap_runs_after_explicit_exit() {
let (code, output, _state) =
run_cli_script_and_capture_output("trap 'echo cleanup:$?' EXIT; exit 7; echo after");
assert_eq!(code, 7);
assert_eq!(output, "cleanup:7\n");
}
#[test]
fn exit_trap_runs_after_errexit() {
let (code, output, _state) =
run_cli_script_and_capture_output("trap 'echo cleanup:$?' EXIT; set -e; false; echo after");
assert_eq!(code, 1);
assert_eq!(output, "cleanup:1\n");
}
#[test]
fn login_shell_argv_detects_dash_prefixed_argv0() {
assert!(is_login_shell_argv(&["-mxsh".to_string()]));
assert!(!is_login_shell_argv(&["mxsh".to_string()]));
assert!(!is_login_shell_argv(&[]));
}
#[test]
fn source_profile_requires_interactive_login_shell() {
let mut state = ShellState::new();
state.interactive = true;
assert!(should_source_profile(&state, &["-mxsh".to_string()]));
assert!(!should_source_profile(&state, &["mxsh".to_string()]));
state.options |= OPT_NOEXEC;
assert!(!should_source_profile(&state, &["-mxsh".to_string()]));
state.options = 0;
state.interactive = false;
assert!(!should_source_profile(&state, &["-mxsh".to_string()]));
}
#[test]
fn builtin_builtin_executes_builtin() {
let (status, _) = run_and_capture("builtin false");
assert_eq!(status, 1);
}
#[test]
fn ulimit_query_succeeds() {
let (status, _) = run_and_capture("ulimit -n");
assert_eq!(status, 0);
}
#[test]
fn alias_defined_on_same_line_not_expanded_later_on_that_line() {
let (status, _) = run_and_capture("alias nope=false; nope");
assert_eq!(status, 127);
}
#[test]
fn alias_expands_on_following_line() {
let (status, _) = run_and_capture("alias nope=false\nnope\n");
assert_eq!(status, 1);
}
#[test]
fn redirect_open_failure_skips_command_and_sets_status() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"echo hi </definitely_missing_file_xyz\n",
);
let output = stdout.collect();
assert_eq!(status, 1);
assert_eq!(output, "");
}
#[test]
fn bad_fd_dup_skips_command_and_sets_status() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(&mut state, &mut runtime, "echo hi 1>&999\n");
let output = stdout.collect();
assert_eq!(status, 1);
assert_eq!(output, "");
}
#[test]
fn multi_digit_fd_redirection_does_not_become_argument() {
let dir = std::env::temp_dir();
let path = dir.join(format!("mxsh-multi-fd-{}", std::process::id()));
let script = format!("echo hi 10>{}\n", path.display());
let (status, _) = run_and_capture(&script);
assert_eq!(status, 0);
let content = std::fs::read_to_string(&path).expect("redirect target should exist");
assert_eq!(content, "");
let _ = std::fs::remove_file(path);
}
#[test]
fn child_launch_plan_open_fds_override_generic_close_fds() {
let extra_pipe = sys::OsPipe::new().expect("extra fd pipe should be creatable");
let stdio_pipe = sys::OsPipe::new().expect("stdio pipe should be creatable");
let used_fds = [
sys::FileDescriptor::STDIN.as_i32(),
sys::FileDescriptor::STDOUT.as_i32(),
sys::FileDescriptor::STDERR.as_i32(),
3,
extra_pipe.read_fd.as_i32(),
extra_pipe.write_fd.as_i32(),
stdio_pipe.read_fd.as_i32(),
stdio_pipe.write_fd.as_i32(),
];
let mut close_fd_candidates = (10..64).filter(|fd| !used_fds.contains(fd));
let closed_child_fd = close_fd_candidates
.next()
.expect("test should have an unused child fd");
let ambient_child_fd = close_fd_candidates
.next()
.expect("test should have an unused ambient fd");
let mut state = ShellState::new();
state.fd_table.raw_fds.clear();
state.fd_table.raw_fds.insert(3, extra_pipe.read_fd);
let launch = ChildLaunchPlan::new(
&state,
"/bin/cat",
vec!["cat".to_string()],
ProcessGroupPlan::Inherit,
)
.with_stdio(
sys::FileDescriptor::STDIN,
stdio_pipe.write_fd,
sys::FileDescriptor::STDERR,
)
.with_extra_fd(closed_child_fd, ChildFdDisposition::Closed)
.with_close_fds(vec![
sys::FileDescriptor::new(1),
sys::FileDescriptor::new(3),
sys::FileDescriptor::new(closed_child_fd),
sys::FileDescriptor::new(ambient_child_fd),
]);
assert_eq!(
launch.child_close_fds(),
vec![
sys::FileDescriptor::new(closed_child_fd),
sys::FileDescriptor::new(ambient_child_fd)
]
);
extra_pipe.read_fd.close();
extra_pipe.write_fd.close();
stdio_pipe.read_fd.close();
stdio_pipe.write_fd.close();
}
#[test]
fn child_launch_plan_does_not_close_open_fd_sources() {
let extra_pipe = sys::OsPipe::new().expect("extra fd pipe should be creatable");
let stdio_pipe = sys::OsPipe::new().expect("stdio pipe should be creatable");
let launch = ChildLaunchPlan::new(
&ShellState::new(),
"/bin/cat",
vec!["cat".to_string()],
ProcessGroupPlan::Inherit,
)
.with_stdio(
sys::FileDescriptor::STDIN,
stdio_pipe.write_fd,
sys::FileDescriptor::STDERR,
)
.with_extra_fd(255, ChildFdDisposition::OpenFrom(extra_pipe.read_fd))
.with_close_fds(vec![extra_pipe.read_fd, stdio_pipe.write_fd]);
assert_eq!(launch.child_close_fds(), Vec::<sys::FileDescriptor>::new());
extra_pipe.read_fd.close();
extra_pipe.write_fd.close();
stdio_pipe.read_fd.close();
stdio_pipe.write_fd.close();
}
#[test]
fn alias_pipeline_executes_pipeline() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, "alias a='echo left | cat'\na\n");
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output.trim(), "left");
}
#[test]
fn alias_in_command_substitution_preserves_closing_delimiter() {
let (status, _out, err, state) = run_and_capture_output_and_error(
"alias myalias='echo )'\nvar=\"$(myalias arg-two)\"\nAFTER=1\n",
);
assert_eq!(status, 0);
assert_eq!(state.exit_code, -1);
assert_eq!(state.env_get("AFTER"), Some("1"));
assert!(
state.env_get("var").is_some_and(|value| !value.is_empty()),
"expected command substitution output to be assigned"
);
assert_eq!(err, "");
}
#[test]
fn unterminated_backquote_in_double_quotes_is_non_fatal() {
let stdout = sys::StringStdioOut::new();
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
state.stderr_fd = stderr.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"printf '%s\\n' \"cmd: `echo \"`\"\n",
);
let output = stdout.collect();
let errors = stderr.collect();
assert_eq!(status, 0);
assert_eq!(output, "cmd: \n");
assert!(errors.contains("unexpected EOF while looking for matching `\"'"));
assert_eq!(state.exit_code, -1);
}
#[test]
fn unterminated_backquote_in_single_quotes_is_non_fatal() {
let stdout = sys::StringStdioOut::new();
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
state.stderr_fd = stderr.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"printf '%s\\n' \"cmd: `echo '`\"\n",
);
let output = stdout.collect();
let errors = stderr.collect();
assert_eq!(status, 0);
assert_eq!(output, "cmd: \n");
assert!(errors.contains("unexpected EOF while looking for matching `''"));
assert_eq!(state.exit_code, -1);
}
#[test]
fn command_substitution_uses_forked_runtime() {
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("caps", |argv, _, _, stdio| {
let text = argv.get(1).cloned().unwrap_or_default().to_uppercase();
let _ = stdio.stdout_fd.write_line(&text);
0
});
let status = run_string(&mut state, &mut runtime, "VALUE=$(caps hello)\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("VALUE"), Some("HELLO"));
}
#[test]
fn command_substitution_argument_preserves_status_for_later_argument() {
let (status, output, _state) =
run_and_capture_output("false; printf '%s\\n' $(printf %s alpha) $?\n");
assert_eq!(status, 0);
assert_eq!(output, "alpha\n1\n");
}
#[test]
fn command_substitution_after_or_preserves_left_status_for_later_argument() {
let (status, output, _state) =
run_and_capture_output("! : || printf '%s\\n' $(printf %s alpha) $?\n");
assert_eq!(status, 0);
assert_eq!(output, "alpha\n1\n");
}
#[test]
fn assignment_command_substitution_preserves_status_for_later_assignment() {
let (status, output, _state) =
run_and_capture_output("false; A=$(true) B=$?; printf '<%s> <%s>\\n' \"$A\" \"$B\"\n");
assert_eq!(status, 0);
assert_eq!(output, "<> <1>\n");
}
#[test]
fn command_substitution_strips_trailing_newlines() {
let (_, state) = run_and_capture("VALUE=$(printf 'hello\\n\\n')\n");
assert_eq!(state.env_get("VALUE"), Some("hello"));
}
#[test]
fn builtin_printf_formats_percent_s_and_escapes() {
let (status, output, _state) = run_and_capture_output("printf 'item:%s\\n' 'hello world'\n");
assert_eq!(status, 0);
assert_eq!(output, "item:hello world\n");
}
#[test]
fn builtin_printf_reuses_format_for_extra_arguments() {
let (status, output, _state) = run_and_capture_output("printf '[%s]\\n' alpha beta\n");
assert_eq!(status, 0);
assert_eq!(output, "[alpha]\n[beta]\n");
}
#[test]
fn builtin_printf_supports_posix_float_conversions() {
let (status, output, _state) = run_and_capture_output(
"printf '[%.2f][%.2e][%.3g][%.3G][%#.0f][%#.0e][%#.3g]\\n' 3.14159 3.14159 12345 12345 2 2 2\n",
);
assert_eq!(status, 0);
assert_eq!(
output,
"[3.14][3.14e+00][1.23e+04][1.23E+04][2.][2.e+00][2.00]\n"
);
}
#[test]
fn builtin_printf_reports_invalid_numeric_operands() {
let (status, output, err, state) =
run_and_capture_output_and_error("printf '%d|%f|%d|%d\\n' nope 2.5x '' 08\n");
assert_eq!(status, 1);
assert_eq!(state.last_status, 1);
assert_eq!(output, "0|0.000000|0|0\n");
assert_eq!(
err,
"printf: nope: invalid number\nprintf: 2.5x: invalid number\nprintf: 08: invalid number\n"
);
}
#[test]
fn builtin_printf_accepts_quoted_characters_and_c_integer_constants() {
let (status, output, err, _state) = run_and_capture_output_and_error(
"printf '%d|%u|%x|%o|%d|%d|%f|%g\\n' \"'A\" \"'A\" \"'A\" \"'A\" 0x10 010 '\"A' 0x1p2\n",
);
assert_eq!(status, 0);
assert_eq!(output, "65|65|41|101|16|8|65.000000|4\n");
assert_eq!(err, "");
}
#[test]
fn builtin_printf_unsupported_format_fails_locally() {
let (status, output, err, state) = run_and_capture_output_and_error("printf '%q\\n' 7\n");
assert_eq!(status, 1);
assert_eq!(state.last_status, 1);
assert_eq!(output, "");
assert_eq!(err, "printf: unsupported format: %q\\n\n");
}
#[test]
fn assignment_uses_command_substitution_exit_status() {
let (status, state) = run_and_capture("A=$(! echo)\n");
assert_eq!(status, 1);
assert_eq!(state.env_get("A"), Some(""));
assert_eq!(state.last_status, 1);
}
#[test]
fn assignment_uses_command_substitution_exit_status_inside_arithmetic() {
let (status, state) = run_and_capture("A=$(( $(printf 2; exit 7) + 3 ))\n");
assert_eq!(status, 7);
assert_eq!(state.env_get("A"), Some("5"));
assert_eq!(state.last_status, 7);
}
#[test]
fn errexit_observes_assignment_arithmetic_command_substitution_status() {
let (status, output, state) =
run_and_capture_output("set -e\nA=$(( $(printf 2; exit 7) + 3 ))\necho after\n");
assert_eq!(status, 7);
assert_eq!(output, "");
assert_eq!(state.env_get("A"), Some("5"));
assert_eq!(state.exit_code, 7);
}
#[test]
fn special_builtin_keeps_prefix_assignment() {
let (_, state) = run_and_capture("X=1 export X; : \"$X\"");
assert_eq!(state.env_get("X"), Some("1"));
}
#[test]
fn function_prefix_assignment_is_visible_and_persistent() {
let (_, state) = run_and_capture("f() { INSIDE=$X; }\nX=1 f\nAFTER=$X\n");
assert_eq!(state.env_get("INSIDE"), Some("1"));
assert_eq!(state.env_get("AFTER"), Some("1"));
}
#[test]
fn nounset_stops_script_on_unset_parameter() {
let (status, state) = run_and_capture("set -u\n: \"$UNSETVAR\"\nAFTER=1\n");
assert_eq!(status, 1);
assert_eq!(state.exit_code, 1);
assert_eq!(state.env_get("AFTER"), None);
}
#[test]
fn nounset_argument_expansion_error_skips_command_execution() {
let (status, state) = run_and_capture(
"side_effect() { SIDE_EFFECT=ran; }\nset -u\nside_effect \"$UNSETVAR\"\nAFTER=1\n",
);
assert_eq!(status, 1);
assert_eq!(state.exit_code, 1);
assert_eq!(state.env_get("SIDE_EFFECT"), None);
assert_eq!(state.env_get("AFTER"), None);
}
#[test]
fn nounset_redirection_expansion_error_skips_file_creation() {
let path = temp_path("nounset-redir-side-effect");
let _ = std::fs::remove_file(&path);
let script = format!("set -u\n: > '{}'$UNSETVAR\nAFTER=1\n", path.display());
let (status, state) = run_and_capture(&script);
assert_eq!(status, 1);
assert_eq!(state.exit_code, 1);
assert!(!path.exists(), "redirection target should not be created");
assert_eq!(state.env_get("AFTER"), None);
let _ = std::fs::remove_file(path);
}
#[test]
fn signal_trap_runs_pending_action() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
shell_traps::clear_all_pending_traps();
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(&mut state, &mut runtime, "trap 'TRAP_SIG=1' INT\n");
assert_eq!(status, 0);
shell_traps::trap_signal_handler(libc::SIGINT);
let status = run_string(&mut state, &mut runtime, ":\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("TRAP_SIG"), Some("1"));
shell_traps::clear_all_pending_traps();
}
#[test]
fn unrelated_shell_does_not_consume_pending_signal_trap() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
shell_traps::clear_all_pending_traps();
let mut trapped_state = ShellState::new();
trapped_state.populate_env();
let mut trapped_runtime = sys::InMemoryRuntime::new();
let status = run_string(
&mut trapped_state,
&mut trapped_runtime,
"trap 'TRAP_SIG=1' INT\n",
);
assert_eq!(status, 0);
let mut other_state = ShellState::new();
other_state.populate_env();
let mut other_runtime = sys::InMemoryRuntime::new();
shell_traps::trap_signal_handler(libc::SIGINT);
let status = run_string(&mut other_state, &mut other_runtime, ":\n");
assert_eq!(status, 0);
assert_eq!(other_state.env_get("TRAP_SIG"), None);
let status = run_string(&mut trapped_state, &mut trapped_runtime, ":\n");
assert_eq!(status, 0);
assert_eq!(trapped_state.env_get("TRAP_SIG"), Some("1"));
shell_traps::clear_all_pending_traps();
}
#[test]
fn same_signal_trap_is_delivered_to_each_registered_shell() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
shell_traps::clear_all_pending_traps();
let mut first_state = ShellState::new();
first_state.populate_env();
let mut first_runtime = sys::InMemoryRuntime::new();
assert_eq!(
run_string(&mut first_state, &mut first_runtime, "trap 'FIRST=1' INT\n"),
0
);
let mut second_state = ShellState::new();
second_state.populate_env();
let mut second_runtime = sys::InMemoryRuntime::new();
assert_eq!(
run_string(
&mut second_state,
&mut second_runtime,
"trap 'SECOND=1' INT\n"
),
0
);
shell_traps::trap_signal_handler(libc::SIGINT);
assert_eq!(run_string(&mut first_state, &mut first_runtime, ":\n"), 0);
assert_eq!(first_state.env_get("FIRST"), Some("1"));
assert_eq!(run_string(&mut second_state, &mut second_runtime, ":\n"), 0);
assert_eq!(second_state.env_get("SECOND"), Some("1"));
shell_traps::clear_all_pending_traps();
}
#[test]
fn restoring_one_managed_signal_session_keeps_other_registration() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
shell_traps::clear_all_pending_traps();
let mut first_state = ShellState::new();
first_state.manage_signals = true;
first_state.populate_env();
let mut first_runtime = sys::InMemoryRuntime::new();
assert_eq!(
run_string(
&mut first_state,
&mut first_runtime,
"trap 'FIRST=1' TERM\n"
),
0
);
let mut second_state = ShellState::new();
second_state.manage_signals = true;
second_state.populate_env();
let mut second_runtime = sys::InMemoryRuntime::new();
assert_eq!(
run_string(
&mut second_state,
&mut second_runtime,
"trap 'SECOND=${SECOND:-0}x' TERM\n"
),
0
);
shell_traps::restore_trap_signals(&mut first_state);
shell_traps::trap_signal_handler(libc::SIGTERM);
assert_eq!(run_string(&mut second_state, &mut second_runtime, ":\n"), 0);
assert_eq!(second_state.env_get("SECOND"), Some("0x"));
shell_traps::restore_trap_signals(&mut second_state);
shell_traps::clear_all_pending_traps();
}
#[test]
fn restored_signal_sessions_release_registration_slots() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
shell_traps::clear_all_pending_traps();
for _ in 0..70 {
let mut state = ShellState::new();
state.manage_signals = true;
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
assert_eq!(run_string(&mut state, &mut runtime, "trap ':' INT\n"), 0);
shell_traps::restore_trap_signals(&mut state);
}
let mut state = ShellState::new();
state.manage_signals = true;
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
assert_eq!(
run_string(&mut state, &mut runtime, "trap 'SEEN=1' INT\n"),
0
);
shell_traps::trap_signal_handler(libc::SIGINT);
assert_eq!(run_string(&mut state, &mut runtime, ":\n"), 0);
assert_eq!(state.env_get("SEEN"), Some("1"));
shell_traps::restore_trap_signals(&mut state);
shell_traps::clear_all_pending_traps();
}
#[test]
fn parameter_assign_default() {
let (_, state) = run_and_capture("Y=${UNSET:=fallback}");
assert_eq!(state.env_get("Y"), Some("fallback"));
assert_eq!(
state.env_get("UNSET"),
Some("fallback"),
":= must assign the default to the variable"
);
}
#[test]
fn parameter_assign_default_already_set() {
let (_, state) = run_and_capture("EXISTING=original; Y=${EXISTING:=fallback}");
assert_eq!(state.env_get("Y"), Some("original"));
assert_eq!(state.env_get("EXISTING"), Some("original"));
}
#[test]
fn parameter_assign_default_respects_readonly() {
let (status, _out, err, state) =
run_and_capture_output_and_error("readonly X\nY=${X:=fallback}\nAFTER=1\n");
assert_eq!(status, 1);
assert_eq!(state.exit_code, 1);
assert_eq!(state.env_get("X"), None);
assert_eq!(state.env_get("Y"), None);
assert_eq!(state.env_get("AFTER"), None);
assert!(err.contains("X: readonly variable"));
}
#[test]
fn parameter_error_if_unset() {
let (_, state) = run_and_capture("Y=${UNSET:?oops}; AFTER=1");
assert_eq!(
state.env_get("AFTER"),
None,
":? on unset var must abort the script"
);
assert_eq!(state.exit_code, 1);
}
#[test]
fn parameter_error_argument_expansion_skips_command_execution() {
let (status, state) = run_and_capture(
"side_effect() { SIDE_EFFECT=ran; }\nside_effect \"${UNSET:?oops}\"\nAFTER=1\n",
);
assert_eq!(status, 1);
assert_eq!(state.exit_code, 1);
assert_eq!(state.env_get("SIDE_EFFECT"), None);
assert_eq!(state.env_get("AFTER"), None);
}
#[test]
fn parameter_error_redirection_expansion_skips_file_creation() {
let path = temp_path("parameter-error-redir-side-effect");
let _ = std::fs::remove_file(&path);
let script = format!(": > '{}'${{UNSET:?oops}}\nAFTER=1\n", path.display());
let (status, state) = run_and_capture(&script);
assert_eq!(status, 1);
assert_eq!(state.exit_code, 1);
assert!(!path.exists(), "redirection target should not be created");
assert_eq!(state.env_get("AFTER"), None);
let _ = std::fs::remove_file(path);
}
#[test]
fn parameter_error_if_unset_but_set() {
let (_, state) = run_and_capture("EXISTING=ok; Y=${EXISTING:?oops}");
assert_eq!(state.env_get("Y"), Some("ok"));
}
#[test]
fn parameter_alternative_when_set() {
let (_, state) = run_and_capture("EXISTING=ok; Y=${EXISTING:+alt}");
assert_eq!(state.env_get("Y"), Some("alt"));
}
#[test]
fn parameter_alternative_when_unset() {
let (_, state) = run_and_capture("Y=${UNSET:+alt}");
assert_eq!(state.env_get("Y"), Some(""));
}
#[test]
fn parameter_length() {
let (_, state) = run_and_capture("X=hello; Y=${#X}");
assert_eq!(state.env_get("Y"), Some("5"));
}
#[test]
fn parameter_length_empty() {
let (_, state) = run_and_capture("X=; Y=${#X}");
assert_eq!(state.env_get("Y"), Some("0"));
}
#[test]
fn parameter_length_unset() {
let (_, state) = run_and_capture("Y=${#UNSET}");
assert_eq!(state.env_get("Y"), Some("0"));
}
#[test]
fn pattern_match_bracket_class() {
assert!(shell_expand::pattern_match("[a-z]", "m"));
assert!(!shell_expand::pattern_match("[a-z]", "M"));
assert!(shell_expand::pattern_match("[0-9]", "5"));
assert!(!shell_expand::pattern_match("[0-9]", "x"));
}
#[test]
fn pattern_match_negated_bracket() {
assert!(shell_expand::pattern_match("[!a-z]", "5"));
assert!(!shell_expand::pattern_match("[!a-z]", "m"));
}
#[test]
fn pattern_match_bracket_with_star() {
assert!(shell_expand::pattern_match(
"file[0-9]*.txt",
"file3abc.txt"
));
assert!(!shell_expand::pattern_match(
"file[0-9]*.txt",
"fileXabc.txt"
));
}
#[test]
fn pattern_match_backslash_escape() {
assert!(shell_expand::pattern_match("hello\\*", "hello*"));
assert!(!shell_expand::pattern_match("hello\\*", "helloX"));
}
#[test]
fn pattern_match_unterminated_bracket_is_literal() {
assert!(shell_expand::pattern_match("[", "["));
assert!(shell_expand::pattern_match("[abc", "[abc"));
assert!(!shell_expand::pattern_match("[abc", "xabc"));
}
#[test]
fn case_and_parameter_removal_treat_unterminated_bracket_as_literal() {
let (status, output, _) = run_and_capture_output(
"case '[' in [) printf 'case-ok\\n';; *) printf 'case-no\\n';; esac\n\
x='[abc'; printf '<%s>\\n' \"${x#[}\"\n",
);
assert_eq!(status, 0);
assert_eq!(output, "case-ok\n<abc>\n");
}
#[test]
fn continue_in_loop() {
let (_, state) = run_and_capture(
"R=; for x in a b c; do if [ $x = b ]; then continue; fi; R=${R}${x}; done",
);
assert_eq!(state.env_get("R"), Some("ac"));
}
#[test]
fn break_nested_levels() {
let (_, state) = run_and_capture(
"R=; for i in 1 2; do for j in a b; do R=${R}${i}${j}; break 2; done; done",
);
assert_eq!(
state.env_get("R"),
Some("1a"),
"break 2 must exit both loops"
);
}
#[test]
fn continue_nested_levels() {
let (_, state) = run_and_capture(
"R=; for i in 1 2; do for j in a b; do R=${R}${i}${j}; continue 2; done; done",
);
assert_eq!(
state.env_get("R"),
Some("1a2a"),
"continue 2 must skip to next outer iteration"
);
}
#[test]
fn overdeep_loop_control_resumes_after_outermost_loop() {
for script in [
"for i in 1; do break 2; done; echo after",
"for i in 1; do continue 2; done; echo after",
] {
let (status, output, _) = run_and_capture_output(script);
assert_eq!(status, 0, "script should succeed: {script}");
assert_eq!(output, "after\n", "script should resume: {script}");
}
}
#[test]
fn return_from_function() {
let (_, state) = run_and_capture("f() { R=before; return 42; R=after; }; f; S=$?");
assert_eq!(state.env_get("R"), Some("before"));
assert_eq!(state.env_get("S"), Some("42"));
}
#[test]
fn return_negative_status_is_normalized() {
let (_, state) = run_and_capture("f() { return -1; R=after; }; f; S=$?");
assert_eq!(state.env_get("R"), None);
assert_eq!(state.env_get("S"), Some("255"));
assert_eq!(state.exit_code, -1);
}
#[test]
fn break_outside_loop_aborts_non_interactive_script() {
let (status, state) = run_and_capture("break\nAFTER=1\n");
assert_eq!(status, 1);
assert_eq!(state.env_get("AFTER"), None);
assert_eq!(state.exit_code, 1);
}
#[test]
fn continue_outside_loop_aborts_non_interactive_script() {
let (status, state) = run_and_capture("continue\nAFTER=1\n");
assert_eq!(status, 1);
assert_eq!(state.env_get("AFTER"), None);
assert_eq!(state.exit_code, 1);
}
#[test]
fn return_outside_function_aborts_non_interactive_script() {
let (status, state) = run_and_capture("return 7\nAFTER=1\n");
assert_eq!(status, 1);
assert_eq!(state.env_get("AFTER"), None);
assert_eq!(state.exit_code, 1);
}
#[test]
fn break_zero_is_rejected() {
let (status, state) = run_and_capture("for x in 1; do break 0; done\nAFTER=1\n");
assert_eq!(status, 1);
assert_eq!(state.env_get("AFTER"), None);
assert_eq!(state.exit_code, 1);
}
#[test]
fn continue_zero_is_rejected() {
let (status, state) = run_and_capture("for x in 1; do continue 0; done\nAFTER=1\n");
assert_eq!(status, 1);
assert_eq!(state.env_get("AFTER"), None);
assert_eq!(state.exit_code, 1);
}
#[test]
fn until_loop() {
let (_, state) = run_and_capture("I=0; until [ $I -ge 3 ]; do I=$((I+1)); done");
assert_eq!(state.env_get("I"), Some("3"));
}
#[test]
fn arithmetic_ternary() {
let (_, state) = run_and_capture("X=$((1 ? 10 : 20))");
assert_eq!(state.env_get("X"), Some("10"));
let (_, state) = run_and_capture("X=$((0 ? 10 : 20))");
assert_eq!(state.env_get("X"), Some("20"));
}
#[test]
fn arithmetic_assignment() {
let (_, state) = run_and_capture("X=$((Y=42))");
assert_eq!(state.env_get("X"), Some("42"));
assert_eq!(state.env_get("Y"), Some("42"));
}
#[test]
fn arithmetic_division_by_zero() {
let (status, _out, err, state) = run_and_capture_output_and_error("X=$((10 / 0))");
assert_eq!(status, 2);
assert_eq!(state.env_get("X"), None);
assert!(err.contains("arithmetic expansion: division by zero"));
}
#[test]
fn arithmetic_modulo_by_zero() {
let (status, _out, err, state) = run_and_capture_output_and_error("X=$((10 % 0))");
assert_eq!(status, 2);
assert_eq!(state.env_get("X"), None);
assert!(err.contains("arithmetic expansion: division by zero"));
}
#[test]
fn arithmetic_invalid_shift_count() {
let (status, _out, err, state) = run_and_capture_output_and_error("X=$((1 << -1))");
assert_eq!(status, 2);
assert_eq!(state.env_get("X"), None);
assert!(err.contains("arithmetic expansion: invalid shift count"));
}
#[test]
fn arithmetic_bitwise_ops() {
let (_, state) = run_and_capture("X=$((0xFF & 0x0F))");
assert_eq!(state.env_get("X"), Some("15"));
let (_, state) = run_and_capture("X=$((0xF0 | 0x0F))");
assert_eq!(state.env_get("X"), Some("255"));
let (_, state) = run_and_capture("X=$((0xFF ^ 0x0F))");
assert_eq!(state.env_get("X"), Some("240"));
}
#[test]
fn arithmetic_shift_ops() {
let (_, state) = run_and_capture("X=$((1 << 4))");
assert_eq!(state.env_get("X"), Some("16"));
let (_, state) = run_and_capture("X=$((16 >> 2))");
assert_eq!(state.env_get("X"), Some("4"));
}
#[test]
fn arithmetic_comparison_ops() {
let (_, state) = run_and_capture("X=$((3 < 5))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((5 < 3))");
assert_eq!(state.env_get("X"), Some("0"));
let (_, state) = run_and_capture("X=$((3 <= 3))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((3 == 3))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((3 != 4))");
assert_eq!(state.env_get("X"), Some("1"));
}
#[test]
fn arithmetic_logical_ops() {
let (_, state) = run_and_capture("X=$((1 && 1))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((1 && 0))");
assert_eq!(state.env_get("X"), Some("0"));
let (_, state) = run_and_capture("X=$((0 || 1))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((0 || 0))");
assert_eq!(state.env_get("X"), Some("0"));
}
#[test]
fn arithmetic_unary_ops() {
let (_, state) = run_and_capture("X=$((-5))");
assert_eq!(state.env_get("X"), Some("-5"));
let (_, state) = run_and_capture("X=$((~0))");
assert_eq!(state.env_get("X"), Some("-1"));
let (_, state) = run_and_capture("X=$((!0))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((!1))");
assert_eq!(state.env_get("X"), Some("0"));
}
#[test]
fn arithmetic_unary_minus_wraps_minimum_integer() {
let (status, output, _state) =
run_and_capture_output("X=-9223372036854775808; echo $((-X)); echo after");
assert_eq!(status, 0);
assert_eq!(output, "-9223372036854775808\nafter\n");
}
#[test]
fn arithmetic_variable_reference() {
let (_, state) = run_and_capture("A=10; X=$((A + 5))");
assert_eq!(state.env_get("X"), Some("15"));
}
#[test]
fn builtin_export_sets_export_flag() {
let (_, state) = run_and_capture("X=hello; export X");
let exported: Vec<_> = state
.exported_env()
.into_iter()
.filter(|(k, _)| k == "X")
.collect();
assert_eq!(exported.len(), 1);
assert_eq!(exported[0].1, "hello");
}
#[test]
fn builtin_export_with_assignment() {
let (_, state) = run_and_capture("export X=world");
let exported: Vec<_> = state
.exported_env()
.into_iter()
.filter(|(k, _)| k == "X")
.collect();
assert_eq!(exported.len(), 1);
assert_eq!(exported[0].1, "world");
}
#[test]
fn plain_assignment_preserves_existing_export_flag() {
let (status, state) = run_and_capture("export X=1; X=2");
let exported: Vec<_> = state
.exported_env()
.into_iter()
.filter(|(k, _)| k == "X")
.collect();
assert_eq!(status, 0);
assert_eq!(
state.variable_store.vars.get("X"),
Some(&Variable {
value: "2".to_string(),
attrib: VAR_EXPORT,
})
);
assert_eq!(exported, vec![("X".to_string(), "2".to_string())]);
}
#[test]
fn builtin_readonly_prevents_modification() {
let (status, state) = run_and_capture("X=first; readonly X; X=second");
assert_eq!(status, 1, "readonly assignment should fail");
assert_eq!(
state.env_get("X"),
Some("first"),
"readonly should prevent modification"
);
}
#[test]
fn readonly_prefix_assignment_fails_before_command_runs() {
let (_, state) = run_and_capture("readonly X=1; X=2 true; STATUS=$?; AFTER=1");
assert_eq!(
state.env_get("STATUS"),
Some("1"),
"readonly prefix assignment should fail"
);
assert_eq!(state.env_get("X"), Some("1"));
assert_eq!(state.env_get("AFTER"), Some("1"));
}
#[test]
fn readonly_assignment_cannot_overwrite_existing_readonly_value() {
let (status, state) = run_and_capture("readonly X=first; readonly X=second");
assert_eq!(status, 1, "redeclaring readonly with assignment must fail");
assert_eq!(state.env_get("X"), Some("first"));
assert_eq!(
state.variable_store.vars.get("X"),
Some(&Variable {
value: "first".to_string(),
attrib: VAR_READONLY,
})
);
}
#[test]
fn readonly_assignment_preserves_existing_export_flag() {
let (status, state) = run_and_capture("export X=first; readonly X=first");
assert_eq!(status, 0);
assert_eq!(
state.variable_store.vars.get("X"),
Some(&Variable {
value: "first".to_string(),
attrib: VAR_EXPORT | VAR_READONLY,
})
);
}
#[test]
fn builtin_unset_removes_variable() {
let (_, state) = run_and_capture("X=hello; unset X");
assert_eq!(state.env_get("X"), None);
}
#[test]
fn builtin_unset_function() {
let (status, _) = run_and_capture("f() { :; }; unset -f f; f");
assert_eq!(status, 127, "calling unset function should fail");
}
#[test]
fn builtin_unset_readonly_kept() {
let (status, _out, err, state) =
run_and_capture_output_and_error("X=keep; readonly X; unset X");
assert_eq!(status, 1, "unset of readonly variable should fail");
assert_eq!(
state.env_get("X"),
Some("keep"),
"unset should not remove readonly"
);
assert!(err.contains("unset: X: readonly variable"));
}
#[test]
fn builtin_shift_removes_positional() {
let (_, state) = run_and_capture("set -- a b c; shift; R=$1");
assert_eq!(state.env_get("R"), Some("b"));
}
#[test]
fn builtin_shift_too_many() {
let (status, _) = run_and_capture("set -- a; shift 5");
assert_eq!(status, 2, "shifting more than available should fail");
}
#[test]
fn builtin_read_trims_leading_ifs_whitespace() {
let (status, state) = run_and_capture_with_stdin("read X Y", " a b\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a"));
assert_eq!(state.env_get("Y"), Some("b"));
}
#[test]
fn builtin_read_last_variable_absorbs_remaining_fields_without_padding() {
let (status, state) = run_and_capture_with_stdin("read X Y", "a b c\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a"));
assert_eq!(state.env_get("Y"), Some("b c"));
}
#[test]
fn builtin_read_preserves_empty_field_for_non_whitespace_ifs() {
let (status, state) = run_and_capture_with_stdin("IFS=,; read X Y Z", "a,,b\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a"));
assert_eq!(state.env_get("Y"), Some(""));
assert_eq!(state.env_get("Z"), Some("b"));
}
#[test]
fn builtin_read_joins_lines_ending_in_backslash() {
let (status, state) = run_and_capture_with_stdin("read X", "a\\\nb\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("ab"));
}
#[test]
fn builtin_read_eof_after_line_continuation_returns_nonzero() {
let (status, state) = run_and_capture_with_stdin("read X", "a\\");
assert_eq!(status, 1);
assert_eq!(state.env_get("X"), Some("a"));
}
#[test]
fn builtin_read_immediate_eof_assigns_empty_and_returns_nonzero() {
let (status, state) = run_and_capture_with_stdin("read X Y", "");
assert_eq!(status, 1);
assert_eq!(state.env_get("X"), Some(""));
assert_eq!(state.env_get("Y"), Some(""));
}
#[test]
fn builtin_read_newline_after_continuation_without_followup_returns_nonzero() {
let (status, state) = run_and_capture_with_stdin("read X", "\\\n");
assert_eq!(status, 1);
assert_eq!(state.env_get("X"), Some(""));
}
#[test]
fn builtin_read_unescapes_backslash_space_without_raw_mode() {
let (status, state) = run_and_capture_with_stdin("read X", "a\\ b\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a b"));
}
#[test]
fn builtin_read_trims_trailing_escaped_whitespace_from_last_field() {
let (status, state) = run_and_capture_with_stdin("read X", "a \\ \n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a"));
}
#[test]
fn builtin_read_unescapes_backslash_character_without_raw_mode() {
let (status, state) = run_and_capture_with_stdin("read X", "a\\c\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("ac"));
}
#[test]
fn builtin_read_keeps_escaped_whitespace_in_same_field() {
let (status, state) = run_and_capture_with_stdin("read X Y", "a\\ b c\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a b"));
assert_eq!(state.env_get("Y"), Some("c"));
}
#[test]
fn builtin_read_keeps_escaped_non_whitespace_ifs_in_same_field() {
let (status, state) = run_and_capture_with_stdin("IFS=,; read X Y", "a\\,b,c\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a,b"));
assert_eq!(state.env_get("Y"), Some("c"));
}
#[test]
fn builtin_read_trims_trailing_non_whitespace_ifs_from_last_field() {
let (status, state) = run_and_capture_with_stdin("IFS=,; read X", "a,");
assert_eq!(status, 1);
assert_eq!(state.env_get("X"), Some("a"));
}
#[test]
fn builtin_read_fails_for_readonly_variable() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdin = sys::StringStdioIn::new("updated\n");
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdin_fd = stdin.fd();
state.stderr_fd = stderr.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"readonly X=orig\nread X\nAFTER=1\n",
);
state.last_status = status;
let error = stderr.collect();
stdin.join();
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("orig"));
assert_eq!(state.env_get("AFTER"), Some("1"));
assert!(error.contains("read: X: readonly variable"));
}
#[test]
fn export_p_output_round_trips_without_executing_embedded_code() {
let stdout = sys::StringStdioOut::new();
let replay_stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
let mut runtime = ExecCaptureRuntime::default();
let mut replay_state = ShellState::new();
let mut replay_runtime = sys::UnixRuntime::new();
let value = "'; echo PWNED; #".to_string();
state.stdout_fd = stdout.fd();
state.env_set("X", value.clone(), VAR_EXPORT);
assert_eq!(
shell_builtins::run_builtin(
&mut state,
&mut runtime,
&["export".to_string(), "-p".to_string()]
),
Some(0)
);
let rendered = stdout.collect();
replay_state.stdout_fd = replay_stdout.fd();
let status = run_string(
&mut replay_state,
&mut replay_runtime,
&format!("{rendered}\n"),
);
replay_state.last_status = status;
assert_eq!(status, 0);
assert_eq!(
replay_state.variable_store.vars.get("X"),
Some(&Variable {
value,
attrib: VAR_EXPORT,
})
);
assert_eq!(replay_stdout.collect(), "");
}
#[test]
fn readonly_p_output_round_trips_without_executing_embedded_code() {
let stdout = sys::StringStdioOut::new();
let replay_stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
let mut runtime = ExecCaptureRuntime::default();
let mut replay_state = ShellState::new();
let mut replay_runtime = sys::UnixRuntime::new();
let value = "'; echo PWNED; #".to_string();
state.stdout_fd = stdout.fd();
state.env_set("X", value.clone(), VAR_READONLY);
assert_eq!(
shell_builtins::run_builtin(
&mut state,
&mut runtime,
&["readonly".to_string(), "-p".to_string()],
),
Some(0)
);
let rendered = stdout.collect();
replay_state.stdout_fd = replay_stdout.fd();
let status = run_string(
&mut replay_state,
&mut replay_runtime,
&format!("{rendered}\n"),
);
replay_state.last_status = status;
assert_eq!(status, 0);
assert_eq!(
replay_state.variable_store.vars.get("X"),
Some(&Variable {
value,
attrib: VAR_READONLY,
})
);
assert_eq!(replay_stdout.collect(), "");
}
#[test]
fn set_output_round_trips_without_executing_embedded_code() {
let stdout = sys::StringStdioOut::new();
let replay_stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
let mut runtime = ExecCaptureRuntime::default();
let mut replay_state = ShellState::new();
let mut replay_runtime = sys::UnixRuntime::new();
let value = "'; echo PWNED; #".to_string();
state.stdout_fd = stdout.fd();
state.env_set("X", value.clone(), 0);
assert_eq!(
shell_builtins::run_builtin(&mut state, &mut runtime, &["set".to_string()]),
Some(0)
);
let rendered = stdout.collect();
replay_state.stdout_fd = replay_stdout.fd();
let status = run_string(
&mut replay_state,
&mut replay_runtime,
&format!("{rendered}\n"),
);
replay_state.last_status = status;
assert_eq!(status, 0);
assert_eq!(
replay_state.variable_store.vars.get("X"),
Some(&Variable { value, attrib: 0 })
);
assert_eq!(replay_stdout.collect(), "");
}
#[test]
fn alias_output_round_trips_without_executing_embedded_code() {
let stdout = sys::StringStdioOut::new();
let replay_stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
let mut runtime = ExecCaptureRuntime::default();
let mut replay_state = ShellState::new();
let mut replay_runtime = sys::UnixRuntime::new();
let value = "echo '; echo PWNED; #".to_string();
state.stdout_fd = stdout.fd();
state.set_alias("a", value.clone());
assert_eq!(
shell_builtins::run_builtin(&mut state, &mut runtime, &["alias".to_string()]),
Some(0)
);
let rendered = stdout.collect();
replay_state.stdout_fd = replay_stdout.fd();
let status = run_string(
&mut replay_state,
&mut replay_runtime,
&format!("{rendered}\n"),
);
replay_state.last_status = status;
assert_eq!(status, 0);
assert_eq!(replay_state.alias("a"), Some(value.as_str()));
assert_eq!(replay_stdout.collect(), "");
}
#[test]
fn read_model_empty_ifs_preserves_entire_line() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: " a b".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: "",
raw_mode: true,
var_count: 2,
},
),
shell_read::ReadResult {
status: 0,
fields: vec![" a b".to_string(), String::new()],
}
);
}
#[test]
fn read_model_trims_ifs_whitespace_and_collapses_runs() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: " a b c ".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: " \t\n",
raw_mode: true,
var_count: 2,
},
),
shell_read::ReadResult {
status: 0,
fields: vec!["a".to_string(), "b c".to_string()],
}
);
}
#[test]
fn read_model_non_whitespace_ifs_can_yield_empty_field() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: "a,,b".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: ",",
raw_mode: true,
var_count: 3,
},
),
shell_read::ReadResult {
status: 0,
fields: vec!["a".to_string(), String::new(), "b".to_string()],
}
);
}
#[test]
fn read_model_nonraw_keeps_escaped_non_whitespace_ifs_in_same_field() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: "a\\,b,c".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: ",",
raw_mode: false,
var_count: 2,
},
),
shell_read::ReadResult {
status: 0,
fields: vec!["a,b".to_string(), "c".to_string()],
}
);
}
#[test]
fn read_model_nonraw_trims_escaped_whitespace_from_last_field() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: "a \\ ".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: " \t\n",
raw_mode: false,
var_count: 1,
},
),
shell_read::ReadResult {
status: 0,
fields: vec!["a".to_string()],
}
);
}
#[test]
fn builtin_getopts_consumes_clustered_short_options() {
let (status, state) = run_and_capture(
"set -- -ab\n\
getopts ab OPT\n\
FIRST=\"$OPT:$OPTIND\"\n\
getopts ab OPT\n\
SECOND=\"$OPT:$OPTIND\"",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("FIRST"), Some("a:1"));
assert_eq!(state.env_get("SECOND"), Some("b:2"));
}
#[test]
fn builtin_getopts_accepts_inline_and_separate_option_arguments() {
let (status, state) = run_and_capture(
"set -- -fvalue -f next\n\
getopts f: OPT\n\
FIRST=\"$OPT:$OPTARG:$OPTIND\"\n\
getopts f: OPT\n\
SECOND=\"$OPT:$OPTARG:$OPTIND\"",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("FIRST"), Some("f:value:2"));
assert_eq!(state.env_get("SECOND"), Some("f:next:4"));
}
#[test]
fn builtin_getopts_resets_cluster_cursor_when_optind_is_reset() {
let (status, state) = run_and_capture(
"set -- -ab\n\
getopts ab OPT\n\
OPTIND=1\n\
getopts ab OPT\n\
RESULT=\"$OPT:$OPTIND\"",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("a:1"));
}
#[test]
fn builtin_getopts_treats_zero_optind_as_one() {
let (status, state) =
run_and_capture("set -- -ab\nOPTIND=0\ngetopts ab OPT\nRESULT=\"$OPT:$OPTIND:$?\"");
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("a:1:0"));
}
#[test]
fn builtin_getopts_normalizes_optind_when_past_end() {
let (status, state) =
run_and_capture("set -- -a\nOPTIND=999\ngetopts ab OPT\nRESULT=\"$OPT:$OPTIND:$?\"");
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("?:2:1"));
}
#[test]
fn builtin_getopts_silent_mode_captures_unknown_option_without_stderr() {
let (status, _, error, state) =
run_and_capture_output_and_error("set -- -z\ngetopts :ab OPT\nRESULT=\"$OPT:$OPTARG:$?\"");
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("?:z:0"));
assert!(
error.is_empty(),
"expected silent getopts error, got {error:?}"
);
}
#[test]
fn builtin_getopts_silent_mode_captures_missing_argument_without_stderr() {
let (status, _, error, state) =
run_and_capture_output_and_error("set -- -f\ngetopts :f: OPT\nRESULT=\"$OPT:$OPTARG:$?\"");
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("::f:0"));
assert!(
error.is_empty(),
"expected silent getopts error, got {error:?}"
);
}
#[test]
fn builtin_getopts_missing_argument_matches_standard_status() {
let (status, _, error, state) = run_and_capture_output_and_error(
"set -- -f\ngetopts f: OPT\nRESULT=\"$OPT:${OPTARG-unset}:$?\"",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("?:unset:0"));
assert!(error.contains("getopts: option requires argument -- f"));
}
#[test]
fn builtin_eval_executes_string() {
let (_, state) = run_and_capture("X=hello; eval 'Y=$X'");
assert_eq!(state.env_get("Y"), Some("hello"));
}
#[test]
fn builtin_dot_searches_path_for_readable_non_executable_file() {
let dir = temp_path("dot");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir must be creatable");
let path = dir.join("dotfile");
std::fs::write(&path, "DOT_RESULT=loaded\n").expect("dot script must be writable");
let mut perms = std::fs::metadata(&path)
.expect("dot script metadata")
.permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&path, perms).expect("dot script permissions must be writable");
let script = format!("PATH={}; . dotfile", dir.display());
let (_, state) = run_and_capture(&script);
assert_eq!(state.env_get("DOT_RESULT"), Some("loaded"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn builtin_dot_searches_relative_path_entries_from_shell_cwd() {
let dir = temp_path("dot-relative-path");
let lib = dir.join("lib");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&lib).expect("temp lib dir must be creatable");
std::fs::write(lib.join("tool"), "DOT_RESULT=path-entry\n")
.expect("path-entry dot script must be writable");
std::fs::write(dir.join("tool"), "DOT_RESULT=fallback\n")
.expect("fallback dot script must be writable");
let (status, state) = run_and_capture_with_setup("PATH=lib . tool", |state| {
state.path_state.cwd = dir.clone();
state.env_set_internal("PWD", dir.display().to_string(), VAR_EXPORT);
});
assert_eq!(status, 0);
assert_eq!(state.env_get("DOT_RESULT"), Some("path-entry"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn builtin_set_positional_params() {
let (_, state) = run_and_capture("set -- x y z; R=$2");
assert_eq!(state.env_get("R"), Some("y"));
}
#[test]
fn builtin_set_option_errexit() {
let (_, state) = run_and_capture("set -e; false; AFTER=1");
assert_eq!(
state.env_get("AFTER"),
None,
"set -e should stop on failure"
);
}
#[test]
fn pwd_assignment_is_not_blocked_by_shell_startup() {
let (_, state) = run_and_capture("PWD=/tmp");
assert_eq!(state.env_get("PWD"), Some("/tmp"));
}
#[test]
fn cd_updates_pwd_and_oldpwd_without_making_pwd_readonly() {
let original = std::env::current_dir().expect("current dir");
let tmp = std::env::temp_dir();
let script = format!("cd {}\nPWD=/override\n", tmp.display());
let (_, state) = run_and_capture(&script);
assert_eq!(
state.env_get("OLDPWD"),
Some(original.to_string_lossy().as_ref())
);
assert_eq!(state.env_get("PWD"), Some("/override"));
}
#[test]
fn exec_uses_shell_cwd() {
let tmp = std::env::temp_dir();
let mut state = ShellState::new();
state.populate_env();
let mut runtime = ExecCaptureRuntime::default();
let script = format!("cd {}\nexec fakecmd\n", tmp.display());
let status = run_string(&mut state, &mut runtime, &script);
assert_eq!(status, 126);
assert_eq!(
runtime.cwd.lock().unwrap().clone(),
Some(tmp),
"exec should receive the shell cwd after cd"
);
}
#[test]
fn failed_exec_restores_process_cwd() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let original = std::env::current_dir().expect("current dir");
struct CurrentDirGuard(PathBuf);
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
let _restore = CurrentDirGuard(original.clone());
let tmp = std::env::temp_dir();
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::UnixRuntime::new();
let script = format!(
"cd {}\nexec definitely_missing_mxsh_command_xyz\n",
tmp.display()
);
let status = run_string(&mut state, &mut runtime, &script);
assert_eq!(status, 127);
assert_eq!(
std::env::current_dir().expect("current dir after failed exec"),
original
);
}
#[test]
fn failed_exec_aborts_non_interactive_script() {
let (status, output, errors, state) =
run_and_capture_output_and_error("exec definitely-not-mxsh-command; echo after\n");
assert_eq!(status, 127);
assert_eq!(state.exit_code, 127);
assert_eq!(output, "");
assert!(
errors.contains("definitely-not-mxsh-command"),
"stderr should report the failed exec lookup:\n{errors}"
);
}
#[test]
fn special_parameter_hash() {
let mut state = ShellState::new();
state.variable_store.frame = vec!["mxsh".into(), "a".into(), "b".into()];
assert_eq!(
shell_expand::get_parameter_value(&state, "#"),
Some("2".to_string())
);
}
#[test]
fn special_parameter_question() {
let mut state = ShellState::new();
state.last_status = 42;
assert_eq!(
shell_expand::get_parameter_value(&state, "?"),
Some("42".to_string())
);
}
#[test]
fn special_parameter_zero() {
let mut state = ShellState::new();
state.variable_store.frame = vec!["myshell".into()];
assert_eq!(
shell_expand::get_parameter_value(&state, "0"),
Some("myshell".to_string())
);
}
#[test]
fn special_parameter_at_star() {
let mut state = ShellState::new();
state.variable_store.frame = vec!["sh".into(), "a".into(), "b".into(), "c".into()];
state.env_set("IFS", " \t\n".to_string(), 0);
assert_eq!(
shell_expand::get_parameter_value(&state, "@"),
Some("a b c".to_string())
);
assert_eq!(
shell_expand::get_parameter_value(&state, "*"),
Some("a b c".to_string())
);
assert_eq!(shell_expand::quoted_star_value(&state), "a b c");
}
#[test]
fn special_parameter_at_star_custom_ifs() {
let mut state = ShellState::new();
state.variable_store.frame = vec!["sh".into(), "a".into(), "b".into(), "c".into()];
state.env_set("IFS", ":".to_string(), 0);
assert_eq!(
shell_expand::get_parameter_value(&state, "@"),
Some("a:b:c".to_string())
);
assert_eq!(
shell_expand::get_parameter_value(&state, "*"),
Some("a:b:c".to_string())
);
assert_eq!(shell_expand::quoted_star_value(&state), "a:b:c");
}
#[test]
fn special_parameter_at_star_empty_ifs() {
let mut state = ShellState::new();
state.variable_store.frame = vec!["sh".into(), "a".into(), "b".into(), "c".into()];
state.env_set("IFS", "".to_string(), 0);
assert_eq!(
shell_expand::get_parameter_value(&state, "@"),
Some("abc".to_string())
);
assert_eq!(
shell_expand::get_parameter_value(&state, "*"),
Some("abc".to_string())
);
assert_eq!(shell_expand::quoted_star_value(&state), "abc");
}
#[test]
fn quoted_dollar_at_empty_expands_to_no_fields() {
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
state.variable_store.frame = vec!["sh".into()];
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_list! {
children: vec![word_parameter! {
name: "@".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
}],
double_quoted: true,
range: Range::default(),
}],
);
assert_eq!(argv, vec!["printf".to_string()]);
}
#[test]
fn mixed_quoted_and_unquoted_segments_split_fields() {
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
state.variable_store.frame = vec!["sh".into(), "a b".into()];
state.env_set("IFS", " \t\n".to_string(), 0);
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_list! {
children: vec![
word_list! {
children: vec![word_string! {
value: "p".to_string(),
single_quoted: false,
split_fields: true,
source: Some("p".to_string()),
range: Range::default(),
}],
double_quoted: true,
range: Range::default(),
},
word_parameter! {
name: "1".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
},
],
double_quoted: false,
range: Range::default(),
}],
);
assert_eq!(
argv,
vec!["printf".to_string(), "pa".to_string(), "b".to_string()]
);
}
#[test]
fn mixed_quoted_prefix_and_unquoted_dollar_at_preserve_fields() {
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
state.variable_store.frame = vec!["sh".into(), "a".into(), "b".into()];
state.env_set("IFS", " \t\n".to_string(), 0);
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_list! {
children: vec![
word_list! {
children: vec![word_string! {
value: "p".to_string(),
single_quoted: false,
split_fields: true,
source: Some("p".to_string()),
range: Range::default(),
}],
double_quoted: true,
range: Range::default(),
},
word_parameter! {
name: "@".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
},
],
double_quoted: false,
range: Range::default(),
}],
);
assert_eq!(
argv,
vec!["printf".to_string(), "pa".to_string(), "b".to_string()]
);
}
#[test]
fn escaped_tilde_does_not_expand() {
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
state.env_set("HOME", "/tmp/home".to_string(), 0);
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_string! {
value: "~".to_string(),
single_quoted: false,
split_fields: true,
source: Some("\\~".to_string()),
range: Range::default(),
}],
);
assert_eq!(argv, vec!["printf".to_string(), "~".to_string()]);
}
#[test]
fn escaped_glob_does_not_expand() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let dir = temp_path("escaped-glob");
std::fs::create_dir_all(&dir).expect("create temp dir");
std::fs::write(dir.join("alpha"), b"alpha").expect("write alpha");
std::fs::write(dir.join("beta"), b"beta").expect("write beta");
let original = std::env::current_dir().expect("current dir");
struct CurrentDirGuard(PathBuf);
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
let restore = CurrentDirGuard(original);
std::env::set_current_dir(&dir).expect("set current dir");
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_string! {
value: "*".to_string(),
single_quoted: false,
split_fields: true,
source: Some("\\*".to_string()),
range: Range::default(),
}],
);
assert_eq!(argv, vec!["printf".to_string(), "*".to_string()]);
drop(restore);
std::fs::remove_dir_all(&dir).expect("remove temp dir");
}
#[test]
fn special_parameter_dash() {
let mut state = ShellState::new();
state.options = OPT_ERREXIT | OPT_XTRACE;
let val = shell_expand::get_parameter_value(&state, "-").unwrap();
assert!(val.contains('e'), "$- must contain 'e' for errexit");
assert!(val.contains('x'), "$- must contain 'x' for xtrace");
}
#[test]
fn positional_parameter_numeric() {
let mut state = ShellState::new();
state.variable_store.frame = vec!["sh".into(), "first".into(), "second".into()];
assert_eq!(
shell_expand::get_parameter_value(&state, "1"),
Some("first".to_string())
);
assert_eq!(
shell_expand::get_parameter_value(&state, "2"),
Some("second".to_string())
);
assert_eq!(shell_expand::get_parameter_value(&state, "3"), None);
}
#[test]
fn format_options_empty() {
assert_eq!(format_options(0), "");
}
#[test]
fn format_options_multiple() {
let s = format_options(OPT_ALLEXPORT | OPT_ERREXIT);
assert!(s.contains('a'));
assert!(s.contains('e'));
}
#[test]
fn split_fields_default_ifs() {
let mut state = ShellState::new();
state.env_set("IFS", " \t\n".to_string(), 0);
let fields = shell_expand::split_fields(&state, "hello world\tfoo");
assert_eq!(fields, vec!["hello", "world", "foo"]);
}
#[test]
fn split_fields_custom_ifs() {
let mut state = ShellState::new();
state.env_set("IFS", ":".to_string(), 0);
let fields = shell_expand::split_fields(&state, "a:b:c");
assert_eq!(fields, vec!["a", "b", "c"]);
}
#[test]
fn split_fields_empty_ifs() {
let mut state = ShellState::new();
state.env_set("IFS", "".to_string(), 0);
let fields = shell_expand::split_fields(&state, "hello world");
assert_eq!(fields, vec!["hello world"]);
}
#[test]
fn eval_arithm_binop_basic() {
assert_eq!(eval_arithm_binop(ArithmBinOp::Add, 3, 4), Ok(7));
assert_eq!(eval_arithm_binop(ArithmBinOp::Sub, 10, 3), Ok(7));
assert_eq!(eval_arithm_binop(ArithmBinOp::Mul, 3, 4), Ok(12));
assert_eq!(eval_arithm_binop(ArithmBinOp::Div, 10, 3), Ok(3));
assert_eq!(eval_arithm_binop(ArithmBinOp::Mod, 10, 3), Ok(1));
}
#[test]
fn eval_arithm_binop_division_by_zero() {
assert_eq!(
eval_arithm_binop(ArithmBinOp::Div, 10, 0),
Err("division by zero")
);
assert_eq!(
eval_arithm_binop(ArithmBinOp::Mod, 10, 0),
Err("division by zero")
);
}
#[test]
fn eval_arithm_binop_bitwise() {
assert_eq!(eval_arithm_binop(ArithmBinOp::BitAnd, 0xFF, 0x0F), Ok(0x0F));
assert_eq!(eval_arithm_binop(ArithmBinOp::BitOr, 0xF0, 0x0F), Ok(0xFF));
assert_eq!(eval_arithm_binop(ArithmBinOp::BitXor, 0xFF, 0x0F), Ok(0xF0));
assert_eq!(eval_arithm_binop(ArithmBinOp::Shl, 1, 4), Ok(16));
assert_eq!(eval_arithm_binop(ArithmBinOp::Shr, 16, 2), Ok(4));
assert_eq!(
eval_arithm_binop(ArithmBinOp::Shl, 1, -1),
Err("invalid shift count")
);
}
#[test]
fn eval_arithm_binop_comparison() {
assert_eq!(eval_arithm_binop(ArithmBinOp::LessThan, 3, 5), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::LessThan, 5, 3), Ok(0));
assert_eq!(eval_arithm_binop(ArithmBinOp::LessEq, 3, 3), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::GreaterThan, 5, 3), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::GreaterEq, 3, 3), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::Equal, 5, 5), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::NotEqual, 5, 3), Ok(1));
}
#[test]
fn eval_arithm_binop_logical() {
assert_eq!(eval_arithm_binop(ArithmBinOp::LogAnd, 1, 1), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::LogAnd, 1, 0), Ok(0));
assert_eq!(eval_arithm_binop(ArithmBinOp::LogOr, 0, 1), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::LogOr, 0, 0), Ok(0));
}
#[test]
fn eval_assign_op_basic() {
assert_eq!(eval_assign_op(ArithmAssignOp::Equal, 5, 10), Ok(10));
assert_eq!(eval_assign_op(ArithmAssignOp::AddEq, 5, 3), Ok(8));
assert_eq!(eval_assign_op(ArithmAssignOp::SubEq, 5, 3), Ok(2));
assert_eq!(eval_assign_op(ArithmAssignOp::MulEq, 5, 3), Ok(15));
assert_eq!(eval_assign_op(ArithmAssignOp::DivEq, 10, 3), Ok(3));
assert_eq!(eval_assign_op(ArithmAssignOp::ModEq, 10, 3), Ok(1));
}
#[test]
fn eval_assign_op_division_by_zero() {
assert_eq!(
eval_assign_op(ArithmAssignOp::DivEq, 10, 0),
Err("division by zero")
);
assert_eq!(
eval_assign_op(ArithmAssignOp::ModEq, 10, 0),
Err("division by zero")
);
}
#[test]
fn eval_assign_op_bitwise() {
assert_eq!(eval_assign_op(ArithmAssignOp::AndEq, 0xFF, 0x0F), Ok(0x0F));
assert_eq!(eval_assign_op(ArithmAssignOp::OrEq, 0xF0, 0x0F), Ok(0xFF));
assert_eq!(eval_assign_op(ArithmAssignOp::XorEq, 0xFF, 0x0F), Ok(0xF0));
assert_eq!(eval_assign_op(ArithmAssignOp::ShlEq, 1, 4), Ok(16));
assert_eq!(eval_assign_op(ArithmAssignOp::ShrEq, 16, 2), Ok(4));
assert_eq!(
eval_assign_op(ArithmAssignOp::ShlEq, 1, -1),
Err("invalid shift count")
);
}
#[test]
fn match_bracket_class_range() {
let (matched, consumed) = shell_expand::match_bracket_class(b"[a-z]rest", b'm').unwrap();
assert!(matched);
assert_eq!(consumed, 5);
}
#[test]
fn match_bracket_class_no_match() {
let (matched, _) = shell_expand::match_bracket_class(b"[a-z]", b'5').unwrap();
assert!(!matched);
}
#[test]
fn match_bracket_class_negated() {
let (matched, _) = shell_expand::match_bracket_class(b"[!a-z]", b'5').unwrap();
assert!(matched);
}
#[test]
fn match_bracket_class_unclosed_returns_none() {
assert!(shell_expand::match_bracket_class(b"[a-z", b'm').is_none());
}
#[test]
fn parse_trap_signal_numeric() {
assert_eq!(shell_traps::parse_trap_signal("0"), Some(TRAP_EXIT));
assert_eq!(shell_traps::parse_trap_signal("2"), Some(2));
}
#[test]
fn parse_trap_signal_named() {
assert_eq!(shell_traps::parse_trap_signal("EXIT"), Some(TRAP_EXIT));
assert_eq!(shell_traps::parse_trap_signal("INT"), Some(libc::SIGINT));
assert_eq!(shell_traps::parse_trap_signal("HUP"), Some(libc::SIGHUP));
assert_eq!(shell_traps::parse_trap_signal("TERM"), Some(libc::SIGTERM));
}
#[test]
fn parse_trap_signal_with_sig_prefix() {
assert_eq!(shell_traps::parse_trap_signal("SIGINT"), Some(libc::SIGINT));
assert_eq!(
shell_traps::parse_trap_signal("SIGTERM"),
Some(libc::SIGTERM)
);
}
#[test]
fn parse_trap_signal_unknown() {
assert_eq!(shell_traps::parse_trap_signal("BOGUS"), None);
}
#[test]
fn trap_signal_name_known() {
assert_eq!(shell_traps::trap_signal_name(TRAP_EXIT), "EXIT");
assert_eq!(shell_traps::trap_signal_name(libc::SIGINT), "INT");
assert_eq!(shell_traps::trap_signal_name(libc::SIGTERM), "TERM");
}
#[test]
fn trap_signal_name_unknown() {
assert_eq!(shell_traps::trap_signal_name(999), "?");
}
#[test]
fn parse_builtin_known() {
let state = ShellState::new();
assert!(matches!(
shell_builtins::lookup_builtin(&state, ":"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::Colon,
properties,
}) if properties == BuiltinProperties::special()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "true"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::True,
properties,
}) if properties == BuiltinProperties::regular()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "false"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::False,
properties,
}) if properties == BuiltinProperties::regular()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "cd"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::Cd,
properties,
}) if properties == BuiltinProperties::regular()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "export"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::Export,
properties,
}) if properties == BuiltinProperties::special()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "break"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::Break,
properties,
}) if properties == BuiltinProperties::special()
));
}
#[test]
fn parse_builtin_unknown() {
let state = ShellState::new();
assert!(shell_builtins::lookup_builtin(&state, "notabuiltin").is_none());
}
#[test]
fn is_builtin_known() {
let state = ShellState::new();
assert!(shell_builtins::is_builtin_in(&state, "cd"));
assert!(shell_builtins::is_builtin_in(&state, "export"));
assert!(shell_builtins::is_builtin_in(&state, "true"));
}
#[test]
fn is_builtin_unknown() {
let state = ShellState::new();
assert!(!shell_builtins::is_builtin_in(&state, "ls"));
assert!(!shell_builtins::is_builtin_in(&state, "cat"));
}
#[test]
fn is_special_builtin_true() {
let state = ShellState::new();
assert!(shell_builtins::is_special_builtin_in(&state, ":"));
assert!(shell_builtins::is_special_builtin_in(&state, "."));
assert!(shell_builtins::is_special_builtin_in(&state, "export"));
assert!(shell_builtins::is_special_builtin_in(&state, "set"));
assert!(shell_builtins::is_special_builtin_in(&state, "trap"));
}
#[test]
fn is_special_builtin_false() {
let state = ShellState::new();
assert!(!shell_builtins::is_special_builtin_in(&state, "cd"));
assert!(!shell_builtins::is_special_builtin_in(&state, "alias"));
assert!(!shell_builtins::is_special_builtin_in(&state, "command"));
}
#[test]
fn env_set_and_get() {
let mut state = ShellState::new();
state.env_set("FOO", "bar".to_string(), 0);
assert_eq!(state.env_get("FOO"), Some("bar"));
}
#[test]
fn env_set_readonly_ignored() {
let mut state = ShellState::new();
state.env_set("FOO", "first".to_string(), VAR_READONLY);
state.env_set("FOO", "second".to_string(), 0);
assert_eq!(state.env_get("FOO"), Some("first"));
}
#[test]
fn env_unset_respects_readonly() {
let mut state = ShellState::new();
state.env_set("FOO", "keep".to_string(), VAR_READONLY);
assert!(
!state.env_unset("FOO"),
"readonly variable should report unset failure"
);
assert_eq!(state.env_get("FOO"), Some("keep"));
}
#[test]
fn exported_env_filters_correctly() {
let mut state = ShellState::new();
state.env_set("EXPORTED", "yes".to_string(), VAR_EXPORT);
state.env_set("LOCAL", "no".to_string(), 0);
let exported = state.exported_env();
assert!(exported.iter().any(|(k, _)| k == "EXPORTED"));
assert!(!exported.iter().any(|(k, _)| k == "LOCAL"));
}
#[test]
fn has_option_works() {
let mut state = ShellState::new();
assert!(!state.has_option(OPT_ERREXIT));
state.options |= OPT_ERREXIT;
assert!(state.has_option(OPT_ERREXIT));
}
#[test]
fn special_parameter_names() {
assert!(shell_expand::is_special_parameter_name("@"));
assert!(shell_expand::is_special_parameter_name("*"));
assert!(shell_expand::is_special_parameter_name("#"));
assert!(shell_expand::is_special_parameter_name("?"));
assert!(shell_expand::is_special_parameter_name("-"));
assert!(shell_expand::is_special_parameter_name("$"));
assert!(shell_expand::is_special_parameter_name("!"));
assert!(shell_expand::is_special_parameter_name("0"));
assert!(shell_expand::is_special_parameter_name("1"));
assert!(shell_expand::is_special_parameter_name("99"));
}
#[test]
fn non_special_parameter_names() {
assert!(!shell_expand::is_special_parameter_name("FOO"));
assert!(!shell_expand::is_special_parameter_name("HOME"));
}
#[test]
fn expand_tilde_no_tilde() {
let state = ShellState::new();
assert_eq!(
shell_expand::expand_tilde(&state, "/usr/local"),
"/usr/local"
);
}
#[test]
fn errexit_stops_on_failure() {
let (_, state) = run_and_capture("set -e; false; AFTER=1");
assert_eq!(
state.env_get("AFTER"),
None,
"errexit should stop execution"
);
}
#[test]
fn errexit_stops_on_normalized_negative_return_status() {
let (status, state) = run_and_capture("set -e; f() { return -1; }; f; AFTER=1");
assert_eq!(status, 255);
assert_eq!(state.env_get("AFTER"), None);
assert_eq!(state.exit_code, 255);
}
#[test]
fn set_parse_error_does_not_commit_partial_options() {
let (status, state) = run_and_capture("set -uZ");
assert_eq!(status, 1);
assert!(!state.has_option(OPT_NOUNSET));
}
#[test]
fn return_invalid_numeric_argument_reports_error() {
let (status, output, state) = run_and_capture_output("f() { return abc; }; f; echo status:$?");
assert_eq!(status, 2);
assert_eq!(output, "");
assert_eq!(state.exit_code, 2);
}
#[test]
fn exit_invalid_numeric_argument_exits_with_error() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let code = run(&["mxsh".to_string(), "-c".to_string(), "exit abc".to_string()]);
assert_eq!(code, 2);
}
#[test]
fn monitor_mode_disable_restores_child_signal_defaults() {
let script = "set -m; set +m; python3 -c 'import signal; print(signal.getsignal(signal.SIGTTOU) == signal.SIG_IGN)'";
let (status, output, _state) = run_and_capture_output(script);
assert_eq!(status, 0);
assert_eq!(output.trim(), "False");
}
#[test]
fn subshell_does_not_leak_to_parent() {
let (_, state) = run_and_capture("X=outer; (X=inner); R=$X");
assert_eq!(state.env_get("R"), Some("outer"));
}
#[test]
fn command_substitution_does_not_leak_to_parent() {
let (_, state) = run_and_capture("X=outer; Y=$(X=inner; echo ok); R=$X");
assert_eq!(state.env_get("X"), Some("outer"));
assert_eq!(state.env_get("R"), Some("outer"));
}
#[test]
fn command_substitution_does_not_leak_into_pipeline_output() {
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, ": \"$(cd /; pwd)\" | cat");
assert_eq!(status, 0);
assert_eq!(output.collect().trim(), "");
}
#[test]
fn allexport_marks_new_assignments() {
let (_, state) = run_and_capture("set -a; NEWVAR=exported_val");
let exported = state.exported_env();
assert!(
exported
.iter()
.any(|(k, v)| k == "NEWVAR" && v == "exported_val"),
"set -a should auto-export new assignments"
);
}
#[test]
fn pipeline_bang_negation() {
let (status, _) = run_and_capture("! false");
assert_eq!(status, 0, "! false should return 0");
let (status, _) = run_and_capture("! true");
assert_eq!(status, 1, "! true should return 1");
}
#[test]
fn negated_pipeline_suppresses_errexit_while_command_runs() {
let cases = [
(
"set -e; ! { false; echo in-group; }; echo after",
"in-group\nafter\n",
),
(
"set -e; f(){ false; echo in-function; }; ! f; echo after",
"in-function\nafter\n",
),
(
"set -e; ! ( false; echo in-subshell ); echo after",
"in-subshell\nafter\n",
),
(
"set -e; ! { false; echo in-pipeline; } | cat; echo after",
"in-pipeline\nafter\n",
),
];
for (script, expected_output) in cases {
let (status, output, state) = run_and_capture_output(script);
assert_eq!(status, 0, "{script}");
assert_eq!(state.exit_code, -1, "{script}");
assert_eq!(output, expected_output, "{script}");
}
}
#[test]
fn function_receives_arguments() {
let (_, state) = run_and_capture("f() { R=$1; S=$2; }; f hello world");
assert_eq!(state.env_get("R"), Some("hello"));
assert_eq!(state.env_get("S"), Some("world"));
}
#[test]
fn function_restores_positional_params() {
let (_, state) = run_and_capture("set -- outer; f() { :; }; f inner; R=$1");
assert_eq!(
state.env_get("R"),
Some("outer"),
"positional params must be restored after function call"
);
}
#[test]
fn exit_with_code() {
let (_, state) = run_and_capture("exit 42");
assert_eq!(state.exit_code, 42);
}
#[test]
fn exit_negative_status_is_normalized_and_terminates_script() {
let (code, output, _state) = run_cli_script_and_capture_output("exit -1; echo after");
assert_eq!(code, 255);
assert_eq!(output, "");
}
#[test]
fn exit_large_status_is_normalized() {
let (code, output, _state) = run_cli_script_and_capture_output("exit 513; echo after");
assert_eq!(code, 1);
assert_eq!(output, "");
}
#[test]
fn exit_without_code_uses_last_status() {
let (_, state) = run_and_capture("false; exit");
assert_eq!(state.exit_code, 1);
}
#[test]
fn shell_state_status_setters_normalize_status_values() {
let mut state = ShellState::new();
state.set_last_status(-1);
assert_eq!(state.last_status, 255);
state.set_exit_code(-1);
assert_eq!(state.exit_code, 255);
state.exit_code = -1;
state.set_exit_code_if_unset(513);
assert_eq!(state.exit_code, 1);
}
#[test]
fn prefix_assignment_is_temporary_for_regular_builtin() {
let (_, state) = run_and_capture("X=keep; X=temp command true; R=$X");
assert_eq!(
state.env_get("R"),
Some("keep"),
"prefix assignment on non-special builtin must be temporary"
);
}
#[test]
fn colon_builtin_succeeds() {
let (status, _) = run_and_capture(":");
assert_eq!(status, 0);
}
#[test]
fn true_builtin_succeeds() {
let (status, _) = run_and_capture("true");
assert_eq!(status, 0);
}
#[test]
fn false_builtin_fails() {
let (status, _) = run_and_capture("false");
assert_eq!(status, 1);
}
#[test]
fn non_executable_command_returns_126() {
let path = std::env::temp_dir().join(format!("mxsh-noexec-{}", std::process::id()));
std::fs::write(&path, "echo hi\n").expect("temp script must be writable");
let script = format!("{}\n", path.display());
let (status, _) = run_and_capture(&script);
let _ = std::fs::remove_file(&path);
assert_eq!(status, 126);
}
#[test]
fn directory_command_returns_126() {
let (status, _) = run_and_capture("/bin\n");
assert_eq!(status, 126);
}
#[test]
fn missing_command_reports_127_and_command_not_found() {
let (status, _out, err, state) = run_and_capture_output_and_error("definitelymissingcommand\n");
assert_eq!(status, 127);
assert_eq!(state.last_status, 127);
assert!(err.contains("definitelymissingcommand: command not found"));
}
#[test]
fn command_p_uses_default_search_path_for_execution() {
let mut state = ShellState::new();
let mut runtime = SpawnCaptureRuntime::default();
let cwd = state.path_state.cwd.clone();
state.env_set("PATH", "/definitely_missing".to_string(), VAR_EXPORT);
let status = run_string(&mut state, &mut runtime, "command -p sh -c true\n");
state.last_status = status;
assert_eq!(status, 0);
assert_eq!(
runtime.resolved_commands(),
vec![("sh".to_string(), "/usr/bin:/bin".to_string(), cwd.clone())]
);
assert_eq!(
runtime.spawned_commands(),
vec![sys::ExternalCommand {
program: "/resolved/sh".to_string(),
argv: vec!["sh".to_string(), "-c".to_string(), "true".to_string()],
env: vec![("PATH".to_string(), "/definitely_missing".to_string())],
cwd,
create_process_group: false,
join_process_group: None,
passed_fds: Vec::new(),
signal_plan: sys::ChildSignalPlan {
default_signals: vec![libc::SIGTTOU, libc::SIGTTIN, libc::SIGTSTP, libc::SIGPIPE],
ignored_signals: Vec::new(),
},
}]
);
}
#[test]
fn multi_tenant_external_child_env_sanitizes_temporary_path_assignment() {
let mut state = ShellState::new();
let cwd = state.path_state.cwd.clone();
let mut runtime = SpawnCaptureRuntime::default();
Arc::make_mut(&mut state.definition).security_policy =
ShellSecurityPolicy::permissive().with_path_search(false);
let status = run_string(
&mut state,
&mut runtime,
"PATH=.:bin:/usr/bin:/bin /bin/sh -c tool\n",
);
assert_eq!(status, 0);
assert_eq!(
runtime.resolved_commands(),
vec![(
"/bin/sh".to_string(),
"/usr/bin:/bin".to_string(),
cwd.clone()
)]
);
let spawned = runtime.spawned_commands();
assert_eq!(spawned.len(), 1);
assert_eq!(spawned[0].program, "/resolved//bin/sh");
assert_eq!(
spawned[0].argv,
vec!["/bin/sh".to_string(), "-c".to_string(), "tool".to_string()]
);
assert_eq!(env_value(&spawned[0].env, "PATH"), Some("/usr/bin:/bin"));
assert_eq!(spawned[0].cwd, cwd);
}
#[test]
fn multi_tenant_prepared_pipeline_stage_env_sanitizes_exported_path() {
let mut state = ShellState::new();
let cwd = state.path_state.cwd.clone();
let mut runtime = SpawnCaptureRuntime::default();
Arc::make_mut(&mut state.definition).security_policy =
ShellSecurityPolicy::permissive().with_path_search(false);
state.env_set("PATH", ".:bin:/usr/bin:/bin".to_string(), VAR_EXPORT);
let status = run_string(&mut state, &mut runtime, "left | right\n");
assert_eq!(status, 0);
assert_eq!(
runtime.resolved_commands(),
vec![
("left".to_string(), "/usr/bin:/bin".to_string(), cwd.clone()),
("right".to_string(), "/usr/bin:/bin".to_string(), cwd)
]
);
let spawned = runtime.spawned_commands();
assert_eq!(spawned.len(), 2);
assert_eq!(spawned[0].program, "/resolved/left");
assert_eq!(spawned[1].program, "/resolved/right");
for command in &spawned {
assert_eq!(env_value(&command.env, "PATH"), Some("/usr/bin:/bin"));
}
}
#[test]
fn background_machine_launcher_env_sanitizes_exported_path() {
let mut state = ShellState::new();
let mut runtime = SpawnCaptureRuntime::default();
Arc::make_mut(&mut state.definition).security_policy =
ShellSecurityPolicy::permissive().with_path_search(false);
state.env_set("PATH", ".:bin:/usr/bin:/bin".to_string(), VAR_EXPORT);
let status = run_string(&mut state, &mut runtime, ": &\n");
assert_eq!(status, 0);
let spawned = runtime.spawned_commands();
assert_eq!(spawned.len(), 1);
assert_eq!(env_value(&spawned[0].env, "PATH"), Some("/usr/bin:/bin"));
}
#[test]
fn unsupported_printf_format_wrappers_do_not_resolve_or_spawn_path_command() {
let mut state = ShellState::new();
let mut runtime = SpawnCaptureRuntime::default();
let stderr = sys::StringStdioOut::new();
Arc::make_mut(&mut state.definition).security_policy =
ShellSecurityPolicy::permissive().with_path_search(false);
state.stderr_fd = stderr.fd();
state.env_set("PATH", ".:/tmp/bin:/bin".to_string(), VAR_EXPORT);
let status = run_string(
&mut state,
&mut runtime,
"printf '%q' hi\ncommand -p printf '%q' hi\nbuiltin printf '%q' hi\n",
);
state.last_status = status;
let err = stderr.collect();
assert_eq!(status, 1);
assert_eq!(state.last_status, 1);
assert_eq!(err, "printf: unsupported format: %q\n".repeat(3));
assert!(runtime.resolved_commands().is_empty());
assert!(runtime.spawned_commands().is_empty());
}
#[test]
fn non_executable_command_reports_126_and_permission_denied() {
let path = std::env::temp_dir().join(format!("mxsh-noexec-stderr-{}", std::process::id()));
std::fs::write(&path, "echo hi\n").expect("temp script must be writable");
let script = format!("{}\n", path.display());
let (status, _out, err, state) = run_and_capture_output_and_error(&script);
let _ = std::fs::remove_file(&path);
assert_eq!(status, 126);
assert_eq!(state.last_status, 126);
assert!(err.contains(&format!("{}: Permission denied", path.display())));
}
#[test]
fn contains_glob_chars_detection() {
assert!(shell_expand::contains_glob_chars("*.txt"));
assert!(shell_expand::contains_glob_chars("file?.rs"));
assert!(shell_expand::contains_glob_chars("[abc]"));
assert!(!shell_expand::contains_glob_chars("plain.txt"));
assert!(!shell_expand::contains_glob_chars("no_meta_here"));
}
#[test]
fn builtin_echo_no_args() {
let (status, output, _) = run_and_capture_output("echo\n");
assert_eq!(status, 0);
assert_eq!(output, "\n");
}
#[test]
fn builtin_echo_simple_args() {
let (status, output, _) = run_and_capture_output("echo hello world\n");
assert_eq!(status, 0);
assert_eq!(output, "hello world\n");
}
#[test]
fn builtin_echo_dash_n() {
let (status, output, _) = run_and_capture_output("echo -n hello\n");
assert_eq!(status, 0);
assert_eq!(output, "hello");
}
#[test]
fn builtin_echo_is_builtin() {
let state = ShellState::new();
assert!(shell_builtins::is_builtin_in(&state, "echo"));
}
#[test]
fn arithm_identifier_variable_value_resolves_as_variable() {
let (status, output, _) = run_and_capture_output("A=alpha\necho $((A + 5))\n");
assert_eq!(status, 0);
assert_eq!(output, "5\n");
}
#[test]
fn arithm_unset_variable_is_zero() {
let (status, output, _) = run_and_capture_output("unset NOSUCH\necho $((NOSUCH + 5))\n");
assert_eq!(status, 0);
assert_eq!(output, "5\n");
}
#[test]
fn arithm_numeric_variable_works() {
let (status, output, _) = run_and_capture_output("N=7\necho $((N + 3))\n");
assert_eq!(status, 0);
assert_eq!(output, "10\n");
}