1#![warn(clippy::unwrap_used)]
421#![cfg_attr(test, allow(clippy::unwrap_used))]
422
423pub mod analysis;
425mod builtins;
426#[cfg(feature = "http_client")]
427mod credential;
428mod error;
429mod execution_capability;
430mod fs;
431pub mod hooks;
433mod host_call;
434#[cfg(feature = "interop")]
435pub mod interop;
436mod interpreter;
437mod limits;
438#[cfg(feature = "logging")]
439mod logging_impl;
440mod network;
441pub mod parser;
443mod profile;
444#[cfg(feature = "scripted_tool")]
447pub mod scripted_tool;
448mod snapshot;
449mod stream;
450#[doc(hidden)]
455pub mod testing;
456mod time_compat;
457#[cfg(feature = "bash_tool")]
460pub mod tool;
461#[cfg(feature = "scripted_tool")]
463pub(crate) mod tool_def;
464#[cfg(feature = "scripted_tool")]
465mod tool_registry;
466pub mod trace;
468pub use stream::StreamData;
469
470pub use analysis::{
471 AnalyzedCommand, AnalyzedRedirect, CommandContext, RedirectMode, ScriptAnalysis,
472};
473pub use async_trait::async_trait;
474pub use builtins::git::GitConfig;
475pub use builtins::ssh::{SshAllowlist, SshConfig, TrustedHostKey};
476pub use builtins::{
477 BashkitContext, Builtin, BuiltinRegistry, ClapBuiltin, CommandResolver,
478 Context as BuiltinContext, Extension,
479};
480pub use clap;
481#[cfg(feature = "http_client")]
482pub use credential::Credential;
483pub use error::{Error, Result};
484pub use execution_capability::{
485 CapabilityCleanupReport, ExecutionCapability, ExecutionCapabilityError, ExecutionExtensions,
486};
487pub use fs::{
488 DirEntry, FileSystem, FileSystemExt, FileType, FsBackend, FsLimitExceeded, FsLimits, FsUsage,
489 InMemoryFs, LazyLoader, Metadata, MountableFs, NamespaceAccess, NamespaceFs,
490 NamespaceFsBuilder, OverlayFs, PosixFs, ReadOnlyFs, SearchCapabilities, SearchCapable,
491 SearchMatch, SearchProvider, SearchQuery, SearchResults, VfsEntry, VfsEntryKind, VfsSnapshot,
492 normalize_path, verify_filesystem_requirements,
493};
494#[cfg(feature = "realfs")]
495pub use fs::{RealFs, RealFsMode};
496pub use host_call::{ExecutionEvent, ExecutionHandle, HostCallId, HostCallRequest};
497pub use interpreter::{
498 ControlFlow, ExecResult, HistoryEntry, OutputCallback, ShellState, ShellStateView,
499};
500pub use limits::{
501 ExecutionBudget, ExecutionBudgetExceeded, ExecutionBudgetLease, ExecutionCounters,
502 ExecutionLimits, LimitExceeded, MemoryBudget, MemoryLimits, SessionLimits,
503};
504#[cfg(feature = "http_client")]
505pub use network::HttpLimits;
506pub use network::NetworkAllowlist;
507pub use profile::{
508 ExecutionProfile, ExecutionProfileBuilder, ExecutionProfileError, ExecutionProfileName,
509 ProfileNetworkPolicy,
510};
511pub use snapshot::{
512 CapabilityDelta, CapabilityFingerprint, CheckoutPolicy, CommitId, CommitObject, CommitOptions,
513 ObjectId, ObjectSource, PackedCommit, Snapshot, SnapshotDiff, SnapshotGraph, SnapshotOptions,
514};
515#[cfg(feature = "bash_tool")]
516pub use tool::BashToolBuilder as ToolBuilder;
517#[cfg(feature = "bash_tool")]
518pub use tool::{
519 BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput,
520 ToolOutputChunk, ToolOutputMetadata, ToolRequest, ToolResponse, ToolService, ToolStatus,
521 VERSION,
522};
523pub use trace::{
524 TraceCallback, TraceCollector, TraceEvent, TraceEventDetails, TraceEventKind, TraceMode,
525};
526
527#[cfg(feature = "scripted_tool")]
528pub use scripted_tool::{
529 AsyncToolCallback, CallbackKind, DiscoverTool, DiscoveryMode, ScriptedCommandInvocation,
530 ScriptedCommandKind, ScriptedExecutionTrace, ScriptedTool, ScriptedToolBuilder,
531 ScriptingToolSet, ScriptingToolSetBuilder, ToolArgs, ToolCallback, ToolDef, ToolDefExtension,
532 ToolDefExtensionBuilder, ToolDefInvocationTrace,
533};
534#[cfg(feature = "scripted_tool")]
535pub use tool_def::{AsyncToolExec, SyncToolExec, ToolImpl};
536#[cfg(feature = "scripted_tool")]
537pub use tool_registry::{
538 ToolCall, ToolCallDecision, ToolCallRequest, ToolCallSurface, ToolRegistry, ToolRegistryBuilder,
539};
540
541#[cfg(feature = "http_client")]
542pub use network::HttpClient;
543
544#[cfg(feature = "http_client")]
545pub use network::{HttpTransport, HttpTransportError, HttpTransportRequest};
546
547#[cfg(feature = "http_client")]
549pub use network::Method as HttpMethod;
550
551#[cfg(feature = "http_client")]
553pub use network::Response as HttpResponse;
554
555#[cfg(feature = "bot-auth")]
556pub use network::{BotAuthConfig, BotAuthError, BotAuthPublicKey, derive_bot_auth_public_key};
557
558#[cfg(feature = "git")]
559pub use builtins::git::GitClient;
560
561#[cfg(feature = "ssh")]
562pub use builtins::ssh::{SshClient, SshHandler, SshOutput, SshTarget};
563
564#[cfg(feature = "python")]
565pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits};
566
567#[cfg(any(feature = "python", feature = "typescript"))]
569pub use builtins::RuntimeLimits;
570
571#[cfg(feature = "sqlite")]
572pub use builtins::{Sqlite, SqliteBackend, SqliteLimits};
573#[cfg(feature = "python")]
577pub use monty_types::{ExcType, ExtFunctionResult, MontyException, MontyObject};
578
579#[cfg(feature = "typescript")]
580pub use builtins::{
581 TypeScriptConfig, TypeScriptExtension, TypeScriptExternalFnHandler, TypeScriptExternalFns,
582 TypeScriptLimits,
583};
584#[cfg(feature = "typescript")]
586pub use zapcode_core::Value as ZapcodeValue;
587
588#[cfg(feature = "logging")]
593pub mod logging {
594 pub use crate::logging_impl::{
595 LogConfig, format_error_for_log, format_script_for_log, sanitize_for_log,
596 };
597}
598
599#[cfg(feature = "logging")]
600pub use logging::LogConfig;
601
602use interpreter::Interpreter;
603use parser::Parser;
604use std::collections::HashMap;
605#[cfg(feature = "realfs")]
606use std::path::Path;
607use std::path::PathBuf;
608use std::sync::Arc;
609
610#[cfg(any(feature = "python", feature = "sqlite"))]
611fn env_opt_in_enabled(env: &HashMap<String, String>, key: &str) -> bool {
612 env.get(key)
613 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
614}
615
616struct OutputCallbackGuard {
619 interpreter: *mut Interpreter,
620}
621
622unsafe impl Send for OutputCallbackGuard {}
626
627impl OutputCallbackGuard {
628 fn install(interpreter: &mut Interpreter, callback: OutputCallback) -> Self {
629 interpreter.set_output_callback(callback);
630 Self { interpreter }
631 }
632}
633
634impl Drop for OutputCallbackGuard {
635 fn drop(&mut self) {
636 unsafe { (*self.interpreter).clear_output_callback() };
641 }
642}
643
644#[derive(Default)]
678pub struct ExecOptions {
679 extensions: ExecutionExtensions,
680 output_callback: Option<OutputCallback>,
681 arg0: Option<String>,
682 positional: Option<Vec<String>>,
683 stdin: Option<StreamData>,
684}
685
686impl ExecOptions {
687 pub fn new() -> Self {
689 Self::default()
690 }
691
692 pub fn streaming(mut self, callback: OutputCallback) -> Self {
695 self.output_callback = Some(callback);
696 self
697 }
698
699 pub fn extensions(mut self, extensions: ExecutionExtensions) -> Self {
702 self.extensions = extensions;
703 self
704 }
705
706 pub fn arg0(mut self, arg0: impl Into<String>) -> Self {
725 self.arg0 = Some(arg0.into());
726 self
727 }
728
729 pub fn positional<I, S>(mut self, positional: I) -> Self
733 where
734 I: IntoIterator<Item = S>,
735 S: Into<String>,
736 {
737 self.positional = Some(positional.into_iter().map(Into::into).collect());
738 self
739 }
740
741 pub fn stdin(mut self, stdin: impl Into<StreamData>) -> Self {
760 self.stdin = Some(stdin.into());
761 self
762 }
763}
764
765#[derive(Default)]
773struct Invocation {
774 arg0: Option<String>,
775 positional: Option<Vec<String>>,
776 stdin: Option<StreamData>,
777}
778
779impl Invocation {
780 fn is_empty(&self) -> bool {
781 self.arg0.is_none() && self.positional.is_none() && self.stdin.is_none()
782 }
783}
784
785pub struct Bash {
789 fs: Arc<dyn FileSystem>,
790 mountable: Arc<MountableFs>,
792 readonly_filesystem: bool,
794 interpreter: Interpreter,
795 parser_timeout: std::time::Duration,
797 max_input_bytes: usize,
799 max_ast_depth: usize,
801 max_parser_operations: usize,
803 #[cfg(feature = "logging")]
805 log_config: logging::LogConfig,
806 #[cfg(feature = "python")]
808 python_inprocess_opt_in: bool,
809 #[cfg(feature = "sqlite")]
811 sqlite_inprocess_opt_in: bool,
812 #[cfg(feature = "realfs")]
814 host_mounts: HostMounts,
815}
816
817impl Default for Bash {
818 fn default() -> Self {
819 Self::new()
820 }
821}
822
823fn inmem_fs_with_home(username: &str, limits: FsLimits) -> InMemoryFs {
828 let fs = InMemoryFs::with_limits(limits);
829 fs.add_dir(format!("/home/{username}"), 0o755);
830 fs
831}
832
833impl Bash {
834 pub fn new() -> Self {
836 Self::builder().build()
837 }
838
839 pub fn builder() -> BashBuilder {
841 BashBuilder::default()
842 }
843
844 pub async fn exec(&mut self, script: &str) -> Result<ExecResult> {
850 self.exec_with_options(script, ExecOptions::new()).await
851 }
852
853 pub fn start_execution(self, script: impl Into<String>) -> ExecutionHandle {
858 self.start_execution_with_options(script, ExecOptions::new())
859 }
860
861 pub fn start_execution_with_options(
866 self,
867 script: impl Into<String>,
868 options: ExecOptions,
869 ) -> ExecutionHandle {
870 ExecutionHandle::new(self, script.into(), options)
871 }
872
873 pub async fn exec_with_extensions(
877 &mut self,
878 script: &str,
879 extensions: ExecutionExtensions,
880 ) -> Result<ExecResult> {
881 self.exec_with_options(script, ExecOptions::new().extensions(extensions))
882 .await
883 }
884
885 pub async fn exec_with_options(
893 &mut self,
894 script: &str,
895 options: ExecOptions,
896 ) -> Result<ExecResult> {
897 let ExecOptions {
898 mut extensions,
899 output_callback,
900 arg0,
901 positional,
902 stdin,
903 } = options;
904 let invocation = Invocation {
905 arg0,
906 positional,
907 stdin,
908 };
909 self.interpreter.begin_execution_budget();
910 let _budget_completion = self.interpreter.execution_budget().completion_guard();
913 let active_limits = self.interpreter.limits().clone();
916 let _ = extensions.insert(active_limits.clone());
917 let _ = extensions.insert(self.interpreter.execution_budget().clone());
918 let _ = extensions.insert(builtins::ExecutionDeadline::new(active_limits.timeout));
919 #[cfg(feature = "python")]
920 let _ = extensions.insert(builtins::PythonInprocessOptIn(self.python_inprocess_opt_in));
921 #[cfg(feature = "sqlite")]
922 let _ = extensions.insert(builtins::SqliteInprocessOptIn(self.sqlite_inprocess_opt_in));
923 let execution_scope = execution_capability::ExecutionScope::new();
924 extensions.bind(execution_scope);
925 let _stream_guard =
930 output_callback.map(|cb| OutputCallbackGuard::install(&mut self.interpreter, cb));
931 let extensions_guard = self.interpreter.scoped_execution_extensions(extensions);
932 let mut result = self.exec_impl(script, invocation).await;
933 let cleanup = extensions_guard.finish();
934 if let Ok(exec_result) = &mut result {
935 exec_result.capability_cleanup = cleanup;
936 }
937 result
938 }
939
940 async fn exec_impl(&mut self, script: &str, invocation: Invocation) -> Result<ExecResult> {
941 self.interpreter.reset_transient_state();
943
944 self.interpreter.begin_exec_invocation()?;
947
948 let input_len = script.len();
951 if input_len > self.max_input_bytes {
952 #[cfg(feature = "logging")]
953 tracing::error!(
954 target: "bashkit::session",
955 input_len = input_len,
956 max_bytes = self.max_input_bytes,
957 "Script exceeds maximum input size"
958 );
959 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
960 input_len,
961 self.max_input_bytes,
962 )));
963 }
964 self.interpreter
965 .execution_budget()
966 .consume_input(input_len)?;
967
968 #[cfg(feature = "logging")]
971 {
972 let script_info = logging::format_script_for_log(script, &self.log_config);
973 tracing::info!(target: "bashkit::session", script = %script_info, "Starting script execution");
974 }
975
976 let script = if !self.interpreter.hooks().before_exec.is_empty() {
978 self.interpreter.execution_budget().consume_work(100)?;
979 let input = hooks::ExecInput {
980 script: script.to_string(),
981 };
982 match self.interpreter.hooks().fire_before_exec(input) {
983 Some(modified) => {
984 self.interpreter
985 .execution_budget()
986 .consume_input(modified.script.len())?;
987 std::borrow::Cow::Owned(modified.script)
988 }
989 None => {
990 return Ok(ExecResult::err("cancelled by before_exec hook", 1));
991 }
992 }
993 } else {
994 std::borrow::Cow::Borrowed(script)
995 };
996 let script = script.as_ref();
997
998 let input_len = script.len();
1000 if input_len > self.max_input_bytes {
1001 #[cfg(feature = "logging")]
1002 tracing::error!(
1003 target: "bashkit::session",
1004 input_len = input_len,
1005 max_bytes = self.max_input_bytes,
1006 "Script exceeds maximum input size"
1007 );
1008 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
1009 input_len,
1010 self.max_input_bytes,
1011 )));
1012 }
1013
1014 let parser_timeout = self.parser_timeout;
1015 let max_ast_depth = self.max_ast_depth;
1016 let max_parser_operations = self.max_parser_operations;
1017
1018 #[cfg(feature = "logging")]
1019 tracing::debug!(
1020 target: "bashkit::parser",
1021 input_len = input_len,
1022 max_ast_depth = max_ast_depth,
1023 max_operations = max_parser_operations,
1024 "Parsing script"
1025 );
1026
1027 #[cfg(not(target_family = "wasm"))]
1037 const SPAWN_BLOCKING_THRESHOLD: usize = 16 * 1024;
1038
1039 #[cfg(target_family = "wasm")]
1042 let ast = {
1043 let parser = Parser::with_limits_and_timeout(
1044 script,
1045 max_ast_depth,
1046 max_parser_operations,
1047 Some(parser_timeout),
1048 )
1049 .with_execution_budget(self.interpreter.execution_budget().clone());
1050 parser.parse()?
1051 };
1052
1053 #[cfg(not(target_family = "wasm"))]
1057 let ast = if input_len <= SPAWN_BLOCKING_THRESHOLD {
1058 let parser = Parser::with_limits(script, max_ast_depth, max_parser_operations)
1059 .with_execution_budget(self.interpreter.execution_budget().clone());
1060 match parser.parse() {
1061 Ok(ast) => {
1062 #[cfg(feature = "logging")]
1063 tracing::debug!(target: "bashkit::parser", "Parse completed (inline)");
1064 ast
1065 }
1066 Err(e) => {
1067 #[cfg(feature = "logging")]
1068 tracing::warn!(target: "bashkit::parser", error = %e, "Parse error (inline)");
1069 return Err(e);
1070 }
1071 }
1072 } else {
1073 let script_owned = script.to_owned();
1074 let execution_budget = self.interpreter.execution_budget().clone();
1075 let parse_result = tokio::time::timeout(parser_timeout, async {
1076 tokio::task::spawn_blocking(move || {
1077 let parser =
1078 Parser::with_limits(&script_owned, max_ast_depth, max_parser_operations)
1079 .with_execution_budget(execution_budget);
1080 parser.parse()
1081 })
1082 .await
1083 })
1084 .await;
1085
1086 match parse_result {
1087 Ok(Ok(result)) => {
1088 match &result {
1089 Ok(_) => {
1090 #[cfg(feature = "logging")]
1091 tracing::debug!(target: "bashkit::parser", "Parse completed successfully");
1092 }
1093 Err(_e) => {
1094 #[cfg(feature = "logging")]
1095 tracing::warn!(target: "bashkit::parser", error = %_e, "Parse error");
1096 }
1097 }
1098 result?
1099 }
1100 Ok(Err(join_error)) => {
1101 #[cfg(feature = "logging")]
1102 tracing::error!(
1103 target: "bashkit::parser",
1104 error = %join_error,
1105 "Parser task failed"
1106 );
1107 return Err(Error::parse(format!("parser task failed: {}", join_error)));
1108 }
1109 Err(_elapsed) => {
1110 #[cfg(feature = "logging")]
1111 tracing::error!(
1112 target: "bashkit::parser",
1113 timeout_ms = parser_timeout.as_millis() as u64,
1114 "Parser timeout exceeded"
1115 );
1116 return Err(Error::ResourceLimit(LimitExceeded::ParserTimeout(
1117 parser_timeout,
1118 )));
1119 }
1120 }
1121 };
1122
1123 #[cfg(feature = "logging")]
1124 tracing::debug!(target: "bashkit::interpreter", "Starting interpretation");
1125
1126 parser::validate_budget(&ast, self.interpreter.limits())
1128 .map_err(|e| Error::Execution(format!("budget validation failed: {e}")))?;
1129
1130 self.interpreter.load_history().await;
1132
1133 let call_stack_baseline = self.interpreter.call_stack_len();
1137 let installed_invocation = !invocation.is_empty();
1138 if installed_invocation {
1139 if let Some(stdin) = invocation.stdin {
1140 self.interpreter.set_pipeline_stdin(stdin);
1141 }
1142 if invocation.arg0.is_some() || invocation.positional.is_some() {
1143 self.interpreter.push_toplevel_positional(
1144 invocation.arg0,
1145 invocation.positional.unwrap_or_default(),
1146 );
1147 }
1148 }
1149
1150 let exec_start = crate::time_compat::Instant::now();
1151 let execution_timeout = self.interpreter.limits().timeout;
1154 let result =
1155 match crate::time_compat::timeout(execution_timeout, self.interpreter.execute(&ast))
1156 .await
1157 {
1158 Ok(r) => r,
1159 Err(_elapsed) => {
1160 self.interpreter.clear_cancelled_execution_state();
1161 Err(Error::ResourceLimit(LimitExceeded::Timeout(
1162 execution_timeout,
1163 )))
1164 }
1165 };
1166 if installed_invocation {
1170 self.interpreter.truncate_call_stack(call_stack_baseline);
1171 }
1172 self.interpreter.cleanup_proc_sub_files().await;
1176 let duration_ms = exec_start.elapsed().as_millis() as u64;
1177
1178 if let Ok(ref exec_result) = result {
1180 let cwd = self.interpreter.cwd().to_string_lossy().to_string();
1181 let timestamp = chrono::Utc::now().timestamp();
1182 for line in script.lines() {
1183 let trimmed = line.trim();
1184 if !trimmed.is_empty() && !trimmed.starts_with('#') {
1185 self.interpreter.record_history(
1186 trimmed.to_string(),
1187 timestamp,
1188 cwd.clone(),
1189 exec_result.exit_code,
1190 duration_ms,
1191 );
1192 }
1193 }
1194 self.interpreter.save_history().await;
1196 }
1197
1198 #[cfg(feature = "logging")]
1199 match &result {
1200 Ok(exec_result) => {
1201 tracing::info!(
1202 target: "bashkit::session",
1203 exit_code = exec_result.exit_code,
1204 stdout_len = exec_result.stdout.len(),
1205 stderr_len = exec_result.stderr.len(),
1206 "Script execution completed"
1207 );
1208 }
1209 Err(e) => {
1210 let error = logging::format_error_for_log(&e.to_string(), &self.log_config);
1211 tracing::error!(
1212 target: "bashkit::session",
1213 error = %error,
1214 "Script execution failed"
1215 );
1216 }
1217 }
1218
1219 let result = if let Ok(exec_result) = result {
1221 if !self.interpreter.hooks().after_exec.is_empty() {
1222 self.interpreter.execution_budget().consume_work(100)?;
1223 self.interpreter.execution_budget().consume_input(
1224 script
1225 .len()
1226 .saturating_add(exec_result.stdout.len())
1227 .saturating_add(exec_result.stderr.len()),
1228 )?;
1229 let output = hooks::ExecOutput {
1230 script: script.to_string(),
1231 stdout: exec_result.stdout.text_lossy().into_owned(),
1232 stderr: exec_result.stderr.text_lossy().into_owned(),
1233 exit_code: exec_result.exit_code,
1234 };
1235 match self.interpreter.hooks().fire_after_exec(output) {
1236 Some(output) => {
1237 self.interpreter.execution_budget().consume_work(
1238 u64::try_from(
1239 output
1240 .stdout
1241 .len()
1242 .saturating_add(output.stderr.len())
1243 .div_ceil(1024),
1244 )
1245 .unwrap_or(u64::MAX),
1246 )?;
1247 Ok(ExecResult {
1248 stdout: output.stdout.into(),
1249 stderr: output.stderr.into(),
1250 exit_code: output.exit_code,
1251 ..exec_result
1252 })
1253 }
1254 None => Ok(ExecResult::err("cancelled by after_exec hook", 1)),
1255 }
1256 } else {
1257 Ok(exec_result)
1258 }
1259 } else {
1260 result
1261 };
1262
1263 if let Err(ref e) = result
1265 && !self.interpreter.hooks().on_error.is_empty()
1266 && self
1267 .interpreter
1268 .execution_budget()
1269 .consume_work(100)
1270 .is_ok()
1271 {
1272 let message = e.to_string();
1273 if self
1274 .interpreter
1275 .execution_budget()
1276 .consume_input(message.len())
1277 .is_err()
1278 {
1279 return result;
1280 }
1281 let error_event = hooks::ErrorEvent { message };
1282 self.interpreter.hooks().fire_on_error(error_event);
1283 }
1284
1285 result
1286 }
1287
1288 pub async fn exec_streaming(
1319 &mut self,
1320 script: &str,
1321 output_callback: OutputCallback,
1322 ) -> Result<ExecResult> {
1323 self.exec_with_options(script, ExecOptions::new().streaming(output_callback))
1324 .await
1325 }
1326
1327 pub async fn exec_streaming_with_extensions(
1331 &mut self,
1332 script: &str,
1333 output_callback: OutputCallback,
1334 extensions: ExecutionExtensions,
1335 ) -> Result<ExecResult> {
1336 self.exec_with_options(
1337 script,
1338 ExecOptions::new()
1339 .streaming(output_callback)
1340 .extensions(extensions),
1341 )
1342 .await
1343 }
1344
1345 pub fn cancellation_token(&self) -> Arc<std::sync::atomic::AtomicBool> {
1353 self.interpreter.cancellation_token()
1354 }
1355
1356 pub fn hooks(&self) -> &hooks::Hooks {
1366 self.interpreter.hooks()
1367 }
1368
1369 pub fn fs(&self) -> Arc<dyn FileSystem> {
1402 Arc::clone(&self.fs)
1403 }
1404
1405 pub fn mount(
1447 &self,
1448 vfs_path: impl AsRef<std::path::Path>,
1449 fs: Arc<dyn FileSystem>,
1450 ) -> Result<()> {
1451 if Arc::ptr_eq(&self.fs, &fs) {
1455 return Err(std::io::Error::other("cannot mount filesystem into itself").into());
1456 }
1457
1458 let fs: Arc<dyn FileSystem> = if self.readonly_filesystem {
1459 Arc::new(ReadOnlyFs::new(fs))
1460 } else {
1461 fs
1462 };
1463 self.mountable.mount(vfs_path, fs)
1464 }
1465
1466 pub fn unmount(&self, vfs_path: impl AsRef<std::path::Path>) -> Result<()> {
1499 self.mountable.unmount(vfs_path)
1500 }
1501
1502 pub fn shell_state(&self) -> ShellState {
1528 self.interpreter.shell_state()
1529 }
1530
1531 pub fn shell_state_view(&self) -> ShellStateView {
1537 self.interpreter.shell_state_view()
1538 }
1539
1540 pub fn restore_shell_state(&mut self, state: &ShellState) {
1545 self.interpreter.restore_shell_state(state);
1546 }
1547
1548 #[cfg(feature = "realfs")]
1554 pub fn host_mounts(&self) -> &HostMounts {
1555 &self.host_mounts
1556 }
1557
1558 #[cfg(feature = "realfs")]
1568 pub fn host_path_for(&self, vfs_path: impl AsRef<Path>) -> Option<PathBuf> {
1569 self.host_mounts.resolve(vfs_path.as_ref())
1570 }
1571
1572 pub fn builtin_names(&self) -> Vec<String> {
1581 self.interpreter.builtin_names()
1582 }
1583
1584 pub fn analyze(&self, script: &str) -> Result<analysis::ScriptAnalysis> {
1611 if script.len() > self.max_input_bytes {
1614 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
1615 script.len(),
1616 self.max_input_bytes,
1617 )));
1618 }
1619 analysis::analyze_with_limits(script, self.max_ast_depth, self.max_parser_operations)
1620 }
1621
1622 pub fn session_counters(&self) -> (u64, u64) {
1626 let c = self.interpreter.counters();
1627 (c.session_commands, c.session_exec_calls)
1628 }
1629
1630 pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
1636 self.interpreter
1637 .restore_session_counters(session_commands, session_exec_calls);
1638 }
1639}
1640
1641struct MountedFile {
1678 path: PathBuf,
1679 content: String,
1680 mode: u32,
1681}
1682
1683struct MountedLazyFile {
1684 path: PathBuf,
1685 size_hint: u64,
1686 mode: u32,
1687 loader: LazyLoader,
1688}
1689
1690#[cfg(feature = "realfs")]
1696#[derive(Debug, Clone, PartialEq, Eq)]
1697pub struct HostMount {
1698 pub host_path: PathBuf,
1700 pub vfs_path: PathBuf,
1702}
1703
1704#[cfg(feature = "realfs")]
1712#[derive(Debug, Clone, Default)]
1713pub struct HostMounts {
1714 mounts: Vec<HostMount>,
1715}
1716
1717#[cfg(feature = "realfs")]
1718impl HostMounts {
1719 pub fn new(mounts: impl IntoIterator<Item = HostMount>) -> Self {
1731 Self {
1732 mounts: mounts.into_iter().collect(),
1733 }
1734 }
1735
1736 pub fn all(&self) -> &[HostMount] {
1738 &self.mounts
1739 }
1740
1741 pub fn is_empty(&self) -> bool {
1743 self.mounts.is_empty()
1744 }
1745
1746 pub fn resolve(&self, vfs_path: &Path) -> Option<PathBuf> {
1756 if !vfs_path.has_root() {
1761 return None;
1762 }
1763 self.mounts
1764 .iter()
1765 .filter_map(|mount| {
1766 let rest = vfs_path.strip_prefix(&mount.vfs_path).ok()?;
1767 Some((
1768 mount.vfs_path.components().count(),
1769 mount.host_path.join(rest),
1770 ))
1771 })
1772 .max_by_key(|(depth, _)| *depth)
1773 .map(|(_, host)| host)
1774 }
1775}
1776
1777#[cfg(feature = "realfs")]
1779struct MountedRealDir {
1780 host_path: PathBuf,
1782 vfs_mount: Option<PathBuf>,
1784 mode: fs::RealFsMode,
1786}
1787
1788#[derive(Default)]
1789pub struct BashBuilder {
1790 fs: Option<Arc<dyn FileSystem>>,
1791 env: HashMap<String, String>,
1792 cwd: Option<PathBuf>,
1793 limits: ExecutionLimits,
1794 session_limits: SessionLimits,
1795 memory_limits: MemoryLimits,
1796 profile: ExecutionProfile,
1798 filesystem_limits: FsLimits,
1800 trace_mode: TraceMode,
1801 trace_callback: Option<TraceCallback>,
1802 username: Option<String>,
1803 hostname: Option<String>,
1804 fixed_epoch: Option<i64>,
1806 epoch_offset: Option<i64>,
1808 shell_profile: interpreter::ShellProfile,
1809 custom_builtins: HashMap<String, Box<dyn Builtin>>,
1810 host_builtins: Option<BuiltinRegistry>,
1813 command_resolver: Option<Arc<dyn CommandResolver>>,
1815 mounted_files: Vec<MountedFile>,
1817 mounted_lazy_files: Vec<MountedLazyFile>,
1819 #[cfg(feature = "http_client")]
1821 network_allowlist: Option<NetworkAllowlist>,
1822 #[cfg(feature = "http_client")]
1824 http_limits: network::HttpLimits,
1825 #[cfg(feature = "http_client")]
1827 http_transport: Option<Arc<dyn network::HttpTransport>>,
1828 #[cfg(feature = "bot-auth")]
1830 bot_auth_config: Option<network::BotAuthConfig>,
1831 #[cfg(feature = "logging")]
1833 log_config: Option<logging::LogConfig>,
1834 #[cfg(feature = "git")]
1836 git_config: Option<GitConfig>,
1837 #[cfg(feature = "ssh")]
1839 ssh_config: Option<SshConfig>,
1840 #[cfg(feature = "ssh")]
1842 ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
1843 #[cfg(feature = "realfs")]
1845 real_mounts: Vec<MountedRealDir>,
1846 #[cfg(feature = "realfs")]
1849 mount_path_allowlist: Option<Vec<PathBuf>>,
1850 history_file: Option<PathBuf>,
1852 readonly_filesystem: bool,
1854 hooks_on_exit: Vec<hooks::Interceptor<hooks::ExitEvent>>,
1856 hooks_before_exec: Vec<hooks::Interceptor<hooks::ExecInput>>,
1857 hooks_after_exec: Vec<hooks::Interceptor<hooks::ExecOutput>>,
1858 hooks_before_tool: Vec<hooks::Interceptor<hooks::ToolEvent>>,
1859 hooks_after_tool: Vec<hooks::Interceptor<hooks::ToolResult>>,
1860 hooks_on_error: Vec<hooks::Interceptor<hooks::ErrorEvent>>,
1861 #[cfg(feature = "http_client")]
1862 hooks_before_http: Vec<hooks::Interceptor<hooks::HttpRequestEvent>>,
1863 #[cfg(feature = "http_client")]
1864 hooks_after_http: Vec<hooks::Interceptor<hooks::HttpResponseEvent>>,
1865 #[cfg(feature = "http_client")]
1867 credential_policy: Option<credential::CredentialPolicy>,
1868}
1869
1870impl BashBuilder {
1871 pub fn profile(mut self, profile: ExecutionProfile) -> Self {
1878 self.limits = profile.execution_limits().clone();
1879 self.session_limits = profile.session_limits().clone();
1880 self.memory_limits = profile.memory_limits().clone();
1881 self.filesystem_limits = profile.filesystem_limits().clone();
1882 self.readonly_filesystem = profile.readonly_filesystem();
1883 #[cfg(feature = "http_client")]
1884 {
1885 self.network_allowlist = match profile.network_policy() {
1886 ProfileNetworkPolicy::Disabled => None,
1887 ProfileNetworkPolicy::Allowlist(allowlist) => Some(allowlist.clone()),
1888 };
1889 self.http_limits = profile.http_limits().clone();
1890 }
1891 self.profile = profile;
1892 self
1893 }
1894
1895 pub fn filesystem_limits(mut self, limits: FsLimits) -> Self {
1897 self.filesystem_limits = limits;
1898 self
1899 }
1900
1901 #[cfg(feature = "scripted_tool")]
1905 pub fn tool_registry(mut self, registry: ToolRegistry) -> Self {
1906 self = self.extension(scripted_tool::ToolDefExtension::from_registry(
1907 registry.clone(),
1908 ));
1909 #[cfg(feature = "python")]
1910 {
1911 let limits = self.profile.python_limits().clone();
1912 let names = vec!["__bashkit_tool_call".to_string()];
1913 let handler = registry.python_handler();
1914 let prelude = registry.python_prelude();
1915 self = self
1916 .builtin(
1917 "python",
1918 Box::new(
1919 builtins::Python::with_limits(limits.clone())
1920 .with_external_handler_and_prelude(
1921 names.clone(),
1922 handler.clone(),
1923 prelude.clone(),
1924 ),
1925 ),
1926 )
1927 .builtin(
1928 "python3",
1929 Box::new(
1930 builtins::Python::with_limits(limits)
1931 .with_external_handler_and_prelude(names, handler, prelude),
1932 ),
1933 );
1934 }
1935 #[cfg(feature = "typescript")]
1936 {
1937 let limits = self.profile.typescript_limits().clone();
1938 self = self.extension(
1939 builtins::TypeScriptExtension::with_external_handler_and_prelude(
1940 limits,
1941 registry.typescript_external_names(),
1942 registry.typescript_handler(),
1943 registry.typescript_prelude(),
1944 registry.typescript_rewrites(),
1945 ),
1946 );
1947 }
1948 self
1949 }
1950
1951 pub fn fs(mut self, fs: Arc<dyn FileSystem>) -> Self {
1953 self.fs = Some(fs);
1954 self
1955 }
1956
1957 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1959 self.env.insert(key.into(), value.into());
1960 self
1961 }
1962
1963 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
1965 self.cwd = Some(cwd.into());
1966 self
1967 }
1968
1969 pub fn limits(mut self, limits: ExecutionLimits) -> Self {
1971 self.limits = limits;
1972 self
1973 }
1974
1975 #[cfg(feature = "scripted_tool")]
1977 pub(crate) fn logic_only(mut self) -> Self {
1978 self.shell_profile = interpreter::ShellProfile::LogicOnly;
1979 self
1980 }
1981
1982 pub fn session_limits(mut self, limits: SessionLimits) -> Self {
1987 self.session_limits = limits;
1988 self
1989 }
1990
1991 pub fn memory_limits(mut self, limits: MemoryLimits) -> Self {
1996 self.memory_limits = limits;
1997 self
1998 }
1999
2000 pub fn max_memory(self, bytes: usize) -> Self {
2016 let defaults = MemoryLimits::default();
2017 self.memory_limits(
2018 MemoryLimits::new()
2019 .max_total_variable_bytes(bytes)
2020 .max_function_body_bytes(bytes.min(defaults.max_function_body_bytes)),
2021 )
2022 }
2023
2024 pub fn trace_mode(mut self, mode: TraceMode) -> Self {
2030 self.trace_mode = mode;
2031 self
2032 }
2033
2034 pub fn on_trace_event(mut self, callback: TraceCallback) -> Self {
2039 self.trace_callback = Some(callback);
2040 self
2041 }
2042
2043 pub fn username(mut self, username: impl Into<String>) -> Self {
2048 self.username = Some(username.into());
2049 self
2050 }
2051
2052 pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
2056 self.hostname = Some(hostname.into());
2057 self
2058 }
2059
2060 pub fn tty(mut self, fd: u32, is_terminal: bool) -> Self {
2074 let key = format!("_TTY_{}", fd);
2075 if is_terminal {
2076 self.env.insert(key, "1".to_string());
2077 } else {
2078 self.env.remove(&key);
2079 }
2080 self
2081 }
2082
2083 pub fn fixed_epoch(mut self, epoch: i64) -> Self {
2088 self.fixed_epoch = Some(epoch);
2089 self.epoch_offset = None;
2090 self
2091 }
2092
2093 pub fn epoch_offset(mut self, seconds: i64) -> Self {
2105 self.epoch_offset = Some(seconds);
2106 self.fixed_epoch = None;
2107 self
2108 }
2109
2110 pub fn history_file(mut self, path: impl Into<PathBuf>) -> Self {
2115 self.history_file = Some(path.into());
2116 self
2117 }
2118
2119 #[cfg(feature = "http_client")]
2151 pub fn network(mut self, allowlist: NetworkAllowlist) -> Self {
2152 self.network_allowlist = Some(allowlist);
2153 self
2154 }
2155
2156 #[cfg(feature = "http_client")]
2158 pub fn http_limits(mut self, limits: network::HttpLimits) -> Self {
2159 self.http_limits = limits;
2160 self
2161 }
2162
2163 #[cfg(feature = "http_client")]
2225 pub fn http_transport(mut self, transport: Arc<dyn network::HttpTransport>) -> Self {
2226 self.http_transport = Some(transport);
2227 self
2228 }
2229
2230 #[cfg(feature = "bot-auth")]
2251 pub fn bot_auth(mut self, config: network::BotAuthConfig) -> Self {
2252 self.bot_auth_config = Some(config);
2253 self
2254 }
2255
2256 #[cfg(feature = "logging")]
2293 pub fn log_config(mut self, config: logging::LogConfig) -> Self {
2294 self.log_config = Some(config);
2295 self
2296 }
2297
2298 #[cfg(feature = "git")]
2327 pub fn git(mut self, config: GitConfig) -> Self {
2328 self.git_config = Some(config);
2329 self
2330 }
2331
2332 #[cfg(feature = "ssh")]
2352 pub fn ssh(mut self, config: SshConfig) -> Self {
2353 self.ssh_config = Some(config);
2354 self
2355 }
2356
2357 #[cfg(feature = "ssh")]
2363 pub fn ssh_handler(mut self, handler: Box<dyn builtins::ssh::SshHandler>) -> Self {
2364 self.ssh_handler = Some(handler);
2365 self
2366 }
2367
2368 #[cfg(feature = "python")]
2387 pub fn python(self) -> Self {
2388 let limits = self.profile.python_limits().clone();
2389 self.python_with_limits(limits)
2390 }
2391
2392 #[cfg(feature = "sqlite")]
2411 pub fn sqlite(self) -> Self {
2412 let limits = self.profile.sqlite_limits().clone();
2413 self.sqlite_with_limits(limits)
2414 }
2415
2416 #[cfg(feature = "sqlite")]
2436 pub fn sqlite_with_limits(self, limits: builtins::SqliteLimits) -> Self {
2437 self.builtin(
2438 "sqlite",
2439 Box::new(builtins::Sqlite::with_limits(limits.clone())),
2440 )
2441 .builtin("sqlite3", Box::new(builtins::Sqlite::with_limits(limits)))
2442 }
2443
2444 #[cfg(feature = "python")]
2459 pub fn python_with_limits(self, limits: builtins::PythonLimits) -> Self {
2460 self.builtin(
2461 "python",
2462 Box::new(builtins::Python::with_limits(limits.clone())),
2463 )
2464 .builtin("python3", Box::new(builtins::Python::with_limits(limits)))
2465 }
2466
2467 #[cfg(feature = "python")]
2471 pub fn python_with_external_handler(
2472 self,
2473 limits: builtins::PythonLimits,
2474 external_fns: Vec<String>,
2475 handler: builtins::PythonExternalFnHandler,
2476 ) -> Self {
2477 self.builtin(
2478 "python",
2479 Box::new(
2480 builtins::Python::with_limits(limits.clone())
2481 .with_external_handler(external_fns.clone(), handler.clone()),
2482 ),
2483 )
2484 .builtin(
2485 "python3",
2486 Box::new(
2487 builtins::Python::with_limits(limits).with_external_handler(external_fns, handler),
2488 ),
2489 )
2490 }
2491
2492 #[cfg(feature = "typescript")]
2504 pub fn typescript(self) -> Self {
2505 let limits = self.profile.typescript_limits().clone();
2506 self.typescript_with_limits(limits)
2507 }
2508
2509 #[cfg(feature = "typescript")]
2513 pub fn typescript_with_limits(self, limits: builtins::TypeScriptLimits) -> Self {
2514 self.typescript_with_config(builtins::TypeScriptConfig::default().limits(limits))
2515 }
2516
2517 #[cfg(feature = "typescript")]
2545 pub fn typescript_with_config(self, config: builtins::TypeScriptConfig) -> Self {
2546 self.extension(builtins::TypeScriptExtension::with_config(config))
2547 }
2548
2549 #[cfg(feature = "typescript")]
2553 pub fn typescript_with_external_handler(
2554 self,
2555 limits: builtins::TypeScriptLimits,
2556 external_fns: Vec<String>,
2557 handler: builtins::TypeScriptExternalFnHandler,
2558 ) -> Self {
2559 self.extension(builtins::TypeScriptExtension::with_external_handler(
2560 limits,
2561 external_fns,
2562 handler,
2563 ))
2564 }
2565
2566 pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
2603 self.custom_builtins.insert(name.into(), builtin);
2604 self
2605 }
2606
2607 pub fn host_call_builtin(mut self, name: impl Into<String>) -> Self {
2613 let name = name.into();
2614 self.custom_builtins.insert(
2615 name.clone(),
2616 Box::new(host_call::HostCallBuiltin::new(name)),
2617 );
2618 self
2619 }
2620
2621 pub fn builtin_registry(mut self, registry: BuiltinRegistry) -> Self {
2638 self.host_builtins = Some(registry);
2639 self
2640 }
2641
2642 pub fn command_resolver(mut self, resolver: Arc<dyn CommandResolver>) -> Self {
2682 self.command_resolver = Some(resolver);
2683 self
2684 }
2685
2686 pub fn extension<E>(mut self, extension: E) -> Self
2718 where
2719 E: builtins::Extension,
2720 {
2721 for (name, builtin) in extension.builtins() {
2722 self.custom_builtins.insert(name, builtin);
2723 }
2724 self
2725 }
2726
2727 pub fn on_exit(mut self, hook: hooks::Interceptor<hooks::ExitEvent>) -> Self {
2750 self.hooks_on_exit.push(hook);
2751 self
2752 }
2753
2754 pub fn before_exec(mut self, hook: hooks::Interceptor<hooks::ExecInput>) -> Self {
2759 self.hooks_before_exec.push(hook);
2760 self
2761 }
2762
2763 pub fn after_exec(mut self, hook: hooks::Interceptor<hooks::ExecOutput>) -> Self {
2768 self.hooks_after_exec.push(hook);
2769 self
2770 }
2771
2772 pub fn before_tool(mut self, hook: hooks::Interceptor<hooks::ToolEvent>) -> Self {
2777 self.hooks_before_tool.push(hook);
2778 self
2779 }
2780
2781 pub fn after_tool(mut self, hook: hooks::Interceptor<hooks::ToolResult>) -> Self {
2785 self.hooks_after_tool.push(hook);
2786 self
2787 }
2788
2789 pub fn on_error(mut self, hook: hooks::Interceptor<hooks::ErrorEvent>) -> Self {
2793 self.hooks_on_error.push(hook);
2794 self
2795 }
2796
2797 #[cfg(feature = "http_client")]
2818 pub fn before_http(mut self, hook: hooks::Interceptor<hooks::HttpRequestEvent>) -> Self {
2819 self.hooks_before_http.push(hook);
2820 self
2821 }
2822
2823 #[cfg(feature = "http_client")]
2828 pub fn after_http(mut self, hook: hooks::Interceptor<hooks::HttpResponseEvent>) -> Self {
2829 self.hooks_after_http.push(hook);
2830 self
2831 }
2832
2833 #[cfg(feature = "http_client")]
2860 pub fn credential(mut self, pattern: &str, cred: credential::Credential) -> Self {
2861 self.credential_policy
2862 .get_or_insert_with(credential::CredentialPolicy::new)
2863 .add_injection(pattern, cred);
2864 self
2865 }
2866
2867 #[cfg(feature = "http_client")]
2896 pub fn credential_placeholder(
2897 mut self,
2898 env_name: &str,
2899 pattern: &str,
2900 cred: credential::Credential,
2901 ) -> Self {
2902 let placeholder = self
2903 .credential_policy
2904 .get_or_insert_with(credential::CredentialPolicy::new)
2905 .add_placeholder(pattern, cred);
2906 self.env.insert(env_name.to_string(), placeholder);
2907 self
2908 }
2909
2910 pub fn mount_text(mut self, path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
2939 self.mounted_files.push(MountedFile {
2940 path: path.into(),
2941 content: content.into(),
2942 mode: 0o644,
2943 });
2944 self
2945 }
2946
2947 pub fn mount_readonly_text(
2986 mut self,
2987 path: impl Into<PathBuf>,
2988 content: impl Into<String>,
2989 ) -> Self {
2990 self.mounted_files.push(MountedFile {
2991 path: path.into(),
2992 content: content.into(),
2993 mode: 0o444,
2994 });
2995 self
2996 }
2997
2998 pub fn mount_lazy(
3024 mut self,
3025 path: impl Into<PathBuf>,
3026 size_hint: u64,
3027 loader: LazyLoader,
3028 ) -> Self {
3029 self.mounted_lazy_files.push(MountedLazyFile {
3030 path: path.into(),
3031 size_hint,
3032 mode: 0o644,
3033 loader,
3034 });
3035 self
3036 }
3037
3038 #[cfg(feature = "realfs")]
3056 pub fn mount_real_readonly(mut self, host_path: impl Into<PathBuf>) -> Self {
3057 self.real_mounts.push(MountedRealDir {
3058 host_path: host_path.into(),
3059 vfs_mount: None,
3060 mode: fs::RealFsMode::ReadOnly,
3061 });
3062 self
3063 }
3064
3065 #[cfg(feature = "realfs")]
3083 pub fn mount_real_readonly_at(
3084 mut self,
3085 host_path: impl Into<PathBuf>,
3086 vfs_mount: impl Into<PathBuf>,
3087 ) -> Self {
3088 self.real_mounts.push(MountedRealDir {
3089 host_path: host_path.into(),
3090 vfs_mount: Some(vfs_mount.into()),
3091 mode: fs::RealFsMode::ReadOnly,
3092 });
3093 self
3094 }
3095
3096 #[cfg(feature = "realfs")]
3113 pub fn mount_real_readwrite(mut self, host_path: impl Into<PathBuf>) -> Self {
3114 self.real_mounts.push(MountedRealDir {
3115 host_path: host_path.into(),
3116 vfs_mount: None,
3117 mode: fs::RealFsMode::ReadWrite,
3118 });
3119 self
3120 }
3121
3122 #[cfg(feature = "realfs")]
3137 pub fn mount_real_readwrite_at(
3138 mut self,
3139 host_path: impl Into<PathBuf>,
3140 vfs_mount: impl Into<PathBuf>,
3141 ) -> Self {
3142 self.real_mounts.push(MountedRealDir {
3143 host_path: host_path.into(),
3144 vfs_mount: Some(vfs_mount.into()),
3145 mode: fs::RealFsMode::ReadWrite,
3146 });
3147 self
3148 }
3149
3150 #[cfg(feature = "realfs")]
3166 pub fn allowed_mount_paths(
3167 mut self,
3168 paths: impl IntoIterator<Item = impl Into<PathBuf>>,
3169 ) -> Self {
3170 self.mount_path_allowlist = Some(paths.into_iter().map(|p| p.into()).collect());
3171 self
3172 }
3173
3174 pub fn readonly_filesystem(mut self, readonly: bool) -> Self {
3180 self.readonly_filesystem = readonly;
3181 self
3182 }
3183
3184 pub fn build(self) -> Bash {
3219 let base_fs: Arc<dyn FileSystem> = if self.shell_profile.is_logic_only() {
3220 Arc::new(fs::DisabledFs)
3221 } else if let Some(fs) = self.fs {
3222 fs
3223 } else {
3224 let username = self
3231 .username
3232 .as_deref()
3233 .unwrap_or(builtins::DEFAULT_USERNAME);
3234 Arc::new(inmem_fs_with_home(username, self.filesystem_limits.clone()))
3235 };
3236
3237 #[cfg(feature = "realfs")]
3239 let (base_fs, host_mounts) = Self::apply_real_mounts(
3240 &self.real_mounts,
3241 self.mount_path_allowlist.as_deref(),
3242 base_fs,
3243 );
3244
3245 let has_mounts = !self.mounted_files.is_empty() || !self.mounted_lazy_files.is_empty();
3247 let base_fs: Arc<dyn FileSystem> = if has_mounts {
3248 let overlay = OverlayFs::with_limits(base_fs.clone(), base_fs.limits());
3249 for mf in &self.mounted_files {
3250 overlay.upper().add_file(&mf.path, &mf.content, mf.mode);
3251 }
3252 for lf in self.mounted_lazy_files {
3253 overlay
3254 .upper()
3255 .add_lazy_file(&lf.path, lf.size_hint, lf.mode, lf.loader);
3256 }
3257 Arc::new(overlay)
3258 } else {
3259 base_fs
3260 };
3261
3262 let base_fs: Arc<dyn FileSystem> = if self.readonly_filesystem {
3264 Arc::new(ReadOnlyFs::new(base_fs))
3265 } else {
3266 base_fs
3267 };
3268
3269 let mountable = Arc::new(MountableFs::new(base_fs));
3271 let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
3272
3273 let mut result = Self::build_with_fs(
3274 fs,
3275 mountable,
3276 self.readonly_filesystem,
3277 self.env,
3278 self.username,
3279 self.hostname,
3280 self.fixed_epoch,
3281 self.epoch_offset,
3282 self.cwd,
3283 self.shell_profile,
3284 self.profile.name() == ExecutionProfileName::Hardened,
3285 self.limits,
3286 self.session_limits,
3287 self.memory_limits,
3288 self.trace_mode,
3289 self.trace_callback,
3290 self.custom_builtins,
3291 self.host_builtins,
3292 self.command_resolver,
3293 self.history_file,
3294 #[cfg(feature = "http_client")]
3295 self.network_allowlist,
3296 #[cfg(feature = "http_client")]
3297 self.http_limits,
3298 #[cfg(feature = "http_client")]
3299 self.http_transport,
3300 #[cfg(feature = "bot-auth")]
3301 self.bot_auth_config,
3302 #[cfg(feature = "logging")]
3303 self.log_config,
3304 #[cfg(feature = "git")]
3305 self.git_config,
3306 #[cfg(feature = "ssh")]
3307 self.ssh_config,
3308 #[cfg(feature = "ssh")]
3309 self.ssh_handler,
3310 );
3311
3312 #[cfg(feature = "realfs")]
3314 {
3315 result.host_mounts = host_mounts;
3316 }
3317
3318 let hooks = hooks::Hooks {
3320 on_exit: self.hooks_on_exit,
3321 before_exec: self.hooks_before_exec,
3322 after_exec: self.hooks_after_exec,
3323 before_tool: self.hooks_before_tool,
3324 after_tool: self.hooks_after_tool,
3325 on_error: self.hooks_on_error,
3326 };
3327 if hooks.has_hooks() {
3328 result.interpreter.set_hooks(hooks);
3329 }
3330
3331 #[cfg(feature = "http_client")]
3334 let mut hooks_before_http = Vec::new();
3335 #[cfg(feature = "http_client")]
3336 if let Some(policy) = self.credential_policy
3337 && !policy.is_empty()
3338 {
3339 hooks_before_http.push(policy.into_hook());
3340 }
3341 #[cfg(feature = "http_client")]
3342 hooks_before_http.extend(self.hooks_before_http);
3343
3344 #[cfg(feature = "http_client")]
3346 if (!hooks_before_http.is_empty() || !self.hooks_after_http.is_empty())
3347 && let Some(client) = result.interpreter.http_client_mut()
3348 {
3349 if !hooks_before_http.is_empty() {
3350 client.set_before_http(hooks_before_http);
3351 }
3352 if !self.hooks_after_http.is_empty() {
3353 client.set_after_http(self.hooks_after_http);
3354 }
3355 }
3356
3357 result
3358 }
3359
3360 #[cfg(feature = "realfs")]
3365 const SENSITIVE_MOUNT_PATHS: &[&str] = &[
3366 "/proc", "/sys", "/dev", "/etc", "/boot", "/root", "/Users", "/home", "/run", "/var/run", "/private",
3373 ];
3374
3375 #[cfg(feature = "realfs")]
3380 const SENSITIVE_PATH_COMPONENTS: &[&str] =
3381 &[".ssh", ".aws", ".kube", ".docker", ".gnupg", ".gcloud"];
3382
3383 #[cfg(feature = "realfs")]
3388 fn is_sensitive_mount_path(host_path: &Path) -> bool {
3389 if host_path.parent().is_none() {
3392 return true;
3393 }
3394 if Self::SENSITIVE_MOUNT_PATHS
3395 .iter()
3396 .any(|s| host_path.starts_with(Path::new(s)))
3397 {
3398 return true;
3399 }
3400 host_path.components().any(|c| {
3401 let s = c.as_os_str();
3402 Self::SENSITIVE_PATH_COMPONENTS.iter().any(|sec| s == *sec)
3403 })
3404 }
3405
3406 #[cfg(feature = "realfs")]
3407 #[allow(deprecated)] fn apply_real_mounts(
3409 real_mounts: &[MountedRealDir],
3410 mount_allowlist: Option<&[PathBuf]>,
3411 base_fs: Arc<dyn FileSystem>,
3412 ) -> (Arc<dyn FileSystem>, HostMounts) {
3413 if real_mounts.is_empty() {
3414 return (base_fs, HostMounts::default());
3415 }
3416
3417 let mut current_fs = base_fs;
3418 let mut mount_points: Vec<(PathBuf, Arc<dyn FileSystem>)> = Vec::new();
3419 let mut host_mounts = HostMounts::default();
3422 let canonical_allowlist: Option<Vec<PathBuf>> = mount_allowlist.map(|allowlist| {
3423 allowlist
3424 .iter()
3425 .filter_map(|allowed| match std::fs::canonicalize(allowed) {
3426 Ok(path) => Some(path),
3427 Err(e) => {
3428 eprintln!(
3429 "bashkit: warning: failed to canonicalize allowlist path {}: {}",
3430 allowed.display(),
3431 e
3432 );
3433 None
3434 }
3435 })
3436 .collect()
3437 });
3438
3439 for m in real_mounts {
3440 if m.mode == fs::RealFsMode::ReadWrite {
3442 eprintln!(
3443 "bashkit: warning: writable mount at {} — scripts can modify host files",
3444 m.host_path.display()
3445 );
3446 }
3447
3448 let canonical_host = match std::fs::canonicalize(&m.host_path) {
3449 Ok(path) => path,
3450 Err(e) => {
3451 eprintln!(
3452 "bashkit: warning: failed to canonicalize mount path {}: {}",
3453 m.host_path.display(),
3454 e
3455 );
3456 continue;
3457 }
3458 };
3459
3460 let is_sensitive = Self::is_sensitive_mount_path(&canonical_host);
3464
3465 if let Some(allowlist) = &canonical_allowlist {
3466 if !allowlist
3467 .iter()
3468 .any(|allowed| canonical_host.starts_with(allowed))
3469 {
3470 eprintln!(
3471 "bashkit: warning: mount path {} not in allowlist, skipping",
3472 m.host_path.display()
3473 );
3474 continue;
3475 }
3476 } else if is_sensitive {
3477 eprintln!(
3478 "bashkit: warning: refusing to mount sensitive path {} (no allowlist set; \
3479 pass an explicit `allowed_mount_paths` entry to override)",
3480 m.host_path.display()
3481 );
3482 continue;
3483 }
3484
3485 let real_backend = match fs::RealFs::new(&canonical_host, m.mode) {
3486 Ok(b) => b,
3487 Err(e) => {
3488 eprintln!(
3489 "bashkit: warning: failed to mount {}: {}",
3490 m.host_path.display(),
3491 e
3492 );
3493 continue;
3494 }
3495 };
3496 let real_fs: Arc<dyn FileSystem> = Arc::new(PosixFs::new(real_backend));
3497
3498 match &m.vfs_mount {
3499 None => {
3500 current_fs = Arc::new(OverlayFs::new(real_fs));
3503 host_mounts.mounts.push(HostMount {
3504 host_path: canonical_host,
3505 vfs_path: PathBuf::from("/"),
3506 });
3507 }
3508 Some(mount_point) => {
3509 mount_points.push((mount_point.clone(), real_fs));
3510 host_mounts.mounts.push(HostMount {
3511 host_path: canonical_host,
3512 vfs_path: mount_point.clone(),
3513 });
3514 }
3515 }
3516 }
3517
3518 if !mount_points.is_empty() {
3520 let mountable = MountableFs::new(current_fs);
3521 for (path, fs) in mount_points {
3522 if let Err(e) = mountable.mount(&path, fs) {
3523 eprintln!(
3524 "bashkit: warning: failed to mount at {}: {}",
3525 path.display(),
3526 e
3527 );
3528 }
3529 }
3530 (Arc::new(mountable), host_mounts)
3531 } else {
3532 (current_fs, host_mounts)
3533 }
3534 }
3535
3536 #[allow(clippy::too_many_arguments)]
3538 fn build_with_fs(
3539 fs: Arc<dyn FileSystem>,
3540 mountable: Arc<MountableFs>,
3541 readonly_filesystem: bool,
3542 env: HashMap<String, String>,
3543 username: Option<String>,
3544 hostname: Option<String>,
3545 fixed_epoch: Option<i64>,
3546 epoch_offset: Option<i64>,
3547 cwd: Option<PathBuf>,
3548 shell_profile: interpreter::ShellProfile,
3549 hardened_timing: bool,
3550 limits: ExecutionLimits,
3551 session_limits: SessionLimits,
3552 memory_limits: MemoryLimits,
3553 trace_mode: TraceMode,
3554 trace_callback: Option<TraceCallback>,
3555 custom_builtins: HashMap<String, Box<dyn Builtin>>,
3556 host_builtins: Option<BuiltinRegistry>,
3557 command_resolver: Option<Arc<dyn CommandResolver>>,
3558 history_file: Option<PathBuf>,
3559 #[cfg(feature = "http_client")] network_allowlist: Option<NetworkAllowlist>,
3560 #[cfg(feature = "http_client")] http_limits: network::HttpLimits,
3561 #[cfg(feature = "http_client")] http_transport: Option<Arc<dyn network::HttpTransport>>,
3562 #[cfg(feature = "bot-auth")] bot_auth_config: Option<network::BotAuthConfig>,
3563 #[cfg(feature = "logging")] log_config: Option<logging::LogConfig>,
3564 #[cfg(feature = "git")] git_config: Option<GitConfig>,
3565 #[cfg(feature = "ssh")] ssh_config: Option<SshConfig>,
3566 #[cfg(feature = "ssh")] ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
3567 ) -> Bash {
3568 #[cfg(feature = "logging")]
3569 let log_config = log_config.unwrap_or_default();
3570
3571 #[cfg(feature = "logging")]
3572 tracing::debug!(
3573 target: "bashkit::config",
3574 redact_sensitive = log_config.redact_sensitive,
3575 log_scripts = log_config.log_script_content,
3576 "Bash instance configured"
3577 );
3578
3579 let mut interpreter = Interpreter::with_config(
3580 Arc::clone(&fs),
3581 username.clone(),
3582 hostname,
3583 fixed_epoch,
3584 epoch_offset,
3585 custom_builtins,
3586 host_builtins,
3587 shell_profile,
3588 hardened_timing,
3589 );
3590
3591 if let Some(resolver) = command_resolver {
3592 interpreter.set_command_resolver(resolver);
3593 }
3594
3595 for (key, value) in &env {
3597 interpreter.set_env(key, value);
3598 interpreter.set_var(key, value);
3601 }
3602 #[cfg(feature = "python")]
3603 let python_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_PYTHON");
3604 #[cfg(feature = "sqlite")]
3605 let sqlite_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_SQLITE");
3606 drop(env);
3607
3608 if let Some(ref username) = username {
3610 interpreter.set_env("USER", username);
3611 interpreter.set_var("USER", username);
3612 }
3613
3614 if let Some(cwd) = cwd {
3615 interpreter.set_cwd(cwd);
3616 }
3617
3618 #[cfg(feature = "http_client")]
3620 if let Some(allowlist) = network_allowlist {
3621 let mut client = network::HttpClient::with_limits(allowlist, http_limits);
3622 if let Some(transport) = http_transport {
3623 client.set_transport(transport);
3624 }
3625 #[cfg(feature = "bot-auth")]
3626 if let Some(bot_auth) = bot_auth_config {
3627 client.set_bot_auth(bot_auth);
3628 }
3629 interpreter.set_http_client(client);
3630 }
3631
3632 #[cfg(feature = "git")]
3634 if let Some(config) = git_config {
3635 let client = builtins::git::GitClient::new(config);
3636 interpreter.set_git_client(client);
3637 }
3638
3639 #[cfg(feature = "ssh")]
3641 if let Some(config) = ssh_config {
3642 let mut client = builtins::ssh::SshClient::new(config);
3643 if let Some(handler) = ssh_handler {
3644 client.set_handler(handler);
3645 }
3646 interpreter.set_ssh_client(client);
3647 }
3648
3649 if let Some(hf) = history_file {
3651 interpreter.set_history_file(hf);
3652 }
3653
3654 let parser_timeout = limits.parser_timeout;
3655 let max_input_bytes = limits.max_input_bytes;
3656 let max_ast_depth = limits.max_ast_depth;
3657 let max_parser_operations = limits.max_parser_operations;
3658 interpreter.set_limits(limits);
3659 interpreter.set_session_limits(session_limits);
3660 interpreter.set_memory_limits(memory_limits);
3661 let mut trace_collector = TraceCollector::new(trace_mode);
3662 if let Some(cb) = trace_callback {
3663 trace_collector.set_callback(cb);
3664 }
3665 interpreter.set_trace(trace_collector);
3666 Bash {
3667 fs,
3668 mountable,
3669 readonly_filesystem,
3670 interpreter,
3671 parser_timeout,
3672 max_input_bytes,
3673 max_ast_depth,
3674 max_parser_operations,
3675 #[cfg(feature = "logging")]
3676 log_config,
3677 #[cfg(feature = "python")]
3678 python_inprocess_opt_in,
3679 #[cfg(feature = "sqlite")]
3680 sqlite_inprocess_opt_in,
3681 #[cfg(feature = "realfs")]
3682 host_mounts: HostMounts::default(),
3683 }
3684 }
3685}
3686
3687#[cfg(feature = "http_client")]
3703#[doc = include_str!("../docs/credential-injection.md")]
3704pub mod credential_injection_guide {}
3705
3706#[doc = include_str!("../docs/script-analysis.md")]
3716pub mod script_analysis_guide {}
3717
3718#[doc = include_str!("../docs/custom_builtins.md")]
3728pub mod custom_builtins_guide {}
3729
3730#[doc = include_str!("../docs/clap-builtins.md")]
3740pub mod clap_builtins_guide {}
3741
3742#[doc = include_str!("../docs/compatibility.md")]
3752pub mod compatibility_scorecard {}
3753
3754#[doc = include_str!("../docs/jq.md")]
3764pub mod jq_guide {}
3765
3766#[doc = include_str!("../docs/yq.md")]
3770pub mod yq_guide {}
3771
3772#[doc = include_str!("../docs/threat-model.md")]
3786pub mod threat_model {}
3787
3788#[cfg(feature = "python")]
3802#[doc = include_str!("../docs/python.md")]
3803pub mod python_guide {}
3804
3805#[cfg(feature = "sqlite")]
3817#[doc = include_str!("../docs/sqlite.md")]
3818pub mod sqlite_guide {}
3819
3820#[cfg(feature = "typescript")]
3832#[doc = include_str!("../docs/typescript.md")]
3833pub mod typescript_guide {}
3834
3835#[cfg(feature = "ssh")]
3839#[doc = include_str!("../docs/ssh.md")]
3840pub mod ssh_guide {}
3841
3842#[doc = include_str!("../docs/live_mounts.md")]
3852pub mod live_mounts_guide {}
3853
3854#[doc = include_str!("../docs/namespace_filesystems.md")]
3856pub mod namespace_filesystems_guide {}
3857
3858#[cfg(feature = "logging")]
3871#[doc = include_str!("../docs/logging.md")]
3872pub mod logging_guide {}
3873
3874#[doc = include_str!("../docs/hooks.md")]
3889pub mod hooks_guide {}
3890
3891#[cfg(test)]
3892mod tests {
3893 use super::*;
3894 use std::sync::{Arc, Mutex};
3895
3896 #[tokio::test]
3897 async fn test_echo_hello() {
3898 let mut bash = Bash::new();
3899 let result = bash.exec("echo hello").await.unwrap();
3900 assert_eq!(result.stdout, "hello\n");
3901 assert_eq!(result.exit_code, 0);
3902 }
3903
3904 #[tokio::test]
3905 async fn test_echo_multiple_args() {
3906 let mut bash = Bash::new();
3907 let result = bash.exec("echo hello world").await.unwrap();
3908 assert_eq!(result.stdout, "hello world\n");
3909 assert_eq!(result.exit_code, 0);
3910 }
3911
3912 #[tokio::test]
3913 async fn test_variable_expansion() {
3914 let mut bash = Bash::builder().env("HOME", "/home/user").build();
3915 let result = bash.exec("echo $HOME").await.unwrap();
3916 assert_eq!(result.stdout, "/home/user\n");
3917 assert_eq!(result.exit_code, 0);
3918 }
3919
3920 #[tokio::test]
3921 async fn test_variable_brace_expansion() {
3922 let mut bash = Bash::builder().env("USER", "testuser").build();
3923 let result = bash.exec("echo ${USER}").await.unwrap();
3924 assert_eq!(result.stdout, "testuser\n");
3925 }
3926
3927 #[tokio::test]
3928 async fn test_undefined_variable_expands_to_empty() {
3929 let mut bash = Bash::new();
3930 let result = bash.exec("echo $UNDEFINED_VAR").await.unwrap();
3931 assert_eq!(result.stdout, "\n");
3932 }
3933
3934 #[tokio::test]
3935 async fn test_pipeline() {
3936 let mut bash = Bash::new();
3937 let result = bash.exec("echo hello | cat").await.unwrap();
3938 assert_eq!(result.stdout, "hello\n");
3939 }
3940
3941 #[tokio::test(start_paused = true)]
3942 async fn test_timed_out_bash_c_does_not_leak_stdin_to_next_exec() {
3943 let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
3944 let mut bash = Bash::builder().limits(limits).build();
3945
3946 let timed_out = bash.exec("printf secret | bash -c 'sleep 10'").await;
3947 assert!(matches!(
3948 timed_out,
3949 Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
3950 ));
3951
3952 let result = bash.exec("cat").await.unwrap();
3953 assert_eq!(result.stdout, "");
3954 }
3955
3956 #[tokio::test(start_paused = true)]
3957 async fn test_timed_out_fd3_capture_does_not_leak_to_next_exec() {
3958 let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
3959 let mut bash = Bash::builder().limits(limits).build();
3960
3961 let timed_out = bash.exec("{ sleep 10; } 3>&1 > /tmp/poison.txt").await;
3962 assert!(matches!(
3963 timed_out,
3964 Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
3965 ));
3966
3967 let hidden = bash.exec("echo SECRET_FROM_EXEC2 1>&3").await.unwrap();
3968 assert_eq!(hidden.stdout, "");
3969
3970 let routed = bash
3971 .exec("echo PUBLIC_FROM_EXEC3 2>&1 > /tmp/public.txt")
3972 .await
3973 .unwrap();
3974 assert_eq!(routed.stdout, "");
3975
3976 let file = bash.exec("cat /tmp/public.txt").await.unwrap();
3977 assert_eq!(file.stdout, "PUBLIC_FROM_EXEC3\n");
3978 }
3979
3980 #[tokio::test(start_paused = true)]
3981 async fn test_timed_out_debug_trap_does_not_suppress_next_exec_debug_trap() {
3982 let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
3983 let mut bash = Bash::builder().limits(limits).build();
3984
3985 let timed_out = bash
3986 .exec("trap 'sleep 10' DEBUG; echo should-not-run")
3987 .await;
3988 assert!(matches!(
3989 timed_out,
3990 Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
3991 ));
3992
3993 let result = bash
3994 .exec("count=0; trap '((count++))' DEBUG; echo body; trap - DEBUG; echo $count")
3995 .await
3996 .unwrap();
3997 assert_eq!(result.stdout, "body\n2\n");
3998 }
3999
4000 #[tokio::test]
4001 async fn test_pipeline_three_commands() {
4002 let mut bash = Bash::new();
4003 let result = bash.exec("echo hello | cat | cat").await.unwrap();
4004 assert_eq!(result.stdout, "hello\n");
4005 }
4006
4007 #[tokio::test]
4008 async fn test_redirect_output() {
4009 let mut bash = Bash::new();
4010 let result = bash.exec("echo hello > /tmp/test.txt").await.unwrap();
4011 assert_eq!(result.stdout, "");
4012 assert_eq!(result.exit_code, 0);
4013
4014 let result = bash.exec("cat /tmp/test.txt").await.unwrap();
4016 assert_eq!(result.stdout, "hello\n");
4017 }
4018
4019 #[tokio::test]
4020 async fn test_redirect_append() {
4021 let mut bash = Bash::new();
4022 bash.exec("echo hello > /tmp/append.txt").await.unwrap();
4023 bash.exec("echo world >> /tmp/append.txt").await.unwrap();
4024
4025 let result = bash.exec("cat /tmp/append.txt").await.unwrap();
4026 assert_eq!(result.stdout, "hello\nworld\n");
4027 }
4028
4029 #[tokio::test]
4030 async fn test_command_list_and() {
4031 let mut bash = Bash::new();
4032 let result = bash.exec("true && echo success").await.unwrap();
4033 assert_eq!(result.stdout, "success\n");
4034 }
4035
4036 #[tokio::test]
4037 async fn test_command_list_and_short_circuit() {
4038 let mut bash = Bash::new();
4039 let result = bash.exec("false && echo should_not_print").await.unwrap();
4040 assert_eq!(result.stdout, "");
4041 assert_eq!(result.exit_code, 1);
4042 }
4043
4044 #[tokio::test]
4045 async fn test_command_list_or() {
4046 let mut bash = Bash::new();
4047 let result = bash.exec("false || echo fallback").await.unwrap();
4048 assert_eq!(result.stdout, "fallback\n");
4049 }
4050
4051 #[tokio::test]
4052 async fn test_command_list_or_short_circuit() {
4053 let mut bash = Bash::new();
4054 let result = bash.exec("true || echo should_not_print").await.unwrap();
4055 assert_eq!(result.stdout, "");
4056 assert_eq!(result.exit_code, 0);
4057 }
4058
4059 #[tokio::test]
4061 async fn test_phase1_target() {
4062 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
4063
4064 let result = bash
4065 .exec("echo $HOME | cat > /tmp/out && cat /tmp/out")
4066 .await
4067 .unwrap();
4068
4069 assert_eq!(result.stdout, "/home/testuser\n");
4070 assert_eq!(result.exit_code, 0);
4071 }
4072
4073 #[tokio::test]
4074 async fn test_redirect_input() {
4075 let mut bash = Bash::new();
4076 bash.exec("echo hello > /tmp/input.txt").await.unwrap();
4078
4079 let result = bash.exec("cat < /tmp/input.txt").await.unwrap();
4081 assert_eq!(result.stdout, "hello\n");
4082 }
4083
4084 #[tokio::test]
4085 async fn test_here_string() {
4086 let mut bash = Bash::new();
4087 let result = bash.exec("cat <<< hello").await.unwrap();
4088 assert_eq!(result.stdout, "hello\n");
4089 }
4090
4091 #[tokio::test]
4092 async fn test_if_true() {
4093 let mut bash = Bash::new();
4094 let result = bash.exec("if true; then echo yes; fi").await.unwrap();
4095 assert_eq!(result.stdout, "yes\n");
4096 }
4097
4098 #[tokio::test]
4099 async fn test_if_false() {
4100 let mut bash = Bash::new();
4101 let result = bash.exec("if false; then echo yes; fi").await.unwrap();
4102 assert_eq!(result.stdout, "");
4103 }
4104
4105 #[tokio::test]
4106 async fn test_if_else() {
4107 let mut bash = Bash::new();
4108 let result = bash
4109 .exec("if false; then echo yes; else echo no; fi")
4110 .await
4111 .unwrap();
4112 assert_eq!(result.stdout, "no\n");
4113 }
4114
4115 #[tokio::test]
4116 async fn test_if_elif() {
4117 let mut bash = Bash::new();
4118 let result = bash
4119 .exec("if false; then echo one; elif true; then echo two; else echo three; fi")
4120 .await
4121 .unwrap();
4122 assert_eq!(result.stdout, "two\n");
4123 }
4124
4125 #[tokio::test]
4126 async fn test_for_loop() {
4127 let mut bash = Bash::new();
4128 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
4129 assert_eq!(result.stdout, "a\nb\nc\n");
4130 }
4131
4132 #[tokio::test]
4133 async fn test_for_loop_positional_params() {
4134 let mut bash = Bash::new();
4135 let result = bash
4137 .exec("f() { for x; do echo $x; done; }; f one two three")
4138 .await
4139 .unwrap();
4140 assert_eq!(result.stdout, "one\ntwo\nthree\n");
4141 }
4142
4143 #[tokio::test]
4144 async fn test_while_loop() {
4145 let mut bash = Bash::new();
4146 let result = bash.exec("while false; do echo loop; done").await.unwrap();
4148 assert_eq!(result.stdout, "");
4149 }
4150
4151 #[tokio::test]
4152 async fn test_subshell() {
4153 let mut bash = Bash::new();
4154 let result = bash.exec("(echo hello)").await.unwrap();
4155 assert_eq!(result.stdout, "hello\n");
4156 }
4157
4158 #[tokio::test]
4159 async fn test_brace_group() {
4160 let mut bash = Bash::new();
4161 let result = bash.exec("{ echo hello; }").await.unwrap();
4162 assert_eq!(result.stdout, "hello\n");
4163 }
4164
4165 #[tokio::test]
4166 async fn test_function_keyword() {
4167 let mut bash = Bash::new();
4168 let result = bash
4169 .exec("function greet { echo hello; }; greet")
4170 .await
4171 .unwrap();
4172 assert_eq!(result.stdout, "hello\n");
4173 }
4174
4175 #[tokio::test]
4176 async fn test_function_posix() {
4177 let mut bash = Bash::new();
4178 let result = bash.exec("greet() { echo hello; }; greet").await.unwrap();
4179 assert_eq!(result.stdout, "hello\n");
4180 }
4181
4182 #[tokio::test]
4183 async fn test_function_args() {
4184 let mut bash = Bash::new();
4185 let result = bash
4186 .exec("greet() { echo $1 $2; }; greet world foo")
4187 .await
4188 .unwrap();
4189 assert_eq!(result.stdout, "world foo\n");
4190 }
4191
4192 #[tokio::test]
4193 async fn test_function_arg_count() {
4194 let mut bash = Bash::new();
4195 let result = bash
4196 .exec("count() { echo $#; }; count a b c")
4197 .await
4198 .unwrap();
4199 assert_eq!(result.stdout, "3\n");
4200 }
4201
4202 #[tokio::test]
4203 async fn test_case_literal() {
4204 let mut bash = Bash::new();
4205 let result = bash
4206 .exec("case foo in foo) echo matched ;; esac")
4207 .await
4208 .unwrap();
4209 assert_eq!(result.stdout, "matched\n");
4210 }
4211
4212 #[tokio::test]
4213 async fn test_case_wildcard() {
4214 let mut bash = Bash::new();
4215 let result = bash
4216 .exec("case bar in *) echo default ;; esac")
4217 .await
4218 .unwrap();
4219 assert_eq!(result.stdout, "default\n");
4220 }
4221
4222 #[tokio::test]
4223 async fn test_case_no_match() {
4224 let mut bash = Bash::new();
4225 let result = bash.exec("case foo in bar) echo no ;; esac").await.unwrap();
4226 assert_eq!(result.stdout, "");
4227 }
4228
4229 #[tokio::test]
4230 async fn test_case_multiple_patterns() {
4231 let mut bash = Bash::new();
4232 let result = bash
4233 .exec("case foo in bar|foo|baz) echo matched ;; esac")
4234 .await
4235 .unwrap();
4236 assert_eq!(result.stdout, "matched\n");
4237 }
4238
4239 #[tokio::test]
4240 async fn test_case_bracket_expr() {
4241 let mut bash = Bash::new();
4242 let result = bash
4244 .exec("case b in [abc]) echo matched ;; esac")
4245 .await
4246 .unwrap();
4247 assert_eq!(result.stdout, "matched\n");
4248 }
4249
4250 #[tokio::test]
4251 async fn test_case_bracket_range() {
4252 let mut bash = Bash::new();
4253 let result = bash
4255 .exec("case m in [a-z]) echo letter ;; esac")
4256 .await
4257 .unwrap();
4258 assert_eq!(result.stdout, "letter\n");
4259 }
4260
4261 #[tokio::test]
4262 async fn test_case_bracket_wide_unicode_range() {
4263 let mut bash = Bash::new();
4264 let result = bash
4265 .exec("case z in [a-\u{10ffff}]) echo wide ;; esac")
4266 .await
4267 .unwrap();
4268 assert_eq!(result.stdout, "wide\n");
4269 }
4270
4271 #[tokio::test]
4272 async fn test_case_bracket_negation() {
4273 let mut bash = Bash::new();
4274 let result = bash
4276 .exec("case x in [!abc]) echo not_abc ;; esac")
4277 .await
4278 .unwrap();
4279 assert_eq!(result.stdout, "not_abc\n");
4280 }
4281
4282 #[tokio::test]
4283 async fn test_break_as_command() {
4284 let mut bash = Bash::new();
4285 let result = bash.exec("break").await.unwrap();
4287 assert_eq!(result.exit_code, 0);
4289 }
4290
4291 #[tokio::test]
4292 async fn test_for_one_item() {
4293 let mut bash = Bash::new();
4294 let result = bash.exec("for i in a; do echo $i; done").await.unwrap();
4296 assert_eq!(result.stdout, "a\n");
4297 }
4298
4299 #[tokio::test]
4300 async fn test_for_with_break() {
4301 let mut bash = Bash::new();
4302 let result = bash.exec("for i in a; do break; done").await.unwrap();
4304 assert_eq!(result.stdout, "");
4305 assert_eq!(result.exit_code, 0);
4306 }
4307
4308 #[tokio::test]
4309 async fn test_for_echo_break() {
4310 let mut bash = Bash::new();
4311 let result = bash
4313 .exec("for i in a b c; do echo $i; break; done")
4314 .await
4315 .unwrap();
4316 assert_eq!(result.stdout, "a\n");
4317 }
4318
4319 #[tokio::test]
4320 async fn test_test_string_empty() {
4321 let mut bash = Bash::new();
4322 let result = bash.exec("test -z '' && echo yes").await.unwrap();
4323 assert_eq!(result.stdout, "yes\n");
4324 }
4325
4326 #[tokio::test]
4327 async fn test_test_string_not_empty() {
4328 let mut bash = Bash::new();
4329 let result = bash.exec("test -n 'hello' && echo yes").await.unwrap();
4330 assert_eq!(result.stdout, "yes\n");
4331 }
4332
4333 #[tokio::test]
4334 async fn test_test_string_equal() {
4335 let mut bash = Bash::new();
4336 let result = bash.exec("test foo = foo && echo yes").await.unwrap();
4337 assert_eq!(result.stdout, "yes\n");
4338 }
4339
4340 #[tokio::test]
4341 async fn test_test_string_not_equal() {
4342 let mut bash = Bash::new();
4343 let result = bash.exec("test foo != bar && echo yes").await.unwrap();
4344 assert_eq!(result.stdout, "yes\n");
4345 }
4346
4347 #[tokio::test]
4348 async fn test_test_numeric_equal() {
4349 let mut bash = Bash::new();
4350 let result = bash.exec("test 5 -eq 5 && echo yes").await.unwrap();
4351 assert_eq!(result.stdout, "yes\n");
4352 }
4353
4354 #[tokio::test]
4355 async fn test_test_numeric_less_than() {
4356 let mut bash = Bash::new();
4357 let result = bash.exec("test 3 -lt 5 && echo yes").await.unwrap();
4358 assert_eq!(result.stdout, "yes\n");
4359 }
4360
4361 #[tokio::test]
4362 async fn test_bracket_form() {
4363 let mut bash = Bash::new();
4364 let result = bash.exec("[ foo = foo ] && echo yes").await.unwrap();
4365 assert_eq!(result.stdout, "yes\n");
4366 }
4367
4368 #[tokio::test]
4369 async fn test_if_with_test() {
4370 let mut bash = Bash::new();
4371 let result = bash
4372 .exec("if [ 5 -gt 3 ]; then echo bigger; fi")
4373 .await
4374 .unwrap();
4375 assert_eq!(result.stdout, "bigger\n");
4376 }
4377
4378 #[tokio::test]
4379 async fn test_variable_assignment() {
4380 let mut bash = Bash::new();
4381 let result = bash.exec("FOO=bar; echo $FOO").await.unwrap();
4382 assert_eq!(result.stdout, "bar\n");
4383 }
4384
4385 #[tokio::test]
4386 async fn test_variable_assignment_inline() {
4387 let mut bash = Bash::new();
4388 let result = bash.exec("MSG=hello; echo $MSG world").await.unwrap();
4390 assert_eq!(result.stdout, "hello world\n");
4391 }
4392
4393 #[tokio::test]
4394 async fn test_variable_assignment_only() {
4395 let mut bash = Bash::new();
4396 let result = bash.exec("FOO=bar").await.unwrap();
4398 assert_eq!(result.stdout, "");
4399 assert_eq!(result.exit_code, 0);
4400
4401 let result = bash.exec("echo $FOO").await.unwrap();
4403 assert_eq!(result.stdout, "bar\n");
4404 }
4405
4406 #[tokio::test]
4407 async fn test_multiple_assignments() {
4408 let mut bash = Bash::new();
4409 let result = bash.exec("A=1; B=2; C=3; echo $A $B $C").await.unwrap();
4410 assert_eq!(result.stdout, "1 2 3\n");
4411 }
4412
4413 #[tokio::test]
4414 async fn test_prefix_assignment_visible_in_env() {
4415 let mut bash = Bash::new();
4416 let result = bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
4418 assert_eq!(result.stdout, "hello\n");
4419 }
4420
4421 #[tokio::test]
4422 async fn test_prefix_assignment_temporary() {
4423 let mut bash = Bash::new();
4424 bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
4426 let result = bash.exec("echo ${MYVAR:-unset}").await.unwrap();
4427 assert_eq!(result.stdout, "unset\n");
4428 }
4429
4430 #[tokio::test]
4431 async fn test_prefix_assignment_duplicate_name_temporary() {
4432 let mut bash = Bash::new();
4433 let result = bash.exec("A=1 A=2 printenv A").await.unwrap();
4435 assert_eq!(result.stdout, "2\n");
4436 let result = bash.exec("echo ${A:-unset}").await.unwrap();
4437 assert_eq!(result.stdout, "unset\n");
4438 }
4439
4440 #[tokio::test]
4441 async fn test_prefix_assignment_does_not_clobber_existing_env() {
4442 let mut bash = Bash::new();
4443 let result = bash
4445 .exec("EXISTING=original; export EXISTING; EXISTING=temp printenv EXISTING")
4446 .await
4447 .unwrap();
4448 assert_eq!(result.stdout, "temp\n");
4449 }
4450
4451 #[tokio::test]
4452 async fn test_prefix_assignment_multiple_vars() {
4453 let mut bash = Bash::new();
4454 let result = bash.exec("A=one B=two printenv A").await.unwrap();
4456 assert_eq!(result.stdout, "one\n");
4457 assert_eq!(result.exit_code, 0);
4458 }
4459
4460 #[tokio::test]
4461 async fn test_prefix_assignment_empty_value() {
4462 let mut bash = Bash::new();
4463 let result = bash.exec("MYVAR= printenv MYVAR").await.unwrap();
4465 assert_eq!(result.stdout, "\n");
4466 assert_eq!(result.exit_code, 0);
4467 }
4468
4469 #[tokio::test]
4470 async fn test_prefix_assignment_not_found_without_prefix() {
4471 let mut bash = Bash::new();
4472 let result = bash.exec("printenv NONEXISTENT").await.unwrap();
4474 assert_eq!(result.stdout, "");
4475 assert_eq!(result.exit_code, 1);
4476 }
4477
4478 #[tokio::test]
4479 async fn test_prefix_assignment_does_not_persist_in_variables() {
4480 let mut bash = Bash::new();
4481 bash.exec("TMPVAR=gone echo ok").await.unwrap();
4483 let result = bash.exec("echo \"${TMPVAR:-unset}\"").await.unwrap();
4484 assert_eq!(result.stdout, "unset\n");
4485 }
4486
4487 #[tokio::test]
4488 async fn test_assignment_only_persists() {
4489 let mut bash = Bash::new();
4490 bash.exec("PERSIST=yes").await.unwrap();
4492 let result = bash.exec("echo $PERSIST").await.unwrap();
4493 assert_eq!(result.stdout, "yes\n");
4494 }
4495
4496 #[tokio::test]
4497 async fn test_printf_string() {
4498 let mut bash = Bash::new();
4499 let result = bash.exec("printf '%s' hello").await.unwrap();
4500 assert_eq!(result.stdout, "hello");
4501 }
4502
4503 #[tokio::test]
4504 async fn test_printf_newline() {
4505 let mut bash = Bash::new();
4506 let result = bash.exec("printf 'hello\\n'").await.unwrap();
4507 assert_eq!(result.stdout, "hello\n");
4508 }
4509
4510 #[tokio::test]
4511 async fn test_printf_multiple_args() {
4512 let mut bash = Bash::new();
4513 let result = bash.exec("printf '%s %s\\n' hello world").await.unwrap();
4514 assert_eq!(result.stdout, "hello world\n");
4515 }
4516
4517 #[tokio::test]
4518 async fn test_printf_integer() {
4519 let mut bash = Bash::new();
4520 let result = bash.exec("printf '%d' 42").await.unwrap();
4521 assert_eq!(result.stdout, "42");
4522 }
4523
4524 #[tokio::test]
4525 async fn test_export() {
4526 let mut bash = Bash::new();
4527 let result = bash.exec("export FOO=bar; echo $FOO").await.unwrap();
4528 assert_eq!(result.stdout, "bar\n");
4529 }
4530
4531 #[tokio::test]
4532 async fn test_read_basic() {
4533 let mut bash = Bash::new();
4534 let result = bash.exec("echo hello | read VAR; echo $VAR").await.unwrap();
4535 assert_eq!(result.stdout, "hello\n");
4536 }
4537
4538 #[tokio::test]
4539 async fn test_read_multiple_vars() {
4540 let mut bash = Bash::new();
4541 let result = bash
4542 .exec("echo 'a b c' | read X Y Z; echo $X $Y $Z")
4543 .await
4544 .unwrap();
4545 assert_eq!(result.stdout, "a b c\n");
4546 }
4547
4548 #[tokio::test]
4549 async fn test_read_respects_local_scope() {
4550 let mut bash = Bash::new();
4552 let result = bash
4553 .exec(
4554 r#"
4555fn() { local k; read -r k <<< "test"; echo "$k"; }
4556fn
4557"#,
4558 )
4559 .await
4560 .unwrap();
4561 assert_eq!(result.stdout, "test\n");
4562 }
4563
4564 #[tokio::test]
4565 async fn test_local_ifs_array_join() {
4566 let mut bash = Bash::new();
4568 let result = bash
4569 .exec(
4570 r#"
4571fn() {
4572 local arr=(a b c)
4573 local IFS=":"
4574 echo "${arr[*]}"
4575}
4576fn
4577"#,
4578 )
4579 .await
4580 .unwrap();
4581 assert_eq!(result.stdout, "a:b:c\n");
4582 }
4583
4584 #[tokio::test]
4585 async fn test_glob_star() {
4586 let mut bash = Bash::new();
4587 bash.exec("echo a > /tmp/file1.txt").await.unwrap();
4589 bash.exec("echo b > /tmp/file2.txt").await.unwrap();
4590 bash.exec("echo c > /tmp/other.log").await.unwrap();
4591
4592 let result = bash.exec("echo /tmp/*.txt").await.unwrap();
4594 assert_eq!(result.stdout, "/tmp/file1.txt /tmp/file2.txt\n");
4595 }
4596
4597 #[tokio::test]
4598 async fn test_glob_question_mark() {
4599 let mut bash = Bash::new();
4600 bash.exec("echo a > /tmp/a1.txt").await.unwrap();
4602 bash.exec("echo b > /tmp/a2.txt").await.unwrap();
4603 bash.exec("echo c > /tmp/a10.txt").await.unwrap();
4604
4605 let result = bash.exec("echo /tmp/a?.txt").await.unwrap();
4607 assert_eq!(result.stdout, "/tmp/a1.txt /tmp/a2.txt\n");
4608 }
4609
4610 #[tokio::test]
4611 async fn test_glob_no_match() {
4612 let mut bash = Bash::new();
4613 let result = bash.exec("echo /nonexistent/*.xyz").await.unwrap();
4615 assert_eq!(result.stdout, "/nonexistent/*.xyz\n");
4616 }
4617
4618 #[tokio::test]
4619 async fn test_command_substitution() {
4620 let mut bash = Bash::new();
4621 let result = bash.exec("echo $(echo hello)").await.unwrap();
4622 assert_eq!(result.stdout, "hello\n");
4623 }
4624
4625 #[tokio::test]
4626 async fn test_command_substitution_in_string() {
4627 let mut bash = Bash::new();
4628 let result = bash.exec("echo \"result: $(echo 42)\"").await.unwrap();
4629 assert_eq!(result.stdout, "result: 42\n");
4630 }
4631
4632 #[tokio::test]
4633 async fn test_command_substitution_pipeline() {
4634 let mut bash = Bash::new();
4635 let result = bash.exec("echo $(echo hello | cat)").await.unwrap();
4636 assert_eq!(result.stdout, "hello\n");
4637 }
4638
4639 #[tokio::test]
4640 async fn test_command_substitution_variable() {
4641 let mut bash = Bash::new();
4642 let result = bash.exec("VAR=$(echo test); echo $VAR").await.unwrap();
4643 assert_eq!(result.stdout, "test\n");
4644 }
4645
4646 #[tokio::test]
4647 async fn test_arithmetic_simple() {
4648 let mut bash = Bash::new();
4649 let result = bash.exec("echo $((1 + 2))").await.unwrap();
4650 assert_eq!(result.stdout, "3\n");
4651 }
4652
4653 #[tokio::test]
4654 async fn test_arithmetic_multiply() {
4655 let mut bash = Bash::new();
4656 let result = bash.exec("echo $((3 * 4))").await.unwrap();
4657 assert_eq!(result.stdout, "12\n");
4658 }
4659
4660 #[tokio::test]
4661 async fn test_arithmetic_with_variable() {
4662 let mut bash = Bash::new();
4663 let result = bash.exec("X=5; echo $((X + 3))").await.unwrap();
4664 assert_eq!(result.stdout, "8\n");
4665 }
4666
4667 #[tokio::test]
4668 async fn test_arithmetic_complex() {
4669 let mut bash = Bash::new();
4670 let result = bash.exec("echo $((2 + 3 * 4))").await.unwrap();
4671 assert_eq!(result.stdout, "14\n");
4672 }
4673
4674 #[tokio::test]
4675 async fn test_heredoc_simple() {
4676 let mut bash = Bash::new();
4677 let result = bash.exec("cat <<EOF\nhello\nworld\nEOF").await.unwrap();
4678 assert_eq!(result.stdout, "hello\nworld\n");
4679 }
4680
4681 #[tokio::test]
4682 async fn test_heredoc_single_line() {
4683 let mut bash = Bash::new();
4684 let result = bash.exec("cat <<END\ntest\nEND").await.unwrap();
4685 assert_eq!(result.stdout, "test\n");
4686 }
4687
4688 #[tokio::test]
4689 async fn test_unset() {
4690 let mut bash = Bash::new();
4691 let result = bash
4692 .exec("FOO=bar; unset FOO; echo \"x${FOO}y\"")
4693 .await
4694 .unwrap();
4695 assert_eq!(result.stdout, "xy\n");
4696 }
4697
4698 #[tokio::test]
4699 async fn test_local_basic() {
4700 let mut bash = Bash::new();
4701 let result = bash.exec("local X=test; echo $X").await.unwrap();
4703 assert_eq!(result.stdout, "test\n");
4704 }
4705
4706 #[tokio::test]
4707 async fn test_set_option() {
4708 let mut bash = Bash::new();
4709 let result = bash.exec("set -e; echo ok").await.unwrap();
4710 assert_eq!(result.stdout, "ok\n");
4711 }
4712
4713 #[tokio::test]
4714 async fn test_param_default() {
4715 let mut bash = Bash::new();
4716 let result = bash.exec("echo ${UNSET:-default}").await.unwrap();
4718 assert_eq!(result.stdout, "default\n");
4719
4720 let result = bash.exec("X=value; echo ${X:-default}").await.unwrap();
4722 assert_eq!(result.stdout, "value\n");
4723 }
4724
4725 #[tokio::test]
4726 async fn test_param_assign_default() {
4727 let mut bash = Bash::new();
4728 let result = bash.exec("echo ${NEW:=assigned}; echo $NEW").await.unwrap();
4730 assert_eq!(result.stdout, "assigned\nassigned\n");
4731 }
4732
4733 #[tokio::test]
4734 async fn test_param_length() {
4735 let mut bash = Bash::new();
4736 let result = bash.exec("X=hello; echo ${#X}").await.unwrap();
4737 assert_eq!(result.stdout, "5\n");
4738 }
4739
4740 #[tokio::test]
4741 async fn test_param_remove_prefix() {
4742 let mut bash = Bash::new();
4743 let result = bash.exec("X=hello.world.txt; echo ${X#*.}").await.unwrap();
4745 assert_eq!(result.stdout, "world.txt\n");
4746 }
4747
4748 #[tokio::test]
4749 async fn test_param_remove_prefix_mixed_pattern() {
4750 let mut bash = Bash::new();
4751 let result = bash
4753 .exec(r#"i="./tag_hello.tmp.html"; prefix_tags="tag_"; echo ${i#./"$prefix_tags"}"#)
4754 .await
4755 .unwrap();
4756 assert_eq!(result.stdout, "hello.tmp.html\n");
4757 }
4758
4759 #[tokio::test]
4760 async fn test_param_remove_suffix() {
4761 let mut bash = Bash::new();
4762 let result = bash.exec("X=file.tar.gz; echo ${X%.*}").await.unwrap();
4764 assert_eq!(result.stdout, "file.tar\n");
4765 }
4766
4767 #[tokio::test]
4768 async fn test_positional_param_prefix_replace() {
4769 let mut bash = Bash::new();
4770 let result = bash
4772 .exec(r#"f() { set -- "${@/#/tag_}"; echo "$@"; }; f hello world"#)
4773 .await
4774 .unwrap();
4775 assert_eq!(result.stdout, "tag_hello tag_world\n");
4776 }
4777
4778 #[tokio::test]
4779 async fn test_positional_param_suffix_replace() {
4780 let mut bash = Bash::new();
4781 let result = bash
4783 .exec(r#"f() { set -- "${@/%/.html}"; echo "$@"; }; f hello world"#)
4784 .await
4785 .unwrap();
4786 assert_eq!(result.stdout, "hello.html world.html\n");
4787 }
4788
4789 #[tokio::test]
4790 async fn test_positional_param_prefix_var_replace() {
4791 let mut bash = Bash::new();
4792 let result = bash
4794 .exec(r#"f() { p="tag_"; set -- "${@/#/$p}"; echo "$@"; }; f hello world"#)
4795 .await
4796 .unwrap();
4797 assert_eq!(result.stdout, "tag_hello tag_world\n");
4798 }
4799
4800 #[tokio::test]
4801 async fn test_positional_param_prefix_strip() {
4802 let mut bash = Bash::new();
4803 let result = bash
4805 .exec(r#"f() { set -- "${@#tag_}"; echo "$@"; }; f tag_hello tag_world"#)
4806 .await
4807 .unwrap();
4808 assert_eq!(result.stdout, "hello world\n");
4809 }
4810
4811 #[tokio::test]
4812 async fn test_array_basic() {
4813 let mut bash = Bash::new();
4814 let result = bash.exec("arr=(a b c); echo ${arr[1]}").await.unwrap();
4816 assert_eq!(result.stdout, "b\n");
4817 }
4818
4819 #[tokio::test]
4820 async fn test_array_all_elements() {
4821 let mut bash = Bash::new();
4822 let result = bash
4824 .exec("arr=(one two three); echo ${arr[@]}")
4825 .await
4826 .unwrap();
4827 assert_eq!(result.stdout, "one two three\n");
4828 }
4829
4830 #[tokio::test]
4831 async fn test_array_length() {
4832 let mut bash = Bash::new();
4833 let result = bash.exec("arr=(a b c d e); echo ${#arr[@]}").await.unwrap();
4835 assert_eq!(result.stdout, "5\n");
4836 }
4837
4838 #[tokio::test]
4839 async fn test_array_indexed_assignment() {
4840 let mut bash = Bash::new();
4841 let result = bash
4843 .exec("arr[0]=first; arr[1]=second; echo ${arr[0]} ${arr[1]}")
4844 .await
4845 .unwrap();
4846 assert_eq!(result.stdout, "first second\n");
4847 }
4848
4849 #[tokio::test]
4850 async fn test_array_single_quote_subscript_no_panic() {
4851 let mut bash = Bash::new();
4853 let _ = bash.exec("echo ${arr[\"]}").await;
4855 }
4856
4857 #[tokio::test]
4860 async fn test_command_limit() {
4861 let limits = ExecutionLimits::new().max_commands(5);
4862 let mut bash = Bash::builder().limits(limits).build();
4863
4864 let result = bash.exec("true; true; true; true; true; true").await;
4866 assert!(result.is_err());
4867 let err = result.unwrap_err();
4868 assert!(
4869 err.to_string().contains("maximum command count exceeded"),
4870 "Expected command limit error, got: {}",
4871 err
4872 );
4873 }
4874
4875 #[tokio::test]
4876 async fn test_command_limit_not_exceeded() {
4877 let limits = ExecutionLimits::new().max_commands(10);
4878 let mut bash = Bash::builder().limits(limits).build();
4879
4880 let result = bash.exec("true; true; true; true; true").await.unwrap();
4882 assert_eq!(result.exit_code, 0);
4883 }
4884
4885 #[tokio::test]
4886 async fn test_loop_iteration_limit() {
4887 let limits = ExecutionLimits::new().max_loop_iterations(5);
4888 let mut bash = Bash::builder().limits(limits).build();
4889
4890 let result = bash
4892 .exec("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done")
4893 .await;
4894 assert!(result.is_err());
4895 let err = result.unwrap_err();
4896 assert!(
4897 err.to_string().contains("maximum loop iterations exceeded"),
4898 "Expected loop limit error, got: {}",
4899 err
4900 );
4901 }
4902
4903 #[tokio::test]
4904 async fn test_loop_iteration_limit_not_exceeded() {
4905 let limits = ExecutionLimits::new().max_loop_iterations(10);
4906 let mut bash = Bash::builder().limits(limits).build();
4907
4908 let result = bash
4910 .exec("for i in 1 2 3 4 5; do echo $i; done")
4911 .await
4912 .unwrap();
4913 assert_eq!(result.stdout, "1\n2\n3\n4\n5\n");
4914 }
4915
4916 #[tokio::test]
4917 async fn test_function_depth_limit() {
4918 let limits = ExecutionLimits::new().max_function_depth(3);
4919 let mut bash = Bash::builder().limits(limits).build();
4920
4921 let result = bash
4923 .exec("f() { echo $1; if [ $1 -lt 5 ]; then f $(($1 + 1)); fi; }; f 1")
4924 .await;
4925 assert!(result.is_err());
4926 let err = result.unwrap_err();
4927 assert!(
4928 err.to_string().contains("maximum function depth exceeded"),
4929 "Expected function depth error, got: {}",
4930 err
4931 );
4932 }
4933
4934 #[tokio::test]
4935 async fn test_function_depth_limit_not_exceeded() {
4936 let limits = ExecutionLimits::new().max_function_depth(10);
4937 let mut bash = Bash::builder().limits(limits).build();
4938
4939 let result = bash.exec("f() { echo hello; }; f").await.unwrap();
4941 assert_eq!(result.stdout, "hello\n");
4942 }
4943
4944 #[tokio::test]
4945 async fn test_while_loop_limit() {
4946 let limits = ExecutionLimits::new().max_loop_iterations(3);
4947 let mut bash = Bash::builder().limits(limits).build();
4948
4949 let result = bash
4951 .exec("i=0; while [ $i -lt 10 ]; do echo $i; i=$((i + 1)); done")
4952 .await;
4953 assert!(result.is_err());
4954 let err = result.unwrap_err();
4955 assert!(
4956 err.to_string().contains("maximum loop iterations exceeded"),
4957 "Expected loop limit error, got: {}",
4958 err
4959 );
4960 }
4961
4962 #[tokio::test]
4963 async fn test_awk_respects_loop_iteration_limit() {
4964 let limits = ExecutionLimits::new().max_loop_iterations(5);
4965 let mut bash = Bash::builder().limits(limits).build();
4966 let result = bash
4967 .exec("awk 'BEGIN { i=0; while(1) { i++; if(i>999) break } print i }'")
4968 .await
4969 .unwrap();
4970 assert_eq!(result.stdout.trim(), "5");
4971 }
4972
4973 #[tokio::test]
4974 async fn test_awk_for_in_respects_loop_iteration_limit() {
4975 let limits = ExecutionLimits::new().max_loop_iterations(3);
4976 let mut bash = Bash::builder().limits(limits).build();
4977 let result = bash
4978 .exec("awk 'BEGIN { for(i=1;i<=10;i++) a[i]=i; c=0; for(k in a) c++; print c }'")
4979 .await
4980 .unwrap();
4981 assert_eq!(result.stdout.trim(), "3");
4982 }
4983
4984 #[tokio::test]
4985 async fn test_default_limits_allow_normal_scripts() {
4986 let mut bash = Bash::new();
4988 let result = bash
4990 .exec("for i in 1 2 3 4 5; do echo $i; done && echo finished")
4991 .await
4992 .unwrap();
4993 assert_eq!(result.stdout, "1\n2\n3\n4\n5\nfinished\n");
4994 }
4995
4996 #[tokio::test]
4997 async fn test_for_followed_by_echo_done() {
4998 let mut bash = Bash::new();
4999 let result = bash
5000 .exec("for i in 1; do echo $i; done; echo ok")
5001 .await
5002 .unwrap();
5003 assert_eq!(result.stdout, "1\nok\n");
5004 }
5005
5006 #[tokio::test]
5009 async fn test_fs_read_write_binary() {
5010 let bash = Bash::new();
5011 let fs = bash.fs();
5012 let path = std::path::Path::new("/tmp/binary.bin");
5013
5014 let binary_data: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE, 0x42, 0x00, 0x7F];
5016 fs.write_file(path, &binary_data).await.unwrap();
5017
5018 let content = fs.read_file(path).await.unwrap();
5020 assert_eq!(content, binary_data);
5021 }
5022
5023 #[tokio::test]
5024 async fn test_fs_write_then_exec_cat() {
5025 let mut bash = Bash::new();
5026 let path = std::path::Path::new("/tmp/prepopulated.txt");
5027
5028 bash.fs()
5030 .write_file(path, b"Hello from Rust!\n")
5031 .await
5032 .unwrap();
5033
5034 let result = bash.exec("cat /tmp/prepopulated.txt").await.unwrap();
5036 assert_eq!(result.stdout, "Hello from Rust!\n");
5037 }
5038
5039 #[tokio::test]
5040 async fn test_fs_exec_then_read() {
5041 let mut bash = Bash::new();
5042 let path = std::path::Path::new("/tmp/from_bash.txt");
5043
5044 bash.exec("echo 'Created by bash' > /tmp/from_bash.txt")
5046 .await
5047 .unwrap();
5048
5049 let content = bash.fs().read_file(path).await.unwrap();
5051 assert_eq!(content, b"Created by bash\n");
5052 }
5053
5054 #[tokio::test]
5055 async fn test_fs_exists_and_stat() {
5056 let bash = Bash::new();
5057 let fs = bash.fs();
5058 let path = std::path::Path::new("/tmp/testfile.txt");
5059
5060 assert!(!fs.exists(path).await.unwrap());
5062
5063 fs.write_file(path, b"content").await.unwrap();
5065
5066 assert!(fs.exists(path).await.unwrap());
5068
5069 let stat = fs.stat(path).await.unwrap();
5071 assert!(stat.file_type.is_file());
5072 assert_eq!(stat.size, 7); }
5074
5075 #[tokio::test]
5076 async fn test_fs_mkdir_and_read_dir() {
5077 let bash = Bash::new();
5078 let fs = bash.fs();
5079
5080 fs.mkdir(std::path::Path::new("/data/nested/dir"), true)
5082 .await
5083 .unwrap();
5084
5085 fs.write_file(std::path::Path::new("/data/file1.txt"), b"1")
5087 .await
5088 .unwrap();
5089 fs.write_file(std::path::Path::new("/data/file2.txt"), b"2")
5090 .await
5091 .unwrap();
5092
5093 let entries = fs.read_dir(std::path::Path::new("/data")).await.unwrap();
5095 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
5096 assert!(names.contains(&"nested"));
5097 assert!(names.contains(&"file1.txt"));
5098 assert!(names.contains(&"file2.txt"));
5099 }
5100
5101 #[tokio::test]
5102 async fn test_fs_append() {
5103 let bash = Bash::new();
5104 let fs = bash.fs();
5105 let path = std::path::Path::new("/tmp/append.txt");
5106
5107 fs.write_file(path, b"line1\n").await.unwrap();
5108 fs.append_file(path, b"line2\n").await.unwrap();
5109 fs.append_file(path, b"line3\n").await.unwrap();
5110
5111 let content = fs.read_file(path).await.unwrap();
5112 assert_eq!(content, b"line1\nline2\nline3\n");
5113 }
5114
5115 #[tokio::test]
5116 async fn test_fs_copy_and_rename() {
5117 let bash = Bash::new();
5118 let fs = bash.fs();
5119
5120 fs.write_file(std::path::Path::new("/tmp/original.txt"), b"data")
5121 .await
5122 .unwrap();
5123
5124 fs.copy(
5126 std::path::Path::new("/tmp/original.txt"),
5127 std::path::Path::new("/tmp/copied.txt"),
5128 )
5129 .await
5130 .unwrap();
5131
5132 fs.rename(
5134 std::path::Path::new("/tmp/copied.txt"),
5135 std::path::Path::new("/tmp/renamed.txt"),
5136 )
5137 .await
5138 .unwrap();
5139
5140 let content = fs
5142 .read_file(std::path::Path::new("/tmp/renamed.txt"))
5143 .await
5144 .unwrap();
5145 assert_eq!(content, b"data");
5146 assert!(
5147 !fs.exists(std::path::Path::new("/tmp/copied.txt"))
5148 .await
5149 .unwrap()
5150 );
5151 }
5152
5153 #[tokio::test]
5156 async fn test_echo_done_as_argument() {
5157 let mut bash = Bash::new();
5159 let result = bash
5160 .exec("for i in 1; do echo $i; done; echo done")
5161 .await
5162 .unwrap();
5163 assert_eq!(result.stdout, "1\ndone\n");
5164 }
5165
5166 #[tokio::test]
5167 async fn test_simple_echo_done() {
5168 let mut bash = Bash::new();
5170 let result = bash.exec("echo done").await.unwrap();
5171 assert_eq!(result.stdout, "done\n");
5172 }
5173
5174 #[tokio::test]
5175 async fn test_dev_null_redirect() {
5176 let mut bash = Bash::new();
5178 let result = bash.exec("echo hello > /dev/null; echo ok").await.unwrap();
5179 assert_eq!(result.stdout, "ok\n");
5180 }
5181
5182 #[tokio::test]
5183 async fn test_string_concatenation_in_loop() {
5184 let mut bash = Bash::new();
5186 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
5188 assert_eq!(result.stdout, "a\nb\nc\n");
5189
5190 let mut bash = Bash::new();
5192 let result = bash
5193 .exec("result=x; for i in a b c; do echo $i; done; echo $result")
5194 .await
5195 .unwrap();
5196 assert_eq!(result.stdout, "a\nb\nc\nx\n");
5197
5198 let mut bash = Bash::new();
5200 let result = bash
5201 .exec("result=start; for i in a b c; do result=${result}$i; done; echo $result")
5202 .await
5203 .unwrap();
5204 assert_eq!(result.stdout, "startabc\n");
5205 }
5206
5207 #[tokio::test]
5210 async fn test_done_still_terminates_loop() {
5211 let mut bash = Bash::new();
5213 let result = bash.exec("for i in 1 2; do echo $i; done").await.unwrap();
5214 assert_eq!(result.stdout, "1\n2\n");
5215 }
5216
5217 #[tokio::test]
5218 async fn test_fi_still_terminates_if() {
5219 let mut bash = Bash::new();
5221 let result = bash.exec("if true; then echo yes; fi").await.unwrap();
5222 assert_eq!(result.stdout, "yes\n");
5223 }
5224
5225 #[tokio::test]
5226 async fn test_echo_fi_as_argument() {
5227 let mut bash = Bash::new();
5229 let result = bash.exec("echo fi").await.unwrap();
5230 assert_eq!(result.stdout, "fi\n");
5231 }
5232
5233 #[tokio::test]
5234 async fn test_echo_then_as_argument() {
5235 let mut bash = Bash::new();
5237 let result = bash.exec("echo then").await.unwrap();
5238 assert_eq!(result.stdout, "then\n");
5239 }
5240
5241 #[tokio::test]
5242 async fn test_reserved_words_in_quotes_are_arguments() {
5243 let mut bash = Bash::new();
5245 let result = bash.exec("echo 'done' 'fi' 'then'").await.unwrap();
5246 assert_eq!(result.stdout, "done fi then\n");
5247 }
5248
5249 #[tokio::test]
5250 async fn test_nested_loops_done_keyword() {
5251 let mut bash = Bash::new();
5253 let result = bash
5254 .exec("for i in 1; do for j in a; do echo $i$j; done; done")
5255 .await
5256 .unwrap();
5257 assert_eq!(result.stdout, "1a\n");
5258 }
5259
5260 #[tokio::test]
5263 async fn test_dev_null_read_returns_empty() {
5264 let mut bash = Bash::new();
5266 let result = bash.exec("cat /dev/null").await.unwrap();
5267 assert_eq!(result.stdout, "");
5268 }
5269
5270 #[tokio::test]
5271 async fn test_dev_null_append() {
5272 let mut bash = Bash::new();
5274 let result = bash.exec("echo hello >> /dev/null; echo ok").await.unwrap();
5275 assert_eq!(result.stdout, "ok\n");
5276 }
5277
5278 #[tokio::test]
5279 async fn test_dev_null_in_pipeline() {
5280 let mut bash = Bash::new();
5282 let result = bash
5283 .exec("echo hello | cat > /dev/null; echo ok")
5284 .await
5285 .unwrap();
5286 assert_eq!(result.stdout, "ok\n");
5287 }
5288
5289 #[tokio::test]
5290 async fn test_dev_null_exists() {
5291 let mut bash = Bash::new();
5293 let result = bash.exec("cat /dev/null; echo exit_$?").await.unwrap();
5294 assert_eq!(result.stdout, "exit_0\n");
5295 }
5296
5297 #[tokio::test]
5300 async fn test_custom_username_whoami() {
5301 let mut bash = Bash::builder().username("alice").build();
5302 let result = bash.exec("whoami").await.unwrap();
5303 assert_eq!(result.stdout, "alice\n");
5304 }
5305
5306 #[tokio::test]
5307 async fn test_custom_username_id() {
5308 let mut bash = Bash::builder().username("bob").build();
5309 let result = bash.exec("id").await.unwrap();
5310 assert!(result.stdout.contains("uid=1000(bob)"));
5311 assert!(result.stdout.contains("gid=1000(bob)"));
5312 }
5313
5314 #[tokio::test]
5315 async fn test_custom_username_sets_user_env() {
5316 let mut bash = Bash::builder().username("charlie").build();
5317 let result = bash.exec("echo $USER").await.unwrap();
5318 assert_eq!(result.stdout, "charlie\n");
5319 }
5320
5321 #[tokio::test]
5322 async fn test_custom_username_provisions_home_dir() {
5323 let mut bash = Bash::builder().username("eval").build();
5328 let result = bash
5329 .exec("echo hi > /home/eval/x.sh && cat /home/eval/x.sh")
5330 .await
5331 .unwrap();
5332 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
5333 assert_eq!(result.stdout, "hi\n");
5334 }
5335
5336 #[tokio::test]
5337 async fn test_custom_username_home_tilde_write() {
5338 let mut bash = Bash::builder().username("agent").build();
5340 let result = bash
5341 .exec("echo $HOME; echo data > ~/file.txt && cat ~/file.txt")
5342 .await
5343 .unwrap();
5344 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
5345 assert_eq!(result.stdout, "/home/agent\ndata\n");
5346 }
5347
5348 #[tokio::test]
5349 async fn test_default_username_provisions_home_dir() {
5350 let mut bash = Bash::new();
5352 let result = bash
5353 .exec("echo data > $HOME/f && cat $HOME/f")
5354 .await
5355 .unwrap();
5356 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
5357 assert_eq!(result.stdout, "data\n");
5358 }
5359
5360 #[tokio::test]
5361 async fn test_default_ppid_is_sandboxed() {
5362 let mut bash = Bash::new();
5363 let result = bash.exec("echo $PPID").await.unwrap();
5364 assert_eq!(result.stdout, "0\n");
5365 }
5366
5367 #[tokio::test]
5368 async fn test_custom_hostname() {
5369 let mut bash = Bash::builder().hostname("my-server").build();
5370 let result = bash.exec("hostname").await.unwrap();
5371 assert_eq!(result.stdout, "my-server\n");
5372 }
5373
5374 #[tokio::test]
5375 async fn test_custom_hostname_uname() {
5376 let mut bash = Bash::builder().hostname("custom-host").build();
5377 let result = bash.exec("uname -n").await.unwrap();
5378 assert_eq!(result.stdout, "custom-host\n");
5379 }
5380
5381 #[tokio::test]
5382 async fn test_default_username_and_hostname() {
5383 let mut bash = Bash::new();
5385 let result = bash.exec("whoami").await.unwrap();
5386 assert_eq!(result.stdout, "sandbox\n");
5387
5388 let result = bash.exec("hostname").await.unwrap();
5389 assert_eq!(result.stdout, "bashkit-sandbox\n");
5390 }
5391
5392 #[tokio::test]
5393 async fn test_custom_username_and_hostname_combined() {
5394 let mut bash = Bash::builder()
5395 .username("deploy")
5396 .hostname("prod-server-01")
5397 .build();
5398
5399 let result = bash.exec("whoami && hostname").await.unwrap();
5400 assert_eq!(result.stdout, "deploy\nprod-server-01\n");
5401
5402 let result = bash.exec("echo $USER").await.unwrap();
5403 assert_eq!(result.stdout, "deploy\n");
5404 }
5405
5406 mod custom_builtins {
5409 use super::*;
5410 use crate::builtins::{Builtin, Context};
5411 use crate::{ExecResult, ExecutionExtensions, Extension};
5412 use async_trait::async_trait;
5413
5414 struct Hello;
5416
5417 #[async_trait]
5418 impl Builtin for Hello {
5419 async fn execute(&self, _ctx: Context<'_>) -> crate::Result<ExecResult> {
5420 Ok(ExecResult::ok("Hello from custom builtin!\n".to_string()))
5421 }
5422 }
5423
5424 #[tokio::test]
5425 async fn test_custom_builtin_basic() {
5426 let mut bash = Bash::builder().builtin("hello", Box::new(Hello)).build();
5427
5428 let result = bash.exec("hello").await.unwrap();
5429 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5430 assert_eq!(result.exit_code, 0);
5431 }
5432
5433 struct ExecutionScoped;
5434
5435 #[async_trait]
5436 impl Builtin for ExecutionScoped {
5437 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5438 let value = ctx
5439 .execution_extension::<String>()
5440 .and_then(|value| value.try_with(Clone::clone).ok())
5441 .unwrap_or_else(|| "missing".to_string());
5442 Ok(ExecResult::ok(format!("{value}\n")))
5443 }
5444 }
5445
5446 #[tokio::test]
5447 async fn test_custom_builtin_execution_extensions_are_per_call() {
5448 let mut bash = Bash::builder()
5449 .builtin("read-ext", Box::new(ExecutionScoped))
5450 .build();
5451
5452 let result = bash
5453 .exec_with_extensions(
5454 "read-ext",
5455 ExecutionExtensions::new().with("scoped".to_string()),
5456 )
5457 .await
5458 .unwrap();
5459 assert_eq!(result.stdout, "scoped\n");
5460
5461 let result = bash.exec("read-ext").await.unwrap();
5462 assert_eq!(result.stdout, "missing\n");
5463 }
5464
5465 struct Greet;
5467
5468 #[async_trait]
5469 impl Builtin for Greet {
5470 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5471 let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World");
5472 Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
5473 }
5474 }
5475
5476 #[tokio::test]
5477 async fn test_custom_builtin_with_args() {
5478 let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
5479
5480 let result = bash.exec("greet").await.unwrap();
5481 assert_eq!(result.stdout, "Hello, World!\n");
5482
5483 let result = bash.exec("greet Alice").await.unwrap();
5484 assert_eq!(result.stdout, "Hello, Alice!\n");
5485
5486 let result = bash.exec("greet Bob Charlie").await.unwrap();
5487 assert_eq!(result.stdout, "Hello, Bob!\n");
5488 }
5489
5490 struct Upper;
5492
5493 #[async_trait]
5494 impl Builtin for Upper {
5495 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5496 let input = ctx.stdin.map(|stdin| &**stdin).unwrap_or("");
5497 Ok(ExecResult::ok(input.to_uppercase()))
5498 }
5499 }
5500
5501 #[tokio::test]
5502 async fn test_custom_builtin_with_stdin() {
5503 let mut bash = Bash::builder().builtin("upper", Box::new(Upper)).build();
5504
5505 let result = bash.exec("echo hello | upper").await.unwrap();
5506 assert_eq!(result.stdout, "HELLO\n");
5507 }
5508
5509 struct WriteFile;
5511
5512 #[async_trait]
5513 impl Builtin for WriteFile {
5514 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5515 if ctx.args.len() < 2 {
5516 return Ok(ExecResult::err(
5517 "Usage: writefile <path> <content>\n".to_string(),
5518 1,
5519 ));
5520 }
5521 let path = std::path::Path::new(&ctx.args[0]);
5522 let content = ctx.args[1..].join(" ");
5523 ctx.fs.write_file(path, content.as_bytes()).await?;
5524 Ok(ExecResult::ok(String::new()))
5525 }
5526 }
5527
5528 #[tokio::test]
5529 async fn test_custom_builtin_with_filesystem() {
5530 let mut bash = Bash::builder()
5531 .builtin("writefile", Box::new(WriteFile))
5532 .build();
5533
5534 bash.exec("writefile /tmp/test.txt custom content here")
5535 .await
5536 .unwrap();
5537
5538 let result = bash.exec("cat /tmp/test.txt").await.unwrap();
5539 assert_eq!(result.stdout, "custom content here");
5540 }
5541
5542 struct CustomEcho;
5544
5545 #[async_trait]
5546 impl Builtin for CustomEcho {
5547 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5548 let msg = ctx.args.join(" ");
5549 Ok(ExecResult::ok(format!("[CUSTOM] {}\n", msg)))
5550 }
5551 }
5552
5553 #[tokio::test]
5554 async fn test_custom_builtin_override_default() {
5555 let mut bash = Bash::builder()
5556 .builtin("echo", Box::new(CustomEcho))
5557 .build();
5558
5559 let result = bash.exec("echo hello world").await.unwrap();
5560 assert_eq!(result.stdout, "[CUSTOM] hello world\n");
5561 }
5562
5563 #[tokio::test]
5565 async fn test_multiple_custom_builtins() {
5566 let mut bash = Bash::builder()
5567 .builtin("hello", Box::new(Hello))
5568 .builtin("greet", Box::new(Greet))
5569 .builtin("upper", Box::new(Upper))
5570 .build();
5571
5572 let result = bash.exec("hello").await.unwrap();
5573 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5574
5575 let result = bash.exec("greet Test").await.unwrap();
5576 assert_eq!(result.stdout, "Hello, Test!\n");
5577
5578 let result = bash.exec("echo foo | upper").await.unwrap();
5579 assert_eq!(result.stdout, "FOO\n");
5580 }
5581
5582 struct GreetingExtension;
5583
5584 impl Extension for GreetingExtension {
5585 fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
5586 vec![
5587 ("hello-ext".to_string(), Box::new(Hello)),
5588 ("greet-ext".to_string(), Box::new(Greet)),
5589 ]
5590 }
5591 }
5592
5593 #[tokio::test]
5594 async fn test_extension_registers_multiple_builtins() {
5595 let mut bash = Bash::builder().extension(GreetingExtension).build();
5596
5597 let result = bash.exec("hello-ext").await.unwrap();
5598 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5599
5600 let result = bash.exec("greet-ext Extension").await.unwrap();
5601 assert_eq!(result.stdout, "Hello, Extension!\n");
5602 }
5603
5604 struct Counter {
5606 prefix: String,
5607 }
5608
5609 #[async_trait]
5610 impl Builtin for Counter {
5611 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5612 let count = ctx
5613 .args
5614 .first()
5615 .and_then(|s| s.parse::<i32>().ok())
5616 .unwrap_or(1);
5617 let mut output = String::new();
5618 for i in 1..=count {
5619 output.push_str(&format!("{}{}\n", self.prefix, i));
5620 }
5621 Ok(ExecResult::ok(output))
5622 }
5623 }
5624
5625 #[tokio::test]
5626 async fn test_custom_builtin_with_state() {
5627 let mut bash = Bash::builder()
5628 .builtin(
5629 "count",
5630 Box::new(Counter {
5631 prefix: "Item ".to_string(),
5632 }),
5633 )
5634 .build();
5635
5636 let result = bash.exec("count 3").await.unwrap();
5637 assert_eq!(result.stdout, "Item 1\nItem 2\nItem 3\n");
5638 }
5639
5640 struct Fail;
5642
5643 #[async_trait]
5644 impl Builtin for Fail {
5645 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5646 let code = ctx
5647 .args
5648 .first()
5649 .and_then(|s| s.parse::<i32>().ok())
5650 .unwrap_or(1);
5651 Ok(ExecResult::err(
5652 format!("Failed with code {}\n", code),
5653 code,
5654 ))
5655 }
5656 }
5657
5658 #[tokio::test]
5659 async fn test_custom_builtin_error() {
5660 let mut bash = Bash::builder().builtin("fail", Box::new(Fail)).build();
5661
5662 let result = bash.exec("fail 42").await.unwrap();
5663 assert_eq!(result.exit_code, 42);
5664 assert_eq!(result.stderr, "Failed with code 42\n");
5665 }
5666
5667 #[tokio::test]
5668 async fn test_custom_builtin_in_script() {
5669 let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
5670
5671 let script = r#"
5672 for name in Alice Bob Charlie; do
5673 greet $name
5674 done
5675 "#;
5676
5677 let result = bash.exec(script).await.unwrap();
5678 assert_eq!(
5679 result.stdout,
5680 "Hello, Alice!\nHello, Bob!\nHello, Charlie!\n"
5681 );
5682 }
5683
5684 #[tokio::test]
5685 async fn test_custom_builtin_with_conditionals() {
5686 let mut bash = Bash::builder()
5687 .builtin("fail", Box::new(Fail))
5688 .builtin("hello", Box::new(Hello))
5689 .build();
5690
5691 let result = bash.exec("fail 1 || hello").await.unwrap();
5692 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5693 assert_eq!(result.exit_code, 0);
5694
5695 let result = bash.exec("hello && fail 5").await.unwrap();
5696 assert_eq!(result.exit_code, 5);
5697 }
5698
5699 struct EnvReader;
5701
5702 #[async_trait]
5703 impl Builtin for EnvReader {
5704 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5705 let var_name = ctx.args.first().map(|s| s.as_str()).unwrap_or("HOME");
5706 let value = ctx
5707 .env
5708 .get(var_name)
5709 .map(|s| s.as_str())
5710 .unwrap_or("(not set)");
5711 Ok(ExecResult::ok(format!("{}={}\n", var_name, value)))
5712 }
5713 }
5714
5715 #[tokio::test]
5716 async fn test_custom_builtin_reads_env() {
5717 let mut bash = Bash::builder()
5718 .env("MY_VAR", "my_value")
5719 .builtin("readenv", Box::new(EnvReader))
5720 .build();
5721
5722 let result = bash.exec("readenv MY_VAR").await.unwrap();
5723 assert_eq!(result.stdout, "MY_VAR=my_value\n");
5724
5725 let result = bash.exec("readenv UNKNOWN").await.unwrap();
5726 assert_eq!(result.stdout, "UNKNOWN=(not set)\n");
5727 }
5728 }
5729
5730 #[tokio::test]
5733 async fn test_parser_timeout_default() {
5734 let limits = ExecutionLimits::default();
5736 assert_eq!(limits.parser_timeout, std::time::Duration::from_secs(5));
5737 }
5738
5739 #[tokio::test]
5740 async fn test_parser_timeout_custom() {
5741 let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_millis(100));
5743 assert_eq!(limits.parser_timeout, std::time::Duration::from_millis(100));
5744 }
5745
5746 #[tokio::test]
5747 async fn test_parser_timeout_normal_script() {
5748 let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_secs(1));
5750 let mut bash = Bash::builder().limits(limits).build();
5751 let result = bash.exec("echo hello").await.unwrap();
5752 assert_eq!(result.stdout, "hello\n");
5753 }
5754
5755 #[tokio::test]
5758 async fn test_parser_fuel_default() {
5759 let limits = ExecutionLimits::default();
5761 assert_eq!(limits.max_parser_operations, 100_000);
5762 }
5763
5764 #[tokio::test]
5765 async fn test_parser_fuel_custom() {
5766 let limits = ExecutionLimits::new().max_parser_operations(1000);
5768 assert_eq!(limits.max_parser_operations, 1000);
5769 }
5770
5771 #[tokio::test]
5772 async fn test_parser_fuel_normal_script() {
5773 let limits = ExecutionLimits::new().max_parser_operations(1000);
5775 let mut bash = Bash::builder().limits(limits).build();
5776 let result = bash.exec("echo hello").await.unwrap();
5777 assert_eq!(result.stdout, "hello\n");
5778 }
5779
5780 #[tokio::test]
5783 async fn test_input_size_limit_default() {
5784 let limits = ExecutionLimits::default();
5786 assert_eq!(limits.max_input_bytes, 10_000_000);
5787 }
5788
5789 #[tokio::test]
5790 async fn test_input_size_limit_custom() {
5791 let limits = ExecutionLimits::new().max_input_bytes(1000);
5793 assert_eq!(limits.max_input_bytes, 1000);
5794 }
5795
5796 #[tokio::test]
5797 async fn test_input_size_limit_enforced() {
5798 let limits = ExecutionLimits::new().max_input_bytes(10);
5800 let mut bash = Bash::builder().limits(limits).build();
5801
5802 let result = bash.exec("echo hello world").await;
5804 assert!(result.is_err());
5805 let err = result.unwrap_err();
5806 assert!(
5807 err.to_string().contains("input too large"),
5808 "Expected input size error, got: {}",
5809 err
5810 );
5811 }
5812
5813 #[tokio::test]
5814 async fn test_input_size_limit_normal_script() {
5815 let limits = ExecutionLimits::new().max_input_bytes(1000);
5817 let mut bash = Bash::builder().limits(limits).build();
5818 let result = bash.exec("echo hello").await.unwrap();
5819 assert_eq!(result.stdout, "hello\n");
5820 }
5821
5822 #[tokio::test]
5825 async fn test_ast_depth_limit_default() {
5826 let limits = ExecutionLimits::default();
5828 assert_eq!(limits.max_ast_depth, 100);
5829 }
5830
5831 #[tokio::test]
5832 async fn test_ast_depth_limit_custom() {
5833 let limits = ExecutionLimits::new().max_ast_depth(10);
5835 assert_eq!(limits.max_ast_depth, 10);
5836 }
5837
5838 #[tokio::test]
5839 async fn test_ast_depth_limit_normal_script() {
5840 let limits = ExecutionLimits::new().max_ast_depth(10);
5842 let mut bash = Bash::builder().limits(limits).build();
5843 let result = bash.exec("if true; then echo ok; fi").await.unwrap();
5844 assert_eq!(result.stdout, "ok\n");
5845 }
5846
5847 #[tokio::test]
5848 async fn test_ast_depth_limit_enforced() {
5849 let limits = ExecutionLimits::new().max_ast_depth(2);
5851 let mut bash = Bash::builder().limits(limits).build();
5852
5853 let result = bash
5855 .exec("if true; then if true; then if true; then echo nested; fi; fi; fi")
5856 .await;
5857 assert!(result.is_err());
5858 let err = result.unwrap_err();
5859 assert!(
5860 err.to_string().contains("AST nesting too deep"),
5861 "Expected AST depth error, got: {}",
5862 err
5863 );
5864 }
5865
5866 #[tokio::test]
5867 async fn test_parser_fuel_enforced() {
5868 let limits = ExecutionLimits::new().max_parser_operations(3);
5871 let mut bash = Bash::builder().limits(limits).build();
5872
5873 let result = bash.exec("echo a; echo b; echo c").await;
5875 assert!(result.is_err());
5876 let err = result.unwrap_err();
5877 assert!(
5878 err.to_string().contains("parser fuel exhausted"),
5879 "Expected parser fuel error, got: {}",
5880 err
5881 );
5882 }
5883
5884 #[tokio::test]
5887 async fn test_set_e_basic() {
5888 let mut bash = Bash::new();
5890 let result = bash
5891 .exec("set -e; true; false; echo should_not_reach")
5892 .await
5893 .unwrap();
5894 assert_eq!(result.stdout, "");
5895 assert_eq!(result.exit_code, 1);
5896 }
5897
5898 #[tokio::test]
5899 async fn test_set_e_after_failing_cmd() {
5900 let mut bash = Bash::new();
5902 let result = bash
5903 .exec("set -e; echo before; false; echo after")
5904 .await
5905 .unwrap();
5906 assert_eq!(result.stdout, "before\n");
5907 assert_eq!(result.exit_code, 1);
5908 }
5909
5910 #[tokio::test]
5911 async fn test_set_e_disabled() {
5912 let mut bash = Bash::new();
5914 let result = bash
5915 .exec("set -e; set +e; false; echo still_running")
5916 .await
5917 .unwrap();
5918 assert_eq!(result.stdout, "still_running\n");
5919 }
5920
5921 #[tokio::test]
5922 async fn test_set_e_in_pipeline_last() {
5923 let mut bash = Bash::new();
5925 let result = bash
5926 .exec("set -e; false | true; echo reached")
5927 .await
5928 .unwrap();
5929 assert_eq!(result.stdout, "reached\n");
5930 }
5931
5932 #[tokio::test]
5933 async fn test_set_e_in_if_condition() {
5934 let mut bash = Bash::new();
5936 let result = bash
5937 .exec("set -e; if false; then echo yes; else echo no; fi; echo done")
5938 .await
5939 .unwrap();
5940 assert_eq!(result.stdout, "no\ndone\n");
5941 }
5942
5943 #[tokio::test]
5944 async fn test_set_e_in_while_condition() {
5945 let mut bash = Bash::new();
5947 let result = bash
5948 .exec("set -e; x=0; while [ \"$x\" -lt 2 ]; do echo \"x=$x\"; x=$((x + 1)); done; echo done")
5949 .await
5950 .unwrap();
5951 assert_eq!(result.stdout, "x=0\nx=1\ndone\n");
5952 }
5953
5954 #[tokio::test]
5955 async fn test_set_e_in_brace_group() {
5956 let mut bash = Bash::new();
5958 let result = bash
5959 .exec("set -e; { echo start; false; echo unreached; }; echo after")
5960 .await
5961 .unwrap();
5962 assert_eq!(result.stdout, "start\n");
5963 assert_eq!(result.exit_code, 1);
5964 }
5965
5966 #[tokio::test]
5967 async fn test_set_e_and_chain() {
5968 let mut bash = Bash::new();
5970 let result = bash
5971 .exec("set -e; false && echo one; echo reached")
5972 .await
5973 .unwrap();
5974 assert_eq!(result.stdout, "reached\n");
5975 }
5976
5977 #[tokio::test]
5978 async fn test_set_e_or_chain() {
5979 let mut bash = Bash::new();
5981 let result = bash
5982 .exec("set -e; true || false; echo reached")
5983 .await
5984 .unwrap();
5985 assert_eq!(result.stdout, "reached\n");
5986 }
5987
5988 #[tokio::test]
5991 async fn test_tilde_expansion_basic() {
5992 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
5994 let result = bash.exec("echo ~").await.unwrap();
5995 assert_eq!(result.stdout, "/home/testuser\n");
5996 }
5997
5998 #[tokio::test]
5999 async fn test_tilde_expansion_with_path() {
6000 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
6002 let result = bash.exec("echo ~/documents/file.txt").await.unwrap();
6003 assert_eq!(result.stdout, "/home/testuser/documents/file.txt\n");
6004 }
6005
6006 #[tokio::test]
6007 async fn test_tilde_expansion_in_assignment() {
6008 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
6010 let result = bash.exec("DIR=~/data; echo $DIR").await.unwrap();
6011 assert_eq!(result.stdout, "/home/testuser/data\n");
6012 }
6013
6014 #[tokio::test]
6015 async fn test_tilde_expansion_default_home() {
6016 let mut bash = Bash::new();
6018 let result = bash.exec("echo ~").await.unwrap();
6019 assert_eq!(result.stdout, "/home/sandbox\n");
6020 }
6021
6022 #[tokio::test]
6023 async fn test_tilde_not_at_start() {
6024 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
6026 let result = bash.exec("echo foo~bar").await.unwrap();
6027 assert_eq!(result.stdout, "foo~bar\n");
6028 }
6029
6030 #[tokio::test]
6033 async fn test_special_var_dollar_dollar() {
6034 let mut bash = Bash::new();
6036 let result = bash.exec("echo $$").await.unwrap();
6037 let pid: u32 = result.stdout.trim().parse().expect("$$ should be a number");
6039 assert!(pid > 0, "$$ should be a positive number");
6040 }
6041
6042 #[tokio::test]
6043 async fn test_special_var_random() {
6044 let mut bash = Bash::new();
6046 let result = bash.exec("echo $RANDOM").await.unwrap();
6047 let random: u32 = result
6048 .stdout
6049 .trim()
6050 .parse()
6051 .expect("$RANDOM should be a number");
6052 assert!(random < 32768, "$RANDOM should be < 32768");
6053 }
6054
6055 #[tokio::test]
6056 async fn test_special_var_random_varies() {
6057 let mut bash = Bash::new();
6059 let result1 = bash.exec("echo $RANDOM").await.unwrap();
6060 let result2 = bash.exec("echo $RANDOM").await.unwrap();
6061 let _: u32 = result1
6065 .stdout
6066 .trim()
6067 .parse()
6068 .expect("$RANDOM should be a number");
6069 let _: u32 = result2
6070 .stdout
6071 .trim()
6072 .parse()
6073 .expect("$RANDOM should be a number");
6074 }
6075
6076 #[tokio::test]
6077 async fn test_random_different_instances() {
6078 let mut bash1 = Bash::new();
6081 let mut bash2 = Bash::new();
6082 let r1 = bash1.exec("echo $RANDOM").await.unwrap();
6083 let r2 = bash2.exec("echo $RANDOM").await.unwrap();
6084 let v1: u32 = r1.stdout.trim().parse().expect("should be a number");
6085 let v2: u32 = r2.stdout.trim().parse().expect("should be a number");
6086 assert!(v1 < 32768);
6087 assert!(v2 < 32768);
6088 assert_ne!(v1, v2, "separate instances should produce different values");
6090 }
6091
6092 #[tokio::test]
6093 async fn test_random_reseed() {
6094 let mut bash1 = Bash::new();
6096 let mut bash2 = Bash::new();
6097 bash1.exec("RANDOM=42").await.unwrap();
6098 bash2.exec("RANDOM=42").await.unwrap();
6099 let r1 = bash1.exec("echo $RANDOM").await.unwrap();
6100 let r2 = bash2.exec("echo $RANDOM").await.unwrap();
6101 assert_eq!(
6102 r1.stdout, r2.stdout,
6103 "same seed should produce same first value"
6104 );
6105 }
6106
6107 #[tokio::test]
6108 async fn test_random_sequential_varies() {
6109 let mut bash = Bash::new();
6111 let result = bash.exec("echo $RANDOM $RANDOM $RANDOM").await.unwrap();
6112 let values: Vec<u32> = result
6113 .stdout
6114 .split_whitespace()
6115 .map(|s| s.parse().expect("should be a number"))
6116 .collect();
6117 assert_eq!(values.len(), 3);
6118 assert!(
6120 values[0] != values[1] || values[1] != values[2],
6121 "sequential RANDOM calls should produce different values"
6122 );
6123 }
6124
6125 #[tokio::test]
6126 async fn test_special_var_lineno() {
6127 let mut bash = Bash::new();
6129 let result = bash.exec("echo $LINENO").await.unwrap();
6130 assert_eq!(result.stdout, "1\n");
6131 }
6132
6133 #[tokio::test]
6134 async fn test_lineno_multiline() {
6135 let mut bash = Bash::new();
6137 let result = bash
6138 .exec(
6139 r#"echo "line $LINENO"
6140echo "line $LINENO"
6141echo "line $LINENO""#,
6142 )
6143 .await
6144 .unwrap();
6145 assert_eq!(result.stdout, "line 1\nline 2\nline 3\n");
6146 }
6147
6148 #[tokio::test]
6149 async fn test_lineno_in_loop() {
6150 let mut bash = Bash::new();
6152 let result = bash
6153 .exec(
6154 r#"for i in 1 2; do
6155 echo "loop $LINENO"
6156done"#,
6157 )
6158 .await
6159 .unwrap();
6160 assert_eq!(result.stdout, "loop 2\nloop 2\n");
6162 }
6163
6164 #[tokio::test]
6167 async fn test_file_test_r_readable() {
6168 let mut bash = Bash::new();
6170 bash.exec("echo hello > /tmp/readable.txt").await.unwrap();
6171 let result = bash
6172 .exec("test -r /tmp/readable.txt && echo yes")
6173 .await
6174 .unwrap();
6175 assert_eq!(result.stdout, "yes\n");
6176 }
6177
6178 #[tokio::test]
6179 async fn test_file_test_r_not_exists() {
6180 let mut bash = Bash::new();
6182 let result = bash
6183 .exec("test -r /tmp/nonexistent.txt && echo yes || echo no")
6184 .await
6185 .unwrap();
6186 assert_eq!(result.stdout, "no\n");
6187 }
6188
6189 #[tokio::test]
6190 async fn test_file_test_w_writable() {
6191 let mut bash = Bash::new();
6193 bash.exec("echo hello > /tmp/writable.txt").await.unwrap();
6194 let result = bash
6195 .exec("test -w /tmp/writable.txt && echo yes")
6196 .await
6197 .unwrap();
6198 assert_eq!(result.stdout, "yes\n");
6199 }
6200
6201 #[tokio::test]
6202 async fn test_file_test_x_executable() {
6203 let mut bash = Bash::new();
6205 bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
6206 .await
6207 .unwrap();
6208 bash.exec("chmod 755 /tmp/script.sh").await.unwrap();
6209 let result = bash
6210 .exec("test -x /tmp/script.sh && echo yes")
6211 .await
6212 .unwrap();
6213 assert_eq!(result.stdout, "yes\n");
6214 }
6215
6216 #[tokio::test]
6217 async fn test_file_test_x_not_executable() {
6218 let mut bash = Bash::new();
6220 bash.exec("echo 'data' > /tmp/noexec.txt").await.unwrap();
6221 bash.exec("chmod 644 /tmp/noexec.txt").await.unwrap();
6222 let result = bash
6223 .exec("test -x /tmp/noexec.txt && echo yes || echo no")
6224 .await
6225 .unwrap();
6226 assert_eq!(result.stdout, "no\n");
6227 }
6228
6229 #[tokio::test]
6230 async fn test_file_test_e_exists() {
6231 let mut bash = Bash::new();
6233 bash.exec("echo hello > /tmp/exists.txt").await.unwrap();
6234 let result = bash
6235 .exec("test -e /tmp/exists.txt && echo yes")
6236 .await
6237 .unwrap();
6238 assert_eq!(result.stdout, "yes\n");
6239 }
6240
6241 #[tokio::test]
6242 async fn test_file_test_f_regular() {
6243 let mut bash = Bash::new();
6245 bash.exec("echo hello > /tmp/regular.txt").await.unwrap();
6246 let result = bash
6247 .exec("test -f /tmp/regular.txt && echo yes")
6248 .await
6249 .unwrap();
6250 assert_eq!(result.stdout, "yes\n");
6251 }
6252
6253 #[tokio::test]
6254 async fn test_file_test_d_directory() {
6255 let mut bash = Bash::new();
6257 bash.exec("mkdir -p /tmp/mydir").await.unwrap();
6258 let result = bash.exec("test -d /tmp/mydir && echo yes").await.unwrap();
6259 assert_eq!(result.stdout, "yes\n");
6260 }
6261
6262 #[tokio::test]
6263 async fn test_file_test_s_size() {
6264 let mut bash = Bash::new();
6266 bash.exec("echo hello > /tmp/nonempty.txt").await.unwrap();
6267 let result = bash
6268 .exec("test -s /tmp/nonempty.txt && echo yes")
6269 .await
6270 .unwrap();
6271 assert_eq!(result.stdout, "yes\n");
6272 }
6273
6274 #[tokio::test]
6279 async fn test_redirect_both_stdout_stderr() {
6280 let mut bash = Bash::new();
6282 let result = bash.exec("echo hello &> /tmp/out.txt").await.unwrap();
6284 assert_eq!(result.stdout, "");
6286 let check = bash.exec("cat /tmp/out.txt").await.unwrap();
6288 assert_eq!(check.stdout, "hello\n");
6289 }
6290
6291 #[tokio::test]
6292 async fn test_stderr_redirect_to_file() {
6293 let mut bash = Bash::new();
6297 bash.exec("echo stdout; echo stderr 2> /tmp/err.txt")
6299 .await
6300 .unwrap();
6301 }
6304
6305 #[tokio::test]
6306 async fn test_fd_redirect_parsing() {
6307 let mut bash = Bash::new();
6309 let result = bash.exec("true 2> /tmp/err.txt").await.unwrap();
6311 assert_eq!(result.exit_code, 0);
6312 }
6313
6314 #[tokio::test]
6315 async fn test_fd_redirect_append_parsing() {
6316 let mut bash = Bash::new();
6318 let result = bash.exec("true 2>> /tmp/err.txt").await.unwrap();
6319 assert_eq!(result.exit_code, 0);
6320 }
6321
6322 #[tokio::test]
6323 async fn test_fd_dup_parsing() {
6324 let mut bash = Bash::new();
6326 let result = bash.exec("echo hello 2>&1").await.unwrap();
6327 assert_eq!(result.stdout, "hello\n");
6328 assert_eq!(result.exit_code, 0);
6329 }
6330
6331 #[tokio::test]
6332 async fn test_dup_output_redirect_stdout_to_stderr() {
6333 let mut bash = Bash::new();
6335 let result = bash.exec("echo hello >&2").await.unwrap();
6336 assert_eq!(result.stdout, "");
6338 assert_eq!(result.stderr, "hello\n");
6339 }
6340
6341 #[tokio::test]
6342 async fn test_lexer_redirect_both() {
6343 let mut bash = Bash::new();
6345 let result = bash.exec("echo test &> /tmp/both.txt").await.unwrap();
6347 assert_eq!(result.stdout, "");
6348 let check = bash.exec("cat /tmp/both.txt").await.unwrap();
6349 assert_eq!(check.stdout, "test\n");
6350 }
6351
6352 #[tokio::test]
6353 async fn test_lexer_dup_output() {
6354 let mut bash = Bash::new();
6356 let result = bash.exec("echo test >&2").await.unwrap();
6357 assert_eq!(result.stdout, "");
6358 assert_eq!(result.stderr, "test\n");
6359 }
6360
6361 #[tokio::test]
6362 async fn test_digit_before_redirect() {
6363 let mut bash = Bash::new();
6365 let result = bash.exec("echo hello 2> /tmp/err.txt").await.unwrap();
6367 assert_eq!(result.exit_code, 0);
6368 assert_eq!(result.stdout, "hello\n");
6370 }
6371
6372 #[tokio::test]
6377 async fn test_arithmetic_logical_and_true() {
6378 let mut bash = Bash::new();
6380 let result = bash.exec("echo $((1 && 1))").await.unwrap();
6381 assert_eq!(result.stdout, "1\n");
6382 }
6383
6384 #[tokio::test]
6385 async fn test_arithmetic_logical_and_false_left() {
6386 let mut bash = Bash::new();
6388 let result = bash.exec("echo $((0 && 1))").await.unwrap();
6389 assert_eq!(result.stdout, "0\n");
6390 }
6391
6392 #[tokio::test]
6393 async fn test_arithmetic_logical_and_false_right() {
6394 let mut bash = Bash::new();
6396 let result = bash.exec("echo $((1 && 0))").await.unwrap();
6397 assert_eq!(result.stdout, "0\n");
6398 }
6399
6400 #[tokio::test]
6401 async fn test_arithmetic_logical_or_false() {
6402 let mut bash = Bash::new();
6404 let result = bash.exec("echo $((0 || 0))").await.unwrap();
6405 assert_eq!(result.stdout, "0\n");
6406 }
6407
6408 #[tokio::test]
6409 async fn test_arithmetic_logical_or_true_left() {
6410 let mut bash = Bash::new();
6412 let result = bash.exec("echo $((1 || 0))").await.unwrap();
6413 assert_eq!(result.stdout, "1\n");
6414 }
6415
6416 #[tokio::test]
6417 async fn test_arithmetic_logical_or_true_right() {
6418 let mut bash = Bash::new();
6420 let result = bash.exec("echo $((0 || 1))").await.unwrap();
6421 assert_eq!(result.stdout, "1\n");
6422 }
6423
6424 #[tokio::test]
6425 async fn test_arithmetic_logical_combined() {
6426 let mut bash = Bash::new();
6428 let result = bash.exec("echo $((5 > 3 && 2 < 4))").await.unwrap();
6430 assert_eq!(result.stdout, "1\n");
6431 }
6432
6433 #[tokio::test]
6434 async fn test_arithmetic_logical_with_comparison() {
6435 let mut bash = Bash::new();
6437 let result = bash.exec("echo $((5 < 3 || 2 < 4))").await.unwrap();
6439 assert_eq!(result.stdout, "1\n");
6440 }
6441
6442 #[tokio::test]
6443 async fn test_arithmetic_multibyte_no_panic() {
6444 let mut bash = Bash::new();
6446 let result = bash.exec("echo $((0,1))").await.unwrap();
6448 assert_eq!(result.stdout, "1\n");
6449 let _ = bash.exec("echo $((\u{00e9}+1))").await;
6451 }
6452
6453 #[tokio::test]
6458 async fn test_brace_expansion_list() {
6459 let mut bash = Bash::new();
6461 let result = bash.exec("echo {a,b,c}").await.unwrap();
6462 assert_eq!(result.stdout, "a b c\n");
6463 }
6464
6465 #[tokio::test]
6466 async fn test_brace_expansion_with_prefix() {
6467 let mut bash = Bash::new();
6469 let result = bash.exec("echo file{1,2,3}.txt").await.unwrap();
6470 assert_eq!(result.stdout, "file1.txt file2.txt file3.txt\n");
6471 }
6472
6473 #[tokio::test]
6474 async fn test_brace_expansion_numeric_range() {
6475 let mut bash = Bash::new();
6477 let result = bash.exec("echo {1..5}").await.unwrap();
6478 assert_eq!(result.stdout, "1 2 3 4 5\n");
6479 }
6480
6481 #[tokio::test]
6482 async fn test_brace_expansion_char_range() {
6483 let mut bash = Bash::new();
6485 let result = bash.exec("echo {a..e}").await.unwrap();
6486 assert_eq!(result.stdout, "a b c d e\n");
6487 }
6488
6489 #[tokio::test]
6490 async fn test_brace_expansion_reverse_range() {
6491 let mut bash = Bash::new();
6493 let result = bash.exec("echo {5..1}").await.unwrap();
6494 assert_eq!(result.stdout, "5 4 3 2 1\n");
6495 }
6496
6497 #[tokio::test]
6498 async fn test_brace_expansion_nested() {
6499 let mut bash = Bash::new();
6501 let result = bash.exec("echo {a,b}{1,2}").await.unwrap();
6502 assert_eq!(result.stdout, "a1 a2 b1 b2\n");
6503 }
6504
6505 #[tokio::test]
6506 async fn test_brace_expansion_with_suffix() {
6507 let mut bash = Bash::new();
6509 let result = bash.exec("echo pre{x,y}suf").await.unwrap();
6510 assert_eq!(result.stdout, "prexsuf preysuf\n");
6511 }
6512
6513 #[tokio::test]
6514 async fn test_brace_expansion_empty_item() {
6515 let mut bash = Bash::new();
6517 let result = bash.exec("echo x{,y}z").await.unwrap();
6518 assert_eq!(result.stdout, "xz xyz\n");
6519 }
6520
6521 #[tokio::test]
6526 async fn test_string_less_than() {
6527 let mut bash = Bash::new();
6528 let result = bash
6529 .exec("test apple '<' banana && echo yes")
6530 .await
6531 .unwrap();
6532 assert_eq!(result.stdout, "yes\n");
6533 }
6534
6535 #[tokio::test]
6536 async fn test_string_greater_than() {
6537 let mut bash = Bash::new();
6538 let result = bash
6539 .exec("test banana '>' apple && echo yes")
6540 .await
6541 .unwrap();
6542 assert_eq!(result.stdout, "yes\n");
6543 }
6544
6545 #[tokio::test]
6546 async fn test_string_less_than_false() {
6547 let mut bash = Bash::new();
6548 let result = bash
6549 .exec("test banana '<' apple && echo yes || echo no")
6550 .await
6551 .unwrap();
6552 assert_eq!(result.stdout, "no\n");
6553 }
6554
6555 #[tokio::test]
6560 async fn test_array_indices_basic() {
6561 let mut bash = Bash::new();
6563 let result = bash.exec("arr=(a b c); echo ${!arr[@]}").await.unwrap();
6564 assert_eq!(result.stdout, "0 1 2\n");
6565 }
6566
6567 #[tokio::test]
6568 async fn test_array_indices_sparse() {
6569 let mut bash = Bash::new();
6571 let result = bash
6572 .exec("arr[0]=a; arr[5]=b; arr[10]=c; echo ${!arr[@]}")
6573 .await
6574 .unwrap();
6575 assert_eq!(result.stdout, "0 5 10\n");
6576 }
6577
6578 #[tokio::test]
6579 async fn test_array_indices_star() {
6580 let mut bash = Bash::new();
6582 let result = bash.exec("arr=(x y z); echo ${!arr[*]}").await.unwrap();
6583 assert_eq!(result.stdout, "0 1 2\n");
6584 }
6585
6586 #[tokio::test]
6587 async fn test_array_indices_empty() {
6588 let mut bash = Bash::new();
6590 let result = bash.exec("arr=(); echo \"${!arr[@]}\"").await.unwrap();
6591 assert_eq!(result.stdout, "\n");
6592 }
6593
6594 #[tokio::test]
6599 async fn test_text_file_basic() {
6600 let mut bash = Bash::builder()
6601 .mount_text("/config/app.conf", "debug=true\nport=8080\n")
6602 .build();
6603
6604 let result = bash.exec("cat /config/app.conf").await.unwrap();
6605 assert_eq!(result.stdout, "debug=true\nport=8080\n");
6606 }
6607
6608 #[tokio::test]
6609 async fn test_text_file_multiple() {
6610 let mut bash = Bash::builder()
6611 .mount_text("/data/file1.txt", "content one")
6612 .mount_text("/data/file2.txt", "content two")
6613 .mount_text("/other/file3.txt", "content three")
6614 .build();
6615
6616 let result = bash.exec("cat /data/file1.txt").await.unwrap();
6617 assert_eq!(result.stdout, "content one");
6618
6619 let result = bash.exec("cat /data/file2.txt").await.unwrap();
6620 assert_eq!(result.stdout, "content two");
6621
6622 let result = bash.exec("cat /other/file3.txt").await.unwrap();
6623 assert_eq!(result.stdout, "content three");
6624 }
6625
6626 #[tokio::test]
6627 async fn test_text_file_nested_directory() {
6628 let mut bash = Bash::builder()
6630 .mount_text("/a/b/c/d/file.txt", "nested content")
6631 .build();
6632
6633 let result = bash.exec("cat /a/b/c/d/file.txt").await.unwrap();
6634 assert_eq!(result.stdout, "nested content");
6635 }
6636
6637 #[tokio::test]
6638 async fn test_text_file_mode() {
6639 let bash = Bash::builder()
6640 .mount_text("/tmp/writable.txt", "content")
6641 .build();
6642
6643 let stat = bash
6644 .fs()
6645 .stat(std::path::Path::new("/tmp/writable.txt"))
6646 .await
6647 .unwrap();
6648 assert_eq!(stat.mode, 0o644);
6649 }
6650
6651 #[tokio::test]
6652 async fn test_readonly_text_basic() {
6653 let mut bash = Bash::builder()
6654 .mount_readonly_text("/etc/version", "1.2.3")
6655 .build();
6656
6657 let result = bash.exec("cat /etc/version").await.unwrap();
6658 assert_eq!(result.stdout, "1.2.3");
6659 }
6660
6661 #[tokio::test]
6662 async fn test_readonly_text_mode() {
6663 let bash = Bash::builder()
6664 .mount_readonly_text("/etc/readonly.conf", "immutable")
6665 .build();
6666
6667 let stat = bash
6668 .fs()
6669 .stat(std::path::Path::new("/etc/readonly.conf"))
6670 .await
6671 .unwrap();
6672 assert_eq!(stat.mode, 0o444);
6673 }
6674
6675 #[tokio::test]
6676 async fn test_text_file_mixed_readonly_writable() {
6677 let bash = Bash::builder()
6678 .mount_text("/data/writable.txt", "can edit")
6679 .mount_readonly_text("/data/readonly.txt", "cannot edit")
6680 .build();
6681
6682 let writable_stat = bash
6683 .fs()
6684 .stat(std::path::Path::new("/data/writable.txt"))
6685 .await
6686 .unwrap();
6687 let readonly_stat = bash
6688 .fs()
6689 .stat(std::path::Path::new("/data/readonly.txt"))
6690 .await
6691 .unwrap();
6692
6693 assert_eq!(writable_stat.mode, 0o644);
6694 assert_eq!(readonly_stat.mode, 0o444);
6695 }
6696
6697 #[tokio::test]
6698 async fn test_text_file_with_env() {
6699 let mut bash = Bash::builder()
6701 .env("APP_NAME", "testapp")
6702 .mount_text("/config/app.conf", "name=${APP_NAME}")
6703 .build();
6704
6705 let result = bash.exec("echo $APP_NAME").await.unwrap();
6706 assert_eq!(result.stdout, "testapp\n");
6707
6708 let result = bash.exec("cat /config/app.conf").await.unwrap();
6709 assert_eq!(result.stdout, "name=${APP_NAME}");
6710 }
6711
6712 #[tokio::test]
6713 #[cfg(feature = "jq")]
6714 async fn test_text_file_json() {
6715 let mut bash = Bash::builder()
6716 .mount_text("/data/users.json", r#"["alice", "bob", "charlie"]"#)
6717 .build();
6718
6719 let result = bash.exec("cat /data/users.json | jq '.[0]'").await.unwrap();
6720 assert_eq!(result.stdout, "\"alice\"\n");
6721 }
6722
6723 #[tokio::test]
6724 async fn test_mount_with_custom_filesystem() {
6725 let custom_fs = std::sync::Arc::new(InMemoryFs::new());
6727
6728 custom_fs
6730 .write_file(std::path::Path::new("/base.txt"), b"from base")
6731 .await
6732 .unwrap();
6733
6734 let mut bash = Bash::builder()
6735 .fs(custom_fs)
6736 .mount_text("/mounted.txt", "from mount")
6737 .mount_readonly_text("/readonly.txt", "immutable")
6738 .build();
6739
6740 let result = bash.exec("cat /base.txt").await.unwrap();
6742 assert_eq!(result.stdout, "from base");
6743
6744 let result = bash.exec("cat /mounted.txt").await.unwrap();
6746 assert_eq!(result.stdout, "from mount");
6747
6748 let result = bash.exec("cat /readonly.txt").await.unwrap();
6749 assert_eq!(result.stdout, "immutable");
6750
6751 let stat = bash
6753 .fs()
6754 .stat(std::path::Path::new("/readonly.txt"))
6755 .await
6756 .unwrap();
6757 assert_eq!(stat.mode, 0o444);
6758 }
6759
6760 #[tokio::test]
6761 async fn test_mount_overwrites_base_file() {
6762 let custom_fs = std::sync::Arc::new(InMemoryFs::new());
6764 custom_fs
6765 .write_file(std::path::Path::new("/config.txt"), b"original")
6766 .await
6767 .unwrap();
6768
6769 let mut bash = Bash::builder()
6770 .fs(custom_fs)
6771 .mount_text("/config.txt", "overwritten")
6772 .build();
6773
6774 let result = bash.exec("cat /config.txt").await.unwrap();
6775 assert_eq!(result.stdout, "overwritten");
6776 }
6777
6778 #[tokio::test]
6779 async fn test_mount_preserves_custom_fs_limits() {
6780 let limited_fs =
6781 std::sync::Arc::new(InMemoryFs::with_limits(FsLimits::new().max_total_bytes(32)));
6782
6783 let bash = Bash::builder()
6784 .fs(limited_fs)
6785 .mount_text("/mounted.txt", "seed")
6786 .build();
6787
6788 let write_err = bash
6789 .fs()
6790 .write_file(
6791 std::path::Path::new("/too-big.txt"),
6792 b"this payload should exceed thirty-two bytes",
6793 )
6794 .await;
6795 assert!(write_err.is_err(), "custom fs limits should still apply");
6796 }
6797
6798 #[tokio::test]
6799 async fn test_mount_text_respects_filesystem_limits() {
6800 let limited_fs = std::sync::Arc::new(InMemoryFs::with_limits(
6801 FsLimits::new().max_total_bytes(5).max_file_size(5),
6802 ));
6803
6804 let bash = Bash::builder()
6805 .fs(limited_fs)
6806 .mount_text("/too-large.txt", "123456")
6807 .build();
6808
6809 let exists = bash
6810 .fs()
6811 .exists(std::path::Path::new("/too-large.txt"))
6812 .await
6813 .unwrap();
6814 assert!(!exists, "mount_text should not bypass configured FsLimits");
6815 }
6816
6817 #[tokio::test]
6822 async fn test_parse_error_includes_line_number() {
6823 let mut bash = Bash::new();
6825 let result = bash
6826 .exec(
6827 r#"echo ok
6828if true; then
6829echo missing fi"#,
6830 )
6831 .await;
6832 assert!(result.is_err());
6834 let err = result.unwrap_err();
6835 let err_msg = format!("{}", err);
6836 assert!(
6838 err_msg.contains("line") || err_msg.contains("parse"),
6839 "Error should be a parse error: {}",
6840 err_msg
6841 );
6842 }
6843
6844 #[tokio::test]
6845 async fn test_parse_error_on_specific_line() {
6846 use crate::parser::Parser;
6848 let script = "echo line1\necho line2\nif true; then\n";
6849 let result = Parser::new(script).parse();
6850 assert!(result.is_err());
6851 let err = result.unwrap_err();
6852 let err_msg = format!("{}", err);
6853 assert!(
6855 err_msg.contains("expected") || err_msg.contains("syntax error"),
6856 "Error should be a parse error: {}",
6857 err_msg
6858 );
6859 }
6860
6861 #[tokio::test]
6864 async fn test_cd_to_root_and_ls() {
6865 let mut bash = Bash::new();
6867 let result = bash.exec("cd / && ls").await.unwrap();
6868 assert_eq!(
6869 result.exit_code, 0,
6870 "cd / && ls should succeed: {}",
6871 result.stderr
6872 );
6873 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6874 assert!(result.stdout.contains("home"), "Root should contain home");
6875 }
6876
6877 #[tokio::test]
6878 async fn test_cd_to_root_and_pwd() {
6879 let mut bash = Bash::new();
6881 let result = bash.exec("cd / && pwd").await.unwrap();
6882 assert_eq!(result.exit_code, 0, "cd / && pwd should succeed");
6883 assert_eq!(result.stdout.trim(), "/");
6884 }
6885
6886 #[tokio::test]
6887 async fn test_cd_to_root_and_ls_dot() {
6888 let mut bash = Bash::new();
6890 let result = bash.exec("cd / && ls .").await.unwrap();
6891 assert_eq!(
6892 result.exit_code, 0,
6893 "cd / && ls . should succeed: {}",
6894 result.stderr
6895 );
6896 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6897 assert!(result.stdout.contains("home"), "Root should contain home");
6898 }
6899
6900 #[tokio::test]
6901 async fn test_ls_root_directly() {
6902 let mut bash = Bash::new();
6904 let result = bash.exec("ls /").await.unwrap();
6905 assert_eq!(
6906 result.exit_code, 0,
6907 "ls / should succeed: {}",
6908 result.stderr
6909 );
6910 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6911 assert!(result.stdout.contains("home"), "Root should contain home");
6912 assert!(result.stdout.contains("dev"), "Root should contain dev");
6913 }
6914
6915 #[tokio::test]
6916 async fn test_ls_root_long_format() {
6917 let mut bash = Bash::new();
6919 let result = bash.exec("ls -la /").await.unwrap();
6920 assert_eq!(
6921 result.exit_code, 0,
6922 "ls -la / should succeed: {}",
6923 result.stderr
6924 );
6925 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6926 assert!(
6927 result.stdout.contains("drw"),
6928 "Should show directory permissions"
6929 );
6930 }
6931
6932 #[tokio::test]
6935 async fn test_heredoc_redirect_to_file() {
6936 let mut bash = Bash::new();
6938 let result = bash
6939 .exec("cat > /tmp/out.txt <<'EOF'\nhello\nworld\nEOF\ncat /tmp/out.txt")
6940 .await
6941 .unwrap();
6942 assert_eq!(result.stdout, "hello\nworld\n");
6943 assert_eq!(result.exit_code, 0);
6944 }
6945
6946 #[tokio::test]
6947 async fn test_heredoc_redirect_to_file_unquoted() {
6948 let mut bash = Bash::new();
6949 let result = bash
6950 .exec("cat > /tmp/out.txt <<EOF\nhello\nworld\nEOF\ncat /tmp/out.txt")
6951 .await
6952 .unwrap();
6953 assert_eq!(result.stdout, "hello\nworld\n");
6954 assert_eq!(result.exit_code, 0);
6955 }
6956
6957 #[tokio::test]
6960 async fn test_pipe_to_while_read() {
6961 let mut bash = Bash::new();
6963 let result = bash
6964 .exec("echo -e 'a\\nb\\nc' | while read line; do echo \"got: $line\"; done")
6965 .await
6966 .unwrap();
6967 assert!(
6968 result.stdout.contains("got: a"),
6969 "stdout: {}",
6970 result.stdout
6971 );
6972 assert!(
6973 result.stdout.contains("got: b"),
6974 "stdout: {}",
6975 result.stdout
6976 );
6977 assert!(
6978 result.stdout.contains("got: c"),
6979 "stdout: {}",
6980 result.stdout
6981 );
6982 }
6983
6984 #[tokio::test]
6985 async fn test_pipe_to_while_read_count() {
6986 let mut bash = Bash::new();
6987 let result = bash
6988 .exec("printf 'x\\ny\\nz\\n' | while read line; do echo $line; done")
6989 .await
6990 .unwrap();
6991 assert_eq!(result.stdout, "x\ny\nz\n");
6992 }
6993
6994 #[tokio::test]
6997 async fn test_source_loads_functions() {
6998 let mut bash = Bash::new();
6999 bash.exec("cat > /tmp/lib.sh <<'EOF'\ngreet() { echo \"hello $1\"; }\nEOF")
7001 .await
7002 .unwrap();
7003 let result = bash.exec("source /tmp/lib.sh; greet world").await.unwrap();
7004 assert_eq!(result.stdout, "hello world\n");
7005 assert_eq!(result.exit_code, 0);
7006 }
7007
7008 #[tokio::test]
7009 async fn test_source_loads_variables() {
7010 let mut bash = Bash::new();
7011 bash.exec("echo 'MY_VAR=loaded' > /tmp/vars.sh")
7012 .await
7013 .unwrap();
7014 let result = bash
7015 .exec("source /tmp/vars.sh; echo $MY_VAR")
7016 .await
7017 .unwrap();
7018 assert_eq!(result.stdout, "loaded\n");
7019 }
7020
7021 #[tokio::test]
7024 async fn test_chmod_symbolic_plus_x() {
7025 let mut bash = Bash::new();
7026 bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
7027 .await
7028 .unwrap();
7029 let result = bash.exec("chmod +x /tmp/script.sh").await.unwrap();
7030 assert_eq!(
7031 result.exit_code, 0,
7032 "chmod +x should succeed: {}",
7033 result.stderr
7034 );
7035 }
7036
7037 #[tokio::test]
7038 async fn test_chmod_symbolic_u_plus_x() {
7039 let mut bash = Bash::new();
7040 bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
7041 let result = bash.exec("chmod u+x /tmp/file.txt").await.unwrap();
7042 assert_eq!(
7043 result.exit_code, 0,
7044 "chmod u+x should succeed: {}",
7045 result.stderr
7046 );
7047 }
7048
7049 #[tokio::test]
7050 async fn test_chmod_symbolic_a_plus_r() {
7051 let mut bash = Bash::new();
7052 bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
7053 let result = bash.exec("chmod a+r /tmp/file.txt").await.unwrap();
7054 assert_eq!(
7055 result.exit_code, 0,
7056 "chmod a+r should succeed: {}",
7057 result.stderr
7058 );
7059 }
7060
7061 #[tokio::test]
7064 async fn test_awk_array_length() {
7065 let mut bash = Bash::new();
7067 let result = bash
7068 .exec(r#"echo "" | awk 'BEGIN{a[1]="x"; a[2]="y"; a[3]="z"} END{print length(a)}'"#)
7069 .await
7070 .unwrap();
7071 assert_eq!(result.stdout, "3\n");
7072 }
7073
7074 #[tokio::test]
7075 async fn test_awk_array_read_after_split() {
7076 let mut bash = Bash::new();
7078 let result = bash
7079 .exec(r#"echo "a:b:c" | awk '{n=split($0,arr,":"); for(i=1;i<=n;i++) print arr[i]}'"#)
7080 .await
7081 .unwrap();
7082 assert_eq!(result.stdout, "a\nb\nc\n");
7083 }
7084
7085 #[tokio::test]
7086 async fn test_awk_array_word_count_pattern() {
7087 let mut bash = Bash::new();
7089 let result = bash
7090 .exec(
7091 r#"printf "apple\nbanana\napple\ncherry\nbanana\napple" | awk '{count[$1]++} END{for(w in count) print w, count[w]}'"#,
7092 )
7093 .await
7094 .unwrap();
7095 assert!(
7096 result.stdout.contains("apple 3"),
7097 "stdout: {}",
7098 result.stdout
7099 );
7100 assert!(
7101 result.stdout.contains("banana 2"),
7102 "stdout: {}",
7103 result.stdout
7104 );
7105 assert!(
7106 result.stdout.contains("cherry 1"),
7107 "stdout: {}",
7108 result.stdout
7109 );
7110 }
7111
7112 #[tokio::test]
7115 async fn test_exec_streaming_for_loop() {
7116 let chunks = Arc::new(Mutex::new(Vec::new()));
7117 let chunks_cb = chunks.clone();
7118 let mut bash = Bash::new();
7119
7120 let result = bash
7121 .exec_streaming(
7122 "for i in 1 2 3; do echo $i; done",
7123 Box::new(move |stdout, _stderr| {
7124 chunks_cb.lock().unwrap().push(stdout.to_string());
7125 }),
7126 )
7127 .await
7128 .unwrap();
7129
7130 assert_eq!(result.stdout, "1\n2\n3\n");
7131 assert_eq!(
7132 *chunks.lock().unwrap(),
7133 vec!["1\n", "2\n", "3\n"],
7134 "each loop iteration should stream separately"
7135 );
7136 }
7137
7138 #[tokio::test]
7139 async fn test_exec_streaming_while_loop() {
7140 let chunks = Arc::new(Mutex::new(Vec::new()));
7141 let chunks_cb = chunks.clone();
7142 let mut bash = Bash::new();
7143
7144 let result = bash
7145 .exec_streaming(
7146 "i=0; while [ $i -lt 3 ]; do i=$((i+1)); echo $i; done",
7147 Box::new(move |stdout, _stderr| {
7148 chunks_cb.lock().unwrap().push(stdout.to_string());
7149 }),
7150 )
7151 .await
7152 .unwrap();
7153
7154 assert_eq!(result.stdout, "1\n2\n3\n");
7155 let chunks = chunks.lock().unwrap();
7156 assert!(
7158 chunks.contains(&"1\n".to_string()),
7159 "should contain first iteration output"
7160 );
7161 assert!(
7162 chunks.contains(&"2\n".to_string()),
7163 "should contain second iteration output"
7164 );
7165 assert!(
7166 chunks.contains(&"3\n".to_string()),
7167 "should contain third iteration output"
7168 );
7169 }
7170
7171 #[tokio::test]
7172 async fn test_exec_streaming_no_callback_still_works() {
7173 let mut bash = Bash::new();
7175 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
7176 assert_eq!(result.stdout, "a\nb\nc\n");
7177 }
7178
7179 #[tokio::test]
7180 async fn test_exec_streaming_cancel_clears_callback() {
7181 use std::time::Duration;
7182
7183 let chunks = Arc::new(Mutex::new(Vec::new()));
7184 let chunks_cb = chunks.clone();
7185 let mut bash = Bash::new();
7186
7187 let timed_out = tokio::time::timeout(
7188 Duration::from_millis(10),
7189 bash.exec_streaming(
7190 "sleep 1; echo should-not-run",
7191 Box::new(move |stdout, stderr| {
7192 chunks_cb
7193 .lock()
7194 .unwrap()
7195 .push((stdout.to_string(), stderr.to_string()));
7196 }),
7197 ),
7198 )
7199 .await;
7200
7201 assert!(timed_out.is_err(), "streaming execution should time out");
7202
7203 let result = bash.exec("echo later-run").await.unwrap();
7204
7205 assert_eq!(result.stdout, "later-run\n");
7206 assert_eq!(
7207 *chunks.lock().unwrap(),
7208 Vec::<(String, String)>::new(),
7209 "cancelled streaming callback must not receive later output"
7210 );
7211 }
7212
7213 #[tokio::test]
7214 async fn test_exec_streaming_nested_loops_no_duplicates() {
7215 let chunks = Arc::new(Mutex::new(Vec::new()));
7216 let chunks_cb = chunks.clone();
7217 let mut bash = Bash::new();
7218
7219 let result = bash
7220 .exec_streaming(
7221 "for i in 1 2; do for j in a b; do echo \"$i$j\"; done; done",
7222 Box::new(move |stdout, _stderr| {
7223 chunks_cb.lock().unwrap().push(stdout.to_string());
7224 }),
7225 )
7226 .await
7227 .unwrap();
7228
7229 assert_eq!(result.stdout, "1a\n1b\n2a\n2b\n");
7230 let chunks = chunks.lock().unwrap();
7231 let total_chars: usize = chunks.iter().map(|c| c.len()).sum();
7233 assert_eq!(
7234 total_chars,
7235 result.stdout.len(),
7236 "total streamed bytes should match final output: chunks={:?}",
7237 *chunks
7238 );
7239 }
7240
7241 #[tokio::test]
7242 async fn test_exec_streaming_mixed_list_and_loop() {
7243 let chunks = Arc::new(Mutex::new(Vec::new()));
7244 let chunks_cb = chunks.clone();
7245 let mut bash = Bash::new();
7246
7247 let result = bash
7248 .exec_streaming(
7249 "echo start; for i in 1 2; do echo $i; done; echo end",
7250 Box::new(move |stdout, _stderr| {
7251 chunks_cb.lock().unwrap().push(stdout.to_string());
7252 }),
7253 )
7254 .await
7255 .unwrap();
7256
7257 assert_eq!(result.stdout, "start\n1\n2\nend\n");
7258 let chunks = chunks.lock().unwrap();
7259 assert_eq!(
7260 *chunks,
7261 vec!["start\n", "1\n", "2\n", "end\n"],
7262 "mixed list+loop should produce exactly 4 events"
7263 );
7264 }
7265
7266 #[tokio::test]
7267 async fn test_exec_streaming_stderr() {
7268 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
7269 let stderr_cb = stderr_chunks.clone();
7270 let mut bash = Bash::new();
7271
7272 let result = bash
7273 .exec_streaming(
7274 "echo ok; echo err >&2; echo ok2",
7275 Box::new(move |_stdout, stderr| {
7276 if !stderr.is_empty() {
7277 stderr_cb.lock().unwrap().push(stderr.to_string());
7278 }
7279 }),
7280 )
7281 .await
7282 .unwrap();
7283
7284 assert_eq!(result.stdout, "ok\nok2\n");
7285 assert_eq!(result.stderr, "err\n");
7286 let stderr_chunks = stderr_chunks.lock().unwrap();
7287 assert!(
7288 stderr_chunks.contains(&"err\n".to_string()),
7289 "stderr should be streamed: {:?}",
7290 *stderr_chunks
7291 );
7292 }
7293
7294 async fn assert_streaming_equivalence(script: &str) {
7301 let mut bash_plain = Bash::new();
7303 let plain = bash_plain.exec(script).await.unwrap();
7304
7305 let stdout_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
7307 let stderr_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
7308 let so = stdout_chunks.clone();
7309 let se = stderr_chunks.clone();
7310 let mut bash_stream = Bash::new();
7311 let streamed = bash_stream
7312 .exec_streaming(
7313 script,
7314 Box::new(move |stdout, stderr| {
7315 if !stdout.is_empty() {
7316 so.lock().unwrap().push(stdout.to_string());
7317 }
7318 if !stderr.is_empty() {
7319 se.lock().unwrap().push(stderr.to_string());
7320 }
7321 }),
7322 )
7323 .await
7324 .unwrap();
7325
7326 assert_eq!(
7328 plain.stdout, streamed.stdout,
7329 "stdout mismatch for: {script}"
7330 );
7331 assert_eq!(
7332 plain.stderr, streamed.stderr,
7333 "stderr mismatch for: {script}"
7334 );
7335 assert_eq!(
7336 plain.exit_code, streamed.exit_code,
7337 "exit_code mismatch for: {script}"
7338 );
7339
7340 let reassembled_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
7342 assert_eq!(
7343 reassembled_stdout, streamed.stdout,
7344 "reassembled stdout chunks != final stdout for: {script}"
7345 );
7346 let reassembled_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
7347 assert_eq!(
7348 reassembled_stderr, streamed.stderr,
7349 "reassembled stderr chunks != final stderr for: {script}"
7350 );
7351 }
7352
7353 #[tokio::test]
7354 async fn test_exec_streaming_respects_stdout_stderr_limits() {
7355 let stdout_chunks = Arc::new(Mutex::new(Vec::new()));
7356 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
7357 let so = stdout_chunks.clone();
7358 let se = stderr_chunks.clone();
7359 let mut bash = Bash::builder()
7360 .limits(
7361 ExecutionLimits::new()
7362 .max_stdout_bytes(10)
7363 .max_stderr_bytes(8),
7364 )
7365 .build();
7366
7367 let result = bash
7368 .exec_streaming(
7369 "echo hello; echo world; echo err1 >&2; echo err2 >&2",
7370 Box::new(move |stdout, stderr| {
7371 if !stdout.is_empty() {
7372 so.lock().unwrap().push(stdout.to_string());
7373 }
7374 if !stderr.is_empty() {
7375 se.lock().unwrap().push(stderr.to_string());
7376 }
7377 }),
7378 )
7379 .await
7380 .unwrap();
7381
7382 assert_eq!(result.stdout, "hello\nworl");
7383 assert_eq!(result.stderr, "err1\nerr");
7384 assert!(result.stdout_truncated);
7385 assert!(result.stderr_truncated);
7386 let streamed_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
7387 let streamed_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
7388 assert_eq!(streamed_stdout, result.stdout);
7389 assert_eq!(streamed_stderr, result.stderr);
7390 }
7391
7392 #[tokio::test]
7393 async fn test_streaming_equivalence_for_loop() {
7394 assert_streaming_equivalence("for i in 1 2 3; do echo $i; done").await;
7395 }
7396
7397 #[tokio::test]
7398 async fn test_streaming_equivalence_while_loop() {
7399 assert_streaming_equivalence("i=0; while [ $i -lt 4 ]; do i=$((i+1)); echo $i; done").await;
7400 }
7401
7402 #[tokio::test]
7403 async fn test_streaming_equivalence_nested_loops() {
7404 assert_streaming_equivalence("for i in a b; do for j in 1 2; do echo \"$i$j\"; done; done")
7405 .await;
7406 }
7407
7408 #[tokio::test]
7409 async fn test_streaming_equivalence_mixed_list() {
7410 assert_streaming_equivalence("echo start; for i in x y; do echo $i; done; echo end").await;
7411 }
7412
7413 #[tokio::test]
7414 async fn test_streaming_equivalence_stderr() {
7415 assert_streaming_equivalence("echo out; echo err >&2; echo out2").await;
7416 }
7417
7418 #[tokio::test]
7419 async fn test_streaming_equivalence_pipeline() {
7420 assert_streaming_equivalence("echo -e 'a\\nb\\nc' | grep b").await;
7421 }
7422
7423 #[tokio::test]
7424 async fn test_streaming_equivalence_conditionals() {
7425 assert_streaming_equivalence("if true; then echo yes; else echo no; fi; echo done").await;
7426 }
7427
7428 #[tokio::test]
7429 async fn test_streaming_equivalence_subshell() {
7430 assert_streaming_equivalence("x=$(echo hello); echo $x").await;
7431 }
7432
7433 #[tokio::test]
7434 async fn test_streaming_equivalence_command_substitution_exit_trap() {
7435 assert_streaming_equivalence("secret=$(trap 'echo TOKEN' EXIT); trap - EXIT; echo ok")
7436 .await;
7437 }
7438
7439 #[tokio::test]
7440 async fn test_max_memory_caps_string_growth() {
7441 let mut bash = Bash::builder()
7442 .max_memory(1024)
7443 .limits(
7444 ExecutionLimits::new()
7445 .max_commands(10_000)
7446 .max_loop_iterations(10_000),
7447 )
7448 .build();
7449 let result = bash
7450 .exec(r#"x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}"#)
7451 .await
7452 .unwrap();
7453 let len: usize = result.stdout.trim().parse().unwrap();
7454 assert!(len <= 1024, "string length {len} must be ≤ 1024");
7456 }
7457
7458 #[tokio::test]
7460 async fn test_stderr_redirect_devnull_streaming() {
7461 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
7462 let stderr_cb = stderr_chunks.clone();
7463 let mut bash = Bash::new();
7464
7465 let result = bash
7467 .exec_streaming(
7468 "{ ls /nonexistent; } 2>/dev/null; echo exit:$?",
7469 Box::new(move |_stdout, stderr| {
7470 if !stderr.is_empty() {
7471 stderr_cb.lock().unwrap().push(stderr.to_string());
7472 }
7473 }),
7474 )
7475 .await
7476 .unwrap();
7477
7478 assert_eq!(result.stderr, "", "final stderr should be empty");
7479 let stderr_chunks = stderr_chunks.lock().unwrap();
7480 assert!(
7481 stderr_chunks.is_empty(),
7482 "no stderr should be streamed when 2>/dev/null is used, got: {:?}",
7483 *stderr_chunks
7484 );
7485 }
7486
7487 #[tokio::test]
7488 async fn test_dot_slash_prefix_ls() {
7489 let mut bash = Bash::new();
7491 bash.exec("mkdir -p /tmp/blogtest && cd /tmp/blogtest && echo hello > tag_hello.html")
7492 .await
7493 .unwrap();
7494
7495 let result = bash
7497 .exec("cd /tmp/blogtest && ls tag_hello.html")
7498 .await
7499 .unwrap();
7500 assert_eq!(
7501 result.exit_code, 0,
7502 "ls tag_hello.html should succeed: {}",
7503 result.stderr
7504 );
7505 assert!(result.stdout.contains("tag_hello.html"));
7506
7507 let result = bash
7509 .exec("cd /tmp/blogtest && ls ./tag_hello.html")
7510 .await
7511 .unwrap();
7512 assert_eq!(
7513 result.exit_code, 0,
7514 "ls ./tag_hello.html should succeed: {}",
7515 result.stderr
7516 );
7517 assert!(result.stdout.contains("tag_hello.html"));
7518 }
7519
7520 #[tokio::test]
7521 async fn test_dot_slash_prefix_glob() {
7522 let mut bash = Bash::new();
7524 bash.exec("mkdir -p /tmp/globtest && cd /tmp/globtest && echo hello > tag_hello.html")
7525 .await
7526 .unwrap();
7527
7528 let result = bash.exec("cd /tmp/globtest && echo *.html").await.unwrap();
7530 assert_eq!(
7531 result.exit_code, 0,
7532 "echo *.html should succeed: {}",
7533 result.stderr
7534 );
7535 assert!(result.stdout.contains("tag_hello.html"));
7536
7537 let result = bash
7539 .exec("cd /tmp/globtest && echo ./*.html")
7540 .await
7541 .unwrap();
7542 assert_eq!(
7543 result.exit_code, 0,
7544 "echo ./*.html should succeed: {}",
7545 result.stderr
7546 );
7547 assert!(result.stdout.contains("tag_hello.html"));
7548 }
7549
7550 #[tokio::test]
7551 async fn test_dot_slash_prefix_cat() {
7552 let mut bash = Bash::new();
7554 bash.exec("mkdir -p /tmp/cattest && cd /tmp/cattest && echo content123 > myfile.txt")
7555 .await
7556 .unwrap();
7557
7558 let result = bash
7559 .exec("cd /tmp/cattest && cat ./myfile.txt")
7560 .await
7561 .unwrap();
7562 assert_eq!(
7563 result.exit_code, 0,
7564 "cat ./myfile.txt should succeed: {}",
7565 result.stderr
7566 );
7567 assert!(result.stdout.contains("content123"));
7568 }
7569
7570 #[tokio::test]
7571 async fn test_dot_slash_prefix_redirect() {
7572 let mut bash = Bash::new();
7574 bash.exec("mkdir -p /tmp/redirtest && cd /tmp/redirtest")
7575 .await
7576 .unwrap();
7577
7578 let result = bash
7579 .exec("cd /tmp/redirtest && echo hello > ./output.txt && cat ./output.txt")
7580 .await
7581 .unwrap();
7582 assert_eq!(
7583 result.exit_code, 0,
7584 "redirect to ./output.txt should succeed: {}",
7585 result.stderr
7586 );
7587 assert!(result.stdout.contains("hello"));
7588 }
7589
7590 #[tokio::test]
7591 async fn test_dot_slash_prefix_test_builtin() {
7592 let mut bash = Bash::new();
7594 bash.exec("mkdir -p /tmp/testbuiltin && cd /tmp/testbuiltin && echo x > myfile.txt")
7595 .await
7596 .unwrap();
7597
7598 let result = bash
7599 .exec("cd /tmp/testbuiltin && test -f ./myfile.txt && echo yes")
7600 .await
7601 .unwrap();
7602 assert_eq!(
7603 result.exit_code, 0,
7604 "test -f ./myfile.txt should succeed: {}",
7605 result.stderr
7606 );
7607 assert!(result.stdout.contains("yes"));
7608 }
7609
7610 #[tokio::test]
7613 async fn test_before_exec_hook_modifies_script() {
7614 use std::sync::Arc;
7615 use std::sync::atomic::{AtomicBool, Ordering};
7616
7617 let called = Arc::new(AtomicBool::new(false));
7618 let called_clone = called.clone();
7619
7620 let mut bash = Bash::builder()
7621 .before_exec(Box::new(move |mut input| {
7622 called_clone.store(true, Ordering::Relaxed);
7623 input.script = "echo intercepted".to_string();
7625 hooks::HookAction::Continue(input)
7626 }))
7627 .build();
7628
7629 let result = bash.exec("echo original").await.unwrap();
7630 assert!(called.load(Ordering::Relaxed));
7631 assert_eq!(result.stdout.trim(), "intercepted");
7632 }
7633
7634 #[tokio::test]
7635 async fn test_before_exec_hook_cancels() {
7636 let mut bash = Bash::builder()
7637 .before_exec(Box::new(|_input| {
7638 hooks::HookAction::Cancel("blocked".to_string())
7639 }))
7640 .build();
7641
7642 let result = bash.exec("echo should-not-run").await.unwrap();
7643 assert_eq!(result.exit_code, 1);
7644 assert!(result.stdout.is_empty());
7645 }
7646
7647 #[tokio::test]
7648 async fn test_input_size_limit_rejects_before_before_exec_hook() {
7649 use std::sync::Arc;
7650 use std::sync::atomic::{AtomicBool, Ordering};
7651
7652 let called = Arc::new(AtomicBool::new(false));
7653 let called_clone = called.clone();
7654
7655 let limits = ExecutionLimits::new().max_input_bytes(8);
7656 let mut bash = Bash::builder()
7657 .limits(limits)
7658 .before_exec(Box::new(move |_input| {
7659 called_clone.store(true, Ordering::Relaxed);
7660 unreachable!("before_exec hook must not run for oversized input");
7661 }))
7662 .build();
7663
7664 let result = bash.exec("echo way-too-long").await;
7665 assert!(result.is_err());
7666 assert!(!called.load(Ordering::Relaxed));
7667 }
7668
7669 #[tokio::test]
7670 async fn test_after_exec_hook_observes_output() {
7671 use std::sync::{Arc, Mutex};
7672
7673 let captured = Arc::new(Mutex::new(String::new()));
7674 let captured_clone = captured.clone();
7675
7676 let mut bash = Bash::builder()
7677 .after_exec(Box::new(move |output| {
7678 *captured_clone.lock().unwrap() = output.stdout.clone();
7679 hooks::HookAction::Continue(output)
7680 }))
7681 .build();
7682
7683 bash.exec("echo hello-hooks").await.unwrap();
7684 assert_eq!(captured.lock().unwrap().trim(), "hello-hooks");
7685 }
7686
7687 #[tokio::test]
7688 async fn test_after_exec_hook_can_modify_output() {
7689 let mut bash = Bash::builder()
7690 .after_exec(Box::new(|mut output| {
7691 output.stdout = output.stdout.replace("SECRET", "[redacted]");
7692 output.stderr = "policy stderr\n".to_string();
7693 output.exit_code = 7;
7694 hooks::HookAction::Continue(output)
7695 }))
7696 .build();
7697
7698 let result = bash.exec("echo SECRET").await.unwrap();
7699 assert_eq!(result.stdout, "[redacted]\n");
7700 assert_eq!(result.stderr, "policy stderr\n");
7701 assert_eq!(result.exit_code, 7);
7702 }
7703
7704 #[tokio::test]
7705 async fn test_after_exec_hook_can_cancel_result() {
7706 let mut bash = Bash::builder()
7707 .after_exec(Box::new(|_output| {
7708 hooks::HookAction::Cancel("blocked".to_string())
7709 }))
7710 .build();
7711
7712 let result = bash.exec("echo SECRET").await.unwrap();
7713 assert_eq!(result.stdout, "");
7714 assert_eq!(result.stderr, "cancelled by after_exec hook");
7715 assert_eq!(result.exit_code, 1);
7716 }
7717
7718 #[tokio::test]
7719 async fn test_before_tool_hook_can_cancel_special_builtin() {
7720 let mut bash = Bash::builder()
7721 .before_tool(Box::new(|event| {
7722 if event.name == "source" {
7723 hooks::HookAction::Cancel("source blocked".to_string())
7724 } else {
7725 hooks::HookAction::Continue(event)
7726 }
7727 }))
7728 .build();
7729
7730 let result = bash.exec("source missing.sh").await.unwrap();
7731 assert_eq!(result.exit_code, 1);
7732 assert!(result.stderr.contains("cancelled by before_tool hook"));
7733 }
7734
7735 #[tokio::test]
7736 async fn test_after_tool_hook_can_modify_builtin_result() {
7737 let mut bash = Bash::builder()
7738 .after_tool(Box::new(|mut result| {
7739 if result.name == "echo" {
7740 result.stdout = result.stdout.replace("SECRET", "[redacted]");
7741 result.exit_code = 9;
7742 }
7743 hooks::HookAction::Continue(result)
7744 }))
7745 .build();
7746
7747 let result = bash.exec("echo SECRET").await.unwrap();
7748 assert_eq!(result.stdout, "[redacted]\n");
7749 assert_eq!(result.exit_code, 9);
7750 }
7751
7752 #[tokio::test]
7753 async fn test_after_tool_hook_can_cancel_builtin_result() {
7754 let mut bash = Bash::builder()
7755 .after_tool(Box::new(|result| {
7756 if result.name == "echo" {
7757 hooks::HookAction::Cancel("blocked".to_string())
7758 } else {
7759 hooks::HookAction::Continue(result)
7760 }
7761 }))
7762 .build();
7763
7764 let result = bash.exec("echo SECRET").await.unwrap();
7765 assert_eq!(result.stdout, "");
7766 assert!(result.stderr.contains("cancelled by after_tool hook"));
7767 assert_eq!(result.exit_code, 1);
7768 }
7769
7770 #[tokio::test]
7771 async fn test_multiple_hooks_chain() {
7772 let mut bash = Bash::builder()
7773 .before_exec(Box::new(|mut input| {
7774 input.script = input.script.replace("world", "hooks");
7775 hooks::HookAction::Continue(input)
7776 }))
7777 .before_exec(Box::new(|mut input| {
7778 input.script = input.script.replace("hello", "greetings");
7779 hooks::HookAction::Continue(input)
7780 }))
7781 .build();
7782
7783 let result = bash.exec("echo hello world").await.unwrap();
7784 assert_eq!(result.stdout.trim(), "greetings hooks");
7785 }
7786
7787 #[tokio::test]
7788 async fn test_on_exit_hook_not_fired_for_path_script_exit() {
7789 use std::path::Path;
7790 use std::sync::Arc;
7791 use std::sync::atomic::{AtomicU32, Ordering};
7792
7793 let count = Arc::new(AtomicU32::new(0));
7794 let count_clone = count.clone();
7795
7796 let mut bash = Bash::builder()
7797 .on_exit(Box::new(move |event| {
7798 count_clone.fetch_add(1, Ordering::Relaxed);
7799 hooks::HookAction::Continue(event)
7800 }))
7801 .build();
7802
7803 let fs = bash.fs();
7804 fs.mkdir(Path::new("/bin"), false).await.unwrap();
7805 fs.write_file(Path::new("/bin/child-exit"), b"#!/usr/bin/env bash\nexit 7")
7806 .await
7807 .unwrap();
7808 fs.chmod(Path::new("/bin/child-exit"), 0o755).await.unwrap();
7809
7810 let result = bash
7811 .exec("PATH=/bin:$PATH\nchild-exit\necho after:$?")
7812 .await
7813 .unwrap();
7814
7815 assert_eq!(result.stdout.trim(), "after:7");
7816 assert_eq!(count.load(Ordering::Relaxed), 0);
7817 }
7818
7819 #[tokio::test]
7820 async fn test_on_exit_hook_not_fired_for_direct_script_exit() {
7821 use std::path::Path;
7822 use std::sync::Arc;
7823 use std::sync::atomic::{AtomicU32, Ordering};
7824
7825 let count = Arc::new(AtomicU32::new(0));
7826 let count_clone = count.clone();
7827
7828 let mut bash = Bash::builder()
7829 .on_exit(Box::new(move |event| {
7830 count_clone.fetch_add(1, Ordering::Relaxed);
7831 hooks::HookAction::Continue(event)
7832 }))
7833 .build();
7834
7835 let fs = bash.fs();
7836 fs.write_file(
7837 Path::new("/tmp/child-exit.sh"),
7838 b"#!/usr/bin/env bash\nexit 8",
7839 )
7840 .await
7841 .unwrap();
7842 fs.chmod(Path::new("/tmp/child-exit.sh"), 0o755)
7843 .await
7844 .unwrap();
7845
7846 let result = bash
7847 .exec("/tmp/child-exit.sh\necho after:$?")
7848 .await
7849 .unwrap();
7850
7851 assert_eq!(result.stdout.trim(), "after:8");
7852 assert_eq!(count.load(Ordering::Relaxed), 0);
7853 }
7854
7855 #[tokio::test]
7856 async fn test_on_exit_hook_not_fired_for_nested_bash_exit() {
7857 use std::sync::Arc;
7858 use std::sync::atomic::{AtomicU32, Ordering};
7859
7860 let count = Arc::new(AtomicU32::new(0));
7861 let count_clone = count.clone();
7862
7863 let mut bash = Bash::builder()
7864 .on_exit(Box::new(move |event| {
7865 count_clone.fetch_add(1, Ordering::Relaxed);
7866 hooks::HookAction::Continue(event)
7867 }))
7868 .build();
7869
7870 let result = bash.exec("bash -c 'exit 9'\necho after:$?").await.unwrap();
7871
7872 assert_eq!(result.stdout.trim(), "after:9");
7873 assert_eq!(count.load(Ordering::Relaxed), 0);
7874 }
7875
7876 #[tokio::test]
7877 async fn test_path_script_exit_runs_child_exit_trap() {
7878 use std::path::Path;
7879
7880 let mut bash = Bash::new();
7881 let fs = bash.fs();
7882 fs.write_file(
7883 Path::new("/tmp/child-trap.sh"),
7884 b"#!/usr/bin/env bash\ntrap 'echo child-trap' EXIT\nexit 4",
7885 )
7886 .await
7887 .unwrap();
7888 fs.chmod(Path::new("/tmp/child-trap.sh"), 0o755)
7889 .await
7890 .unwrap();
7891
7892 let result = bash
7893 .exec("/tmp/child-trap.sh\necho after:$?")
7894 .await
7895 .unwrap();
7896
7897 assert_eq!(result.stdout.trim(), "child-trap\nafter:4");
7898 }
7899
7900 #[tokio::test]
7901 async fn test_on_exit_hook_still_fires_for_source_exit() {
7902 use std::path::Path;
7903 use std::sync::Arc;
7904 use std::sync::atomic::{AtomicU32, Ordering};
7905
7906 let count = Arc::new(AtomicU32::new(0));
7907 let count_clone = count.clone();
7908
7909 let mut bash = Bash::builder()
7910 .on_exit(Box::new(move |event| {
7911 count_clone.fetch_add(1, Ordering::Relaxed);
7912 hooks::HookAction::Continue(event)
7913 }))
7914 .build();
7915
7916 let fs = bash.fs();
7917 fs.write_file(Path::new("/tmp/source-exit.sh"), b"exit 5")
7918 .await
7919 .unwrap();
7920
7921 let result = bash.exec("source /tmp/source-exit.sh").await.unwrap();
7922
7923 assert_eq!(result.exit_code, 5);
7924 assert_eq!(count.load(Ordering::Relaxed), 1);
7925 }
7926
7927 #[tokio::test]
7928 async fn test_on_exit_hook_cancel_prevents_exit() {
7929 let mut bash = Bash::builder()
7930 .on_exit(Box::new(|_event| {
7931 hooks::HookAction::Cancel("blocked by policy".to_string())
7932 }))
7933 .build();
7934
7935 let result = bash.exec("echo before\nexit 5\necho after").await.unwrap();
7936 assert_eq!(result.stdout.trim(), "before\nafter");
7937 assert_eq!(result.exit_code, 0);
7938 }
7939
7940 #[tokio::test]
7941 async fn test_on_exit_hook_can_modify_exit_code() {
7942 let mut bash = Bash::builder()
7943 .on_exit(Box::new(|mut event| {
7944 event.code = 17;
7945 hooks::HookAction::Continue(event)
7946 }))
7947 .build();
7948
7949 let result = bash.exec("exit 5").await.unwrap();
7950 assert_eq!(result.exit_code, 17);
7951 }
7952
7953 #[tokio::test]
7954 async fn test_bash_versinfo_reports_bash_compatible_major() {
7955 let mut bash = Bash::new();
7956
7957 let result = bash
7958 .exec(r#"[[ ${BASH_VERSINFO[0]} -ge 4 ]] && echo bash4plus"#)
7959 .await
7960 .unwrap();
7961
7962 assert_eq!(result.stdout.trim(), "bash4plus");
7963 }
7964
7965 #[tokio::test]
7966 async fn test_bash_version_surface_matches_bash_compatible_tuple() {
7967 let mut bash = Bash::new();
7968
7969 let result = bash
7970 .exec(
7971 r#"printf '%s\n' "$BASH_VERSION" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}" "${BASH_VERSINFO[2]}" "${BASH_VERSINFO[3]}" "${BASH_VERSINFO[4]}" "${BASH_VERSINFO[5]}""#,
7972 )
7973 .await
7974 .unwrap();
7975
7976 assert_eq!(
7977 result.stdout,
7978 "5.2.15(1)-release\n5\n2\n15\n1\nrelease\nvirtual\n"
7979 );
7980 }
7981
7982 #[tokio::test]
7983 async fn test_path_script_retains_bash_versinfo_array() {
7984 use std::path::Path;
7985
7986 let mut bash = Bash::new();
7987 let fs = bash.fs();
7988 fs.write_file(
7989 Path::new("/tmp/bash-version-check.sh"),
7990 b"#!/usr/bin/env bash\nprintf '%s\\n' \"${BASH_VERSINFO[0]}\"",
7991 )
7992 .await
7993 .unwrap();
7994 fs.chmod(Path::new("/tmp/bash-version-check.sh"), 0o755)
7995 .await
7996 .unwrap();
7997
7998 let result = bash.exec("/tmp/bash-version-check.sh").await.unwrap();
7999
8000 assert_eq!(result.stdout.trim(), "5");
8001 }
8002
8003 #[tokio::test]
8004 async fn test_path_script_bash_versinfo_satisfies_bash4_guard() {
8005 use std::path::Path;
8006
8007 let mut bash = Bash::new();
8008 let fs = bash.fs();
8009 fs.write_file(
8010 Path::new("/tmp/bash-version-guard.sh"),
8011 b"#!/usr/bin/env bash\nif (( BASH_VERSINFO[0] < 4 )); then echo too-old; else echo ok; fi",
8012 )
8013 .await
8014 .unwrap();
8015 fs.chmod(Path::new("/tmp/bash-version-guard.sh"), 0o755)
8016 .await
8017 .unwrap();
8018
8019 let result = bash.exec("/tmp/bash-version-guard.sh").await.unwrap();
8020
8021 assert_eq!(result.stdout.trim(), "ok");
8022 }
8023
8024 #[tokio::test]
8025 async fn test_before_tool_hook_modifies_args() {
8026 use std::sync::Arc;
8027 use std::sync::atomic::{AtomicBool, Ordering};
8028
8029 let called = Arc::new(AtomicBool::new(false));
8030 let called_clone = called.clone();
8031
8032 let mut bash = Bash::builder()
8033 .before_tool(Box::new(move |mut event| {
8034 called_clone.store(true, Ordering::Relaxed);
8035 if !event.args.is_empty() {
8037 event.args = vec!["intercepted".to_string()];
8038 }
8039 hooks::HookAction::Continue(event)
8040 }))
8041 .build();
8042
8043 let result = bash.exec("echo original").await.unwrap();
8044 assert!(called.load(Ordering::Relaxed));
8045 assert_eq!(result.stdout.trim(), "intercepted");
8046 }
8047
8048 #[tokio::test]
8049 async fn test_before_tool_hook_cancels() {
8050 let mut bash = Bash::builder()
8051 .before_tool(Box::new(|event| {
8052 if event.name == "echo" {
8053 hooks::HookAction::Cancel("echo blocked".to_string())
8054 } else {
8055 hooks::HookAction::Continue(event)
8056 }
8057 }))
8058 .build();
8059
8060 let result = bash.exec("echo should-not-run").await.unwrap();
8061 assert_eq!(result.exit_code, 1);
8062 assert!(result.stderr.contains("cancelled by before_tool hook"));
8063 }
8064
8065 #[tokio::test]
8066 async fn test_after_tool_hook_observes_result() {
8067 use std::sync::{Arc, Mutex};
8068
8069 let captured = Arc::new(Mutex::new(Vec::new()));
8070 let captured_clone = captured.clone();
8071
8072 let mut bash = Bash::builder()
8073 .after_tool(Box::new(move |result| {
8074 captured_clone.lock().unwrap().push((
8075 result.name.clone(),
8076 result.stdout.clone(),
8077 result.exit_code,
8078 ));
8079 hooks::HookAction::Continue(result)
8080 }))
8081 .build();
8082
8083 bash.exec("echo hello-tool").await.unwrap();
8084 let results = captured.lock().unwrap();
8085 assert!(!results.is_empty());
8086 assert_eq!(results[0].0, "echo");
8087 assert!(results[0].1.contains("hello-tool"));
8088 assert_eq!(results[0].2, 0);
8089 }
8090
8091 #[tokio::test]
8092 async fn test_before_tool_hook_fires_for_special_and_registered_builtins() {
8093 use std::sync::Arc;
8096 use std::sync::atomic::{AtomicU32, Ordering};
8097
8098 let count = Arc::new(AtomicU32::new(0));
8099 let count_clone = count.clone();
8100
8101 let mut bash = Bash::builder()
8102 .before_tool(Box::new(move |event| {
8103 count_clone.fetch_add(1, Ordering::Relaxed);
8104 hooks::HookAction::Continue(event)
8105 }))
8106 .build();
8107
8108 bash.exec("declare x=1").await.unwrap();
8110 assert_eq!(count.load(Ordering::Relaxed), 1);
8111
8112 bash.exec("echo hi").await.unwrap();
8114 assert_eq!(count.load(Ordering::Relaxed), 2);
8115 }
8116
8117 #[cfg(feature = "http_client")]
8118 #[tokio::test]
8119 async fn test_before_http_hook_cancels_request() {
8120 use crate::NetworkAllowlist;
8121
8122 let mut bash = Bash::builder()
8123 .network(NetworkAllowlist::allow_all())
8124 .before_http(Box::new(|req| {
8125 if req.url.contains("blocked.example.com") {
8126 hooks::HookAction::Cancel("blocked by policy".to_string())
8127 } else {
8128 hooks::HookAction::Continue(req)
8129 }
8130 }))
8131 .build();
8132
8133 let result = bash
8135 .exec("curl -s https://blocked.example.com/data")
8136 .await
8137 .unwrap();
8138 assert_ne!(result.exit_code, 0);
8139 assert!(result.stderr.contains("cancelled by before_http hook"));
8140 }
8141
8142 #[cfg(feature = "http_client")]
8143 #[tokio::test]
8144 async fn test_after_http_hook_observes_response() {
8145 use std::sync::{Arc, Mutex};
8146
8147 use crate::NetworkAllowlist;
8148
8149 let captured = Arc::new(Mutex::new(Vec::new()));
8150 let captured_clone = captured.clone();
8151
8152 let mut bash = Bash::builder()
8153 .network(NetworkAllowlist::allow_all())
8154 .after_http(Box::new(move |event| {
8155 captured_clone
8156 .lock()
8157 .unwrap()
8158 .push((event.url.clone(), event.status));
8159 hooks::HookAction::Continue(event)
8160 }))
8161 .build();
8162
8163 let _result = bash.exec("curl -s https://httpbin.org/get").await;
8167 }
8170}