use crate::{Bash, ControlFlow, ExecResult};
pub const UNIVERSAL_BANNED: &[&str] = &[
"File {",
"path: ()",
"Token(",
"Tok::",
"Undefined::",
"Errors {",
"Vec [",
" { code:",
"Some([",
"Span {",
"Range {",
"/rustc/",
"/.cargo/registry/",
"target/debug/deps/",
"target/release/deps/",
"/.rustup/toolchains/",
];
pub const MAX_STDERR_BYTES: usize = 1024;
pub const FUZZ_HOST_CANARY: &str = "BASHKIT_FUZZ_HOST_CANARY_47a83bcf_DO_NOT_LEAK";
pub fn fuzz_init() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
unsafe {
std::env::set_var("BASHKIT_FUZZ_HOST_SECRET", FUZZ_HOST_CANARY);
}
});
}
pub async fn run(script: &str) -> ExecResult {
let mut bash = Bash::new();
bash.exec(script).await.unwrap_or_else(|e| ExecResult {
stdout: String::new(),
stderr: e.to_string(),
exit_code: 1,
control_flow: ControlFlow::None,
..Default::default()
})
}
pub async fn fuzz_exec(bash: &mut Bash, script: &str, ctx: &str, tool_banned: &[&str]) {
let result = bash.exec(script).await.unwrap_or_else(|e| ExecResult {
stdout: String::new(),
stderr: e.to_string(),
exit_code: 1,
control_flow: ControlFlow::None,
..Default::default()
});
assert_fuzz_invariants(&result, ctx, tool_banned);
}
#[track_caller]
pub fn assert_no_leak(result: &ExecResult, ctx: &str, tool_banned: &[&str]) {
let stderr = &result.stderr;
assert!(
stderr.len() <= MAX_STDERR_BYTES,
"[{ctx}] stderr exceeds {MAX_STDERR_BYTES} bytes ({} bytes):\n---\n{stderr}\n---",
stderr.len()
);
for pat in UNIVERSAL_BANNED.iter().chain(tool_banned.iter()) {
assert!(
!stderr.contains(pat),
"[{ctx}] stderr leaks banned shape `{pat}`:\n---\n{stderr}\n---"
);
}
}
fn strip_real_shell_error_lines(stderr: &str) -> String {
let lines: Vec<&str> = stderr
.lines()
.filter(|line| !is_real_shell_error_line(line))
.collect();
lines.join("\n")
}
fn is_real_shell_error_line(line: &str) -> bool {
const SHELL_ERROR_SUFFIXES: &[&str] = &[
": command not found",
": No such file or directory",
": Is a directory",
": Permission denied",
": cannot execute: required file not found",
": cannot execute binary file",
];
if let Some(rest) = line.strip_prefix("bash: ") {
if SHELL_ERROR_SUFFIXES.iter().any(|suf| rest.ends_with(suf)) {
return true;
}
if rest.ends_with(". Did you mean: ., :, [?") {
return true;
}
return false;
}
if let Some(rest) = line.strip_prefix("ls: ") {
if rest.starts_with("cannot access ")
&& (rest.ends_with(": No such file or directory")
|| rest.ends_with(": Is a directory")
|| rest.ends_with(": Permission denied"))
{
return true;
}
return false;
}
is_clap_error_chrome_line(line)
}
fn is_clap_error_chrome_line(line: &str) -> bool {
if let Some(rest) = line.strip_prefix("error: ") {
const CLAP_ERROR_FRAGMENTS: &[&str] = &[
"unexpected argument '",
"the argument '",
"unrecognized subcommand '",
"the following required arguments were not provided",
"a value is required for '",
"equal sign is needed when assigning values to '",
];
if CLAP_ERROR_FRAGMENTS.iter().any(|frag| rest.contains(frag)) {
return true;
}
return false;
}
if let Some(rest) = line.strip_prefix(" tip: ") {
if rest.starts_with("to pass '") && rest.contains("' as a value, use '") {
return true;
}
return false;
}
if let Some(rest) = line.strip_prefix("Usage: ") {
if rest.contains(" [") || rest.ends_with(" --help") || rest.ends_with(" --version") {
return true;
}
return false;
}
if line.starts_with("For more information, try '") && line.ends_with("'.") {
return true;
}
false
}
#[track_caller]
pub fn assert_fuzz_invariants(result: &ExecResult, ctx: &str, tool_banned: &[&str]) {
let stderr = &result.stderr;
assert!(
stderr.len() <= MAX_STDERR_BYTES,
"[{ctx}] stderr exceeds {MAX_STDERR_BYTES} bytes ({} bytes):\n---\n{stderr}\n---",
stderr.len()
);
let stripped = strip_real_shell_error_lines(stderr);
for pat in UNIVERSAL_BANNED.iter().chain(tool_banned.iter()) {
assert!(
!stripped.contains(pat),
"[{ctx}] stderr leaks banned shape `{pat}` (after stripping shell echoes):\n\
---raw stderr---\n{stderr}\n---stripped---\n{stripped}\n---"
);
}
assert!(
!result.stdout.contains(FUZZ_HOST_CANARY),
"[{ctx}] FUZZ canary leaked into stdout (TM-INF-013 regression — \
a builtin is reading host env). stdout:\n---\n{}\n---",
truncate(&result.stdout, 512)
);
assert!(
!result.stderr.contains(FUZZ_HOST_CANARY),
"[{ctx}] FUZZ canary leaked into stderr (TM-INF-013 regression — \
a builtin is reading host env). stderr:\n---\n{}\n---",
truncate(&result.stderr, 512)
);
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...<truncated>", &s[..max.min(s.len())])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_keeps_unrelated_lines() {
let s = "warning: something\nthread panicked at lib.rs:1\n";
assert_eq!(
strip_real_shell_error_lines(s),
"warning: something\nthread panicked at lib.rs:1"
);
}
#[test]
fn strip_removes_command_not_found() {
let s = "bash: Tok:: command not found\n";
assert_eq!(strip_real_shell_error_lines(s), "");
}
#[test]
fn strip_removes_no_such_file() {
let s = "bash: /.rustup/toolchains/gww: No such file or directory\n";
assert_eq!(strip_real_shell_error_lines(s), "");
}
#[test]
fn strip_removes_did_you_mean_variant() {
let s = "bash: : command not found. Did you mean: ., :, [?\n";
assert_eq!(strip_real_shell_error_lines(s), "");
}
#[test]
fn strip_removes_ls_cannot_access() {
let s = "ls: cannot access '/tmp/==(Span {(;': No such file or directory\n";
assert_eq!(strip_real_shell_error_lines(s), "");
}
#[test]
fn strip_keeps_internal_panic_lines() {
let s = "thread 'fuzz' panicked at parse.rs:42:\nFile { code: \"oops\", path: () }\n";
let stripped = strip_real_shell_error_lines(s);
assert!(stripped.contains("File {"), "stripped: {stripped:?}");
assert!(stripped.contains("path: ()"), "stripped: {stripped:?}");
}
#[test]
fn strip_keeps_partial_matches() {
let s = "bash: something weird Span { not at end\n\
some-other-tool: Tok:: blah\n";
let stripped = strip_real_shell_error_lines(s);
assert!(stripped.contains("Span {"));
assert!(stripped.contains("Tok::"));
}
#[test]
fn strip_handles_multiline_mixed() {
let s = "bash: foo: command not found\n\
bash: /tmp/Span {bar: No such file or directory\n\
thread panicked at runtime.rs:1\n\
ls: cannot access 'baz': No such file or directory\n";
let stripped = strip_real_shell_error_lines(s);
assert!(!stripped.contains("command not found"));
assert!(!stripped.contains("/tmp/Span {"));
assert!(!stripped.contains("cannot access"));
assert!(stripped.contains("thread panicked"));
}
#[test]
fn strip_removes_clap_unexpected_argument_block() {
let s = "error: unexpected argument '--i{fi/rustc/fi{{RRi' found\n\
\n\
\x20\x20tip: to pass '--i{fi/rustc/fi{{RRi' as a value, use '-- --i{fi/rustc/fi{{RRi'\n\
\n\
Usage: ls [OPTION]... [FILE]...\n\
\n\
For more information, try '--help'.\n";
let stripped = strip_real_shell_error_lines(s);
assert!(!stripped.contains("/rustc/"), "stripped: {stripped:?}");
assert!(
!stripped.contains("unexpected argument"),
"stripped: {stripped:?}"
);
assert!(!stripped.contains("Usage:"), "stripped: {stripped:?}");
assert!(
!stripped.contains("For more information"),
"stripped: {stripped:?}"
);
}
#[test]
fn strip_keeps_clap_invalid_value_line() {
let s = "error: invalid value 'Span {abc' for '--width <N>': not a number\n";
let stripped = strip_real_shell_error_lines(s);
assert!(stripped.contains("Span {"), "stripped: {stripped:?}");
}
#[test]
fn strip_keeps_unrelated_error_prefix_lines() {
let s = "error: parser failed: Tok::Ident\n";
let stripped = strip_real_shell_error_lines(s);
assert!(stripped.contains("Tok::"), "stripped: {stripped:?}");
}
#[test]
fn strip_keeps_usage_lookalikes() {
let s = "Usage: see Span { for details\n";
let stripped = strip_real_shell_error_lines(s);
assert!(stripped.contains("Span {"), "stripped: {stripped:?}");
}
}