use async_trait::async_trait;
use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, InMemoryFs};
use std::sync::Arc;
struct ErrorReturner {
message: String,
code: i32,
}
#[async_trait]
impl Builtin for ErrorReturner {
async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
Ok(ExecResult::err(self.message.clone(), self.code))
}
}
struct FatalErrorReturner {
message: String,
}
#[async_trait]
impl Builtin for FatalErrorReturner {
async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
Err(bashkit::Error::Execution(self.message.clone()))
}
}
struct FsErrorReporter;
#[async_trait]
impl Builtin for FsErrorReporter {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
let path = ctx
.args
.first()
.map(|s| s.as_str())
.unwrap_or("/nonexistent");
match ctx.fs.read_file(std::path::Path::new(path)).await {
Ok(_) => Ok(ExecResult::ok("success\n".to_string())),
Err(e) => Ok(ExecResult::err(format!("Error: {}\n", e), 1)),
}
}
}
#[allow(dead_code)]
struct LeakyErrorBuiltin {
actually_leak: bool,
}
#[async_trait]
impl Builtin for LeakyErrorBuiltin {
async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
let ptr = &0 as *const i32;
if self.actually_leak {
Ok(ExecResult::err(
format!("Error at {:p} in /home/user/real/path/to/code.rs:42\n", ptr),
1,
))
} else {
Ok(ExecResult::err("Operation failed\n".to_string(), 1))
}
}
}
struct PanickingBuiltin {
message: String,
}
#[async_trait]
impl Builtin for PanickingBuiltin {
async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
panic!("{}", self.message);
}
}
struct ContentValidator;
#[async_trait]
impl Builtin for ContentValidator {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
let content_type = ctx.args.first().map(|s| s.as_str()).unwrap_or("normal");
match content_type {
"path" => Ok(ExecResult::err(
"Error: /usr/local/lib/internal.so\n".to_string(),
1,
)),
"address" => Ok(ExecResult::err("Error at 0x7fff5fbff8c0\n".to_string(), 1)),
"stacktrace" => Ok(ExecResult::err(
"Error:\n at main (src/main.rs:10)\n at func (src/lib.rs:20)\n".to_string(),
1,
)),
"env" => Ok(ExecResult::err(
format!(
"Error: HOME={}\n",
ctx.env.get("HOME").map(|s| s.as_str()).unwrap_or("")
),
1,
)),
_ => Ok(ExecResult::err("Generic error\n".to_string(), 1)),
}
}
}
struct ArgEcho;
#[async_trait]
impl Builtin for ArgEcho {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
Ok(ExecResult::ok(format!("{}\n", ctx.args.join(" "))))
}
}
#[tokio::test]
async fn builtin_error_basic_error_message() {
let mut bash = Bash::builder()
.builtin(
"err",
Box::new(ErrorReturner {
message: "Something went wrong\n".to_string(),
code: 1,
}),
)
.build();
let result = bash.exec("err").await.unwrap();
assert_eq!(result.stderr, "Something went wrong\n");
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn builtin_error_special_characters_preserved() {
let mut bash = Bash::builder()
.builtin(
"err",
Box::new(ErrorReturner {
message: "Error: <tag> & \"quoted\" 'single'\n".to_string(),
code: 1,
}),
)
.build();
let result = bash.exec("err").await.unwrap();
assert!(result.stderr.contains("<tag>"));
assert!(result.stderr.contains("&"));
assert!(result.stderr.contains("\"quoted\""));
}
#[tokio::test]
async fn builtin_error_custom_exit_codes() {
for code in [0, 1, 2, 42, 126, 127, 128, 255] {
let mut bash = Bash::builder()
.builtin(
"err",
Box::new(ErrorReturner {
message: "error\n".to_string(),
code,
}),
)
.build();
let result = bash.exec("err").await.unwrap();
assert_eq!(result.exit_code, code, "Exit code {} not propagated", code);
}
}
#[tokio::test]
async fn builtin_error_no_host_path_leak_in_fs_errors() {
let mut bash = Bash::builder()
.builtin("fscheck", Box::new(FsErrorReporter))
.build();
let result = bash.exec("fscheck /etc/passwd").await.unwrap();
assert!(
!result.stderr.contains("/home/") || result.stderr.contains("/home/user"),
"Error should not leak real home paths"
);
assert!(
!result.stderr.contains(".cargo"),
"Error should not contain cargo paths"
);
assert!(
!result.stderr.contains(".rustup"),
"Error should not contain rustup paths"
);
}
#[tokio::test]
async fn builtin_error_no_memory_address_leak() {
let mut bash = Bash::builder()
.builtin("validate", Box::new(ContentValidator))
.build();
let result = bash.exec("validate address").await.unwrap();
let has_hex_address =
result.stderr.contains("0x") && result.stderr.chars().any(|c| c.is_ascii_hexdigit());
if has_hex_address {
println!(
"WARNING: Error message contains memory address: {}",
result.stderr
);
}
}
#[tokio::test]
async fn builtin_error_consistent_error_format() {
let mut bash = Bash::new();
let result = bash.exec("cat /nonexistent/file").await.unwrap();
assert!(result.exit_code != 0);
assert!(
!result.stderr.is_empty() || result.exit_code != 0,
"Should indicate error"
);
let result = bash.exec("cd /nonexistent/dir").await.unwrap();
assert!(result.exit_code != 0);
let result = bash.exec("mkdir").await.unwrap();
assert!(result.exit_code != 0);
assert!(
result.stderr.contains("mkdir") || result.stderr.contains("missing"),
"Error should mention the command"
);
}
#[tokio::test]
async fn builtin_error_fatal_error_propagation() {
let mut bash = Bash::builder()
.builtin(
"fatal",
Box::new(FatalErrorReturner {
message: "Critical failure occurred".to_string(),
}),
)
.build();
let result = bash.exec("fatal").await;
assert!(result.is_err(), "Fatal error should propagate as Err");
let err = result.unwrap_err();
assert!(
err.to_string().contains("Critical failure"),
"Error message should be preserved"
);
}
#[tokio::test]
async fn builtin_error_fatal_error_no_internal_state_leak() {
let mut bash = Bash::builder()
.builtin(
"fatal",
Box::new(FatalErrorReturner {
message: "Operation failed".to_string(),
}),
)
.build();
bash.exec("SECRET=password123").await.unwrap();
bash.exec("INTERNAL_STATE=sensitive_data").await.unwrap();
let result = bash.exec("fatal").await;
let err_msg = result.unwrap_err().to_string();
assert!(
!err_msg.contains("password123"),
"Error should not contain variable values"
);
assert!(
!err_msg.contains("sensitive_data"),
"Error should not contain internal state"
);
}
#[tokio::test]
async fn builtin_error_in_conditional_context() {
let mut bash = Bash::builder()
.builtin(
"fail",
Box::new(ErrorReturner {
message: "error\n".to_string(),
code: 1,
}),
)
.build();
let result = bash
.exec("if fail; then echo yes; else echo no; fi")
.await
.unwrap();
assert!(result.stdout.contains("no"));
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn builtin_error_in_pipeline_context() {
let mut bash = Bash::builder()
.builtin(
"fail",
Box::new(ErrorReturner {
message: "pipeline error\n".to_string(),
code: 1,
}),
)
.build();
let result = bash.exec("echo test | fail").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("pipeline error"));
}
#[tokio::test]
async fn builtin_error_or_operator_fallback() {
let mut bash = Bash::builder()
.builtin(
"fail",
Box::new(ErrorReturner {
message: "primary failed\n".to_string(),
code: 1,
}),
)
.build();
let result = bash.exec("fail || echo fallback").await.unwrap();
assert!(result.stdout.contains("fallback"));
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn builtin_error_and_operator_short_circuit() {
let mut bash = Bash::builder()
.builtin(
"fail",
Box::new(ErrorReturner {
message: "first failed\n".to_string(),
code: 1,
}),
)
.build();
let result = bash.exec("fail && echo should_not_run").await.unwrap();
assert!(!result.stdout.contains("should_not_run"));
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn builtin_error_exit_status_variable() {
let mut bash = Bash::builder()
.builtin(
"fail42",
Box::new(ErrorReturner {
message: "error\n".to_string(),
code: 42,
}),
)
.build();
let result = bash.exec("fail42; echo $?").await.unwrap();
assert!(result.stdout.contains("42"));
}
#[tokio::test]
async fn builtin_error_multiple_builtins_isolated() {
let mut bash = Bash::builder()
.builtin(
"err1",
Box::new(ErrorReturner {
message: "error one\n".to_string(),
code: 1,
}),
)
.builtin(
"err2",
Box::new(ErrorReturner {
message: "error two\n".to_string(),
code: 2,
}),
)
.builtin("echo_args", Box::new(ArgEcho))
.build();
let result = bash.exec("err1").await.unwrap();
assert!(result.stderr.contains("error one"));
assert!(!result.stderr.contains("error two"));
let result = bash.exec("err2").await.unwrap();
assert!(result.stderr.contains("error two"));
assert!(!result.stderr.contains("error one"));
let result = bash.exec("echo_args hello").await.unwrap();
assert_eq!(result.stdout, "hello\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn builtin_error_script_continues_after_error() {
let mut bash = Bash::builder()
.builtin(
"fail",
Box::new(ErrorReturner {
message: "error\n".to_string(),
code: 1,
}),
)
.build();
let result = bash.exec("fail; echo continued").await.unwrap();
assert!(result.stdout.contains("continued"));
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn builtin_error_with_errexit() {
let mut bash = Bash::builder()
.builtin(
"fail",
Box::new(ErrorReturner {
message: "error\n".to_string(),
code: 1,
}),
)
.build();
let result = bash
.exec("set -e; fail; echo should_not_run")
.await
.unwrap();
assert!(!result.stdout.contains("should_not_run"));
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn builtin_error_empty_message() {
let mut bash = Bash::builder()
.builtin(
"err_empty",
Box::new(ErrorReturner {
message: String::new(),
code: 1,
}),
)
.build();
let result = bash.exec("err_empty").await.unwrap();
assert_eq!(result.exit_code, 1);
assert_eq!(result.stderr, "");
}
#[tokio::test]
async fn builtin_error_long_message() {
let long_msg = "x".repeat(10000) + "\n";
let mut bash = Bash::builder()
.builtin(
"err_long",
Box::new(ErrorReturner {
message: long_msg.clone(),
code: 1,
}),
)
.build();
let result = bash.exec("err_long").await.unwrap();
assert_eq!(result.exit_code, 1);
assert_eq!(result.stderr, long_msg);
}
#[tokio::test]
async fn builtin_error_unicode_message() {
let mut bash = Bash::builder()
.builtin(
"err_unicode",
Box::new(ErrorReturner {
message: "错误: 操作失败 🚫\n".to_string(),
code: 1,
}),
)
.build();
let result = bash.exec("err_unicode").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("错误"));
assert!(result.stderr.contains("🚫"));
}
#[tokio::test]
async fn builtin_error_multiline_message() {
let mut bash = Bash::builder()
.builtin(
"err_multi",
Box::new(ErrorReturner {
message: "Line 1\nLine 2\nLine 3\n".to_string(),
code: 1,
}),
)
.build();
let result = bash.exec("err_multi").await.unwrap();
assert_eq!(result.exit_code, 1);
assert_eq!(result.stderr.lines().count(), 3);
}
#[tokio::test]
async fn builtin_error_no_host_env_leak() {
let mut bash = Bash::builder()
.builtin("validate", Box::new(ContentValidator))
.build();
let result = bash.exec("validate env").await.unwrap();
let home_from_process = std::env::var("HOME").unwrap_or_default();
assert!(
result.stderr.contains("HOME=\n") || result.stderr.contains("HOME="),
"Error should show HOME with empty value"
);
if !home_from_process.is_empty() {
assert!(
!result.stderr.contains(&home_from_process),
"Host HOME '{}' should not be leaked in error: {}",
home_from_process,
result.stderr
);
}
}
#[tokio::test]
async fn builtin_error_uses_virtual_fs() {
let fs = Arc::new(InMemoryFs::new());
let mut bash = Bash::builder()
.fs(fs)
.builtin("fscheck", Box::new(FsErrorReporter))
.build();
bash.exec("echo 'test' > /tmp/test.txt").await.unwrap();
let result = bash.exec("fscheck /tmp/test.txt").await.unwrap();
assert_eq!(result.exit_code, 0);
let result = bash.exec("fscheck /etc/shadow").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(
result.stderr.contains("/etc/shadow") || result.stderr.contains("Error"),
"Error should reference requested path or generic error"
);
}
#[tokio::test]
async fn builtin_error_no_state_accumulation() {
let mut bash = Bash::builder()
.builtin(
"err",
Box::new(ErrorReturner {
message: "error\n".to_string(),
code: 1,
}),
)
.build();
for _ in 0..10 {
let result = bash.exec("err").await.unwrap();
assert_eq!(result.exit_code, 1);
assert_eq!(result.stderr.matches("error").count(), 1);
}
}
#[tokio::test]
async fn builtin_error_subshell_isolation() {
let mut bash = Bash::builder()
.builtin(
"fail",
Box::new(ErrorReturner {
message: "subshell error\n".to_string(),
code: 1,
}),
)
.build();
let result = bash.exec("(fail); echo parent").await.unwrap();
assert!(result.stdout.contains("parent"));
}
#[tokio::test]
async fn builtin_error_cat_file_not_found() {
let mut bash = Bash::new();
let result = bash.exec("cat /nonexistent/file.txt").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(
result.stderr.contains("cat") || result.stderr.contains("nonexistent"),
"Error should be informative"
);
}
#[tokio::test]
async fn builtin_error_mkdir_missing_operand() {
let mut bash = Bash::new();
let result = bash.exec("mkdir").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(
result.stderr.contains("mkdir") || result.stderr.contains("operand"),
"Error should mention mkdir"
);
}
#[tokio::test]
async fn builtin_error_grep_no_match() {
let mut bash = Bash::new();
let result = bash.exec("echo test | grep nonexistent").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stdout.is_empty());
}
#[tokio::test]
async fn builtin_error_command_not_found_format() {
let mut bash = Bash::new();
let result = bash.exec("nonexistent_command").await.unwrap();
assert_eq!(result.exit_code, 127);
assert!(result.stderr.contains("command not found"));
assert!(result.stderr.contains("nonexistent_command"));
}
#[tokio::test]
async fn builtin_error_panic_is_caught() {
let mut bash = Bash::builder()
.builtin(
"panic_cmd",
Box::new(PanickingBuiltin {
message: "internal panic with SECRET_KEY=abc123".to_string(),
}),
)
.build();
let result = bash.exec("panic_cmd").await;
match result {
Ok(r) => {
assert!(r.exit_code != 0, "Panic should result in non-zero exit");
assert!(
!r.stderr.contains("SECRET_KEY"),
"Panic message should not expose secrets: {}",
r.stderr
);
assert!(
!r.stderr.contains("abc123"),
"Panic message should not expose secrets: {}",
r.stderr
);
}
Err(e) => {
let err_msg = e.to_string();
assert!(
!err_msg.contains("SECRET_KEY"),
"Error should not expose secrets: {}",
err_msg
);
assert!(
!err_msg.contains("abc123"),
"Error should not expose secrets: {}",
err_msg
);
}
}
}
#[tokio::test]
async fn builtin_error_panic_no_stack_trace() {
let mut bash = Bash::builder()
.builtin(
"panic_cmd",
Box::new(PanickingBuiltin {
message: "simple panic".to_string(),
}),
)
.build();
let result = bash.exec("panic_cmd").await;
let error_text = match &result {
Ok(r) => r.stderr.clone(),
Err(e) => e.to_string(),
};
assert!(
!error_text.contains("at src/"),
"Should not expose source paths"
);
assert!(
!error_text.contains(".rs:"),
"Should not expose Rust file locations"
);
assert!(
!error_text.contains("stack backtrace"),
"Should not expose stack backtrace"
);
}
#[tokio::test]
async fn builtin_error_panic_script_continues() {
let mut bash = Bash::builder()
.builtin(
"panic_cmd",
Box::new(PanickingBuiltin {
message: "expected panic".to_string(),
}),
)
.build();
let result = bash.exec("panic_cmd; echo continued").await;
match result {
Ok(r) => {
if r.stdout.contains("continued") {
assert_eq!(r.exit_code, 0, "Script should complete successfully");
}
}
Err(_) => {
}
}
}
#[tokio::test]
async fn builtin_error_panic_in_conditional() {
let mut bash = Bash::builder()
.builtin(
"panic_cmd",
Box::new(PanickingBuiltin {
message: "conditional panic".to_string(),
}),
)
.build();
let result = bash
.exec("if panic_cmd; then echo yes; else echo no; fi")
.await;
match result {
Ok(r) => {
if !r.stdout.is_empty() {
assert!(r.stdout.contains("no"), "Should take else branch on panic");
}
}
Err(_) => {
}
}
}
#[tokio::test]
async fn builtin_error_panic_or_fallback() {
let mut bash = Bash::builder()
.builtin(
"panic_cmd",
Box::new(PanickingBuiltin {
message: "fallback test".to_string(),
}),
)
.build();
let result = bash.exec("panic_cmd || echo fallback").await;
match result {
Ok(r) => {
if r.stdout.contains("fallback") {
assert_eq!(r.exit_code, 0);
}
}
Err(_) => {
}
}
}
#[tokio::test]
async fn builtin_error_leaky_message_bad_pattern() {
let mut bash = Bash::builder()
.builtin(
"leaky",
Box::new(LeakyErrorBuiltin {
actually_leak: true,
}),
)
.builtin(
"good",
Box::new(LeakyErrorBuiltin {
actually_leak: false,
}),
)
.build();
let good_result = bash.exec("good").await.unwrap();
assert_eq!(good_result.stderr, "Operation failed\n");
assert!(!good_result.stderr.contains("0x"));
let bad_result = bash.exec("leaky").await.unwrap();
assert!(
bad_result.stderr.contains("0x") || bad_result.stderr.contains("/home/"),
"Leaky builtin exposes internal details (this is BAD)"
);
}
#[tokio::test]
async fn date_invalid_format_returns_error_not_panic() {
let mut bash = Bash::new();
let result = bash.exec("date '+%Q'").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(
result.stderr.contains("invalid format string"),
"Should provide human-readable error: {}",
result.stderr
);
assert!(result.stdout.is_empty());
}
#[tokio::test]
async fn date_incomplete_format_returns_error() {
let mut bash = Bash::new();
let result = bash.exec("date '+%Y-%m-%'").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(
result.stderr.contains("invalid format string"),
"Should provide error for incomplete format: {}",
result.stderr
);
}
#[tokio::test]
async fn date_valid_formats_work() {
let mut bash = Bash::new();
let result = bash.exec("date '+%Y-%m-%d'").await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.len() >= 10);
let result = bash.exec("date '+%s'").await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.trim().parse::<i64>().is_ok());
}
#[tokio::test]
async fn date_error_messages_are_safe() {
let mut bash = Bash::new();
let result = bash.exec("date '+%Q'").await.unwrap();
assert!(!result.stderr.contains("panic"));
assert!(!result.stderr.contains("unwrap"));
assert!(!result.stderr.contains("chrono"));
assert!(!result.stderr.contains("0x")); }
#[tokio::test]
async fn date_error_allows_script_to_continue() {
let mut bash = Bash::new();
let result = bash.exec("date '+%Q'; echo 'continued'").await.unwrap();
assert!(
result.stdout.contains("continued"),
"Script should continue after date error"
);
}