#![warn(clippy::unwrap_used)]
#![cfg_attr(test, allow(clippy::unwrap_used))]
mod builtins;
#[cfg(feature = "http_client")]
mod credential;
mod error;
mod fs;
pub mod hooks;
#[cfg(feature = "interop")]
pub mod interop;
mod interpreter;
mod limits;
#[cfg(feature = "logging")]
mod logging_impl;
mod network;
pub mod parser;
#[cfg(feature = "scripted_tool")]
pub mod scripted_tool;
mod snapshot;
#[doc(hidden)]
pub mod testing;
pub mod tool;
#[cfg(feature = "scripted_tool")]
pub(crate) mod tool_def;
pub mod trace;
pub use async_trait::async_trait;
pub use builtins::git::GitConfig;
pub use builtins::ssh::{SshAllowlist, SshConfig, TrustedHostKey};
pub use builtins::{
BashkitContext, Builtin, ClapBuiltin, Context as BuiltinContext, ExecutionExtensions, Extension,
};
pub use clap;
#[cfg(feature = "http_client")]
pub use credential::Credential;
pub use error::{Error, Result};
pub use fs::{
DirEntry, FileSystem, FileSystemExt, FileType, FsBackend, FsLimitExceeded, FsLimits, FsUsage,
InMemoryFs, LazyLoader, Metadata, MountableFs, OverlayFs, PosixFs, SearchCapabilities,
SearchCapable, SearchMatch, SearchProvider, SearchQuery, SearchResults, VfsSnapshot,
normalize_path, verify_filesystem_requirements,
};
#[cfg(feature = "realfs")]
pub use fs::{RealFs, RealFsMode};
pub use interpreter::{
ControlFlow, ExecResult, HistoryEntry, OutputCallback, ShellState, ShellStateView,
};
pub use limits::{
ExecutionCounters, ExecutionLimits, LimitExceeded, MemoryBudget, MemoryLimits, SessionLimits,
};
pub use network::NetworkAllowlist;
pub use snapshot::{Snapshot, SnapshotOptions};
pub use tool::BashToolBuilder as ToolBuilder;
pub use tool::{
BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput,
ToolOutputChunk, ToolOutputMetadata, ToolRequest, ToolResponse, ToolService, ToolStatus,
VERSION,
};
pub use trace::{
TraceCallback, TraceCollector, TraceEvent, TraceEventDetails, TraceEventKind, TraceMode,
};
#[cfg(feature = "scripted_tool")]
pub use scripted_tool::{
AsyncToolCallback, CallbackKind, DiscoverTool, DiscoveryMode, ScriptedCommandInvocation,
ScriptedCommandKind, ScriptedExecutionTrace, ScriptedTool, ScriptedToolBuilder,
ScriptingToolSet, ScriptingToolSetBuilder, ToolArgs, ToolCallback, ToolDef, ToolDefExtension,
ToolDefExtensionBuilder,
};
#[cfg(feature = "scripted_tool")]
pub use tool_def::{AsyncToolExec, SyncToolExec, ToolImpl};
#[cfg(feature = "http_client")]
pub use network::{HttpClient, HttpHandler};
#[cfg(feature = "http_client")]
pub use network::Response as HttpResponse;
#[cfg(feature = "bot-auth")]
pub use network::{BotAuthConfig, BotAuthError, BotAuthPublicKey, derive_bot_auth_public_key};
#[cfg(feature = "git")]
pub use builtins::git::GitClient;
#[cfg(feature = "ssh")]
pub use builtins::ssh::{SshClient, SshHandler, SshOutput, SshTarget};
#[cfg(feature = "python")]
pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits};
#[cfg(feature = "sqlite")]
pub use builtins::{Sqlite, SqliteBackend, SqliteLimits};
#[cfg(feature = "python")]
pub use monty::{ExcType, ExtFunctionResult, MontyException, MontyObject};
#[cfg(feature = "typescript")]
pub use builtins::{
TypeScriptConfig, TypeScriptExtension, TypeScriptExternalFnHandler, TypeScriptExternalFns,
TypeScriptLimits,
};
#[cfg(feature = "typescript")]
pub use zapcode_core::Value as ZapcodeValue;
#[cfg(feature = "logging")]
pub mod logging {
pub use crate::logging_impl::{
LogConfig, format_error_for_log, format_script_for_log, sanitize_for_log,
};
}
#[cfg(feature = "logging")]
pub use logging::LogConfig;
use interpreter::Interpreter;
use parser::Parser;
use std::collections::HashMap;
#[cfg(feature = "realfs")]
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
#[cfg(any(feature = "python", feature = "sqlite"))]
fn env_opt_in_enabled(env: &HashMap<String, String>, key: &str) -> bool {
env.get(key)
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
}
pub struct Bash {
fs: Arc<dyn FileSystem>,
mountable: Arc<MountableFs>,
interpreter: Interpreter,
parser_timeout: std::time::Duration,
max_input_bytes: usize,
max_ast_depth: usize,
max_parser_operations: usize,
#[cfg(feature = "logging")]
log_config: logging::LogConfig,
#[cfg(feature = "python")]
python_inprocess_opt_in: bool,
#[cfg(feature = "sqlite")]
sqlite_inprocess_opt_in: bool,
}
impl Default for Bash {
fn default() -> Self {
Self::new()
}
}
impl Bash {
pub fn new() -> Self {
let base_fs: Arc<dyn FileSystem> = Arc::new(InMemoryFs::new());
let mountable = Arc::new(MountableFs::new(base_fs));
let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
let interpreter = Interpreter::new(Arc::clone(&fs));
let parser_timeout = ExecutionLimits::default().parser_timeout;
let max_input_bytes = ExecutionLimits::default().max_input_bytes;
let max_ast_depth = ExecutionLimits::default().max_ast_depth;
let max_parser_operations = ExecutionLimits::default().max_parser_operations;
Self {
fs,
mountable,
interpreter,
parser_timeout,
max_input_bytes,
max_ast_depth,
max_parser_operations,
#[cfg(feature = "logging")]
log_config: logging::LogConfig::default(),
#[cfg(feature = "python")]
python_inprocess_opt_in: false,
#[cfg(feature = "sqlite")]
sqlite_inprocess_opt_in: false,
}
}
pub fn builder() -> BashBuilder {
BashBuilder::default()
}
pub async fn exec(&mut self, script: &str) -> Result<ExecResult> {
self.exec_with_extensions(script, ExecutionExtensions::new())
.await
}
pub async fn exec_with_extensions(
&mut self,
script: &str,
mut extensions: ExecutionExtensions,
) -> Result<ExecResult> {
let _ = extensions.insert(self.interpreter.limits().clone());
#[cfg(feature = "python")]
let _ = extensions.insert(builtins::PythonInprocessOptIn(self.python_inprocess_opt_in));
#[cfg(feature = "sqlite")]
let _ = extensions.insert(builtins::SqliteInprocessOptIn(self.sqlite_inprocess_opt_in));
let _extensions_guard = self.interpreter.scoped_execution_extensions(extensions);
self.exec_impl(script).await
}
async fn exec_impl(&mut self, script: &str) -> Result<ExecResult> {
self.interpreter.reset_transient_state();
let input_len = script.len();
if input_len > self.max_input_bytes {
#[cfg(feature = "logging")]
tracing::error!(
target: "bashkit::session",
input_len = input_len,
max_bytes = self.max_input_bytes,
"Script exceeds maximum input size"
);
return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
input_len,
self.max_input_bytes,
)));
}
#[cfg(feature = "logging")]
{
let script_info = logging::format_script_for_log(script, &self.log_config);
tracing::info!(target: "bashkit::session", script = %script_info, "Starting script execution");
}
let script = if !self.interpreter.hooks().before_exec.is_empty() {
let input = hooks::ExecInput {
script: script.to_string(),
};
match self.interpreter.hooks().fire_before_exec(input) {
Some(modified) => std::borrow::Cow::Owned(modified.script),
None => {
return Ok(ExecResult::err("cancelled by before_exec hook", 1));
}
}
} else {
std::borrow::Cow::Borrowed(script)
};
let script = script.as_ref();
let input_len = script.len();
if input_len > self.max_input_bytes {
#[cfg(feature = "logging")]
tracing::error!(
target: "bashkit::session",
input_len = input_len,
max_bytes = self.max_input_bytes,
"Script exceeds maximum input size"
);
return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
input_len,
self.max_input_bytes,
)));
}
let parser_timeout = self.parser_timeout;
let max_ast_depth = self.max_ast_depth;
let max_parser_operations = self.max_parser_operations;
let script_owned = script.to_owned();
#[cfg(feature = "logging")]
tracing::debug!(
target: "bashkit::parser",
input_len = input_len,
max_ast_depth = max_ast_depth,
max_operations = max_parser_operations,
"Parsing script"
);
#[cfg(target_family = "wasm")]
let ast = {
let parser = Parser::with_limits_and_timeout(
&script_owned,
max_ast_depth,
max_parser_operations,
Some(parser_timeout),
);
parser.parse()?
};
#[cfg(not(target_family = "wasm"))]
let ast = {
let parse_result = tokio::time::timeout(parser_timeout, async {
tokio::task::spawn_blocking(move || {
let parser =
Parser::with_limits(&script_owned, max_ast_depth, max_parser_operations);
parser.parse()
})
.await
})
.await;
match parse_result {
Ok(Ok(result)) => {
match &result {
Ok(_) => {
#[cfg(feature = "logging")]
tracing::debug!(target: "bashkit::parser", "Parse completed successfully");
}
Err(_e) => {
#[cfg(feature = "logging")]
tracing::warn!(target: "bashkit::parser", error = %_e, "Parse error");
}
}
result?
}
Ok(Err(join_error)) => {
#[cfg(feature = "logging")]
tracing::error!(
target: "bashkit::parser",
error = %join_error,
"Parser task failed"
);
return Err(Error::parse(format!("parser task failed: {}", join_error)));
}
Err(_elapsed) => {
#[cfg(feature = "logging")]
tracing::error!(
target: "bashkit::parser",
timeout_ms = parser_timeout.as_millis() as u64,
"Parser timeout exceeded"
);
return Err(Error::ResourceLimit(LimitExceeded::ParserTimeout(
parser_timeout,
)));
}
}
};
#[cfg(feature = "logging")]
tracing::debug!(target: "bashkit::interpreter", "Starting interpretation");
parser::validate_budget(&ast, self.interpreter.limits())
.map_err(|e| Error::Execution(format!("budget validation failed: {e}")))?;
self.interpreter.load_history().await;
let exec_start = std::time::Instant::now();
let execution_timeout = self.interpreter.limits().timeout;
#[cfg(not(target_family = "wasm"))]
let result =
match tokio::time::timeout(execution_timeout, self.interpreter.execute(&ast)).await {
Ok(r) => r,
Err(_elapsed) => Err(Error::ResourceLimit(LimitExceeded::Timeout(
execution_timeout,
))),
};
#[cfg(target_family = "wasm")]
let result = self.interpreter.execute(&ast).await;
self.interpreter.cleanup_proc_sub_files().await;
let duration_ms = exec_start.elapsed().as_millis() as u64;
if let Ok(ref exec_result) = result {
let cwd = self.interpreter.cwd().to_string_lossy().to_string();
let timestamp = chrono::Utc::now().timestamp();
for line in script.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
self.interpreter.record_history(
trimmed.to_string(),
timestamp,
cwd.clone(),
exec_result.exit_code,
duration_ms,
);
}
}
self.interpreter.save_history().await;
}
#[cfg(feature = "logging")]
match &result {
Ok(exec_result) => {
tracing::info!(
target: "bashkit::session",
exit_code = exec_result.exit_code,
stdout_len = exec_result.stdout.len(),
stderr_len = exec_result.stderr.len(),
"Script execution completed"
);
}
Err(e) => {
let error = logging::format_error_for_log(&e.to_string(), &self.log_config);
tracing::error!(
target: "bashkit::session",
error = %error,
"Script execution failed"
);
}
}
if let Ok(ref exec_result) = result
&& !self.interpreter.hooks().after_exec.is_empty()
{
let output = hooks::ExecOutput {
script: script.to_string(),
stdout: exec_result.stdout.clone(),
stderr: exec_result.stderr.clone(),
exit_code: exec_result.exit_code,
};
self.interpreter.hooks().fire_after_exec(output);
}
if let Err(ref e) = result
&& !self.interpreter.hooks().on_error.is_empty()
{
let error_event = hooks::ErrorEvent {
message: e.to_string(),
};
self.interpreter.hooks().fire_on_error(error_event);
}
result
}
pub async fn exec_streaming(
&mut self,
script: &str,
output_callback: OutputCallback,
) -> Result<ExecResult> {
self.exec_streaming_with_extensions(script, output_callback, ExecutionExtensions::new())
.await
}
pub async fn exec_streaming_with_extensions(
&mut self,
script: &str,
output_callback: OutputCallback,
extensions: ExecutionExtensions,
) -> Result<ExecResult> {
self.interpreter.set_output_callback(output_callback);
let result = self.exec_with_extensions(script, extensions).await;
self.interpreter.clear_output_callback();
result
}
pub fn cancellation_token(&self) -> Arc<std::sync::atomic::AtomicBool> {
self.interpreter.cancellation_token()
}
pub fn hooks(&self) -> &hooks::Hooks {
self.interpreter.hooks()
}
pub fn fs(&self) -> Arc<dyn FileSystem> {
Arc::clone(&self.fs)
}
pub fn mount(
&self,
vfs_path: impl AsRef<std::path::Path>,
fs: Arc<dyn FileSystem>,
) -> Result<()> {
self.mountable.mount(vfs_path, fs)
}
pub fn unmount(&self, vfs_path: impl AsRef<std::path::Path>) -> Result<()> {
self.mountable.unmount(vfs_path)
}
pub fn shell_state(&self) -> ShellState {
self.interpreter.shell_state()
}
pub fn shell_state_view(&self) -> ShellStateView {
self.interpreter.shell_state_view()
}
pub fn restore_shell_state(&mut self, state: &ShellState) {
self.interpreter.restore_shell_state(state);
}
pub fn session_counters(&self) -> (u64, u64) {
let c = self.interpreter.counters();
(c.session_commands, c.session_exec_calls)
}
pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
self.interpreter
.restore_session_counters(session_commands, session_exec_calls);
}
}
struct MountedFile {
path: PathBuf,
content: String,
mode: u32,
}
struct MountedLazyFile {
path: PathBuf,
size_hint: u64,
mode: u32,
loader: LazyLoader,
}
#[cfg(feature = "realfs")]
struct MountedRealDir {
host_path: PathBuf,
vfs_mount: Option<PathBuf>,
mode: fs::RealFsMode,
}
#[derive(Default)]
pub struct BashBuilder {
fs: Option<Arc<dyn FileSystem>>,
env: HashMap<String, String>,
cwd: Option<PathBuf>,
limits: ExecutionLimits,
session_limits: SessionLimits,
memory_limits: MemoryLimits,
trace_mode: TraceMode,
trace_callback: Option<TraceCallback>,
username: Option<String>,
hostname: Option<String>,
fixed_epoch: Option<i64>,
shell_profile: interpreter::ShellProfile,
custom_builtins: HashMap<String, Box<dyn Builtin>>,
mounted_files: Vec<MountedFile>,
mounted_lazy_files: Vec<MountedLazyFile>,
#[cfg(feature = "http_client")]
network_allowlist: Option<NetworkAllowlist>,
#[cfg(feature = "http_client")]
http_handler: Option<Box<dyn network::HttpHandler>>,
#[cfg(feature = "bot-auth")]
bot_auth_config: Option<network::BotAuthConfig>,
#[cfg(feature = "logging")]
log_config: Option<logging::LogConfig>,
#[cfg(feature = "git")]
git_config: Option<GitConfig>,
#[cfg(feature = "ssh")]
ssh_config: Option<SshConfig>,
#[cfg(feature = "ssh")]
ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
#[cfg(feature = "realfs")]
real_mounts: Vec<MountedRealDir>,
#[cfg(feature = "realfs")]
mount_path_allowlist: Option<Vec<PathBuf>>,
history_file: Option<PathBuf>,
hooks_on_exit: Vec<hooks::Interceptor<hooks::ExitEvent>>,
hooks_before_exec: Vec<hooks::Interceptor<hooks::ExecInput>>,
hooks_after_exec: Vec<hooks::Interceptor<hooks::ExecOutput>>,
hooks_before_tool: Vec<hooks::Interceptor<hooks::ToolEvent>>,
hooks_after_tool: Vec<hooks::Interceptor<hooks::ToolResult>>,
hooks_on_error: Vec<hooks::Interceptor<hooks::ErrorEvent>>,
#[cfg(feature = "http_client")]
hooks_before_http: Vec<hooks::Interceptor<hooks::HttpRequestEvent>>,
#[cfg(feature = "http_client")]
hooks_after_http: Vec<hooks::Interceptor<hooks::HttpResponseEvent>>,
#[cfg(feature = "http_client")]
credential_policy: Option<credential::CredentialPolicy>,
}
impl BashBuilder {
pub fn fs(mut self, fs: Arc<dyn FileSystem>) -> Self {
self.fs = Some(fs);
self
}
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn limits(mut self, limits: ExecutionLimits) -> Self {
self.limits = limits;
self
}
#[cfg(feature = "scripted_tool")]
pub(crate) fn logic_only(mut self) -> Self {
self.shell_profile = interpreter::ShellProfile::LogicOnly;
self
}
pub fn session_limits(mut self, limits: SessionLimits) -> Self {
self.session_limits = limits;
self
}
pub fn memory_limits(mut self, limits: MemoryLimits) -> Self {
self.memory_limits = limits;
self
}
pub fn max_memory(self, bytes: usize) -> Self {
let defaults = MemoryLimits::default();
self.memory_limits(
MemoryLimits::new()
.max_total_variable_bytes(bytes)
.max_function_body_bytes(bytes.min(defaults.max_function_body_bytes)),
)
}
pub fn trace_mode(mut self, mode: TraceMode) -> Self {
self.trace_mode = mode;
self
}
pub fn on_trace_event(mut self, callback: TraceCallback) -> Self {
self.trace_callback = Some(callback);
self
}
pub fn username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}
pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
self.hostname = Some(hostname.into());
self
}
pub fn tty(mut self, fd: u32, is_terminal: bool) -> Self {
if is_terminal {
self.env.insert(format!("_TTY_{}", fd), "1".to_string());
}
self
}
pub fn fixed_epoch(mut self, epoch: i64) -> Self {
self.fixed_epoch = Some(epoch);
self
}
pub fn history_file(mut self, path: impl Into<PathBuf>) -> Self {
self.history_file = Some(path.into());
self
}
#[cfg(feature = "http_client")]
pub fn network(mut self, allowlist: NetworkAllowlist) -> Self {
self.network_allowlist = Some(allowlist);
self
}
#[cfg(feature = "http_client")]
pub fn http_handler(mut self, handler: Box<dyn network::HttpHandler>) -> Self {
self.http_handler = Some(handler);
self
}
#[cfg(feature = "bot-auth")]
pub fn bot_auth(mut self, config: network::BotAuthConfig) -> Self {
self.bot_auth_config = Some(config);
self
}
#[cfg(feature = "logging")]
pub fn log_config(mut self, config: logging::LogConfig) -> Self {
self.log_config = Some(config);
self
}
#[cfg(feature = "git")]
pub fn git(mut self, config: GitConfig) -> Self {
self.git_config = Some(config);
self
}
#[cfg(feature = "ssh")]
pub fn ssh(mut self, config: SshConfig) -> Self {
self.ssh_config = Some(config);
self
}
#[cfg(feature = "ssh")]
pub fn ssh_handler(mut self, handler: Box<dyn builtins::ssh::SshHandler>) -> Self {
self.ssh_handler = Some(handler);
self
}
#[cfg(feature = "python")]
pub fn python(self) -> Self {
self.python_with_limits(builtins::PythonLimits::default())
}
#[cfg(feature = "sqlite")]
pub fn sqlite(self) -> Self {
self.sqlite_with_limits(builtins::SqliteLimits::default())
}
#[cfg(feature = "sqlite")]
pub fn sqlite_with_limits(self, limits: builtins::SqliteLimits) -> Self {
self.builtin(
"sqlite",
Box::new(builtins::Sqlite::with_limits(limits.clone())),
)
.builtin("sqlite3", Box::new(builtins::Sqlite::with_limits(limits)))
}
#[cfg(feature = "python")]
pub fn python_with_limits(self, limits: builtins::PythonLimits) -> Self {
self.builtin(
"python",
Box::new(builtins::Python::with_limits(limits.clone())),
)
.builtin("python3", Box::new(builtins::Python::with_limits(limits)))
}
#[cfg(feature = "python")]
pub fn python_with_external_handler(
self,
limits: builtins::PythonLimits,
external_fns: Vec<String>,
handler: builtins::PythonExternalFnHandler,
) -> Self {
self.builtin(
"python",
Box::new(
builtins::Python::with_limits(limits.clone())
.with_external_handler(external_fns.clone(), handler.clone()),
),
)
.builtin(
"python3",
Box::new(
builtins::Python::with_limits(limits).with_external_handler(external_fns, handler),
),
)
}
#[cfg(feature = "typescript")]
pub fn typescript(self) -> Self {
self.typescript_with_config(builtins::TypeScriptConfig::default())
}
#[cfg(feature = "typescript")]
pub fn typescript_with_limits(self, limits: builtins::TypeScriptLimits) -> Self {
self.typescript_with_config(builtins::TypeScriptConfig::default().limits(limits))
}
#[cfg(feature = "typescript")]
pub fn typescript_with_config(self, config: builtins::TypeScriptConfig) -> Self {
self.extension(builtins::TypeScriptExtension::with_config(config))
}
#[cfg(feature = "typescript")]
pub fn typescript_with_external_handler(
self,
limits: builtins::TypeScriptLimits,
external_fns: Vec<String>,
handler: builtins::TypeScriptExternalFnHandler,
) -> Self {
self.extension(builtins::TypeScriptExtension::with_external_handler(
limits,
external_fns,
handler,
))
}
pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
self.custom_builtins.insert(name.into(), builtin);
self
}
pub fn extension<E>(mut self, extension: E) -> Self
where
E: builtins::Extension,
{
for (name, builtin) in extension.builtins() {
self.custom_builtins.insert(name, builtin);
}
self
}
pub fn on_exit(mut self, hook: hooks::Interceptor<hooks::ExitEvent>) -> Self {
self.hooks_on_exit.push(hook);
self
}
pub fn before_exec(mut self, hook: hooks::Interceptor<hooks::ExecInput>) -> Self {
self.hooks_before_exec.push(hook);
self
}
pub fn after_exec(mut self, hook: hooks::Interceptor<hooks::ExecOutput>) -> Self {
self.hooks_after_exec.push(hook);
self
}
pub fn before_tool(mut self, hook: hooks::Interceptor<hooks::ToolEvent>) -> Self {
self.hooks_before_tool.push(hook);
self
}
pub fn after_tool(mut self, hook: hooks::Interceptor<hooks::ToolResult>) -> Self {
self.hooks_after_tool.push(hook);
self
}
pub fn on_error(mut self, hook: hooks::Interceptor<hooks::ErrorEvent>) -> Self {
self.hooks_on_error.push(hook);
self
}
#[cfg(feature = "http_client")]
pub fn before_http(mut self, hook: hooks::Interceptor<hooks::HttpRequestEvent>) -> Self {
self.hooks_before_http.push(hook);
self
}
#[cfg(feature = "http_client")]
pub fn after_http(mut self, hook: hooks::Interceptor<hooks::HttpResponseEvent>) -> Self {
self.hooks_after_http.push(hook);
self
}
#[cfg(feature = "http_client")]
pub fn credential(mut self, pattern: &str, cred: credential::Credential) -> Self {
self.credential_policy
.get_or_insert_with(credential::CredentialPolicy::new)
.add_injection(pattern, cred);
self
}
#[cfg(feature = "http_client")]
pub fn credential_placeholder(
mut self,
env_name: &str,
pattern: &str,
cred: credential::Credential,
) -> Self {
let placeholder = self
.credential_policy
.get_or_insert_with(credential::CredentialPolicy::new)
.add_placeholder(pattern, cred);
self.env.insert(env_name.to_string(), placeholder);
self
}
pub fn mount_text(mut self, path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
self.mounted_files.push(MountedFile {
path: path.into(),
content: content.into(),
mode: 0o644,
});
self
}
pub fn mount_readonly_text(
mut self,
path: impl Into<PathBuf>,
content: impl Into<String>,
) -> Self {
self.mounted_files.push(MountedFile {
path: path.into(),
content: content.into(),
mode: 0o444,
});
self
}
pub fn mount_lazy(
mut self,
path: impl Into<PathBuf>,
size_hint: u64,
loader: LazyLoader,
) -> Self {
self.mounted_lazy_files.push(MountedLazyFile {
path: path.into(),
size_hint,
mode: 0o644,
loader,
});
self
}
#[cfg(feature = "realfs")]
pub fn mount_real_readonly(mut self, host_path: impl Into<PathBuf>) -> Self {
self.real_mounts.push(MountedRealDir {
host_path: host_path.into(),
vfs_mount: None,
mode: fs::RealFsMode::ReadOnly,
});
self
}
#[cfg(feature = "realfs")]
pub fn mount_real_readonly_at(
mut self,
host_path: impl Into<PathBuf>,
vfs_mount: impl Into<PathBuf>,
) -> Self {
self.real_mounts.push(MountedRealDir {
host_path: host_path.into(),
vfs_mount: Some(vfs_mount.into()),
mode: fs::RealFsMode::ReadOnly,
});
self
}
#[cfg(feature = "realfs")]
pub fn mount_real_readwrite(mut self, host_path: impl Into<PathBuf>) -> Self {
self.real_mounts.push(MountedRealDir {
host_path: host_path.into(),
vfs_mount: None,
mode: fs::RealFsMode::ReadWrite,
});
self
}
#[cfg(feature = "realfs")]
pub fn mount_real_readwrite_at(
mut self,
host_path: impl Into<PathBuf>,
vfs_mount: impl Into<PathBuf>,
) -> Self {
self.real_mounts.push(MountedRealDir {
host_path: host_path.into(),
vfs_mount: Some(vfs_mount.into()),
mode: fs::RealFsMode::ReadWrite,
});
self
}
#[cfg(feature = "realfs")]
pub fn allowed_mount_paths(
mut self,
paths: impl IntoIterator<Item = impl Into<PathBuf>>,
) -> Self {
self.mount_path_allowlist = Some(paths.into_iter().map(|p| p.into()).collect());
self
}
pub fn build(self) -> Bash {
let base_fs: Arc<dyn FileSystem> = if self.shell_profile.is_logic_only() {
Arc::new(fs::DisabledFs)
} else {
self.fs.unwrap_or_else(|| Arc::new(InMemoryFs::new()))
};
#[cfg(feature = "realfs")]
let base_fs = Self::apply_real_mounts(
&self.real_mounts,
self.mount_path_allowlist.as_deref(),
base_fs,
);
let has_mounts = !self.mounted_files.is_empty() || !self.mounted_lazy_files.is_empty();
let base_fs: Arc<dyn FileSystem> = if has_mounts {
let overlay = OverlayFs::with_limits(base_fs.clone(), base_fs.limits());
for mf in &self.mounted_files {
overlay.upper().add_file(&mf.path, &mf.content, mf.mode);
}
for lf in self.mounted_lazy_files {
overlay
.upper()
.add_lazy_file(&lf.path, lf.size_hint, lf.mode, lf.loader);
}
Arc::new(overlay)
} else {
base_fs
};
let mountable = Arc::new(MountableFs::new(base_fs));
let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
let mut result = Self::build_with_fs(
fs,
mountable,
self.env,
self.username,
self.hostname,
self.fixed_epoch,
self.cwd,
self.shell_profile,
self.limits,
self.session_limits,
self.memory_limits,
self.trace_mode,
self.trace_callback,
self.custom_builtins,
self.history_file,
#[cfg(feature = "http_client")]
self.network_allowlist,
#[cfg(feature = "http_client")]
self.http_handler,
#[cfg(feature = "bot-auth")]
self.bot_auth_config,
#[cfg(feature = "logging")]
self.log_config,
#[cfg(feature = "git")]
self.git_config,
#[cfg(feature = "ssh")]
self.ssh_config,
#[cfg(feature = "ssh")]
self.ssh_handler,
);
let hooks = hooks::Hooks {
on_exit: self.hooks_on_exit,
before_exec: self.hooks_before_exec,
after_exec: self.hooks_after_exec,
before_tool: self.hooks_before_tool,
after_tool: self.hooks_after_tool,
on_error: self.hooks_on_error,
};
if hooks.has_hooks() {
result.interpreter.set_hooks(hooks);
}
#[cfg(feature = "http_client")]
let mut hooks_before_http = Vec::new();
#[cfg(feature = "http_client")]
if let Some(policy) = self.credential_policy
&& !policy.is_empty()
{
hooks_before_http.push(policy.into_hook());
}
#[cfg(feature = "http_client")]
hooks_before_http.extend(self.hooks_before_http);
#[cfg(feature = "http_client")]
if (!hooks_before_http.is_empty() || !self.hooks_after_http.is_empty())
&& let Some(client) = result.interpreter.http_client_mut()
{
if !hooks_before_http.is_empty() {
client.set_before_http(hooks_before_http);
}
if !self.hooks_after_http.is_empty() {
client.set_after_http(self.hooks_after_http);
}
}
result
}
#[cfg(feature = "realfs")]
const SENSITIVE_MOUNT_PATHS: &[&str] = &["/etc/shadow", "/etc/sudoers", "/proc", "/sys"];
#[cfg(feature = "realfs")]
fn apply_real_mounts(
real_mounts: &[MountedRealDir],
mount_allowlist: Option<&[PathBuf]>,
base_fs: Arc<dyn FileSystem>,
) -> Arc<dyn FileSystem> {
if real_mounts.is_empty() {
return base_fs;
}
let mut current_fs = base_fs;
let mut mount_points: Vec<(PathBuf, Arc<dyn FileSystem>)> = Vec::new();
let canonical_allowlist: Option<Vec<PathBuf>> = mount_allowlist.map(|allowlist| {
allowlist
.iter()
.filter_map(|allowed| match std::fs::canonicalize(allowed) {
Ok(path) => Some(path),
Err(e) => {
eprintln!(
"bashkit: warning: failed to canonicalize allowlist path {}: {}",
allowed.display(),
e
);
None
}
})
.collect()
});
for m in real_mounts {
if m.mode == fs::RealFsMode::ReadWrite {
eprintln!(
"bashkit: warning: writable mount at {} — scripts can modify host files",
m.host_path.display()
);
}
let canonical_host = match std::fs::canonicalize(&m.host_path) {
Ok(path) => path,
Err(e) => {
eprintln!(
"bashkit: warning: failed to canonicalize mount path {}: {}",
m.host_path.display(),
e
);
continue;
}
};
if Self::SENSITIVE_MOUNT_PATHS
.iter()
.any(|s| canonical_host.starts_with(Path::new(s)))
{
eprintln!(
"bashkit: warning: refusing to mount sensitive path {}",
m.host_path.display()
);
continue;
}
if let Some(allowlist) = &canonical_allowlist
&& !allowlist
.iter()
.any(|allowed| canonical_host.starts_with(allowed))
{
eprintln!(
"bashkit: warning: mount path {} not in allowlist, skipping",
m.host_path.display()
);
continue;
}
let real_backend = match fs::RealFs::new(&canonical_host, m.mode) {
Ok(b) => b,
Err(e) => {
eprintln!(
"bashkit: warning: failed to mount {}: {}",
m.host_path.display(),
e
);
continue;
}
};
let real_fs: Arc<dyn FileSystem> = Arc::new(PosixFs::new(real_backend));
match &m.vfs_mount {
None => {
current_fs = Arc::new(OverlayFs::new(real_fs));
}
Some(mount_point) => {
mount_points.push((mount_point.clone(), real_fs));
}
}
}
if !mount_points.is_empty() {
let mountable = MountableFs::new(current_fs);
for (path, fs) in mount_points {
if let Err(e) = mountable.mount(&path, fs) {
eprintln!(
"bashkit: warning: failed to mount at {}: {}",
path.display(),
e
);
}
}
Arc::new(mountable)
} else {
current_fs
}
}
#[allow(clippy::too_many_arguments)]
fn build_with_fs(
fs: Arc<dyn FileSystem>,
mountable: Arc<MountableFs>,
env: HashMap<String, String>,
username: Option<String>,
hostname: Option<String>,
fixed_epoch: Option<i64>,
cwd: Option<PathBuf>,
shell_profile: interpreter::ShellProfile,
limits: ExecutionLimits,
session_limits: SessionLimits,
memory_limits: MemoryLimits,
trace_mode: TraceMode,
trace_callback: Option<TraceCallback>,
custom_builtins: HashMap<String, Box<dyn Builtin>>,
history_file: Option<PathBuf>,
#[cfg(feature = "http_client")] network_allowlist: Option<NetworkAllowlist>,
#[cfg(feature = "http_client")] http_handler: Option<Box<dyn network::HttpHandler>>,
#[cfg(feature = "bot-auth")] bot_auth_config: Option<network::BotAuthConfig>,
#[cfg(feature = "logging")] log_config: Option<logging::LogConfig>,
#[cfg(feature = "git")] git_config: Option<GitConfig>,
#[cfg(feature = "ssh")] ssh_config: Option<SshConfig>,
#[cfg(feature = "ssh")] ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
) -> Bash {
#[cfg(feature = "logging")]
let log_config = log_config.unwrap_or_default();
#[cfg(feature = "logging")]
tracing::debug!(
target: "bashkit::config",
redact_sensitive = log_config.redact_sensitive,
log_scripts = log_config.log_script_content,
"Bash instance configured"
);
let mut interpreter = Interpreter::with_config(
Arc::clone(&fs),
username.clone(),
hostname,
fixed_epoch,
custom_builtins,
shell_profile,
);
for (key, value) in &env {
interpreter.set_env(key, value);
interpreter.set_var(key, value);
}
#[cfg(feature = "python")]
let python_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_PYTHON");
#[cfg(feature = "sqlite")]
let sqlite_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_SQLITE");
drop(env);
if let Some(ref username) = username {
interpreter.set_env("USER", username);
interpreter.set_var("USER", username);
}
if let Some(cwd) = cwd {
interpreter.set_cwd(cwd);
}
#[cfg(feature = "http_client")]
if let Some(allowlist) = network_allowlist {
let mut client = network::HttpClient::new(allowlist);
if let Some(handler) = http_handler {
client.set_handler(handler);
}
#[cfg(feature = "bot-auth")]
if let Some(bot_auth) = bot_auth_config {
client.set_bot_auth(bot_auth);
}
interpreter.set_http_client(client);
}
#[cfg(feature = "git")]
if let Some(config) = git_config {
let client = builtins::git::GitClient::new(config);
interpreter.set_git_client(client);
}
#[cfg(feature = "ssh")]
if let Some(config) = ssh_config {
let mut client = builtins::ssh::SshClient::new(config);
if let Some(handler) = ssh_handler {
client.set_handler(handler);
}
interpreter.set_ssh_client(client);
}
if let Some(hf) = history_file {
interpreter.set_history_file(hf);
}
let parser_timeout = limits.parser_timeout;
let max_input_bytes = limits.max_input_bytes;
let max_ast_depth = limits.max_ast_depth;
let max_parser_operations = limits.max_parser_operations;
interpreter.set_limits(limits);
interpreter.set_session_limits(session_limits);
interpreter.set_memory_limits(memory_limits);
let mut trace_collector = TraceCollector::new(trace_mode);
if let Some(cb) = trace_callback {
trace_collector.set_callback(cb);
}
interpreter.set_trace(trace_collector);
Bash {
fs,
mountable,
interpreter,
parser_timeout,
max_input_bytes,
max_ast_depth,
max_parser_operations,
#[cfg(feature = "logging")]
log_config,
#[cfg(feature = "python")]
python_inprocess_opt_in,
#[cfg(feature = "sqlite")]
sqlite_inprocess_opt_in,
}
}
}
#[cfg(feature = "http_client")]
#[doc = include_str!("../docs/credential-injection.md")]
pub mod credential_injection_guide {}
#[doc = include_str!("../docs/custom_builtins.md")]
pub mod custom_builtins_guide {}
#[doc = include_str!("../docs/clap-builtins.md")]
pub mod clap_builtins_guide {}
#[doc = include_str!("../docs/compatibility.md")]
pub mod compatibility_scorecard {}
#[doc = include_str!("../docs/jq.md")]
pub mod jq_guide {}
#[doc = include_str!("../docs/threat-model.md")]
pub mod threat_model {}
#[cfg(feature = "python")]
#[doc = include_str!("../docs/python.md")]
pub mod python_guide {}
#[cfg(feature = "sqlite")]
#[doc = include_str!("../docs/sqlite.md")]
pub mod sqlite_guide {}
#[cfg(feature = "typescript")]
#[doc = include_str!("../docs/typescript.md")]
pub mod typescript_guide {}
#[cfg(feature = "ssh")]
#[doc = include_str!("../docs/ssh.md")]
pub mod ssh_guide {}
#[doc = include_str!("../docs/live_mounts.md")]
pub mod live_mounts_guide {}
#[cfg(feature = "logging")]
#[doc = include_str!("../docs/logging.md")]
pub mod logging_guide {}
#[doc = include_str!("../docs/hooks.md")]
pub mod hooks_guide {}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[tokio::test]
async fn test_echo_hello() {
let mut bash = Bash::new();
let result = bash.exec("echo hello").await.unwrap();
assert_eq!(result.stdout, "hello\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_echo_multiple_args() {
let mut bash = Bash::new();
let result = bash.exec("echo hello world").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_variable_expansion() {
let mut bash = Bash::builder().env("HOME", "/home/user").build();
let result = bash.exec("echo $HOME").await.unwrap();
assert_eq!(result.stdout, "/home/user\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_variable_brace_expansion() {
let mut bash = Bash::builder().env("USER", "testuser").build();
let result = bash.exec("echo ${USER}").await.unwrap();
assert_eq!(result.stdout, "testuser\n");
}
#[tokio::test]
async fn test_undefined_variable_expands_to_empty() {
let mut bash = Bash::new();
let result = bash.exec("echo $UNDEFINED_VAR").await.unwrap();
assert_eq!(result.stdout, "\n");
}
#[tokio::test]
async fn test_pipeline() {
let mut bash = Bash::new();
let result = bash.exec("echo hello | cat").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_pipeline_three_commands() {
let mut bash = Bash::new();
let result = bash.exec("echo hello | cat | cat").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_redirect_output() {
let mut bash = Bash::new();
let result = bash.exec("echo hello > /tmp/test.txt").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
let result = bash.exec("cat /tmp/test.txt").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_redirect_append() {
let mut bash = Bash::new();
bash.exec("echo hello > /tmp/append.txt").await.unwrap();
bash.exec("echo world >> /tmp/append.txt").await.unwrap();
let result = bash.exec("cat /tmp/append.txt").await.unwrap();
assert_eq!(result.stdout, "hello\nworld\n");
}
#[tokio::test]
async fn test_command_list_and() {
let mut bash = Bash::new();
let result = bash.exec("true && echo success").await.unwrap();
assert_eq!(result.stdout, "success\n");
}
#[tokio::test]
async fn test_command_list_and_short_circuit() {
let mut bash = Bash::new();
let result = bash.exec("false && echo should_not_print").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn test_command_list_or() {
let mut bash = Bash::new();
let result = bash.exec("false || echo fallback").await.unwrap();
assert_eq!(result.stdout, "fallback\n");
}
#[tokio::test]
async fn test_command_list_or_short_circuit() {
let mut bash = Bash::new();
let result = bash.exec("true || echo should_not_print").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_phase1_target() {
let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
let result = bash
.exec("echo $HOME | cat > /tmp/out && cat /tmp/out")
.await
.unwrap();
assert_eq!(result.stdout, "/home/testuser\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_redirect_input() {
let mut bash = Bash::new();
bash.exec("echo hello > /tmp/input.txt").await.unwrap();
let result = bash.exec("cat < /tmp/input.txt").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_here_string() {
let mut bash = Bash::new();
let result = bash.exec("cat <<< hello").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_if_true() {
let mut bash = Bash::new();
let result = bash.exec("if true; then echo yes; fi").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_if_false() {
let mut bash = Bash::new();
let result = bash.exec("if false; then echo yes; fi").await.unwrap();
assert_eq!(result.stdout, "");
}
#[tokio::test]
async fn test_if_else() {
let mut bash = Bash::new();
let result = bash
.exec("if false; then echo yes; else echo no; fi")
.await
.unwrap();
assert_eq!(result.stdout, "no\n");
}
#[tokio::test]
async fn test_if_elif() {
let mut bash = Bash::new();
let result = bash
.exec("if false; then echo one; elif true; then echo two; else echo three; fi")
.await
.unwrap();
assert_eq!(result.stdout, "two\n");
}
#[tokio::test]
async fn test_for_loop() {
let mut bash = Bash::new();
let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
assert_eq!(result.stdout, "a\nb\nc\n");
}
#[tokio::test]
async fn test_for_loop_positional_params() {
let mut bash = Bash::new();
let result = bash
.exec("f() { for x; do echo $x; done; }; f one two three")
.await
.unwrap();
assert_eq!(result.stdout, "one\ntwo\nthree\n");
}
#[tokio::test]
async fn test_while_loop() {
let mut bash = Bash::new();
let result = bash.exec("while false; do echo loop; done").await.unwrap();
assert_eq!(result.stdout, "");
}
#[tokio::test]
async fn test_subshell() {
let mut bash = Bash::new();
let result = bash.exec("(echo hello)").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_brace_group() {
let mut bash = Bash::new();
let result = bash.exec("{ echo hello; }").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_function_keyword() {
let mut bash = Bash::new();
let result = bash
.exec("function greet { echo hello; }; greet")
.await
.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_function_posix() {
let mut bash = Bash::new();
let result = bash.exec("greet() { echo hello; }; greet").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_function_args() {
let mut bash = Bash::new();
let result = bash
.exec("greet() { echo $1 $2; }; greet world foo")
.await
.unwrap();
assert_eq!(result.stdout, "world foo\n");
}
#[tokio::test]
async fn test_function_arg_count() {
let mut bash = Bash::new();
let result = bash
.exec("count() { echo $#; }; count a b c")
.await
.unwrap();
assert_eq!(result.stdout, "3\n");
}
#[tokio::test]
async fn test_case_literal() {
let mut bash = Bash::new();
let result = bash
.exec("case foo in foo) echo matched ;; esac")
.await
.unwrap();
assert_eq!(result.stdout, "matched\n");
}
#[tokio::test]
async fn test_case_wildcard() {
let mut bash = Bash::new();
let result = bash
.exec("case bar in *) echo default ;; esac")
.await
.unwrap();
assert_eq!(result.stdout, "default\n");
}
#[tokio::test]
async fn test_case_no_match() {
let mut bash = Bash::new();
let result = bash.exec("case foo in bar) echo no ;; esac").await.unwrap();
assert_eq!(result.stdout, "");
}
#[tokio::test]
async fn test_case_multiple_patterns() {
let mut bash = Bash::new();
let result = bash
.exec("case foo in bar|foo|baz) echo matched ;; esac")
.await
.unwrap();
assert_eq!(result.stdout, "matched\n");
}
#[tokio::test]
async fn test_case_bracket_expr() {
let mut bash = Bash::new();
let result = bash
.exec("case b in [abc]) echo matched ;; esac")
.await
.unwrap();
assert_eq!(result.stdout, "matched\n");
}
#[tokio::test]
async fn test_case_bracket_range() {
let mut bash = Bash::new();
let result = bash
.exec("case m in [a-z]) echo letter ;; esac")
.await
.unwrap();
assert_eq!(result.stdout, "letter\n");
}
#[tokio::test]
async fn test_case_bracket_wide_unicode_range() {
let mut bash = Bash::new();
let result = bash
.exec("case z in [a-\u{10ffff}]) echo wide ;; esac")
.await
.unwrap();
assert_eq!(result.stdout, "wide\n");
}
#[tokio::test]
async fn test_case_bracket_negation() {
let mut bash = Bash::new();
let result = bash
.exec("case x in [!abc]) echo not_abc ;; esac")
.await
.unwrap();
assert_eq!(result.stdout, "not_abc\n");
}
#[tokio::test]
async fn test_break_as_command() {
let mut bash = Bash::new();
let result = bash.exec("break").await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_for_one_item() {
let mut bash = Bash::new();
let result = bash.exec("for i in a; do echo $i; done").await.unwrap();
assert_eq!(result.stdout, "a\n");
}
#[tokio::test]
async fn test_for_with_break() {
let mut bash = Bash::new();
let result = bash.exec("for i in a; do break; done").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_for_echo_break() {
let mut bash = Bash::new();
let result = bash
.exec("for i in a b c; do echo $i; break; done")
.await
.unwrap();
assert_eq!(result.stdout, "a\n");
}
#[tokio::test]
async fn test_test_string_empty() {
let mut bash = Bash::new();
let result = bash.exec("test -z '' && echo yes").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_test_string_not_empty() {
let mut bash = Bash::new();
let result = bash.exec("test -n 'hello' && echo yes").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_test_string_equal() {
let mut bash = Bash::new();
let result = bash.exec("test foo = foo && echo yes").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_test_string_not_equal() {
let mut bash = Bash::new();
let result = bash.exec("test foo != bar && echo yes").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_test_numeric_equal() {
let mut bash = Bash::new();
let result = bash.exec("test 5 -eq 5 && echo yes").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_test_numeric_less_than() {
let mut bash = Bash::new();
let result = bash.exec("test 3 -lt 5 && echo yes").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_bracket_form() {
let mut bash = Bash::new();
let result = bash.exec("[ foo = foo ] && echo yes").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_if_with_test() {
let mut bash = Bash::new();
let result = bash
.exec("if [ 5 -gt 3 ]; then echo bigger; fi")
.await
.unwrap();
assert_eq!(result.stdout, "bigger\n");
}
#[tokio::test]
async fn test_variable_assignment() {
let mut bash = Bash::new();
let result = bash.exec("FOO=bar; echo $FOO").await.unwrap();
assert_eq!(result.stdout, "bar\n");
}
#[tokio::test]
async fn test_variable_assignment_inline() {
let mut bash = Bash::new();
let result = bash.exec("MSG=hello; echo $MSG world").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
}
#[tokio::test]
async fn test_variable_assignment_only() {
let mut bash = Bash::new();
let result = bash.exec("FOO=bar").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 0);
let result = bash.exec("echo $FOO").await.unwrap();
assert_eq!(result.stdout, "bar\n");
}
#[tokio::test]
async fn test_multiple_assignments() {
let mut bash = Bash::new();
let result = bash.exec("A=1; B=2; C=3; echo $A $B $C").await.unwrap();
assert_eq!(result.stdout, "1 2 3\n");
}
#[tokio::test]
async fn test_prefix_assignment_visible_in_env() {
let mut bash = Bash::new();
let result = bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_prefix_assignment_temporary() {
let mut bash = Bash::new();
bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
let result = bash.exec("echo ${MYVAR:-unset}").await.unwrap();
assert_eq!(result.stdout, "unset\n");
}
#[tokio::test]
async fn test_prefix_assignment_duplicate_name_temporary() {
let mut bash = Bash::new();
let result = bash.exec("A=1 A=2 printenv A").await.unwrap();
assert_eq!(result.stdout, "2\n");
let result = bash.exec("echo ${A:-unset}").await.unwrap();
assert_eq!(result.stdout, "unset\n");
}
#[tokio::test]
async fn test_prefix_assignment_does_not_clobber_existing_env() {
let mut bash = Bash::new();
let result = bash
.exec("EXISTING=original; export EXISTING; EXISTING=temp printenv EXISTING")
.await
.unwrap();
assert_eq!(result.stdout, "temp\n");
}
#[tokio::test]
async fn test_prefix_assignment_multiple_vars() {
let mut bash = Bash::new();
let result = bash.exec("A=one B=two printenv A").await.unwrap();
assert_eq!(result.stdout, "one\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_prefix_assignment_empty_value() {
let mut bash = Bash::new();
let result = bash.exec("MYVAR= printenv MYVAR").await.unwrap();
assert_eq!(result.stdout, "\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_prefix_assignment_not_found_without_prefix() {
let mut bash = Bash::new();
let result = bash.exec("printenv NONEXISTENT").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn test_prefix_assignment_does_not_persist_in_variables() {
let mut bash = Bash::new();
bash.exec("TMPVAR=gone echo ok").await.unwrap();
let result = bash.exec("echo \"${TMPVAR:-unset}\"").await.unwrap();
assert_eq!(result.stdout, "unset\n");
}
#[tokio::test]
async fn test_assignment_only_persists() {
let mut bash = Bash::new();
bash.exec("PERSIST=yes").await.unwrap();
let result = bash.exec("echo $PERSIST").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_printf_string() {
let mut bash = Bash::new();
let result = bash.exec("printf '%s' hello").await.unwrap();
assert_eq!(result.stdout, "hello");
}
#[tokio::test]
async fn test_printf_newline() {
let mut bash = Bash::new();
let result = bash.exec("printf 'hello\\n'").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_printf_multiple_args() {
let mut bash = Bash::new();
let result = bash.exec("printf '%s %s\\n' hello world").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
}
#[tokio::test]
async fn test_printf_integer() {
let mut bash = Bash::new();
let result = bash.exec("printf '%d' 42").await.unwrap();
assert_eq!(result.stdout, "42");
}
#[tokio::test]
async fn test_export() {
let mut bash = Bash::new();
let result = bash.exec("export FOO=bar; echo $FOO").await.unwrap();
assert_eq!(result.stdout, "bar\n");
}
#[tokio::test]
async fn test_read_basic() {
let mut bash = Bash::new();
let result = bash.exec("echo hello | read VAR; echo $VAR").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_read_multiple_vars() {
let mut bash = Bash::new();
let result = bash
.exec("echo 'a b c' | read X Y Z; echo $X $Y $Z")
.await
.unwrap();
assert_eq!(result.stdout, "a b c\n");
}
#[tokio::test]
async fn test_read_respects_local_scope() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"
fn() { local k; read -r k <<< "test"; echo "$k"; }
fn
"#,
)
.await
.unwrap();
assert_eq!(result.stdout, "test\n");
}
#[tokio::test]
async fn test_local_ifs_array_join() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"
fn() {
local arr=(a b c)
local IFS=":"
echo "${arr[*]}"
}
fn
"#,
)
.await
.unwrap();
assert_eq!(result.stdout, "a:b:c\n");
}
#[tokio::test]
async fn test_glob_star() {
let mut bash = Bash::new();
bash.exec("echo a > /tmp/file1.txt").await.unwrap();
bash.exec("echo b > /tmp/file2.txt").await.unwrap();
bash.exec("echo c > /tmp/other.log").await.unwrap();
let result = bash.exec("echo /tmp/*.txt").await.unwrap();
assert_eq!(result.stdout, "/tmp/file1.txt /tmp/file2.txt\n");
}
#[tokio::test]
async fn test_glob_question_mark() {
let mut bash = Bash::new();
bash.exec("echo a > /tmp/a1.txt").await.unwrap();
bash.exec("echo b > /tmp/a2.txt").await.unwrap();
bash.exec("echo c > /tmp/a10.txt").await.unwrap();
let result = bash.exec("echo /tmp/a?.txt").await.unwrap();
assert_eq!(result.stdout, "/tmp/a1.txt /tmp/a2.txt\n");
}
#[tokio::test]
async fn test_glob_no_match() {
let mut bash = Bash::new();
let result = bash.exec("echo /nonexistent/*.xyz").await.unwrap();
assert_eq!(result.stdout, "/nonexistent/*.xyz\n");
}
#[tokio::test]
async fn test_command_substitution() {
let mut bash = Bash::new();
let result = bash.exec("echo $(echo hello)").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_command_substitution_in_string() {
let mut bash = Bash::new();
let result = bash.exec("echo \"result: $(echo 42)\"").await.unwrap();
assert_eq!(result.stdout, "result: 42\n");
}
#[tokio::test]
async fn test_command_substitution_pipeline() {
let mut bash = Bash::new();
let result = bash.exec("echo $(echo hello | cat)").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_command_substitution_variable() {
let mut bash = Bash::new();
let result = bash.exec("VAR=$(echo test); echo $VAR").await.unwrap();
assert_eq!(result.stdout, "test\n");
}
#[tokio::test]
async fn test_arithmetic_simple() {
let mut bash = Bash::new();
let result = bash.exec("echo $((1 + 2))").await.unwrap();
assert_eq!(result.stdout, "3\n");
}
#[tokio::test]
async fn test_arithmetic_multiply() {
let mut bash = Bash::new();
let result = bash.exec("echo $((3 * 4))").await.unwrap();
assert_eq!(result.stdout, "12\n");
}
#[tokio::test]
async fn test_arithmetic_with_variable() {
let mut bash = Bash::new();
let result = bash.exec("X=5; echo $((X + 3))").await.unwrap();
assert_eq!(result.stdout, "8\n");
}
#[tokio::test]
async fn test_arithmetic_complex() {
let mut bash = Bash::new();
let result = bash.exec("echo $((2 + 3 * 4))").await.unwrap();
assert_eq!(result.stdout, "14\n");
}
#[tokio::test]
async fn test_heredoc_simple() {
let mut bash = Bash::new();
let result = bash.exec("cat <<EOF\nhello\nworld\nEOF").await.unwrap();
assert_eq!(result.stdout, "hello\nworld\n");
}
#[tokio::test]
async fn test_heredoc_single_line() {
let mut bash = Bash::new();
let result = bash.exec("cat <<END\ntest\nEND").await.unwrap();
assert_eq!(result.stdout, "test\n");
}
#[tokio::test]
async fn test_unset() {
let mut bash = Bash::new();
let result = bash
.exec("FOO=bar; unset FOO; echo \"x${FOO}y\"")
.await
.unwrap();
assert_eq!(result.stdout, "xy\n");
}
#[tokio::test]
async fn test_local_basic() {
let mut bash = Bash::new();
let result = bash.exec("local X=test; echo $X").await.unwrap();
assert_eq!(result.stdout, "test\n");
}
#[tokio::test]
async fn test_set_option() {
let mut bash = Bash::new();
let result = bash.exec("set -e; echo ok").await.unwrap();
assert_eq!(result.stdout, "ok\n");
}
#[tokio::test]
async fn test_param_default() {
let mut bash = Bash::new();
let result = bash.exec("echo ${UNSET:-default}").await.unwrap();
assert_eq!(result.stdout, "default\n");
let result = bash.exec("X=value; echo ${X:-default}").await.unwrap();
assert_eq!(result.stdout, "value\n");
}
#[tokio::test]
async fn test_param_assign_default() {
let mut bash = Bash::new();
let result = bash.exec("echo ${NEW:=assigned}; echo $NEW").await.unwrap();
assert_eq!(result.stdout, "assigned\nassigned\n");
}
#[tokio::test]
async fn test_param_length() {
let mut bash = Bash::new();
let result = bash.exec("X=hello; echo ${#X}").await.unwrap();
assert_eq!(result.stdout, "5\n");
}
#[tokio::test]
async fn test_param_remove_prefix() {
let mut bash = Bash::new();
let result = bash.exec("X=hello.world.txt; echo ${X#*.}").await.unwrap();
assert_eq!(result.stdout, "world.txt\n");
}
#[tokio::test]
async fn test_param_remove_prefix_mixed_pattern() {
let mut bash = Bash::new();
let result = bash
.exec(r#"i="./tag_hello.tmp.html"; prefix_tags="tag_"; echo ${i#./"$prefix_tags"}"#)
.await
.unwrap();
assert_eq!(result.stdout, "hello.tmp.html\n");
}
#[tokio::test]
async fn test_param_remove_suffix() {
let mut bash = Bash::new();
let result = bash.exec("X=file.tar.gz; echo ${X%.*}").await.unwrap();
assert_eq!(result.stdout, "file.tar\n");
}
#[tokio::test]
async fn test_positional_param_prefix_replace() {
let mut bash = Bash::new();
let result = bash
.exec(r#"f() { set -- "${@/#/tag_}"; echo "$@"; }; f hello world"#)
.await
.unwrap();
assert_eq!(result.stdout, "tag_hello tag_world\n");
}
#[tokio::test]
async fn test_positional_param_suffix_replace() {
let mut bash = Bash::new();
let result = bash
.exec(r#"f() { set -- "${@/%/.html}"; echo "$@"; }; f hello world"#)
.await
.unwrap();
assert_eq!(result.stdout, "hello.html world.html\n");
}
#[tokio::test]
async fn test_positional_param_prefix_var_replace() {
let mut bash = Bash::new();
let result = bash
.exec(r#"f() { p="tag_"; set -- "${@/#/$p}"; echo "$@"; }; f hello world"#)
.await
.unwrap();
assert_eq!(result.stdout, "tag_hello tag_world\n");
}
#[tokio::test]
async fn test_positional_param_prefix_strip() {
let mut bash = Bash::new();
let result = bash
.exec(r#"f() { set -- "${@#tag_}"; echo "$@"; }; f tag_hello tag_world"#)
.await
.unwrap();
assert_eq!(result.stdout, "hello world\n");
}
#[tokio::test]
async fn test_array_basic() {
let mut bash = Bash::new();
let result = bash.exec("arr=(a b c); echo ${arr[1]}").await.unwrap();
assert_eq!(result.stdout, "b\n");
}
#[tokio::test]
async fn test_array_all_elements() {
let mut bash = Bash::new();
let result = bash
.exec("arr=(one two three); echo ${arr[@]}")
.await
.unwrap();
assert_eq!(result.stdout, "one two three\n");
}
#[tokio::test]
async fn test_array_length() {
let mut bash = Bash::new();
let result = bash.exec("arr=(a b c d e); echo ${#arr[@]}").await.unwrap();
assert_eq!(result.stdout, "5\n");
}
#[tokio::test]
async fn test_array_indexed_assignment() {
let mut bash = Bash::new();
let result = bash
.exec("arr[0]=first; arr[1]=second; echo ${arr[0]} ${arr[1]}")
.await
.unwrap();
assert_eq!(result.stdout, "first second\n");
}
#[tokio::test]
async fn test_array_single_quote_subscript_no_panic() {
let mut bash = Bash::new();
let _ = bash.exec("echo ${arr[\"]}").await;
}
#[tokio::test]
async fn test_command_limit() {
let limits = ExecutionLimits::new().max_commands(5);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("true; true; true; true; true; true").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("maximum command count exceeded"),
"Expected command limit error, got: {}",
err
);
}
#[tokio::test]
async fn test_command_limit_not_exceeded() {
let limits = ExecutionLimits::new().max_commands(10);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("true; true; true; true; true").await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_loop_iteration_limit() {
let limits = ExecutionLimits::new().max_loop_iterations(5);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done")
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("maximum loop iterations exceeded"),
"Expected loop limit error, got: {}",
err
);
}
#[tokio::test]
async fn test_loop_iteration_limit_not_exceeded() {
let limits = ExecutionLimits::new().max_loop_iterations(10);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("for i in 1 2 3 4 5; do echo $i; done")
.await
.unwrap();
assert_eq!(result.stdout, "1\n2\n3\n4\n5\n");
}
#[tokio::test]
async fn test_function_depth_limit() {
let limits = ExecutionLimits::new().max_function_depth(3);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("f() { echo $1; if [ $1 -lt 5 ]; then f $(($1 + 1)); fi; }; f 1")
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("maximum function depth exceeded"),
"Expected function depth error, got: {}",
err
);
}
#[tokio::test]
async fn test_function_depth_limit_not_exceeded() {
let limits = ExecutionLimits::new().max_function_depth(10);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("f() { echo hello; }; f").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_while_loop_limit() {
let limits = ExecutionLimits::new().max_loop_iterations(3);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("i=0; while [ $i -lt 10 ]; do echo $i; i=$((i + 1)); done")
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("maximum loop iterations exceeded"),
"Expected loop limit error, got: {}",
err
);
}
#[tokio::test]
async fn test_awk_respects_loop_iteration_limit() {
let limits = ExecutionLimits::new().max_loop_iterations(5);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("awk 'BEGIN { i=0; while(1) { i++; if(i>999) break } print i }'")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "5");
}
#[tokio::test]
async fn test_awk_for_in_respects_loop_iteration_limit() {
let limits = ExecutionLimits::new().max_loop_iterations(3);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("awk 'BEGIN { for(i=1;i<=10;i++) a[i]=i; c=0; for(k in a) c++; print c }'")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "3");
}
#[tokio::test]
async fn test_default_limits_allow_normal_scripts() {
let mut bash = Bash::new();
let result = bash
.exec("for i in 1 2 3 4 5; do echo $i; done && echo finished")
.await
.unwrap();
assert_eq!(result.stdout, "1\n2\n3\n4\n5\nfinished\n");
}
#[tokio::test]
async fn test_for_followed_by_echo_done() {
let mut bash = Bash::new();
let result = bash
.exec("for i in 1; do echo $i; done; echo ok")
.await
.unwrap();
assert_eq!(result.stdout, "1\nok\n");
}
#[tokio::test]
async fn test_fs_read_write_binary() {
let bash = Bash::new();
let fs = bash.fs();
let path = std::path::Path::new("/tmp/binary.bin");
let binary_data: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE, 0x42, 0x00, 0x7F];
fs.write_file(path, &binary_data).await.unwrap();
let content = fs.read_file(path).await.unwrap();
assert_eq!(content, binary_data);
}
#[tokio::test]
async fn test_fs_write_then_exec_cat() {
let mut bash = Bash::new();
let path = std::path::Path::new("/tmp/prepopulated.txt");
bash.fs()
.write_file(path, b"Hello from Rust!\n")
.await
.unwrap();
let result = bash.exec("cat /tmp/prepopulated.txt").await.unwrap();
assert_eq!(result.stdout, "Hello from Rust!\n");
}
#[tokio::test]
async fn test_fs_exec_then_read() {
let mut bash = Bash::new();
let path = std::path::Path::new("/tmp/from_bash.txt");
bash.exec("echo 'Created by bash' > /tmp/from_bash.txt")
.await
.unwrap();
let content = bash.fs().read_file(path).await.unwrap();
assert_eq!(content, b"Created by bash\n");
}
#[tokio::test]
async fn test_fs_exists_and_stat() {
let bash = Bash::new();
let fs = bash.fs();
let path = std::path::Path::new("/tmp/testfile.txt");
assert!(!fs.exists(path).await.unwrap());
fs.write_file(path, b"content").await.unwrap();
assert!(fs.exists(path).await.unwrap());
let stat = fs.stat(path).await.unwrap();
assert!(stat.file_type.is_file());
assert_eq!(stat.size, 7); }
#[tokio::test]
async fn test_fs_mkdir_and_read_dir() {
let bash = Bash::new();
let fs = bash.fs();
fs.mkdir(std::path::Path::new("/data/nested/dir"), true)
.await
.unwrap();
fs.write_file(std::path::Path::new("/data/file1.txt"), b"1")
.await
.unwrap();
fs.write_file(std::path::Path::new("/data/file2.txt"), b"2")
.await
.unwrap();
let entries = fs.read_dir(std::path::Path::new("/data")).await.unwrap();
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"nested"));
assert!(names.contains(&"file1.txt"));
assert!(names.contains(&"file2.txt"));
}
#[tokio::test]
async fn test_fs_append() {
let bash = Bash::new();
let fs = bash.fs();
let path = std::path::Path::new("/tmp/append.txt");
fs.write_file(path, b"line1\n").await.unwrap();
fs.append_file(path, b"line2\n").await.unwrap();
fs.append_file(path, b"line3\n").await.unwrap();
let content = fs.read_file(path).await.unwrap();
assert_eq!(content, b"line1\nline2\nline3\n");
}
#[tokio::test]
async fn test_fs_copy_and_rename() {
let bash = Bash::new();
let fs = bash.fs();
fs.write_file(std::path::Path::new("/tmp/original.txt"), b"data")
.await
.unwrap();
fs.copy(
std::path::Path::new("/tmp/original.txt"),
std::path::Path::new("/tmp/copied.txt"),
)
.await
.unwrap();
fs.rename(
std::path::Path::new("/tmp/copied.txt"),
std::path::Path::new("/tmp/renamed.txt"),
)
.await
.unwrap();
let content = fs
.read_file(std::path::Path::new("/tmp/renamed.txt"))
.await
.unwrap();
assert_eq!(content, b"data");
assert!(
!fs.exists(std::path::Path::new("/tmp/copied.txt"))
.await
.unwrap()
);
}
#[tokio::test]
async fn test_echo_done_as_argument() {
let mut bash = Bash::new();
let result = bash
.exec("for i in 1; do echo $i; done; echo done")
.await
.unwrap();
assert_eq!(result.stdout, "1\ndone\n");
}
#[tokio::test]
async fn test_simple_echo_done() {
let mut bash = Bash::new();
let result = bash.exec("echo done").await.unwrap();
assert_eq!(result.stdout, "done\n");
}
#[tokio::test]
async fn test_dev_null_redirect() {
let mut bash = Bash::new();
let result = bash.exec("echo hello > /dev/null; echo ok").await.unwrap();
assert_eq!(result.stdout, "ok\n");
}
#[tokio::test]
async fn test_string_concatenation_in_loop() {
let mut bash = Bash::new();
let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
assert_eq!(result.stdout, "a\nb\nc\n");
let mut bash = Bash::new();
let result = bash
.exec("result=x; for i in a b c; do echo $i; done; echo $result")
.await
.unwrap();
assert_eq!(result.stdout, "a\nb\nc\nx\n");
let mut bash = Bash::new();
let result = bash
.exec("result=start; for i in a b c; do result=${result}$i; done; echo $result")
.await
.unwrap();
assert_eq!(result.stdout, "startabc\n");
}
#[tokio::test]
async fn test_done_still_terminates_loop() {
let mut bash = Bash::new();
let result = bash.exec("for i in 1 2; do echo $i; done").await.unwrap();
assert_eq!(result.stdout, "1\n2\n");
}
#[tokio::test]
async fn test_fi_still_terminates_if() {
let mut bash = Bash::new();
let result = bash.exec("if true; then echo yes; fi").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_echo_fi_as_argument() {
let mut bash = Bash::new();
let result = bash.exec("echo fi").await.unwrap();
assert_eq!(result.stdout, "fi\n");
}
#[tokio::test]
async fn test_echo_then_as_argument() {
let mut bash = Bash::new();
let result = bash.exec("echo then").await.unwrap();
assert_eq!(result.stdout, "then\n");
}
#[tokio::test]
async fn test_reserved_words_in_quotes_are_arguments() {
let mut bash = Bash::new();
let result = bash.exec("echo 'done' 'fi' 'then'").await.unwrap();
assert_eq!(result.stdout, "done fi then\n");
}
#[tokio::test]
async fn test_nested_loops_done_keyword() {
let mut bash = Bash::new();
let result = bash
.exec("for i in 1; do for j in a; do echo $i$j; done; done")
.await
.unwrap();
assert_eq!(result.stdout, "1a\n");
}
#[tokio::test]
async fn test_dev_null_read_returns_empty() {
let mut bash = Bash::new();
let result = bash.exec("cat /dev/null").await.unwrap();
assert_eq!(result.stdout, "");
}
#[tokio::test]
async fn test_dev_null_append() {
let mut bash = Bash::new();
let result = bash.exec("echo hello >> /dev/null; echo ok").await.unwrap();
assert_eq!(result.stdout, "ok\n");
}
#[tokio::test]
async fn test_dev_null_in_pipeline() {
let mut bash = Bash::new();
let result = bash
.exec("echo hello | cat > /dev/null; echo ok")
.await
.unwrap();
assert_eq!(result.stdout, "ok\n");
}
#[tokio::test]
async fn test_dev_null_exists() {
let mut bash = Bash::new();
let result = bash.exec("cat /dev/null; echo exit_$?").await.unwrap();
assert_eq!(result.stdout, "exit_0\n");
}
#[tokio::test]
async fn test_custom_username_whoami() {
let mut bash = Bash::builder().username("alice").build();
let result = bash.exec("whoami").await.unwrap();
assert_eq!(result.stdout, "alice\n");
}
#[tokio::test]
async fn test_custom_username_id() {
let mut bash = Bash::builder().username("bob").build();
let result = bash.exec("id").await.unwrap();
assert!(result.stdout.contains("uid=1000(bob)"));
assert!(result.stdout.contains("gid=1000(bob)"));
}
#[tokio::test]
async fn test_custom_username_sets_user_env() {
let mut bash = Bash::builder().username("charlie").build();
let result = bash.exec("echo $USER").await.unwrap();
assert_eq!(result.stdout, "charlie\n");
}
#[tokio::test]
async fn test_default_ppid_is_sandboxed() {
let mut bash = Bash::new();
let result = bash.exec("echo $PPID").await.unwrap();
assert_eq!(result.stdout, "0\n");
}
#[tokio::test]
async fn test_custom_hostname() {
let mut bash = Bash::builder().hostname("my-server").build();
let result = bash.exec("hostname").await.unwrap();
assert_eq!(result.stdout, "my-server\n");
}
#[tokio::test]
async fn test_custom_hostname_uname() {
let mut bash = Bash::builder().hostname("custom-host").build();
let result = bash.exec("uname -n").await.unwrap();
assert_eq!(result.stdout, "custom-host\n");
}
#[tokio::test]
async fn test_default_username_and_hostname() {
let mut bash = Bash::new();
let result = bash.exec("whoami").await.unwrap();
assert_eq!(result.stdout, "sandbox\n");
let result = bash.exec("hostname").await.unwrap();
assert_eq!(result.stdout, "bashkit-sandbox\n");
}
#[tokio::test]
async fn test_custom_username_and_hostname_combined() {
let mut bash = Bash::builder()
.username("deploy")
.hostname("prod-server-01")
.build();
let result = bash.exec("whoami && hostname").await.unwrap();
assert_eq!(result.stdout, "deploy\nprod-server-01\n");
let result = bash.exec("echo $USER").await.unwrap();
assert_eq!(result.stdout, "deploy\n");
}
mod custom_builtins {
use super::*;
use crate::builtins::{Builtin, Context};
use crate::{ExecResult, ExecutionExtensions, Extension};
use async_trait::async_trait;
struct Hello;
#[async_trait]
impl Builtin for Hello {
async fn execute(&self, _ctx: Context<'_>) -> crate::Result<ExecResult> {
Ok(ExecResult::ok("Hello from custom builtin!\n".to_string()))
}
}
#[tokio::test]
async fn test_custom_builtin_basic() {
let mut bash = Bash::builder().builtin("hello", Box::new(Hello)).build();
let result = bash.exec("hello").await.unwrap();
assert_eq!(result.stdout, "Hello from custom builtin!\n");
assert_eq!(result.exit_code, 0);
}
struct ExecutionScoped;
#[async_trait]
impl Builtin for ExecutionScoped {
async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
let value = ctx
.execution_extension::<String>()
.cloned()
.unwrap_or_else(|| "missing".to_string());
Ok(ExecResult::ok(format!("{value}\n")))
}
}
#[tokio::test]
async fn test_custom_builtin_execution_extensions_are_per_call() {
let mut bash = Bash::builder()
.builtin("read-ext", Box::new(ExecutionScoped))
.build();
let result = bash
.exec_with_extensions(
"read-ext",
ExecutionExtensions::new().with("scoped".to_string()),
)
.await
.unwrap();
assert_eq!(result.stdout, "scoped\n");
let result = bash.exec("read-ext").await.unwrap();
assert_eq!(result.stdout, "missing\n");
}
struct Greet;
#[async_trait]
impl Builtin for Greet {
async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World");
Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
}
}
#[tokio::test]
async fn test_custom_builtin_with_args() {
let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
let result = bash.exec("greet").await.unwrap();
assert_eq!(result.stdout, "Hello, World!\n");
let result = bash.exec("greet Alice").await.unwrap();
assert_eq!(result.stdout, "Hello, Alice!\n");
let result = bash.exec("greet Bob Charlie").await.unwrap();
assert_eq!(result.stdout, "Hello, Bob!\n");
}
struct Upper;
#[async_trait]
impl Builtin for Upper {
async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
let input = ctx.stdin.unwrap_or("");
Ok(ExecResult::ok(input.to_uppercase()))
}
}
#[tokio::test]
async fn test_custom_builtin_with_stdin() {
let mut bash = Bash::builder().builtin("upper", Box::new(Upper)).build();
let result = bash.exec("echo hello | upper").await.unwrap();
assert_eq!(result.stdout, "HELLO\n");
}
struct WriteFile;
#[async_trait]
impl Builtin for WriteFile {
async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
if ctx.args.len() < 2 {
return Ok(ExecResult::err(
"Usage: writefile <path> <content>\n".to_string(),
1,
));
}
let path = std::path::Path::new(&ctx.args[0]);
let content = ctx.args[1..].join(" ");
ctx.fs.write_file(path, content.as_bytes()).await?;
Ok(ExecResult::ok(String::new()))
}
}
#[tokio::test]
async fn test_custom_builtin_with_filesystem() {
let mut bash = Bash::builder()
.builtin("writefile", Box::new(WriteFile))
.build();
bash.exec("writefile /tmp/test.txt custom content here")
.await
.unwrap();
let result = bash.exec("cat /tmp/test.txt").await.unwrap();
assert_eq!(result.stdout, "custom content here");
}
struct CustomEcho;
#[async_trait]
impl Builtin for CustomEcho {
async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
let msg = ctx.args.join(" ");
Ok(ExecResult::ok(format!("[CUSTOM] {}\n", msg)))
}
}
#[tokio::test]
async fn test_custom_builtin_override_default() {
let mut bash = Bash::builder()
.builtin("echo", Box::new(CustomEcho))
.build();
let result = bash.exec("echo hello world").await.unwrap();
assert_eq!(result.stdout, "[CUSTOM] hello world\n");
}
#[tokio::test]
async fn test_multiple_custom_builtins() {
let mut bash = Bash::builder()
.builtin("hello", Box::new(Hello))
.builtin("greet", Box::new(Greet))
.builtin("upper", Box::new(Upper))
.build();
let result = bash.exec("hello").await.unwrap();
assert_eq!(result.stdout, "Hello from custom builtin!\n");
let result = bash.exec("greet Test").await.unwrap();
assert_eq!(result.stdout, "Hello, Test!\n");
let result = bash.exec("echo foo | upper").await.unwrap();
assert_eq!(result.stdout, "FOO\n");
}
struct GreetingExtension;
impl Extension for GreetingExtension {
fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
vec![
("hello-ext".to_string(), Box::new(Hello)),
("greet-ext".to_string(), Box::new(Greet)),
]
}
}
#[tokio::test]
async fn test_extension_registers_multiple_builtins() {
let mut bash = Bash::builder().extension(GreetingExtension).build();
let result = bash.exec("hello-ext").await.unwrap();
assert_eq!(result.stdout, "Hello from custom builtin!\n");
let result = bash.exec("greet-ext Extension").await.unwrap();
assert_eq!(result.stdout, "Hello, Extension!\n");
}
struct Counter {
prefix: String,
}
#[async_trait]
impl Builtin for Counter {
async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
let count = ctx
.args
.first()
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1);
let mut output = String::new();
for i in 1..=count {
output.push_str(&format!("{}{}\n", self.prefix, i));
}
Ok(ExecResult::ok(output))
}
}
#[tokio::test]
async fn test_custom_builtin_with_state() {
let mut bash = Bash::builder()
.builtin(
"count",
Box::new(Counter {
prefix: "Item ".to_string(),
}),
)
.build();
let result = bash.exec("count 3").await.unwrap();
assert_eq!(result.stdout, "Item 1\nItem 2\nItem 3\n");
}
struct Fail;
#[async_trait]
impl Builtin for Fail {
async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
let code = ctx
.args
.first()
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1);
Ok(ExecResult::err(
format!("Failed with code {}\n", code),
code,
))
}
}
#[tokio::test]
async fn test_custom_builtin_error() {
let mut bash = Bash::builder().builtin("fail", Box::new(Fail)).build();
let result = bash.exec("fail 42").await.unwrap();
assert_eq!(result.exit_code, 42);
assert_eq!(result.stderr, "Failed with code 42\n");
}
#[tokio::test]
async fn test_custom_builtin_in_script() {
let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
let script = r#"
for name in Alice Bob Charlie; do
greet $name
done
"#;
let result = bash.exec(script).await.unwrap();
assert_eq!(
result.stdout,
"Hello, Alice!\nHello, Bob!\nHello, Charlie!\n"
);
}
#[tokio::test]
async fn test_custom_builtin_with_conditionals() {
let mut bash = Bash::builder()
.builtin("fail", Box::new(Fail))
.builtin("hello", Box::new(Hello))
.build();
let result = bash.exec("fail 1 || hello").await.unwrap();
assert_eq!(result.stdout, "Hello from custom builtin!\n");
assert_eq!(result.exit_code, 0);
let result = bash.exec("hello && fail 5").await.unwrap();
assert_eq!(result.exit_code, 5);
}
struct EnvReader;
#[async_trait]
impl Builtin for EnvReader {
async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
let var_name = ctx.args.first().map(|s| s.as_str()).unwrap_or("HOME");
let value = ctx
.env
.get(var_name)
.map(|s| s.as_str())
.unwrap_or("(not set)");
Ok(ExecResult::ok(format!("{}={}\n", var_name, value)))
}
}
#[tokio::test]
async fn test_custom_builtin_reads_env() {
let mut bash = Bash::builder()
.env("MY_VAR", "my_value")
.builtin("readenv", Box::new(EnvReader))
.build();
let result = bash.exec("readenv MY_VAR").await.unwrap();
assert_eq!(result.stdout, "MY_VAR=my_value\n");
let result = bash.exec("readenv UNKNOWN").await.unwrap();
assert_eq!(result.stdout, "UNKNOWN=(not set)\n");
}
}
#[tokio::test]
async fn test_parser_timeout_default() {
let limits = ExecutionLimits::default();
assert_eq!(limits.parser_timeout, std::time::Duration::from_secs(5));
}
#[tokio::test]
async fn test_parser_timeout_custom() {
let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_millis(100));
assert_eq!(limits.parser_timeout, std::time::Duration::from_millis(100));
}
#[tokio::test]
async fn test_parser_timeout_normal_script() {
let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_secs(1));
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("echo hello").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_parser_fuel_default() {
let limits = ExecutionLimits::default();
assert_eq!(limits.max_parser_operations, 100_000);
}
#[tokio::test]
async fn test_parser_fuel_custom() {
let limits = ExecutionLimits::new().max_parser_operations(1000);
assert_eq!(limits.max_parser_operations, 1000);
}
#[tokio::test]
async fn test_parser_fuel_normal_script() {
let limits = ExecutionLimits::new().max_parser_operations(1000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("echo hello").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_input_size_limit_default() {
let limits = ExecutionLimits::default();
assert_eq!(limits.max_input_bytes, 10_000_000);
}
#[tokio::test]
async fn test_input_size_limit_custom() {
let limits = ExecutionLimits::new().max_input_bytes(1000);
assert_eq!(limits.max_input_bytes, 1000);
}
#[tokio::test]
async fn test_input_size_limit_enforced() {
let limits = ExecutionLimits::new().max_input_bytes(10);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("echo hello world").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("input too large"),
"Expected input size error, got: {}",
err
);
}
#[tokio::test]
async fn test_input_size_limit_normal_script() {
let limits = ExecutionLimits::new().max_input_bytes(1000);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("echo hello").await.unwrap();
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_ast_depth_limit_default() {
let limits = ExecutionLimits::default();
assert_eq!(limits.max_ast_depth, 100);
}
#[tokio::test]
async fn test_ast_depth_limit_custom() {
let limits = ExecutionLimits::new().max_ast_depth(10);
assert_eq!(limits.max_ast_depth, 10);
}
#[tokio::test]
async fn test_ast_depth_limit_normal_script() {
let limits = ExecutionLimits::new().max_ast_depth(10);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("if true; then echo ok; fi").await.unwrap();
assert_eq!(result.stdout, "ok\n");
}
#[tokio::test]
async fn test_ast_depth_limit_enforced() {
let limits = ExecutionLimits::new().max_ast_depth(2);
let mut bash = Bash::builder().limits(limits).build();
let result = bash
.exec("if true; then if true; then if true; then echo nested; fi; fi; fi")
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("AST nesting too deep"),
"Expected AST depth error, got: {}",
err
);
}
#[tokio::test]
async fn test_parser_fuel_enforced() {
let limits = ExecutionLimits::new().max_parser_operations(3);
let mut bash = Bash::builder().limits(limits).build();
let result = bash.exec("echo a; echo b; echo c").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("parser fuel exhausted"),
"Expected parser fuel error, got: {}",
err
);
}
#[tokio::test]
async fn test_set_e_basic() {
let mut bash = Bash::new();
let result = bash
.exec("set -e; true; false; echo should_not_reach")
.await
.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn test_set_e_after_failing_cmd() {
let mut bash = Bash::new();
let result = bash
.exec("set -e; echo before; false; echo after")
.await
.unwrap();
assert_eq!(result.stdout, "before\n");
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn test_set_e_disabled() {
let mut bash = Bash::new();
let result = bash
.exec("set -e; set +e; false; echo still_running")
.await
.unwrap();
assert_eq!(result.stdout, "still_running\n");
}
#[tokio::test]
async fn test_set_e_in_pipeline_last() {
let mut bash = Bash::new();
let result = bash
.exec("set -e; false | true; echo reached")
.await
.unwrap();
assert_eq!(result.stdout, "reached\n");
}
#[tokio::test]
async fn test_set_e_in_if_condition() {
let mut bash = Bash::new();
let result = bash
.exec("set -e; if false; then echo yes; else echo no; fi; echo done")
.await
.unwrap();
assert_eq!(result.stdout, "no\ndone\n");
}
#[tokio::test]
async fn test_set_e_in_while_condition() {
let mut bash = Bash::new();
let result = bash
.exec("set -e; x=0; while [ \"$x\" -lt 2 ]; do echo \"x=$x\"; x=$((x + 1)); done; echo done")
.await
.unwrap();
assert_eq!(result.stdout, "x=0\nx=1\ndone\n");
}
#[tokio::test]
async fn test_set_e_in_brace_group() {
let mut bash = Bash::new();
let result = bash
.exec("set -e; { echo start; false; echo unreached; }; echo after")
.await
.unwrap();
assert_eq!(result.stdout, "start\n");
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn test_set_e_and_chain() {
let mut bash = Bash::new();
let result = bash
.exec("set -e; false && echo one; echo reached")
.await
.unwrap();
assert_eq!(result.stdout, "reached\n");
}
#[tokio::test]
async fn test_set_e_or_chain() {
let mut bash = Bash::new();
let result = bash
.exec("set -e; true || false; echo reached")
.await
.unwrap();
assert_eq!(result.stdout, "reached\n");
}
#[tokio::test]
async fn test_tilde_expansion_basic() {
let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
let result = bash.exec("echo ~").await.unwrap();
assert_eq!(result.stdout, "/home/testuser\n");
}
#[tokio::test]
async fn test_tilde_expansion_with_path() {
let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
let result = bash.exec("echo ~/documents/file.txt").await.unwrap();
assert_eq!(result.stdout, "/home/testuser/documents/file.txt\n");
}
#[tokio::test]
async fn test_tilde_expansion_in_assignment() {
let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
let result = bash.exec("DIR=~/data; echo $DIR").await.unwrap();
assert_eq!(result.stdout, "/home/testuser/data\n");
}
#[tokio::test]
async fn test_tilde_expansion_default_home() {
let mut bash = Bash::new();
let result = bash.exec("echo ~").await.unwrap();
assert_eq!(result.stdout, "/home/sandbox\n");
}
#[tokio::test]
async fn test_tilde_not_at_start() {
let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
let result = bash.exec("echo foo~bar").await.unwrap();
assert_eq!(result.stdout, "foo~bar\n");
}
#[tokio::test]
async fn test_special_var_dollar_dollar() {
let mut bash = Bash::new();
let result = bash.exec("echo $$").await.unwrap();
let pid: u32 = result.stdout.trim().parse().expect("$$ should be a number");
assert!(pid > 0, "$$ should be a positive number");
}
#[tokio::test]
async fn test_special_var_random() {
let mut bash = Bash::new();
let result = bash.exec("echo $RANDOM").await.unwrap();
let random: u32 = result
.stdout
.trim()
.parse()
.expect("$RANDOM should be a number");
assert!(random < 32768, "$RANDOM should be < 32768");
}
#[tokio::test]
async fn test_special_var_random_varies() {
let mut bash = Bash::new();
let result1 = bash.exec("echo $RANDOM").await.unwrap();
let result2 = bash.exec("echo $RANDOM").await.unwrap();
let _: u32 = result1
.stdout
.trim()
.parse()
.expect("$RANDOM should be a number");
let _: u32 = result2
.stdout
.trim()
.parse()
.expect("$RANDOM should be a number");
}
#[tokio::test]
async fn test_random_different_instances() {
let mut bash1 = Bash::new();
let mut bash2 = Bash::new();
let r1 = bash1.exec("echo $RANDOM").await.unwrap();
let r2 = bash2.exec("echo $RANDOM").await.unwrap();
let v1: u32 = r1.stdout.trim().parse().expect("should be a number");
let v2: u32 = r2.stdout.trim().parse().expect("should be a number");
assert!(v1 < 32768);
assert!(v2 < 32768);
assert_ne!(v1, v2, "separate instances should produce different values");
}
#[tokio::test]
async fn test_random_reseed() {
let mut bash1 = Bash::new();
let mut bash2 = Bash::new();
bash1.exec("RANDOM=42").await.unwrap();
bash2.exec("RANDOM=42").await.unwrap();
let r1 = bash1.exec("echo $RANDOM").await.unwrap();
let r2 = bash2.exec("echo $RANDOM").await.unwrap();
assert_eq!(
r1.stdout, r2.stdout,
"same seed should produce same first value"
);
}
#[tokio::test]
async fn test_random_sequential_varies() {
let mut bash = Bash::new();
let result = bash.exec("echo $RANDOM $RANDOM $RANDOM").await.unwrap();
let values: Vec<u32> = result
.stdout
.split_whitespace()
.map(|s| s.parse().expect("should be a number"))
.collect();
assert_eq!(values.len(), 3);
assert!(
values[0] != values[1] || values[1] != values[2],
"sequential RANDOM calls should produce different values"
);
}
#[tokio::test]
async fn test_special_var_lineno() {
let mut bash = Bash::new();
let result = bash.exec("echo $LINENO").await.unwrap();
assert_eq!(result.stdout, "1\n");
}
#[tokio::test]
async fn test_lineno_multiline() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"echo "line $LINENO"
echo "line $LINENO"
echo "line $LINENO""#,
)
.await
.unwrap();
assert_eq!(result.stdout, "line 1\nline 2\nline 3\n");
}
#[tokio::test]
async fn test_lineno_in_loop() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"for i in 1 2; do
echo "loop $LINENO"
done"#,
)
.await
.unwrap();
assert_eq!(result.stdout, "loop 2\nloop 2\n");
}
#[tokio::test]
async fn test_file_test_r_readable() {
let mut bash = Bash::new();
bash.exec("echo hello > /tmp/readable.txt").await.unwrap();
let result = bash
.exec("test -r /tmp/readable.txt && echo yes")
.await
.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_file_test_r_not_exists() {
let mut bash = Bash::new();
let result = bash
.exec("test -r /tmp/nonexistent.txt && echo yes || echo no")
.await
.unwrap();
assert_eq!(result.stdout, "no\n");
}
#[tokio::test]
async fn test_file_test_w_writable() {
let mut bash = Bash::new();
bash.exec("echo hello > /tmp/writable.txt").await.unwrap();
let result = bash
.exec("test -w /tmp/writable.txt && echo yes")
.await
.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_file_test_x_executable() {
let mut bash = Bash::new();
bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
.await
.unwrap();
bash.exec("chmod 755 /tmp/script.sh").await.unwrap();
let result = bash
.exec("test -x /tmp/script.sh && echo yes")
.await
.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_file_test_x_not_executable() {
let mut bash = Bash::new();
bash.exec("echo 'data' > /tmp/noexec.txt").await.unwrap();
bash.exec("chmod 644 /tmp/noexec.txt").await.unwrap();
let result = bash
.exec("test -x /tmp/noexec.txt && echo yes || echo no")
.await
.unwrap();
assert_eq!(result.stdout, "no\n");
}
#[tokio::test]
async fn test_file_test_e_exists() {
let mut bash = Bash::new();
bash.exec("echo hello > /tmp/exists.txt").await.unwrap();
let result = bash
.exec("test -e /tmp/exists.txt && echo yes")
.await
.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_file_test_f_regular() {
let mut bash = Bash::new();
bash.exec("echo hello > /tmp/regular.txt").await.unwrap();
let result = bash
.exec("test -f /tmp/regular.txt && echo yes")
.await
.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_file_test_d_directory() {
let mut bash = Bash::new();
bash.exec("mkdir -p /tmp/mydir").await.unwrap();
let result = bash.exec("test -d /tmp/mydir && echo yes").await.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_file_test_s_size() {
let mut bash = Bash::new();
bash.exec("echo hello > /tmp/nonempty.txt").await.unwrap();
let result = bash
.exec("test -s /tmp/nonempty.txt && echo yes")
.await
.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_redirect_both_stdout_stderr() {
let mut bash = Bash::new();
let result = bash.exec("echo hello &> /tmp/out.txt").await.unwrap();
assert_eq!(result.stdout, "");
let check = bash.exec("cat /tmp/out.txt").await.unwrap();
assert_eq!(check.stdout, "hello\n");
}
#[tokio::test]
async fn test_stderr_redirect_to_file() {
let mut bash = Bash::new();
bash.exec("echo stdout; echo stderr 2> /tmp/err.txt")
.await
.unwrap();
}
#[tokio::test]
async fn test_fd_redirect_parsing() {
let mut bash = Bash::new();
let result = bash.exec("true 2> /tmp/err.txt").await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_fd_redirect_append_parsing() {
let mut bash = Bash::new();
let result = bash.exec("true 2>> /tmp/err.txt").await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_fd_dup_parsing() {
let mut bash = Bash::new();
let result = bash.exec("echo hello 2>&1").await.unwrap();
assert_eq!(result.stdout, "hello\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_dup_output_redirect_stdout_to_stderr() {
let mut bash = Bash::new();
let result = bash.exec("echo hello >&2").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.stderr, "hello\n");
}
#[tokio::test]
async fn test_lexer_redirect_both() {
let mut bash = Bash::new();
let result = bash.exec("echo test &> /tmp/both.txt").await.unwrap();
assert_eq!(result.stdout, "");
let check = bash.exec("cat /tmp/both.txt").await.unwrap();
assert_eq!(check.stdout, "test\n");
}
#[tokio::test]
async fn test_lexer_dup_output() {
let mut bash = Bash::new();
let result = bash.exec("echo test >&2").await.unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.stderr, "test\n");
}
#[tokio::test]
async fn test_digit_before_redirect() {
let mut bash = Bash::new();
let result = bash.exec("echo hello 2> /tmp/err.txt").await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "hello\n");
}
#[tokio::test]
async fn test_arithmetic_logical_and_true() {
let mut bash = Bash::new();
let result = bash.exec("echo $((1 && 1))").await.unwrap();
assert_eq!(result.stdout, "1\n");
}
#[tokio::test]
async fn test_arithmetic_logical_and_false_left() {
let mut bash = Bash::new();
let result = bash.exec("echo $((0 && 1))").await.unwrap();
assert_eq!(result.stdout, "0\n");
}
#[tokio::test]
async fn test_arithmetic_logical_and_false_right() {
let mut bash = Bash::new();
let result = bash.exec("echo $((1 && 0))").await.unwrap();
assert_eq!(result.stdout, "0\n");
}
#[tokio::test]
async fn test_arithmetic_logical_or_false() {
let mut bash = Bash::new();
let result = bash.exec("echo $((0 || 0))").await.unwrap();
assert_eq!(result.stdout, "0\n");
}
#[tokio::test]
async fn test_arithmetic_logical_or_true_left() {
let mut bash = Bash::new();
let result = bash.exec("echo $((1 || 0))").await.unwrap();
assert_eq!(result.stdout, "1\n");
}
#[tokio::test]
async fn test_arithmetic_logical_or_true_right() {
let mut bash = Bash::new();
let result = bash.exec("echo $((0 || 1))").await.unwrap();
assert_eq!(result.stdout, "1\n");
}
#[tokio::test]
async fn test_arithmetic_logical_combined() {
let mut bash = Bash::new();
let result = bash.exec("echo $((5 > 3 && 2 < 4))").await.unwrap();
assert_eq!(result.stdout, "1\n");
}
#[tokio::test]
async fn test_arithmetic_logical_with_comparison() {
let mut bash = Bash::new();
let result = bash.exec("echo $((5 < 3 || 2 < 4))").await.unwrap();
assert_eq!(result.stdout, "1\n");
}
#[tokio::test]
async fn test_arithmetic_multibyte_no_panic() {
let mut bash = Bash::new();
let result = bash.exec("echo $((0,1))").await.unwrap();
assert_eq!(result.stdout, "1\n");
let _ = bash.exec("echo $((\u{00e9}+1))").await;
}
#[tokio::test]
async fn test_brace_expansion_list() {
let mut bash = Bash::new();
let result = bash.exec("echo {a,b,c}").await.unwrap();
assert_eq!(result.stdout, "a b c\n");
}
#[tokio::test]
async fn test_brace_expansion_with_prefix() {
let mut bash = Bash::new();
let result = bash.exec("echo file{1,2,3}.txt").await.unwrap();
assert_eq!(result.stdout, "file1.txt file2.txt file3.txt\n");
}
#[tokio::test]
async fn test_brace_expansion_numeric_range() {
let mut bash = Bash::new();
let result = bash.exec("echo {1..5}").await.unwrap();
assert_eq!(result.stdout, "1 2 3 4 5\n");
}
#[tokio::test]
async fn test_brace_expansion_char_range() {
let mut bash = Bash::new();
let result = bash.exec("echo {a..e}").await.unwrap();
assert_eq!(result.stdout, "a b c d e\n");
}
#[tokio::test]
async fn test_brace_expansion_reverse_range() {
let mut bash = Bash::new();
let result = bash.exec("echo {5..1}").await.unwrap();
assert_eq!(result.stdout, "5 4 3 2 1\n");
}
#[tokio::test]
async fn test_brace_expansion_nested() {
let mut bash = Bash::new();
let result = bash.exec("echo {a,b}{1,2}").await.unwrap();
assert_eq!(result.stdout, "a1 a2 b1 b2\n");
}
#[tokio::test]
async fn test_brace_expansion_with_suffix() {
let mut bash = Bash::new();
let result = bash.exec("echo pre{x,y}suf").await.unwrap();
assert_eq!(result.stdout, "prexsuf preysuf\n");
}
#[tokio::test]
async fn test_brace_expansion_empty_item() {
let mut bash = Bash::new();
let result = bash.exec("echo x{,y}z").await.unwrap();
assert_eq!(result.stdout, "xz xyz\n");
}
#[tokio::test]
async fn test_string_less_than() {
let mut bash = Bash::new();
let result = bash
.exec("test apple '<' banana && echo yes")
.await
.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_string_greater_than() {
let mut bash = Bash::new();
let result = bash
.exec("test banana '>' apple && echo yes")
.await
.unwrap();
assert_eq!(result.stdout, "yes\n");
}
#[tokio::test]
async fn test_string_less_than_false() {
let mut bash = Bash::new();
let result = bash
.exec("test banana '<' apple && echo yes || echo no")
.await
.unwrap();
assert_eq!(result.stdout, "no\n");
}
#[tokio::test]
async fn test_array_indices_basic() {
let mut bash = Bash::new();
let result = bash.exec("arr=(a b c); echo ${!arr[@]}").await.unwrap();
assert_eq!(result.stdout, "0 1 2\n");
}
#[tokio::test]
async fn test_array_indices_sparse() {
let mut bash = Bash::new();
let result = bash
.exec("arr[0]=a; arr[5]=b; arr[10]=c; echo ${!arr[@]}")
.await
.unwrap();
assert_eq!(result.stdout, "0 5 10\n");
}
#[tokio::test]
async fn test_array_indices_star() {
let mut bash = Bash::new();
let result = bash.exec("arr=(x y z); echo ${!arr[*]}").await.unwrap();
assert_eq!(result.stdout, "0 1 2\n");
}
#[tokio::test]
async fn test_array_indices_empty() {
let mut bash = Bash::new();
let result = bash.exec("arr=(); echo \"${!arr[@]}\"").await.unwrap();
assert_eq!(result.stdout, "\n");
}
#[tokio::test]
async fn test_text_file_basic() {
let mut bash = Bash::builder()
.mount_text("/config/app.conf", "debug=true\nport=8080\n")
.build();
let result = bash.exec("cat /config/app.conf").await.unwrap();
assert_eq!(result.stdout, "debug=true\nport=8080\n");
}
#[tokio::test]
async fn test_text_file_multiple() {
let mut bash = Bash::builder()
.mount_text("/data/file1.txt", "content one")
.mount_text("/data/file2.txt", "content two")
.mount_text("/other/file3.txt", "content three")
.build();
let result = bash.exec("cat /data/file1.txt").await.unwrap();
assert_eq!(result.stdout, "content one");
let result = bash.exec("cat /data/file2.txt").await.unwrap();
assert_eq!(result.stdout, "content two");
let result = bash.exec("cat /other/file3.txt").await.unwrap();
assert_eq!(result.stdout, "content three");
}
#[tokio::test]
async fn test_text_file_nested_directory() {
let mut bash = Bash::builder()
.mount_text("/a/b/c/d/file.txt", "nested content")
.build();
let result = bash.exec("cat /a/b/c/d/file.txt").await.unwrap();
assert_eq!(result.stdout, "nested content");
}
#[tokio::test]
async fn test_text_file_mode() {
let bash = Bash::builder()
.mount_text("/tmp/writable.txt", "content")
.build();
let stat = bash
.fs()
.stat(std::path::Path::new("/tmp/writable.txt"))
.await
.unwrap();
assert_eq!(stat.mode, 0o644);
}
#[tokio::test]
async fn test_readonly_text_basic() {
let mut bash = Bash::builder()
.mount_readonly_text("/etc/version", "1.2.3")
.build();
let result = bash.exec("cat /etc/version").await.unwrap();
assert_eq!(result.stdout, "1.2.3");
}
#[tokio::test]
async fn test_readonly_text_mode() {
let bash = Bash::builder()
.mount_readonly_text("/etc/readonly.conf", "immutable")
.build();
let stat = bash
.fs()
.stat(std::path::Path::new("/etc/readonly.conf"))
.await
.unwrap();
assert_eq!(stat.mode, 0o444);
}
#[tokio::test]
async fn test_text_file_mixed_readonly_writable() {
let bash = Bash::builder()
.mount_text("/data/writable.txt", "can edit")
.mount_readonly_text("/data/readonly.txt", "cannot edit")
.build();
let writable_stat = bash
.fs()
.stat(std::path::Path::new("/data/writable.txt"))
.await
.unwrap();
let readonly_stat = bash
.fs()
.stat(std::path::Path::new("/data/readonly.txt"))
.await
.unwrap();
assert_eq!(writable_stat.mode, 0o644);
assert_eq!(readonly_stat.mode, 0o444);
}
#[tokio::test]
async fn test_text_file_with_env() {
let mut bash = Bash::builder()
.env("APP_NAME", "testapp")
.mount_text("/config/app.conf", "name=${APP_NAME}")
.build();
let result = bash.exec("echo $APP_NAME").await.unwrap();
assert_eq!(result.stdout, "testapp\n");
let result = bash.exec("cat /config/app.conf").await.unwrap();
assert_eq!(result.stdout, "name=${APP_NAME}");
}
#[tokio::test]
#[cfg(feature = "jq")]
async fn test_text_file_json() {
let mut bash = Bash::builder()
.mount_text("/data/users.json", r#"["alice", "bob", "charlie"]"#)
.build();
let result = bash.exec("cat /data/users.json | jq '.[0]'").await.unwrap();
assert_eq!(result.stdout, "\"alice\"\n");
}
#[tokio::test]
async fn test_mount_with_custom_filesystem() {
let custom_fs = std::sync::Arc::new(InMemoryFs::new());
custom_fs
.write_file(std::path::Path::new("/base.txt"), b"from base")
.await
.unwrap();
let mut bash = Bash::builder()
.fs(custom_fs)
.mount_text("/mounted.txt", "from mount")
.mount_readonly_text("/readonly.txt", "immutable")
.build();
let result = bash.exec("cat /base.txt").await.unwrap();
assert_eq!(result.stdout, "from base");
let result = bash.exec("cat /mounted.txt").await.unwrap();
assert_eq!(result.stdout, "from mount");
let result = bash.exec("cat /readonly.txt").await.unwrap();
assert_eq!(result.stdout, "immutable");
let stat = bash
.fs()
.stat(std::path::Path::new("/readonly.txt"))
.await
.unwrap();
assert_eq!(stat.mode, 0o444);
}
#[tokio::test]
async fn test_mount_overwrites_base_file() {
let custom_fs = std::sync::Arc::new(InMemoryFs::new());
custom_fs
.write_file(std::path::Path::new("/config.txt"), b"original")
.await
.unwrap();
let mut bash = Bash::builder()
.fs(custom_fs)
.mount_text("/config.txt", "overwritten")
.build();
let result = bash.exec("cat /config.txt").await.unwrap();
assert_eq!(result.stdout, "overwritten");
}
#[tokio::test]
async fn test_mount_preserves_custom_fs_limits() {
let limited_fs =
std::sync::Arc::new(InMemoryFs::with_limits(FsLimits::new().max_total_bytes(32)));
let bash = Bash::builder()
.fs(limited_fs)
.mount_text("/mounted.txt", "seed")
.build();
let write_err = bash
.fs()
.write_file(
std::path::Path::new("/too-big.txt"),
b"this payload should exceed thirty-two bytes",
)
.await;
assert!(write_err.is_err(), "custom fs limits should still apply");
}
#[tokio::test]
async fn test_mount_text_respects_filesystem_limits() {
let limited_fs = std::sync::Arc::new(InMemoryFs::with_limits(
FsLimits::new().max_total_bytes(5).max_file_size(5),
));
let bash = Bash::builder()
.fs(limited_fs)
.mount_text("/too-large.txt", "123456")
.build();
let exists = bash
.fs()
.exists(std::path::Path::new("/too-large.txt"))
.await
.unwrap();
assert!(!exists, "mount_text should not bypass configured FsLimits");
}
#[tokio::test]
async fn test_parse_error_includes_line_number() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"echo ok
if true; then
echo missing fi"#,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{}", err);
assert!(
err_msg.contains("line") || err_msg.contains("parse"),
"Error should be a parse error: {}",
err_msg
);
}
#[tokio::test]
async fn test_parse_error_on_specific_line() {
use crate::parser::Parser;
let script = "echo line1\necho line2\nif true; then\n";
let result = Parser::new(script).parse();
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = format!("{}", err);
assert!(
err_msg.contains("expected") || err_msg.contains("syntax error"),
"Error should be a parse error: {}",
err_msg
);
}
#[tokio::test]
async fn test_cd_to_root_and_ls() {
let mut bash = Bash::new();
let result = bash.exec("cd / && ls").await.unwrap();
assert_eq!(
result.exit_code, 0,
"cd / && ls should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("tmp"), "Root should contain tmp");
assert!(result.stdout.contains("home"), "Root should contain home");
}
#[tokio::test]
async fn test_cd_to_root_and_pwd() {
let mut bash = Bash::new();
let result = bash.exec("cd / && pwd").await.unwrap();
assert_eq!(result.exit_code, 0, "cd / && pwd should succeed");
assert_eq!(result.stdout.trim(), "/");
}
#[tokio::test]
async fn test_cd_to_root_and_ls_dot() {
let mut bash = Bash::new();
let result = bash.exec("cd / && ls .").await.unwrap();
assert_eq!(
result.exit_code, 0,
"cd / && ls . should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("tmp"), "Root should contain tmp");
assert!(result.stdout.contains("home"), "Root should contain home");
}
#[tokio::test]
async fn test_ls_root_directly() {
let mut bash = Bash::new();
let result = bash.exec("ls /").await.unwrap();
assert_eq!(
result.exit_code, 0,
"ls / should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("tmp"), "Root should contain tmp");
assert!(result.stdout.contains("home"), "Root should contain home");
assert!(result.stdout.contains("dev"), "Root should contain dev");
}
#[tokio::test]
async fn test_ls_root_long_format() {
let mut bash = Bash::new();
let result = bash.exec("ls -la /").await.unwrap();
assert_eq!(
result.exit_code, 0,
"ls -la / should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("tmp"), "Root should contain tmp");
assert!(
result.stdout.contains("drw"),
"Should show directory permissions"
);
}
#[tokio::test]
async fn test_heredoc_redirect_to_file() {
let mut bash = Bash::new();
let result = bash
.exec("cat > /tmp/out.txt <<'EOF'\nhello\nworld\nEOF\ncat /tmp/out.txt")
.await
.unwrap();
assert_eq!(result.stdout, "hello\nworld\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_heredoc_redirect_to_file_unquoted() {
let mut bash = Bash::new();
let result = bash
.exec("cat > /tmp/out.txt <<EOF\nhello\nworld\nEOF\ncat /tmp/out.txt")
.await
.unwrap();
assert_eq!(result.stdout, "hello\nworld\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_pipe_to_while_read() {
let mut bash = Bash::new();
let result = bash
.exec("echo -e 'a\\nb\\nc' | while read line; do echo \"got: $line\"; done")
.await
.unwrap();
assert!(
result.stdout.contains("got: a"),
"stdout: {}",
result.stdout
);
assert!(
result.stdout.contains("got: b"),
"stdout: {}",
result.stdout
);
assert!(
result.stdout.contains("got: c"),
"stdout: {}",
result.stdout
);
}
#[tokio::test]
async fn test_pipe_to_while_read_count() {
let mut bash = Bash::new();
let result = bash
.exec("printf 'x\\ny\\nz\\n' | while read line; do echo $line; done")
.await
.unwrap();
assert_eq!(result.stdout, "x\ny\nz\n");
}
#[tokio::test]
async fn test_source_loads_functions() {
let mut bash = Bash::new();
bash.exec("cat > /tmp/lib.sh <<'EOF'\ngreet() { echo \"hello $1\"; }\nEOF")
.await
.unwrap();
let result = bash.exec("source /tmp/lib.sh; greet world").await.unwrap();
assert_eq!(result.stdout, "hello world\n");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_source_loads_variables() {
let mut bash = Bash::new();
bash.exec("echo 'MY_VAR=loaded' > /tmp/vars.sh")
.await
.unwrap();
let result = bash
.exec("source /tmp/vars.sh; echo $MY_VAR")
.await
.unwrap();
assert_eq!(result.stdout, "loaded\n");
}
#[tokio::test]
async fn test_chmod_symbolic_plus_x() {
let mut bash = Bash::new();
bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
.await
.unwrap();
let result = bash.exec("chmod +x /tmp/script.sh").await.unwrap();
assert_eq!(
result.exit_code, 0,
"chmod +x should succeed: {}",
result.stderr
);
}
#[tokio::test]
async fn test_chmod_symbolic_u_plus_x() {
let mut bash = Bash::new();
bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
let result = bash.exec("chmod u+x /tmp/file.txt").await.unwrap();
assert_eq!(
result.exit_code, 0,
"chmod u+x should succeed: {}",
result.stderr
);
}
#[tokio::test]
async fn test_chmod_symbolic_a_plus_r() {
let mut bash = Bash::new();
bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
let result = bash.exec("chmod a+r /tmp/file.txt").await.unwrap();
assert_eq!(
result.exit_code, 0,
"chmod a+r should succeed: {}",
result.stderr
);
}
#[tokio::test]
async fn test_awk_array_length() {
let mut bash = Bash::new();
let result = bash
.exec(r#"echo "" | awk 'BEGIN{a[1]="x"; a[2]="y"; a[3]="z"} END{print length(a)}'"#)
.await
.unwrap();
assert_eq!(result.stdout, "3\n");
}
#[tokio::test]
async fn test_awk_array_read_after_split() {
let mut bash = Bash::new();
let result = bash
.exec(r#"echo "a:b:c" | awk '{n=split($0,arr,":"); for(i=1;i<=n;i++) print arr[i]}'"#)
.await
.unwrap();
assert_eq!(result.stdout, "a\nb\nc\n");
}
#[tokio::test]
async fn test_awk_array_word_count_pattern() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"printf "apple\nbanana\napple\ncherry\nbanana\napple" | awk '{count[$1]++} END{for(w in count) print w, count[w]}'"#,
)
.await
.unwrap();
assert!(
result.stdout.contains("apple 3"),
"stdout: {}",
result.stdout
);
assert!(
result.stdout.contains("banana 2"),
"stdout: {}",
result.stdout
);
assert!(
result.stdout.contains("cherry 1"),
"stdout: {}",
result.stdout
);
}
#[tokio::test]
async fn test_exec_streaming_for_loop() {
let chunks = Arc::new(Mutex::new(Vec::new()));
let chunks_cb = chunks.clone();
let mut bash = Bash::new();
let result = bash
.exec_streaming(
"for i in 1 2 3; do echo $i; done",
Box::new(move |stdout, _stderr| {
chunks_cb.lock().unwrap().push(stdout.to_string());
}),
)
.await
.unwrap();
assert_eq!(result.stdout, "1\n2\n3\n");
assert_eq!(
*chunks.lock().unwrap(),
vec!["1\n", "2\n", "3\n"],
"each loop iteration should stream separately"
);
}
#[tokio::test]
async fn test_exec_streaming_while_loop() {
let chunks = Arc::new(Mutex::new(Vec::new()));
let chunks_cb = chunks.clone();
let mut bash = Bash::new();
let result = bash
.exec_streaming(
"i=0; while [ $i -lt 3 ]; do i=$((i+1)); echo $i; done",
Box::new(move |stdout, _stderr| {
chunks_cb.lock().unwrap().push(stdout.to_string());
}),
)
.await
.unwrap();
assert_eq!(result.stdout, "1\n2\n3\n");
let chunks = chunks.lock().unwrap();
assert!(
chunks.contains(&"1\n".to_string()),
"should contain first iteration output"
);
assert!(
chunks.contains(&"2\n".to_string()),
"should contain second iteration output"
);
assert!(
chunks.contains(&"3\n".to_string()),
"should contain third iteration output"
);
}
#[tokio::test]
async fn test_exec_streaming_no_callback_still_works() {
let mut bash = Bash::new();
let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
assert_eq!(result.stdout, "a\nb\nc\n");
}
#[tokio::test]
async fn test_exec_streaming_nested_loops_no_duplicates() {
let chunks = Arc::new(Mutex::new(Vec::new()));
let chunks_cb = chunks.clone();
let mut bash = Bash::new();
let result = bash
.exec_streaming(
"for i in 1 2; do for j in a b; do echo \"$i$j\"; done; done",
Box::new(move |stdout, _stderr| {
chunks_cb.lock().unwrap().push(stdout.to_string());
}),
)
.await
.unwrap();
assert_eq!(result.stdout, "1a\n1b\n2a\n2b\n");
let chunks = chunks.lock().unwrap();
let total_chars: usize = chunks.iter().map(|c| c.len()).sum();
assert_eq!(
total_chars,
result.stdout.len(),
"total streamed bytes should match final output: chunks={:?}",
*chunks
);
}
#[tokio::test]
async fn test_exec_streaming_mixed_list_and_loop() {
let chunks = Arc::new(Mutex::new(Vec::new()));
let chunks_cb = chunks.clone();
let mut bash = Bash::new();
let result = bash
.exec_streaming(
"echo start; for i in 1 2; do echo $i; done; echo end",
Box::new(move |stdout, _stderr| {
chunks_cb.lock().unwrap().push(stdout.to_string());
}),
)
.await
.unwrap();
assert_eq!(result.stdout, "start\n1\n2\nend\n");
let chunks = chunks.lock().unwrap();
assert_eq!(
*chunks,
vec!["start\n", "1\n", "2\n", "end\n"],
"mixed list+loop should produce exactly 4 events"
);
}
#[tokio::test]
async fn test_exec_streaming_stderr() {
let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
let stderr_cb = stderr_chunks.clone();
let mut bash = Bash::new();
let result = bash
.exec_streaming(
"echo ok; echo err >&2; echo ok2",
Box::new(move |_stdout, stderr| {
if !stderr.is_empty() {
stderr_cb.lock().unwrap().push(stderr.to_string());
}
}),
)
.await
.unwrap();
assert_eq!(result.stdout, "ok\nok2\n");
assert_eq!(result.stderr, "err\n");
let stderr_chunks = stderr_chunks.lock().unwrap();
assert!(
stderr_chunks.contains(&"err\n".to_string()),
"stderr should be streamed: {:?}",
*stderr_chunks
);
}
async fn assert_streaming_equivalence(script: &str) {
let mut bash_plain = Bash::new();
let plain = bash_plain.exec(script).await.unwrap();
let stdout_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let stderr_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let so = stdout_chunks.clone();
let se = stderr_chunks.clone();
let mut bash_stream = Bash::new();
let streamed = bash_stream
.exec_streaming(
script,
Box::new(move |stdout, stderr| {
if !stdout.is_empty() {
so.lock().unwrap().push(stdout.to_string());
}
if !stderr.is_empty() {
se.lock().unwrap().push(stderr.to_string());
}
}),
)
.await
.unwrap();
assert_eq!(
plain.stdout, streamed.stdout,
"stdout mismatch for: {script}"
);
assert_eq!(
plain.stderr, streamed.stderr,
"stderr mismatch for: {script}"
);
assert_eq!(
plain.exit_code, streamed.exit_code,
"exit_code mismatch for: {script}"
);
let reassembled_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
assert_eq!(
reassembled_stdout, streamed.stdout,
"reassembled stdout chunks != final stdout for: {script}"
);
let reassembled_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
assert_eq!(
reassembled_stderr, streamed.stderr,
"reassembled stderr chunks != final stderr for: {script}"
);
}
#[tokio::test]
async fn test_streaming_equivalence_for_loop() {
assert_streaming_equivalence("for i in 1 2 3; do echo $i; done").await;
}
#[tokio::test]
async fn test_streaming_equivalence_while_loop() {
assert_streaming_equivalence("i=0; while [ $i -lt 4 ]; do i=$((i+1)); echo $i; done").await;
}
#[tokio::test]
async fn test_streaming_equivalence_nested_loops() {
assert_streaming_equivalence("for i in a b; do for j in 1 2; do echo \"$i$j\"; done; done")
.await;
}
#[tokio::test]
async fn test_streaming_equivalence_mixed_list() {
assert_streaming_equivalence("echo start; for i in x y; do echo $i; done; echo end").await;
}
#[tokio::test]
async fn test_streaming_equivalence_stderr() {
assert_streaming_equivalence("echo out; echo err >&2; echo out2").await;
}
#[tokio::test]
async fn test_streaming_equivalence_pipeline() {
assert_streaming_equivalence("echo -e 'a\\nb\\nc' | grep b").await;
}
#[tokio::test]
async fn test_streaming_equivalence_conditionals() {
assert_streaming_equivalence("if true; then echo yes; else echo no; fi; echo done").await;
}
#[tokio::test]
async fn test_streaming_equivalence_subshell() {
assert_streaming_equivalence("x=$(echo hello); echo $x").await;
}
#[tokio::test]
async fn test_max_memory_caps_string_growth() {
let mut bash = Bash::builder()
.max_memory(1024)
.limits(
ExecutionLimits::new()
.max_commands(10_000)
.max_loop_iterations(10_000),
)
.build();
let result = bash
.exec(r#"x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}"#)
.await
.unwrap();
let len: usize = result.stdout.trim().parse().unwrap();
assert!(len <= 1024, "string length {len} must be ≤ 1024");
}
#[tokio::test]
async fn test_stderr_redirect_devnull_streaming() {
let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
let stderr_cb = stderr_chunks.clone();
let mut bash = Bash::new();
let result = bash
.exec_streaming(
"{ ls /nonexistent; } 2>/dev/null; echo exit:$?",
Box::new(move |_stdout, stderr| {
if !stderr.is_empty() {
stderr_cb.lock().unwrap().push(stderr.to_string());
}
}),
)
.await
.unwrap();
assert_eq!(result.stderr, "", "final stderr should be empty");
let stderr_chunks = stderr_chunks.lock().unwrap();
assert!(
stderr_chunks.is_empty(),
"no stderr should be streamed when 2>/dev/null is used, got: {:?}",
*stderr_chunks
);
}
#[tokio::test]
async fn test_dot_slash_prefix_ls() {
let mut bash = Bash::new();
bash.exec("mkdir -p /tmp/blogtest && cd /tmp/blogtest && echo hello > tag_hello.html")
.await
.unwrap();
let result = bash
.exec("cd /tmp/blogtest && ls tag_hello.html")
.await
.unwrap();
assert_eq!(
result.exit_code, 0,
"ls tag_hello.html should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("tag_hello.html"));
let result = bash
.exec("cd /tmp/blogtest && ls ./tag_hello.html")
.await
.unwrap();
assert_eq!(
result.exit_code, 0,
"ls ./tag_hello.html should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("tag_hello.html"));
}
#[tokio::test]
async fn test_dot_slash_prefix_glob() {
let mut bash = Bash::new();
bash.exec("mkdir -p /tmp/globtest && cd /tmp/globtest && echo hello > tag_hello.html")
.await
.unwrap();
let result = bash.exec("cd /tmp/globtest && echo *.html").await.unwrap();
assert_eq!(
result.exit_code, 0,
"echo *.html should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("tag_hello.html"));
let result = bash
.exec("cd /tmp/globtest && echo ./*.html")
.await
.unwrap();
assert_eq!(
result.exit_code, 0,
"echo ./*.html should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("tag_hello.html"));
}
#[tokio::test]
async fn test_dot_slash_prefix_cat() {
let mut bash = Bash::new();
bash.exec("mkdir -p /tmp/cattest && cd /tmp/cattest && echo content123 > myfile.txt")
.await
.unwrap();
let result = bash
.exec("cd /tmp/cattest && cat ./myfile.txt")
.await
.unwrap();
assert_eq!(
result.exit_code, 0,
"cat ./myfile.txt should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("content123"));
}
#[tokio::test]
async fn test_dot_slash_prefix_redirect() {
let mut bash = Bash::new();
bash.exec("mkdir -p /tmp/redirtest && cd /tmp/redirtest")
.await
.unwrap();
let result = bash
.exec("cd /tmp/redirtest && echo hello > ./output.txt && cat ./output.txt")
.await
.unwrap();
assert_eq!(
result.exit_code, 0,
"redirect to ./output.txt should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("hello"));
}
#[tokio::test]
async fn test_dot_slash_prefix_test_builtin() {
let mut bash = Bash::new();
bash.exec("mkdir -p /tmp/testbuiltin && cd /tmp/testbuiltin && echo x > myfile.txt")
.await
.unwrap();
let result = bash
.exec("cd /tmp/testbuiltin && test -f ./myfile.txt && echo yes")
.await
.unwrap();
assert_eq!(
result.exit_code, 0,
"test -f ./myfile.txt should succeed: {}",
result.stderr
);
assert!(result.stdout.contains("yes"));
}
#[tokio::test]
async fn test_before_exec_hook_modifies_script() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let called = Arc::new(AtomicBool::new(false));
let called_clone = called.clone();
let mut bash = Bash::builder()
.before_exec(Box::new(move |mut input| {
called_clone.store(true, Ordering::Relaxed);
input.script = "echo intercepted".to_string();
hooks::HookAction::Continue(input)
}))
.build();
let result = bash.exec("echo original").await.unwrap();
assert!(called.load(Ordering::Relaxed));
assert_eq!(result.stdout.trim(), "intercepted");
}
#[tokio::test]
async fn test_before_exec_hook_cancels() {
let mut bash = Bash::builder()
.before_exec(Box::new(|_input| {
hooks::HookAction::Cancel("blocked".to_string())
}))
.build();
let result = bash.exec("echo should-not-run").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stdout.is_empty());
}
#[tokio::test]
async fn test_input_size_limit_rejects_before_before_exec_hook() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let called = Arc::new(AtomicBool::new(false));
let called_clone = called.clone();
let limits = ExecutionLimits::new().max_input_bytes(8);
let mut bash = Bash::builder()
.limits(limits)
.before_exec(Box::new(move |_input| {
called_clone.store(true, Ordering::Relaxed);
unreachable!("before_exec hook must not run for oversized input");
}))
.build();
let result = bash.exec("echo way-too-long").await;
assert!(result.is_err());
assert!(!called.load(Ordering::Relaxed));
}
#[tokio::test]
async fn test_after_exec_hook_observes_output() {
use std::sync::{Arc, Mutex};
let captured = Arc::new(Mutex::new(String::new()));
let captured_clone = captured.clone();
let mut bash = Bash::builder()
.after_exec(Box::new(move |output| {
*captured_clone.lock().unwrap() = output.stdout.clone();
hooks::HookAction::Continue(output)
}))
.build();
bash.exec("echo hello-hooks").await.unwrap();
assert_eq!(captured.lock().unwrap().trim(), "hello-hooks");
}
#[tokio::test]
async fn test_multiple_hooks_chain() {
let mut bash = Bash::builder()
.before_exec(Box::new(|mut input| {
input.script = input.script.replace("world", "hooks");
hooks::HookAction::Continue(input)
}))
.before_exec(Box::new(|mut input| {
input.script = input.script.replace("hello", "greetings");
hooks::HookAction::Continue(input)
}))
.build();
let result = bash.exec("echo hello world").await.unwrap();
assert_eq!(result.stdout.trim(), "greetings hooks");
}
#[tokio::test]
async fn test_on_exit_hook_not_fired_for_path_script_exit() {
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let count = Arc::new(AtomicU32::new(0));
let count_clone = count.clone();
let mut bash = Bash::builder()
.on_exit(Box::new(move |event| {
count_clone.fetch_add(1, Ordering::Relaxed);
hooks::HookAction::Continue(event)
}))
.build();
let fs = bash.fs();
fs.mkdir(Path::new("/bin"), false).await.unwrap();
fs.write_file(Path::new("/bin/child-exit"), b"#!/usr/bin/env bash\nexit 7")
.await
.unwrap();
fs.chmod(Path::new("/bin/child-exit"), 0o755).await.unwrap();
let result = bash
.exec("PATH=/bin:$PATH\nchild-exit\necho after:$?")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "after:7");
assert_eq!(count.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn test_on_exit_hook_not_fired_for_direct_script_exit() {
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let count = Arc::new(AtomicU32::new(0));
let count_clone = count.clone();
let mut bash = Bash::builder()
.on_exit(Box::new(move |event| {
count_clone.fetch_add(1, Ordering::Relaxed);
hooks::HookAction::Continue(event)
}))
.build();
let fs = bash.fs();
fs.write_file(
Path::new("/tmp/child-exit.sh"),
b"#!/usr/bin/env bash\nexit 8",
)
.await
.unwrap();
fs.chmod(Path::new("/tmp/child-exit.sh"), 0o755)
.await
.unwrap();
let result = bash
.exec("/tmp/child-exit.sh\necho after:$?")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "after:8");
assert_eq!(count.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn test_on_exit_hook_not_fired_for_nested_bash_exit() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let count = Arc::new(AtomicU32::new(0));
let count_clone = count.clone();
let mut bash = Bash::builder()
.on_exit(Box::new(move |event| {
count_clone.fetch_add(1, Ordering::Relaxed);
hooks::HookAction::Continue(event)
}))
.build();
let result = bash.exec("bash -c 'exit 9'\necho after:$?").await.unwrap();
assert_eq!(result.stdout.trim(), "after:9");
assert_eq!(count.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn test_path_script_exit_runs_child_exit_trap() {
use std::path::Path;
let mut bash = Bash::new();
let fs = bash.fs();
fs.write_file(
Path::new("/tmp/child-trap.sh"),
b"#!/usr/bin/env bash\ntrap 'echo child-trap' EXIT\nexit 4",
)
.await
.unwrap();
fs.chmod(Path::new("/tmp/child-trap.sh"), 0o755)
.await
.unwrap();
let result = bash
.exec("/tmp/child-trap.sh\necho after:$?")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "child-trap\nafter:4");
}
#[tokio::test]
async fn test_on_exit_hook_still_fires_for_source_exit() {
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let count = Arc::new(AtomicU32::new(0));
let count_clone = count.clone();
let mut bash = Bash::builder()
.on_exit(Box::new(move |event| {
count_clone.fetch_add(1, Ordering::Relaxed);
hooks::HookAction::Continue(event)
}))
.build();
let fs = bash.fs();
fs.write_file(Path::new("/tmp/source-exit.sh"), b"exit 5")
.await
.unwrap();
let result = bash.exec("source /tmp/source-exit.sh").await.unwrap();
assert_eq!(result.exit_code, 5);
assert_eq!(count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_on_exit_hook_cancel_prevents_exit() {
let mut bash = Bash::builder()
.on_exit(Box::new(|_event| {
hooks::HookAction::Cancel("blocked by policy".to_string())
}))
.build();
let result = bash.exec("echo before\nexit 5\necho after").await.unwrap();
assert_eq!(result.stdout.trim(), "before\nafter");
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_on_exit_hook_can_modify_exit_code() {
let mut bash = Bash::builder()
.on_exit(Box::new(|mut event| {
event.code = 17;
hooks::HookAction::Continue(event)
}))
.build();
let result = bash.exec("exit 5").await.unwrap();
assert_eq!(result.exit_code, 17);
}
#[tokio::test]
async fn test_bash_versinfo_reports_bash_compatible_major() {
let mut bash = Bash::new();
let result = bash
.exec(r#"[[ ${BASH_VERSINFO[0]} -ge 4 ]] && echo bash4plus"#)
.await
.unwrap();
assert_eq!(result.stdout.trim(), "bash4plus");
}
#[tokio::test]
async fn test_bash_version_surface_matches_bash_compatible_tuple() {
let mut bash = Bash::new();
let result = bash
.exec(
r#"printf '%s\n' "$BASH_VERSION" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}" "${BASH_VERSINFO[2]}" "${BASH_VERSINFO[3]}" "${BASH_VERSINFO[4]}" "${BASH_VERSINFO[5]}""#,
)
.await
.unwrap();
assert_eq!(
result.stdout,
"5.2.15(1)-release\n5\n2\n15\n1\nrelease\nvirtual\n"
);
}
#[tokio::test]
async fn test_path_script_retains_bash_versinfo_array() {
use std::path::Path;
let mut bash = Bash::new();
let fs = bash.fs();
fs.write_file(
Path::new("/tmp/bash-version-check.sh"),
b"#!/usr/bin/env bash\nprintf '%s\\n' \"${BASH_VERSINFO[0]}\"",
)
.await
.unwrap();
fs.chmod(Path::new("/tmp/bash-version-check.sh"), 0o755)
.await
.unwrap();
let result = bash.exec("/tmp/bash-version-check.sh").await.unwrap();
assert_eq!(result.stdout.trim(), "5");
}
#[tokio::test]
async fn test_path_script_bash_versinfo_satisfies_bash4_guard() {
use std::path::Path;
let mut bash = Bash::new();
let fs = bash.fs();
fs.write_file(
Path::new("/tmp/bash-version-guard.sh"),
b"#!/usr/bin/env bash\nif (( BASH_VERSINFO[0] < 4 )); then echo too-old; else echo ok; fi",
)
.await
.unwrap();
fs.chmod(Path::new("/tmp/bash-version-guard.sh"), 0o755)
.await
.unwrap();
let result = bash.exec("/tmp/bash-version-guard.sh").await.unwrap();
assert_eq!(result.stdout.trim(), "ok");
}
#[tokio::test]
async fn test_before_tool_hook_modifies_args() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let called = Arc::new(AtomicBool::new(false));
let called_clone = called.clone();
let mut bash = Bash::builder()
.before_tool(Box::new(move |mut event| {
called_clone.store(true, Ordering::Relaxed);
if !event.args.is_empty() {
event.args = vec!["intercepted".to_string()];
}
hooks::HookAction::Continue(event)
}))
.build();
let result = bash.exec("echo original").await.unwrap();
assert!(called.load(Ordering::Relaxed));
assert_eq!(result.stdout.trim(), "intercepted");
}
#[tokio::test]
async fn test_before_tool_hook_cancels() {
let mut bash = Bash::builder()
.before_tool(Box::new(|event| {
if event.name == "echo" {
hooks::HookAction::Cancel("echo blocked".to_string())
} else {
hooks::HookAction::Continue(event)
}
}))
.build();
let result = bash.exec("echo should-not-run").await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("cancelled by before_tool hook"));
}
#[tokio::test]
async fn test_after_tool_hook_observes_result() {
use std::sync::{Arc, Mutex};
let captured = Arc::new(Mutex::new(Vec::new()));
let captured_clone = captured.clone();
let mut bash = Bash::builder()
.after_tool(Box::new(move |result| {
captured_clone.lock().unwrap().push((
result.name.clone(),
result.stdout.clone(),
result.exit_code,
));
hooks::HookAction::Continue(result)
}))
.build();
bash.exec("echo hello-tool").await.unwrap();
let results = captured.lock().unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].0, "echo");
assert!(results[0].1.contains("hello-tool"));
assert_eq!(results[0].2, 0);
}
#[tokio::test]
async fn test_before_tool_hook_does_not_fire_for_special_builtins() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
let count = Arc::new(AtomicU32::new(0));
let count_clone = count.clone();
let mut bash = Bash::builder()
.before_tool(Box::new(move |event| {
count_clone.fetch_add(1, Ordering::Relaxed);
hooks::HookAction::Continue(event)
}))
.build();
bash.exec("declare x=1").await.unwrap();
assert_eq!(count.load(Ordering::Relaxed), 0);
bash.exec("echo hi").await.unwrap();
assert_eq!(count.load(Ordering::Relaxed), 1);
}
#[cfg(feature = "http_client")]
#[tokio::test]
async fn test_before_http_hook_cancels_request() {
use crate::NetworkAllowlist;
let mut bash = Bash::builder()
.network(NetworkAllowlist::allow_all())
.before_http(Box::new(|req| {
if req.url.contains("blocked.example.com") {
hooks::HookAction::Cancel("blocked by policy".to_string())
} else {
hooks::HookAction::Continue(req)
}
}))
.build();
let result = bash
.exec("curl -s https://blocked.example.com/data")
.await
.unwrap();
assert_ne!(result.exit_code, 0);
assert!(result.stderr.contains("cancelled by before_http hook"));
}
#[cfg(feature = "http_client")]
#[tokio::test]
async fn test_after_http_hook_observes_response() {
use std::sync::{Arc, Mutex};
use crate::NetworkAllowlist;
let captured = Arc::new(Mutex::new(Vec::new()));
let captured_clone = captured.clone();
let mut bash = Bash::builder()
.network(NetworkAllowlist::allow_all())
.after_http(Box::new(move |event| {
captured_clone
.lock()
.unwrap()
.push((event.url.clone(), event.status));
hooks::HookAction::Continue(event)
}))
.build();
let _result = bash.exec("curl -s https://httpbin.org/get").await.unwrap();
}
}