use bashkit::Bash;
use proptest::prelude::*;
use std::process::Command;
fn run_real_bash(script: &str) -> (String, i32) {
let output = Command::new("bash")
.arg("-c")
.arg(script)
.output()
.expect("Failed to run bash");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let exit_code = output.status.code().unwrap_or(1);
(stdout, exit_code)
}
async fn run_bashkit(script: &str) -> (String, i32) {
let mut bash = Bash::new();
match bash.exec(script).await {
Ok(result) => (result.stdout, result.exit_code),
Err(e) => {
let exit_code = if matches!(e, bashkit::Error::Parse { .. }) {
2
} else {
1
};
(String::new(), exit_code)
}
}
}
fn var_name_strategy() -> impl Strategy<Value = String> {
"[a-z]{1,8}".prop_map(|s| s.to_uppercase())
}
fn safe_value_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9_]{1,20}"
}
fn echo_command_strategy() -> impl Strategy<Value = String> {
prop_oneof![
safe_value_strategy().prop_map(|v| format!("echo {}", v)),
safe_value_strategy().prop_map(|v| format!("echo \"{}\"", v)),
safe_value_strategy().prop_map(|v| format!("echo '{}'", v)),
safe_value_strategy().prop_map(|v| format!("echo -n {}", v)),
(safe_value_strategy(), safe_value_strategy())
.prop_map(|(a, b)| format!("echo {} {}", a, b)),
]
}
fn assignment_strategy() -> impl Strategy<Value = String> {
(var_name_strategy(), safe_value_strategy())
.prop_map(|(name, value)| format!("{}={}", name, value))
}
fn arithmetic_strategy() -> impl Strategy<Value = String> {
let num = 0i32..100;
prop_oneof![
num.clone().prop_map(|n| format!("echo $(({}))", n)),
(num.clone(), num.clone()).prop_map(|(a, b)| format!("echo $(({} + {}))", a, b)),
(num.clone(), num.clone()).prop_map(|(a, b)| format!("echo $(({} - {}))", a, b)),
(num.clone(), num.clone()).prop_map(|(a, b)| format!("echo $(({} * {}))", a, b)),
(num.clone(), 1i32..100).prop_map(|(a, b)| format!("echo $(({} / {}))", a, b)),
(num.clone(), 1i32..100).prop_map(|(a, b)| format!("echo $(({} % {}))", a, b)),
]
}
fn if_statement_strategy() -> impl Strategy<Value = String> {
let condition = prop_oneof![
Just("true".to_string()),
Just("false".to_string()),
(0i32..10, 0i32..10).prop_map(|(a, b)| format!("[ {} -eq {} ]", a, b)),
(0i32..10, 0i32..10).prop_map(|(a, b)| format!("[ {} -lt {} ]", a, b)),
(0i32..10, 0i32..10).prop_map(|(a, b)| format!("[ {} -gt {} ]", a, b)),
];
(condition, safe_value_strategy(), safe_value_strategy()).prop_map(
|(cond, then_val, else_val)| {
format!(
"if {}; then echo {}; else echo {}; fi",
cond, then_val, else_val
)
},
)
}
fn for_loop_strategy() -> impl Strategy<Value = String> {
let items = proptest::collection::vec(safe_value_strategy(), 1..5);
(var_name_strategy(), items).prop_map(|(var, items)| {
format!(
"for {} in {}; do echo ${}; done",
var.to_lowercase(),
items.join(" "),
var.to_lowercase()
)
})
}
fn while_loop_strategy() -> impl Strategy<Value = String> {
(1i32..5, safe_value_strategy()).prop_map(|(count, val)| {
format!(
"i=0; while [ $i -lt {} ]; do echo {}; i=$((i + 1)); done",
count, val
)
})
}
fn case_statement_strategy() -> impl Strategy<Value = String> {
(
safe_value_strategy(),
safe_value_strategy(),
safe_value_strategy(),
)
.prop_map(|(input, pattern, result)| {
format!(
"case {} in {}) echo {};; *) echo default;; esac",
input, pattern, result
)
})
}
fn pipeline_strategy() -> impl Strategy<Value = String> {
prop_oneof![
safe_value_strategy().prop_map(|v| format!("echo {} | cat", v)),
safe_value_strategy().prop_map(|v| format!("echo {} | head -n 1", v)),
safe_value_strategy().prop_map(|v| format!("printf '{}\\n' | cat", v)),
safe_value_strategy().prop_map(|v| format!("echo {} | cat | cat", v)),
]
}
fn command_subst_strategy() -> impl Strategy<Value = String> {
prop_oneof![
safe_value_strategy().prop_map(|v| format!("echo $(echo {})", v)),
Just("echo $(pwd)".to_string()),
Just("X=$(echo hello); echo $X".to_string()),
]
}
fn logical_ops_strategy() -> impl Strategy<Value = String> {
prop_oneof![
(safe_value_strategy(), safe_value_strategy())
.prop_map(|(a, b)| format!("echo {} && echo {}", a, b)),
safe_value_strategy().prop_map(|v| format!("false || echo {}", v)),
(safe_value_strategy(), safe_value_strategy())
.prop_map(|(a, b)| format!("true && echo {} || echo {}", a, b)),
]
}
const RESERVED_WORDS: &[&str] = &[
"if", "then", "else", "elif", "fi", "case", "esac", "for", "select", "while", "until", "do",
"done", "in", "function", "time", "coproc",
];
fn function_strategy() -> impl Strategy<Value = String> {
(var_name_strategy(), safe_value_strategy()).prop_filter_map(
"avoid reserved words",
|(name, body)| {
let lower_name = name.to_lowercase();
if RESERVED_WORDS.contains(&lower_name.as_str()) {
None
} else {
Some(format!(
"{}() {{ echo {}; }}; {}",
lower_name, body, lower_name
))
}
},
)
}
fn prefix_assignment_strategy() -> impl Strategy<Value = String> {
prop_oneof![
(var_name_strategy(), safe_value_strategy())
.prop_map(|(name, value)| format!("{}={} printenv {}", name, value, name)),
(var_name_strategy(), safe_value_strategy())
.prop_map(|(name, value)| format!("{}={} echo done", name, value)),
(var_name_strategy(), safe_value_strategy()).prop_map(|(name, value)| {
format!("{}={} echo done; echo ${{{}:-unset}}", name, value, name)
}),
]
}
fn valid_script_strategy() -> impl Strategy<Value = String> {
prop_oneof![
10 => echo_command_strategy(),
5 => assignment_strategy().prop_map(|a| format!("{}; echo done", a)),
5 => arithmetic_strategy(),
3 => if_statement_strategy(),
3 => for_loop_strategy(),
2 => while_loop_strategy(),
2 => case_statement_strategy(),
3 => pipeline_strategy(),
3 => command_subst_strategy(),
3 => logical_ops_strategy(),
2 => function_strategy(),
3 => prefix_assignment_strategy(),
]
}
fn multi_statement_strategy() -> impl Strategy<Value = String> {
proptest::collection::vec(valid_script_strategy(), 1..4).prop_map(|scripts| scripts.join("; "))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn echo_matches_bash(script in echo_command_strategy()) {
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}\nBashkit: {}\nBash: {}",
script, bashkit_exit, bash_exit
);
}
#[test]
fn arithmetic_matches_bash(script in arithmetic_strategy()) {
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}",
script
);
}
#[test]
fn control_flow_matches_bash(script in prop_oneof![
if_statement_strategy(),
for_loop_strategy(),
while_loop_strategy(),
case_statement_strategy(),
]) {
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}",
script
);
}
#[test]
fn pipelines_match_bash(script in pipeline_strategy()) {
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}",
script
);
}
#[test]
fn logical_ops_match_bash(script in logical_ops_strategy()) {
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}",
script
);
}
#[test]
fn command_subst_matches_bash(script in command_subst_strategy()) {
prop_assume!(!script.contains("pwd"));
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}",
script
);
}
#[test]
fn functions_match_bash(script in function_strategy()) {
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}",
script
);
}
#[test]
fn valid_scripts_match_bash(script in valid_script_strategy()) {
prop_assume!(!script.contains("pwd"));
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}",
script
);
}
#[test]
fn prefix_assignments_match_bash(script in prefix_assignment_strategy()) {
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}",
script
);
}
#[test]
fn multi_statement_matches_bash(script in multi_statement_strategy()) {
prop_assume!(!script.contains("pwd"));
let (bash_out, bash_exit) = run_real_bash(&script);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script));
prop_assert_eq!(
&bashkit_out, &bash_out,
"Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}",
script, bashkit_out, bash_out
);
prop_assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for script: {}",
script
);
}
}
#[tokio::test]
async fn differential_edge_cases() {
let test_cases = [
("echo", "echo"),
("echo hello", "echo hello"),
("echo hello world", "echo hello world"),
("echo -n hello", "echo -n hello"),
("echo \"hello world\"", "echo \"hello world\""),
("echo 'hello world'", "echo 'hello world'"),
("X=hello; echo $X", "X=hello; echo $X"),
(
"X=hello; Y=world; echo $X $Y",
"X=hello; Y=world; echo $X $Y",
),
("prefix assign visible", "X=hello printenv X"),
(
"prefix assign temporary",
"X=hello printenv X; echo ${X:-unset}",
),
(
"prefix assign no clobber",
"X=original; X=temp echo done; echo $X",
),
("prefix assign empty", "X= printenv X"),
("multiple prefix assigns", "A=1 B=2 printenv A"),
("assignment only persists", "X=persist; echo $X"),
("echo $((1 + 2))", "echo $((1 + 2))"),
("echo $((10 - 3))", "echo $((10 - 3))"),
("echo $((4 * 5))", "echo $((4 * 5))"),
("echo $((10 / 2))", "echo $((10 / 2))"),
("if true; then echo yes; fi", "if true; then echo yes; fi"),
(
"if false; then echo yes; else echo no; fi",
"if false; then echo yes; else echo no; fi",
),
(
"for i in a b c; do echo $i; done",
"for i in a b c; do echo $i; done",
),
("true && echo yes", "true && echo yes"),
("false || echo no", "false || echo no"),
("echo hello | cat", "echo hello | cat"),
("echo $(echo hello)", "echo $(echo hello)"),
(
"if() { echo 0; }; if",
"reserved word 'if' as function name",
),
(
"do() { echo a; }; do",
"reserved word 'do' as function name",
),
];
for (name, script) in test_cases {
let (bash_out, bash_exit) = run_real_bash(script);
let (bashkit_out, bashkit_exit) = run_bashkit(script).await;
assert_eq!(
bashkit_out, bash_out,
"Output mismatch for '{}'\nScript: {}\nBashkit: {:?}\nBash: {:?}",
name, script, bashkit_out, bash_out
);
assert_eq!(
bashkit_exit, bash_exit,
"Exit code mismatch for '{}'\nScript: {}",
name, script
);
}
}