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", &[]);
}));
}
}