use bashkit::testing::{assert_fuzz_invariants, fuzz_init};
use bashkit::{Bash, ExecutionLimits};
use proptest::prelude::*;
use std::time::Duration;
fn bash_input_strategy() -> impl Strategy<Value = String> {
proptest::string::string_regex("[a-zA-Z0-9_ ;|$()]{0,50}").unwrap()
}
fn arithmetic_multibyte_strategy() -> impl Strategy<Value = String> {
prop_oneof![
proptest::string::string_regex("[0-9a-z+\\-*/%,()éèüöñ]{1,30}").unwrap(),
proptest::string::string_regex("[0-9+\\-*/()你好世界]{1,20}").unwrap(),
proptest::string::string_regex("[0-9+\\-*/,🎉🚀]{1,15}").unwrap(),
proptest::string::string_regex("[0-9a-z?:|&^!<>=éü]{1,30}").unwrap(),
]
}
fn array_subscript_strategy() -> impl Strategy<Value = String> {
prop_oneof![
proptest::string::string_regex("\\$\\{arr\\[[\"'a-z]{0,5}\\]\\}").unwrap(),
proptest::string::string_regex("\\$\\{arr\\[[éü0-9\"']{0,5}\\]\\}").unwrap(),
Just("${arr[\"]}".to_string()),
Just("${arr[']}".to_string()),
]
}
fn resource_stress_strategy() -> impl Strategy<Value = String> {
prop_oneof![
(2..20usize).prop_map(|n| {
let mut s = "echo x".to_string();
for _ in 0..n {
s.push_str(" | cat");
}
s
}),
(2..50usize).prop_map(|n| { (0..n).map(|_| "echo x").collect::<Vec<_>>().join("; ") }),
(1..100usize).prop_map(|n| format!("{}=value", "A".repeat(n))),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(16))]
#[test]
fn lexer_never_panics(input in bash_input_strategy()) {
let mut lexer = bashkit::parser::Lexer::new(&input);
while lexer.next_token().is_some() {}
}
#[test]
fn resource_limits_enforced(input in resource_stress_strategy()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
RT.with(|rt| {
rt.block_on(async {
let limits = ExecutionLimits::new()
.max_commands(10)
.max_loop_iterations(10)
.timeout(Duration::from_millis(20));
let mut bash = Bash::builder().limits(limits).build();
let _ = bash.exec(&input).await;
});
});
}
#[test]
fn output_bounded(input in resource_stress_strategy()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
let (stdout_len, stderr_len) = RT.with(|rt| {
rt.block_on(async {
let limits = ExecutionLimits::new()
.max_commands(10)
.timeout(Duration::from_millis(20));
let mut bash = Bash::builder().limits(limits).build();
if let Ok(result) = bash.exec(&input).await {
(result.stdout.len(), result.stderr.len())
} else {
(0, 0)
}
})
});
prop_assert!(stdout_len < 10_000_000);
prop_assert!(stderr_len < 10_000_000);
}
#[test]
fn path_traversal_contained(
prefix in "[.]{0,10}",
slashes in "[/]{1,10}",
segments in proptest::collection::vec("[.]{0,3}", 0..10)
) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
let path = format!("{prefix}{slashes}{}", segments.join("/"));
let script = format!("cat {path}");
RT.with(|rt| {
rt.block_on(async {
let mut bash = Bash::new();
let _ = bash.exec(&script).await;
});
});
}
#[test]
fn arithmetic_multibyte_no_panic(expr in arithmetic_multibyte_strategy()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
let script = format!("echo $(({expr}))");
RT.with(|rt| {
rt.block_on(async {
let limits = ExecutionLimits::new()
.max_commands(10)
.timeout(Duration::from_millis(50));
let mut bash = Bash::builder().limits(limits).build();
let _ = bash.exec(&script).await;
});
});
}
#[test]
fn parser_subscript_no_panic(input in array_subscript_strategy()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
let script = format!("arr=(a b c); echo {input}");
RT.with(|rt| {
rt.block_on(async {
let limits = ExecutionLimits::new()
.max_commands(10)
.timeout(Duration::from_millis(50));
let mut bash = Bash::builder().limits(limits).build();
let _ = bash.exec(&script).await;
});
});
}
#[test]
fn lexer_multibyte_no_panic(input in proptest::string::string_regex("[a-zA-Z0-9_ ;|$()\"'éèüöñ你好🎉]{0,50}").unwrap()) {
let mut lexer = bashkit::parser::Lexer::new(&input);
while lexer.next_token().is_some() {}
}
#[test]
fn variable_expansion_safe(var_content in "[^']{0,100}") {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
let script = format!("X='{var_content}'; echo $X");
RT.with(|rt| {
rt.block_on(async {
let limits = ExecutionLimits::new()
.max_commands(10)
.timeout(Duration::from_millis(20));
let mut bash = Bash::builder().limits(limits).build();
let _ = bash.exec(&script).await;
});
});
}
}
#[test]
fn test_deeply_nested_parens() {
let deep = format!("{}1{}", "(".repeat(500), ")".repeat(500));
let parser = bashkit::parser::Parser::new(&deep);
let _ = parser.parse();
}
#[test]
fn test_very_long_pipeline() {
let pipeline = (0..100).map(|_| "cat").collect::<Vec<_>>().join(" | ");
let script = format!("echo x | {pipeline}");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let limits = ExecutionLimits::new()
.max_commands(200)
.timeout(Duration::from_millis(500));
let mut bash = Bash::builder().limits(limits).build();
let _ = bash.exec(&script).await;
});
}
#[test]
fn test_null_bytes_handled() {
let input = "echo hello\x00world";
let parser = bashkit::parser::Parser::new(input);
let _ = parser.parse();
}
#[test]
fn test_unicode_handling() {
let scripts = [
"echo 你好世界",
"echo مرحبا",
"echo 🎉🚀",
"VAR=émoji; echo $VAR",
"echo '\u{0000}\u{FFFF}'",
];
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
for script in scripts {
let mut bash = Bash::new();
let _ = bash.exec(script).await;
}
});
}
#[test]
fn test_multibyte_in_variable_expansion() {
let scripts = [
"X='${:¡%'; echo $X",
"X='¡%'; echo ${X:1}",
"X='日本語'; echo ${X:1:2}",
"X='émoji'; echo ${X:0:3}",
"X='über'; echo ${#X}",
];
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
for script in scripts {
let limits = ExecutionLimits::new()
.max_commands(10)
.timeout(Duration::from_millis(100));
let mut bash = Bash::builder().limits(limits).build();
let _ = bash.exec(script).await;
}
});
}
fn arbitrary_tool_arg() -> impl Strategy<Value = String> {
proptest::string::string_regex(r"[\x20-\x7e\t]{0,80}").unwrap()
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 64,
max_shrink_iters: 32,
..ProptestConfig::default()
})]
#[cfg(feature = "jq")]
#[test]
fn jq_arbitrary_filter_no_leak(filter in arbitrary_tool_arg()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
RT.with(|rt| rt.block_on(async {
fuzz_init();
let limits = ExecutionLimits::new()
.max_commands(5)
.max_stdout_bytes(4096)
.max_stderr_bytes(4096)
.timeout(Duration::from_millis(200));
let mut bash = Bash::builder().limits(limits).build();
let escaped = filter.replace('\'', "'\\''");
let script = format!("echo '{{}}' | jq '{}'", escaped);
let result = bash.exec(&script).await.unwrap_or_default();
assert_fuzz_invariants(&result, "jq_arbitrary_filter", &[]);
}));
}
#[test]
fn awk_arbitrary_program_no_leak(program in arbitrary_tool_arg()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
RT.with(|rt| rt.block_on(async {
fuzz_init();
let limits = ExecutionLimits::new()
.max_commands(5)
.max_stdout_bytes(4096)
.max_stderr_bytes(4096)
.timeout(Duration::from_millis(200));
let mut bash = Bash::builder().limits(limits).build();
let escaped = program.replace('\'', "'\\''");
let script = format!("echo 'a b c' | awk '{}'", escaped);
let result = bash.exec(&script).await.unwrap_or_default();
assert_fuzz_invariants(&result, "awk_arbitrary_program", &[]);
}));
}
#[test]
fn grep_arbitrary_regex_no_leak(pattern in arbitrary_tool_arg()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
RT.with(|rt| rt.block_on(async {
fuzz_init();
let limits = ExecutionLimits::new()
.max_commands(5)
.max_stdout_bytes(4096)
.max_stderr_bytes(4096)
.timeout(Duration::from_millis(200));
let mut bash = Bash::builder().limits(limits).build();
let escaped = pattern.replace('\'', "'\\''");
let script = format!("echo 'hello world' | grep -E '{}'", escaped);
let result = bash.exec(&script).await.unwrap_or_default();
assert_fuzz_invariants(&result, "grep_arbitrary_regex", &[]);
}));
}
#[test]
fn sed_arbitrary_expr_no_leak(expr in arbitrary_tool_arg()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
RT.with(|rt| rt.block_on(async {
fuzz_init();
let limits = ExecutionLimits::new()
.max_commands(5)
.max_stdout_bytes(4096)
.max_stderr_bytes(4096)
.timeout(Duration::from_millis(200));
let mut bash = Bash::builder().limits(limits).build();
let escaped = expr.replace('\'', "'\\''");
let script = format!("echo 'hello' | sed '{}'", escaped);
let result = bash.exec(&script).await.unwrap_or_default();
assert_fuzz_invariants(&result, "sed_arbitrary_expr", &[]);
}));
}
#[test]
fn json_arbitrary_path_no_leak(path in arbitrary_tool_arg()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
RT.with(|rt| rt.block_on(async {
fuzz_init();
let limits = ExecutionLimits::new()
.max_commands(5)
.max_stdout_bytes(4096)
.max_stderr_bytes(4096)
.timeout(Duration::from_millis(200));
let mut bash = Bash::builder().limits(limits).build();
let escaped = path.replace('\'', "'\\''");
let script = format!("echo '{{\"a\":1}}' | json get '{}'", escaped);
let result = bash.exec(&script).await.unwrap_or_default();
assert_fuzz_invariants(&result, "json_arbitrary_path", &[]);
}));
}
}
fn static_script_strategy() -> impl Strategy<Value = String> {
let atom = prop_oneof![
Just("echo hello".to_string()),
Just("true".to_string()),
Just("printf '%s' x".to_string()),
Just("echo a | grep a".to_string()),
Just("basename /x/y".to_string()),
Just("echo one > /tmp/p_a".to_string()),
Just("cat /tmp/p_a".to_string()),
Just("if true; then echo t; fi".to_string()),
Just("for i in 1 2; do echo $i; done".to_string()),
Just("echo $(basename /x/y)".to_string()),
Just("V=1 echo $V".to_string()),
];
proptest::collection::vec(atom, 1..6).prop_map(|parts| parts.join("; "))
}
fn opaque_script_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("c=echo; $c hi".to_string()),
Just("$(echo echo) hi".to_string()),
Just("eval \"echo hi\"".to_string()),
Just("bash -c 'echo hi'".to_string()),
Just("sh -c 'echo hi'".to_string()),
Just("bash /tmp/nope.sh".to_string()),
Just(". /tmp/nope.sh".to_string()),
Just("source /tmp/nope.sh".to_string()),
Just("${cmd} hi".to_string()),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(32))]
#[test]
fn analyze_never_panics(input in bash_input_strategy()) {
let _ = bashkit::analysis::analyze(&input);
}
#[test]
fn analyze_never_panics_on_multibyte(input in arithmetic_multibyte_strategy()) {
let _ = bashkit::analysis::analyze(&input);
}
#[test]
fn analyze_is_deterministic(input in bash_input_strategy()) {
let first = bashkit::analysis::analyze(&input);
let second = bashkit::analysis::analyze(&input);
prop_assert_eq!(first.is_ok(), second.is_ok());
if let (Ok(a), Ok(b)) = (first, second) {
prop_assert_eq!(a, b);
}
}
#[test]
fn analyze_command_name_characters_come_from_source(input in bash_input_strategy()) {
if let Ok(analysis) = bashkit::analysis::analyze(&input)
&& !input.contains("$'") {
for command in &analysis.commands {
if let Some(name) = command.name.as_deref() {
let mut source = input.chars();
prop_assert!(name.chars().all(|wanted| source.any(|ch| ch == wanted)));
}
}
}
}
#[test]
fn analyze_respects_the_node_budget(n in 1..600usize) {
let script = "echo x > /tmp/f;".repeat(n);
let analysis = bashkit::analysis::analyze(&script).expect("parses");
let nodes = analysis.commands.len() + analysis.redirects.len();
prop_assert!(nodes <= bashkit::analysis::MAX_ANALYSIS_NODES);
prop_assert_eq!(
analysis.truncated,
nodes == bashkit::analysis::MAX_ANALYSIS_NODES
);
prop_assert!(!analysis.truncated || analysis.is_opaque());
}
#[test]
fn analysis_covers_every_dispatched_command(script in static_script_strategy()) {
thread_local! {
static RT: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
}
let dispatched = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = dispatched.clone();
let mut bash = Bash::builder()
.limits(
ExecutionLimits::new()
.max_commands(200)
.timeout(Duration::from_millis(500)),
)
.before_tool(Box::new(move |event: bashkit::hooks::ToolEvent| {
sink.lock().expect("lock").push(event.name.clone());
bashkit::hooks::HookAction::Continue(event)
}))
.build();
let analysis = bash.analyze(&script).expect("generated script parses");
prop_assert!(!analysis.is_opaque(), "generator emits transparent scripts only");
RT.with(|rt| rt.block_on(async {
let _ = bash.exec(&script).await;
}));
let names = analysis.command_names();
for ran in dispatched.lock().expect("lock").iter() {
prop_assert!(
names.contains(&ran.as_str()),
"`{}` ran but analysis reported {:?} for `{}`",
ran,
names,
script
);
}
}
#[test]
fn runtime_resolved_scripts_are_opaque(script in opaque_script_strategy()) {
let analysis = bashkit::analysis::analyze(&script).expect("parses");
prop_assert!(analysis.is_opaque(), "`{}` must not analyze as transparent", script);
}
}
fn snapshot_bytes_strategy() -> impl Strategy<Value = Vec<u8>> {
prop_oneof![
proptest::collection::vec(any::<u8>(), 0..200),
proptest::collection::vec(any::<u8>(), 32..160),
proptest::collection::vec(any::<u8>(), 0..128).prop_map(|tail| [
vec![0u8; 32],
b"BKSNAP".to_vec(),
tail
]
.concat()),
proptest::collection::vec(any::<u8>(), 0..128).prop_map(|tail| [
vec![0u8; 32],
b"{".to_vec(),
tail
]
.concat()),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn snapshot_decode_never_panics(data in snapshot_bytes_strategy()) {
fuzz_init();
let _ = bashkit::Snapshot::from_bytes(&data);
let _ = bashkit::Snapshot::from_bytes_keyed(&data, b"proptest-key");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut bash = Bash::new();
rt.block_on(async {
bash.exec("echo intact > /intact.txt").await.unwrap();
});
let _ = bash.restore_snapshot_with_policy(&data, bashkit::CheckoutPolicy::Force);
let result = rt.block_on(async { bash.exec("echo alive").await });
prop_assert!(result.is_ok(), "instance unusable after a rejected restore");
prop_assert_eq!(result.unwrap().stdout, "alive\n");
}
#[test]
fn object_id_parsing_never_panics(text in ".{0,80}") {
let _ = bashkit::ObjectId::from_hex(&text);
for (offset, _) in text.char_indices() {
let _ = bashkit::ObjectId::from_hex(&text[offset..]);
}
}
#[test]
fn graph_walks_never_panic_on_a_junk_store(
seed in proptest::collection::vec(any::<u8>(), 32..128),
) {
use std::collections::HashMap;
let mut root_bytes = [0u8; 32];
root_bytes.copy_from_slice(&seed[..32]);
let root = bashkit::ObjectId::from_bytes(root_bytes);
let mut store: HashMap<bashkit::ObjectId, Vec<u8>> = HashMap::new();
store.insert(root, seed[32..].to_vec());
let _ = bashkit::SnapshotGraph::read_commit(root, &store);
let _ = bashkit::SnapshotGraph::parents(root, &store);
let _ = bashkit::SnapshotGraph::meta(root, &store);
let _ = bashkit::SnapshotGraph::capabilities(root, &store);
let _ = bashkit::SnapshotGraph::ancestry(root, &store, 1000);
let _ = bashkit::SnapshotGraph::plan_checkout(root, &store);
let _ = bashkit::SnapshotGraph::reachable(root, &store);
let _ = bashkit::SnapshotGraph::diff(root, root, &store);
let mut bash = Bash::new();
prop_assert!(
bash.checkout(root, &store, bashkit::CheckoutPolicy::Force).is_err(),
"a junk store must never satisfy a checkout"
);
}
}