1#![warn(clippy::unwrap_used)]
421#![cfg_attr(test, allow(clippy::unwrap_used))]
422
423#[cfg(all(
427 feature = "http_client",
428 not(any(feature = "ring", feature = "aws-lc-rs"))
429))]
430compile_error!(
431 "bashkit's `http_client` needs a crypto backend: enable `ring` (default) or `aws-lc-rs`"
432);
433
434pub mod analysis;
436mod builtins;
437#[cfg(feature = "http_client")]
438mod credential;
439mod error;
440mod execution_capability;
441mod fs;
442pub mod hooks;
444mod host_call;
445#[cfg(feature = "interop")]
446pub mod interop;
447mod interpreter;
448mod limits;
449#[cfg(feature = "logging")]
450mod logging_impl;
451mod network;
452pub mod parser;
454mod profile;
455#[cfg(feature = "scripted_tool")]
458pub mod scripted_tool;
459mod snapshot;
460mod stream;
461#[doc(hidden)]
466pub mod testing;
467mod time_compat;
468#[cfg(feature = "bash_tool")]
471pub mod tool;
472#[cfg(feature = "scripted_tool")]
474pub(crate) mod tool_def;
475#[cfg(feature = "scripted_tool")]
476mod tool_registry;
477pub mod trace;
479pub use stream::StreamData;
480
481pub use analysis::{
482 AnalyzedCommand, AnalyzedRedirect, CommandContext, RedirectMode, ScriptAnalysis,
483};
484pub use async_trait::async_trait;
485pub use builtins::git::GitConfig;
486pub use builtins::ssh::{SshAllowlist, SshConfig, TrustedHostKey};
487pub use builtins::{
488 BashkitContext, Builtin, BuiltinRegistry, ClapBuiltin, CommandResolver,
489 Context as BuiltinContext, Extension,
490};
491pub use clap;
492#[cfg(feature = "http_client")]
493pub use credential::Credential;
494pub use error::{Error, Result};
495pub use execution_capability::{
496 CapabilityCleanupReport, ExecutionCapability, ExecutionCapabilityError, ExecutionExtensions,
497};
498pub use fs::{
499 DirEntry, FileSystem, FileSystemExt, FileType, FsBackend, FsLimitExceeded, FsLimits, FsUsage,
500 InMemoryFs, LazyLoader, Metadata, MountableFs, NamespaceAccess, NamespaceFs,
501 NamespaceFsBuilder, OverlayFs, PosixFs, ReadOnlyFs, SearchCapabilities, SearchCapable,
502 SearchMatch, SearchProvider, SearchQuery, SearchResults, VfsEntry, VfsEntryKind, VfsSnapshot,
503 normalize_path, verify_filesystem_requirements,
504};
505#[cfg(feature = "realfs")]
506pub use fs::{RealFs, RealFsMode};
507pub use host_call::{ExecutionEvent, ExecutionHandle, HostCallId, HostCallRequest};
508pub use interpreter::{
509 ControlFlow, ExecResult, HistoryEntry, OutputCallback, ShellState, ShellStateView,
510};
511pub use limits::{
512 ExecutionBudget, ExecutionBudgetExceeded, ExecutionBudgetLease, ExecutionCounters,
513 ExecutionLimits, LimitExceeded, MemoryBudget, MemoryLimits, SessionLimits,
514};
515#[cfg(feature = "http_client")]
516pub use network::HttpLimits;
517pub use network::NetworkAllowlist;
518pub use profile::{
519 ExecutionProfile, ExecutionProfileBuilder, ExecutionProfileError, ExecutionProfileName,
520 ProfileNetworkPolicy,
521};
522pub use snapshot::{
523 CapabilityDelta, CapabilityFingerprint, CheckoutPolicy, CommitId, CommitObject, CommitOptions,
524 ObjectId, ObjectSource, PackedCommit, Snapshot, SnapshotDiff, SnapshotGraph, SnapshotOptions,
525};
526#[cfg(feature = "bash_tool")]
527pub use tool::BashToolBuilder as ToolBuilder;
528#[cfg(feature = "bash_tool")]
529pub use tool::{
530 BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput,
531 ToolOutputChunk, ToolOutputMetadata, ToolRequest, ToolResponse, ToolService, ToolStatus,
532 VERSION,
533};
534pub use trace::{
535 TraceCallback, TraceCollector, TraceEvent, TraceEventDetails, TraceEventKind, TraceMode,
536};
537
538#[cfg(feature = "scripted_tool")]
539pub use scripted_tool::{
540 AsyncToolCallback, CallbackKind, DiscoverTool, DiscoveryMode, ScriptedCommandInvocation,
541 ScriptedCommandKind, ScriptedExecutionTrace, ScriptedTool, ScriptedToolBuilder,
542 ScriptingToolSet, ScriptingToolSetBuilder, ToolArgs, ToolCallback, ToolDef, ToolDefExtension,
543 ToolDefExtensionBuilder, ToolDefInvocationTrace,
544};
545#[cfg(feature = "scripted_tool")]
546pub use tool_def::{AsyncToolExec, SyncToolExec, ToolImpl};
547#[cfg(feature = "scripted_tool")]
548pub use tool_registry::{
549 ToolCall, ToolCallDecision, ToolCallRequest, ToolCallSurface, ToolRegistry, ToolRegistryBuilder,
550};
551
552#[cfg(feature = "http_client")]
553pub use network::HttpClient;
554
555#[cfg(feature = "http_client")]
556pub use network::{HttpTransport, HttpTransportError, HttpTransportRequest};
557
558#[cfg(feature = "http_client")]
560pub use network::Method as HttpMethod;
561
562#[cfg(feature = "http_client")]
564pub use network::Response as HttpResponse;
565
566#[cfg(feature = "bot-auth")]
567pub use network::{BotAuthConfig, BotAuthError, BotAuthPublicKey, derive_bot_auth_public_key};
568
569#[cfg(feature = "git")]
570pub use builtins::git::GitClient;
571
572#[cfg(feature = "ssh")]
573pub use builtins::ssh::{SshClient, SshHandler, SshOutput, SshTarget};
574
575#[cfg(feature = "python")]
576pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits};
577
578#[cfg(any(feature = "python", feature = "typescript"))]
580pub use builtins::RuntimeLimits;
581
582#[cfg(feature = "sqlite")]
583pub use builtins::{Sqlite, SqliteBackend, SqliteLimits};
584#[cfg(feature = "python")]
588pub use monty_types::{ExcType, ExtFunctionResult, MontyException, MontyObject};
589
590#[cfg(feature = "typescript")]
591pub use builtins::{
592 TypeScriptConfig, TypeScriptExtension, TypeScriptExternalFnHandler, TypeScriptExternalFns,
593 TypeScriptLimits,
594};
595#[cfg(feature = "typescript")]
597pub use zapcode_core::Value as ZapcodeValue;
598
599#[cfg(feature = "logging")]
604pub mod logging {
605 pub use crate::logging_impl::{
606 LogConfig, format_error_for_log, format_script_for_log, sanitize_for_log,
607 };
608}
609
610#[cfg(feature = "logging")]
611pub use logging::LogConfig;
612
613use interpreter::Interpreter;
614use parser::Parser;
615use std::collections::HashMap;
616#[cfg(feature = "realfs")]
617use std::path::Path;
618use std::path::PathBuf;
619use std::sync::Arc;
620
621#[cfg(any(feature = "python", feature = "sqlite"))]
622fn env_opt_in_enabled(env: &HashMap<String, String>, key: &str) -> bool {
623 env.get(key)
624 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
625}
626
627struct OutputCallbackGuard {
630 interpreter: *mut Interpreter,
631}
632
633unsafe impl Send for OutputCallbackGuard {}
637
638impl OutputCallbackGuard {
639 fn install(interpreter: &mut Interpreter, callback: OutputCallback) -> Self {
640 interpreter.set_output_callback(callback);
641 Self { interpreter }
642 }
643}
644
645impl Drop for OutputCallbackGuard {
646 fn drop(&mut self) {
647 unsafe { (*self.interpreter).clear_output_callback() };
652 }
653}
654
655#[derive(Default)]
689pub struct ExecOptions {
690 extensions: ExecutionExtensions,
691 output_callback: Option<OutputCallback>,
692 arg0: Option<String>,
693 positional: Option<Vec<String>>,
694 stdin: Option<StreamData>,
695}
696
697impl ExecOptions {
698 pub fn new() -> Self {
700 Self::default()
701 }
702
703 pub fn streaming(mut self, callback: OutputCallback) -> Self {
706 self.output_callback = Some(callback);
707 self
708 }
709
710 pub fn extensions(mut self, extensions: ExecutionExtensions) -> Self {
713 self.extensions = extensions;
714 self
715 }
716
717 pub fn arg0(mut self, arg0: impl Into<String>) -> Self {
736 self.arg0 = Some(arg0.into());
737 self
738 }
739
740 pub fn positional<I, S>(mut self, positional: I) -> Self
744 where
745 I: IntoIterator<Item = S>,
746 S: Into<String>,
747 {
748 self.positional = Some(positional.into_iter().map(Into::into).collect());
749 self
750 }
751
752 pub fn stdin(mut self, stdin: impl Into<StreamData>) -> Self {
771 self.stdin = Some(stdin.into());
772 self
773 }
774}
775
776#[derive(Default)]
784struct Invocation {
785 arg0: Option<String>,
786 positional: Option<Vec<String>>,
787 stdin: Option<StreamData>,
788}
789
790impl Invocation {
791 fn is_empty(&self) -> bool {
792 self.arg0.is_none() && self.positional.is_none() && self.stdin.is_none()
793 }
794}
795
796pub struct Bash {
800 fs: Arc<dyn FileSystem>,
801 mountable: Arc<MountableFs>,
803 readonly_filesystem: bool,
805 interpreter: Interpreter,
806 parser_timeout: std::time::Duration,
808 max_input_bytes: usize,
810 max_ast_depth: usize,
812 max_parser_operations: usize,
814 #[cfg(feature = "logging")]
816 log_config: logging::LogConfig,
817 #[cfg(feature = "python")]
819 python_inprocess_opt_in: bool,
820 #[cfg(feature = "sqlite")]
822 sqlite_inprocess_opt_in: bool,
823 #[cfg(feature = "realfs")]
825 host_mounts: HostMounts,
826}
827
828impl Default for Bash {
829 fn default() -> Self {
830 Self::new()
831 }
832}
833
834fn inmem_fs_with_home(username: &str, limits: FsLimits) -> InMemoryFs {
839 let fs = InMemoryFs::with_limits(limits);
840 fs.add_dir(format!("/home/{username}"), 0o755);
841 fs
842}
843
844impl Bash {
845 pub fn new() -> Self {
847 Self::builder().build()
848 }
849
850 pub fn builder() -> BashBuilder {
852 BashBuilder::default()
853 }
854
855 pub async fn exec(&mut self, script: &str) -> Result<ExecResult> {
861 self.exec_with_options(script, ExecOptions::new()).await
862 }
863
864 pub fn start_execution(self, script: impl Into<String>) -> ExecutionHandle {
869 self.start_execution_with_options(script, ExecOptions::new())
870 }
871
872 pub fn start_execution_with_options(
877 self,
878 script: impl Into<String>,
879 options: ExecOptions,
880 ) -> ExecutionHandle {
881 ExecutionHandle::new(self, script.into(), options)
882 }
883
884 pub async fn exec_with_extensions(
888 &mut self,
889 script: &str,
890 extensions: ExecutionExtensions,
891 ) -> Result<ExecResult> {
892 self.exec_with_options(script, ExecOptions::new().extensions(extensions))
893 .await
894 }
895
896 pub async fn exec_with_options(
904 &mut self,
905 script: &str,
906 options: ExecOptions,
907 ) -> Result<ExecResult> {
908 let ExecOptions {
909 mut extensions,
910 output_callback,
911 arg0,
912 positional,
913 stdin,
914 } = options;
915 let invocation = Invocation {
916 arg0,
917 positional,
918 stdin,
919 };
920 self.interpreter.begin_execution_budget();
921 let _budget_completion = self.interpreter.execution_budget().completion_guard();
924 let active_limits = self.interpreter.limits().clone();
927 let _ = extensions.insert(active_limits.clone());
928 let _ = extensions.insert(self.interpreter.execution_budget().clone());
929 let _ = extensions.insert(builtins::ExecutionDeadline::new(active_limits.timeout));
930 #[cfg(feature = "python")]
931 let _ = extensions.insert(builtins::PythonInprocessOptIn(self.python_inprocess_opt_in));
932 #[cfg(feature = "sqlite")]
933 let _ = extensions.insert(builtins::SqliteInprocessOptIn(self.sqlite_inprocess_opt_in));
934 let execution_scope = execution_capability::ExecutionScope::new();
935 extensions.bind(execution_scope);
936 let _stream_guard =
941 output_callback.map(|cb| OutputCallbackGuard::install(&mut self.interpreter, cb));
942 let extensions_guard = self.interpreter.scoped_execution_extensions(extensions);
943 let mut result = self.exec_impl(script, invocation).await;
944 let cleanup = extensions_guard.finish();
945 if let Ok(exec_result) = &mut result {
946 exec_result.capability_cleanup = cleanup;
947 }
948 result
949 }
950
951 async fn exec_impl(&mut self, script: &str, invocation: Invocation) -> Result<ExecResult> {
952 self.interpreter.reset_transient_state();
954
955 self.interpreter.begin_exec_invocation()?;
958
959 let input_len = script.len();
962 if input_len > self.max_input_bytes {
963 #[cfg(feature = "logging")]
964 tracing::error!(
965 target: "bashkit::session",
966 input_len = input_len,
967 max_bytes = self.max_input_bytes,
968 "Script exceeds maximum input size"
969 );
970 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
971 input_len,
972 self.max_input_bytes,
973 )));
974 }
975 self.interpreter
976 .execution_budget()
977 .consume_input(input_len)?;
978
979 #[cfg(feature = "logging")]
982 {
983 let script_info = logging::format_script_for_log(script, &self.log_config);
984 tracing::info!(target: "bashkit::session", script = %script_info, "Starting script execution");
985 }
986
987 let script = if !self.interpreter.hooks().before_exec.is_empty() {
989 self.interpreter.execution_budget().consume_work(100)?;
990 let input = hooks::ExecInput {
991 script: script.to_string(),
992 };
993 match self.interpreter.hooks().fire_before_exec(input) {
994 Some(modified) => {
995 self.interpreter
996 .execution_budget()
997 .consume_input(modified.script.len())?;
998 std::borrow::Cow::Owned(modified.script)
999 }
1000 None => {
1001 return Ok(ExecResult::err("cancelled by before_exec hook", 1));
1002 }
1003 }
1004 } else {
1005 std::borrow::Cow::Borrowed(script)
1006 };
1007 let script = script.as_ref();
1008
1009 let input_len = script.len();
1011 if input_len > self.max_input_bytes {
1012 #[cfg(feature = "logging")]
1013 tracing::error!(
1014 target: "bashkit::session",
1015 input_len = input_len,
1016 max_bytes = self.max_input_bytes,
1017 "Script exceeds maximum input size"
1018 );
1019 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
1020 input_len,
1021 self.max_input_bytes,
1022 )));
1023 }
1024
1025 let parser_timeout = self.parser_timeout;
1026 let max_ast_depth = self.max_ast_depth;
1027 let max_parser_operations = self.max_parser_operations;
1028
1029 #[cfg(feature = "logging")]
1030 tracing::debug!(
1031 target: "bashkit::parser",
1032 input_len = input_len,
1033 max_ast_depth = max_ast_depth,
1034 max_operations = max_parser_operations,
1035 "Parsing script"
1036 );
1037
1038 #[cfg(not(target_family = "wasm"))]
1048 const SPAWN_BLOCKING_THRESHOLD: usize = 16 * 1024;
1049
1050 #[cfg(target_family = "wasm")]
1053 let ast = {
1054 let parser = Parser::with_limits_and_timeout(
1055 script,
1056 max_ast_depth,
1057 max_parser_operations,
1058 Some(parser_timeout),
1059 )
1060 .with_execution_budget(self.interpreter.execution_budget().clone());
1061 parser.parse()?
1062 };
1063
1064 #[cfg(not(target_family = "wasm"))]
1068 let ast = if input_len <= SPAWN_BLOCKING_THRESHOLD {
1069 let parser = Parser::with_limits(script, max_ast_depth, max_parser_operations)
1070 .with_execution_budget(self.interpreter.execution_budget().clone());
1071 match parser.parse() {
1072 Ok(ast) => {
1073 #[cfg(feature = "logging")]
1074 tracing::debug!(target: "bashkit::parser", "Parse completed (inline)");
1075 ast
1076 }
1077 Err(e) => {
1078 #[cfg(feature = "logging")]
1079 tracing::warn!(target: "bashkit::parser", error = %e, "Parse error (inline)");
1080 return Err(e);
1081 }
1082 }
1083 } else {
1084 let script_owned = script.to_owned();
1085 let execution_budget = self.interpreter.execution_budget().clone();
1086 let parse_result = tokio::time::timeout(parser_timeout, async {
1087 tokio::task::spawn_blocking(move || {
1088 let parser =
1089 Parser::with_limits(&script_owned, max_ast_depth, max_parser_operations)
1090 .with_execution_budget(execution_budget);
1091 parser.parse()
1092 })
1093 .await
1094 })
1095 .await;
1096
1097 match parse_result {
1098 Ok(Ok(result)) => {
1099 match &result {
1100 Ok(_) => {
1101 #[cfg(feature = "logging")]
1102 tracing::debug!(target: "bashkit::parser", "Parse completed successfully");
1103 }
1104 Err(_e) => {
1105 #[cfg(feature = "logging")]
1106 tracing::warn!(target: "bashkit::parser", error = %_e, "Parse error");
1107 }
1108 }
1109 result?
1110 }
1111 Ok(Err(join_error)) => {
1112 #[cfg(feature = "logging")]
1113 tracing::error!(
1114 target: "bashkit::parser",
1115 error = %join_error,
1116 "Parser task failed"
1117 );
1118 return Err(Error::parse(format!("parser task failed: {}", join_error)));
1119 }
1120 Err(_elapsed) => {
1121 #[cfg(feature = "logging")]
1122 tracing::error!(
1123 target: "bashkit::parser",
1124 timeout_ms = parser_timeout.as_millis() as u64,
1125 "Parser timeout exceeded"
1126 );
1127 return Err(Error::ResourceLimit(LimitExceeded::ParserTimeout(
1128 parser_timeout,
1129 )));
1130 }
1131 }
1132 };
1133
1134 #[cfg(feature = "logging")]
1135 tracing::debug!(target: "bashkit::interpreter", "Starting interpretation");
1136
1137 parser::validate_budget(&ast, self.interpreter.limits())
1139 .map_err(|e| Error::Execution(format!("budget validation failed: {e}")))?;
1140
1141 self.interpreter.load_history().await;
1143
1144 let call_stack_baseline = self.interpreter.call_stack_len();
1148 let installed_invocation = !invocation.is_empty();
1149 if installed_invocation {
1150 if let Some(stdin) = invocation.stdin {
1151 self.interpreter.set_pipeline_stdin(stdin);
1152 }
1153 if invocation.arg0.is_some() || invocation.positional.is_some() {
1154 self.interpreter.push_toplevel_positional(
1155 invocation.arg0,
1156 invocation.positional.unwrap_or_default(),
1157 );
1158 }
1159 }
1160
1161 let exec_start = crate::time_compat::Instant::now();
1162 let execution_timeout = self.interpreter.limits().timeout;
1165 let result =
1166 match crate::time_compat::timeout(execution_timeout, self.interpreter.execute(&ast))
1167 .await
1168 {
1169 Ok(r) => r,
1170 Err(_elapsed) => {
1171 self.interpreter.clear_cancelled_execution_state();
1172 Err(Error::ResourceLimit(LimitExceeded::Timeout(
1173 execution_timeout,
1174 )))
1175 }
1176 };
1177 if installed_invocation {
1181 self.interpreter.truncate_call_stack(call_stack_baseline);
1182 }
1183 self.interpreter.cleanup_proc_sub_files().await;
1187 let duration_ms = exec_start.elapsed().as_millis() as u64;
1188
1189 if let Ok(ref exec_result) = result {
1191 let cwd = self.interpreter.cwd().to_string_lossy().to_string();
1192 let timestamp = crate::time_compat::now_utc().timestamp();
1193 for line in script.lines() {
1194 let trimmed = line.trim();
1195 if !trimmed.is_empty() && !trimmed.starts_with('#') {
1196 self.interpreter.record_history(
1197 trimmed.to_string(),
1198 timestamp,
1199 cwd.clone(),
1200 exec_result.exit_code,
1201 duration_ms,
1202 );
1203 }
1204 }
1205 self.interpreter.save_history().await;
1207 }
1208
1209 #[cfg(feature = "logging")]
1210 match &result {
1211 Ok(exec_result) => {
1212 tracing::info!(
1213 target: "bashkit::session",
1214 exit_code = exec_result.exit_code,
1215 stdout_len = exec_result.stdout.len(),
1216 stderr_len = exec_result.stderr.len(),
1217 "Script execution completed"
1218 );
1219 }
1220 Err(e) => {
1221 let error = logging::format_error_for_log(&e.to_string(), &self.log_config);
1222 tracing::error!(
1223 target: "bashkit::session",
1224 error = %error,
1225 "Script execution failed"
1226 );
1227 }
1228 }
1229
1230 let result = if let Ok(exec_result) = result {
1232 if !self.interpreter.hooks().after_exec.is_empty() {
1233 self.interpreter.execution_budget().consume_work(100)?;
1234 self.interpreter.execution_budget().consume_input(
1235 script
1236 .len()
1237 .saturating_add(exec_result.stdout.len())
1238 .saturating_add(exec_result.stderr.len()),
1239 )?;
1240 let output = hooks::ExecOutput {
1241 script: script.to_string(),
1242 stdout: exec_result.stdout.text_lossy().into_owned(),
1243 stderr: exec_result.stderr.text_lossy().into_owned(),
1244 exit_code: exec_result.exit_code,
1245 };
1246 match self.interpreter.hooks().fire_after_exec(output) {
1247 Some(output) => {
1248 self.interpreter.execution_budget().consume_work(
1249 u64::try_from(
1250 output
1251 .stdout
1252 .len()
1253 .saturating_add(output.stderr.len())
1254 .div_ceil(1024),
1255 )
1256 .unwrap_or(u64::MAX),
1257 )?;
1258 Ok(ExecResult {
1259 stdout: output.stdout.into(),
1260 stderr: output.stderr.into(),
1261 exit_code: output.exit_code,
1262 ..exec_result
1263 })
1264 }
1265 None => Ok(ExecResult::err("cancelled by after_exec hook", 1)),
1266 }
1267 } else {
1268 Ok(exec_result)
1269 }
1270 } else {
1271 result
1272 };
1273
1274 if let Err(ref e) = result
1276 && !self.interpreter.hooks().on_error.is_empty()
1277 && self
1278 .interpreter
1279 .execution_budget()
1280 .consume_work(100)
1281 .is_ok()
1282 {
1283 let message = e.to_string();
1284 if self
1285 .interpreter
1286 .execution_budget()
1287 .consume_input(message.len())
1288 .is_err()
1289 {
1290 return result;
1291 }
1292 let error_event = hooks::ErrorEvent { message };
1293 self.interpreter.hooks().fire_on_error(error_event);
1294 }
1295
1296 result
1297 }
1298
1299 pub async fn exec_streaming(
1330 &mut self,
1331 script: &str,
1332 output_callback: OutputCallback,
1333 ) -> Result<ExecResult> {
1334 self.exec_with_options(script, ExecOptions::new().streaming(output_callback))
1335 .await
1336 }
1337
1338 pub async fn exec_streaming_with_extensions(
1342 &mut self,
1343 script: &str,
1344 output_callback: OutputCallback,
1345 extensions: ExecutionExtensions,
1346 ) -> Result<ExecResult> {
1347 self.exec_with_options(
1348 script,
1349 ExecOptions::new()
1350 .streaming(output_callback)
1351 .extensions(extensions),
1352 )
1353 .await
1354 }
1355
1356 pub fn cancellation_token(&self) -> Arc<std::sync::atomic::AtomicBool> {
1364 self.interpreter.cancellation_token()
1365 }
1366
1367 pub fn hooks(&self) -> &hooks::Hooks {
1377 self.interpreter.hooks()
1378 }
1379
1380 pub fn fs(&self) -> Arc<dyn FileSystem> {
1413 Arc::clone(&self.fs)
1414 }
1415
1416 pub fn mount(
1458 &self,
1459 vfs_path: impl AsRef<std::path::Path>,
1460 fs: Arc<dyn FileSystem>,
1461 ) -> Result<()> {
1462 if Arc::ptr_eq(&self.fs, &fs) {
1466 return Err(std::io::Error::other("cannot mount filesystem into itself").into());
1467 }
1468
1469 let fs: Arc<dyn FileSystem> = if self.readonly_filesystem {
1470 Arc::new(ReadOnlyFs::new(fs))
1471 } else {
1472 fs
1473 };
1474 self.mountable.mount(vfs_path, fs)
1475 }
1476
1477 pub fn unmount(&self, vfs_path: impl AsRef<std::path::Path>) -> Result<()> {
1510 self.mountable.unmount(vfs_path)
1511 }
1512
1513 pub fn shell_state(&self) -> ShellState {
1539 self.interpreter.shell_state()
1540 }
1541
1542 pub fn shell_state_view(&self) -> ShellStateView {
1548 self.interpreter.shell_state_view()
1549 }
1550
1551 pub fn set_env(&mut self, key: &str, value: &str) {
1581 self.interpreter.set_env(key, value);
1586 self.interpreter.set_var(key, value);
1587 }
1588
1589 pub fn restore_shell_state(&mut self, state: &ShellState) {
1594 self.interpreter.restore_shell_state(state);
1595 }
1596
1597 #[cfg(feature = "realfs")]
1603 pub fn host_mounts(&self) -> &HostMounts {
1604 &self.host_mounts
1605 }
1606
1607 #[cfg(feature = "realfs")]
1617 pub fn host_path_for(&self, vfs_path: impl AsRef<Path>) -> Option<PathBuf> {
1618 self.host_mounts.resolve(vfs_path.as_ref())
1619 }
1620
1621 pub fn builtin_names(&self) -> Vec<String> {
1630 self.interpreter.builtin_names()
1631 }
1632
1633 pub fn analyze(&self, script: &str) -> Result<analysis::ScriptAnalysis> {
1660 if script.len() > self.max_input_bytes {
1663 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
1664 script.len(),
1665 self.max_input_bytes,
1666 )));
1667 }
1668 analysis::analyze_with_limits(script, self.max_ast_depth, self.max_parser_operations)
1669 }
1670
1671 pub fn session_counters(&self) -> (u64, u64) {
1675 let c = self.interpreter.counters();
1676 (c.session_commands, c.session_exec_calls)
1677 }
1678
1679 pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
1685 self.interpreter
1686 .restore_session_counters(session_commands, session_exec_calls);
1687 }
1688}
1689
1690struct MountedFile {
1727 path: PathBuf,
1728 content: String,
1729 mode: u32,
1730}
1731
1732struct MountedLazyFile {
1733 path: PathBuf,
1734 size_hint: u64,
1735 mode: u32,
1736 loader: LazyLoader,
1737}
1738
1739#[cfg(feature = "realfs")]
1745#[derive(Debug, Clone, PartialEq, Eq)]
1746pub struct HostMount {
1747 pub host_path: PathBuf,
1749 pub vfs_path: PathBuf,
1751}
1752
1753#[cfg(feature = "realfs")]
1761#[derive(Debug, Clone, Default)]
1762pub struct HostMounts {
1763 mounts: Vec<HostMount>,
1764}
1765
1766#[cfg(feature = "realfs")]
1767impl HostMounts {
1768 pub fn new(mounts: impl IntoIterator<Item = HostMount>) -> Self {
1780 Self {
1781 mounts: mounts
1782 .into_iter()
1783 .map(|mut mount| {
1784 mount.vfs_path = normalize_path(&mount.vfs_path);
1785 mount
1786 })
1787 .collect(),
1788 }
1789 }
1790
1791 pub fn all(&self) -> &[HostMount] {
1793 &self.mounts
1794 }
1795
1796 pub fn is_empty(&self) -> bool {
1798 self.mounts.is_empty()
1799 }
1800
1801 pub fn resolve(&self, vfs_path: &Path) -> Option<PathBuf> {
1811 if !vfs_path.has_root() {
1816 return None;
1817 }
1818 let vfs_path = normalize_path(vfs_path);
1821 self.mounts
1822 .iter()
1823 .filter_map(|mount| {
1824 let rest = vfs_path.strip_prefix(&mount.vfs_path).ok()?;
1825 Some((
1826 mount.vfs_path.components().count(),
1827 mount.host_path.join(rest),
1828 ))
1829 })
1830 .max_by_key(|(depth, _)| *depth)
1831 .map(|(_, host)| host)
1832 }
1833}
1834
1835#[cfg(feature = "realfs")]
1837struct MountedRealDir {
1838 host_path: PathBuf,
1840 vfs_mount: Option<PathBuf>,
1842 mode: fs::RealFsMode,
1844}
1845
1846#[derive(Default)]
1847pub struct BashBuilder {
1848 fs: Option<Arc<dyn FileSystem>>,
1849 env: HashMap<String, String>,
1850 cwd: Option<PathBuf>,
1851 limits: ExecutionLimits,
1852 session_limits: SessionLimits,
1853 memory_limits: MemoryLimits,
1854 profile: ExecutionProfile,
1856 filesystem_limits: FsLimits,
1858 trace_mode: TraceMode,
1859 trace_callback: Option<TraceCallback>,
1860 username: Option<String>,
1861 hostname: Option<String>,
1862 fixed_epoch: Option<i64>,
1864 epoch_offset: Option<i64>,
1866 shell_profile: interpreter::ShellProfile,
1867 custom_builtins: HashMap<String, Box<dyn Builtin>>,
1868 host_builtins: Option<BuiltinRegistry>,
1871 command_resolver: Option<Arc<dyn CommandResolver>>,
1873 mounted_files: Vec<MountedFile>,
1875 mounted_lazy_files: Vec<MountedLazyFile>,
1877 #[cfg(feature = "http_client")]
1879 network_allowlist: Option<NetworkAllowlist>,
1880 #[cfg(feature = "http_client")]
1882 http_limits: network::HttpLimits,
1883 #[cfg(feature = "http_client")]
1885 http_transport: Option<Arc<dyn network::HttpTransport>>,
1886 #[cfg(feature = "bot-auth")]
1888 bot_auth_config: Option<network::BotAuthConfig>,
1889 #[cfg(feature = "logging")]
1891 log_config: Option<logging::LogConfig>,
1892 #[cfg(feature = "git")]
1894 git_config: Option<GitConfig>,
1895 #[cfg(feature = "ssh")]
1897 ssh_config: Option<SshConfig>,
1898 #[cfg(feature = "ssh")]
1900 ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
1901 #[cfg(feature = "realfs")]
1903 real_mounts: Vec<MountedRealDir>,
1904 #[cfg(feature = "realfs")]
1907 mount_path_allowlist: Option<Vec<PathBuf>>,
1908 history_file: Option<PathBuf>,
1910 readonly_filesystem: bool,
1912 hooks_on_exit: Vec<hooks::Interceptor<hooks::ExitEvent>>,
1914 hooks_before_exec: Vec<hooks::Interceptor<hooks::ExecInput>>,
1915 hooks_after_exec: Vec<hooks::Interceptor<hooks::ExecOutput>>,
1916 hooks_before_tool: Vec<hooks::Interceptor<hooks::ToolEvent>>,
1917 hooks_after_tool: Vec<hooks::Interceptor<hooks::ToolResult>>,
1918 hooks_on_error: Vec<hooks::Interceptor<hooks::ErrorEvent>>,
1919 #[cfg(feature = "http_client")]
1920 hooks_before_http: Vec<hooks::Interceptor<hooks::HttpRequestEvent>>,
1921 #[cfg(feature = "http_client")]
1922 hooks_after_http: Vec<hooks::Interceptor<hooks::HttpResponseEvent>>,
1923 #[cfg(feature = "http_client")]
1925 credential_policy: Option<credential::CredentialPolicy>,
1926}
1927
1928impl BashBuilder {
1929 pub fn profile(mut self, profile: ExecutionProfile) -> Self {
1936 self.limits = profile.execution_limits().clone();
1937 self.session_limits = profile.session_limits().clone();
1938 self.memory_limits = profile.memory_limits().clone();
1939 self.filesystem_limits = profile.filesystem_limits().clone();
1940 self.readonly_filesystem = profile.readonly_filesystem();
1941 #[cfg(feature = "http_client")]
1942 {
1943 self.network_allowlist = match profile.network_policy() {
1944 ProfileNetworkPolicy::Disabled => None,
1945 ProfileNetworkPolicy::Allowlist(allowlist) => Some(allowlist.clone()),
1946 };
1947 self.http_limits = profile.http_limits().clone();
1948 }
1949 self.profile = profile;
1950 self
1951 }
1952
1953 pub fn filesystem_limits(mut self, limits: FsLimits) -> Self {
1955 self.filesystem_limits = limits;
1956 self
1957 }
1958
1959 #[cfg(feature = "scripted_tool")]
1963 pub fn tool_registry(mut self, registry: ToolRegistry) -> Self {
1964 self = self.extension(scripted_tool::ToolDefExtension::from_registry(
1965 registry.clone(),
1966 ));
1967 #[cfg(feature = "python")]
1968 {
1969 let limits = self.profile.python_limits().clone();
1970 let names = vec!["__bashkit_tool_call".to_string()];
1971 let handler = registry.python_handler();
1972 let prelude = registry.python_prelude();
1973 self = self
1974 .builtin(
1975 "python",
1976 Box::new(
1977 builtins::Python::with_limits(limits.clone())
1978 .with_external_handler_and_prelude(
1979 names.clone(),
1980 handler.clone(),
1981 prelude.clone(),
1982 ),
1983 ),
1984 )
1985 .builtin(
1986 "python3",
1987 Box::new(
1988 builtins::Python::with_limits(limits)
1989 .with_external_handler_and_prelude(names, handler, prelude),
1990 ),
1991 );
1992 }
1993 #[cfg(feature = "typescript")]
1994 {
1995 let limits = self.profile.typescript_limits().clone();
1996 self = self.extension(
1997 builtins::TypeScriptExtension::with_external_handler_and_prelude(
1998 limits,
1999 registry.typescript_external_names(),
2000 registry.typescript_handler(),
2001 registry.typescript_prelude(),
2002 registry.typescript_rewrites(),
2003 ),
2004 );
2005 }
2006 self
2007 }
2008
2009 pub fn fs(mut self, fs: Arc<dyn FileSystem>) -> Self {
2011 self.fs = Some(fs);
2012 self
2013 }
2014
2015 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
2017 self.env.insert(key.into(), value.into());
2018 self
2019 }
2020
2021 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
2023 self.cwd = Some(cwd.into());
2024 self
2025 }
2026
2027 pub fn limits(mut self, limits: ExecutionLimits) -> Self {
2029 self.limits = limits;
2030 self
2031 }
2032
2033 #[cfg(feature = "scripted_tool")]
2035 pub(crate) fn logic_only(mut self) -> Self {
2036 self.shell_profile = interpreter::ShellProfile::LogicOnly;
2037 self
2038 }
2039
2040 pub fn session_limits(mut self, limits: SessionLimits) -> Self {
2045 self.session_limits = limits;
2046 self
2047 }
2048
2049 pub fn memory_limits(mut self, limits: MemoryLimits) -> Self {
2054 self.memory_limits = limits;
2055 self
2056 }
2057
2058 pub fn max_memory(self, bytes: usize) -> Self {
2074 let defaults = MemoryLimits::default();
2075 self.memory_limits(
2076 MemoryLimits::new()
2077 .max_total_variable_bytes(bytes)
2078 .max_function_body_bytes(bytes.min(defaults.max_function_body_bytes)),
2079 )
2080 }
2081
2082 pub fn trace_mode(mut self, mode: TraceMode) -> Self {
2088 self.trace_mode = mode;
2089 self
2090 }
2091
2092 pub fn on_trace_event(mut self, callback: TraceCallback) -> Self {
2097 self.trace_callback = Some(callback);
2098 self
2099 }
2100
2101 pub fn username(mut self, username: impl Into<String>) -> Self {
2106 self.username = Some(username.into());
2107 self
2108 }
2109
2110 pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
2114 self.hostname = Some(hostname.into());
2115 self
2116 }
2117
2118 pub fn tty(mut self, fd: u32, is_terminal: bool) -> Self {
2132 let key = format!("_TTY_{}", fd);
2133 if is_terminal {
2134 self.env.insert(key, "1".to_string());
2135 } else {
2136 self.env.remove(&key);
2137 }
2138 self
2139 }
2140
2141 pub fn fixed_epoch(mut self, epoch: i64) -> Self {
2146 self.fixed_epoch = Some(epoch);
2147 self.epoch_offset = None;
2148 self
2149 }
2150
2151 pub fn epoch_offset(mut self, seconds: i64) -> Self {
2163 self.epoch_offset = Some(seconds);
2164 self.fixed_epoch = None;
2165 self
2166 }
2167
2168 pub fn history_file(mut self, path: impl Into<PathBuf>) -> Self {
2173 self.history_file = Some(path.into());
2174 self
2175 }
2176
2177 #[cfg(feature = "http_client")]
2209 pub fn network(mut self, allowlist: NetworkAllowlist) -> Self {
2210 self.network_allowlist = Some(allowlist);
2211 self
2212 }
2213
2214 #[cfg(feature = "http_client")]
2216 pub fn http_limits(mut self, limits: network::HttpLimits) -> Self {
2217 self.http_limits = limits;
2218 self
2219 }
2220
2221 #[cfg(feature = "http_client")]
2283 pub fn http_transport(mut self, transport: Arc<dyn network::HttpTransport>) -> Self {
2284 self.http_transport = Some(transport);
2285 self
2286 }
2287
2288 #[cfg(feature = "bot-auth")]
2309 pub fn bot_auth(mut self, config: network::BotAuthConfig) -> Self {
2310 self.bot_auth_config = Some(config);
2311 self
2312 }
2313
2314 #[cfg(feature = "logging")]
2351 pub fn log_config(mut self, config: logging::LogConfig) -> Self {
2352 self.log_config = Some(config);
2353 self
2354 }
2355
2356 #[cfg(feature = "git")]
2385 pub fn git(mut self, config: GitConfig) -> Self {
2386 self.git_config = Some(config);
2387 self
2388 }
2389
2390 #[cfg(feature = "ssh")]
2410 pub fn ssh(mut self, config: SshConfig) -> Self {
2411 self.ssh_config = Some(config);
2412 self
2413 }
2414
2415 #[cfg(feature = "ssh")]
2421 pub fn ssh_handler(mut self, handler: Box<dyn builtins::ssh::SshHandler>) -> Self {
2422 self.ssh_handler = Some(handler);
2423 self
2424 }
2425
2426 #[cfg(feature = "python")]
2445 pub fn python(self) -> Self {
2446 let limits = self.profile.python_limits().clone();
2447 self.python_with_limits(limits)
2448 }
2449
2450 #[cfg(feature = "sqlite")]
2469 pub fn sqlite(self) -> Self {
2470 let limits = self.profile.sqlite_limits().clone();
2471 self.sqlite_with_limits(limits)
2472 }
2473
2474 #[cfg(feature = "sqlite")]
2494 pub fn sqlite_with_limits(self, limits: builtins::SqliteLimits) -> Self {
2495 self.builtin(
2496 "sqlite",
2497 Box::new(builtins::Sqlite::with_limits(limits.clone())),
2498 )
2499 .builtin("sqlite3", Box::new(builtins::Sqlite::with_limits(limits)))
2500 }
2501
2502 #[cfg(feature = "python")]
2517 pub fn python_with_limits(self, limits: builtins::PythonLimits) -> Self {
2518 self.builtin(
2519 "python",
2520 Box::new(builtins::Python::with_limits(limits.clone())),
2521 )
2522 .builtin("python3", Box::new(builtins::Python::with_limits(limits)))
2523 }
2524
2525 #[cfg(feature = "python")]
2529 pub fn python_with_external_handler(
2530 self,
2531 limits: builtins::PythonLimits,
2532 external_fns: Vec<String>,
2533 handler: builtins::PythonExternalFnHandler,
2534 ) -> Self {
2535 self.builtin(
2536 "python",
2537 Box::new(
2538 builtins::Python::with_limits(limits.clone())
2539 .with_external_handler(external_fns.clone(), handler.clone()),
2540 ),
2541 )
2542 .builtin(
2543 "python3",
2544 Box::new(
2545 builtins::Python::with_limits(limits).with_external_handler(external_fns, handler),
2546 ),
2547 )
2548 }
2549
2550 #[cfg(feature = "typescript")]
2562 pub fn typescript(self) -> Self {
2563 let limits = self.profile.typescript_limits().clone();
2564 self.typescript_with_limits(limits)
2565 }
2566
2567 #[cfg(feature = "typescript")]
2571 pub fn typescript_with_limits(self, limits: builtins::TypeScriptLimits) -> Self {
2572 self.typescript_with_config(builtins::TypeScriptConfig::default().limits(limits))
2573 }
2574
2575 #[cfg(feature = "typescript")]
2603 pub fn typescript_with_config(self, config: builtins::TypeScriptConfig) -> Self {
2604 self.extension(builtins::TypeScriptExtension::with_config(config))
2605 }
2606
2607 #[cfg(feature = "typescript")]
2611 pub fn typescript_with_external_handler(
2612 self,
2613 limits: builtins::TypeScriptLimits,
2614 external_fns: Vec<String>,
2615 handler: builtins::TypeScriptExternalFnHandler,
2616 ) -> Self {
2617 self.extension(builtins::TypeScriptExtension::with_external_handler(
2618 limits,
2619 external_fns,
2620 handler,
2621 ))
2622 }
2623
2624 pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
2661 self.custom_builtins.insert(name.into(), builtin);
2662 self
2663 }
2664
2665 pub fn host_call_builtin(mut self, name: impl Into<String>) -> Self {
2671 let name = name.into();
2672 self.custom_builtins.insert(
2673 name.clone(),
2674 Box::new(host_call::HostCallBuiltin::new(name)),
2675 );
2676 self
2677 }
2678
2679 pub fn builtin_registry(mut self, registry: BuiltinRegistry) -> Self {
2696 self.host_builtins = Some(registry);
2697 self
2698 }
2699
2700 pub fn command_resolver(mut self, resolver: Arc<dyn CommandResolver>) -> Self {
2740 self.command_resolver = Some(resolver);
2741 self
2742 }
2743
2744 pub fn extension<E>(mut self, extension: E) -> Self
2776 where
2777 E: builtins::Extension,
2778 {
2779 for (name, builtin) in extension.builtins() {
2780 self.custom_builtins.insert(name, builtin);
2781 }
2782 self
2783 }
2784
2785 pub fn on_exit(mut self, hook: hooks::Interceptor<hooks::ExitEvent>) -> Self {
2808 self.hooks_on_exit.push(hook);
2809 self
2810 }
2811
2812 pub fn before_exec(mut self, hook: hooks::Interceptor<hooks::ExecInput>) -> Self {
2817 self.hooks_before_exec.push(hook);
2818 self
2819 }
2820
2821 pub fn after_exec(mut self, hook: hooks::Interceptor<hooks::ExecOutput>) -> Self {
2826 self.hooks_after_exec.push(hook);
2827 self
2828 }
2829
2830 pub fn before_tool(mut self, hook: hooks::Interceptor<hooks::ToolEvent>) -> Self {
2835 self.hooks_before_tool.push(hook);
2836 self
2837 }
2838
2839 pub fn after_tool(mut self, hook: hooks::Interceptor<hooks::ToolResult>) -> Self {
2843 self.hooks_after_tool.push(hook);
2844 self
2845 }
2846
2847 pub fn on_error(mut self, hook: hooks::Interceptor<hooks::ErrorEvent>) -> Self {
2851 self.hooks_on_error.push(hook);
2852 self
2853 }
2854
2855 #[cfg(feature = "http_client")]
2876 pub fn before_http(mut self, hook: hooks::Interceptor<hooks::HttpRequestEvent>) -> Self {
2877 self.hooks_before_http.push(hook);
2878 self
2879 }
2880
2881 #[cfg(feature = "http_client")]
2886 pub fn after_http(mut self, hook: hooks::Interceptor<hooks::HttpResponseEvent>) -> Self {
2887 self.hooks_after_http.push(hook);
2888 self
2889 }
2890
2891 #[cfg(feature = "http_client")]
2918 pub fn credential(mut self, pattern: &str, cred: credential::Credential) -> Self {
2919 self.credential_policy
2920 .get_or_insert_with(credential::CredentialPolicy::new)
2921 .add_injection(pattern, cred);
2922 self
2923 }
2924
2925 #[cfg(feature = "http_client")]
2954 pub fn credential_placeholder(
2955 mut self,
2956 env_name: &str,
2957 pattern: &str,
2958 cred: credential::Credential,
2959 ) -> Self {
2960 let placeholder = self
2961 .credential_policy
2962 .get_or_insert_with(credential::CredentialPolicy::new)
2963 .add_placeholder(pattern, cred);
2964 self.env.insert(env_name.to_string(), placeholder);
2965 self
2966 }
2967
2968 pub fn mount_text(mut self, path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
2997 self.mounted_files.push(MountedFile {
2998 path: path.into(),
2999 content: content.into(),
3000 mode: 0o644,
3001 });
3002 self
3003 }
3004
3005 pub fn mount_readonly_text(
3044 mut self,
3045 path: impl Into<PathBuf>,
3046 content: impl Into<String>,
3047 ) -> Self {
3048 self.mounted_files.push(MountedFile {
3049 path: path.into(),
3050 content: content.into(),
3051 mode: 0o444,
3052 });
3053 self
3054 }
3055
3056 pub fn mount_lazy(
3082 mut self,
3083 path: impl Into<PathBuf>,
3084 size_hint: u64,
3085 loader: LazyLoader,
3086 ) -> Self {
3087 self.mounted_lazy_files.push(MountedLazyFile {
3088 path: path.into(),
3089 size_hint,
3090 mode: 0o644,
3091 loader,
3092 });
3093 self
3094 }
3095
3096 #[cfg(feature = "realfs")]
3114 pub fn mount_real_readonly(mut self, host_path: impl Into<PathBuf>) -> Self {
3115 self.real_mounts.push(MountedRealDir {
3116 host_path: host_path.into(),
3117 vfs_mount: None,
3118 mode: fs::RealFsMode::ReadOnly,
3119 });
3120 self
3121 }
3122
3123 #[cfg(feature = "realfs")]
3141 pub fn mount_real_readonly_at(
3142 mut self,
3143 host_path: impl Into<PathBuf>,
3144 vfs_mount: impl Into<PathBuf>,
3145 ) -> Self {
3146 self.real_mounts.push(MountedRealDir {
3147 host_path: host_path.into(),
3148 vfs_mount: Some(vfs_mount.into()),
3149 mode: fs::RealFsMode::ReadOnly,
3150 });
3151 self
3152 }
3153
3154 #[cfg(feature = "realfs")]
3171 pub fn mount_real_readwrite(mut self, host_path: impl Into<PathBuf>) -> Self {
3172 self.real_mounts.push(MountedRealDir {
3173 host_path: host_path.into(),
3174 vfs_mount: None,
3175 mode: fs::RealFsMode::ReadWrite,
3176 });
3177 self
3178 }
3179
3180 #[cfg(feature = "realfs")]
3195 pub fn mount_real_readwrite_at(
3196 mut self,
3197 host_path: impl Into<PathBuf>,
3198 vfs_mount: impl Into<PathBuf>,
3199 ) -> Self {
3200 self.real_mounts.push(MountedRealDir {
3201 host_path: host_path.into(),
3202 vfs_mount: Some(vfs_mount.into()),
3203 mode: fs::RealFsMode::ReadWrite,
3204 });
3205 self
3206 }
3207
3208 #[cfg(feature = "realfs")]
3224 pub fn allowed_mount_paths(
3225 mut self,
3226 paths: impl IntoIterator<Item = impl Into<PathBuf>>,
3227 ) -> Self {
3228 self.mount_path_allowlist = Some(paths.into_iter().map(|p| p.into()).collect());
3229 self
3230 }
3231
3232 pub fn readonly_filesystem(mut self, readonly: bool) -> Self {
3238 self.readonly_filesystem = readonly;
3239 self
3240 }
3241
3242 pub fn build(self) -> Bash {
3277 let base_fs: Arc<dyn FileSystem> = if self.shell_profile.is_logic_only() {
3278 Arc::new(fs::DisabledFs)
3279 } else if let Some(fs) = self.fs {
3280 fs
3281 } else {
3282 let username = self
3289 .username
3290 .as_deref()
3291 .unwrap_or(builtins::DEFAULT_USERNAME);
3292 Arc::new(inmem_fs_with_home(username, self.filesystem_limits.clone()))
3293 };
3294
3295 #[cfg(feature = "realfs")]
3297 let (base_fs, host_mounts) = Self::apply_real_mounts(
3298 &self.real_mounts,
3299 self.mount_path_allowlist.as_deref(),
3300 base_fs,
3301 );
3302
3303 let has_mounts = !self.mounted_files.is_empty() || !self.mounted_lazy_files.is_empty();
3305 let base_fs: Arc<dyn FileSystem> = if has_mounts {
3306 let overlay = OverlayFs::with_limits(base_fs.clone(), base_fs.limits());
3307 for mf in &self.mounted_files {
3308 overlay.upper().add_file(&mf.path, &mf.content, mf.mode);
3309 }
3310 for lf in self.mounted_lazy_files {
3311 overlay
3312 .upper()
3313 .add_lazy_file(&lf.path, lf.size_hint, lf.mode, lf.loader);
3314 }
3315 Arc::new(overlay)
3316 } else {
3317 base_fs
3318 };
3319
3320 let base_fs: Arc<dyn FileSystem> = if self.readonly_filesystem {
3322 Arc::new(ReadOnlyFs::new(base_fs))
3323 } else {
3324 base_fs
3325 };
3326
3327 let mountable = Arc::new(MountableFs::new(base_fs));
3329 let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
3330
3331 let mut result = Self::build_with_fs(
3332 fs,
3333 mountable,
3334 self.readonly_filesystem,
3335 self.env,
3336 self.username,
3337 self.hostname,
3338 self.fixed_epoch,
3339 self.epoch_offset,
3340 self.cwd,
3341 self.shell_profile,
3342 self.profile.name() == ExecutionProfileName::Hardened,
3343 self.limits,
3344 self.session_limits,
3345 self.memory_limits,
3346 self.trace_mode,
3347 self.trace_callback,
3348 self.custom_builtins,
3349 self.host_builtins,
3350 self.command_resolver,
3351 self.history_file,
3352 #[cfg(feature = "http_client")]
3353 self.network_allowlist,
3354 #[cfg(feature = "http_client")]
3355 self.http_limits,
3356 #[cfg(feature = "http_client")]
3357 self.http_transport,
3358 #[cfg(feature = "bot-auth")]
3359 self.bot_auth_config,
3360 #[cfg(feature = "logging")]
3361 self.log_config,
3362 #[cfg(feature = "git")]
3363 self.git_config,
3364 #[cfg(feature = "ssh")]
3365 self.ssh_config,
3366 #[cfg(feature = "ssh")]
3367 self.ssh_handler,
3368 );
3369
3370 #[cfg(feature = "realfs")]
3372 {
3373 result.host_mounts = host_mounts;
3374 }
3375
3376 let hooks = hooks::Hooks {
3378 on_exit: self.hooks_on_exit,
3379 before_exec: self.hooks_before_exec,
3380 after_exec: self.hooks_after_exec,
3381 before_tool: self.hooks_before_tool,
3382 after_tool: self.hooks_after_tool,
3383 on_error: self.hooks_on_error,
3384 };
3385 if hooks.has_hooks() {
3386 result.interpreter.set_hooks(hooks);
3387 }
3388
3389 #[cfg(feature = "http_client")]
3392 let mut hooks_before_http = Vec::new();
3393 #[cfg(feature = "http_client")]
3394 if let Some(policy) = self.credential_policy
3395 && !policy.is_empty()
3396 {
3397 hooks_before_http.push(policy.into_hook());
3398 }
3399 #[cfg(feature = "http_client")]
3400 hooks_before_http.extend(self.hooks_before_http);
3401
3402 #[cfg(feature = "http_client")]
3404 if (!hooks_before_http.is_empty() || !self.hooks_after_http.is_empty())
3405 && let Some(client) = result.interpreter.http_client_mut()
3406 {
3407 if !hooks_before_http.is_empty() {
3408 client.set_before_http(hooks_before_http);
3409 }
3410 if !self.hooks_after_http.is_empty() {
3411 client.set_after_http(self.hooks_after_http);
3412 }
3413 }
3414
3415 result
3416 }
3417
3418 #[cfg(feature = "realfs")]
3423 const SENSITIVE_MOUNT_PATHS: &[&str] = &[
3424 "/proc", "/sys", "/dev", "/etc", "/boot", "/root", "/Users", "/home", "/run", "/var/run", "/private",
3431 ];
3432
3433 #[cfg(feature = "realfs")]
3438 const SENSITIVE_PATH_COMPONENTS: &[&str] =
3439 &[".ssh", ".aws", ".kube", ".docker", ".gnupg", ".gcloud"];
3440
3441 #[cfg(feature = "realfs")]
3446 fn is_sensitive_mount_path(host_path: &Path) -> bool {
3447 if host_path.parent().is_none() {
3450 return true;
3451 }
3452 if Self::SENSITIVE_MOUNT_PATHS
3453 .iter()
3454 .any(|s| host_path.starts_with(Path::new(s)))
3455 {
3456 return true;
3457 }
3458 host_path.components().any(|c| {
3459 let s = c.as_os_str();
3460 Self::SENSITIVE_PATH_COMPONENTS.iter().any(|sec| s == *sec)
3461 })
3462 }
3463
3464 #[cfg(feature = "realfs")]
3465 #[allow(deprecated)] fn apply_real_mounts(
3467 real_mounts: &[MountedRealDir],
3468 mount_allowlist: Option<&[PathBuf]>,
3469 base_fs: Arc<dyn FileSystem>,
3470 ) -> (Arc<dyn FileSystem>, HostMounts) {
3471 if real_mounts.is_empty() {
3472 return (base_fs, HostMounts::default());
3473 }
3474
3475 let mut current_fs = base_fs;
3476 let mut mount_points: Vec<(PathBuf, Arc<dyn FileSystem>)> = Vec::new();
3477 let mut host_mounts = HostMounts::default();
3480 let canonical_allowlist: Option<Vec<PathBuf>> = mount_allowlist.map(|allowlist| {
3481 allowlist
3482 .iter()
3483 .filter_map(|allowed| match std::fs::canonicalize(allowed) {
3484 Ok(path) => Some(path),
3485 Err(e) => {
3486 eprintln!(
3487 "bashkit: warning: failed to canonicalize allowlist path {}: {}",
3488 allowed.display(),
3489 e
3490 );
3491 None
3492 }
3493 })
3494 .collect()
3495 });
3496
3497 for m in real_mounts {
3498 if m.mode == fs::RealFsMode::ReadWrite {
3500 eprintln!(
3501 "bashkit: warning: writable mount at {} — scripts can modify host files",
3502 m.host_path.display()
3503 );
3504 }
3505
3506 let canonical_host = match std::fs::canonicalize(&m.host_path) {
3507 Ok(path) => path,
3508 Err(e) => {
3509 eprintln!(
3510 "bashkit: warning: failed to canonicalize mount path {}: {}",
3511 m.host_path.display(),
3512 e
3513 );
3514 continue;
3515 }
3516 };
3517
3518 let is_sensitive = Self::is_sensitive_mount_path(&canonical_host);
3522
3523 if let Some(allowlist) = &canonical_allowlist {
3524 if !allowlist
3525 .iter()
3526 .any(|allowed| canonical_host.starts_with(allowed))
3527 {
3528 eprintln!(
3529 "bashkit: warning: mount path {} not in allowlist, skipping",
3530 m.host_path.display()
3531 );
3532 continue;
3533 }
3534 } else if is_sensitive {
3535 eprintln!(
3536 "bashkit: warning: refusing to mount sensitive path {} (no allowlist set; \
3537 pass an explicit `allowed_mount_paths` entry to override)",
3538 m.host_path.display()
3539 );
3540 continue;
3541 }
3542
3543 let real_backend = match fs::RealFs::new(&canonical_host, m.mode) {
3544 Ok(b) => b,
3545 Err(e) => {
3546 eprintln!(
3547 "bashkit: warning: failed to mount {}: {}",
3548 m.host_path.display(),
3549 e
3550 );
3551 continue;
3552 }
3553 };
3554 let real_fs: Arc<dyn FileSystem> = Arc::new(PosixFs::new(real_backend));
3555
3556 match &m.vfs_mount {
3557 None => {
3558 current_fs = Arc::new(OverlayFs::new(real_fs));
3561 host_mounts.mounts.push(HostMount {
3562 host_path: canonical_host,
3563 vfs_path: PathBuf::from("/"),
3564 });
3565 }
3566 Some(mount_point) => {
3567 mount_points.push((mount_point.clone(), real_fs));
3568 host_mounts.mounts.push(HostMount {
3569 host_path: canonical_host,
3570 vfs_path: mount_point.clone(),
3571 });
3572 }
3573 }
3574 }
3575
3576 if !mount_points.is_empty() {
3578 let mountable = MountableFs::new(current_fs);
3579 for (path, fs) in mount_points {
3580 if let Err(e) = mountable.mount(&path, fs) {
3581 eprintln!(
3582 "bashkit: warning: failed to mount at {}: {}",
3583 path.display(),
3584 e
3585 );
3586 }
3587 }
3588 (Arc::new(mountable), host_mounts)
3589 } else {
3590 (current_fs, host_mounts)
3591 }
3592 }
3593
3594 #[allow(clippy::too_many_arguments)]
3596 fn build_with_fs(
3597 fs: Arc<dyn FileSystem>,
3598 mountable: Arc<MountableFs>,
3599 readonly_filesystem: bool,
3600 env: HashMap<String, String>,
3601 username: Option<String>,
3602 hostname: Option<String>,
3603 fixed_epoch: Option<i64>,
3604 epoch_offset: Option<i64>,
3605 cwd: Option<PathBuf>,
3606 shell_profile: interpreter::ShellProfile,
3607 hardened_timing: bool,
3608 limits: ExecutionLimits,
3609 session_limits: SessionLimits,
3610 memory_limits: MemoryLimits,
3611 trace_mode: TraceMode,
3612 trace_callback: Option<TraceCallback>,
3613 custom_builtins: HashMap<String, Box<dyn Builtin>>,
3614 host_builtins: Option<BuiltinRegistry>,
3615 command_resolver: Option<Arc<dyn CommandResolver>>,
3616 history_file: Option<PathBuf>,
3617 #[cfg(feature = "http_client")] network_allowlist: Option<NetworkAllowlist>,
3618 #[cfg(feature = "http_client")] http_limits: network::HttpLimits,
3619 #[cfg(feature = "http_client")] http_transport: Option<Arc<dyn network::HttpTransport>>,
3620 #[cfg(feature = "bot-auth")] bot_auth_config: Option<network::BotAuthConfig>,
3621 #[cfg(feature = "logging")] log_config: Option<logging::LogConfig>,
3622 #[cfg(feature = "git")] git_config: Option<GitConfig>,
3623 #[cfg(feature = "ssh")] ssh_config: Option<SshConfig>,
3624 #[cfg(feature = "ssh")] ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
3625 ) -> Bash {
3626 #[cfg(feature = "logging")]
3627 let log_config = log_config.unwrap_or_default();
3628
3629 #[cfg(feature = "logging")]
3630 tracing::debug!(
3631 target: "bashkit::config",
3632 redact_sensitive = log_config.redact_sensitive,
3633 log_scripts = log_config.log_script_content,
3634 "Bash instance configured"
3635 );
3636
3637 let mut interpreter = Interpreter::with_config(
3638 Arc::clone(&fs),
3639 username.clone(),
3640 hostname,
3641 fixed_epoch,
3642 epoch_offset,
3643 custom_builtins,
3644 host_builtins,
3645 shell_profile,
3646 hardened_timing,
3647 );
3648
3649 if let Some(resolver) = command_resolver {
3650 interpreter.set_command_resolver(resolver);
3651 }
3652
3653 for (key, value) in &env {
3655 interpreter.set_env(key, value);
3656 interpreter.set_var(key, value);
3659 }
3660 #[cfg(feature = "python")]
3661 let python_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_PYTHON");
3662 #[cfg(feature = "sqlite")]
3663 let sqlite_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_SQLITE");
3664 drop(env);
3665
3666 if let Some(ref username) = username {
3668 interpreter.set_env("USER", username);
3669 interpreter.set_var("USER", username);
3670 }
3671
3672 if let Some(cwd) = cwd {
3673 interpreter.set_cwd(cwd);
3674 }
3675
3676 #[cfg(feature = "http_client")]
3678 if let Some(allowlist) = network_allowlist {
3679 let mut client = network::HttpClient::with_limits(allowlist, http_limits);
3680 if let Some(transport) = http_transport {
3681 client.set_transport(transport);
3682 }
3683 #[cfg(feature = "bot-auth")]
3684 if let Some(bot_auth) = bot_auth_config {
3685 client.set_bot_auth(bot_auth);
3686 }
3687 interpreter.set_http_client(client);
3688 }
3689
3690 #[cfg(feature = "git")]
3692 if let Some(config) = git_config {
3693 let client = builtins::git::GitClient::new(config);
3694 interpreter.set_git_client(client);
3695 }
3696
3697 #[cfg(feature = "ssh")]
3699 if let Some(config) = ssh_config {
3700 let mut client = builtins::ssh::SshClient::new(config);
3701 if let Some(handler) = ssh_handler {
3702 client.set_handler(handler);
3703 }
3704 interpreter.set_ssh_client(client);
3705 }
3706
3707 if let Some(hf) = history_file {
3709 interpreter.set_history_file(hf);
3710 }
3711
3712 let parser_timeout = limits.parser_timeout;
3713 let max_input_bytes = limits.max_input_bytes;
3714 let max_ast_depth = limits.max_ast_depth;
3715 let max_parser_operations = limits.max_parser_operations;
3716 interpreter.set_limits(limits);
3717 interpreter.set_session_limits(session_limits);
3718 interpreter.set_memory_limits(memory_limits);
3719 let mut trace_collector = TraceCollector::new(trace_mode);
3720 if let Some(cb) = trace_callback {
3721 trace_collector.set_callback(cb);
3722 }
3723 interpreter.set_trace(trace_collector);
3724 Bash {
3725 fs,
3726 mountable,
3727 readonly_filesystem,
3728 interpreter,
3729 parser_timeout,
3730 max_input_bytes,
3731 max_ast_depth,
3732 max_parser_operations,
3733 #[cfg(feature = "logging")]
3734 log_config,
3735 #[cfg(feature = "python")]
3736 python_inprocess_opt_in,
3737 #[cfg(feature = "sqlite")]
3738 sqlite_inprocess_opt_in,
3739 #[cfg(feature = "realfs")]
3740 host_mounts: HostMounts::default(),
3741 }
3742 }
3743}
3744
3745#[cfg(feature = "http_client")]
3761#[doc = include_str!("../docs/credential-injection.md")]
3762pub mod credential_injection_guide {}
3763
3764#[doc = include_str!("../docs/script-analysis.md")]
3774pub mod script_analysis_guide {}
3775
3776#[doc = include_str!("../docs/custom_builtins.md")]
3786pub mod custom_builtins_guide {}
3787
3788#[doc = include_str!("../docs/clap-builtins.md")]
3798pub mod clap_builtins_guide {}
3799
3800#[doc = include_str!("../docs/compatibility.md")]
3810pub mod compatibility_scorecard {}
3811
3812#[doc = include_str!("../docs/jq.md")]
3822pub mod jq_guide {}
3823
3824#[doc = include_str!("../docs/yq.md")]
3828pub mod yq_guide {}
3829
3830#[doc = include_str!("../docs/threat-model.md")]
3844pub mod threat_model {}
3845
3846#[cfg(feature = "python")]
3860#[doc = include_str!("../docs/python.md")]
3861pub mod python_guide {}
3862
3863#[cfg(feature = "sqlite")]
3875#[doc = include_str!("../docs/sqlite.md")]
3876pub mod sqlite_guide {}
3877
3878#[cfg(feature = "typescript")]
3890#[doc = include_str!("../docs/typescript.md")]
3891pub mod typescript_guide {}
3892
3893#[cfg(feature = "ssh")]
3897#[doc = include_str!("../docs/ssh.md")]
3898pub mod ssh_guide {}
3899
3900#[doc = include_str!("../docs/live_mounts.md")]
3910pub mod live_mounts_guide {}
3911
3912#[doc = include_str!("../docs/namespace_filesystems.md")]
3914pub mod namespace_filesystems_guide {}
3915
3916#[cfg(feature = "logging")]
3929#[doc = include_str!("../docs/logging.md")]
3930pub mod logging_guide {}
3931
3932#[doc = include_str!("../docs/hooks.md")]
3947pub mod hooks_guide {}
3948
3949#[cfg(test)]
3950mod tests {
3951 use super::*;
3952 use std::sync::{Arc, Mutex};
3953
3954 #[tokio::test]
3955 async fn test_echo_hello() {
3956 let mut bash = Bash::new();
3957 let result = bash.exec("echo hello").await.unwrap();
3958 assert_eq!(result.stdout, "hello\n");
3959 assert_eq!(result.exit_code, 0);
3960 }
3961
3962 #[tokio::test]
3963 async fn test_echo_multiple_args() {
3964 let mut bash = Bash::new();
3965 let result = bash.exec("echo hello world").await.unwrap();
3966 assert_eq!(result.stdout, "hello world\n");
3967 assert_eq!(result.exit_code, 0);
3968 }
3969
3970 #[tokio::test]
3971 async fn test_variable_expansion() {
3972 let mut bash = Bash::builder().env("HOME", "/home/user").build();
3973 let result = bash.exec("echo $HOME").await.unwrap();
3974 assert_eq!(result.stdout, "/home/user\n");
3975 assert_eq!(result.exit_code, 0);
3976 }
3977
3978 #[tokio::test]
3979 async fn test_variable_brace_expansion() {
3980 let mut bash = Bash::builder().env("USER", "testuser").build();
3981 let result = bash.exec("echo ${USER}").await.unwrap();
3982 assert_eq!(result.stdout, "testuser\n");
3983 }
3984
3985 #[tokio::test]
3986 async fn test_undefined_variable_expands_to_empty() {
3987 let mut bash = Bash::new();
3988 let result = bash.exec("echo $UNDEFINED_VAR").await.unwrap();
3989 assert_eq!(result.stdout, "\n");
3990 }
3991
3992 #[tokio::test]
3993 async fn test_pipeline() {
3994 let mut bash = Bash::new();
3995 let result = bash.exec("echo hello | cat").await.unwrap();
3996 assert_eq!(result.stdout, "hello\n");
3997 }
3998
3999 #[tokio::test(start_paused = true)]
4000 async fn test_timed_out_bash_c_does_not_leak_stdin_to_next_exec() {
4001 let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
4002 let mut bash = Bash::builder().limits(limits).build();
4003
4004 let timed_out = bash.exec("printf secret | bash -c 'sleep 10'").await;
4005 assert!(matches!(
4006 timed_out,
4007 Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
4008 ));
4009
4010 let result = bash.exec("cat").await.unwrap();
4011 assert_eq!(result.stdout, "");
4012 }
4013
4014 #[tokio::test(start_paused = true)]
4015 async fn test_timed_out_fd3_capture_does_not_leak_to_next_exec() {
4016 let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
4017 let mut bash = Bash::builder().limits(limits).build();
4018
4019 let timed_out = bash.exec("{ sleep 10; } 3>&1 > /tmp/poison.txt").await;
4020 assert!(matches!(
4021 timed_out,
4022 Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
4023 ));
4024
4025 let hidden = bash.exec("echo SECRET_FROM_EXEC2 1>&3").await.unwrap();
4026 assert_eq!(hidden.stdout, "");
4027
4028 let routed = bash
4029 .exec("echo PUBLIC_FROM_EXEC3 2>&1 > /tmp/public.txt")
4030 .await
4031 .unwrap();
4032 assert_eq!(routed.stdout, "");
4033
4034 let file = bash.exec("cat /tmp/public.txt").await.unwrap();
4035 assert_eq!(file.stdout, "PUBLIC_FROM_EXEC3\n");
4036 }
4037
4038 #[tokio::test(start_paused = true)]
4039 async fn test_timed_out_debug_trap_does_not_suppress_next_exec_debug_trap() {
4040 let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
4041 let mut bash = Bash::builder().limits(limits).build();
4042
4043 let timed_out = bash
4044 .exec("trap 'sleep 10' DEBUG; echo should-not-run")
4045 .await;
4046 assert!(matches!(
4047 timed_out,
4048 Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
4049 ));
4050
4051 let result = bash
4052 .exec("count=0; trap '((count++))' DEBUG; echo body; trap - DEBUG; echo $count")
4053 .await
4054 .unwrap();
4055 assert_eq!(result.stdout, "body\n2\n");
4056 }
4057
4058 #[tokio::test]
4059 async fn test_pipeline_three_commands() {
4060 let mut bash = Bash::new();
4061 let result = bash.exec("echo hello | cat | cat").await.unwrap();
4062 assert_eq!(result.stdout, "hello\n");
4063 }
4064
4065 #[tokio::test]
4066 async fn test_redirect_output() {
4067 let mut bash = Bash::new();
4068 let result = bash.exec("echo hello > /tmp/test.txt").await.unwrap();
4069 assert_eq!(result.stdout, "");
4070 assert_eq!(result.exit_code, 0);
4071
4072 let result = bash.exec("cat /tmp/test.txt").await.unwrap();
4074 assert_eq!(result.stdout, "hello\n");
4075 }
4076
4077 #[tokio::test]
4078 async fn test_redirect_append() {
4079 let mut bash = Bash::new();
4080 bash.exec("echo hello > /tmp/append.txt").await.unwrap();
4081 bash.exec("echo world >> /tmp/append.txt").await.unwrap();
4082
4083 let result = bash.exec("cat /tmp/append.txt").await.unwrap();
4084 assert_eq!(result.stdout, "hello\nworld\n");
4085 }
4086
4087 #[tokio::test]
4088 async fn test_command_list_and() {
4089 let mut bash = Bash::new();
4090 let result = bash.exec("true && echo success").await.unwrap();
4091 assert_eq!(result.stdout, "success\n");
4092 }
4093
4094 #[tokio::test]
4095 async fn test_command_list_and_short_circuit() {
4096 let mut bash = Bash::new();
4097 let result = bash.exec("false && echo should_not_print").await.unwrap();
4098 assert_eq!(result.stdout, "");
4099 assert_eq!(result.exit_code, 1);
4100 }
4101
4102 #[tokio::test]
4103 async fn test_command_list_or() {
4104 let mut bash = Bash::new();
4105 let result = bash.exec("false || echo fallback").await.unwrap();
4106 assert_eq!(result.stdout, "fallback\n");
4107 }
4108
4109 #[tokio::test]
4110 async fn test_command_list_or_short_circuit() {
4111 let mut bash = Bash::new();
4112 let result = bash.exec("true || echo should_not_print").await.unwrap();
4113 assert_eq!(result.stdout, "");
4114 assert_eq!(result.exit_code, 0);
4115 }
4116
4117 #[tokio::test]
4119 async fn test_phase1_target() {
4120 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
4121
4122 let result = bash
4123 .exec("echo $HOME | cat > /tmp/out && cat /tmp/out")
4124 .await
4125 .unwrap();
4126
4127 assert_eq!(result.stdout, "/home/testuser\n");
4128 assert_eq!(result.exit_code, 0);
4129 }
4130
4131 #[tokio::test]
4132 async fn test_redirect_input() {
4133 let mut bash = Bash::new();
4134 bash.exec("echo hello > /tmp/input.txt").await.unwrap();
4136
4137 let result = bash.exec("cat < /tmp/input.txt").await.unwrap();
4139 assert_eq!(result.stdout, "hello\n");
4140 }
4141
4142 #[tokio::test]
4143 async fn test_here_string() {
4144 let mut bash = Bash::new();
4145 let result = bash.exec("cat <<< hello").await.unwrap();
4146 assert_eq!(result.stdout, "hello\n");
4147 }
4148
4149 #[tokio::test]
4150 async fn test_if_true() {
4151 let mut bash = Bash::new();
4152 let result = bash.exec("if true; then echo yes; fi").await.unwrap();
4153 assert_eq!(result.stdout, "yes\n");
4154 }
4155
4156 #[tokio::test]
4157 async fn test_if_false() {
4158 let mut bash = Bash::new();
4159 let result = bash.exec("if false; then echo yes; fi").await.unwrap();
4160 assert_eq!(result.stdout, "");
4161 }
4162
4163 #[tokio::test]
4164 async fn test_if_else() {
4165 let mut bash = Bash::new();
4166 let result = bash
4167 .exec("if false; then echo yes; else echo no; fi")
4168 .await
4169 .unwrap();
4170 assert_eq!(result.stdout, "no\n");
4171 }
4172
4173 #[tokio::test]
4174 async fn test_if_elif() {
4175 let mut bash = Bash::new();
4176 let result = bash
4177 .exec("if false; then echo one; elif true; then echo two; else echo three; fi")
4178 .await
4179 .unwrap();
4180 assert_eq!(result.stdout, "two\n");
4181 }
4182
4183 #[tokio::test]
4184 async fn test_for_loop() {
4185 let mut bash = Bash::new();
4186 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
4187 assert_eq!(result.stdout, "a\nb\nc\n");
4188 }
4189
4190 #[tokio::test]
4191 async fn test_for_loop_positional_params() {
4192 let mut bash = Bash::new();
4193 let result = bash
4195 .exec("f() { for x; do echo $x; done; }; f one two three")
4196 .await
4197 .unwrap();
4198 assert_eq!(result.stdout, "one\ntwo\nthree\n");
4199 }
4200
4201 #[tokio::test]
4202 async fn test_while_loop() {
4203 let mut bash = Bash::new();
4204 let result = bash.exec("while false; do echo loop; done").await.unwrap();
4206 assert_eq!(result.stdout, "");
4207 }
4208
4209 #[tokio::test]
4210 async fn test_subshell() {
4211 let mut bash = Bash::new();
4212 let result = bash.exec("(echo hello)").await.unwrap();
4213 assert_eq!(result.stdout, "hello\n");
4214 }
4215
4216 #[tokio::test]
4217 async fn test_brace_group() {
4218 let mut bash = Bash::new();
4219 let result = bash.exec("{ echo hello; }").await.unwrap();
4220 assert_eq!(result.stdout, "hello\n");
4221 }
4222
4223 #[tokio::test]
4224 async fn test_function_keyword() {
4225 let mut bash = Bash::new();
4226 let result = bash
4227 .exec("function greet { echo hello; }; greet")
4228 .await
4229 .unwrap();
4230 assert_eq!(result.stdout, "hello\n");
4231 }
4232
4233 #[tokio::test]
4234 async fn test_function_posix() {
4235 let mut bash = Bash::new();
4236 let result = bash.exec("greet() { echo hello; }; greet").await.unwrap();
4237 assert_eq!(result.stdout, "hello\n");
4238 }
4239
4240 #[tokio::test]
4241 async fn test_function_args() {
4242 let mut bash = Bash::new();
4243 let result = bash
4244 .exec("greet() { echo $1 $2; }; greet world foo")
4245 .await
4246 .unwrap();
4247 assert_eq!(result.stdout, "world foo\n");
4248 }
4249
4250 #[tokio::test]
4251 async fn test_function_arg_count() {
4252 let mut bash = Bash::new();
4253 let result = bash
4254 .exec("count() { echo $#; }; count a b c")
4255 .await
4256 .unwrap();
4257 assert_eq!(result.stdout, "3\n");
4258 }
4259
4260 #[tokio::test]
4261 async fn test_case_literal() {
4262 let mut bash = Bash::new();
4263 let result = bash
4264 .exec("case foo in foo) echo matched ;; esac")
4265 .await
4266 .unwrap();
4267 assert_eq!(result.stdout, "matched\n");
4268 }
4269
4270 #[tokio::test]
4271 async fn test_case_wildcard() {
4272 let mut bash = Bash::new();
4273 let result = bash
4274 .exec("case bar in *) echo default ;; esac")
4275 .await
4276 .unwrap();
4277 assert_eq!(result.stdout, "default\n");
4278 }
4279
4280 #[tokio::test]
4281 async fn test_case_no_match() {
4282 let mut bash = Bash::new();
4283 let result = bash.exec("case foo in bar) echo no ;; esac").await.unwrap();
4284 assert_eq!(result.stdout, "");
4285 }
4286
4287 #[tokio::test]
4288 async fn test_case_multiple_patterns() {
4289 let mut bash = Bash::new();
4290 let result = bash
4291 .exec("case foo in bar|foo|baz) echo matched ;; esac")
4292 .await
4293 .unwrap();
4294 assert_eq!(result.stdout, "matched\n");
4295 }
4296
4297 #[tokio::test]
4298 async fn test_case_bracket_expr() {
4299 let mut bash = Bash::new();
4300 let result = bash
4302 .exec("case b in [abc]) echo matched ;; esac")
4303 .await
4304 .unwrap();
4305 assert_eq!(result.stdout, "matched\n");
4306 }
4307
4308 #[tokio::test]
4309 async fn test_case_bracket_range() {
4310 let mut bash = Bash::new();
4311 let result = bash
4313 .exec("case m in [a-z]) echo letter ;; esac")
4314 .await
4315 .unwrap();
4316 assert_eq!(result.stdout, "letter\n");
4317 }
4318
4319 #[tokio::test]
4320 async fn test_case_bracket_wide_unicode_range() {
4321 let mut bash = Bash::new();
4322 let result = bash
4323 .exec("case z in [a-\u{10ffff}]) echo wide ;; esac")
4324 .await
4325 .unwrap();
4326 assert_eq!(result.stdout, "wide\n");
4327 }
4328
4329 #[tokio::test]
4330 async fn test_case_bracket_negation() {
4331 let mut bash = Bash::new();
4332 let result = bash
4334 .exec("case x in [!abc]) echo not_abc ;; esac")
4335 .await
4336 .unwrap();
4337 assert_eq!(result.stdout, "not_abc\n");
4338 }
4339
4340 #[tokio::test]
4341 async fn test_break_as_command() {
4342 let mut bash = Bash::new();
4343 let result = bash.exec("break").await.unwrap();
4345 assert_eq!(result.exit_code, 0);
4347 }
4348
4349 #[tokio::test]
4350 async fn test_for_one_item() {
4351 let mut bash = Bash::new();
4352 let result = bash.exec("for i in a; do echo $i; done").await.unwrap();
4354 assert_eq!(result.stdout, "a\n");
4355 }
4356
4357 #[tokio::test]
4358 async fn test_for_with_break() {
4359 let mut bash = Bash::new();
4360 let result = bash.exec("for i in a; do break; done").await.unwrap();
4362 assert_eq!(result.stdout, "");
4363 assert_eq!(result.exit_code, 0);
4364 }
4365
4366 #[tokio::test]
4367 async fn test_for_echo_break() {
4368 let mut bash = Bash::new();
4369 let result = bash
4371 .exec("for i in a b c; do echo $i; break; done")
4372 .await
4373 .unwrap();
4374 assert_eq!(result.stdout, "a\n");
4375 }
4376
4377 #[tokio::test]
4378 async fn test_test_string_empty() {
4379 let mut bash = Bash::new();
4380 let result = bash.exec("test -z '' && echo yes").await.unwrap();
4381 assert_eq!(result.stdout, "yes\n");
4382 }
4383
4384 #[tokio::test]
4385 async fn test_test_string_not_empty() {
4386 let mut bash = Bash::new();
4387 let result = bash.exec("test -n 'hello' && echo yes").await.unwrap();
4388 assert_eq!(result.stdout, "yes\n");
4389 }
4390
4391 #[tokio::test]
4392 async fn test_test_string_equal() {
4393 let mut bash = Bash::new();
4394 let result = bash.exec("test foo = foo && echo yes").await.unwrap();
4395 assert_eq!(result.stdout, "yes\n");
4396 }
4397
4398 #[tokio::test]
4399 async fn test_test_string_not_equal() {
4400 let mut bash = Bash::new();
4401 let result = bash.exec("test foo != bar && echo yes").await.unwrap();
4402 assert_eq!(result.stdout, "yes\n");
4403 }
4404
4405 #[tokio::test]
4406 async fn test_test_numeric_equal() {
4407 let mut bash = Bash::new();
4408 let result = bash.exec("test 5 -eq 5 && echo yes").await.unwrap();
4409 assert_eq!(result.stdout, "yes\n");
4410 }
4411
4412 #[tokio::test]
4413 async fn test_test_numeric_less_than() {
4414 let mut bash = Bash::new();
4415 let result = bash.exec("test 3 -lt 5 && echo yes").await.unwrap();
4416 assert_eq!(result.stdout, "yes\n");
4417 }
4418
4419 #[tokio::test]
4420 async fn test_bracket_form() {
4421 let mut bash = Bash::new();
4422 let result = bash.exec("[ foo = foo ] && echo yes").await.unwrap();
4423 assert_eq!(result.stdout, "yes\n");
4424 }
4425
4426 #[tokio::test]
4427 async fn test_if_with_test() {
4428 let mut bash = Bash::new();
4429 let result = bash
4430 .exec("if [ 5 -gt 3 ]; then echo bigger; fi")
4431 .await
4432 .unwrap();
4433 assert_eq!(result.stdout, "bigger\n");
4434 }
4435
4436 #[tokio::test]
4437 async fn test_variable_assignment() {
4438 let mut bash = Bash::new();
4439 let result = bash.exec("FOO=bar; echo $FOO").await.unwrap();
4440 assert_eq!(result.stdout, "bar\n");
4441 }
4442
4443 #[tokio::test]
4444 async fn test_variable_assignment_inline() {
4445 let mut bash = Bash::new();
4446 let result = bash.exec("MSG=hello; echo $MSG world").await.unwrap();
4448 assert_eq!(result.stdout, "hello world\n");
4449 }
4450
4451 #[tokio::test]
4452 async fn test_variable_assignment_only() {
4453 let mut bash = Bash::new();
4454 let result = bash.exec("FOO=bar").await.unwrap();
4456 assert_eq!(result.stdout, "");
4457 assert_eq!(result.exit_code, 0);
4458
4459 let result = bash.exec("echo $FOO").await.unwrap();
4461 assert_eq!(result.stdout, "bar\n");
4462 }
4463
4464 #[tokio::test]
4465 async fn test_multiple_assignments() {
4466 let mut bash = Bash::new();
4467 let result = bash.exec("A=1; B=2; C=3; echo $A $B $C").await.unwrap();
4468 assert_eq!(result.stdout, "1 2 3\n");
4469 }
4470
4471 #[tokio::test]
4472 async fn test_prefix_assignment_visible_in_env() {
4473 let mut bash = Bash::new();
4474 let result = bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
4476 assert_eq!(result.stdout, "hello\n");
4477 }
4478
4479 #[tokio::test]
4480 async fn test_prefix_assignment_temporary() {
4481 let mut bash = Bash::new();
4482 bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
4484 let result = bash.exec("echo ${MYVAR:-unset}").await.unwrap();
4485 assert_eq!(result.stdout, "unset\n");
4486 }
4487
4488 #[tokio::test]
4489 async fn test_prefix_assignment_duplicate_name_temporary() {
4490 let mut bash = Bash::new();
4491 let result = bash.exec("A=1 A=2 printenv A").await.unwrap();
4493 assert_eq!(result.stdout, "2\n");
4494 let result = bash.exec("echo ${A:-unset}").await.unwrap();
4495 assert_eq!(result.stdout, "unset\n");
4496 }
4497
4498 #[tokio::test]
4499 async fn test_prefix_assignment_does_not_clobber_existing_env() {
4500 let mut bash = Bash::new();
4501 let result = bash
4503 .exec("EXISTING=original; export EXISTING; EXISTING=temp printenv EXISTING")
4504 .await
4505 .unwrap();
4506 assert_eq!(result.stdout, "temp\n");
4507 }
4508
4509 #[tokio::test]
4510 async fn test_prefix_assignment_multiple_vars() {
4511 let mut bash = Bash::new();
4512 let result = bash.exec("A=one B=two printenv A").await.unwrap();
4514 assert_eq!(result.stdout, "one\n");
4515 assert_eq!(result.exit_code, 0);
4516 }
4517
4518 #[tokio::test]
4519 async fn test_prefix_assignment_empty_value() {
4520 let mut bash = Bash::new();
4521 let result = bash.exec("MYVAR= printenv MYVAR").await.unwrap();
4523 assert_eq!(result.stdout, "\n");
4524 assert_eq!(result.exit_code, 0);
4525 }
4526
4527 #[tokio::test]
4528 async fn test_prefix_assignment_not_found_without_prefix() {
4529 let mut bash = Bash::new();
4530 let result = bash.exec("printenv NONEXISTENT").await.unwrap();
4532 assert_eq!(result.stdout, "");
4533 assert_eq!(result.exit_code, 1);
4534 }
4535
4536 #[tokio::test]
4537 async fn test_prefix_assignment_does_not_persist_in_variables() {
4538 let mut bash = Bash::new();
4539 bash.exec("TMPVAR=gone echo ok").await.unwrap();
4541 let result = bash.exec("echo \"${TMPVAR:-unset}\"").await.unwrap();
4542 assert_eq!(result.stdout, "unset\n");
4543 }
4544
4545 #[tokio::test]
4546 async fn test_assignment_only_persists() {
4547 let mut bash = Bash::new();
4548 bash.exec("PERSIST=yes").await.unwrap();
4550 let result = bash.exec("echo $PERSIST").await.unwrap();
4551 assert_eq!(result.stdout, "yes\n");
4552 }
4553
4554 #[tokio::test]
4555 async fn test_printf_string() {
4556 let mut bash = Bash::new();
4557 let result = bash.exec("printf '%s' hello").await.unwrap();
4558 assert_eq!(result.stdout, "hello");
4559 }
4560
4561 #[tokio::test]
4562 async fn test_printf_newline() {
4563 let mut bash = Bash::new();
4564 let result = bash.exec("printf 'hello\\n'").await.unwrap();
4565 assert_eq!(result.stdout, "hello\n");
4566 }
4567
4568 #[tokio::test]
4569 async fn test_printf_multiple_args() {
4570 let mut bash = Bash::new();
4571 let result = bash.exec("printf '%s %s\\n' hello world").await.unwrap();
4572 assert_eq!(result.stdout, "hello world\n");
4573 }
4574
4575 #[tokio::test]
4576 async fn test_printf_integer() {
4577 let mut bash = Bash::new();
4578 let result = bash.exec("printf '%d' 42").await.unwrap();
4579 assert_eq!(result.stdout, "42");
4580 }
4581
4582 #[tokio::test]
4583 async fn test_export() {
4584 let mut bash = Bash::new();
4585 let result = bash.exec("export FOO=bar; echo $FOO").await.unwrap();
4586 assert_eq!(result.stdout, "bar\n");
4587 }
4588
4589 #[tokio::test]
4590 async fn test_read_basic() {
4591 let mut bash = Bash::new();
4592 let result = bash.exec("echo hello | read VAR; echo $VAR").await.unwrap();
4593 assert_eq!(result.stdout, "hello\n");
4594 }
4595
4596 #[tokio::test]
4597 async fn test_read_multiple_vars() {
4598 let mut bash = Bash::new();
4599 let result = bash
4600 .exec("echo 'a b c' | read X Y Z; echo $X $Y $Z")
4601 .await
4602 .unwrap();
4603 assert_eq!(result.stdout, "a b c\n");
4604 }
4605
4606 #[tokio::test]
4607 async fn test_read_respects_local_scope() {
4608 let mut bash = Bash::new();
4610 let result = bash
4611 .exec(
4612 r#"
4613fn() { local k; read -r k <<< "test"; echo "$k"; }
4614fn
4615"#,
4616 )
4617 .await
4618 .unwrap();
4619 assert_eq!(result.stdout, "test\n");
4620 }
4621
4622 #[tokio::test]
4623 async fn test_local_ifs_array_join() {
4624 let mut bash = Bash::new();
4626 let result = bash
4627 .exec(
4628 r#"
4629fn() {
4630 local arr=(a b c)
4631 local IFS=":"
4632 echo "${arr[*]}"
4633}
4634fn
4635"#,
4636 )
4637 .await
4638 .unwrap();
4639 assert_eq!(result.stdout, "a:b:c\n");
4640 }
4641
4642 #[tokio::test]
4643 async fn test_glob_star() {
4644 let mut bash = Bash::new();
4645 bash.exec("echo a > /tmp/file1.txt").await.unwrap();
4647 bash.exec("echo b > /tmp/file2.txt").await.unwrap();
4648 bash.exec("echo c > /tmp/other.log").await.unwrap();
4649
4650 let result = bash.exec("echo /tmp/*.txt").await.unwrap();
4652 assert_eq!(result.stdout, "/tmp/file1.txt /tmp/file2.txt\n");
4653 }
4654
4655 #[tokio::test]
4656 async fn test_glob_question_mark() {
4657 let mut bash = Bash::new();
4658 bash.exec("echo a > /tmp/a1.txt").await.unwrap();
4660 bash.exec("echo b > /tmp/a2.txt").await.unwrap();
4661 bash.exec("echo c > /tmp/a10.txt").await.unwrap();
4662
4663 let result = bash.exec("echo /tmp/a?.txt").await.unwrap();
4665 assert_eq!(result.stdout, "/tmp/a1.txt /tmp/a2.txt\n");
4666 }
4667
4668 #[tokio::test]
4669 async fn test_glob_no_match() {
4670 let mut bash = Bash::new();
4671 let result = bash.exec("echo /nonexistent/*.xyz").await.unwrap();
4673 assert_eq!(result.stdout, "/nonexistent/*.xyz\n");
4674 }
4675
4676 #[tokio::test]
4677 async fn test_command_substitution() {
4678 let mut bash = Bash::new();
4679 let result = bash.exec("echo $(echo hello)").await.unwrap();
4680 assert_eq!(result.stdout, "hello\n");
4681 }
4682
4683 #[tokio::test]
4684 async fn test_command_substitution_in_string() {
4685 let mut bash = Bash::new();
4686 let result = bash.exec("echo \"result: $(echo 42)\"").await.unwrap();
4687 assert_eq!(result.stdout, "result: 42\n");
4688 }
4689
4690 #[tokio::test]
4691 async fn test_command_substitution_pipeline() {
4692 let mut bash = Bash::new();
4693 let result = bash.exec("echo $(echo hello | cat)").await.unwrap();
4694 assert_eq!(result.stdout, "hello\n");
4695 }
4696
4697 #[tokio::test]
4698 async fn test_command_substitution_variable() {
4699 let mut bash = Bash::new();
4700 let result = bash.exec("VAR=$(echo test); echo $VAR").await.unwrap();
4701 assert_eq!(result.stdout, "test\n");
4702 }
4703
4704 #[tokio::test]
4705 async fn test_arithmetic_simple() {
4706 let mut bash = Bash::new();
4707 let result = bash.exec("echo $((1 + 2))").await.unwrap();
4708 assert_eq!(result.stdout, "3\n");
4709 }
4710
4711 #[tokio::test]
4712 async fn test_arithmetic_multiply() {
4713 let mut bash = Bash::new();
4714 let result = bash.exec("echo $((3 * 4))").await.unwrap();
4715 assert_eq!(result.stdout, "12\n");
4716 }
4717
4718 #[tokio::test]
4719 async fn test_arithmetic_with_variable() {
4720 let mut bash = Bash::new();
4721 let result = bash.exec("X=5; echo $((X + 3))").await.unwrap();
4722 assert_eq!(result.stdout, "8\n");
4723 }
4724
4725 #[tokio::test]
4726 async fn test_arithmetic_complex() {
4727 let mut bash = Bash::new();
4728 let result = bash.exec("echo $((2 + 3 * 4))").await.unwrap();
4729 assert_eq!(result.stdout, "14\n");
4730 }
4731
4732 #[tokio::test]
4733 async fn test_heredoc_simple() {
4734 let mut bash = Bash::new();
4735 let result = bash.exec("cat <<EOF\nhello\nworld\nEOF").await.unwrap();
4736 assert_eq!(result.stdout, "hello\nworld\n");
4737 }
4738
4739 #[tokio::test]
4740 async fn test_heredoc_single_line() {
4741 let mut bash = Bash::new();
4742 let result = bash.exec("cat <<END\ntest\nEND").await.unwrap();
4743 assert_eq!(result.stdout, "test\n");
4744 }
4745
4746 #[tokio::test]
4747 async fn test_unset() {
4748 let mut bash = Bash::new();
4749 let result = bash
4750 .exec("FOO=bar; unset FOO; echo \"x${FOO}y\"")
4751 .await
4752 .unwrap();
4753 assert_eq!(result.stdout, "xy\n");
4754 }
4755
4756 #[tokio::test]
4757 async fn test_local_basic() {
4758 let mut bash = Bash::new();
4759 let result = bash.exec("local X=test; echo $X").await.unwrap();
4761 assert_eq!(result.stdout, "test\n");
4762 }
4763
4764 #[tokio::test]
4765 async fn test_set_option() {
4766 let mut bash = Bash::new();
4767 let result = bash.exec("set -e; echo ok").await.unwrap();
4768 assert_eq!(result.stdout, "ok\n");
4769 }
4770
4771 #[tokio::test]
4772 async fn test_param_default() {
4773 let mut bash = Bash::new();
4774 let result = bash.exec("echo ${UNSET:-default}").await.unwrap();
4776 assert_eq!(result.stdout, "default\n");
4777
4778 let result = bash.exec("X=value; echo ${X:-default}").await.unwrap();
4780 assert_eq!(result.stdout, "value\n");
4781 }
4782
4783 #[tokio::test]
4784 async fn test_param_assign_default() {
4785 let mut bash = Bash::new();
4786 let result = bash.exec("echo ${NEW:=assigned}; echo $NEW").await.unwrap();
4788 assert_eq!(result.stdout, "assigned\nassigned\n");
4789 }
4790
4791 #[tokio::test]
4792 async fn test_param_length() {
4793 let mut bash = Bash::new();
4794 let result = bash.exec("X=hello; echo ${#X}").await.unwrap();
4795 assert_eq!(result.stdout, "5\n");
4796 }
4797
4798 #[tokio::test]
4799 async fn test_param_remove_prefix() {
4800 let mut bash = Bash::new();
4801 let result = bash.exec("X=hello.world.txt; echo ${X#*.}").await.unwrap();
4803 assert_eq!(result.stdout, "world.txt\n");
4804 }
4805
4806 #[tokio::test]
4807 async fn test_param_remove_prefix_mixed_pattern() {
4808 let mut bash = Bash::new();
4809 let result = bash
4811 .exec(r#"i="./tag_hello.tmp.html"; prefix_tags="tag_"; echo ${i#./"$prefix_tags"}"#)
4812 .await
4813 .unwrap();
4814 assert_eq!(result.stdout, "hello.tmp.html\n");
4815 }
4816
4817 #[tokio::test]
4818 async fn test_param_remove_suffix() {
4819 let mut bash = Bash::new();
4820 let result = bash.exec("X=file.tar.gz; echo ${X%.*}").await.unwrap();
4822 assert_eq!(result.stdout, "file.tar\n");
4823 }
4824
4825 #[tokio::test]
4826 async fn test_positional_param_prefix_replace() {
4827 let mut bash = Bash::new();
4828 let result = bash
4830 .exec(r#"f() { set -- "${@/#/tag_}"; echo "$@"; }; f hello world"#)
4831 .await
4832 .unwrap();
4833 assert_eq!(result.stdout, "tag_hello tag_world\n");
4834 }
4835
4836 #[tokio::test]
4837 async fn test_positional_param_suffix_replace() {
4838 let mut bash = Bash::new();
4839 let result = bash
4841 .exec(r#"f() { set -- "${@/%/.html}"; echo "$@"; }; f hello world"#)
4842 .await
4843 .unwrap();
4844 assert_eq!(result.stdout, "hello.html world.html\n");
4845 }
4846
4847 #[tokio::test]
4848 async fn test_positional_param_prefix_var_replace() {
4849 let mut bash = Bash::new();
4850 let result = bash
4852 .exec(r#"f() { p="tag_"; set -- "${@/#/$p}"; echo "$@"; }; f hello world"#)
4853 .await
4854 .unwrap();
4855 assert_eq!(result.stdout, "tag_hello tag_world\n");
4856 }
4857
4858 #[tokio::test]
4859 async fn test_positional_param_prefix_strip() {
4860 let mut bash = Bash::new();
4861 let result = bash
4863 .exec(r#"f() { set -- "${@#tag_}"; echo "$@"; }; f tag_hello tag_world"#)
4864 .await
4865 .unwrap();
4866 assert_eq!(result.stdout, "hello world\n");
4867 }
4868
4869 #[tokio::test]
4870 async fn test_array_basic() {
4871 let mut bash = Bash::new();
4872 let result = bash.exec("arr=(a b c); echo ${arr[1]}").await.unwrap();
4874 assert_eq!(result.stdout, "b\n");
4875 }
4876
4877 #[tokio::test]
4878 async fn test_array_all_elements() {
4879 let mut bash = Bash::new();
4880 let result = bash
4882 .exec("arr=(one two three); echo ${arr[@]}")
4883 .await
4884 .unwrap();
4885 assert_eq!(result.stdout, "one two three\n");
4886 }
4887
4888 #[tokio::test]
4889 async fn test_array_length() {
4890 let mut bash = Bash::new();
4891 let result = bash.exec("arr=(a b c d e); echo ${#arr[@]}").await.unwrap();
4893 assert_eq!(result.stdout, "5\n");
4894 }
4895
4896 #[tokio::test]
4897 async fn test_array_indexed_assignment() {
4898 let mut bash = Bash::new();
4899 let result = bash
4901 .exec("arr[0]=first; arr[1]=second; echo ${arr[0]} ${arr[1]}")
4902 .await
4903 .unwrap();
4904 assert_eq!(result.stdout, "first second\n");
4905 }
4906
4907 #[tokio::test]
4908 async fn test_array_single_quote_subscript_no_panic() {
4909 let mut bash = Bash::new();
4911 let _ = bash.exec("echo ${arr[\"]}").await;
4913 }
4914
4915 #[tokio::test]
4918 async fn test_command_limit() {
4919 let limits = ExecutionLimits::new().max_commands(5);
4920 let mut bash = Bash::builder().limits(limits).build();
4921
4922 let result = bash.exec("true; true; true; true; true; true").await;
4924 assert!(result.is_err());
4925 let err = result.unwrap_err();
4926 assert!(
4927 err.to_string().contains("maximum command count exceeded"),
4928 "Expected command limit error, got: {}",
4929 err
4930 );
4931 }
4932
4933 #[tokio::test]
4934 async fn test_command_limit_not_exceeded() {
4935 let limits = ExecutionLimits::new().max_commands(10);
4936 let mut bash = Bash::builder().limits(limits).build();
4937
4938 let result = bash.exec("true; true; true; true; true").await.unwrap();
4940 assert_eq!(result.exit_code, 0);
4941 }
4942
4943 #[tokio::test]
4944 async fn test_loop_iteration_limit() {
4945 let limits = ExecutionLimits::new().max_loop_iterations(5);
4946 let mut bash = Bash::builder().limits(limits).build();
4947
4948 let result = bash
4950 .exec("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done")
4951 .await;
4952 assert!(result.is_err());
4953 let err = result.unwrap_err();
4954 assert!(
4955 err.to_string().contains("maximum loop iterations exceeded"),
4956 "Expected loop limit error, got: {}",
4957 err
4958 );
4959 }
4960
4961 #[tokio::test]
4962 async fn test_loop_iteration_limit_not_exceeded() {
4963 let limits = ExecutionLimits::new().max_loop_iterations(10);
4964 let mut bash = Bash::builder().limits(limits).build();
4965
4966 let result = bash
4968 .exec("for i in 1 2 3 4 5; do echo $i; done")
4969 .await
4970 .unwrap();
4971 assert_eq!(result.stdout, "1\n2\n3\n4\n5\n");
4972 }
4973
4974 #[tokio::test]
4975 async fn test_function_depth_limit() {
4976 let limits = ExecutionLimits::new().max_function_depth(3);
4977 let mut bash = Bash::builder().limits(limits).build();
4978
4979 let result = bash
4981 .exec("f() { echo $1; if [ $1 -lt 5 ]; then f $(($1 + 1)); fi; }; f 1")
4982 .await;
4983 assert!(result.is_err());
4984 let err = result.unwrap_err();
4985 assert!(
4986 err.to_string().contains("maximum function depth exceeded"),
4987 "Expected function depth error, got: {}",
4988 err
4989 );
4990 }
4991
4992 #[tokio::test]
4993 async fn test_function_depth_limit_not_exceeded() {
4994 let limits = ExecutionLimits::new().max_function_depth(10);
4995 let mut bash = Bash::builder().limits(limits).build();
4996
4997 let result = bash.exec("f() { echo hello; }; f").await.unwrap();
4999 assert_eq!(result.stdout, "hello\n");
5000 }
5001
5002 #[tokio::test]
5003 async fn test_while_loop_limit() {
5004 let limits = ExecutionLimits::new().max_loop_iterations(3);
5005 let mut bash = Bash::builder().limits(limits).build();
5006
5007 let result = bash
5009 .exec("i=0; while [ $i -lt 10 ]; do echo $i; i=$((i + 1)); done")
5010 .await;
5011 assert!(result.is_err());
5012 let err = result.unwrap_err();
5013 assert!(
5014 err.to_string().contains("maximum loop iterations exceeded"),
5015 "Expected loop limit error, got: {}",
5016 err
5017 );
5018 }
5019
5020 #[tokio::test]
5021 async fn test_awk_respects_loop_iteration_limit() {
5022 let limits = ExecutionLimits::new().max_loop_iterations(5);
5023 let mut bash = Bash::builder().limits(limits).build();
5024 let result = bash
5025 .exec("awk 'BEGIN { i=0; while(1) { i++; if(i>999) break } print i }'")
5026 .await
5027 .unwrap();
5028 assert_eq!(result.stdout.trim(), "5");
5029 }
5030
5031 #[tokio::test]
5032 async fn test_awk_for_in_respects_loop_iteration_limit() {
5033 let limits = ExecutionLimits::new().max_loop_iterations(3);
5034 let mut bash = Bash::builder().limits(limits).build();
5035 let result = bash
5036 .exec("awk 'BEGIN { for(i=1;i<=10;i++) a[i]=i; c=0; for(k in a) c++; print c }'")
5037 .await
5038 .unwrap();
5039 assert_eq!(result.stdout.trim(), "3");
5040 }
5041
5042 #[tokio::test]
5043 async fn test_default_limits_allow_normal_scripts() {
5044 let mut bash = Bash::new();
5046 let result = bash
5048 .exec("for i in 1 2 3 4 5; do echo $i; done && echo finished")
5049 .await
5050 .unwrap();
5051 assert_eq!(result.stdout, "1\n2\n3\n4\n5\nfinished\n");
5052 }
5053
5054 #[tokio::test]
5055 async fn test_for_followed_by_echo_done() {
5056 let mut bash = Bash::new();
5057 let result = bash
5058 .exec("for i in 1; do echo $i; done; echo ok")
5059 .await
5060 .unwrap();
5061 assert_eq!(result.stdout, "1\nok\n");
5062 }
5063
5064 #[tokio::test]
5067 async fn test_fs_read_write_binary() {
5068 let bash = Bash::new();
5069 let fs = bash.fs();
5070 let path = std::path::Path::new("/tmp/binary.bin");
5071
5072 let binary_data: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE, 0x42, 0x00, 0x7F];
5074 fs.write_file(path, &binary_data).await.unwrap();
5075
5076 let content = fs.read_file(path).await.unwrap();
5078 assert_eq!(content, binary_data);
5079 }
5080
5081 #[tokio::test]
5082 async fn test_fs_write_then_exec_cat() {
5083 let mut bash = Bash::new();
5084 let path = std::path::Path::new("/tmp/prepopulated.txt");
5085
5086 bash.fs()
5088 .write_file(path, b"Hello from Rust!\n")
5089 .await
5090 .unwrap();
5091
5092 let result = bash.exec("cat /tmp/prepopulated.txt").await.unwrap();
5094 assert_eq!(result.stdout, "Hello from Rust!\n");
5095 }
5096
5097 #[tokio::test]
5098 async fn test_fs_exec_then_read() {
5099 let mut bash = Bash::new();
5100 let path = std::path::Path::new("/tmp/from_bash.txt");
5101
5102 bash.exec("echo 'Created by bash' > /tmp/from_bash.txt")
5104 .await
5105 .unwrap();
5106
5107 let content = bash.fs().read_file(path).await.unwrap();
5109 assert_eq!(content, b"Created by bash\n");
5110 }
5111
5112 #[tokio::test]
5113 async fn test_fs_exists_and_stat() {
5114 let bash = Bash::new();
5115 let fs = bash.fs();
5116 let path = std::path::Path::new("/tmp/testfile.txt");
5117
5118 assert!(!fs.exists(path).await.unwrap());
5120
5121 fs.write_file(path, b"content").await.unwrap();
5123
5124 assert!(fs.exists(path).await.unwrap());
5126
5127 let stat = fs.stat(path).await.unwrap();
5129 assert!(stat.file_type.is_file());
5130 assert_eq!(stat.size, 7); }
5132
5133 #[tokio::test]
5134 async fn test_fs_mkdir_and_read_dir() {
5135 let bash = Bash::new();
5136 let fs = bash.fs();
5137
5138 fs.mkdir(std::path::Path::new("/data/nested/dir"), true)
5140 .await
5141 .unwrap();
5142
5143 fs.write_file(std::path::Path::new("/data/file1.txt"), b"1")
5145 .await
5146 .unwrap();
5147 fs.write_file(std::path::Path::new("/data/file2.txt"), b"2")
5148 .await
5149 .unwrap();
5150
5151 let entries = fs.read_dir(std::path::Path::new("/data")).await.unwrap();
5153 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
5154 assert!(names.contains(&"nested"));
5155 assert!(names.contains(&"file1.txt"));
5156 assert!(names.contains(&"file2.txt"));
5157 }
5158
5159 #[tokio::test]
5160 async fn test_fs_append() {
5161 let bash = Bash::new();
5162 let fs = bash.fs();
5163 let path = std::path::Path::new("/tmp/append.txt");
5164
5165 fs.write_file(path, b"line1\n").await.unwrap();
5166 fs.append_file(path, b"line2\n").await.unwrap();
5167 fs.append_file(path, b"line3\n").await.unwrap();
5168
5169 let content = fs.read_file(path).await.unwrap();
5170 assert_eq!(content, b"line1\nline2\nline3\n");
5171 }
5172
5173 #[tokio::test]
5174 async fn test_fs_copy_and_rename() {
5175 let bash = Bash::new();
5176 let fs = bash.fs();
5177
5178 fs.write_file(std::path::Path::new("/tmp/original.txt"), b"data")
5179 .await
5180 .unwrap();
5181
5182 fs.copy(
5184 std::path::Path::new("/tmp/original.txt"),
5185 std::path::Path::new("/tmp/copied.txt"),
5186 )
5187 .await
5188 .unwrap();
5189
5190 fs.rename(
5192 std::path::Path::new("/tmp/copied.txt"),
5193 std::path::Path::new("/tmp/renamed.txt"),
5194 )
5195 .await
5196 .unwrap();
5197
5198 let content = fs
5200 .read_file(std::path::Path::new("/tmp/renamed.txt"))
5201 .await
5202 .unwrap();
5203 assert_eq!(content, b"data");
5204 assert!(
5205 !fs.exists(std::path::Path::new("/tmp/copied.txt"))
5206 .await
5207 .unwrap()
5208 );
5209 }
5210
5211 #[tokio::test]
5214 async fn test_echo_done_as_argument() {
5215 let mut bash = Bash::new();
5217 let result = bash
5218 .exec("for i in 1; do echo $i; done; echo done")
5219 .await
5220 .unwrap();
5221 assert_eq!(result.stdout, "1\ndone\n");
5222 }
5223
5224 #[tokio::test]
5225 async fn test_simple_echo_done() {
5226 let mut bash = Bash::new();
5228 let result = bash.exec("echo done").await.unwrap();
5229 assert_eq!(result.stdout, "done\n");
5230 }
5231
5232 #[tokio::test]
5233 async fn test_dev_null_redirect() {
5234 let mut bash = Bash::new();
5236 let result = bash.exec("echo hello > /dev/null; echo ok").await.unwrap();
5237 assert_eq!(result.stdout, "ok\n");
5238 }
5239
5240 #[tokio::test]
5241 async fn test_string_concatenation_in_loop() {
5242 let mut bash = Bash::new();
5244 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
5246 assert_eq!(result.stdout, "a\nb\nc\n");
5247
5248 let mut bash = Bash::new();
5250 let result = bash
5251 .exec("result=x; for i in a b c; do echo $i; done; echo $result")
5252 .await
5253 .unwrap();
5254 assert_eq!(result.stdout, "a\nb\nc\nx\n");
5255
5256 let mut bash = Bash::new();
5258 let result = bash
5259 .exec("result=start; for i in a b c; do result=${result}$i; done; echo $result")
5260 .await
5261 .unwrap();
5262 assert_eq!(result.stdout, "startabc\n");
5263 }
5264
5265 #[tokio::test]
5268 async fn test_done_still_terminates_loop() {
5269 let mut bash = Bash::new();
5271 let result = bash.exec("for i in 1 2; do echo $i; done").await.unwrap();
5272 assert_eq!(result.stdout, "1\n2\n");
5273 }
5274
5275 #[tokio::test]
5276 async fn test_fi_still_terminates_if() {
5277 let mut bash = Bash::new();
5279 let result = bash.exec("if true; then echo yes; fi").await.unwrap();
5280 assert_eq!(result.stdout, "yes\n");
5281 }
5282
5283 #[tokio::test]
5284 async fn test_echo_fi_as_argument() {
5285 let mut bash = Bash::new();
5287 let result = bash.exec("echo fi").await.unwrap();
5288 assert_eq!(result.stdout, "fi\n");
5289 }
5290
5291 #[tokio::test]
5292 async fn test_echo_then_as_argument() {
5293 let mut bash = Bash::new();
5295 let result = bash.exec("echo then").await.unwrap();
5296 assert_eq!(result.stdout, "then\n");
5297 }
5298
5299 #[tokio::test]
5300 async fn test_reserved_words_in_quotes_are_arguments() {
5301 let mut bash = Bash::new();
5303 let result = bash.exec("echo 'done' 'fi' 'then'").await.unwrap();
5304 assert_eq!(result.stdout, "done fi then\n");
5305 }
5306
5307 #[tokio::test]
5308 async fn test_nested_loops_done_keyword() {
5309 let mut bash = Bash::new();
5311 let result = bash
5312 .exec("for i in 1; do for j in a; do echo $i$j; done; done")
5313 .await
5314 .unwrap();
5315 assert_eq!(result.stdout, "1a\n");
5316 }
5317
5318 #[tokio::test]
5321 async fn test_dev_null_read_returns_empty() {
5322 let mut bash = Bash::new();
5324 let result = bash.exec("cat /dev/null").await.unwrap();
5325 assert_eq!(result.stdout, "");
5326 }
5327
5328 #[tokio::test]
5329 async fn test_dev_null_append() {
5330 let mut bash = Bash::new();
5332 let result = bash.exec("echo hello >> /dev/null; echo ok").await.unwrap();
5333 assert_eq!(result.stdout, "ok\n");
5334 }
5335
5336 #[tokio::test]
5337 async fn test_dev_null_in_pipeline() {
5338 let mut bash = Bash::new();
5340 let result = bash
5341 .exec("echo hello | cat > /dev/null; echo ok")
5342 .await
5343 .unwrap();
5344 assert_eq!(result.stdout, "ok\n");
5345 }
5346
5347 #[tokio::test]
5348 async fn test_dev_null_exists() {
5349 let mut bash = Bash::new();
5351 let result = bash.exec("cat /dev/null; echo exit_$?").await.unwrap();
5352 assert_eq!(result.stdout, "exit_0\n");
5353 }
5354
5355 #[tokio::test]
5358 async fn test_custom_username_whoami() {
5359 let mut bash = Bash::builder().username("alice").build();
5360 let result = bash.exec("whoami").await.unwrap();
5361 assert_eq!(result.stdout, "alice\n");
5362 }
5363
5364 #[tokio::test]
5365 async fn test_custom_username_id() {
5366 let mut bash = Bash::builder().username("bob").build();
5367 let result = bash.exec("id").await.unwrap();
5368 assert!(result.stdout.contains("uid=1000(bob)"));
5369 assert!(result.stdout.contains("gid=1000(bob)"));
5370 }
5371
5372 #[tokio::test]
5373 async fn test_custom_username_sets_user_env() {
5374 let mut bash = Bash::builder().username("charlie").build();
5375 let result = bash.exec("echo $USER").await.unwrap();
5376 assert_eq!(result.stdout, "charlie\n");
5377 }
5378
5379 #[tokio::test]
5380 async fn test_custom_username_provisions_home_dir() {
5381 let mut bash = Bash::builder().username("eval").build();
5386 let result = bash
5387 .exec("echo hi > /home/eval/x.sh && cat /home/eval/x.sh")
5388 .await
5389 .unwrap();
5390 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
5391 assert_eq!(result.stdout, "hi\n");
5392 }
5393
5394 #[tokio::test]
5395 async fn test_custom_username_home_tilde_write() {
5396 let mut bash = Bash::builder().username("agent").build();
5398 let result = bash
5399 .exec("echo $HOME; echo data > ~/file.txt && cat ~/file.txt")
5400 .await
5401 .unwrap();
5402 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
5403 assert_eq!(result.stdout, "/home/agent\ndata\n");
5404 }
5405
5406 #[tokio::test]
5407 async fn test_default_username_provisions_home_dir() {
5408 let mut bash = Bash::new();
5410 let result = bash
5411 .exec("echo data > $HOME/f && cat $HOME/f")
5412 .await
5413 .unwrap();
5414 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
5415 assert_eq!(result.stdout, "data\n");
5416 }
5417
5418 #[tokio::test]
5419 async fn test_default_ppid_is_sandboxed() {
5420 let mut bash = Bash::new();
5421 let result = bash.exec("echo $PPID").await.unwrap();
5422 assert_eq!(result.stdout, "0\n");
5423 }
5424
5425 #[tokio::test]
5426 async fn test_custom_hostname() {
5427 let mut bash = Bash::builder().hostname("my-server").build();
5428 let result = bash.exec("hostname").await.unwrap();
5429 assert_eq!(result.stdout, "my-server\n");
5430 }
5431
5432 #[tokio::test]
5433 async fn test_custom_hostname_uname() {
5434 let mut bash = Bash::builder().hostname("custom-host").build();
5435 let result = bash.exec("uname -n").await.unwrap();
5436 assert_eq!(result.stdout, "custom-host\n");
5437 }
5438
5439 #[tokio::test]
5440 async fn test_default_username_and_hostname() {
5441 let mut bash = Bash::new();
5443 let result = bash.exec("whoami").await.unwrap();
5444 assert_eq!(result.stdout, "sandbox\n");
5445
5446 let result = bash.exec("hostname").await.unwrap();
5447 assert_eq!(result.stdout, "bashkit-sandbox\n");
5448 }
5449
5450 #[tokio::test]
5451 async fn test_custom_username_and_hostname_combined() {
5452 let mut bash = Bash::builder()
5453 .username("deploy")
5454 .hostname("prod-server-01")
5455 .build();
5456
5457 let result = bash.exec("whoami && hostname").await.unwrap();
5458 assert_eq!(result.stdout, "deploy\nprod-server-01\n");
5459
5460 let result = bash.exec("echo $USER").await.unwrap();
5461 assert_eq!(result.stdout, "deploy\n");
5462 }
5463
5464 mod custom_builtins {
5467 use super::*;
5468 use crate::builtins::{Builtin, Context};
5469 use crate::{ExecResult, ExecutionExtensions, Extension};
5470 use async_trait::async_trait;
5471
5472 struct Hello;
5474
5475 #[async_trait]
5476 impl Builtin for Hello {
5477 async fn execute(&self, _ctx: Context<'_>) -> crate::Result<ExecResult> {
5478 Ok(ExecResult::ok("Hello from custom builtin!\n".to_string()))
5479 }
5480 }
5481
5482 #[tokio::test]
5483 async fn test_custom_builtin_basic() {
5484 let mut bash = Bash::builder().builtin("hello", Box::new(Hello)).build();
5485
5486 let result = bash.exec("hello").await.unwrap();
5487 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5488 assert_eq!(result.exit_code, 0);
5489 }
5490
5491 struct ExecutionScoped;
5492
5493 #[async_trait]
5494 impl Builtin for ExecutionScoped {
5495 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5496 let value = ctx
5497 .execution_extension::<String>()
5498 .and_then(|value| value.try_with(Clone::clone).ok())
5499 .unwrap_or_else(|| "missing".to_string());
5500 Ok(ExecResult::ok(format!("{value}\n")))
5501 }
5502 }
5503
5504 #[tokio::test]
5505 async fn test_custom_builtin_execution_extensions_are_per_call() {
5506 let mut bash = Bash::builder()
5507 .builtin("read-ext", Box::new(ExecutionScoped))
5508 .build();
5509
5510 let result = bash
5511 .exec_with_extensions(
5512 "read-ext",
5513 ExecutionExtensions::new().with("scoped".to_string()),
5514 )
5515 .await
5516 .unwrap();
5517 assert_eq!(result.stdout, "scoped\n");
5518
5519 let result = bash.exec("read-ext").await.unwrap();
5520 assert_eq!(result.stdout, "missing\n");
5521 }
5522
5523 struct Greet;
5525
5526 #[async_trait]
5527 impl Builtin for Greet {
5528 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5529 let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World");
5530 Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
5531 }
5532 }
5533
5534 #[tokio::test]
5535 async fn test_custom_builtin_with_args() {
5536 let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
5537
5538 let result = bash.exec("greet").await.unwrap();
5539 assert_eq!(result.stdout, "Hello, World!\n");
5540
5541 let result = bash.exec("greet Alice").await.unwrap();
5542 assert_eq!(result.stdout, "Hello, Alice!\n");
5543
5544 let result = bash.exec("greet Bob Charlie").await.unwrap();
5545 assert_eq!(result.stdout, "Hello, Bob!\n");
5546 }
5547
5548 struct Upper;
5550
5551 #[async_trait]
5552 impl Builtin for Upper {
5553 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5554 let input = ctx.stdin.map(|stdin| &**stdin).unwrap_or("");
5555 Ok(ExecResult::ok(input.to_uppercase()))
5556 }
5557 }
5558
5559 #[tokio::test]
5560 async fn test_custom_builtin_with_stdin() {
5561 let mut bash = Bash::builder().builtin("upper", Box::new(Upper)).build();
5562
5563 let result = bash.exec("echo hello | upper").await.unwrap();
5564 assert_eq!(result.stdout, "HELLO\n");
5565 }
5566
5567 struct WriteFile;
5569
5570 #[async_trait]
5571 impl Builtin for WriteFile {
5572 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5573 if ctx.args.len() < 2 {
5574 return Ok(ExecResult::err(
5575 "Usage: writefile <path> <content>\n".to_string(),
5576 1,
5577 ));
5578 }
5579 let path = std::path::Path::new(&ctx.args[0]);
5580 let content = ctx.args[1..].join(" ");
5581 ctx.fs.write_file(path, content.as_bytes()).await?;
5582 Ok(ExecResult::ok(String::new()))
5583 }
5584 }
5585
5586 #[tokio::test]
5587 async fn test_custom_builtin_with_filesystem() {
5588 let mut bash = Bash::builder()
5589 .builtin("writefile", Box::new(WriteFile))
5590 .build();
5591
5592 bash.exec("writefile /tmp/test.txt custom content here")
5593 .await
5594 .unwrap();
5595
5596 let result = bash.exec("cat /tmp/test.txt").await.unwrap();
5597 assert_eq!(result.stdout, "custom content here");
5598 }
5599
5600 struct CustomEcho;
5602
5603 #[async_trait]
5604 impl Builtin for CustomEcho {
5605 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5606 let msg = ctx.args.join(" ");
5607 Ok(ExecResult::ok(format!("[CUSTOM] {}\n", msg)))
5608 }
5609 }
5610
5611 #[tokio::test]
5612 async fn test_custom_builtin_override_default() {
5613 let mut bash = Bash::builder()
5614 .builtin("echo", Box::new(CustomEcho))
5615 .build();
5616
5617 let result = bash.exec("echo hello world").await.unwrap();
5618 assert_eq!(result.stdout, "[CUSTOM] hello world\n");
5619 }
5620
5621 #[tokio::test]
5623 async fn test_multiple_custom_builtins() {
5624 let mut bash = Bash::builder()
5625 .builtin("hello", Box::new(Hello))
5626 .builtin("greet", Box::new(Greet))
5627 .builtin("upper", Box::new(Upper))
5628 .build();
5629
5630 let result = bash.exec("hello").await.unwrap();
5631 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5632
5633 let result = bash.exec("greet Test").await.unwrap();
5634 assert_eq!(result.stdout, "Hello, Test!\n");
5635
5636 let result = bash.exec("echo foo | upper").await.unwrap();
5637 assert_eq!(result.stdout, "FOO\n");
5638 }
5639
5640 struct GreetingExtension;
5641
5642 impl Extension for GreetingExtension {
5643 fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
5644 vec![
5645 ("hello-ext".to_string(), Box::new(Hello)),
5646 ("greet-ext".to_string(), Box::new(Greet)),
5647 ]
5648 }
5649 }
5650
5651 #[tokio::test]
5652 async fn test_extension_registers_multiple_builtins() {
5653 let mut bash = Bash::builder().extension(GreetingExtension).build();
5654
5655 let result = bash.exec("hello-ext").await.unwrap();
5656 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5657
5658 let result = bash.exec("greet-ext Extension").await.unwrap();
5659 assert_eq!(result.stdout, "Hello, Extension!\n");
5660 }
5661
5662 struct Counter {
5664 prefix: String,
5665 }
5666
5667 #[async_trait]
5668 impl Builtin for Counter {
5669 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5670 let count = ctx
5671 .args
5672 .first()
5673 .and_then(|s| s.parse::<i32>().ok())
5674 .unwrap_or(1);
5675 let mut output = String::new();
5676 for i in 1..=count {
5677 output.push_str(&format!("{}{}\n", self.prefix, i));
5678 }
5679 Ok(ExecResult::ok(output))
5680 }
5681 }
5682
5683 #[tokio::test]
5684 async fn test_custom_builtin_with_state() {
5685 let mut bash = Bash::builder()
5686 .builtin(
5687 "count",
5688 Box::new(Counter {
5689 prefix: "Item ".to_string(),
5690 }),
5691 )
5692 .build();
5693
5694 let result = bash.exec("count 3").await.unwrap();
5695 assert_eq!(result.stdout, "Item 1\nItem 2\nItem 3\n");
5696 }
5697
5698 struct Fail;
5700
5701 #[async_trait]
5702 impl Builtin for Fail {
5703 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5704 let code = ctx
5705 .args
5706 .first()
5707 .and_then(|s| s.parse::<i32>().ok())
5708 .unwrap_or(1);
5709 Ok(ExecResult::err(
5710 format!("Failed with code {}\n", code),
5711 code,
5712 ))
5713 }
5714 }
5715
5716 #[tokio::test]
5717 async fn test_custom_builtin_error() {
5718 let mut bash = Bash::builder().builtin("fail", Box::new(Fail)).build();
5719
5720 let result = bash.exec("fail 42").await.unwrap();
5721 assert_eq!(result.exit_code, 42);
5722 assert_eq!(result.stderr, "Failed with code 42\n");
5723 }
5724
5725 #[tokio::test]
5726 async fn test_custom_builtin_in_script() {
5727 let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
5728
5729 let script = r#"
5730 for name in Alice Bob Charlie; do
5731 greet $name
5732 done
5733 "#;
5734
5735 let result = bash.exec(script).await.unwrap();
5736 assert_eq!(
5737 result.stdout,
5738 "Hello, Alice!\nHello, Bob!\nHello, Charlie!\n"
5739 );
5740 }
5741
5742 #[tokio::test]
5743 async fn test_custom_builtin_with_conditionals() {
5744 let mut bash = Bash::builder()
5745 .builtin("fail", Box::new(Fail))
5746 .builtin("hello", Box::new(Hello))
5747 .build();
5748
5749 let result = bash.exec("fail 1 || hello").await.unwrap();
5750 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5751 assert_eq!(result.exit_code, 0);
5752
5753 let result = bash.exec("hello && fail 5").await.unwrap();
5754 assert_eq!(result.exit_code, 5);
5755 }
5756
5757 struct EnvReader;
5759
5760 #[async_trait]
5761 impl Builtin for EnvReader {
5762 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5763 let var_name = ctx.args.first().map(|s| s.as_str()).unwrap_or("HOME");
5764 let value = ctx
5765 .env
5766 .get(var_name)
5767 .map(|s| s.as_str())
5768 .unwrap_or("(not set)");
5769 Ok(ExecResult::ok(format!("{}={}\n", var_name, value)))
5770 }
5771 }
5772
5773 #[tokio::test]
5774 async fn test_custom_builtin_reads_env() {
5775 let mut bash = Bash::builder()
5776 .env("MY_VAR", "my_value")
5777 .builtin("readenv", Box::new(EnvReader))
5778 .build();
5779
5780 let result = bash.exec("readenv MY_VAR").await.unwrap();
5781 assert_eq!(result.stdout, "MY_VAR=my_value\n");
5782
5783 let result = bash.exec("readenv UNKNOWN").await.unwrap();
5784 assert_eq!(result.stdout, "UNKNOWN=(not set)\n");
5785 }
5786 }
5787
5788 #[tokio::test]
5791 async fn test_parser_timeout_default() {
5792 let limits = ExecutionLimits::default();
5794 assert_eq!(limits.parser_timeout, std::time::Duration::from_secs(5));
5795 }
5796
5797 #[tokio::test]
5798 async fn test_parser_timeout_custom() {
5799 let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_millis(100));
5801 assert_eq!(limits.parser_timeout, std::time::Duration::from_millis(100));
5802 }
5803
5804 #[tokio::test]
5805 async fn test_parser_timeout_normal_script() {
5806 let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_secs(1));
5808 let mut bash = Bash::builder().limits(limits).build();
5809 let result = bash.exec("echo hello").await.unwrap();
5810 assert_eq!(result.stdout, "hello\n");
5811 }
5812
5813 #[tokio::test]
5816 async fn test_parser_fuel_default() {
5817 let limits = ExecutionLimits::default();
5819 assert_eq!(limits.max_parser_operations, 100_000);
5820 }
5821
5822 #[tokio::test]
5823 async fn test_parser_fuel_custom() {
5824 let limits = ExecutionLimits::new().max_parser_operations(1000);
5826 assert_eq!(limits.max_parser_operations, 1000);
5827 }
5828
5829 #[tokio::test]
5830 async fn test_parser_fuel_normal_script() {
5831 let limits = ExecutionLimits::new().max_parser_operations(1000);
5833 let mut bash = Bash::builder().limits(limits).build();
5834 let result = bash.exec("echo hello").await.unwrap();
5835 assert_eq!(result.stdout, "hello\n");
5836 }
5837
5838 #[tokio::test]
5841 async fn test_input_size_limit_default() {
5842 let limits = ExecutionLimits::default();
5844 assert_eq!(limits.max_input_bytes, 10_000_000);
5845 }
5846
5847 #[tokio::test]
5848 async fn test_input_size_limit_custom() {
5849 let limits = ExecutionLimits::new().max_input_bytes(1000);
5851 assert_eq!(limits.max_input_bytes, 1000);
5852 }
5853
5854 #[tokio::test]
5855 async fn test_input_size_limit_enforced() {
5856 let limits = ExecutionLimits::new().max_input_bytes(10);
5858 let mut bash = Bash::builder().limits(limits).build();
5859
5860 let result = bash.exec("echo hello world").await;
5862 assert!(result.is_err());
5863 let err = result.unwrap_err();
5864 assert!(
5865 err.to_string().contains("input too large"),
5866 "Expected input size error, got: {}",
5867 err
5868 );
5869 }
5870
5871 #[tokio::test]
5872 async fn test_input_size_limit_normal_script() {
5873 let limits = ExecutionLimits::new().max_input_bytes(1000);
5875 let mut bash = Bash::builder().limits(limits).build();
5876 let result = bash.exec("echo hello").await.unwrap();
5877 assert_eq!(result.stdout, "hello\n");
5878 }
5879
5880 #[tokio::test]
5883 async fn test_ast_depth_limit_default() {
5884 let limits = ExecutionLimits::default();
5886 assert_eq!(limits.max_ast_depth, 100);
5887 }
5888
5889 #[tokio::test]
5890 async fn test_ast_depth_limit_custom() {
5891 let limits = ExecutionLimits::new().max_ast_depth(10);
5893 assert_eq!(limits.max_ast_depth, 10);
5894 }
5895
5896 #[tokio::test]
5897 async fn test_ast_depth_limit_normal_script() {
5898 let limits = ExecutionLimits::new().max_ast_depth(10);
5900 let mut bash = Bash::builder().limits(limits).build();
5901 let result = bash.exec("if true; then echo ok; fi").await.unwrap();
5902 assert_eq!(result.stdout, "ok\n");
5903 }
5904
5905 #[tokio::test]
5906 async fn test_ast_depth_limit_enforced() {
5907 let limits = ExecutionLimits::new().max_ast_depth(2);
5909 let mut bash = Bash::builder().limits(limits).build();
5910
5911 let result = bash
5913 .exec("if true; then if true; then if true; then echo nested; fi; fi; fi")
5914 .await;
5915 assert!(result.is_err());
5916 let err = result.unwrap_err();
5917 assert!(
5918 err.to_string().contains("AST nesting too deep"),
5919 "Expected AST depth error, got: {}",
5920 err
5921 );
5922 }
5923
5924 #[tokio::test]
5925 async fn test_parser_fuel_enforced() {
5926 let limits = ExecutionLimits::new().max_parser_operations(3);
5929 let mut bash = Bash::builder().limits(limits).build();
5930
5931 let result = bash.exec("echo a; echo b; echo c").await;
5933 assert!(result.is_err());
5934 let err = result.unwrap_err();
5935 assert!(
5936 err.to_string().contains("parser fuel exhausted"),
5937 "Expected parser fuel error, got: {}",
5938 err
5939 );
5940 }
5941
5942 #[tokio::test]
5945 async fn test_set_e_basic() {
5946 let mut bash = Bash::new();
5948 let result = bash
5949 .exec("set -e; true; false; echo should_not_reach")
5950 .await
5951 .unwrap();
5952 assert_eq!(result.stdout, "");
5953 assert_eq!(result.exit_code, 1);
5954 }
5955
5956 #[tokio::test]
5957 async fn test_set_e_after_failing_cmd() {
5958 let mut bash = Bash::new();
5960 let result = bash
5961 .exec("set -e; echo before; false; echo after")
5962 .await
5963 .unwrap();
5964 assert_eq!(result.stdout, "before\n");
5965 assert_eq!(result.exit_code, 1);
5966 }
5967
5968 #[tokio::test]
5969 async fn test_set_e_disabled() {
5970 let mut bash = Bash::new();
5972 let result = bash
5973 .exec("set -e; set +e; false; echo still_running")
5974 .await
5975 .unwrap();
5976 assert_eq!(result.stdout, "still_running\n");
5977 }
5978
5979 #[tokio::test]
5980 async fn test_set_e_in_pipeline_last() {
5981 let mut bash = Bash::new();
5983 let result = bash
5984 .exec("set -e; false | true; echo reached")
5985 .await
5986 .unwrap();
5987 assert_eq!(result.stdout, "reached\n");
5988 }
5989
5990 #[tokio::test]
5991 async fn test_set_e_in_if_condition() {
5992 let mut bash = Bash::new();
5994 let result = bash
5995 .exec("set -e; if false; then echo yes; else echo no; fi; echo done")
5996 .await
5997 .unwrap();
5998 assert_eq!(result.stdout, "no\ndone\n");
5999 }
6000
6001 #[tokio::test]
6002 async fn test_set_e_in_while_condition() {
6003 let mut bash = Bash::new();
6005 let result = bash
6006 .exec("set -e; x=0; while [ \"$x\" -lt 2 ]; do echo \"x=$x\"; x=$((x + 1)); done; echo done")
6007 .await
6008 .unwrap();
6009 assert_eq!(result.stdout, "x=0\nx=1\ndone\n");
6010 }
6011
6012 #[tokio::test]
6013 async fn test_set_e_in_brace_group() {
6014 let mut bash = Bash::new();
6016 let result = bash
6017 .exec("set -e; { echo start; false; echo unreached; }; echo after")
6018 .await
6019 .unwrap();
6020 assert_eq!(result.stdout, "start\n");
6021 assert_eq!(result.exit_code, 1);
6022 }
6023
6024 #[tokio::test]
6025 async fn test_set_e_and_chain() {
6026 let mut bash = Bash::new();
6028 let result = bash
6029 .exec("set -e; false && echo one; echo reached")
6030 .await
6031 .unwrap();
6032 assert_eq!(result.stdout, "reached\n");
6033 }
6034
6035 #[tokio::test]
6036 async fn test_set_e_or_chain() {
6037 let mut bash = Bash::new();
6039 let result = bash
6040 .exec("set -e; true || false; echo reached")
6041 .await
6042 .unwrap();
6043 assert_eq!(result.stdout, "reached\n");
6044 }
6045
6046 #[tokio::test]
6049 async fn test_tilde_expansion_basic() {
6050 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
6052 let result = bash.exec("echo ~").await.unwrap();
6053 assert_eq!(result.stdout, "/home/testuser\n");
6054 }
6055
6056 #[tokio::test]
6057 async fn test_tilde_expansion_with_path() {
6058 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
6060 let result = bash.exec("echo ~/documents/file.txt").await.unwrap();
6061 assert_eq!(result.stdout, "/home/testuser/documents/file.txt\n");
6062 }
6063
6064 #[tokio::test]
6065 async fn test_tilde_expansion_in_assignment() {
6066 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
6068 let result = bash.exec("DIR=~/data; echo $DIR").await.unwrap();
6069 assert_eq!(result.stdout, "/home/testuser/data\n");
6070 }
6071
6072 #[tokio::test]
6073 async fn test_tilde_expansion_default_home() {
6074 let mut bash = Bash::new();
6076 let result = bash.exec("echo ~").await.unwrap();
6077 assert_eq!(result.stdout, "/home/sandbox\n");
6078 }
6079
6080 #[tokio::test]
6081 async fn test_tilde_not_at_start() {
6082 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
6084 let result = bash.exec("echo foo~bar").await.unwrap();
6085 assert_eq!(result.stdout, "foo~bar\n");
6086 }
6087
6088 #[tokio::test]
6091 async fn test_special_var_dollar_dollar() {
6092 let mut bash = Bash::new();
6094 let result = bash.exec("echo $$").await.unwrap();
6095 let pid: u32 = result.stdout.trim().parse().expect("$$ should be a number");
6097 assert!(pid > 0, "$$ should be a positive number");
6098 }
6099
6100 #[tokio::test]
6101 async fn test_special_var_random() {
6102 let mut bash = Bash::new();
6104 let result = bash.exec("echo $RANDOM").await.unwrap();
6105 let random: u32 = result
6106 .stdout
6107 .trim()
6108 .parse()
6109 .expect("$RANDOM should be a number");
6110 assert!(random < 32768, "$RANDOM should be < 32768");
6111 }
6112
6113 #[tokio::test]
6114 async fn test_special_var_random_varies() {
6115 let mut bash = Bash::new();
6117 let result1 = bash.exec("echo $RANDOM").await.unwrap();
6118 let result2 = bash.exec("echo $RANDOM").await.unwrap();
6119 let _: u32 = result1
6123 .stdout
6124 .trim()
6125 .parse()
6126 .expect("$RANDOM should be a number");
6127 let _: u32 = result2
6128 .stdout
6129 .trim()
6130 .parse()
6131 .expect("$RANDOM should be a number");
6132 }
6133
6134 #[tokio::test]
6135 async fn test_random_different_instances() {
6136 let mut bash1 = Bash::new();
6139 let mut bash2 = Bash::new();
6140 let r1 = bash1.exec("echo $RANDOM").await.unwrap();
6141 let r2 = bash2.exec("echo $RANDOM").await.unwrap();
6142 let v1: u32 = r1.stdout.trim().parse().expect("should be a number");
6143 let v2: u32 = r2.stdout.trim().parse().expect("should be a number");
6144 assert!(v1 < 32768);
6145 assert!(v2 < 32768);
6146 assert_ne!(v1, v2, "separate instances should produce different values");
6148 }
6149
6150 #[tokio::test]
6151 async fn test_random_reseed() {
6152 let mut bash1 = Bash::new();
6154 let mut bash2 = Bash::new();
6155 bash1.exec("RANDOM=42").await.unwrap();
6156 bash2.exec("RANDOM=42").await.unwrap();
6157 let r1 = bash1.exec("echo $RANDOM").await.unwrap();
6158 let r2 = bash2.exec("echo $RANDOM").await.unwrap();
6159 assert_eq!(
6160 r1.stdout, r2.stdout,
6161 "same seed should produce same first value"
6162 );
6163 }
6164
6165 #[tokio::test]
6166 async fn test_random_sequential_varies() {
6167 let mut bash = Bash::new();
6169 let result = bash.exec("echo $RANDOM $RANDOM $RANDOM").await.unwrap();
6170 let values: Vec<u32> = result
6171 .stdout
6172 .split_whitespace()
6173 .map(|s| s.parse().expect("should be a number"))
6174 .collect();
6175 assert_eq!(values.len(), 3);
6176 assert!(
6178 values[0] != values[1] || values[1] != values[2],
6179 "sequential RANDOM calls should produce different values"
6180 );
6181 }
6182
6183 #[tokio::test]
6184 async fn test_special_var_lineno() {
6185 let mut bash = Bash::new();
6187 let result = bash.exec("echo $LINENO").await.unwrap();
6188 assert_eq!(result.stdout, "1\n");
6189 }
6190
6191 #[tokio::test]
6192 async fn test_lineno_multiline() {
6193 let mut bash = Bash::new();
6195 let result = bash
6196 .exec(
6197 r#"echo "line $LINENO"
6198echo "line $LINENO"
6199echo "line $LINENO""#,
6200 )
6201 .await
6202 .unwrap();
6203 assert_eq!(result.stdout, "line 1\nline 2\nline 3\n");
6204 }
6205
6206 #[tokio::test]
6207 async fn test_lineno_in_loop() {
6208 let mut bash = Bash::new();
6210 let result = bash
6211 .exec(
6212 r#"for i in 1 2; do
6213 echo "loop $LINENO"
6214done"#,
6215 )
6216 .await
6217 .unwrap();
6218 assert_eq!(result.stdout, "loop 2\nloop 2\n");
6220 }
6221
6222 #[tokio::test]
6225 async fn test_file_test_r_readable() {
6226 let mut bash = Bash::new();
6228 bash.exec("echo hello > /tmp/readable.txt").await.unwrap();
6229 let result = bash
6230 .exec("test -r /tmp/readable.txt && echo yes")
6231 .await
6232 .unwrap();
6233 assert_eq!(result.stdout, "yes\n");
6234 }
6235
6236 #[tokio::test]
6237 async fn test_file_test_r_not_exists() {
6238 let mut bash = Bash::new();
6240 let result = bash
6241 .exec("test -r /tmp/nonexistent.txt && echo yes || echo no")
6242 .await
6243 .unwrap();
6244 assert_eq!(result.stdout, "no\n");
6245 }
6246
6247 #[tokio::test]
6248 async fn test_file_test_w_writable() {
6249 let mut bash = Bash::new();
6251 bash.exec("echo hello > /tmp/writable.txt").await.unwrap();
6252 let result = bash
6253 .exec("test -w /tmp/writable.txt && echo yes")
6254 .await
6255 .unwrap();
6256 assert_eq!(result.stdout, "yes\n");
6257 }
6258
6259 #[tokio::test]
6260 async fn test_file_test_x_executable() {
6261 let mut bash = Bash::new();
6263 bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
6264 .await
6265 .unwrap();
6266 bash.exec("chmod 755 /tmp/script.sh").await.unwrap();
6267 let result = bash
6268 .exec("test -x /tmp/script.sh && echo yes")
6269 .await
6270 .unwrap();
6271 assert_eq!(result.stdout, "yes\n");
6272 }
6273
6274 #[tokio::test]
6275 async fn test_file_test_x_not_executable() {
6276 let mut bash = Bash::new();
6278 bash.exec("echo 'data' > /tmp/noexec.txt").await.unwrap();
6279 bash.exec("chmod 644 /tmp/noexec.txt").await.unwrap();
6280 let result = bash
6281 .exec("test -x /tmp/noexec.txt && echo yes || echo no")
6282 .await
6283 .unwrap();
6284 assert_eq!(result.stdout, "no\n");
6285 }
6286
6287 #[tokio::test]
6288 async fn test_file_test_e_exists() {
6289 let mut bash = Bash::new();
6291 bash.exec("echo hello > /tmp/exists.txt").await.unwrap();
6292 let result = bash
6293 .exec("test -e /tmp/exists.txt && echo yes")
6294 .await
6295 .unwrap();
6296 assert_eq!(result.stdout, "yes\n");
6297 }
6298
6299 #[tokio::test]
6300 async fn test_file_test_f_regular() {
6301 let mut bash = Bash::new();
6303 bash.exec("echo hello > /tmp/regular.txt").await.unwrap();
6304 let result = bash
6305 .exec("test -f /tmp/regular.txt && echo yes")
6306 .await
6307 .unwrap();
6308 assert_eq!(result.stdout, "yes\n");
6309 }
6310
6311 #[tokio::test]
6312 async fn test_file_test_d_directory() {
6313 let mut bash = Bash::new();
6315 bash.exec("mkdir -p /tmp/mydir").await.unwrap();
6316 let result = bash.exec("test -d /tmp/mydir && echo yes").await.unwrap();
6317 assert_eq!(result.stdout, "yes\n");
6318 }
6319
6320 #[tokio::test]
6321 async fn test_file_test_s_size() {
6322 let mut bash = Bash::new();
6324 bash.exec("echo hello > /tmp/nonempty.txt").await.unwrap();
6325 let result = bash
6326 .exec("test -s /tmp/nonempty.txt && echo yes")
6327 .await
6328 .unwrap();
6329 assert_eq!(result.stdout, "yes\n");
6330 }
6331
6332 #[tokio::test]
6337 async fn test_redirect_both_stdout_stderr() {
6338 let mut bash = Bash::new();
6340 let result = bash.exec("echo hello &> /tmp/out.txt").await.unwrap();
6342 assert_eq!(result.stdout, "");
6344 let check = bash.exec("cat /tmp/out.txt").await.unwrap();
6346 assert_eq!(check.stdout, "hello\n");
6347 }
6348
6349 #[tokio::test]
6350 async fn test_stderr_redirect_to_file() {
6351 let mut bash = Bash::new();
6355 bash.exec("echo stdout; echo stderr 2> /tmp/err.txt")
6357 .await
6358 .unwrap();
6359 }
6362
6363 #[tokio::test]
6364 async fn test_fd_redirect_parsing() {
6365 let mut bash = Bash::new();
6367 let result = bash.exec("true 2> /tmp/err.txt").await.unwrap();
6369 assert_eq!(result.exit_code, 0);
6370 }
6371
6372 #[tokio::test]
6373 async fn test_fd_redirect_append_parsing() {
6374 let mut bash = Bash::new();
6376 let result = bash.exec("true 2>> /tmp/err.txt").await.unwrap();
6377 assert_eq!(result.exit_code, 0);
6378 }
6379
6380 #[tokio::test]
6381 async fn test_fd_dup_parsing() {
6382 let mut bash = Bash::new();
6384 let result = bash.exec("echo hello 2>&1").await.unwrap();
6385 assert_eq!(result.stdout, "hello\n");
6386 assert_eq!(result.exit_code, 0);
6387 }
6388
6389 #[tokio::test]
6390 async fn test_dup_output_redirect_stdout_to_stderr() {
6391 let mut bash = Bash::new();
6393 let result = bash.exec("echo hello >&2").await.unwrap();
6394 assert_eq!(result.stdout, "");
6396 assert_eq!(result.stderr, "hello\n");
6397 }
6398
6399 #[tokio::test]
6400 async fn test_lexer_redirect_both() {
6401 let mut bash = Bash::new();
6403 let result = bash.exec("echo test &> /tmp/both.txt").await.unwrap();
6405 assert_eq!(result.stdout, "");
6406 let check = bash.exec("cat /tmp/both.txt").await.unwrap();
6407 assert_eq!(check.stdout, "test\n");
6408 }
6409
6410 #[tokio::test]
6411 async fn test_lexer_dup_output() {
6412 let mut bash = Bash::new();
6414 let result = bash.exec("echo test >&2").await.unwrap();
6415 assert_eq!(result.stdout, "");
6416 assert_eq!(result.stderr, "test\n");
6417 }
6418
6419 #[tokio::test]
6420 async fn test_digit_before_redirect() {
6421 let mut bash = Bash::new();
6423 let result = bash.exec("echo hello 2> /tmp/err.txt").await.unwrap();
6425 assert_eq!(result.exit_code, 0);
6426 assert_eq!(result.stdout, "hello\n");
6428 }
6429
6430 #[tokio::test]
6435 async fn test_arithmetic_logical_and_true() {
6436 let mut bash = Bash::new();
6438 let result = bash.exec("echo $((1 && 1))").await.unwrap();
6439 assert_eq!(result.stdout, "1\n");
6440 }
6441
6442 #[tokio::test]
6443 async fn test_arithmetic_logical_and_false_left() {
6444 let mut bash = Bash::new();
6446 let result = bash.exec("echo $((0 && 1))").await.unwrap();
6447 assert_eq!(result.stdout, "0\n");
6448 }
6449
6450 #[tokio::test]
6451 async fn test_arithmetic_logical_and_false_right() {
6452 let mut bash = Bash::new();
6454 let result = bash.exec("echo $((1 && 0))").await.unwrap();
6455 assert_eq!(result.stdout, "0\n");
6456 }
6457
6458 #[tokio::test]
6459 async fn test_arithmetic_logical_or_false() {
6460 let mut bash = Bash::new();
6462 let result = bash.exec("echo $((0 || 0))").await.unwrap();
6463 assert_eq!(result.stdout, "0\n");
6464 }
6465
6466 #[tokio::test]
6467 async fn test_arithmetic_logical_or_true_left() {
6468 let mut bash = Bash::new();
6470 let result = bash.exec("echo $((1 || 0))").await.unwrap();
6471 assert_eq!(result.stdout, "1\n");
6472 }
6473
6474 #[tokio::test]
6475 async fn test_arithmetic_logical_or_true_right() {
6476 let mut bash = Bash::new();
6478 let result = bash.exec("echo $((0 || 1))").await.unwrap();
6479 assert_eq!(result.stdout, "1\n");
6480 }
6481
6482 #[tokio::test]
6483 async fn test_arithmetic_logical_combined() {
6484 let mut bash = Bash::new();
6486 let result = bash.exec("echo $((5 > 3 && 2 < 4))").await.unwrap();
6488 assert_eq!(result.stdout, "1\n");
6489 }
6490
6491 #[tokio::test]
6492 async fn test_arithmetic_logical_with_comparison() {
6493 let mut bash = Bash::new();
6495 let result = bash.exec("echo $((5 < 3 || 2 < 4))").await.unwrap();
6497 assert_eq!(result.stdout, "1\n");
6498 }
6499
6500 #[tokio::test]
6501 async fn test_arithmetic_multibyte_no_panic() {
6502 let mut bash = Bash::new();
6504 let result = bash.exec("echo $((0,1))").await.unwrap();
6506 assert_eq!(result.stdout, "1\n");
6507 let _ = bash.exec("echo $((\u{00e9}+1))").await;
6509 }
6510
6511 #[tokio::test]
6516 async fn test_brace_expansion_list() {
6517 let mut bash = Bash::new();
6519 let result = bash.exec("echo {a,b,c}").await.unwrap();
6520 assert_eq!(result.stdout, "a b c\n");
6521 }
6522
6523 #[tokio::test]
6524 async fn test_brace_expansion_with_prefix() {
6525 let mut bash = Bash::new();
6527 let result = bash.exec("echo file{1,2,3}.txt").await.unwrap();
6528 assert_eq!(result.stdout, "file1.txt file2.txt file3.txt\n");
6529 }
6530
6531 #[tokio::test]
6532 async fn test_brace_expansion_numeric_range() {
6533 let mut bash = Bash::new();
6535 let result = bash.exec("echo {1..5}").await.unwrap();
6536 assert_eq!(result.stdout, "1 2 3 4 5\n");
6537 }
6538
6539 #[tokio::test]
6540 async fn test_brace_expansion_char_range() {
6541 let mut bash = Bash::new();
6543 let result = bash.exec("echo {a..e}").await.unwrap();
6544 assert_eq!(result.stdout, "a b c d e\n");
6545 }
6546
6547 #[tokio::test]
6548 async fn test_brace_expansion_reverse_range() {
6549 let mut bash = Bash::new();
6551 let result = bash.exec("echo {5..1}").await.unwrap();
6552 assert_eq!(result.stdout, "5 4 3 2 1\n");
6553 }
6554
6555 #[tokio::test]
6556 async fn test_brace_expansion_nested() {
6557 let mut bash = Bash::new();
6559 let result = bash.exec("echo {a,b}{1,2}").await.unwrap();
6560 assert_eq!(result.stdout, "a1 a2 b1 b2\n");
6561 }
6562
6563 #[tokio::test]
6564 async fn test_brace_expansion_with_suffix() {
6565 let mut bash = Bash::new();
6567 let result = bash.exec("echo pre{x,y}suf").await.unwrap();
6568 assert_eq!(result.stdout, "prexsuf preysuf\n");
6569 }
6570
6571 #[tokio::test]
6572 async fn test_brace_expansion_empty_item() {
6573 let mut bash = Bash::new();
6575 let result = bash.exec("echo x{,y}z").await.unwrap();
6576 assert_eq!(result.stdout, "xz xyz\n");
6577 }
6578
6579 #[tokio::test]
6584 async fn test_string_less_than() {
6585 let mut bash = Bash::new();
6586 let result = bash
6587 .exec("test apple '<' banana && echo yes")
6588 .await
6589 .unwrap();
6590 assert_eq!(result.stdout, "yes\n");
6591 }
6592
6593 #[tokio::test]
6594 async fn test_string_greater_than() {
6595 let mut bash = Bash::new();
6596 let result = bash
6597 .exec("test banana '>' apple && echo yes")
6598 .await
6599 .unwrap();
6600 assert_eq!(result.stdout, "yes\n");
6601 }
6602
6603 #[tokio::test]
6604 async fn test_string_less_than_false() {
6605 let mut bash = Bash::new();
6606 let result = bash
6607 .exec("test banana '<' apple && echo yes || echo no")
6608 .await
6609 .unwrap();
6610 assert_eq!(result.stdout, "no\n");
6611 }
6612
6613 #[tokio::test]
6618 async fn test_array_indices_basic() {
6619 let mut bash = Bash::new();
6621 let result = bash.exec("arr=(a b c); echo ${!arr[@]}").await.unwrap();
6622 assert_eq!(result.stdout, "0 1 2\n");
6623 }
6624
6625 #[tokio::test]
6626 async fn test_array_indices_sparse() {
6627 let mut bash = Bash::new();
6629 let result = bash
6630 .exec("arr[0]=a; arr[5]=b; arr[10]=c; echo ${!arr[@]}")
6631 .await
6632 .unwrap();
6633 assert_eq!(result.stdout, "0 5 10\n");
6634 }
6635
6636 #[tokio::test]
6637 async fn test_array_indices_star() {
6638 let mut bash = Bash::new();
6640 let result = bash.exec("arr=(x y z); echo ${!arr[*]}").await.unwrap();
6641 assert_eq!(result.stdout, "0 1 2\n");
6642 }
6643
6644 #[tokio::test]
6645 async fn test_array_indices_empty() {
6646 let mut bash = Bash::new();
6648 let result = bash.exec("arr=(); echo \"${!arr[@]}\"").await.unwrap();
6649 assert_eq!(result.stdout, "\n");
6650 }
6651
6652 #[tokio::test]
6657 async fn test_text_file_basic() {
6658 let mut bash = Bash::builder()
6659 .mount_text("/config/app.conf", "debug=true\nport=8080\n")
6660 .build();
6661
6662 let result = bash.exec("cat /config/app.conf").await.unwrap();
6663 assert_eq!(result.stdout, "debug=true\nport=8080\n");
6664 }
6665
6666 #[tokio::test]
6667 async fn test_text_file_multiple() {
6668 let mut bash = Bash::builder()
6669 .mount_text("/data/file1.txt", "content one")
6670 .mount_text("/data/file2.txt", "content two")
6671 .mount_text("/other/file3.txt", "content three")
6672 .build();
6673
6674 let result = bash.exec("cat /data/file1.txt").await.unwrap();
6675 assert_eq!(result.stdout, "content one");
6676
6677 let result = bash.exec("cat /data/file2.txt").await.unwrap();
6678 assert_eq!(result.stdout, "content two");
6679
6680 let result = bash.exec("cat /other/file3.txt").await.unwrap();
6681 assert_eq!(result.stdout, "content three");
6682 }
6683
6684 #[tokio::test]
6685 async fn test_text_file_nested_directory() {
6686 let mut bash = Bash::builder()
6688 .mount_text("/a/b/c/d/file.txt", "nested content")
6689 .build();
6690
6691 let result = bash.exec("cat /a/b/c/d/file.txt").await.unwrap();
6692 assert_eq!(result.stdout, "nested content");
6693 }
6694
6695 #[tokio::test]
6696 async fn test_text_file_mode() {
6697 let bash = Bash::builder()
6698 .mount_text("/tmp/writable.txt", "content")
6699 .build();
6700
6701 let stat = bash
6702 .fs()
6703 .stat(std::path::Path::new("/tmp/writable.txt"))
6704 .await
6705 .unwrap();
6706 assert_eq!(stat.mode, 0o644);
6707 }
6708
6709 #[tokio::test]
6710 async fn test_readonly_text_basic() {
6711 let mut bash = Bash::builder()
6712 .mount_readonly_text("/etc/version", "1.2.3")
6713 .build();
6714
6715 let result = bash.exec("cat /etc/version").await.unwrap();
6716 assert_eq!(result.stdout, "1.2.3");
6717 }
6718
6719 #[tokio::test]
6720 async fn test_readonly_text_mode() {
6721 let bash = Bash::builder()
6722 .mount_readonly_text("/etc/readonly.conf", "immutable")
6723 .build();
6724
6725 let stat = bash
6726 .fs()
6727 .stat(std::path::Path::new("/etc/readonly.conf"))
6728 .await
6729 .unwrap();
6730 assert_eq!(stat.mode, 0o444);
6731 }
6732
6733 #[tokio::test]
6734 async fn test_text_file_mixed_readonly_writable() {
6735 let bash = Bash::builder()
6736 .mount_text("/data/writable.txt", "can edit")
6737 .mount_readonly_text("/data/readonly.txt", "cannot edit")
6738 .build();
6739
6740 let writable_stat = bash
6741 .fs()
6742 .stat(std::path::Path::new("/data/writable.txt"))
6743 .await
6744 .unwrap();
6745 let readonly_stat = bash
6746 .fs()
6747 .stat(std::path::Path::new("/data/readonly.txt"))
6748 .await
6749 .unwrap();
6750
6751 assert_eq!(writable_stat.mode, 0o644);
6752 assert_eq!(readonly_stat.mode, 0o444);
6753 }
6754
6755 #[tokio::test]
6756 async fn test_text_file_with_env() {
6757 let mut bash = Bash::builder()
6759 .env("APP_NAME", "testapp")
6760 .mount_text("/config/app.conf", "name=${APP_NAME}")
6761 .build();
6762
6763 let result = bash.exec("echo $APP_NAME").await.unwrap();
6764 assert_eq!(result.stdout, "testapp\n");
6765
6766 let result = bash.exec("cat /config/app.conf").await.unwrap();
6767 assert_eq!(result.stdout, "name=${APP_NAME}");
6768 }
6769
6770 #[tokio::test]
6771 #[cfg(feature = "jq")]
6772 async fn test_text_file_json() {
6773 let mut bash = Bash::builder()
6774 .mount_text("/data/users.json", r#"["alice", "bob", "charlie"]"#)
6775 .build();
6776
6777 let result = bash.exec("cat /data/users.json | jq '.[0]'").await.unwrap();
6778 assert_eq!(result.stdout, "\"alice\"\n");
6779 }
6780
6781 #[tokio::test]
6782 async fn test_mount_with_custom_filesystem() {
6783 let custom_fs = std::sync::Arc::new(InMemoryFs::new());
6785
6786 custom_fs
6788 .write_file(std::path::Path::new("/base.txt"), b"from base")
6789 .await
6790 .unwrap();
6791
6792 let mut bash = Bash::builder()
6793 .fs(custom_fs)
6794 .mount_text("/mounted.txt", "from mount")
6795 .mount_readonly_text("/readonly.txt", "immutable")
6796 .build();
6797
6798 let result = bash.exec("cat /base.txt").await.unwrap();
6800 assert_eq!(result.stdout, "from base");
6801
6802 let result = bash.exec("cat /mounted.txt").await.unwrap();
6804 assert_eq!(result.stdout, "from mount");
6805
6806 let result = bash.exec("cat /readonly.txt").await.unwrap();
6807 assert_eq!(result.stdout, "immutable");
6808
6809 let stat = bash
6811 .fs()
6812 .stat(std::path::Path::new("/readonly.txt"))
6813 .await
6814 .unwrap();
6815 assert_eq!(stat.mode, 0o444);
6816 }
6817
6818 #[tokio::test]
6819 async fn test_mount_overwrites_base_file() {
6820 let custom_fs = std::sync::Arc::new(InMemoryFs::new());
6822 custom_fs
6823 .write_file(std::path::Path::new("/config.txt"), b"original")
6824 .await
6825 .unwrap();
6826
6827 let mut bash = Bash::builder()
6828 .fs(custom_fs)
6829 .mount_text("/config.txt", "overwritten")
6830 .build();
6831
6832 let result = bash.exec("cat /config.txt").await.unwrap();
6833 assert_eq!(result.stdout, "overwritten");
6834 }
6835
6836 #[tokio::test]
6837 async fn test_mount_preserves_custom_fs_limits() {
6838 let limited_fs =
6839 std::sync::Arc::new(InMemoryFs::with_limits(FsLimits::new().max_total_bytes(32)));
6840
6841 let bash = Bash::builder()
6842 .fs(limited_fs)
6843 .mount_text("/mounted.txt", "seed")
6844 .build();
6845
6846 let write_err = bash
6847 .fs()
6848 .write_file(
6849 std::path::Path::new("/too-big.txt"),
6850 b"this payload should exceed thirty-two bytes",
6851 )
6852 .await;
6853 assert!(write_err.is_err(), "custom fs limits should still apply");
6854 }
6855
6856 #[tokio::test]
6857 async fn test_mount_text_respects_filesystem_limits() {
6858 let limited_fs = std::sync::Arc::new(InMemoryFs::with_limits(
6859 FsLimits::new().max_total_bytes(5).max_file_size(5),
6860 ));
6861
6862 let bash = Bash::builder()
6863 .fs(limited_fs)
6864 .mount_text("/too-large.txt", "123456")
6865 .build();
6866
6867 let exists = bash
6868 .fs()
6869 .exists(std::path::Path::new("/too-large.txt"))
6870 .await
6871 .unwrap();
6872 assert!(!exists, "mount_text should not bypass configured FsLimits");
6873 }
6874
6875 #[tokio::test]
6880 async fn test_parse_error_includes_line_number() {
6881 let mut bash = Bash::new();
6883 let result = bash
6884 .exec(
6885 r#"echo ok
6886if true; then
6887echo missing fi"#,
6888 )
6889 .await;
6890 assert!(result.is_err());
6892 let err = result.unwrap_err();
6893 let err_msg = format!("{}", err);
6894 assert!(
6896 err_msg.contains("line") || err_msg.contains("parse"),
6897 "Error should be a parse error: {}",
6898 err_msg
6899 );
6900 }
6901
6902 #[tokio::test]
6903 async fn test_parse_error_on_specific_line() {
6904 use crate::parser::Parser;
6906 let script = "echo line1\necho line2\nif true; then\n";
6907 let result = Parser::new(script).parse();
6908 assert!(result.is_err());
6909 let err = result.unwrap_err();
6910 let err_msg = format!("{}", err);
6911 assert!(
6913 err_msg.contains("expected") || err_msg.contains("syntax error"),
6914 "Error should be a parse error: {}",
6915 err_msg
6916 );
6917 }
6918
6919 #[tokio::test]
6922 async fn test_cd_to_root_and_ls() {
6923 let mut bash = Bash::new();
6925 let result = bash.exec("cd / && ls").await.unwrap();
6926 assert_eq!(
6927 result.exit_code, 0,
6928 "cd / && ls should succeed: {}",
6929 result.stderr
6930 );
6931 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6932 assert!(result.stdout.contains("home"), "Root should contain home");
6933 }
6934
6935 #[tokio::test]
6936 async fn test_cd_to_root_and_pwd() {
6937 let mut bash = Bash::new();
6939 let result = bash.exec("cd / && pwd").await.unwrap();
6940 assert_eq!(result.exit_code, 0, "cd / && pwd should succeed");
6941 assert_eq!(result.stdout.trim(), "/");
6942 }
6943
6944 #[tokio::test]
6945 async fn test_cd_to_root_and_ls_dot() {
6946 let mut bash = Bash::new();
6948 let result = bash.exec("cd / && ls .").await.unwrap();
6949 assert_eq!(
6950 result.exit_code, 0,
6951 "cd / && ls . should succeed: {}",
6952 result.stderr
6953 );
6954 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6955 assert!(result.stdout.contains("home"), "Root should contain home");
6956 }
6957
6958 #[tokio::test]
6959 async fn test_ls_root_directly() {
6960 let mut bash = Bash::new();
6962 let result = bash.exec("ls /").await.unwrap();
6963 assert_eq!(
6964 result.exit_code, 0,
6965 "ls / should succeed: {}",
6966 result.stderr
6967 );
6968 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6969 assert!(result.stdout.contains("home"), "Root should contain home");
6970 assert!(result.stdout.contains("dev"), "Root should contain dev");
6971 }
6972
6973 #[tokio::test]
6974 async fn test_ls_root_long_format() {
6975 let mut bash = Bash::new();
6977 let result = bash.exec("ls -la /").await.unwrap();
6978 assert_eq!(
6979 result.exit_code, 0,
6980 "ls -la / should succeed: {}",
6981 result.stderr
6982 );
6983 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6984 assert!(
6985 result.stdout.contains("drw"),
6986 "Should show directory permissions"
6987 );
6988 }
6989
6990 #[tokio::test]
6993 async fn test_heredoc_redirect_to_file() {
6994 let mut bash = Bash::new();
6996 let result = bash
6997 .exec("cat > /tmp/out.txt <<'EOF'\nhello\nworld\nEOF\ncat /tmp/out.txt")
6998 .await
6999 .unwrap();
7000 assert_eq!(result.stdout, "hello\nworld\n");
7001 assert_eq!(result.exit_code, 0);
7002 }
7003
7004 #[tokio::test]
7005 async fn test_heredoc_redirect_to_file_unquoted() {
7006 let mut bash = Bash::new();
7007 let result = bash
7008 .exec("cat > /tmp/out.txt <<EOF\nhello\nworld\nEOF\ncat /tmp/out.txt")
7009 .await
7010 .unwrap();
7011 assert_eq!(result.stdout, "hello\nworld\n");
7012 assert_eq!(result.exit_code, 0);
7013 }
7014
7015 #[tokio::test]
7018 async fn test_pipe_to_while_read() {
7019 let mut bash = Bash::new();
7021 let result = bash
7022 .exec("echo -e 'a\\nb\\nc' | while read line; do echo \"got: $line\"; done")
7023 .await
7024 .unwrap();
7025 assert!(
7026 result.stdout.contains("got: a"),
7027 "stdout: {}",
7028 result.stdout
7029 );
7030 assert!(
7031 result.stdout.contains("got: b"),
7032 "stdout: {}",
7033 result.stdout
7034 );
7035 assert!(
7036 result.stdout.contains("got: c"),
7037 "stdout: {}",
7038 result.stdout
7039 );
7040 }
7041
7042 #[tokio::test]
7043 async fn test_pipe_to_while_read_count() {
7044 let mut bash = Bash::new();
7045 let result = bash
7046 .exec("printf 'x\\ny\\nz\\n' | while read line; do echo $line; done")
7047 .await
7048 .unwrap();
7049 assert_eq!(result.stdout, "x\ny\nz\n");
7050 }
7051
7052 #[tokio::test]
7055 async fn test_source_loads_functions() {
7056 let mut bash = Bash::new();
7057 bash.exec("cat > /tmp/lib.sh <<'EOF'\ngreet() { echo \"hello $1\"; }\nEOF")
7059 .await
7060 .unwrap();
7061 let result = bash.exec("source /tmp/lib.sh; greet world").await.unwrap();
7062 assert_eq!(result.stdout, "hello world\n");
7063 assert_eq!(result.exit_code, 0);
7064 }
7065
7066 #[tokio::test]
7067 async fn test_source_loads_variables() {
7068 let mut bash = Bash::new();
7069 bash.exec("echo 'MY_VAR=loaded' > /tmp/vars.sh")
7070 .await
7071 .unwrap();
7072 let result = bash
7073 .exec("source /tmp/vars.sh; echo $MY_VAR")
7074 .await
7075 .unwrap();
7076 assert_eq!(result.stdout, "loaded\n");
7077 }
7078
7079 #[tokio::test]
7082 async fn test_chmod_symbolic_plus_x() {
7083 let mut bash = Bash::new();
7084 bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
7085 .await
7086 .unwrap();
7087 let result = bash.exec("chmod +x /tmp/script.sh").await.unwrap();
7088 assert_eq!(
7089 result.exit_code, 0,
7090 "chmod +x should succeed: {}",
7091 result.stderr
7092 );
7093 }
7094
7095 #[tokio::test]
7096 async fn test_chmod_symbolic_u_plus_x() {
7097 let mut bash = Bash::new();
7098 bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
7099 let result = bash.exec("chmod u+x /tmp/file.txt").await.unwrap();
7100 assert_eq!(
7101 result.exit_code, 0,
7102 "chmod u+x should succeed: {}",
7103 result.stderr
7104 );
7105 }
7106
7107 #[tokio::test]
7108 async fn test_chmod_symbolic_a_plus_r() {
7109 let mut bash = Bash::new();
7110 bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
7111 let result = bash.exec("chmod a+r /tmp/file.txt").await.unwrap();
7112 assert_eq!(
7113 result.exit_code, 0,
7114 "chmod a+r should succeed: {}",
7115 result.stderr
7116 );
7117 }
7118
7119 #[tokio::test]
7122 async fn test_awk_array_length() {
7123 let mut bash = Bash::new();
7125 let result = bash
7126 .exec(r#"echo "" | awk 'BEGIN{a[1]="x"; a[2]="y"; a[3]="z"} END{print length(a)}'"#)
7127 .await
7128 .unwrap();
7129 assert_eq!(result.stdout, "3\n");
7130 }
7131
7132 #[tokio::test]
7133 async fn test_awk_array_read_after_split() {
7134 let mut bash = Bash::new();
7136 let result = bash
7137 .exec(r#"echo "a:b:c" | awk '{n=split($0,arr,":"); for(i=1;i<=n;i++) print arr[i]}'"#)
7138 .await
7139 .unwrap();
7140 assert_eq!(result.stdout, "a\nb\nc\n");
7141 }
7142
7143 #[tokio::test]
7144 async fn test_awk_array_word_count_pattern() {
7145 let mut bash = Bash::new();
7147 let result = bash
7148 .exec(
7149 r#"printf "apple\nbanana\napple\ncherry\nbanana\napple" | awk '{count[$1]++} END{for(w in count) print w, count[w]}'"#,
7150 )
7151 .await
7152 .unwrap();
7153 assert!(
7154 result.stdout.contains("apple 3"),
7155 "stdout: {}",
7156 result.stdout
7157 );
7158 assert!(
7159 result.stdout.contains("banana 2"),
7160 "stdout: {}",
7161 result.stdout
7162 );
7163 assert!(
7164 result.stdout.contains("cherry 1"),
7165 "stdout: {}",
7166 result.stdout
7167 );
7168 }
7169
7170 #[tokio::test]
7173 async fn test_exec_streaming_for_loop() {
7174 let chunks = Arc::new(Mutex::new(Vec::new()));
7175 let chunks_cb = chunks.clone();
7176 let mut bash = Bash::new();
7177
7178 let result = bash
7179 .exec_streaming(
7180 "for i in 1 2 3; do echo $i; done",
7181 Box::new(move |stdout, _stderr| {
7182 chunks_cb.lock().unwrap().push(stdout.to_string());
7183 }),
7184 )
7185 .await
7186 .unwrap();
7187
7188 assert_eq!(result.stdout, "1\n2\n3\n");
7189 assert_eq!(
7190 *chunks.lock().unwrap(),
7191 vec!["1\n", "2\n", "3\n"],
7192 "each loop iteration should stream separately"
7193 );
7194 }
7195
7196 #[tokio::test]
7197 async fn test_exec_streaming_while_loop() {
7198 let chunks = Arc::new(Mutex::new(Vec::new()));
7199 let chunks_cb = chunks.clone();
7200 let mut bash = Bash::new();
7201
7202 let result = bash
7203 .exec_streaming(
7204 "i=0; while [ $i -lt 3 ]; do i=$((i+1)); echo $i; done",
7205 Box::new(move |stdout, _stderr| {
7206 chunks_cb.lock().unwrap().push(stdout.to_string());
7207 }),
7208 )
7209 .await
7210 .unwrap();
7211
7212 assert_eq!(result.stdout, "1\n2\n3\n");
7213 let chunks = chunks.lock().unwrap();
7214 assert!(
7216 chunks.contains(&"1\n".to_string()),
7217 "should contain first iteration output"
7218 );
7219 assert!(
7220 chunks.contains(&"2\n".to_string()),
7221 "should contain second iteration output"
7222 );
7223 assert!(
7224 chunks.contains(&"3\n".to_string()),
7225 "should contain third iteration output"
7226 );
7227 }
7228
7229 #[tokio::test]
7230 async fn test_exec_streaming_no_callback_still_works() {
7231 let mut bash = Bash::new();
7233 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
7234 assert_eq!(result.stdout, "a\nb\nc\n");
7235 }
7236
7237 #[tokio::test]
7238 async fn test_exec_streaming_cancel_clears_callback() {
7239 use std::time::Duration;
7240
7241 let chunks = Arc::new(Mutex::new(Vec::new()));
7242 let chunks_cb = chunks.clone();
7243 let mut bash = Bash::new();
7244
7245 let timed_out = tokio::time::timeout(
7246 Duration::from_millis(10),
7247 bash.exec_streaming(
7248 "sleep 1; echo should-not-run",
7249 Box::new(move |stdout, stderr| {
7250 chunks_cb
7251 .lock()
7252 .unwrap()
7253 .push((stdout.to_string(), stderr.to_string()));
7254 }),
7255 ),
7256 )
7257 .await;
7258
7259 assert!(timed_out.is_err(), "streaming execution should time out");
7260
7261 let result = bash.exec("echo later-run").await.unwrap();
7262
7263 assert_eq!(result.stdout, "later-run\n");
7264 assert_eq!(
7265 *chunks.lock().unwrap(),
7266 Vec::<(String, String)>::new(),
7267 "cancelled streaming callback must not receive later output"
7268 );
7269 }
7270
7271 #[tokio::test]
7272 async fn test_exec_streaming_nested_loops_no_duplicates() {
7273 let chunks = Arc::new(Mutex::new(Vec::new()));
7274 let chunks_cb = chunks.clone();
7275 let mut bash = Bash::new();
7276
7277 let result = bash
7278 .exec_streaming(
7279 "for i in 1 2; do for j in a b; do echo \"$i$j\"; done; done",
7280 Box::new(move |stdout, _stderr| {
7281 chunks_cb.lock().unwrap().push(stdout.to_string());
7282 }),
7283 )
7284 .await
7285 .unwrap();
7286
7287 assert_eq!(result.stdout, "1a\n1b\n2a\n2b\n");
7288 let chunks = chunks.lock().unwrap();
7289 let total_chars: usize = chunks.iter().map(|c| c.len()).sum();
7291 assert_eq!(
7292 total_chars,
7293 result.stdout.len(),
7294 "total streamed bytes should match final output: chunks={:?}",
7295 *chunks
7296 );
7297 }
7298
7299 #[tokio::test]
7300 async fn test_exec_streaming_mixed_list_and_loop() {
7301 let chunks = Arc::new(Mutex::new(Vec::new()));
7302 let chunks_cb = chunks.clone();
7303 let mut bash = Bash::new();
7304
7305 let result = bash
7306 .exec_streaming(
7307 "echo start; for i in 1 2; do echo $i; done; echo end",
7308 Box::new(move |stdout, _stderr| {
7309 chunks_cb.lock().unwrap().push(stdout.to_string());
7310 }),
7311 )
7312 .await
7313 .unwrap();
7314
7315 assert_eq!(result.stdout, "start\n1\n2\nend\n");
7316 let chunks = chunks.lock().unwrap();
7317 assert_eq!(
7318 *chunks,
7319 vec!["start\n", "1\n", "2\n", "end\n"],
7320 "mixed list+loop should produce exactly 4 events"
7321 );
7322 }
7323
7324 #[tokio::test]
7325 async fn test_exec_streaming_stderr() {
7326 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
7327 let stderr_cb = stderr_chunks.clone();
7328 let mut bash = Bash::new();
7329
7330 let result = bash
7331 .exec_streaming(
7332 "echo ok; echo err >&2; echo ok2",
7333 Box::new(move |_stdout, stderr| {
7334 if !stderr.is_empty() {
7335 stderr_cb.lock().unwrap().push(stderr.to_string());
7336 }
7337 }),
7338 )
7339 .await
7340 .unwrap();
7341
7342 assert_eq!(result.stdout, "ok\nok2\n");
7343 assert_eq!(result.stderr, "err\n");
7344 let stderr_chunks = stderr_chunks.lock().unwrap();
7345 assert!(
7346 stderr_chunks.contains(&"err\n".to_string()),
7347 "stderr should be streamed: {:?}",
7348 *stderr_chunks
7349 );
7350 }
7351
7352 async fn assert_streaming_equivalence(script: &str) {
7359 let mut bash_plain = Bash::new();
7361 let plain = bash_plain.exec(script).await.unwrap();
7362
7363 let stdout_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
7365 let stderr_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
7366 let so = stdout_chunks.clone();
7367 let se = stderr_chunks.clone();
7368 let mut bash_stream = Bash::new();
7369 let streamed = bash_stream
7370 .exec_streaming(
7371 script,
7372 Box::new(move |stdout, stderr| {
7373 if !stdout.is_empty() {
7374 so.lock().unwrap().push(stdout.to_string());
7375 }
7376 if !stderr.is_empty() {
7377 se.lock().unwrap().push(stderr.to_string());
7378 }
7379 }),
7380 )
7381 .await
7382 .unwrap();
7383
7384 assert_eq!(
7386 plain.stdout, streamed.stdout,
7387 "stdout mismatch for: {script}"
7388 );
7389 assert_eq!(
7390 plain.stderr, streamed.stderr,
7391 "stderr mismatch for: {script}"
7392 );
7393 assert_eq!(
7394 plain.exit_code, streamed.exit_code,
7395 "exit_code mismatch for: {script}"
7396 );
7397
7398 let reassembled_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
7400 assert_eq!(
7401 reassembled_stdout, streamed.stdout,
7402 "reassembled stdout chunks != final stdout for: {script}"
7403 );
7404 let reassembled_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
7405 assert_eq!(
7406 reassembled_stderr, streamed.stderr,
7407 "reassembled stderr chunks != final stderr for: {script}"
7408 );
7409 }
7410
7411 #[tokio::test]
7412 async fn test_exec_streaming_respects_stdout_stderr_limits() {
7413 let stdout_chunks = Arc::new(Mutex::new(Vec::new()));
7414 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
7415 let so = stdout_chunks.clone();
7416 let se = stderr_chunks.clone();
7417 let mut bash = Bash::builder()
7418 .limits(
7419 ExecutionLimits::new()
7420 .max_stdout_bytes(10)
7421 .max_stderr_bytes(8),
7422 )
7423 .build();
7424
7425 let result = bash
7426 .exec_streaming(
7427 "echo hello; echo world; echo err1 >&2; echo err2 >&2",
7428 Box::new(move |stdout, stderr| {
7429 if !stdout.is_empty() {
7430 so.lock().unwrap().push(stdout.to_string());
7431 }
7432 if !stderr.is_empty() {
7433 se.lock().unwrap().push(stderr.to_string());
7434 }
7435 }),
7436 )
7437 .await
7438 .unwrap();
7439
7440 assert_eq!(result.stdout, "hello\nworl");
7441 assert_eq!(result.stderr, "err1\nerr");
7442 assert!(result.stdout_truncated);
7443 assert!(result.stderr_truncated);
7444 let streamed_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
7445 let streamed_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
7446 assert_eq!(streamed_stdout, result.stdout);
7447 assert_eq!(streamed_stderr, result.stderr);
7448 }
7449
7450 #[tokio::test]
7451 async fn test_streaming_equivalence_for_loop() {
7452 assert_streaming_equivalence("for i in 1 2 3; do echo $i; done").await;
7453 }
7454
7455 #[tokio::test]
7456 async fn test_streaming_equivalence_while_loop() {
7457 assert_streaming_equivalence("i=0; while [ $i -lt 4 ]; do i=$((i+1)); echo $i; done").await;
7458 }
7459
7460 #[tokio::test]
7461 async fn test_streaming_equivalence_nested_loops() {
7462 assert_streaming_equivalence("for i in a b; do for j in 1 2; do echo \"$i$j\"; done; done")
7463 .await;
7464 }
7465
7466 #[tokio::test]
7467 async fn test_streaming_equivalence_mixed_list() {
7468 assert_streaming_equivalence("echo start; for i in x y; do echo $i; done; echo end").await;
7469 }
7470
7471 #[tokio::test]
7472 async fn test_streaming_equivalence_stderr() {
7473 assert_streaming_equivalence("echo out; echo err >&2; echo out2").await;
7474 }
7475
7476 #[tokio::test]
7477 async fn test_streaming_equivalence_pipeline() {
7478 assert_streaming_equivalence("echo -e 'a\\nb\\nc' | grep b").await;
7479 }
7480
7481 #[tokio::test]
7482 async fn test_streaming_equivalence_conditionals() {
7483 assert_streaming_equivalence("if true; then echo yes; else echo no; fi; echo done").await;
7484 }
7485
7486 #[tokio::test]
7487 async fn test_streaming_equivalence_subshell() {
7488 assert_streaming_equivalence("x=$(echo hello); echo $x").await;
7489 }
7490
7491 #[tokio::test]
7492 async fn test_streaming_equivalence_command_substitution_exit_trap() {
7493 assert_streaming_equivalence("secret=$(trap 'echo TOKEN' EXIT); trap - EXIT; echo ok")
7494 .await;
7495 }
7496
7497 #[tokio::test]
7498 async fn test_max_memory_caps_string_growth() {
7499 let mut bash = Bash::builder()
7500 .max_memory(1024)
7501 .limits(
7502 ExecutionLimits::new()
7503 .max_commands(10_000)
7504 .max_loop_iterations(10_000),
7505 )
7506 .build();
7507 let result = bash
7508 .exec(r#"x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}"#)
7509 .await
7510 .unwrap();
7511 let len: usize = result.stdout.trim().parse().unwrap();
7512 assert!(len <= 1024, "string length {len} must be ≤ 1024");
7514 }
7515
7516 #[tokio::test]
7518 async fn test_stderr_redirect_devnull_streaming() {
7519 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
7520 let stderr_cb = stderr_chunks.clone();
7521 let mut bash = Bash::new();
7522
7523 let result = bash
7525 .exec_streaming(
7526 "{ ls /nonexistent; } 2>/dev/null; echo exit:$?",
7527 Box::new(move |_stdout, stderr| {
7528 if !stderr.is_empty() {
7529 stderr_cb.lock().unwrap().push(stderr.to_string());
7530 }
7531 }),
7532 )
7533 .await
7534 .unwrap();
7535
7536 assert_eq!(result.stderr, "", "final stderr should be empty");
7537 let stderr_chunks = stderr_chunks.lock().unwrap();
7538 assert!(
7539 stderr_chunks.is_empty(),
7540 "no stderr should be streamed when 2>/dev/null is used, got: {:?}",
7541 *stderr_chunks
7542 );
7543 }
7544
7545 #[tokio::test]
7546 async fn test_dot_slash_prefix_ls() {
7547 let mut bash = Bash::new();
7549 bash.exec("mkdir -p /tmp/blogtest && cd /tmp/blogtest && echo hello > tag_hello.html")
7550 .await
7551 .unwrap();
7552
7553 let result = bash
7555 .exec("cd /tmp/blogtest && ls tag_hello.html")
7556 .await
7557 .unwrap();
7558 assert_eq!(
7559 result.exit_code, 0,
7560 "ls tag_hello.html should succeed: {}",
7561 result.stderr
7562 );
7563 assert!(result.stdout.contains("tag_hello.html"));
7564
7565 let result = bash
7567 .exec("cd /tmp/blogtest && ls ./tag_hello.html")
7568 .await
7569 .unwrap();
7570 assert_eq!(
7571 result.exit_code, 0,
7572 "ls ./tag_hello.html should succeed: {}",
7573 result.stderr
7574 );
7575 assert!(result.stdout.contains("tag_hello.html"));
7576 }
7577
7578 #[tokio::test]
7579 async fn test_dot_slash_prefix_glob() {
7580 let mut bash = Bash::new();
7582 bash.exec("mkdir -p /tmp/globtest && cd /tmp/globtest && echo hello > tag_hello.html")
7583 .await
7584 .unwrap();
7585
7586 let result = bash.exec("cd /tmp/globtest && echo *.html").await.unwrap();
7588 assert_eq!(
7589 result.exit_code, 0,
7590 "echo *.html should succeed: {}",
7591 result.stderr
7592 );
7593 assert!(result.stdout.contains("tag_hello.html"));
7594
7595 let result = bash
7597 .exec("cd /tmp/globtest && echo ./*.html")
7598 .await
7599 .unwrap();
7600 assert_eq!(
7601 result.exit_code, 0,
7602 "echo ./*.html should succeed: {}",
7603 result.stderr
7604 );
7605 assert!(result.stdout.contains("tag_hello.html"));
7606 }
7607
7608 #[tokio::test]
7609 async fn test_dot_slash_prefix_cat() {
7610 let mut bash = Bash::new();
7612 bash.exec("mkdir -p /tmp/cattest && cd /tmp/cattest && echo content123 > myfile.txt")
7613 .await
7614 .unwrap();
7615
7616 let result = bash
7617 .exec("cd /tmp/cattest && cat ./myfile.txt")
7618 .await
7619 .unwrap();
7620 assert_eq!(
7621 result.exit_code, 0,
7622 "cat ./myfile.txt should succeed: {}",
7623 result.stderr
7624 );
7625 assert!(result.stdout.contains("content123"));
7626 }
7627
7628 #[tokio::test]
7629 async fn test_dot_slash_prefix_redirect() {
7630 let mut bash = Bash::new();
7632 bash.exec("mkdir -p /tmp/redirtest && cd /tmp/redirtest")
7633 .await
7634 .unwrap();
7635
7636 let result = bash
7637 .exec("cd /tmp/redirtest && echo hello > ./output.txt && cat ./output.txt")
7638 .await
7639 .unwrap();
7640 assert_eq!(
7641 result.exit_code, 0,
7642 "redirect to ./output.txt should succeed: {}",
7643 result.stderr
7644 );
7645 assert!(result.stdout.contains("hello"));
7646 }
7647
7648 #[tokio::test]
7649 async fn test_dot_slash_prefix_test_builtin() {
7650 let mut bash = Bash::new();
7652 bash.exec("mkdir -p /tmp/testbuiltin && cd /tmp/testbuiltin && echo x > myfile.txt")
7653 .await
7654 .unwrap();
7655
7656 let result = bash
7657 .exec("cd /tmp/testbuiltin && test -f ./myfile.txt && echo yes")
7658 .await
7659 .unwrap();
7660 assert_eq!(
7661 result.exit_code, 0,
7662 "test -f ./myfile.txt should succeed: {}",
7663 result.stderr
7664 );
7665 assert!(result.stdout.contains("yes"));
7666 }
7667
7668 #[tokio::test]
7671 async fn test_before_exec_hook_modifies_script() {
7672 use std::sync::Arc;
7673 use std::sync::atomic::{AtomicBool, Ordering};
7674
7675 let called = Arc::new(AtomicBool::new(false));
7676 let called_clone = called.clone();
7677
7678 let mut bash = Bash::builder()
7679 .before_exec(Box::new(move |mut input| {
7680 called_clone.store(true, Ordering::Relaxed);
7681 input.script = "echo intercepted".to_string();
7683 hooks::HookAction::Continue(input)
7684 }))
7685 .build();
7686
7687 let result = bash.exec("echo original").await.unwrap();
7688 assert!(called.load(Ordering::Relaxed));
7689 assert_eq!(result.stdout.trim(), "intercepted");
7690 }
7691
7692 #[tokio::test]
7693 async fn test_before_exec_hook_cancels() {
7694 let mut bash = Bash::builder()
7695 .before_exec(Box::new(|_input| {
7696 hooks::HookAction::Cancel("blocked".to_string())
7697 }))
7698 .build();
7699
7700 let result = bash.exec("echo should-not-run").await.unwrap();
7701 assert_eq!(result.exit_code, 1);
7702 assert!(result.stdout.is_empty());
7703 }
7704
7705 #[tokio::test]
7706 async fn test_input_size_limit_rejects_before_before_exec_hook() {
7707 use std::sync::Arc;
7708 use std::sync::atomic::{AtomicBool, Ordering};
7709
7710 let called = Arc::new(AtomicBool::new(false));
7711 let called_clone = called.clone();
7712
7713 let limits = ExecutionLimits::new().max_input_bytes(8);
7714 let mut bash = Bash::builder()
7715 .limits(limits)
7716 .before_exec(Box::new(move |_input| {
7717 called_clone.store(true, Ordering::Relaxed);
7718 unreachable!("before_exec hook must not run for oversized input");
7719 }))
7720 .build();
7721
7722 let result = bash.exec("echo way-too-long").await;
7723 assert!(result.is_err());
7724 assert!(!called.load(Ordering::Relaxed));
7725 }
7726
7727 #[tokio::test]
7728 async fn test_after_exec_hook_observes_output() {
7729 use std::sync::{Arc, Mutex};
7730
7731 let captured = Arc::new(Mutex::new(String::new()));
7732 let captured_clone = captured.clone();
7733
7734 let mut bash = Bash::builder()
7735 .after_exec(Box::new(move |output| {
7736 *captured_clone.lock().unwrap() = output.stdout.clone();
7737 hooks::HookAction::Continue(output)
7738 }))
7739 .build();
7740
7741 bash.exec("echo hello-hooks").await.unwrap();
7742 assert_eq!(captured.lock().unwrap().trim(), "hello-hooks");
7743 }
7744
7745 #[tokio::test]
7746 async fn test_after_exec_hook_can_modify_output() {
7747 let mut bash = Bash::builder()
7748 .after_exec(Box::new(|mut output| {
7749 output.stdout = output.stdout.replace("SECRET", "[redacted]");
7750 output.stderr = "policy stderr\n".to_string();
7751 output.exit_code = 7;
7752 hooks::HookAction::Continue(output)
7753 }))
7754 .build();
7755
7756 let result = bash.exec("echo SECRET").await.unwrap();
7757 assert_eq!(result.stdout, "[redacted]\n");
7758 assert_eq!(result.stderr, "policy stderr\n");
7759 assert_eq!(result.exit_code, 7);
7760 }
7761
7762 #[tokio::test]
7763 async fn test_after_exec_hook_can_cancel_result() {
7764 let mut bash = Bash::builder()
7765 .after_exec(Box::new(|_output| {
7766 hooks::HookAction::Cancel("blocked".to_string())
7767 }))
7768 .build();
7769
7770 let result = bash.exec("echo SECRET").await.unwrap();
7771 assert_eq!(result.stdout, "");
7772 assert_eq!(result.stderr, "cancelled by after_exec hook");
7773 assert_eq!(result.exit_code, 1);
7774 }
7775
7776 #[tokio::test]
7777 async fn test_before_tool_hook_can_cancel_special_builtin() {
7778 let mut bash = Bash::builder()
7779 .before_tool(Box::new(|event| {
7780 if event.name == "source" {
7781 hooks::HookAction::Cancel("source blocked".to_string())
7782 } else {
7783 hooks::HookAction::Continue(event)
7784 }
7785 }))
7786 .build();
7787
7788 let result = bash.exec("source missing.sh").await.unwrap();
7789 assert_eq!(result.exit_code, 1);
7790 assert!(result.stderr.contains("cancelled by before_tool hook"));
7791 }
7792
7793 #[tokio::test]
7794 async fn test_after_tool_hook_can_modify_builtin_result() {
7795 let mut bash = Bash::builder()
7796 .after_tool(Box::new(|mut result| {
7797 if result.name == "echo" {
7798 result.stdout = result.stdout.replace("SECRET", "[redacted]");
7799 result.exit_code = 9;
7800 }
7801 hooks::HookAction::Continue(result)
7802 }))
7803 .build();
7804
7805 let result = bash.exec("echo SECRET").await.unwrap();
7806 assert_eq!(result.stdout, "[redacted]\n");
7807 assert_eq!(result.exit_code, 9);
7808 }
7809
7810 #[tokio::test]
7811 async fn test_after_tool_hook_can_cancel_builtin_result() {
7812 let mut bash = Bash::builder()
7813 .after_tool(Box::new(|result| {
7814 if result.name == "echo" {
7815 hooks::HookAction::Cancel("blocked".to_string())
7816 } else {
7817 hooks::HookAction::Continue(result)
7818 }
7819 }))
7820 .build();
7821
7822 let result = bash.exec("echo SECRET").await.unwrap();
7823 assert_eq!(result.stdout, "");
7824 assert!(result.stderr.contains("cancelled by after_tool hook"));
7825 assert_eq!(result.exit_code, 1);
7826 }
7827
7828 #[tokio::test]
7829 async fn test_multiple_hooks_chain() {
7830 let mut bash = Bash::builder()
7831 .before_exec(Box::new(|mut input| {
7832 input.script = input.script.replace("world", "hooks");
7833 hooks::HookAction::Continue(input)
7834 }))
7835 .before_exec(Box::new(|mut input| {
7836 input.script = input.script.replace("hello", "greetings");
7837 hooks::HookAction::Continue(input)
7838 }))
7839 .build();
7840
7841 let result = bash.exec("echo hello world").await.unwrap();
7842 assert_eq!(result.stdout.trim(), "greetings hooks");
7843 }
7844
7845 #[tokio::test]
7846 async fn test_on_exit_hook_not_fired_for_path_script_exit() {
7847 use std::path::Path;
7848 use std::sync::Arc;
7849 use std::sync::atomic::{AtomicU32, Ordering};
7850
7851 let count = Arc::new(AtomicU32::new(0));
7852 let count_clone = count.clone();
7853
7854 let mut bash = Bash::builder()
7855 .on_exit(Box::new(move |event| {
7856 count_clone.fetch_add(1, Ordering::Relaxed);
7857 hooks::HookAction::Continue(event)
7858 }))
7859 .build();
7860
7861 let fs = bash.fs();
7862 fs.mkdir(Path::new("/bin"), false).await.unwrap();
7863 fs.write_file(Path::new("/bin/child-exit"), b"#!/usr/bin/env bash\nexit 7")
7864 .await
7865 .unwrap();
7866 fs.chmod(Path::new("/bin/child-exit"), 0o755).await.unwrap();
7867
7868 let result = bash
7869 .exec("PATH=/bin:$PATH\nchild-exit\necho after:$?")
7870 .await
7871 .unwrap();
7872
7873 assert_eq!(result.stdout.trim(), "after:7");
7874 assert_eq!(count.load(Ordering::Relaxed), 0);
7875 }
7876
7877 #[tokio::test]
7878 async fn test_on_exit_hook_not_fired_for_direct_script_exit() {
7879 use std::path::Path;
7880 use std::sync::Arc;
7881 use std::sync::atomic::{AtomicU32, Ordering};
7882
7883 let count = Arc::new(AtomicU32::new(0));
7884 let count_clone = count.clone();
7885
7886 let mut bash = Bash::builder()
7887 .on_exit(Box::new(move |event| {
7888 count_clone.fetch_add(1, Ordering::Relaxed);
7889 hooks::HookAction::Continue(event)
7890 }))
7891 .build();
7892
7893 let fs = bash.fs();
7894 fs.write_file(
7895 Path::new("/tmp/child-exit.sh"),
7896 b"#!/usr/bin/env bash\nexit 8",
7897 )
7898 .await
7899 .unwrap();
7900 fs.chmod(Path::new("/tmp/child-exit.sh"), 0o755)
7901 .await
7902 .unwrap();
7903
7904 let result = bash
7905 .exec("/tmp/child-exit.sh\necho after:$?")
7906 .await
7907 .unwrap();
7908
7909 assert_eq!(result.stdout.trim(), "after:8");
7910 assert_eq!(count.load(Ordering::Relaxed), 0);
7911 }
7912
7913 #[tokio::test]
7914 async fn test_on_exit_hook_not_fired_for_nested_bash_exit() {
7915 use std::sync::Arc;
7916 use std::sync::atomic::{AtomicU32, Ordering};
7917
7918 let count = Arc::new(AtomicU32::new(0));
7919 let count_clone = count.clone();
7920
7921 let mut bash = Bash::builder()
7922 .on_exit(Box::new(move |event| {
7923 count_clone.fetch_add(1, Ordering::Relaxed);
7924 hooks::HookAction::Continue(event)
7925 }))
7926 .build();
7927
7928 let result = bash.exec("bash -c 'exit 9'\necho after:$?").await.unwrap();
7929
7930 assert_eq!(result.stdout.trim(), "after:9");
7931 assert_eq!(count.load(Ordering::Relaxed), 0);
7932 }
7933
7934 #[tokio::test]
7935 async fn test_path_script_exit_runs_child_exit_trap() {
7936 use std::path::Path;
7937
7938 let mut bash = Bash::new();
7939 let fs = bash.fs();
7940 fs.write_file(
7941 Path::new("/tmp/child-trap.sh"),
7942 b"#!/usr/bin/env bash\ntrap 'echo child-trap' EXIT\nexit 4",
7943 )
7944 .await
7945 .unwrap();
7946 fs.chmod(Path::new("/tmp/child-trap.sh"), 0o755)
7947 .await
7948 .unwrap();
7949
7950 let result = bash
7951 .exec("/tmp/child-trap.sh\necho after:$?")
7952 .await
7953 .unwrap();
7954
7955 assert_eq!(result.stdout.trim(), "child-trap\nafter:4");
7956 }
7957
7958 #[tokio::test]
7959 async fn test_on_exit_hook_still_fires_for_source_exit() {
7960 use std::path::Path;
7961 use std::sync::Arc;
7962 use std::sync::atomic::{AtomicU32, Ordering};
7963
7964 let count = Arc::new(AtomicU32::new(0));
7965 let count_clone = count.clone();
7966
7967 let mut bash = Bash::builder()
7968 .on_exit(Box::new(move |event| {
7969 count_clone.fetch_add(1, Ordering::Relaxed);
7970 hooks::HookAction::Continue(event)
7971 }))
7972 .build();
7973
7974 let fs = bash.fs();
7975 fs.write_file(Path::new("/tmp/source-exit.sh"), b"exit 5")
7976 .await
7977 .unwrap();
7978
7979 let result = bash.exec("source /tmp/source-exit.sh").await.unwrap();
7980
7981 assert_eq!(result.exit_code, 5);
7982 assert_eq!(count.load(Ordering::Relaxed), 1);
7983 }
7984
7985 #[tokio::test]
7986 async fn test_on_exit_hook_cancel_prevents_exit() {
7987 let mut bash = Bash::builder()
7988 .on_exit(Box::new(|_event| {
7989 hooks::HookAction::Cancel("blocked by policy".to_string())
7990 }))
7991 .build();
7992
7993 let result = bash.exec("echo before\nexit 5\necho after").await.unwrap();
7994 assert_eq!(result.stdout.trim(), "before\nafter");
7995 assert_eq!(result.exit_code, 0);
7996 }
7997
7998 #[tokio::test]
7999 async fn test_on_exit_hook_can_modify_exit_code() {
8000 let mut bash = Bash::builder()
8001 .on_exit(Box::new(|mut event| {
8002 event.code = 17;
8003 hooks::HookAction::Continue(event)
8004 }))
8005 .build();
8006
8007 let result = bash.exec("exit 5").await.unwrap();
8008 assert_eq!(result.exit_code, 17);
8009 }
8010
8011 #[tokio::test]
8012 async fn test_bash_versinfo_reports_bash_compatible_major() {
8013 let mut bash = Bash::new();
8014
8015 let result = bash
8016 .exec(r#"[[ ${BASH_VERSINFO[0]} -ge 4 ]] && echo bash4plus"#)
8017 .await
8018 .unwrap();
8019
8020 assert_eq!(result.stdout.trim(), "bash4plus");
8021 }
8022
8023 #[tokio::test]
8024 async fn test_bash_version_surface_matches_bash_compatible_tuple() {
8025 let mut bash = Bash::new();
8026
8027 let result = bash
8028 .exec(
8029 r#"printf '%s\n' "$BASH_VERSION" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}" "${BASH_VERSINFO[2]}" "${BASH_VERSINFO[3]}" "${BASH_VERSINFO[4]}" "${BASH_VERSINFO[5]}""#,
8030 )
8031 .await
8032 .unwrap();
8033
8034 assert_eq!(
8035 result.stdout,
8036 "5.2.15(1)-release\n5\n2\n15\n1\nrelease\nvirtual\n"
8037 );
8038 }
8039
8040 #[tokio::test]
8041 async fn test_path_script_retains_bash_versinfo_array() {
8042 use std::path::Path;
8043
8044 let mut bash = Bash::new();
8045 let fs = bash.fs();
8046 fs.write_file(
8047 Path::new("/tmp/bash-version-check.sh"),
8048 b"#!/usr/bin/env bash\nprintf '%s\\n' \"${BASH_VERSINFO[0]}\"",
8049 )
8050 .await
8051 .unwrap();
8052 fs.chmod(Path::new("/tmp/bash-version-check.sh"), 0o755)
8053 .await
8054 .unwrap();
8055
8056 let result = bash.exec("/tmp/bash-version-check.sh").await.unwrap();
8057
8058 assert_eq!(result.stdout.trim(), "5");
8059 }
8060
8061 #[tokio::test]
8062 async fn test_path_script_bash_versinfo_satisfies_bash4_guard() {
8063 use std::path::Path;
8064
8065 let mut bash = Bash::new();
8066 let fs = bash.fs();
8067 fs.write_file(
8068 Path::new("/tmp/bash-version-guard.sh"),
8069 b"#!/usr/bin/env bash\nif (( BASH_VERSINFO[0] < 4 )); then echo too-old; else echo ok; fi",
8070 )
8071 .await
8072 .unwrap();
8073 fs.chmod(Path::new("/tmp/bash-version-guard.sh"), 0o755)
8074 .await
8075 .unwrap();
8076
8077 let result = bash.exec("/tmp/bash-version-guard.sh").await.unwrap();
8078
8079 assert_eq!(result.stdout.trim(), "ok");
8080 }
8081
8082 #[tokio::test]
8083 async fn test_before_tool_hook_modifies_args() {
8084 use std::sync::Arc;
8085 use std::sync::atomic::{AtomicBool, Ordering};
8086
8087 let called = Arc::new(AtomicBool::new(false));
8088 let called_clone = called.clone();
8089
8090 let mut bash = Bash::builder()
8091 .before_tool(Box::new(move |mut event| {
8092 called_clone.store(true, Ordering::Relaxed);
8093 if !event.args.is_empty() {
8095 event.args = vec!["intercepted".to_string()];
8096 }
8097 hooks::HookAction::Continue(event)
8098 }))
8099 .build();
8100
8101 let result = bash.exec("echo original").await.unwrap();
8102 assert!(called.load(Ordering::Relaxed));
8103 assert_eq!(result.stdout.trim(), "intercepted");
8104 }
8105
8106 #[tokio::test]
8107 async fn test_before_tool_hook_cancels() {
8108 let mut bash = Bash::builder()
8109 .before_tool(Box::new(|event| {
8110 if event.name == "echo" {
8111 hooks::HookAction::Cancel("echo blocked".to_string())
8112 } else {
8113 hooks::HookAction::Continue(event)
8114 }
8115 }))
8116 .build();
8117
8118 let result = bash.exec("echo should-not-run").await.unwrap();
8119 assert_eq!(result.exit_code, 1);
8120 assert!(result.stderr.contains("cancelled by before_tool hook"));
8121 }
8122
8123 #[tokio::test]
8124 async fn test_after_tool_hook_observes_result() {
8125 use std::sync::{Arc, Mutex};
8126
8127 let captured = Arc::new(Mutex::new(Vec::new()));
8128 let captured_clone = captured.clone();
8129
8130 let mut bash = Bash::builder()
8131 .after_tool(Box::new(move |result| {
8132 captured_clone.lock().unwrap().push((
8133 result.name.clone(),
8134 result.stdout.clone(),
8135 result.exit_code,
8136 ));
8137 hooks::HookAction::Continue(result)
8138 }))
8139 .build();
8140
8141 bash.exec("echo hello-tool").await.unwrap();
8142 let results = captured.lock().unwrap();
8143 assert!(!results.is_empty());
8144 assert_eq!(results[0].0, "echo");
8145 assert!(results[0].1.contains("hello-tool"));
8146 assert_eq!(results[0].2, 0);
8147 }
8148
8149 #[tokio::test]
8150 async fn test_before_tool_hook_fires_for_special_and_registered_builtins() {
8151 use std::sync::Arc;
8154 use std::sync::atomic::{AtomicU32, Ordering};
8155
8156 let count = Arc::new(AtomicU32::new(0));
8157 let count_clone = count.clone();
8158
8159 let mut bash = Bash::builder()
8160 .before_tool(Box::new(move |event| {
8161 count_clone.fetch_add(1, Ordering::Relaxed);
8162 hooks::HookAction::Continue(event)
8163 }))
8164 .build();
8165
8166 bash.exec("declare x=1").await.unwrap();
8168 assert_eq!(count.load(Ordering::Relaxed), 1);
8169
8170 bash.exec("echo hi").await.unwrap();
8172 assert_eq!(count.load(Ordering::Relaxed), 2);
8173 }
8174
8175 #[cfg(feature = "http_client")]
8176 #[tokio::test]
8177 async fn test_before_http_hook_cancels_request() {
8178 use crate::NetworkAllowlist;
8179
8180 let mut bash = Bash::builder()
8181 .network(NetworkAllowlist::allow_all())
8182 .before_http(Box::new(|req| {
8183 if req.url.contains("blocked.example.com") {
8184 hooks::HookAction::Cancel("blocked by policy".to_string())
8185 } else {
8186 hooks::HookAction::Continue(req)
8187 }
8188 }))
8189 .build();
8190
8191 let result = bash
8193 .exec("curl -s https://blocked.example.com/data")
8194 .await
8195 .unwrap();
8196 assert_ne!(result.exit_code, 0);
8197 assert!(result.stderr.contains("cancelled by before_http hook"));
8198 }
8199
8200 #[cfg(feature = "http_client")]
8201 #[tokio::test]
8202 async fn test_after_http_hook_observes_response() {
8203 use std::sync::{Arc, Mutex};
8204
8205 use crate::NetworkAllowlist;
8206
8207 let captured = Arc::new(Mutex::new(Vec::new()));
8208 let captured_clone = captured.clone();
8209
8210 let mut bash = Bash::builder()
8211 .network(NetworkAllowlist::allow_all())
8212 .after_http(Box::new(move |event| {
8213 captured_clone
8214 .lock()
8215 .unwrap()
8216 .push((event.url.clone(), event.status));
8217 hooks::HookAction::Continue(event)
8218 }))
8219 .build();
8220
8221 let _result = bash.exec("curl -s https://httpbin.org/get").await;
8225 }
8228}