#![cfg(all(
feature = "cli",
feature = "embed",
feature = "test-support",
feature = "unix-runtime"
))]
use std::process::{Command, Stdio};
use std::time::Duration;
use proptest::prelude::*;
const SAFE_LITERALS: &[&str] = &[
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
"one",
"two",
"three",
"/dev/null",
];
const SAFE_VAR_NAMES: &[&str] = &["A", "B", "C", "X", "Y", "Z", "ITEM", "NAME", "VALUE"];
const SAFE_ARITH_VARS: &[&str] = &["N", "M", "P"];
const PREAMBLE: &str = "\
A=alpha
B=bravo
C=charlie
X=delta
Y=echo
Z=foxtrot
N=3
M=5
P=7
set -- one two three
f_echo() { printf '%s\\n' \"$@\"; }
f_status() { return \"$1\"; }
f_cat() { cat \"$@\"; }
f_wrap() { { printf '%s\\n' \"$1\"; } | cat; }
";
const TIMEOUT: Duration = Duration::from_secs(5);
fn pick_literal() -> impl Strategy<Value = String> {
prop::sample::select(SAFE_LITERALS).prop_map(|s| s.to_string())
}
fn pick_var() -> impl Strategy<Value = String> {
prop::sample::select(SAFE_VAR_NAMES).prop_map(|s| s.to_string())
}
fn safe_arithm_expr(depth: u32) -> BoxedStrategy<String> {
let literal = (0i64..8i64).prop_map(|n| n.to_string());
let variable = prop::sample::select(SAFE_ARITH_VARS).prop_map(|s| s.to_string());
let positional = (1u8..=3u8).prop_map(|n| n.to_string());
let leaf = prop_oneof![literal, variable, positional].boxed();
if depth == 0 {
return leaf;
}
let bin_op = prop_oneof![
Just("+".to_string()),
Just("-".to_string()),
Just("*".to_string()),
Just("/".to_string()),
Just("%".to_string()),
Just("<<".to_string()),
Just(">>".to_string()),
Just("<".to_string()),
Just("<=".to_string()),
Just(">".to_string()),
Just(">=".to_string()),
Just("==".to_string()),
Just("!=".to_string()),
Just("&".to_string()),
Just("^".to_string()),
Just("|".to_string()),
Just("&&".to_string()),
Just("||".to_string()),
];
let un_op = prop_oneof![
Just("+".to_string()),
Just("-".to_string()),
Just("~".to_string()),
Just("!".to_string()),
];
let left = safe_arithm_expr(depth - 1);
let right = safe_arithm_expr(depth - 1);
prop_oneof![
4 => leaf,
3 => (left.clone(), bin_op, right.clone())
.prop_map(|(l, op, r)| format!("({l} {op} {r})")),
1 => (un_op, left.clone()).prop_map(|(op, e)| format!("({op}{e})")),
1 => (left.clone(), right.clone(), safe_arithm_expr(depth - 1))
.prop_map(|(c, t, e)| format!("({c} ? {t} : {e})")),
]
.boxed()
}
fn safe_command_substitution(depth: u32) -> BoxedStrategy<String> {
prop_oneof![
safe_word(depth).prop_map(|w| format!("$(printf %s {w})")),
safe_word(depth).prop_map(|w| format!("$(f_echo {w} | cat)")),
Just("$(printf %s \"$#\")".to_string()),
]
.boxed()
}
fn safe_word(depth: u32) -> BoxedStrategy<String> {
let literal = pick_literal().boxed();
let single_quoted = pick_literal().prop_map(|w| format!("'{w}'")).boxed();
let double_quoted = pick_literal().prop_map(|w| format!("\"{w}\"")).boxed();
let parameter = prop_oneof![
pick_var().prop_map(|v| format!("${v}")),
pick_var().prop_map(|v| format!("${{{v}}}")),
pick_var().prop_map(|v| format!("${{#{v}}}")),
(pick_var(), pick_literal()).prop_map(|(v, w)| format!("${{{v}:-{w}}}")),
(pick_var(), pick_literal()).prop_map(|(v, w)| format!("${{{v}:+{w}}}")),
(pick_var(), pick_literal()).prop_map(|(v, w)| format!("${{{v}%{w}}}")),
(pick_var(), pick_literal()).prop_map(|(v, w)| format!("${{{v}#{w}}}")),
Just("$1".to_string()),
Just("$2".to_string()),
Just("$3".to_string()),
Just("$#".to_string()),
Just("$@".to_string()),
Just("\"$@\"".to_string()),
Just("$*".to_string()),
Just("\"$*\"".to_string()),
Just("$?".to_string()),
Just("$-".to_string()),
]
.boxed();
let leaf = prop_oneof![literal, single_quoted, double_quoted, parameter].boxed();
if depth == 0 {
return leaf;
}
let arithmetic = safe_arithm_expr(depth - 1).prop_map(|e| format!("$(({e}))"));
let cmd_sub = safe_command_substitution(depth - 1);
let composite = (
pick_literal(),
prop_oneof![
pick_var().prop_map(|v| format!("${v}")),
safe_arithm_expr(depth - 1).prop_map(|e| format!("$(({e}))")),
],
pick_literal(),
)
.prop_map(|(a, b, c)| format!("{a}{b}{c}"));
let quoted_composite = (
pick_literal(),
prop_oneof![
pick_var().prop_map(|v| format!("${v}")),
safe_command_substitution(depth - 1),
],
pick_literal(),
)
.prop_map(|(a, b, c)| format!("\"{a}{b}{c}\""));
prop_oneof![
5 => leaf,
2 => arithmetic,
2 => cmd_sub,
1 => composite,
1 => quoted_composite,
]
.boxed()
}
fn null_target() -> BoxedStrategy<String> {
Just("/dev/null".to_string()).boxed()
}
#[derive(Clone, Debug)]
struct GenCommand {
inline: String,
heredoc_bodies: Vec<String>,
}
impl GenCommand {
fn plain(inline: String) -> Self {
Self {
inline,
heredoc_bodies: Vec::new(),
}
}
}
fn heredoc_redir(depth: u32) -> BoxedStrategy<(String, String)> {
let delim = prop_oneof![
Just("EOF".to_string()),
Just("TAG".to_string()),
Just("DOC".to_string()),
Just("END".to_string()),
];
let delim_token = (delim.clone(), any::<bool>()).prop_map(|(d, quoted)| {
if quoted {
(format!("'{d}'"), d)
} else {
(d.clone(), d)
}
});
let body_line = prop::collection::vec(safe_word(depth), 1..3).prop_map(|parts| parts.join(" "));
(
any::<bool>(),
delim_token,
prop::collection::vec(body_line, 1..3),
)
.prop_map(|(strip_tabs, (delim_token, delim_close), lines)| {
let op = if strip_tabs { "<<-" } else { "<<" };
let inline_part = format!("{op} {delim_token}");
let mut body = String::new();
for line in lines {
if strip_tabs {
body.push('\t');
}
body.push_str(&line);
body.push('\n');
}
body.push_str(&delim_close);
(inline_part, body)
})
.boxed()
}
fn simple_command_no_heredoc(depth: u32) -> BoxedStrategy<GenCommand> {
let assignment = (pick_var(), safe_word(depth))
.prop_map(|(k, v)| format!("{k}={v}"))
.boxed();
let args = prop::collection::vec(safe_word(depth), 0..4).boxed();
let redirs = Just(Vec::<String>::new()).boxed();
let colon = (
prop::collection::vec(assignment.clone(), 0..2),
args.clone(),
redirs.clone(),
)
.prop_map(|(assigns, args, redirs)| {
let mut parts: Vec<String> = assigns;
parts.push(":".to_string());
parts.extend(args);
parts.extend(redirs);
GenCommand::plain(parts.join(" "))
});
let printf_cmd = (
prop::collection::vec(assignment.clone(), 0..2),
prop::collection::vec(safe_word(depth), 1..4),
redirs.clone(),
)
.prop_map(|(assigns, args, redirs)| {
let mut parts: Vec<String> = assigns;
parts.push("printf".to_string());
parts.push("'%s\\n'".to_string());
parts.extend(args);
parts.extend(redirs);
GenCommand::plain(parts.join(" "))
});
let cat_cmd = (
prop::collection::vec(assignment.clone(), 0..2),
prop::collection::vec(null_target(), 1..3),
)
.prop_map(|(assigns, files)| {
let mut parts: Vec<String> = assigns;
parts.push("cat".to_string());
parts.extend(files);
GenCommand::plain(parts.join(" "))
});
let echo_call = (
prop::collection::vec(assignment.clone(), 0..1),
prop::collection::vec(safe_word(depth), 1..4),
redirs.clone(),
)
.prop_map(|(assigns, args, redirs)| {
let mut parts: Vec<String> = assigns;
parts.push("f_echo".to_string());
parts.extend(args);
parts.extend(redirs);
GenCommand::plain(parts.join(" "))
});
let wrap_call = (
prop::collection::vec(assignment.clone(), 0..1),
prop::collection::vec(safe_word(depth), 1..3),
redirs.clone(),
)
.prop_map(|(assigns, args, redirs)| {
let mut parts: Vec<String> = assigns;
parts.push("f_wrap".to_string());
parts.extend(args);
parts.extend(redirs);
GenCommand::plain(parts.join(" "))
});
let cat_call = (
prop::collection::vec(assignment.clone(), 0..1),
prop::collection::vec(null_target(), 1..3),
)
.prop_map(|(assigns, args)| {
let mut parts: Vec<String> = assigns;
parts.push("f_cat".to_string());
parts.extend(args);
GenCommand::plain(parts.join(" "))
});
let status_call = (
prop::collection::vec(assignment.clone(), 0..1),
(0u8..=1u8),
redirs.clone(),
)
.prop_map(|(assigns, status, redirs)| {
let mut parts: Vec<String> = assigns;
parts.push("f_status".to_string());
parts.push(status.to_string());
parts.extend(redirs);
GenCommand::plain(parts.join(" "))
});
let set_positional = prop::collection::vec(safe_word(depth), 0..4).prop_map(|args| {
let mut parts = vec!["set".to_string(), "--".to_string()];
parts.extend(args);
GenCommand::plain(parts.join(" "))
});
let shift_cmd = prop_oneof![
Just(GenCommand::plain("shift".to_string())),
(1u8..=2u8).prop_map(|n| GenCommand::plain(format!("shift {n}"))),
];
let status_cmd = prop_oneof![
Just(GenCommand::plain("true".to_string())),
Just(GenCommand::plain("false".to_string())),
(0u8..=1u8).prop_map(|n| GenCommand::plain(format!("f_status {n}"))),
];
let assignments_only = prop::collection::vec(assignment, 1..3)
.prop_map(|assigns| GenCommand::plain(assigns.join(" ")));
prop_oneof![
4 => colon,
4 => printf_cmd,
2 => cat_cmd,
2 => echo_call,
1 => cat_call,
1 => wrap_call,
1 => status_call,
2 => set_positional,
1 => shift_cmd,
2 => status_cmd,
1 => assignments_only,
]
.boxed()
}
fn simple_command(depth: u32) -> BoxedStrategy<GenCommand> {
let heredoc = prop::option::weighted(0.20, heredoc_redir(depth));
(simple_command_no_heredoc(depth), heredoc)
.prop_map(|(mut cmd, heredoc)| {
if let Some((inline_part, body)) = heredoc {
cmd.inline.push(' ');
cmd.inline.push_str(&inline_part);
cmd.heredoc_bodies.push(body);
}
cmd
})
.boxed()
}
fn safe_condition(depth: u32) -> BoxedStrategy<String> {
prop_oneof![
Just("true".to_string()),
Just("false".to_string()),
Just(":".to_string()),
(0u8..=1u8).prop_map(|n| format!("f_status {n}")),
simple_pipeline(depth).prop_map(|cmd| flatten_gen(&cmd).trim_end().to_string()),
]
.boxed()
}
fn command_with_leaf(depth: u32, leaf: BoxedStrategy<GenCommand>) -> BoxedStrategy<GenCommand> {
if depth == 0 {
return leaf;
}
let compound_body = compound_list(depth - 1);
let brace_group = compound_body
.clone()
.prop_map(|body| {
let sb = sep_before_keyword(&body);
GenCommand::plain(format!("{{ {body}{sb}}}"))
})
.boxed();
let subshell = compound_body
.clone()
.prop_map(|body| GenCommand::plain(format!("( {body} )")))
.boxed();
let else_part = prop_oneof![
Just(String::new()),
compound_list(depth - 1).prop_map(|else_body| {
let se = sep_before_keyword(&else_body);
format!("\nelse {else_body}{se}")
}),
(
safe_condition(depth - 1),
compound_list(depth - 1),
prop::option::of(compound_list(depth - 1)),
)
.prop_map(|(cond, body, tail_else)| {
let sb = sep_before_keyword(&body);
match tail_else {
Some(else_body) => {
let se = sep_before_keyword(&else_body);
format!("\nelif {cond}; then {body}{sb}\nelse {else_body}{se}")
}
None => format!("\nelif {cond}; then {body}{sb}"),
}
}),
];
let if_clause = (
safe_condition(depth - 1),
compound_list(depth - 1),
else_part,
)
.prop_map(|(cond, then_body, else_part)| {
let sb = sep_before_keyword(&then_body);
GenCommand::plain(format!("if {cond}; then {then_body}{sb}{else_part}fi"))
})
.boxed();
let loop_body = compound_list(depth - 1).prop_map(|body| {
let sb = sep_before_keyword(&body);
format!("{body}{sb}break")
});
let for_clause = (
pick_var(),
prop::collection::vec(safe_word(depth - 1), 0..4),
loop_body.clone(),
)
.prop_map(|(name, words, body)| {
if words.is_empty() {
GenCommand::plain(format!("for {name}; do {body}; done"))
} else {
GenCommand::plain(format!(
"for {name} in {}; do {body}; done",
words.join(" ")
))
}
})
.boxed();
let while_clause = (safe_condition(depth - 1), loop_body.clone())
.prop_map(|(cond, body)| GenCommand::plain(format!("while {cond}; do {body}; done")))
.boxed();
let until_clause = (safe_condition(depth - 1), loop_body)
.prop_map(|(cond, body)| GenCommand::plain(format!("until {cond}; do {body}; done")))
.boxed();
let case_item = (
prop::collection::vec(pick_literal(), 1..3),
prop::option::of(compound_list(depth - 1)),
)
.prop_map(|(patterns, body)| match body {
Some(body) => {
let sb = sep_before_keyword(&body);
format!("{} ) {body}{sb};;", patterns.join(" | "))
}
None => format!("{} ) ;;", patterns.join(" | ")),
})
.boxed();
let case_clause = (safe_word(depth - 1), prop::collection::vec(case_item, 1..4))
.prop_map(|(word, items)| {
GenCommand::plain(format!("case {word} in {} esac", items.join(" ")))
})
.boxed();
prop_oneof![
8 => leaf,
2 => brace_group,
2 => subshell,
2 => if_clause,
2 => for_clause,
1 => while_clause,
1 => until_clause,
2 => case_clause,
]
.boxed()
}
fn command_no_heredoc(depth: u32) -> BoxedStrategy<GenCommand> {
command_with_leaf(depth, simple_command_no_heredoc(depth))
}
fn pipeline(depth: u32) -> BoxedStrategy<GenCommand> {
let single = command_with_leaf(depth, simple_command(depth));
let multi = (
any::<bool>(),
prop::collection::vec(simple_command_no_heredoc(depth), 0..2),
simple_command(depth),
prop::collection::vec(simple_command(depth), 0..2),
)
.prop_map(|(bang, before, middle, after)| {
let mut heredoc_bodies = Vec::new();
let mut inlines: Vec<&str> = Vec::new();
for cmd in &before {
inlines.push(cmd.inline.as_str());
}
inlines.push(middle.inline.as_str());
heredoc_bodies.extend(middle.heredoc_bodies.iter().cloned());
for cmd in &after {
inlines.push(cmd.inline.as_str());
heredoc_bodies.extend(cmd.heredoc_bodies.iter().cloned());
}
let mut inline = inlines.join(" | ");
if bang {
inline = format!("! {inline}");
}
GenCommand {
inline,
heredoc_bodies,
}
});
prop_oneof![3 => single, 2 => multi].boxed()
}
fn pipeline_no_heredoc(depth: u32) -> BoxedStrategy<GenCommand> {
let single = command_no_heredoc(depth);
let multi = (
any::<bool>(),
prop::collection::vec(simple_command_no_heredoc(depth), 1..3),
)
.prop_map(|(bang, commands)| {
let inlines: Vec<&str> = commands.iter().map(|c| c.inline.as_str()).collect();
let mut inline = inlines.join(" | ");
if bang {
inline = format!("! {inline}");
}
GenCommand::plain(inline)
});
prop_oneof![3 => single, 2 => multi].boxed()
}
fn simple_pipeline(depth: u32) -> BoxedStrategy<GenCommand> {
(
any::<bool>(),
prop::collection::vec(simple_command(depth), 1..3),
)
.prop_map(|(bang, commands)| {
let mut heredoc_bodies = Vec::new();
let inlines: Vec<&str> = commands.iter().map(|c| c.inline.as_str()).collect();
for cmd in &commands {
heredoc_bodies.extend(cmd.heredoc_bodies.iter().cloned());
}
let mut inline = inlines.join(" | ");
if bang {
inline = format!("! {inline}");
}
GenCommand {
inline,
heredoc_bodies,
}
})
.boxed()
}
fn and_or_list(depth: u32) -> BoxedStrategy<GenCommand> {
let and_or_op = prop_oneof![Just("&&".to_string()), Just("||".to_string())];
(
pipeline_no_heredoc(depth),
prop::collection::vec((and_or_op.clone(), pipeline_no_heredoc(depth)), 0..1),
and_or_op.clone(),
pipeline(depth),
prop::collection::vec((and_or_op, simple_pipeline(depth)), 0..1),
any::<bool>(),
)
.prop_map(|(first, mid, mid_op, heredoc_pl, after, use_heredoc)| {
let mut inline = first.inline.clone();
for (op, cmd) in &mid {
inline.push(' ');
inline.push_str(op);
inline.push(' ');
inline.push_str(&cmd.inline);
}
let mut heredoc_bodies = Vec::new();
if use_heredoc {
inline.push(' ');
inline.push_str(&mid_op);
inline.push(' ');
inline.push_str(&heredoc_pl.inline);
heredoc_bodies = heredoc_pl.heredoc_bodies;
}
for (op, cmd) in &after {
inline.push(' ');
inline.push_str(op);
inline.push(' ');
inline.push_str(&cmd.inline);
}
GenCommand {
inline,
heredoc_bodies,
}
})
.boxed()
}
fn command_list(depth: u32) -> BoxedStrategy<GenCommand> {
and_or_list(depth)
}
fn flatten_gen(cmd: &GenCommand) -> String {
let mut out = cmd.inline.clone();
if !cmd.heredoc_bodies.is_empty() {
out.push('\n');
out.push_str(&cmd.heredoc_bodies.join("\n"));
out.push('\n');
}
out
}
fn compound_list(depth: u32) -> BoxedStrategy<String> {
prop::collection::vec(command_list(depth), 1..=2)
.prop_map(|items| {
let mut out = String::new();
for (i, item) in items.iter().enumerate() {
let flat = flatten_gen(item);
if i > 0 && !out.ends_with('\n') {
out.push_str(" ; ");
}
out.push_str(&flat);
}
out
})
.boxed()
}
fn sep_before_keyword(list: &str) -> &str {
if list.ends_with('\n') { "" } else { "; " }
}
fn program_strategy() -> BoxedStrategy<String> {
prop::collection::vec(command_list(1), 1..=3)
.prop_map(|items| {
let mut script = String::from(PREAMBLE);
for (i, item) in items.iter().enumerate() {
let flat = flatten_gen(item);
if i > 0 && !script.ends_with('\n') {
script.push_str(" ;\n");
}
script.push_str(&flat);
if !script.ends_with('\n') {
script.push('\n');
}
}
script
})
.boxed()
}
fn run_script_with_timeout(shell: &str, script: &str) -> (i32, String, String) {
let spawn_result = Command::new(shell)
.arg("-c")
.arg(script)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = spawn_result.unwrap_or_else(|err| panic!("failed to spawn {shell}: {err}"));
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
let output = child.wait_with_output().unwrap();
let code = status.code().unwrap_or(128);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
break (code, stdout, stderr);
}
Ok(None) => {
if start.elapsed() > TIMEOUT {
let _ = child.kill();
let _ = child.wait();
panic!("shell {shell} timed out after {TIMEOUT:?} on script:\n{script}");
}
std::thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("error waiting for {shell}: {err}"),
}
}
}
fn mxsh_binary() -> String {
env!("CARGO_BIN_EXE_mxsh").to_string()
}
fn assert_shells_match(script: &str) {
let mxsh = mxsh_binary();
let (mx_rc, mx_out, mx_err) = run_script_with_timeout(&mxsh, script);
let (dash_rc, dash_out, dash_err) = run_script_with_timeout("/bin/dash", script);
if mx_rc != dash_rc || mx_out != dash_out {
panic!(
"mxsh rc={mx_rc}, dash rc={dash_rc}\n\
mxsh stdout:\n{mx_out}\n\
dash stdout:\n{dash_out}\n\
mxsh stderr:\n{mx_err}\n\
dash stderr:\n{dash_err}\n\
script:\n{script}"
);
}
}
#[test]
fn regression_heredocs_in_nested_loops_match_dash() {
let script = "\
A=alpha
B=bravo
for A in alpha; do
cat <<'LEFT'
alpha
LEFT
for B in bravo; do
cat <<'FIRST' | cat <<'SECOND'
left
FIRST
right
SECOND
break
done
break
done
";
assert_shells_match(script);
}
#[test]
fn regression_function_definition_and_invocation_match_dash() {
let script = "\
local_fn() {
printf '%s\\n' \"$1\"
}
local_fn alpha | cat
";
assert_shells_match(script);
}
#[test]
fn regression_dev_null_redirections_match_dash() {
let script = "\
printf '%s\\n' alpha > /dev/null
printf '%s\\n' bravo < /dev/null
printf '%s\\n' charlie 2> /dev/null
";
assert_shells_match(script);
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 64,
failure_persistence: None,
.. ProptestConfig::default()
})]
#[test]
fn differential_vs_dash(script in program_strategy()) {
let mxsh = mxsh_binary();
let (mx_rc, mx_out, mx_err) = run_script_with_timeout(&mxsh, &script);
let (dash_rc, dash_out, dash_err) = run_script_with_timeout("/bin/dash", &script);
if mx_rc != dash_rc || mx_out != dash_out {
let detail = format!(
"mxsh rc={}, dash rc={}\n\
mxsh stdout:\n{}\n\
dash stdout:\n{}\n\
mxsh stderr:\n{}\n\
dash stderr:\n{}\n\
script:\n{}",
mx_rc, dash_rc, mx_out, dash_out, mx_err, dash_err, script
);
prop_assert_eq!(mx_rc, dash_rc, "exit code mismatch.\n{}", detail);
prop_assert_eq!(mx_out, dash_out, "stdout mismatch.\n{}", detail);
}
}
}