1#![warn(clippy::unwrap_used)]
417#![cfg_attr(test, allow(clippy::unwrap_used))]
418
419mod builtins;
420#[cfg(feature = "http_client")]
421mod credential;
422mod error;
423mod fs;
424pub mod hooks;
426#[cfg(feature = "interop")]
427pub mod interop;
428mod interpreter;
429mod limits;
430#[cfg(feature = "logging")]
431mod logging_impl;
432mod network;
433pub mod parser;
435#[cfg(feature = "scripted_tool")]
438pub mod scripted_tool;
439mod snapshot;
440#[doc(hidden)]
445pub mod testing;
446mod time_compat;
447#[cfg(feature = "bash_tool")]
450pub mod tool;
451#[cfg(feature = "scripted_tool")]
453pub(crate) mod tool_def;
454pub mod trace;
456
457pub use async_trait::async_trait;
458pub use builtins::git::GitConfig;
459pub use builtins::ssh::{SshAllowlist, SshConfig, TrustedHostKey};
460pub use builtins::{
461 BashkitContext, Builtin, BuiltinRegistry, ClapBuiltin, Context as BuiltinContext,
462 ExecutionExtensions, Extension,
463};
464pub use clap;
465#[cfg(feature = "http_client")]
466pub use credential::Credential;
467pub use error::{Error, Result};
468pub use fs::{
469 DirEntry, FileSystem, FileSystemExt, FileType, FsBackend, FsLimitExceeded, FsLimits, FsUsage,
470 InMemoryFs, LazyLoader, Metadata, MountableFs, NamespaceAccess, NamespaceFs,
471 NamespaceFsBuilder, OverlayFs, PosixFs, ReadOnlyFs, SearchCapabilities, SearchCapable,
472 SearchMatch, SearchProvider, SearchQuery, SearchResults, VfsSnapshot, normalize_path,
473 verify_filesystem_requirements,
474};
475#[cfg(feature = "realfs")]
476pub use fs::{RealFs, RealFsMode};
477pub use interpreter::{
478 ControlFlow, ExecResult, HistoryEntry, OutputCallback, ShellState, ShellStateView,
479};
480pub use limits::{
481 ExecutionCounters, ExecutionLimits, LimitExceeded, MemoryBudget, MemoryLimits, SessionLimits,
482};
483pub use network::NetworkAllowlist;
484pub use snapshot::{Snapshot, SnapshotOptions};
485#[cfg(feature = "bash_tool")]
486pub use tool::BashToolBuilder as ToolBuilder;
487#[cfg(feature = "bash_tool")]
488pub use tool::{
489 BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput,
490 ToolOutputChunk, ToolOutputMetadata, ToolRequest, ToolResponse, ToolService, ToolStatus,
491 VERSION,
492};
493pub use trace::{
494 TraceCallback, TraceCollector, TraceEvent, TraceEventDetails, TraceEventKind, TraceMode,
495};
496
497#[cfg(feature = "scripted_tool")]
498pub use scripted_tool::{
499 AsyncToolCallback, CallbackKind, DiscoverTool, DiscoveryMode, ScriptedCommandInvocation,
500 ScriptedCommandKind, ScriptedExecutionTrace, ScriptedTool, ScriptedToolBuilder,
501 ScriptingToolSet, ScriptingToolSetBuilder, ToolArgs, ToolCallback, ToolDef, ToolDefExtension,
502 ToolDefExtensionBuilder, ToolDefInvocationTrace,
503};
504#[cfg(feature = "scripted_tool")]
505pub use tool_def::{AsyncToolExec, SyncToolExec, ToolImpl};
506
507#[cfg(feature = "http_client")]
508pub use network::HttpClient;
509
510#[cfg(feature = "http_client")]
511pub use network::{HttpTransport, HttpTransportError, HttpTransportRequest};
512
513#[cfg(feature = "http_client")]
515pub use network::Method as HttpMethod;
516
517#[cfg(feature = "http_client")]
519pub use network::Response as HttpResponse;
520
521#[cfg(feature = "bot-auth")]
522pub use network::{BotAuthConfig, BotAuthError, BotAuthPublicKey, derive_bot_auth_public_key};
523
524#[cfg(feature = "git")]
525pub use builtins::git::GitClient;
526
527#[cfg(feature = "ssh")]
528pub use builtins::ssh::{SshClient, SshHandler, SshOutput, SshTarget};
529
530#[cfg(feature = "python")]
531pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits};
532
533#[cfg(any(feature = "python", feature = "typescript"))]
535pub use builtins::RuntimeLimits;
536
537#[cfg(feature = "sqlite")]
538pub use builtins::{Sqlite, SqliteBackend, SqliteLimits};
539#[cfg(feature = "python")]
543pub use monty::{ExcType, ExtFunctionResult, MontyException, MontyObject};
544
545#[cfg(feature = "typescript")]
546pub use builtins::{
547 TypeScriptConfig, TypeScriptExtension, TypeScriptExternalFnHandler, TypeScriptExternalFns,
548 TypeScriptLimits,
549};
550#[cfg(feature = "typescript")]
552pub use zapcode_core::Value as ZapcodeValue;
553
554#[cfg(feature = "logging")]
559pub mod logging {
560 pub use crate::logging_impl::{
561 LogConfig, format_error_for_log, format_script_for_log, sanitize_for_log,
562 };
563}
564
565#[cfg(feature = "logging")]
566pub use logging::LogConfig;
567
568use interpreter::Interpreter;
569use parser::Parser;
570use std::collections::HashMap;
571#[cfg(feature = "realfs")]
572use std::path::Path;
573use std::path::PathBuf;
574use std::sync::Arc;
575
576#[cfg(any(feature = "python", feature = "sqlite"))]
577fn env_opt_in_enabled(env: &HashMap<String, String>, key: &str) -> bool {
578 env.get(key)
579 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
580}
581
582struct OutputCallbackGuard {
585 interpreter: *mut Interpreter,
586}
587
588unsafe impl Send for OutputCallbackGuard {}
592
593impl OutputCallbackGuard {
594 fn install(interpreter: &mut Interpreter, callback: OutputCallback) -> Self {
595 interpreter.set_output_callback(callback);
596 Self { interpreter }
597 }
598}
599
600impl Drop for OutputCallbackGuard {
601 fn drop(&mut self) {
602 unsafe { (*self.interpreter).clear_output_callback() };
607 }
608}
609
610#[derive(Default)]
644pub struct ExecOptions {
645 extensions: ExecutionExtensions,
646 output_callback: Option<OutputCallback>,
647}
648
649impl ExecOptions {
650 pub fn new() -> Self {
652 Self::default()
653 }
654
655 pub fn streaming(mut self, callback: OutputCallback) -> Self {
658 self.output_callback = Some(callback);
659 self
660 }
661
662 pub fn extensions(mut self, extensions: ExecutionExtensions) -> Self {
665 self.extensions = extensions;
666 self
667 }
668}
669
670pub struct Bash {
674 fs: Arc<dyn FileSystem>,
675 mountable: Arc<MountableFs>,
677 readonly_filesystem: bool,
679 interpreter: Interpreter,
680 parser_timeout: std::time::Duration,
682 max_input_bytes: usize,
684 max_ast_depth: usize,
686 max_parser_operations: usize,
688 #[cfg(feature = "logging")]
690 log_config: logging::LogConfig,
691 #[cfg(feature = "python")]
693 python_inprocess_opt_in: bool,
694 #[cfg(feature = "sqlite")]
696 sqlite_inprocess_opt_in: bool,
697}
698
699impl Default for Bash {
700 fn default() -> Self {
701 Self::new()
702 }
703}
704
705fn inmem_fs_with_home(username: &str) -> InMemoryFs {
710 let fs = InMemoryFs::new();
711 fs.add_dir(format!("/home/{username}"), 0o755);
712 fs
713}
714
715impl Bash {
716 pub fn new() -> Self {
718 let base_inmem = inmem_fs_with_home(builtins::DEFAULT_USERNAME);
719 let base_fs: Arc<dyn FileSystem> = Arc::new(base_inmem);
720 let mountable = Arc::new(MountableFs::new(base_fs));
721 let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
722 let interpreter = Interpreter::new(Arc::clone(&fs));
723 let parser_timeout = ExecutionLimits::default().parser_timeout;
724 let max_input_bytes = ExecutionLimits::default().max_input_bytes;
725 let max_ast_depth = ExecutionLimits::default().max_ast_depth;
726 let max_parser_operations = ExecutionLimits::default().max_parser_operations;
727 Self {
728 fs,
729 mountable,
730 readonly_filesystem: false,
731 interpreter,
732 parser_timeout,
733 max_input_bytes,
734 max_ast_depth,
735 max_parser_operations,
736 #[cfg(feature = "logging")]
737 log_config: logging::LogConfig::default(),
738 #[cfg(feature = "python")]
739 python_inprocess_opt_in: false,
740 #[cfg(feature = "sqlite")]
741 sqlite_inprocess_opt_in: false,
742 }
743 }
744
745 pub fn builder() -> BashBuilder {
747 BashBuilder::default()
748 }
749
750 pub async fn exec(&mut self, script: &str) -> Result<ExecResult> {
756 self.exec_with_options(script, ExecOptions::new()).await
757 }
758
759 pub async fn exec_with_extensions(
763 &mut self,
764 script: &str,
765 extensions: ExecutionExtensions,
766 ) -> Result<ExecResult> {
767 self.exec_with_options(script, ExecOptions::new().extensions(extensions))
768 .await
769 }
770
771 pub async fn exec_with_options(
779 &mut self,
780 script: &str,
781 options: ExecOptions,
782 ) -> Result<ExecResult> {
783 let ExecOptions {
784 mut extensions,
785 output_callback,
786 } = options;
787 let active_limits = self.interpreter.limits().clone();
790 let _ = extensions.insert(active_limits.clone());
791 let _ = extensions.insert(builtins::ExecutionDeadline::new(active_limits.timeout));
792 #[cfg(feature = "python")]
793 let _ = extensions.insert(builtins::PythonInprocessOptIn(self.python_inprocess_opt_in));
794 #[cfg(feature = "sqlite")]
795 let _ = extensions.insert(builtins::SqliteInprocessOptIn(self.sqlite_inprocess_opt_in));
796 let _stream_guard =
801 output_callback.map(|cb| OutputCallbackGuard::install(&mut self.interpreter, cb));
802 let _extensions_guard = self.interpreter.scoped_execution_extensions(extensions);
803 self.exec_impl(script).await
804 }
805
806 async fn exec_impl(&mut self, script: &str) -> Result<ExecResult> {
807 self.interpreter.reset_transient_state();
809
810 self.interpreter.begin_exec_invocation()?;
813
814 let input_len = script.len();
817 if input_len > self.max_input_bytes {
818 #[cfg(feature = "logging")]
819 tracing::error!(
820 target: "bashkit::session",
821 input_len = input_len,
822 max_bytes = self.max_input_bytes,
823 "Script exceeds maximum input size"
824 );
825 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
826 input_len,
827 self.max_input_bytes,
828 )));
829 }
830
831 #[cfg(feature = "logging")]
834 {
835 let script_info = logging::format_script_for_log(script, &self.log_config);
836 tracing::info!(target: "bashkit::session", script = %script_info, "Starting script execution");
837 }
838
839 let script = if !self.interpreter.hooks().before_exec.is_empty() {
841 let input = hooks::ExecInput {
842 script: script.to_string(),
843 };
844 match self.interpreter.hooks().fire_before_exec(input) {
845 Some(modified) => std::borrow::Cow::Owned(modified.script),
846 None => {
847 return Ok(ExecResult::err("cancelled by before_exec hook", 1));
848 }
849 }
850 } else {
851 std::borrow::Cow::Borrowed(script)
852 };
853 let script = script.as_ref();
854
855 let input_len = script.len();
857 if input_len > self.max_input_bytes {
858 #[cfg(feature = "logging")]
859 tracing::error!(
860 target: "bashkit::session",
861 input_len = input_len,
862 max_bytes = self.max_input_bytes,
863 "Script exceeds maximum input size"
864 );
865 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
866 input_len,
867 self.max_input_bytes,
868 )));
869 }
870
871 let parser_timeout = self.parser_timeout;
872 let max_ast_depth = self.max_ast_depth;
873 let max_parser_operations = self.max_parser_operations;
874
875 #[cfg(feature = "logging")]
876 tracing::debug!(
877 target: "bashkit::parser",
878 input_len = input_len,
879 max_ast_depth = max_ast_depth,
880 max_operations = max_parser_operations,
881 "Parsing script"
882 );
883
884 #[cfg(not(target_family = "wasm"))]
894 const SPAWN_BLOCKING_THRESHOLD: usize = 16 * 1024;
895
896 #[cfg(target_family = "wasm")]
899 let ast = {
900 let parser = Parser::with_limits_and_timeout(
901 script,
902 max_ast_depth,
903 max_parser_operations,
904 Some(parser_timeout),
905 );
906 parser.parse()?
907 };
908
909 #[cfg(not(target_family = "wasm"))]
913 let ast = if input_len <= SPAWN_BLOCKING_THRESHOLD {
914 let parser = Parser::with_limits(script, max_ast_depth, max_parser_operations);
915 match parser.parse() {
916 Ok(ast) => {
917 #[cfg(feature = "logging")]
918 tracing::debug!(target: "bashkit::parser", "Parse completed (inline)");
919 ast
920 }
921 Err(e) => {
922 #[cfg(feature = "logging")]
923 tracing::warn!(target: "bashkit::parser", error = %e, "Parse error (inline)");
924 return Err(e);
925 }
926 }
927 } else {
928 let script_owned = script.to_owned();
929 let parse_result = tokio::time::timeout(parser_timeout, async {
930 tokio::task::spawn_blocking(move || {
931 let parser =
932 Parser::with_limits(&script_owned, max_ast_depth, max_parser_operations);
933 parser.parse()
934 })
935 .await
936 })
937 .await;
938
939 match parse_result {
940 Ok(Ok(result)) => {
941 match &result {
942 Ok(_) => {
943 #[cfg(feature = "logging")]
944 tracing::debug!(target: "bashkit::parser", "Parse completed successfully");
945 }
946 Err(_e) => {
947 #[cfg(feature = "logging")]
948 tracing::warn!(target: "bashkit::parser", error = %_e, "Parse error");
949 }
950 }
951 result?
952 }
953 Ok(Err(join_error)) => {
954 #[cfg(feature = "logging")]
955 tracing::error!(
956 target: "bashkit::parser",
957 error = %join_error,
958 "Parser task failed"
959 );
960 return Err(Error::parse(format!("parser task failed: {}", join_error)));
961 }
962 Err(_elapsed) => {
963 #[cfg(feature = "logging")]
964 tracing::error!(
965 target: "bashkit::parser",
966 timeout_ms = parser_timeout.as_millis() as u64,
967 "Parser timeout exceeded"
968 );
969 return Err(Error::ResourceLimit(LimitExceeded::ParserTimeout(
970 parser_timeout,
971 )));
972 }
973 }
974 };
975
976 #[cfg(feature = "logging")]
977 tracing::debug!(target: "bashkit::interpreter", "Starting interpretation");
978
979 parser::validate_budget(&ast, self.interpreter.limits())
981 .map_err(|e| Error::Execution(format!("budget validation failed: {e}")))?;
982
983 self.interpreter.load_history().await;
985
986 let exec_start = crate::time_compat::Instant::now();
987 #[cfg(not(target_family = "wasm"))]
990 let execution_timeout = self.interpreter.limits().timeout;
991 #[cfg(not(target_family = "wasm"))]
992 let result =
993 match tokio::time::timeout(execution_timeout, self.interpreter.execute(&ast)).await {
994 Ok(r) => r,
995 Err(_elapsed) => {
996 self.interpreter.clear_cancelled_execution_state();
997 Err(Error::ResourceLimit(LimitExceeded::Timeout(
998 execution_timeout,
999 )))
1000 }
1001 };
1002 #[cfg(target_family = "wasm")]
1003 let result = self.interpreter.execute(&ast).await;
1004 self.interpreter.cleanup_proc_sub_files().await;
1008 let duration_ms = exec_start.elapsed().as_millis() as u64;
1009
1010 if let Ok(ref exec_result) = result {
1012 let cwd = self.interpreter.cwd().to_string_lossy().to_string();
1013 let timestamp = chrono::Utc::now().timestamp();
1014 for line in script.lines() {
1015 let trimmed = line.trim();
1016 if !trimmed.is_empty() && !trimmed.starts_with('#') {
1017 self.interpreter.record_history(
1018 trimmed.to_string(),
1019 timestamp,
1020 cwd.clone(),
1021 exec_result.exit_code,
1022 duration_ms,
1023 );
1024 }
1025 }
1026 self.interpreter.save_history().await;
1028 }
1029
1030 #[cfg(feature = "logging")]
1031 match &result {
1032 Ok(exec_result) => {
1033 tracing::info!(
1034 target: "bashkit::session",
1035 exit_code = exec_result.exit_code,
1036 stdout_len = exec_result.stdout.len(),
1037 stderr_len = exec_result.stderr.len(),
1038 "Script execution completed"
1039 );
1040 }
1041 Err(e) => {
1042 let error = logging::format_error_for_log(&e.to_string(), &self.log_config);
1043 tracing::error!(
1044 target: "bashkit::session",
1045 error = %error,
1046 "Script execution failed"
1047 );
1048 }
1049 }
1050
1051 let result = if let Ok(exec_result) = result {
1053 if !self.interpreter.hooks().after_exec.is_empty() {
1054 let output = hooks::ExecOutput {
1055 script: script.to_string(),
1056 stdout: exec_result.stdout.clone(),
1057 stderr: exec_result.stderr.clone(),
1058 exit_code: exec_result.exit_code,
1059 };
1060 match self.interpreter.hooks().fire_after_exec(output) {
1061 Some(output) => Ok(ExecResult {
1062 stdout: output.stdout,
1063 stderr: output.stderr,
1064 exit_code: output.exit_code,
1065 ..exec_result
1066 }),
1067 None => Ok(ExecResult::err("cancelled by after_exec hook", 1)),
1068 }
1069 } else {
1070 Ok(exec_result)
1071 }
1072 } else {
1073 result
1074 };
1075
1076 if let Err(ref e) = result
1078 && !self.interpreter.hooks().on_error.is_empty()
1079 {
1080 let error_event = hooks::ErrorEvent {
1081 message: e.to_string(),
1082 };
1083 self.interpreter.hooks().fire_on_error(error_event);
1084 }
1085
1086 result
1087 }
1088
1089 pub async fn exec_streaming(
1120 &mut self,
1121 script: &str,
1122 output_callback: OutputCallback,
1123 ) -> Result<ExecResult> {
1124 self.exec_with_options(script, ExecOptions::new().streaming(output_callback))
1125 .await
1126 }
1127
1128 pub async fn exec_streaming_with_extensions(
1132 &mut self,
1133 script: &str,
1134 output_callback: OutputCallback,
1135 extensions: ExecutionExtensions,
1136 ) -> Result<ExecResult> {
1137 self.exec_with_options(
1138 script,
1139 ExecOptions::new()
1140 .streaming(output_callback)
1141 .extensions(extensions),
1142 )
1143 .await
1144 }
1145
1146 pub fn cancellation_token(&self) -> Arc<std::sync::atomic::AtomicBool> {
1154 self.interpreter.cancellation_token()
1155 }
1156
1157 pub fn hooks(&self) -> &hooks::Hooks {
1167 self.interpreter.hooks()
1168 }
1169
1170 pub fn fs(&self) -> Arc<dyn FileSystem> {
1203 Arc::clone(&self.fs)
1204 }
1205
1206 pub fn mount(
1248 &self,
1249 vfs_path: impl AsRef<std::path::Path>,
1250 fs: Arc<dyn FileSystem>,
1251 ) -> Result<()> {
1252 if Arc::ptr_eq(&self.fs, &fs) {
1256 return Err(std::io::Error::other("cannot mount filesystem into itself").into());
1257 }
1258
1259 let fs: Arc<dyn FileSystem> = if self.readonly_filesystem {
1260 Arc::new(ReadOnlyFs::new(fs))
1261 } else {
1262 fs
1263 };
1264 self.mountable.mount(vfs_path, fs)
1265 }
1266
1267 pub fn unmount(&self, vfs_path: impl AsRef<std::path::Path>) -> Result<()> {
1300 self.mountable.unmount(vfs_path)
1301 }
1302
1303 pub fn shell_state(&self) -> ShellState {
1329 self.interpreter.shell_state()
1330 }
1331
1332 pub fn shell_state_view(&self) -> ShellStateView {
1338 self.interpreter.shell_state_view()
1339 }
1340
1341 pub fn restore_shell_state(&mut self, state: &ShellState) {
1346 self.interpreter.restore_shell_state(state);
1347 }
1348
1349 pub fn builtin_names(&self) -> Vec<String> {
1358 self.interpreter.builtin_names()
1359 }
1360
1361 pub fn session_counters(&self) -> (u64, u64) {
1365 let c = self.interpreter.counters();
1366 (c.session_commands, c.session_exec_calls)
1367 }
1368
1369 pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
1375 self.interpreter
1376 .restore_session_counters(session_commands, session_exec_calls);
1377 }
1378}
1379
1380struct MountedFile {
1417 path: PathBuf,
1418 content: String,
1419 mode: u32,
1420}
1421
1422struct MountedLazyFile {
1423 path: PathBuf,
1424 size_hint: u64,
1425 mode: u32,
1426 loader: LazyLoader,
1427}
1428
1429#[cfg(feature = "realfs")]
1431struct MountedRealDir {
1432 host_path: PathBuf,
1434 vfs_mount: Option<PathBuf>,
1436 mode: fs::RealFsMode,
1438}
1439
1440#[derive(Default)]
1441pub struct BashBuilder {
1442 fs: Option<Arc<dyn FileSystem>>,
1443 env: HashMap<String, String>,
1444 cwd: Option<PathBuf>,
1445 limits: ExecutionLimits,
1446 session_limits: SessionLimits,
1447 memory_limits: MemoryLimits,
1448 trace_mode: TraceMode,
1449 trace_callback: Option<TraceCallback>,
1450 username: Option<String>,
1451 hostname: Option<String>,
1452 fixed_epoch: Option<i64>,
1454 epoch_offset: Option<i64>,
1456 shell_profile: interpreter::ShellProfile,
1457 custom_builtins: HashMap<String, Box<dyn Builtin>>,
1458 host_builtins: Option<BuiltinRegistry>,
1461 mounted_files: Vec<MountedFile>,
1463 mounted_lazy_files: Vec<MountedLazyFile>,
1465 #[cfg(feature = "http_client")]
1467 network_allowlist: Option<NetworkAllowlist>,
1468 #[cfg(feature = "http_client")]
1470 http_transport: Option<Arc<dyn network::HttpTransport>>,
1471 #[cfg(feature = "bot-auth")]
1473 bot_auth_config: Option<network::BotAuthConfig>,
1474 #[cfg(feature = "logging")]
1476 log_config: Option<logging::LogConfig>,
1477 #[cfg(feature = "git")]
1479 git_config: Option<GitConfig>,
1480 #[cfg(feature = "ssh")]
1482 ssh_config: Option<SshConfig>,
1483 #[cfg(feature = "ssh")]
1485 ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
1486 #[cfg(feature = "realfs")]
1488 real_mounts: Vec<MountedRealDir>,
1489 #[cfg(feature = "realfs")]
1492 mount_path_allowlist: Option<Vec<PathBuf>>,
1493 history_file: Option<PathBuf>,
1495 readonly_filesystem: bool,
1497 hooks_on_exit: Vec<hooks::Interceptor<hooks::ExitEvent>>,
1499 hooks_before_exec: Vec<hooks::Interceptor<hooks::ExecInput>>,
1500 hooks_after_exec: Vec<hooks::Interceptor<hooks::ExecOutput>>,
1501 hooks_before_tool: Vec<hooks::Interceptor<hooks::ToolEvent>>,
1502 hooks_after_tool: Vec<hooks::Interceptor<hooks::ToolResult>>,
1503 hooks_on_error: Vec<hooks::Interceptor<hooks::ErrorEvent>>,
1504 #[cfg(feature = "http_client")]
1505 hooks_before_http: Vec<hooks::Interceptor<hooks::HttpRequestEvent>>,
1506 #[cfg(feature = "http_client")]
1507 hooks_after_http: Vec<hooks::Interceptor<hooks::HttpResponseEvent>>,
1508 #[cfg(feature = "http_client")]
1510 credential_policy: Option<credential::CredentialPolicy>,
1511}
1512
1513impl BashBuilder {
1514 pub fn fs(mut self, fs: Arc<dyn FileSystem>) -> Self {
1516 self.fs = Some(fs);
1517 self
1518 }
1519
1520 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1522 self.env.insert(key.into(), value.into());
1523 self
1524 }
1525
1526 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
1528 self.cwd = Some(cwd.into());
1529 self
1530 }
1531
1532 pub fn limits(mut self, limits: ExecutionLimits) -> Self {
1534 self.limits = limits;
1535 self
1536 }
1537
1538 #[cfg(feature = "scripted_tool")]
1540 pub(crate) fn logic_only(mut self) -> Self {
1541 self.shell_profile = interpreter::ShellProfile::LogicOnly;
1542 self
1543 }
1544
1545 pub fn session_limits(mut self, limits: SessionLimits) -> Self {
1550 self.session_limits = limits;
1551 self
1552 }
1553
1554 pub fn memory_limits(mut self, limits: MemoryLimits) -> Self {
1559 self.memory_limits = limits;
1560 self
1561 }
1562
1563 pub fn max_memory(self, bytes: usize) -> Self {
1579 let defaults = MemoryLimits::default();
1580 self.memory_limits(
1581 MemoryLimits::new()
1582 .max_total_variable_bytes(bytes)
1583 .max_function_body_bytes(bytes.min(defaults.max_function_body_bytes)),
1584 )
1585 }
1586
1587 pub fn trace_mode(mut self, mode: TraceMode) -> Self {
1593 self.trace_mode = mode;
1594 self
1595 }
1596
1597 pub fn on_trace_event(mut self, callback: TraceCallback) -> Self {
1602 self.trace_callback = Some(callback);
1603 self
1604 }
1605
1606 pub fn username(mut self, username: impl Into<String>) -> Self {
1611 self.username = Some(username.into());
1612 self
1613 }
1614
1615 pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
1619 self.hostname = Some(hostname.into());
1620 self
1621 }
1622
1623 pub fn tty(mut self, fd: u32, is_terminal: bool) -> Self {
1637 let key = format!("_TTY_{}", fd);
1638 if is_terminal {
1639 self.env.insert(key, "1".to_string());
1640 } else {
1641 self.env.remove(&key);
1642 }
1643 self
1644 }
1645
1646 pub fn fixed_epoch(mut self, epoch: i64) -> Self {
1651 self.fixed_epoch = Some(epoch);
1652 self.epoch_offset = None;
1653 self
1654 }
1655
1656 pub fn epoch_offset(mut self, seconds: i64) -> Self {
1668 self.epoch_offset = Some(seconds);
1669 self.fixed_epoch = None;
1670 self
1671 }
1672
1673 pub fn history_file(mut self, path: impl Into<PathBuf>) -> Self {
1678 self.history_file = Some(path.into());
1679 self
1680 }
1681
1682 #[cfg(feature = "http_client")]
1714 pub fn network(mut self, allowlist: NetworkAllowlist) -> Self {
1715 self.network_allowlist = Some(allowlist);
1716 self
1717 }
1718
1719 #[cfg(feature = "http_client")]
1781 pub fn http_transport(mut self, transport: Arc<dyn network::HttpTransport>) -> Self {
1782 self.http_transport = Some(transport);
1783 self
1784 }
1785
1786 #[cfg(feature = "bot-auth")]
1807 pub fn bot_auth(mut self, config: network::BotAuthConfig) -> Self {
1808 self.bot_auth_config = Some(config);
1809 self
1810 }
1811
1812 #[cfg(feature = "logging")]
1849 pub fn log_config(mut self, config: logging::LogConfig) -> Self {
1850 self.log_config = Some(config);
1851 self
1852 }
1853
1854 #[cfg(feature = "git")]
1883 pub fn git(mut self, config: GitConfig) -> Self {
1884 self.git_config = Some(config);
1885 self
1886 }
1887
1888 #[cfg(feature = "ssh")]
1908 pub fn ssh(mut self, config: SshConfig) -> Self {
1909 self.ssh_config = Some(config);
1910 self
1911 }
1912
1913 #[cfg(feature = "ssh")]
1919 pub fn ssh_handler(mut self, handler: Box<dyn builtins::ssh::SshHandler>) -> Self {
1920 self.ssh_handler = Some(handler);
1921 self
1922 }
1923
1924 #[cfg(feature = "python")]
1943 pub fn python(self) -> Self {
1944 self.python_with_limits(builtins::PythonLimits::default())
1945 }
1946
1947 #[cfg(feature = "sqlite")]
1966 pub fn sqlite(self) -> Self {
1967 self.sqlite_with_limits(builtins::SqliteLimits::default())
1968 }
1969
1970 #[cfg(feature = "sqlite")]
1990 pub fn sqlite_with_limits(self, limits: builtins::SqliteLimits) -> Self {
1991 self.builtin(
1992 "sqlite",
1993 Box::new(builtins::Sqlite::with_limits(limits.clone())),
1994 )
1995 .builtin("sqlite3", Box::new(builtins::Sqlite::with_limits(limits)))
1996 }
1997
1998 #[cfg(feature = "python")]
2013 pub fn python_with_limits(self, limits: builtins::PythonLimits) -> Self {
2014 self.builtin(
2015 "python",
2016 Box::new(builtins::Python::with_limits(limits.clone())),
2017 )
2018 .builtin("python3", Box::new(builtins::Python::with_limits(limits)))
2019 }
2020
2021 #[cfg(feature = "python")]
2025 pub fn python_with_external_handler(
2026 self,
2027 limits: builtins::PythonLimits,
2028 external_fns: Vec<String>,
2029 handler: builtins::PythonExternalFnHandler,
2030 ) -> Self {
2031 self.builtin(
2032 "python",
2033 Box::new(
2034 builtins::Python::with_limits(limits.clone())
2035 .with_external_handler(external_fns.clone(), handler.clone()),
2036 ),
2037 )
2038 .builtin(
2039 "python3",
2040 Box::new(
2041 builtins::Python::with_limits(limits).with_external_handler(external_fns, handler),
2042 ),
2043 )
2044 }
2045
2046 #[cfg(feature = "typescript")]
2058 pub fn typescript(self) -> Self {
2059 self.typescript_with_config(builtins::TypeScriptConfig::default())
2060 }
2061
2062 #[cfg(feature = "typescript")]
2066 pub fn typescript_with_limits(self, limits: builtins::TypeScriptLimits) -> Self {
2067 self.typescript_with_config(builtins::TypeScriptConfig::default().limits(limits))
2068 }
2069
2070 #[cfg(feature = "typescript")]
2098 pub fn typescript_with_config(self, config: builtins::TypeScriptConfig) -> Self {
2099 self.extension(builtins::TypeScriptExtension::with_config(config))
2100 }
2101
2102 #[cfg(feature = "typescript")]
2106 pub fn typescript_with_external_handler(
2107 self,
2108 limits: builtins::TypeScriptLimits,
2109 external_fns: Vec<String>,
2110 handler: builtins::TypeScriptExternalFnHandler,
2111 ) -> Self {
2112 self.extension(builtins::TypeScriptExtension::with_external_handler(
2113 limits,
2114 external_fns,
2115 handler,
2116 ))
2117 }
2118
2119 pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
2156 self.custom_builtins.insert(name.into(), builtin);
2157 self
2158 }
2159
2160 pub fn builtin_registry(mut self, registry: BuiltinRegistry) -> Self {
2177 self.host_builtins = Some(registry);
2178 self
2179 }
2180
2181 pub fn extension<E>(mut self, extension: E) -> Self
2213 where
2214 E: builtins::Extension,
2215 {
2216 for (name, builtin) in extension.builtins() {
2217 self.custom_builtins.insert(name, builtin);
2218 }
2219 self
2220 }
2221
2222 pub fn on_exit(mut self, hook: hooks::Interceptor<hooks::ExitEvent>) -> Self {
2245 self.hooks_on_exit.push(hook);
2246 self
2247 }
2248
2249 pub fn before_exec(mut self, hook: hooks::Interceptor<hooks::ExecInput>) -> Self {
2254 self.hooks_before_exec.push(hook);
2255 self
2256 }
2257
2258 pub fn after_exec(mut self, hook: hooks::Interceptor<hooks::ExecOutput>) -> Self {
2263 self.hooks_after_exec.push(hook);
2264 self
2265 }
2266
2267 pub fn before_tool(mut self, hook: hooks::Interceptor<hooks::ToolEvent>) -> Self {
2272 self.hooks_before_tool.push(hook);
2273 self
2274 }
2275
2276 pub fn after_tool(mut self, hook: hooks::Interceptor<hooks::ToolResult>) -> Self {
2280 self.hooks_after_tool.push(hook);
2281 self
2282 }
2283
2284 pub fn on_error(mut self, hook: hooks::Interceptor<hooks::ErrorEvent>) -> Self {
2288 self.hooks_on_error.push(hook);
2289 self
2290 }
2291
2292 #[cfg(feature = "http_client")]
2313 pub fn before_http(mut self, hook: hooks::Interceptor<hooks::HttpRequestEvent>) -> Self {
2314 self.hooks_before_http.push(hook);
2315 self
2316 }
2317
2318 #[cfg(feature = "http_client")]
2323 pub fn after_http(mut self, hook: hooks::Interceptor<hooks::HttpResponseEvent>) -> Self {
2324 self.hooks_after_http.push(hook);
2325 self
2326 }
2327
2328 #[cfg(feature = "http_client")]
2355 pub fn credential(mut self, pattern: &str, cred: credential::Credential) -> Self {
2356 self.credential_policy
2357 .get_or_insert_with(credential::CredentialPolicy::new)
2358 .add_injection(pattern, cred);
2359 self
2360 }
2361
2362 #[cfg(feature = "http_client")]
2391 pub fn credential_placeholder(
2392 mut self,
2393 env_name: &str,
2394 pattern: &str,
2395 cred: credential::Credential,
2396 ) -> Self {
2397 let placeholder = self
2398 .credential_policy
2399 .get_or_insert_with(credential::CredentialPolicy::new)
2400 .add_placeholder(pattern, cred);
2401 self.env.insert(env_name.to_string(), placeholder);
2402 self
2403 }
2404
2405 pub fn mount_text(mut self, path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
2434 self.mounted_files.push(MountedFile {
2435 path: path.into(),
2436 content: content.into(),
2437 mode: 0o644,
2438 });
2439 self
2440 }
2441
2442 pub fn mount_readonly_text(
2481 mut self,
2482 path: impl Into<PathBuf>,
2483 content: impl Into<String>,
2484 ) -> Self {
2485 self.mounted_files.push(MountedFile {
2486 path: path.into(),
2487 content: content.into(),
2488 mode: 0o444,
2489 });
2490 self
2491 }
2492
2493 pub fn mount_lazy(
2519 mut self,
2520 path: impl Into<PathBuf>,
2521 size_hint: u64,
2522 loader: LazyLoader,
2523 ) -> Self {
2524 self.mounted_lazy_files.push(MountedLazyFile {
2525 path: path.into(),
2526 size_hint,
2527 mode: 0o644,
2528 loader,
2529 });
2530 self
2531 }
2532
2533 #[cfg(feature = "realfs")]
2551 pub fn mount_real_readonly(mut self, host_path: impl Into<PathBuf>) -> Self {
2552 self.real_mounts.push(MountedRealDir {
2553 host_path: host_path.into(),
2554 vfs_mount: None,
2555 mode: fs::RealFsMode::ReadOnly,
2556 });
2557 self
2558 }
2559
2560 #[cfg(feature = "realfs")]
2578 pub fn mount_real_readonly_at(
2579 mut self,
2580 host_path: impl Into<PathBuf>,
2581 vfs_mount: impl Into<PathBuf>,
2582 ) -> Self {
2583 self.real_mounts.push(MountedRealDir {
2584 host_path: host_path.into(),
2585 vfs_mount: Some(vfs_mount.into()),
2586 mode: fs::RealFsMode::ReadOnly,
2587 });
2588 self
2589 }
2590
2591 #[cfg(feature = "realfs")]
2608 pub fn mount_real_readwrite(mut self, host_path: impl Into<PathBuf>) -> Self {
2609 self.real_mounts.push(MountedRealDir {
2610 host_path: host_path.into(),
2611 vfs_mount: None,
2612 mode: fs::RealFsMode::ReadWrite,
2613 });
2614 self
2615 }
2616
2617 #[cfg(feature = "realfs")]
2632 pub fn mount_real_readwrite_at(
2633 mut self,
2634 host_path: impl Into<PathBuf>,
2635 vfs_mount: impl Into<PathBuf>,
2636 ) -> Self {
2637 self.real_mounts.push(MountedRealDir {
2638 host_path: host_path.into(),
2639 vfs_mount: Some(vfs_mount.into()),
2640 mode: fs::RealFsMode::ReadWrite,
2641 });
2642 self
2643 }
2644
2645 #[cfg(feature = "realfs")]
2661 pub fn allowed_mount_paths(
2662 mut self,
2663 paths: impl IntoIterator<Item = impl Into<PathBuf>>,
2664 ) -> Self {
2665 self.mount_path_allowlist = Some(paths.into_iter().map(|p| p.into()).collect());
2666 self
2667 }
2668
2669 pub fn readonly_filesystem(mut self, readonly: bool) -> Self {
2675 self.readonly_filesystem = readonly;
2676 self
2677 }
2678
2679 pub fn build(self) -> Bash {
2714 let base_fs: Arc<dyn FileSystem> = if self.shell_profile.is_logic_only() {
2715 Arc::new(fs::DisabledFs)
2716 } else if let Some(fs) = self.fs {
2717 fs
2718 } else {
2719 let username = self
2726 .username
2727 .as_deref()
2728 .unwrap_or(builtins::DEFAULT_USERNAME);
2729 Arc::new(inmem_fs_with_home(username))
2730 };
2731
2732 #[cfg(feature = "realfs")]
2734 let base_fs = Self::apply_real_mounts(
2735 &self.real_mounts,
2736 self.mount_path_allowlist.as_deref(),
2737 base_fs,
2738 );
2739
2740 let has_mounts = !self.mounted_files.is_empty() || !self.mounted_lazy_files.is_empty();
2742 let base_fs: Arc<dyn FileSystem> = if has_mounts {
2743 let overlay = OverlayFs::with_limits(base_fs.clone(), base_fs.limits());
2744 for mf in &self.mounted_files {
2745 overlay.upper().add_file(&mf.path, &mf.content, mf.mode);
2746 }
2747 for lf in self.mounted_lazy_files {
2748 overlay
2749 .upper()
2750 .add_lazy_file(&lf.path, lf.size_hint, lf.mode, lf.loader);
2751 }
2752 Arc::new(overlay)
2753 } else {
2754 base_fs
2755 };
2756
2757 let base_fs: Arc<dyn FileSystem> = if self.readonly_filesystem {
2759 Arc::new(ReadOnlyFs::new(base_fs))
2760 } else {
2761 base_fs
2762 };
2763
2764 let mountable = Arc::new(MountableFs::new(base_fs));
2766 let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
2767
2768 let mut result = Self::build_with_fs(
2769 fs,
2770 mountable,
2771 self.readonly_filesystem,
2772 self.env,
2773 self.username,
2774 self.hostname,
2775 self.fixed_epoch,
2776 self.epoch_offset,
2777 self.cwd,
2778 self.shell_profile,
2779 self.limits,
2780 self.session_limits,
2781 self.memory_limits,
2782 self.trace_mode,
2783 self.trace_callback,
2784 self.custom_builtins,
2785 self.host_builtins,
2786 self.history_file,
2787 #[cfg(feature = "http_client")]
2788 self.network_allowlist,
2789 #[cfg(feature = "http_client")]
2790 self.http_transport,
2791 #[cfg(feature = "bot-auth")]
2792 self.bot_auth_config,
2793 #[cfg(feature = "logging")]
2794 self.log_config,
2795 #[cfg(feature = "git")]
2796 self.git_config,
2797 #[cfg(feature = "ssh")]
2798 self.ssh_config,
2799 #[cfg(feature = "ssh")]
2800 self.ssh_handler,
2801 );
2802
2803 let hooks = hooks::Hooks {
2805 on_exit: self.hooks_on_exit,
2806 before_exec: self.hooks_before_exec,
2807 after_exec: self.hooks_after_exec,
2808 before_tool: self.hooks_before_tool,
2809 after_tool: self.hooks_after_tool,
2810 on_error: self.hooks_on_error,
2811 };
2812 if hooks.has_hooks() {
2813 result.interpreter.set_hooks(hooks);
2814 }
2815
2816 #[cfg(feature = "http_client")]
2819 let mut hooks_before_http = Vec::new();
2820 #[cfg(feature = "http_client")]
2821 if let Some(policy) = self.credential_policy
2822 && !policy.is_empty()
2823 {
2824 hooks_before_http.push(policy.into_hook());
2825 }
2826 #[cfg(feature = "http_client")]
2827 hooks_before_http.extend(self.hooks_before_http);
2828
2829 #[cfg(feature = "http_client")]
2831 if (!hooks_before_http.is_empty() || !self.hooks_after_http.is_empty())
2832 && let Some(client) = result.interpreter.http_client_mut()
2833 {
2834 if !hooks_before_http.is_empty() {
2835 client.set_before_http(hooks_before_http);
2836 }
2837 if !self.hooks_after_http.is_empty() {
2838 client.set_after_http(self.hooks_after_http);
2839 }
2840 }
2841
2842 result
2843 }
2844
2845 #[cfg(feature = "realfs")]
2850 const SENSITIVE_MOUNT_PATHS: &[&str] = &[
2851 "/proc", "/sys", "/dev", "/etc", "/boot", "/root", "/Users", "/home", "/run", "/var/run", "/private",
2858 ];
2859
2860 #[cfg(feature = "realfs")]
2865 const SENSITIVE_PATH_COMPONENTS: &[&str] =
2866 &[".ssh", ".aws", ".kube", ".docker", ".gnupg", ".gcloud"];
2867
2868 #[cfg(feature = "realfs")]
2873 fn is_sensitive_mount_path(host_path: &Path) -> bool {
2874 if host_path == Path::new("/") {
2877 return true;
2878 }
2879 if Self::SENSITIVE_MOUNT_PATHS
2880 .iter()
2881 .any(|s| host_path.starts_with(Path::new(s)))
2882 {
2883 return true;
2884 }
2885 host_path.components().any(|c| {
2886 let s = c.as_os_str();
2887 Self::SENSITIVE_PATH_COMPONENTS.iter().any(|sec| s == *sec)
2888 })
2889 }
2890
2891 #[cfg(feature = "realfs")]
2892 fn apply_real_mounts(
2893 real_mounts: &[MountedRealDir],
2894 mount_allowlist: Option<&[PathBuf]>,
2895 base_fs: Arc<dyn FileSystem>,
2896 ) -> Arc<dyn FileSystem> {
2897 if real_mounts.is_empty() {
2898 return base_fs;
2899 }
2900
2901 let mut current_fs = base_fs;
2902 let mut mount_points: Vec<(PathBuf, Arc<dyn FileSystem>)> = Vec::new();
2903 let canonical_allowlist: Option<Vec<PathBuf>> = mount_allowlist.map(|allowlist| {
2904 allowlist
2905 .iter()
2906 .filter_map(|allowed| match std::fs::canonicalize(allowed) {
2907 Ok(path) => Some(path),
2908 Err(e) => {
2909 eprintln!(
2910 "bashkit: warning: failed to canonicalize allowlist path {}: {}",
2911 allowed.display(),
2912 e
2913 );
2914 None
2915 }
2916 })
2917 .collect()
2918 });
2919
2920 for m in real_mounts {
2921 if m.mode == fs::RealFsMode::ReadWrite {
2923 eprintln!(
2924 "bashkit: warning: writable mount at {} — scripts can modify host files",
2925 m.host_path.display()
2926 );
2927 }
2928
2929 let canonical_host = match std::fs::canonicalize(&m.host_path) {
2930 Ok(path) => path,
2931 Err(e) => {
2932 eprintln!(
2933 "bashkit: warning: failed to canonicalize mount path {}: {}",
2934 m.host_path.display(),
2935 e
2936 );
2937 continue;
2938 }
2939 };
2940
2941 let is_sensitive = Self::is_sensitive_mount_path(&canonical_host);
2945
2946 if let Some(allowlist) = &canonical_allowlist {
2947 if !allowlist
2948 .iter()
2949 .any(|allowed| canonical_host.starts_with(allowed))
2950 {
2951 eprintln!(
2952 "bashkit: warning: mount path {} not in allowlist, skipping",
2953 m.host_path.display()
2954 );
2955 continue;
2956 }
2957 } else if is_sensitive {
2958 eprintln!(
2959 "bashkit: warning: refusing to mount sensitive path {} (no allowlist set; \
2960 pass an explicit `allowed_mount_paths` entry to override)",
2961 m.host_path.display()
2962 );
2963 continue;
2964 }
2965
2966 let real_backend = match fs::RealFs::new(&canonical_host, m.mode) {
2967 Ok(b) => b,
2968 Err(e) => {
2969 eprintln!(
2970 "bashkit: warning: failed to mount {}: {}",
2971 m.host_path.display(),
2972 e
2973 );
2974 continue;
2975 }
2976 };
2977 let real_fs: Arc<dyn FileSystem> = Arc::new(PosixFs::new(real_backend));
2978
2979 match &m.vfs_mount {
2980 None => {
2981 current_fs = Arc::new(OverlayFs::new(real_fs));
2984 }
2985 Some(mount_point) => {
2986 mount_points.push((mount_point.clone(), real_fs));
2987 }
2988 }
2989 }
2990
2991 if !mount_points.is_empty() {
2993 let mountable = MountableFs::new(current_fs);
2994 for (path, fs) in mount_points {
2995 if let Err(e) = mountable.mount(&path, fs) {
2996 eprintln!(
2997 "bashkit: warning: failed to mount at {}: {}",
2998 path.display(),
2999 e
3000 );
3001 }
3002 }
3003 Arc::new(mountable)
3004 } else {
3005 current_fs
3006 }
3007 }
3008
3009 #[allow(clippy::too_many_arguments)]
3011 fn build_with_fs(
3012 fs: Arc<dyn FileSystem>,
3013 mountable: Arc<MountableFs>,
3014 readonly_filesystem: bool,
3015 env: HashMap<String, String>,
3016 username: Option<String>,
3017 hostname: Option<String>,
3018 fixed_epoch: Option<i64>,
3019 epoch_offset: Option<i64>,
3020 cwd: Option<PathBuf>,
3021 shell_profile: interpreter::ShellProfile,
3022 limits: ExecutionLimits,
3023 session_limits: SessionLimits,
3024 memory_limits: MemoryLimits,
3025 trace_mode: TraceMode,
3026 trace_callback: Option<TraceCallback>,
3027 custom_builtins: HashMap<String, Box<dyn Builtin>>,
3028 host_builtins: Option<BuiltinRegistry>,
3029 history_file: Option<PathBuf>,
3030 #[cfg(feature = "http_client")] network_allowlist: Option<NetworkAllowlist>,
3031 #[cfg(feature = "http_client")] http_transport: Option<Arc<dyn network::HttpTransport>>,
3032 #[cfg(feature = "bot-auth")] bot_auth_config: Option<network::BotAuthConfig>,
3033 #[cfg(feature = "logging")] log_config: Option<logging::LogConfig>,
3034 #[cfg(feature = "git")] git_config: Option<GitConfig>,
3035 #[cfg(feature = "ssh")] ssh_config: Option<SshConfig>,
3036 #[cfg(feature = "ssh")] ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
3037 ) -> Bash {
3038 #[cfg(feature = "logging")]
3039 let log_config = log_config.unwrap_or_default();
3040
3041 #[cfg(feature = "logging")]
3042 tracing::debug!(
3043 target: "bashkit::config",
3044 redact_sensitive = log_config.redact_sensitive,
3045 log_scripts = log_config.log_script_content,
3046 "Bash instance configured"
3047 );
3048
3049 let mut interpreter = Interpreter::with_config(
3050 Arc::clone(&fs),
3051 username.clone(),
3052 hostname,
3053 fixed_epoch,
3054 epoch_offset,
3055 custom_builtins,
3056 host_builtins,
3057 shell_profile,
3058 );
3059
3060 for (key, value) in &env {
3062 interpreter.set_env(key, value);
3063 interpreter.set_var(key, value);
3066 }
3067 #[cfg(feature = "python")]
3068 let python_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_PYTHON");
3069 #[cfg(feature = "sqlite")]
3070 let sqlite_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_SQLITE");
3071 drop(env);
3072
3073 if let Some(ref username) = username {
3075 interpreter.set_env("USER", username);
3076 interpreter.set_var("USER", username);
3077 }
3078
3079 if let Some(cwd) = cwd {
3080 interpreter.set_cwd(cwd);
3081 }
3082
3083 #[cfg(feature = "http_client")]
3085 if let Some(allowlist) = network_allowlist {
3086 let mut client = network::HttpClient::new(allowlist);
3087 if let Some(transport) = http_transport {
3088 client.set_transport(transport);
3089 }
3090 #[cfg(feature = "bot-auth")]
3091 if let Some(bot_auth) = bot_auth_config {
3092 client.set_bot_auth(bot_auth);
3093 }
3094 interpreter.set_http_client(client);
3095 }
3096
3097 #[cfg(feature = "git")]
3099 if let Some(config) = git_config {
3100 let client = builtins::git::GitClient::new(config);
3101 interpreter.set_git_client(client);
3102 }
3103
3104 #[cfg(feature = "ssh")]
3106 if let Some(config) = ssh_config {
3107 let mut client = builtins::ssh::SshClient::new(config);
3108 if let Some(handler) = ssh_handler {
3109 client.set_handler(handler);
3110 }
3111 interpreter.set_ssh_client(client);
3112 }
3113
3114 if let Some(hf) = history_file {
3116 interpreter.set_history_file(hf);
3117 }
3118
3119 let parser_timeout = limits.parser_timeout;
3120 let max_input_bytes = limits.max_input_bytes;
3121 let max_ast_depth = limits.max_ast_depth;
3122 let max_parser_operations = limits.max_parser_operations;
3123 interpreter.set_limits(limits);
3124 interpreter.set_session_limits(session_limits);
3125 interpreter.set_memory_limits(memory_limits);
3126 let mut trace_collector = TraceCollector::new(trace_mode);
3127 if let Some(cb) = trace_callback {
3128 trace_collector.set_callback(cb);
3129 }
3130 interpreter.set_trace(trace_collector);
3131 Bash {
3132 fs,
3133 mountable,
3134 readonly_filesystem,
3135 interpreter,
3136 parser_timeout,
3137 max_input_bytes,
3138 max_ast_depth,
3139 max_parser_operations,
3140 #[cfg(feature = "logging")]
3141 log_config,
3142 #[cfg(feature = "python")]
3143 python_inprocess_opt_in,
3144 #[cfg(feature = "sqlite")]
3145 sqlite_inprocess_opt_in,
3146 }
3147 }
3148}
3149
3150#[cfg(feature = "http_client")]
3166#[doc = include_str!("../docs/credential-injection.md")]
3167pub mod credential_injection_guide {}
3168
3169#[doc = include_str!("../docs/custom_builtins.md")]
3179pub mod custom_builtins_guide {}
3180
3181#[doc = include_str!("../docs/clap-builtins.md")]
3191pub mod clap_builtins_guide {}
3192
3193#[doc = include_str!("../docs/compatibility.md")]
3203pub mod compatibility_scorecard {}
3204
3205#[doc = include_str!("../docs/jq.md")]
3215pub mod jq_guide {}
3216
3217#[doc = include_str!("../docs/threat-model.md")]
3231pub mod threat_model {}
3232
3233#[cfg(feature = "python")]
3247#[doc = include_str!("../docs/python.md")]
3248pub mod python_guide {}
3249
3250#[cfg(feature = "sqlite")]
3262#[doc = include_str!("../docs/sqlite.md")]
3263pub mod sqlite_guide {}
3264
3265#[cfg(feature = "typescript")]
3277#[doc = include_str!("../docs/typescript.md")]
3278pub mod typescript_guide {}
3279
3280#[cfg(feature = "ssh")]
3284#[doc = include_str!("../docs/ssh.md")]
3285pub mod ssh_guide {}
3286
3287#[doc = include_str!("../docs/live_mounts.md")]
3297pub mod live_mounts_guide {}
3298
3299#[doc = include_str!("../docs/namespace_filesystems.md")]
3301pub mod namespace_filesystems_guide {}
3302
3303#[cfg(feature = "logging")]
3316#[doc = include_str!("../docs/logging.md")]
3317pub mod logging_guide {}
3318
3319#[doc = include_str!("../docs/hooks.md")]
3334pub mod hooks_guide {}
3335
3336#[cfg(test)]
3337mod tests {
3338 use super::*;
3339 use std::sync::{Arc, Mutex};
3340
3341 #[tokio::test]
3342 async fn test_echo_hello() {
3343 let mut bash = Bash::new();
3344 let result = bash.exec("echo hello").await.unwrap();
3345 assert_eq!(result.stdout, "hello\n");
3346 assert_eq!(result.exit_code, 0);
3347 }
3348
3349 #[tokio::test]
3350 async fn test_echo_multiple_args() {
3351 let mut bash = Bash::new();
3352 let result = bash.exec("echo hello world").await.unwrap();
3353 assert_eq!(result.stdout, "hello world\n");
3354 assert_eq!(result.exit_code, 0);
3355 }
3356
3357 #[tokio::test]
3358 async fn test_variable_expansion() {
3359 let mut bash = Bash::builder().env("HOME", "/home/user").build();
3360 let result = bash.exec("echo $HOME").await.unwrap();
3361 assert_eq!(result.stdout, "/home/user\n");
3362 assert_eq!(result.exit_code, 0);
3363 }
3364
3365 #[tokio::test]
3366 async fn test_variable_brace_expansion() {
3367 let mut bash = Bash::builder().env("USER", "testuser").build();
3368 let result = bash.exec("echo ${USER}").await.unwrap();
3369 assert_eq!(result.stdout, "testuser\n");
3370 }
3371
3372 #[tokio::test]
3373 async fn test_undefined_variable_expands_to_empty() {
3374 let mut bash = Bash::new();
3375 let result = bash.exec("echo $UNDEFINED_VAR").await.unwrap();
3376 assert_eq!(result.stdout, "\n");
3377 }
3378
3379 #[tokio::test]
3380 async fn test_pipeline() {
3381 let mut bash = Bash::new();
3382 let result = bash.exec("echo hello | cat").await.unwrap();
3383 assert_eq!(result.stdout, "hello\n");
3384 }
3385
3386 #[tokio::test(start_paused = true)]
3387 async fn test_timed_out_bash_c_does_not_leak_stdin_to_next_exec() {
3388 let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
3389 let mut bash = Bash::builder().limits(limits).build();
3390
3391 let timed_out = bash.exec("printf secret | bash -c 'sleep 10'").await;
3392 assert!(matches!(
3393 timed_out,
3394 Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
3395 ));
3396
3397 let result = bash.exec("cat").await.unwrap();
3398 assert_eq!(result.stdout, "");
3399 }
3400
3401 #[tokio::test(start_paused = true)]
3402 async fn test_timed_out_fd3_capture_does_not_leak_to_next_exec() {
3403 let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
3404 let mut bash = Bash::builder().limits(limits).build();
3405
3406 let timed_out = bash.exec("{ sleep 10; } 3>&1 > /tmp/poison.txt").await;
3407 assert!(matches!(
3408 timed_out,
3409 Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
3410 ));
3411
3412 let hidden = bash.exec("echo SECRET_FROM_EXEC2 1>&3").await.unwrap();
3413 assert_eq!(hidden.stdout, "");
3414
3415 let routed = bash
3416 .exec("echo PUBLIC_FROM_EXEC3 2>&1 > /tmp/public.txt")
3417 .await
3418 .unwrap();
3419 assert_eq!(routed.stdout, "");
3420
3421 let file = bash.exec("cat /tmp/public.txt").await.unwrap();
3422 assert_eq!(file.stdout, "PUBLIC_FROM_EXEC3\n");
3423 }
3424
3425 #[tokio::test(start_paused = true)]
3426 async fn test_timed_out_debug_trap_does_not_suppress_next_exec_debug_trap() {
3427 let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
3428 let mut bash = Bash::builder().limits(limits).build();
3429
3430 let timed_out = bash
3431 .exec("trap 'sleep 10' DEBUG; echo should-not-run")
3432 .await;
3433 assert!(matches!(
3434 timed_out,
3435 Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
3436 ));
3437
3438 let result = bash
3439 .exec("count=0; trap '((count++))' DEBUG; echo body; trap - DEBUG; echo $count")
3440 .await
3441 .unwrap();
3442 assert_eq!(result.stdout, "body\n2\n");
3443 }
3444
3445 #[tokio::test]
3446 async fn test_pipeline_three_commands() {
3447 let mut bash = Bash::new();
3448 let result = bash.exec("echo hello | cat | cat").await.unwrap();
3449 assert_eq!(result.stdout, "hello\n");
3450 }
3451
3452 #[tokio::test]
3453 async fn test_redirect_output() {
3454 let mut bash = Bash::new();
3455 let result = bash.exec("echo hello > /tmp/test.txt").await.unwrap();
3456 assert_eq!(result.stdout, "");
3457 assert_eq!(result.exit_code, 0);
3458
3459 let result = bash.exec("cat /tmp/test.txt").await.unwrap();
3461 assert_eq!(result.stdout, "hello\n");
3462 }
3463
3464 #[tokio::test]
3465 async fn test_redirect_append() {
3466 let mut bash = Bash::new();
3467 bash.exec("echo hello > /tmp/append.txt").await.unwrap();
3468 bash.exec("echo world >> /tmp/append.txt").await.unwrap();
3469
3470 let result = bash.exec("cat /tmp/append.txt").await.unwrap();
3471 assert_eq!(result.stdout, "hello\nworld\n");
3472 }
3473
3474 #[tokio::test]
3475 async fn test_command_list_and() {
3476 let mut bash = Bash::new();
3477 let result = bash.exec("true && echo success").await.unwrap();
3478 assert_eq!(result.stdout, "success\n");
3479 }
3480
3481 #[tokio::test]
3482 async fn test_command_list_and_short_circuit() {
3483 let mut bash = Bash::new();
3484 let result = bash.exec("false && echo should_not_print").await.unwrap();
3485 assert_eq!(result.stdout, "");
3486 assert_eq!(result.exit_code, 1);
3487 }
3488
3489 #[tokio::test]
3490 async fn test_command_list_or() {
3491 let mut bash = Bash::new();
3492 let result = bash.exec("false || echo fallback").await.unwrap();
3493 assert_eq!(result.stdout, "fallback\n");
3494 }
3495
3496 #[tokio::test]
3497 async fn test_command_list_or_short_circuit() {
3498 let mut bash = Bash::new();
3499 let result = bash.exec("true || echo should_not_print").await.unwrap();
3500 assert_eq!(result.stdout, "");
3501 assert_eq!(result.exit_code, 0);
3502 }
3503
3504 #[tokio::test]
3506 async fn test_phase1_target() {
3507 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
3508
3509 let result = bash
3510 .exec("echo $HOME | cat > /tmp/out && cat /tmp/out")
3511 .await
3512 .unwrap();
3513
3514 assert_eq!(result.stdout, "/home/testuser\n");
3515 assert_eq!(result.exit_code, 0);
3516 }
3517
3518 #[tokio::test]
3519 async fn test_redirect_input() {
3520 let mut bash = Bash::new();
3521 bash.exec("echo hello > /tmp/input.txt").await.unwrap();
3523
3524 let result = bash.exec("cat < /tmp/input.txt").await.unwrap();
3526 assert_eq!(result.stdout, "hello\n");
3527 }
3528
3529 #[tokio::test]
3530 async fn test_here_string() {
3531 let mut bash = Bash::new();
3532 let result = bash.exec("cat <<< hello").await.unwrap();
3533 assert_eq!(result.stdout, "hello\n");
3534 }
3535
3536 #[tokio::test]
3537 async fn test_if_true() {
3538 let mut bash = Bash::new();
3539 let result = bash.exec("if true; then echo yes; fi").await.unwrap();
3540 assert_eq!(result.stdout, "yes\n");
3541 }
3542
3543 #[tokio::test]
3544 async fn test_if_false() {
3545 let mut bash = Bash::new();
3546 let result = bash.exec("if false; then echo yes; fi").await.unwrap();
3547 assert_eq!(result.stdout, "");
3548 }
3549
3550 #[tokio::test]
3551 async fn test_if_else() {
3552 let mut bash = Bash::new();
3553 let result = bash
3554 .exec("if false; then echo yes; else echo no; fi")
3555 .await
3556 .unwrap();
3557 assert_eq!(result.stdout, "no\n");
3558 }
3559
3560 #[tokio::test]
3561 async fn test_if_elif() {
3562 let mut bash = Bash::new();
3563 let result = bash
3564 .exec("if false; then echo one; elif true; then echo two; else echo three; fi")
3565 .await
3566 .unwrap();
3567 assert_eq!(result.stdout, "two\n");
3568 }
3569
3570 #[tokio::test]
3571 async fn test_for_loop() {
3572 let mut bash = Bash::new();
3573 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
3574 assert_eq!(result.stdout, "a\nb\nc\n");
3575 }
3576
3577 #[tokio::test]
3578 async fn test_for_loop_positional_params() {
3579 let mut bash = Bash::new();
3580 let result = bash
3582 .exec("f() { for x; do echo $x; done; }; f one two three")
3583 .await
3584 .unwrap();
3585 assert_eq!(result.stdout, "one\ntwo\nthree\n");
3586 }
3587
3588 #[tokio::test]
3589 async fn test_while_loop() {
3590 let mut bash = Bash::new();
3591 let result = bash.exec("while false; do echo loop; done").await.unwrap();
3593 assert_eq!(result.stdout, "");
3594 }
3595
3596 #[tokio::test]
3597 async fn test_subshell() {
3598 let mut bash = Bash::new();
3599 let result = bash.exec("(echo hello)").await.unwrap();
3600 assert_eq!(result.stdout, "hello\n");
3601 }
3602
3603 #[tokio::test]
3604 async fn test_brace_group() {
3605 let mut bash = Bash::new();
3606 let result = bash.exec("{ echo hello; }").await.unwrap();
3607 assert_eq!(result.stdout, "hello\n");
3608 }
3609
3610 #[tokio::test]
3611 async fn test_function_keyword() {
3612 let mut bash = Bash::new();
3613 let result = bash
3614 .exec("function greet { echo hello; }; greet")
3615 .await
3616 .unwrap();
3617 assert_eq!(result.stdout, "hello\n");
3618 }
3619
3620 #[tokio::test]
3621 async fn test_function_posix() {
3622 let mut bash = Bash::new();
3623 let result = bash.exec("greet() { echo hello; }; greet").await.unwrap();
3624 assert_eq!(result.stdout, "hello\n");
3625 }
3626
3627 #[tokio::test]
3628 async fn test_function_args() {
3629 let mut bash = Bash::new();
3630 let result = bash
3631 .exec("greet() { echo $1 $2; }; greet world foo")
3632 .await
3633 .unwrap();
3634 assert_eq!(result.stdout, "world foo\n");
3635 }
3636
3637 #[tokio::test]
3638 async fn test_function_arg_count() {
3639 let mut bash = Bash::new();
3640 let result = bash
3641 .exec("count() { echo $#; }; count a b c")
3642 .await
3643 .unwrap();
3644 assert_eq!(result.stdout, "3\n");
3645 }
3646
3647 #[tokio::test]
3648 async fn test_case_literal() {
3649 let mut bash = Bash::new();
3650 let result = bash
3651 .exec("case foo in foo) echo matched ;; esac")
3652 .await
3653 .unwrap();
3654 assert_eq!(result.stdout, "matched\n");
3655 }
3656
3657 #[tokio::test]
3658 async fn test_case_wildcard() {
3659 let mut bash = Bash::new();
3660 let result = bash
3661 .exec("case bar in *) echo default ;; esac")
3662 .await
3663 .unwrap();
3664 assert_eq!(result.stdout, "default\n");
3665 }
3666
3667 #[tokio::test]
3668 async fn test_case_no_match() {
3669 let mut bash = Bash::new();
3670 let result = bash.exec("case foo in bar) echo no ;; esac").await.unwrap();
3671 assert_eq!(result.stdout, "");
3672 }
3673
3674 #[tokio::test]
3675 async fn test_case_multiple_patterns() {
3676 let mut bash = Bash::new();
3677 let result = bash
3678 .exec("case foo in bar|foo|baz) echo matched ;; esac")
3679 .await
3680 .unwrap();
3681 assert_eq!(result.stdout, "matched\n");
3682 }
3683
3684 #[tokio::test]
3685 async fn test_case_bracket_expr() {
3686 let mut bash = Bash::new();
3687 let result = bash
3689 .exec("case b in [abc]) echo matched ;; esac")
3690 .await
3691 .unwrap();
3692 assert_eq!(result.stdout, "matched\n");
3693 }
3694
3695 #[tokio::test]
3696 async fn test_case_bracket_range() {
3697 let mut bash = Bash::new();
3698 let result = bash
3700 .exec("case m in [a-z]) echo letter ;; esac")
3701 .await
3702 .unwrap();
3703 assert_eq!(result.stdout, "letter\n");
3704 }
3705
3706 #[tokio::test]
3707 async fn test_case_bracket_wide_unicode_range() {
3708 let mut bash = Bash::new();
3709 let result = bash
3710 .exec("case z in [a-\u{10ffff}]) echo wide ;; esac")
3711 .await
3712 .unwrap();
3713 assert_eq!(result.stdout, "wide\n");
3714 }
3715
3716 #[tokio::test]
3717 async fn test_case_bracket_negation() {
3718 let mut bash = Bash::new();
3719 let result = bash
3721 .exec("case x in [!abc]) echo not_abc ;; esac")
3722 .await
3723 .unwrap();
3724 assert_eq!(result.stdout, "not_abc\n");
3725 }
3726
3727 #[tokio::test]
3728 async fn test_break_as_command() {
3729 let mut bash = Bash::new();
3730 let result = bash.exec("break").await.unwrap();
3732 assert_eq!(result.exit_code, 0);
3734 }
3735
3736 #[tokio::test]
3737 async fn test_for_one_item() {
3738 let mut bash = Bash::new();
3739 let result = bash.exec("for i in a; do echo $i; done").await.unwrap();
3741 assert_eq!(result.stdout, "a\n");
3742 }
3743
3744 #[tokio::test]
3745 async fn test_for_with_break() {
3746 let mut bash = Bash::new();
3747 let result = bash.exec("for i in a; do break; done").await.unwrap();
3749 assert_eq!(result.stdout, "");
3750 assert_eq!(result.exit_code, 0);
3751 }
3752
3753 #[tokio::test]
3754 async fn test_for_echo_break() {
3755 let mut bash = Bash::new();
3756 let result = bash
3758 .exec("for i in a b c; do echo $i; break; done")
3759 .await
3760 .unwrap();
3761 assert_eq!(result.stdout, "a\n");
3762 }
3763
3764 #[tokio::test]
3765 async fn test_test_string_empty() {
3766 let mut bash = Bash::new();
3767 let result = bash.exec("test -z '' && echo yes").await.unwrap();
3768 assert_eq!(result.stdout, "yes\n");
3769 }
3770
3771 #[tokio::test]
3772 async fn test_test_string_not_empty() {
3773 let mut bash = Bash::new();
3774 let result = bash.exec("test -n 'hello' && echo yes").await.unwrap();
3775 assert_eq!(result.stdout, "yes\n");
3776 }
3777
3778 #[tokio::test]
3779 async fn test_test_string_equal() {
3780 let mut bash = Bash::new();
3781 let result = bash.exec("test foo = foo && echo yes").await.unwrap();
3782 assert_eq!(result.stdout, "yes\n");
3783 }
3784
3785 #[tokio::test]
3786 async fn test_test_string_not_equal() {
3787 let mut bash = Bash::new();
3788 let result = bash.exec("test foo != bar && echo yes").await.unwrap();
3789 assert_eq!(result.stdout, "yes\n");
3790 }
3791
3792 #[tokio::test]
3793 async fn test_test_numeric_equal() {
3794 let mut bash = Bash::new();
3795 let result = bash.exec("test 5 -eq 5 && echo yes").await.unwrap();
3796 assert_eq!(result.stdout, "yes\n");
3797 }
3798
3799 #[tokio::test]
3800 async fn test_test_numeric_less_than() {
3801 let mut bash = Bash::new();
3802 let result = bash.exec("test 3 -lt 5 && echo yes").await.unwrap();
3803 assert_eq!(result.stdout, "yes\n");
3804 }
3805
3806 #[tokio::test]
3807 async fn test_bracket_form() {
3808 let mut bash = Bash::new();
3809 let result = bash.exec("[ foo = foo ] && echo yes").await.unwrap();
3810 assert_eq!(result.stdout, "yes\n");
3811 }
3812
3813 #[tokio::test]
3814 async fn test_if_with_test() {
3815 let mut bash = Bash::new();
3816 let result = bash
3817 .exec("if [ 5 -gt 3 ]; then echo bigger; fi")
3818 .await
3819 .unwrap();
3820 assert_eq!(result.stdout, "bigger\n");
3821 }
3822
3823 #[tokio::test]
3824 async fn test_variable_assignment() {
3825 let mut bash = Bash::new();
3826 let result = bash.exec("FOO=bar; echo $FOO").await.unwrap();
3827 assert_eq!(result.stdout, "bar\n");
3828 }
3829
3830 #[tokio::test]
3831 async fn test_variable_assignment_inline() {
3832 let mut bash = Bash::new();
3833 let result = bash.exec("MSG=hello; echo $MSG world").await.unwrap();
3835 assert_eq!(result.stdout, "hello world\n");
3836 }
3837
3838 #[tokio::test]
3839 async fn test_variable_assignment_only() {
3840 let mut bash = Bash::new();
3841 let result = bash.exec("FOO=bar").await.unwrap();
3843 assert_eq!(result.stdout, "");
3844 assert_eq!(result.exit_code, 0);
3845
3846 let result = bash.exec("echo $FOO").await.unwrap();
3848 assert_eq!(result.stdout, "bar\n");
3849 }
3850
3851 #[tokio::test]
3852 async fn test_multiple_assignments() {
3853 let mut bash = Bash::new();
3854 let result = bash.exec("A=1; B=2; C=3; echo $A $B $C").await.unwrap();
3855 assert_eq!(result.stdout, "1 2 3\n");
3856 }
3857
3858 #[tokio::test]
3859 async fn test_prefix_assignment_visible_in_env() {
3860 let mut bash = Bash::new();
3861 let result = bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
3863 assert_eq!(result.stdout, "hello\n");
3864 }
3865
3866 #[tokio::test]
3867 async fn test_prefix_assignment_temporary() {
3868 let mut bash = Bash::new();
3869 bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
3871 let result = bash.exec("echo ${MYVAR:-unset}").await.unwrap();
3872 assert_eq!(result.stdout, "unset\n");
3873 }
3874
3875 #[tokio::test]
3876 async fn test_prefix_assignment_duplicate_name_temporary() {
3877 let mut bash = Bash::new();
3878 let result = bash.exec("A=1 A=2 printenv A").await.unwrap();
3880 assert_eq!(result.stdout, "2\n");
3881 let result = bash.exec("echo ${A:-unset}").await.unwrap();
3882 assert_eq!(result.stdout, "unset\n");
3883 }
3884
3885 #[tokio::test]
3886 async fn test_prefix_assignment_does_not_clobber_existing_env() {
3887 let mut bash = Bash::new();
3888 let result = bash
3890 .exec("EXISTING=original; export EXISTING; EXISTING=temp printenv EXISTING")
3891 .await
3892 .unwrap();
3893 assert_eq!(result.stdout, "temp\n");
3894 }
3895
3896 #[tokio::test]
3897 async fn test_prefix_assignment_multiple_vars() {
3898 let mut bash = Bash::new();
3899 let result = bash.exec("A=one B=two printenv A").await.unwrap();
3901 assert_eq!(result.stdout, "one\n");
3902 assert_eq!(result.exit_code, 0);
3903 }
3904
3905 #[tokio::test]
3906 async fn test_prefix_assignment_empty_value() {
3907 let mut bash = Bash::new();
3908 let result = bash.exec("MYVAR= printenv MYVAR").await.unwrap();
3910 assert_eq!(result.stdout, "\n");
3911 assert_eq!(result.exit_code, 0);
3912 }
3913
3914 #[tokio::test]
3915 async fn test_prefix_assignment_not_found_without_prefix() {
3916 let mut bash = Bash::new();
3917 let result = bash.exec("printenv NONEXISTENT").await.unwrap();
3919 assert_eq!(result.stdout, "");
3920 assert_eq!(result.exit_code, 1);
3921 }
3922
3923 #[tokio::test]
3924 async fn test_prefix_assignment_does_not_persist_in_variables() {
3925 let mut bash = Bash::new();
3926 bash.exec("TMPVAR=gone echo ok").await.unwrap();
3928 let result = bash.exec("echo \"${TMPVAR:-unset}\"").await.unwrap();
3929 assert_eq!(result.stdout, "unset\n");
3930 }
3931
3932 #[tokio::test]
3933 async fn test_assignment_only_persists() {
3934 let mut bash = Bash::new();
3935 bash.exec("PERSIST=yes").await.unwrap();
3937 let result = bash.exec("echo $PERSIST").await.unwrap();
3938 assert_eq!(result.stdout, "yes\n");
3939 }
3940
3941 #[tokio::test]
3942 async fn test_printf_string() {
3943 let mut bash = Bash::new();
3944 let result = bash.exec("printf '%s' hello").await.unwrap();
3945 assert_eq!(result.stdout, "hello");
3946 }
3947
3948 #[tokio::test]
3949 async fn test_printf_newline() {
3950 let mut bash = Bash::new();
3951 let result = bash.exec("printf 'hello\\n'").await.unwrap();
3952 assert_eq!(result.stdout, "hello\n");
3953 }
3954
3955 #[tokio::test]
3956 async fn test_printf_multiple_args() {
3957 let mut bash = Bash::new();
3958 let result = bash.exec("printf '%s %s\\n' hello world").await.unwrap();
3959 assert_eq!(result.stdout, "hello world\n");
3960 }
3961
3962 #[tokio::test]
3963 async fn test_printf_integer() {
3964 let mut bash = Bash::new();
3965 let result = bash.exec("printf '%d' 42").await.unwrap();
3966 assert_eq!(result.stdout, "42");
3967 }
3968
3969 #[tokio::test]
3970 async fn test_export() {
3971 let mut bash = Bash::new();
3972 let result = bash.exec("export FOO=bar; echo $FOO").await.unwrap();
3973 assert_eq!(result.stdout, "bar\n");
3974 }
3975
3976 #[tokio::test]
3977 async fn test_read_basic() {
3978 let mut bash = Bash::new();
3979 let result = bash.exec("echo hello | read VAR; echo $VAR").await.unwrap();
3980 assert_eq!(result.stdout, "hello\n");
3981 }
3982
3983 #[tokio::test]
3984 async fn test_read_multiple_vars() {
3985 let mut bash = Bash::new();
3986 let result = bash
3987 .exec("echo 'a b c' | read X Y Z; echo $X $Y $Z")
3988 .await
3989 .unwrap();
3990 assert_eq!(result.stdout, "a b c\n");
3991 }
3992
3993 #[tokio::test]
3994 async fn test_read_respects_local_scope() {
3995 let mut bash = Bash::new();
3997 let result = bash
3998 .exec(
3999 r#"
4000fn() { local k; read -r k <<< "test"; echo "$k"; }
4001fn
4002"#,
4003 )
4004 .await
4005 .unwrap();
4006 assert_eq!(result.stdout, "test\n");
4007 }
4008
4009 #[tokio::test]
4010 async fn test_local_ifs_array_join() {
4011 let mut bash = Bash::new();
4013 let result = bash
4014 .exec(
4015 r#"
4016fn() {
4017 local arr=(a b c)
4018 local IFS=":"
4019 echo "${arr[*]}"
4020}
4021fn
4022"#,
4023 )
4024 .await
4025 .unwrap();
4026 assert_eq!(result.stdout, "a:b:c\n");
4027 }
4028
4029 #[tokio::test]
4030 async fn test_glob_star() {
4031 let mut bash = Bash::new();
4032 bash.exec("echo a > /tmp/file1.txt").await.unwrap();
4034 bash.exec("echo b > /tmp/file2.txt").await.unwrap();
4035 bash.exec("echo c > /tmp/other.log").await.unwrap();
4036
4037 let result = bash.exec("echo /tmp/*.txt").await.unwrap();
4039 assert_eq!(result.stdout, "/tmp/file1.txt /tmp/file2.txt\n");
4040 }
4041
4042 #[tokio::test]
4043 async fn test_glob_question_mark() {
4044 let mut bash = Bash::new();
4045 bash.exec("echo a > /tmp/a1.txt").await.unwrap();
4047 bash.exec("echo b > /tmp/a2.txt").await.unwrap();
4048 bash.exec("echo c > /tmp/a10.txt").await.unwrap();
4049
4050 let result = bash.exec("echo /tmp/a?.txt").await.unwrap();
4052 assert_eq!(result.stdout, "/tmp/a1.txt /tmp/a2.txt\n");
4053 }
4054
4055 #[tokio::test]
4056 async fn test_glob_no_match() {
4057 let mut bash = Bash::new();
4058 let result = bash.exec("echo /nonexistent/*.xyz").await.unwrap();
4060 assert_eq!(result.stdout, "/nonexistent/*.xyz\n");
4061 }
4062
4063 #[tokio::test]
4064 async fn test_command_substitution() {
4065 let mut bash = Bash::new();
4066 let result = bash.exec("echo $(echo hello)").await.unwrap();
4067 assert_eq!(result.stdout, "hello\n");
4068 }
4069
4070 #[tokio::test]
4071 async fn test_command_substitution_in_string() {
4072 let mut bash = Bash::new();
4073 let result = bash.exec("echo \"result: $(echo 42)\"").await.unwrap();
4074 assert_eq!(result.stdout, "result: 42\n");
4075 }
4076
4077 #[tokio::test]
4078 async fn test_command_substitution_pipeline() {
4079 let mut bash = Bash::new();
4080 let result = bash.exec("echo $(echo hello | cat)").await.unwrap();
4081 assert_eq!(result.stdout, "hello\n");
4082 }
4083
4084 #[tokio::test]
4085 async fn test_command_substitution_variable() {
4086 let mut bash = Bash::new();
4087 let result = bash.exec("VAR=$(echo test); echo $VAR").await.unwrap();
4088 assert_eq!(result.stdout, "test\n");
4089 }
4090
4091 #[tokio::test]
4092 async fn test_arithmetic_simple() {
4093 let mut bash = Bash::new();
4094 let result = bash.exec("echo $((1 + 2))").await.unwrap();
4095 assert_eq!(result.stdout, "3\n");
4096 }
4097
4098 #[tokio::test]
4099 async fn test_arithmetic_multiply() {
4100 let mut bash = Bash::new();
4101 let result = bash.exec("echo $((3 * 4))").await.unwrap();
4102 assert_eq!(result.stdout, "12\n");
4103 }
4104
4105 #[tokio::test]
4106 async fn test_arithmetic_with_variable() {
4107 let mut bash = Bash::new();
4108 let result = bash.exec("X=5; echo $((X + 3))").await.unwrap();
4109 assert_eq!(result.stdout, "8\n");
4110 }
4111
4112 #[tokio::test]
4113 async fn test_arithmetic_complex() {
4114 let mut bash = Bash::new();
4115 let result = bash.exec("echo $((2 + 3 * 4))").await.unwrap();
4116 assert_eq!(result.stdout, "14\n");
4117 }
4118
4119 #[tokio::test]
4120 async fn test_heredoc_simple() {
4121 let mut bash = Bash::new();
4122 let result = bash.exec("cat <<EOF\nhello\nworld\nEOF").await.unwrap();
4123 assert_eq!(result.stdout, "hello\nworld\n");
4124 }
4125
4126 #[tokio::test]
4127 async fn test_heredoc_single_line() {
4128 let mut bash = Bash::new();
4129 let result = bash.exec("cat <<END\ntest\nEND").await.unwrap();
4130 assert_eq!(result.stdout, "test\n");
4131 }
4132
4133 #[tokio::test]
4134 async fn test_unset() {
4135 let mut bash = Bash::new();
4136 let result = bash
4137 .exec("FOO=bar; unset FOO; echo \"x${FOO}y\"")
4138 .await
4139 .unwrap();
4140 assert_eq!(result.stdout, "xy\n");
4141 }
4142
4143 #[tokio::test]
4144 async fn test_local_basic() {
4145 let mut bash = Bash::new();
4146 let result = bash.exec("local X=test; echo $X").await.unwrap();
4148 assert_eq!(result.stdout, "test\n");
4149 }
4150
4151 #[tokio::test]
4152 async fn test_set_option() {
4153 let mut bash = Bash::new();
4154 let result = bash.exec("set -e; echo ok").await.unwrap();
4155 assert_eq!(result.stdout, "ok\n");
4156 }
4157
4158 #[tokio::test]
4159 async fn test_param_default() {
4160 let mut bash = Bash::new();
4161 let result = bash.exec("echo ${UNSET:-default}").await.unwrap();
4163 assert_eq!(result.stdout, "default\n");
4164
4165 let result = bash.exec("X=value; echo ${X:-default}").await.unwrap();
4167 assert_eq!(result.stdout, "value\n");
4168 }
4169
4170 #[tokio::test]
4171 async fn test_param_assign_default() {
4172 let mut bash = Bash::new();
4173 let result = bash.exec("echo ${NEW:=assigned}; echo $NEW").await.unwrap();
4175 assert_eq!(result.stdout, "assigned\nassigned\n");
4176 }
4177
4178 #[tokio::test]
4179 async fn test_param_length() {
4180 let mut bash = Bash::new();
4181 let result = bash.exec("X=hello; echo ${#X}").await.unwrap();
4182 assert_eq!(result.stdout, "5\n");
4183 }
4184
4185 #[tokio::test]
4186 async fn test_param_remove_prefix() {
4187 let mut bash = Bash::new();
4188 let result = bash.exec("X=hello.world.txt; echo ${X#*.}").await.unwrap();
4190 assert_eq!(result.stdout, "world.txt\n");
4191 }
4192
4193 #[tokio::test]
4194 async fn test_param_remove_prefix_mixed_pattern() {
4195 let mut bash = Bash::new();
4196 let result = bash
4198 .exec(r#"i="./tag_hello.tmp.html"; prefix_tags="tag_"; echo ${i#./"$prefix_tags"}"#)
4199 .await
4200 .unwrap();
4201 assert_eq!(result.stdout, "hello.tmp.html\n");
4202 }
4203
4204 #[tokio::test]
4205 async fn test_param_remove_suffix() {
4206 let mut bash = Bash::new();
4207 let result = bash.exec("X=file.tar.gz; echo ${X%.*}").await.unwrap();
4209 assert_eq!(result.stdout, "file.tar\n");
4210 }
4211
4212 #[tokio::test]
4213 async fn test_positional_param_prefix_replace() {
4214 let mut bash = Bash::new();
4215 let result = bash
4217 .exec(r#"f() { set -- "${@/#/tag_}"; echo "$@"; }; f hello world"#)
4218 .await
4219 .unwrap();
4220 assert_eq!(result.stdout, "tag_hello tag_world\n");
4221 }
4222
4223 #[tokio::test]
4224 async fn test_positional_param_suffix_replace() {
4225 let mut bash = Bash::new();
4226 let result = bash
4228 .exec(r#"f() { set -- "${@/%/.html}"; echo "$@"; }; f hello world"#)
4229 .await
4230 .unwrap();
4231 assert_eq!(result.stdout, "hello.html world.html\n");
4232 }
4233
4234 #[tokio::test]
4235 async fn test_positional_param_prefix_var_replace() {
4236 let mut bash = Bash::new();
4237 let result = bash
4239 .exec(r#"f() { p="tag_"; set -- "${@/#/$p}"; echo "$@"; }; f hello world"#)
4240 .await
4241 .unwrap();
4242 assert_eq!(result.stdout, "tag_hello tag_world\n");
4243 }
4244
4245 #[tokio::test]
4246 async fn test_positional_param_prefix_strip() {
4247 let mut bash = Bash::new();
4248 let result = bash
4250 .exec(r#"f() { set -- "${@#tag_}"; echo "$@"; }; f tag_hello tag_world"#)
4251 .await
4252 .unwrap();
4253 assert_eq!(result.stdout, "hello world\n");
4254 }
4255
4256 #[tokio::test]
4257 async fn test_array_basic() {
4258 let mut bash = Bash::new();
4259 let result = bash.exec("arr=(a b c); echo ${arr[1]}").await.unwrap();
4261 assert_eq!(result.stdout, "b\n");
4262 }
4263
4264 #[tokio::test]
4265 async fn test_array_all_elements() {
4266 let mut bash = Bash::new();
4267 let result = bash
4269 .exec("arr=(one two three); echo ${arr[@]}")
4270 .await
4271 .unwrap();
4272 assert_eq!(result.stdout, "one two three\n");
4273 }
4274
4275 #[tokio::test]
4276 async fn test_array_length() {
4277 let mut bash = Bash::new();
4278 let result = bash.exec("arr=(a b c d e); echo ${#arr[@]}").await.unwrap();
4280 assert_eq!(result.stdout, "5\n");
4281 }
4282
4283 #[tokio::test]
4284 async fn test_array_indexed_assignment() {
4285 let mut bash = Bash::new();
4286 let result = bash
4288 .exec("arr[0]=first; arr[1]=second; echo ${arr[0]} ${arr[1]}")
4289 .await
4290 .unwrap();
4291 assert_eq!(result.stdout, "first second\n");
4292 }
4293
4294 #[tokio::test]
4295 async fn test_array_single_quote_subscript_no_panic() {
4296 let mut bash = Bash::new();
4298 let _ = bash.exec("echo ${arr[\"]}").await;
4300 }
4301
4302 #[tokio::test]
4305 async fn test_command_limit() {
4306 let limits = ExecutionLimits::new().max_commands(5);
4307 let mut bash = Bash::builder().limits(limits).build();
4308
4309 let result = bash.exec("true; true; true; true; true; true").await;
4311 assert!(result.is_err());
4312 let err = result.unwrap_err();
4313 assert!(
4314 err.to_string().contains("maximum command count exceeded"),
4315 "Expected command limit error, got: {}",
4316 err
4317 );
4318 }
4319
4320 #[tokio::test]
4321 async fn test_command_limit_not_exceeded() {
4322 let limits = ExecutionLimits::new().max_commands(10);
4323 let mut bash = Bash::builder().limits(limits).build();
4324
4325 let result = bash.exec("true; true; true; true; true").await.unwrap();
4327 assert_eq!(result.exit_code, 0);
4328 }
4329
4330 #[tokio::test]
4331 async fn test_loop_iteration_limit() {
4332 let limits = ExecutionLimits::new().max_loop_iterations(5);
4333 let mut bash = Bash::builder().limits(limits).build();
4334
4335 let result = bash
4337 .exec("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done")
4338 .await;
4339 assert!(result.is_err());
4340 let err = result.unwrap_err();
4341 assert!(
4342 err.to_string().contains("maximum loop iterations exceeded"),
4343 "Expected loop limit error, got: {}",
4344 err
4345 );
4346 }
4347
4348 #[tokio::test]
4349 async fn test_loop_iteration_limit_not_exceeded() {
4350 let limits = ExecutionLimits::new().max_loop_iterations(10);
4351 let mut bash = Bash::builder().limits(limits).build();
4352
4353 let result = bash
4355 .exec("for i in 1 2 3 4 5; do echo $i; done")
4356 .await
4357 .unwrap();
4358 assert_eq!(result.stdout, "1\n2\n3\n4\n5\n");
4359 }
4360
4361 #[tokio::test]
4362 async fn test_function_depth_limit() {
4363 let limits = ExecutionLimits::new().max_function_depth(3);
4364 let mut bash = Bash::builder().limits(limits).build();
4365
4366 let result = bash
4368 .exec("f() { echo $1; if [ $1 -lt 5 ]; then f $(($1 + 1)); fi; }; f 1")
4369 .await;
4370 assert!(result.is_err());
4371 let err = result.unwrap_err();
4372 assert!(
4373 err.to_string().contains("maximum function depth exceeded"),
4374 "Expected function depth error, got: {}",
4375 err
4376 );
4377 }
4378
4379 #[tokio::test]
4380 async fn test_function_depth_limit_not_exceeded() {
4381 let limits = ExecutionLimits::new().max_function_depth(10);
4382 let mut bash = Bash::builder().limits(limits).build();
4383
4384 let result = bash.exec("f() { echo hello; }; f").await.unwrap();
4386 assert_eq!(result.stdout, "hello\n");
4387 }
4388
4389 #[tokio::test]
4390 async fn test_while_loop_limit() {
4391 let limits = ExecutionLimits::new().max_loop_iterations(3);
4392 let mut bash = Bash::builder().limits(limits).build();
4393
4394 let result = bash
4396 .exec("i=0; while [ $i -lt 10 ]; do echo $i; i=$((i + 1)); done")
4397 .await;
4398 assert!(result.is_err());
4399 let err = result.unwrap_err();
4400 assert!(
4401 err.to_string().contains("maximum loop iterations exceeded"),
4402 "Expected loop limit error, got: {}",
4403 err
4404 );
4405 }
4406
4407 #[tokio::test]
4408 async fn test_awk_respects_loop_iteration_limit() {
4409 let limits = ExecutionLimits::new().max_loop_iterations(5);
4410 let mut bash = Bash::builder().limits(limits).build();
4411 let result = bash
4412 .exec("awk 'BEGIN { i=0; while(1) { i++; if(i>999) break } print i }'")
4413 .await
4414 .unwrap();
4415 assert_eq!(result.stdout.trim(), "5");
4416 }
4417
4418 #[tokio::test]
4419 async fn test_awk_for_in_respects_loop_iteration_limit() {
4420 let limits = ExecutionLimits::new().max_loop_iterations(3);
4421 let mut bash = Bash::builder().limits(limits).build();
4422 let result = bash
4423 .exec("awk 'BEGIN { for(i=1;i<=10;i++) a[i]=i; c=0; for(k in a) c++; print c }'")
4424 .await
4425 .unwrap();
4426 assert_eq!(result.stdout.trim(), "3");
4427 }
4428
4429 #[tokio::test]
4430 async fn test_default_limits_allow_normal_scripts() {
4431 let mut bash = Bash::new();
4433 let result = bash
4435 .exec("for i in 1 2 3 4 5; do echo $i; done && echo finished")
4436 .await
4437 .unwrap();
4438 assert_eq!(result.stdout, "1\n2\n3\n4\n5\nfinished\n");
4439 }
4440
4441 #[tokio::test]
4442 async fn test_for_followed_by_echo_done() {
4443 let mut bash = Bash::new();
4444 let result = bash
4445 .exec("for i in 1; do echo $i; done; echo ok")
4446 .await
4447 .unwrap();
4448 assert_eq!(result.stdout, "1\nok\n");
4449 }
4450
4451 #[tokio::test]
4454 async fn test_fs_read_write_binary() {
4455 let bash = Bash::new();
4456 let fs = bash.fs();
4457 let path = std::path::Path::new("/tmp/binary.bin");
4458
4459 let binary_data: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE, 0x42, 0x00, 0x7F];
4461 fs.write_file(path, &binary_data).await.unwrap();
4462
4463 let content = fs.read_file(path).await.unwrap();
4465 assert_eq!(content, binary_data);
4466 }
4467
4468 #[tokio::test]
4469 async fn test_fs_write_then_exec_cat() {
4470 let mut bash = Bash::new();
4471 let path = std::path::Path::new("/tmp/prepopulated.txt");
4472
4473 bash.fs()
4475 .write_file(path, b"Hello from Rust!\n")
4476 .await
4477 .unwrap();
4478
4479 let result = bash.exec("cat /tmp/prepopulated.txt").await.unwrap();
4481 assert_eq!(result.stdout, "Hello from Rust!\n");
4482 }
4483
4484 #[tokio::test]
4485 async fn test_fs_exec_then_read() {
4486 let mut bash = Bash::new();
4487 let path = std::path::Path::new("/tmp/from_bash.txt");
4488
4489 bash.exec("echo 'Created by bash' > /tmp/from_bash.txt")
4491 .await
4492 .unwrap();
4493
4494 let content = bash.fs().read_file(path).await.unwrap();
4496 assert_eq!(content, b"Created by bash\n");
4497 }
4498
4499 #[tokio::test]
4500 async fn test_fs_exists_and_stat() {
4501 let bash = Bash::new();
4502 let fs = bash.fs();
4503 let path = std::path::Path::new("/tmp/testfile.txt");
4504
4505 assert!(!fs.exists(path).await.unwrap());
4507
4508 fs.write_file(path, b"content").await.unwrap();
4510
4511 assert!(fs.exists(path).await.unwrap());
4513
4514 let stat = fs.stat(path).await.unwrap();
4516 assert!(stat.file_type.is_file());
4517 assert_eq!(stat.size, 7); }
4519
4520 #[tokio::test]
4521 async fn test_fs_mkdir_and_read_dir() {
4522 let bash = Bash::new();
4523 let fs = bash.fs();
4524
4525 fs.mkdir(std::path::Path::new("/data/nested/dir"), true)
4527 .await
4528 .unwrap();
4529
4530 fs.write_file(std::path::Path::new("/data/file1.txt"), b"1")
4532 .await
4533 .unwrap();
4534 fs.write_file(std::path::Path::new("/data/file2.txt"), b"2")
4535 .await
4536 .unwrap();
4537
4538 let entries = fs.read_dir(std::path::Path::new("/data")).await.unwrap();
4540 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
4541 assert!(names.contains(&"nested"));
4542 assert!(names.contains(&"file1.txt"));
4543 assert!(names.contains(&"file2.txt"));
4544 }
4545
4546 #[tokio::test]
4547 async fn test_fs_append() {
4548 let bash = Bash::new();
4549 let fs = bash.fs();
4550 let path = std::path::Path::new("/tmp/append.txt");
4551
4552 fs.write_file(path, b"line1\n").await.unwrap();
4553 fs.append_file(path, b"line2\n").await.unwrap();
4554 fs.append_file(path, b"line3\n").await.unwrap();
4555
4556 let content = fs.read_file(path).await.unwrap();
4557 assert_eq!(content, b"line1\nline2\nline3\n");
4558 }
4559
4560 #[tokio::test]
4561 async fn test_fs_copy_and_rename() {
4562 let bash = Bash::new();
4563 let fs = bash.fs();
4564
4565 fs.write_file(std::path::Path::new("/tmp/original.txt"), b"data")
4566 .await
4567 .unwrap();
4568
4569 fs.copy(
4571 std::path::Path::new("/tmp/original.txt"),
4572 std::path::Path::new("/tmp/copied.txt"),
4573 )
4574 .await
4575 .unwrap();
4576
4577 fs.rename(
4579 std::path::Path::new("/tmp/copied.txt"),
4580 std::path::Path::new("/tmp/renamed.txt"),
4581 )
4582 .await
4583 .unwrap();
4584
4585 let content = fs
4587 .read_file(std::path::Path::new("/tmp/renamed.txt"))
4588 .await
4589 .unwrap();
4590 assert_eq!(content, b"data");
4591 assert!(
4592 !fs.exists(std::path::Path::new("/tmp/copied.txt"))
4593 .await
4594 .unwrap()
4595 );
4596 }
4597
4598 #[tokio::test]
4601 async fn test_echo_done_as_argument() {
4602 let mut bash = Bash::new();
4604 let result = bash
4605 .exec("for i in 1; do echo $i; done; echo done")
4606 .await
4607 .unwrap();
4608 assert_eq!(result.stdout, "1\ndone\n");
4609 }
4610
4611 #[tokio::test]
4612 async fn test_simple_echo_done() {
4613 let mut bash = Bash::new();
4615 let result = bash.exec("echo done").await.unwrap();
4616 assert_eq!(result.stdout, "done\n");
4617 }
4618
4619 #[tokio::test]
4620 async fn test_dev_null_redirect() {
4621 let mut bash = Bash::new();
4623 let result = bash.exec("echo hello > /dev/null; echo ok").await.unwrap();
4624 assert_eq!(result.stdout, "ok\n");
4625 }
4626
4627 #[tokio::test]
4628 async fn test_string_concatenation_in_loop() {
4629 let mut bash = Bash::new();
4631 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
4633 assert_eq!(result.stdout, "a\nb\nc\n");
4634
4635 let mut bash = Bash::new();
4637 let result = bash
4638 .exec("result=x; for i in a b c; do echo $i; done; echo $result")
4639 .await
4640 .unwrap();
4641 assert_eq!(result.stdout, "a\nb\nc\nx\n");
4642
4643 let mut bash = Bash::new();
4645 let result = bash
4646 .exec("result=start; for i in a b c; do result=${result}$i; done; echo $result")
4647 .await
4648 .unwrap();
4649 assert_eq!(result.stdout, "startabc\n");
4650 }
4651
4652 #[tokio::test]
4655 async fn test_done_still_terminates_loop() {
4656 let mut bash = Bash::new();
4658 let result = bash.exec("for i in 1 2; do echo $i; done").await.unwrap();
4659 assert_eq!(result.stdout, "1\n2\n");
4660 }
4661
4662 #[tokio::test]
4663 async fn test_fi_still_terminates_if() {
4664 let mut bash = Bash::new();
4666 let result = bash.exec("if true; then echo yes; fi").await.unwrap();
4667 assert_eq!(result.stdout, "yes\n");
4668 }
4669
4670 #[tokio::test]
4671 async fn test_echo_fi_as_argument() {
4672 let mut bash = Bash::new();
4674 let result = bash.exec("echo fi").await.unwrap();
4675 assert_eq!(result.stdout, "fi\n");
4676 }
4677
4678 #[tokio::test]
4679 async fn test_echo_then_as_argument() {
4680 let mut bash = Bash::new();
4682 let result = bash.exec("echo then").await.unwrap();
4683 assert_eq!(result.stdout, "then\n");
4684 }
4685
4686 #[tokio::test]
4687 async fn test_reserved_words_in_quotes_are_arguments() {
4688 let mut bash = Bash::new();
4690 let result = bash.exec("echo 'done' 'fi' 'then'").await.unwrap();
4691 assert_eq!(result.stdout, "done fi then\n");
4692 }
4693
4694 #[tokio::test]
4695 async fn test_nested_loops_done_keyword() {
4696 let mut bash = Bash::new();
4698 let result = bash
4699 .exec("for i in 1; do for j in a; do echo $i$j; done; done")
4700 .await
4701 .unwrap();
4702 assert_eq!(result.stdout, "1a\n");
4703 }
4704
4705 #[tokio::test]
4708 async fn test_dev_null_read_returns_empty() {
4709 let mut bash = Bash::new();
4711 let result = bash.exec("cat /dev/null").await.unwrap();
4712 assert_eq!(result.stdout, "");
4713 }
4714
4715 #[tokio::test]
4716 async fn test_dev_null_append() {
4717 let mut bash = Bash::new();
4719 let result = bash.exec("echo hello >> /dev/null; echo ok").await.unwrap();
4720 assert_eq!(result.stdout, "ok\n");
4721 }
4722
4723 #[tokio::test]
4724 async fn test_dev_null_in_pipeline() {
4725 let mut bash = Bash::new();
4727 let result = bash
4728 .exec("echo hello | cat > /dev/null; echo ok")
4729 .await
4730 .unwrap();
4731 assert_eq!(result.stdout, "ok\n");
4732 }
4733
4734 #[tokio::test]
4735 async fn test_dev_null_exists() {
4736 let mut bash = Bash::new();
4738 let result = bash.exec("cat /dev/null; echo exit_$?").await.unwrap();
4739 assert_eq!(result.stdout, "exit_0\n");
4740 }
4741
4742 #[tokio::test]
4745 async fn test_custom_username_whoami() {
4746 let mut bash = Bash::builder().username("alice").build();
4747 let result = bash.exec("whoami").await.unwrap();
4748 assert_eq!(result.stdout, "alice\n");
4749 }
4750
4751 #[tokio::test]
4752 async fn test_custom_username_id() {
4753 let mut bash = Bash::builder().username("bob").build();
4754 let result = bash.exec("id").await.unwrap();
4755 assert!(result.stdout.contains("uid=1000(bob)"));
4756 assert!(result.stdout.contains("gid=1000(bob)"));
4757 }
4758
4759 #[tokio::test]
4760 async fn test_custom_username_sets_user_env() {
4761 let mut bash = Bash::builder().username("charlie").build();
4762 let result = bash.exec("echo $USER").await.unwrap();
4763 assert_eq!(result.stdout, "charlie\n");
4764 }
4765
4766 #[tokio::test]
4767 async fn test_custom_username_provisions_home_dir() {
4768 let mut bash = Bash::builder().username("eval").build();
4773 let result = bash
4774 .exec("echo hi > /home/eval/x.sh && cat /home/eval/x.sh")
4775 .await
4776 .unwrap();
4777 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
4778 assert_eq!(result.stdout, "hi\n");
4779 }
4780
4781 #[tokio::test]
4782 async fn test_custom_username_home_tilde_write() {
4783 let mut bash = Bash::builder().username("agent").build();
4785 let result = bash
4786 .exec("echo $HOME; echo data > ~/file.txt && cat ~/file.txt")
4787 .await
4788 .unwrap();
4789 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
4790 assert_eq!(result.stdout, "/home/agent\ndata\n");
4791 }
4792
4793 #[tokio::test]
4794 async fn test_default_username_provisions_home_dir() {
4795 let mut bash = Bash::new();
4797 let result = bash
4798 .exec("echo data > $HOME/f && cat $HOME/f")
4799 .await
4800 .unwrap();
4801 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
4802 assert_eq!(result.stdout, "data\n");
4803 }
4804
4805 #[tokio::test]
4806 async fn test_default_ppid_is_sandboxed() {
4807 let mut bash = Bash::new();
4808 let result = bash.exec("echo $PPID").await.unwrap();
4809 assert_eq!(result.stdout, "0\n");
4810 }
4811
4812 #[tokio::test]
4813 async fn test_custom_hostname() {
4814 let mut bash = Bash::builder().hostname("my-server").build();
4815 let result = bash.exec("hostname").await.unwrap();
4816 assert_eq!(result.stdout, "my-server\n");
4817 }
4818
4819 #[tokio::test]
4820 async fn test_custom_hostname_uname() {
4821 let mut bash = Bash::builder().hostname("custom-host").build();
4822 let result = bash.exec("uname -n").await.unwrap();
4823 assert_eq!(result.stdout, "custom-host\n");
4824 }
4825
4826 #[tokio::test]
4827 async fn test_default_username_and_hostname() {
4828 let mut bash = Bash::new();
4830 let result = bash.exec("whoami").await.unwrap();
4831 assert_eq!(result.stdout, "sandbox\n");
4832
4833 let result = bash.exec("hostname").await.unwrap();
4834 assert_eq!(result.stdout, "bashkit-sandbox\n");
4835 }
4836
4837 #[tokio::test]
4838 async fn test_custom_username_and_hostname_combined() {
4839 let mut bash = Bash::builder()
4840 .username("deploy")
4841 .hostname("prod-server-01")
4842 .build();
4843
4844 let result = bash.exec("whoami && hostname").await.unwrap();
4845 assert_eq!(result.stdout, "deploy\nprod-server-01\n");
4846
4847 let result = bash.exec("echo $USER").await.unwrap();
4848 assert_eq!(result.stdout, "deploy\n");
4849 }
4850
4851 mod custom_builtins {
4854 use super::*;
4855 use crate::builtins::{Builtin, Context};
4856 use crate::{ExecResult, ExecutionExtensions, Extension};
4857 use async_trait::async_trait;
4858
4859 struct Hello;
4861
4862 #[async_trait]
4863 impl Builtin for Hello {
4864 async fn execute(&self, _ctx: Context<'_>) -> crate::Result<ExecResult> {
4865 Ok(ExecResult::ok("Hello from custom builtin!\n".to_string()))
4866 }
4867 }
4868
4869 #[tokio::test]
4870 async fn test_custom_builtin_basic() {
4871 let mut bash = Bash::builder().builtin("hello", Box::new(Hello)).build();
4872
4873 let result = bash.exec("hello").await.unwrap();
4874 assert_eq!(result.stdout, "Hello from custom builtin!\n");
4875 assert_eq!(result.exit_code, 0);
4876 }
4877
4878 struct ExecutionScoped;
4879
4880 #[async_trait]
4881 impl Builtin for ExecutionScoped {
4882 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4883 let value = ctx
4884 .execution_extension::<String>()
4885 .cloned()
4886 .unwrap_or_else(|| "missing".to_string());
4887 Ok(ExecResult::ok(format!("{value}\n")))
4888 }
4889 }
4890
4891 #[tokio::test]
4892 async fn test_custom_builtin_execution_extensions_are_per_call() {
4893 let mut bash = Bash::builder()
4894 .builtin("read-ext", Box::new(ExecutionScoped))
4895 .build();
4896
4897 let result = bash
4898 .exec_with_extensions(
4899 "read-ext",
4900 ExecutionExtensions::new().with("scoped".to_string()),
4901 )
4902 .await
4903 .unwrap();
4904 assert_eq!(result.stdout, "scoped\n");
4905
4906 let result = bash.exec("read-ext").await.unwrap();
4907 assert_eq!(result.stdout, "missing\n");
4908 }
4909
4910 struct Greet;
4912
4913 #[async_trait]
4914 impl Builtin for Greet {
4915 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4916 let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World");
4917 Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
4918 }
4919 }
4920
4921 #[tokio::test]
4922 async fn test_custom_builtin_with_args() {
4923 let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
4924
4925 let result = bash.exec("greet").await.unwrap();
4926 assert_eq!(result.stdout, "Hello, World!\n");
4927
4928 let result = bash.exec("greet Alice").await.unwrap();
4929 assert_eq!(result.stdout, "Hello, Alice!\n");
4930
4931 let result = bash.exec("greet Bob Charlie").await.unwrap();
4932 assert_eq!(result.stdout, "Hello, Bob!\n");
4933 }
4934
4935 struct Upper;
4937
4938 #[async_trait]
4939 impl Builtin for Upper {
4940 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4941 let input = ctx.stdin.unwrap_or("");
4942 Ok(ExecResult::ok(input.to_uppercase()))
4943 }
4944 }
4945
4946 #[tokio::test]
4947 async fn test_custom_builtin_with_stdin() {
4948 let mut bash = Bash::builder().builtin("upper", Box::new(Upper)).build();
4949
4950 let result = bash.exec("echo hello | upper").await.unwrap();
4951 assert_eq!(result.stdout, "HELLO\n");
4952 }
4953
4954 struct WriteFile;
4956
4957 #[async_trait]
4958 impl Builtin for WriteFile {
4959 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4960 if ctx.args.len() < 2 {
4961 return Ok(ExecResult::err(
4962 "Usage: writefile <path> <content>\n".to_string(),
4963 1,
4964 ));
4965 }
4966 let path = std::path::Path::new(&ctx.args[0]);
4967 let content = ctx.args[1..].join(" ");
4968 ctx.fs.write_file(path, content.as_bytes()).await?;
4969 Ok(ExecResult::ok(String::new()))
4970 }
4971 }
4972
4973 #[tokio::test]
4974 async fn test_custom_builtin_with_filesystem() {
4975 let mut bash = Bash::builder()
4976 .builtin("writefile", Box::new(WriteFile))
4977 .build();
4978
4979 bash.exec("writefile /tmp/test.txt custom content here")
4980 .await
4981 .unwrap();
4982
4983 let result = bash.exec("cat /tmp/test.txt").await.unwrap();
4984 assert_eq!(result.stdout, "custom content here");
4985 }
4986
4987 struct CustomEcho;
4989
4990 #[async_trait]
4991 impl Builtin for CustomEcho {
4992 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4993 let msg = ctx.args.join(" ");
4994 Ok(ExecResult::ok(format!("[CUSTOM] {}\n", msg)))
4995 }
4996 }
4997
4998 #[tokio::test]
4999 async fn test_custom_builtin_override_default() {
5000 let mut bash = Bash::builder()
5001 .builtin("echo", Box::new(CustomEcho))
5002 .build();
5003
5004 let result = bash.exec("echo hello world").await.unwrap();
5005 assert_eq!(result.stdout, "[CUSTOM] hello world\n");
5006 }
5007
5008 #[tokio::test]
5010 async fn test_multiple_custom_builtins() {
5011 let mut bash = Bash::builder()
5012 .builtin("hello", Box::new(Hello))
5013 .builtin("greet", Box::new(Greet))
5014 .builtin("upper", Box::new(Upper))
5015 .build();
5016
5017 let result = bash.exec("hello").await.unwrap();
5018 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5019
5020 let result = bash.exec("greet Test").await.unwrap();
5021 assert_eq!(result.stdout, "Hello, Test!\n");
5022
5023 let result = bash.exec("echo foo | upper").await.unwrap();
5024 assert_eq!(result.stdout, "FOO\n");
5025 }
5026
5027 struct GreetingExtension;
5028
5029 impl Extension for GreetingExtension {
5030 fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
5031 vec![
5032 ("hello-ext".to_string(), Box::new(Hello)),
5033 ("greet-ext".to_string(), Box::new(Greet)),
5034 ]
5035 }
5036 }
5037
5038 #[tokio::test]
5039 async fn test_extension_registers_multiple_builtins() {
5040 let mut bash = Bash::builder().extension(GreetingExtension).build();
5041
5042 let result = bash.exec("hello-ext").await.unwrap();
5043 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5044
5045 let result = bash.exec("greet-ext Extension").await.unwrap();
5046 assert_eq!(result.stdout, "Hello, Extension!\n");
5047 }
5048
5049 struct Counter {
5051 prefix: String,
5052 }
5053
5054 #[async_trait]
5055 impl Builtin for Counter {
5056 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5057 let count = ctx
5058 .args
5059 .first()
5060 .and_then(|s| s.parse::<i32>().ok())
5061 .unwrap_or(1);
5062 let mut output = String::new();
5063 for i in 1..=count {
5064 output.push_str(&format!("{}{}\n", self.prefix, i));
5065 }
5066 Ok(ExecResult::ok(output))
5067 }
5068 }
5069
5070 #[tokio::test]
5071 async fn test_custom_builtin_with_state() {
5072 let mut bash = Bash::builder()
5073 .builtin(
5074 "count",
5075 Box::new(Counter {
5076 prefix: "Item ".to_string(),
5077 }),
5078 )
5079 .build();
5080
5081 let result = bash.exec("count 3").await.unwrap();
5082 assert_eq!(result.stdout, "Item 1\nItem 2\nItem 3\n");
5083 }
5084
5085 struct Fail;
5087
5088 #[async_trait]
5089 impl Builtin for Fail {
5090 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5091 let code = ctx
5092 .args
5093 .first()
5094 .and_then(|s| s.parse::<i32>().ok())
5095 .unwrap_or(1);
5096 Ok(ExecResult::err(
5097 format!("Failed with code {}\n", code),
5098 code,
5099 ))
5100 }
5101 }
5102
5103 #[tokio::test]
5104 async fn test_custom_builtin_error() {
5105 let mut bash = Bash::builder().builtin("fail", Box::new(Fail)).build();
5106
5107 let result = bash.exec("fail 42").await.unwrap();
5108 assert_eq!(result.exit_code, 42);
5109 assert_eq!(result.stderr, "Failed with code 42\n");
5110 }
5111
5112 #[tokio::test]
5113 async fn test_custom_builtin_in_script() {
5114 let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
5115
5116 let script = r#"
5117 for name in Alice Bob Charlie; do
5118 greet $name
5119 done
5120 "#;
5121
5122 let result = bash.exec(script).await.unwrap();
5123 assert_eq!(
5124 result.stdout,
5125 "Hello, Alice!\nHello, Bob!\nHello, Charlie!\n"
5126 );
5127 }
5128
5129 #[tokio::test]
5130 async fn test_custom_builtin_with_conditionals() {
5131 let mut bash = Bash::builder()
5132 .builtin("fail", Box::new(Fail))
5133 .builtin("hello", Box::new(Hello))
5134 .build();
5135
5136 let result = bash.exec("fail 1 || hello").await.unwrap();
5137 assert_eq!(result.stdout, "Hello from custom builtin!\n");
5138 assert_eq!(result.exit_code, 0);
5139
5140 let result = bash.exec("hello && fail 5").await.unwrap();
5141 assert_eq!(result.exit_code, 5);
5142 }
5143
5144 struct EnvReader;
5146
5147 #[async_trait]
5148 impl Builtin for EnvReader {
5149 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5150 let var_name = ctx.args.first().map(|s| s.as_str()).unwrap_or("HOME");
5151 let value = ctx
5152 .env
5153 .get(var_name)
5154 .map(|s| s.as_str())
5155 .unwrap_or("(not set)");
5156 Ok(ExecResult::ok(format!("{}={}\n", var_name, value)))
5157 }
5158 }
5159
5160 #[tokio::test]
5161 async fn test_custom_builtin_reads_env() {
5162 let mut bash = Bash::builder()
5163 .env("MY_VAR", "my_value")
5164 .builtin("readenv", Box::new(EnvReader))
5165 .build();
5166
5167 let result = bash.exec("readenv MY_VAR").await.unwrap();
5168 assert_eq!(result.stdout, "MY_VAR=my_value\n");
5169
5170 let result = bash.exec("readenv UNKNOWN").await.unwrap();
5171 assert_eq!(result.stdout, "UNKNOWN=(not set)\n");
5172 }
5173 }
5174
5175 #[tokio::test]
5178 async fn test_parser_timeout_default() {
5179 let limits = ExecutionLimits::default();
5181 assert_eq!(limits.parser_timeout, std::time::Duration::from_secs(5));
5182 }
5183
5184 #[tokio::test]
5185 async fn test_parser_timeout_custom() {
5186 let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_millis(100));
5188 assert_eq!(limits.parser_timeout, std::time::Duration::from_millis(100));
5189 }
5190
5191 #[tokio::test]
5192 async fn test_parser_timeout_normal_script() {
5193 let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_secs(1));
5195 let mut bash = Bash::builder().limits(limits).build();
5196 let result = bash.exec("echo hello").await.unwrap();
5197 assert_eq!(result.stdout, "hello\n");
5198 }
5199
5200 #[tokio::test]
5203 async fn test_parser_fuel_default() {
5204 let limits = ExecutionLimits::default();
5206 assert_eq!(limits.max_parser_operations, 100_000);
5207 }
5208
5209 #[tokio::test]
5210 async fn test_parser_fuel_custom() {
5211 let limits = ExecutionLimits::new().max_parser_operations(1000);
5213 assert_eq!(limits.max_parser_operations, 1000);
5214 }
5215
5216 #[tokio::test]
5217 async fn test_parser_fuel_normal_script() {
5218 let limits = ExecutionLimits::new().max_parser_operations(1000);
5220 let mut bash = Bash::builder().limits(limits).build();
5221 let result = bash.exec("echo hello").await.unwrap();
5222 assert_eq!(result.stdout, "hello\n");
5223 }
5224
5225 #[tokio::test]
5228 async fn test_input_size_limit_default() {
5229 let limits = ExecutionLimits::default();
5231 assert_eq!(limits.max_input_bytes, 10_000_000);
5232 }
5233
5234 #[tokio::test]
5235 async fn test_input_size_limit_custom() {
5236 let limits = ExecutionLimits::new().max_input_bytes(1000);
5238 assert_eq!(limits.max_input_bytes, 1000);
5239 }
5240
5241 #[tokio::test]
5242 async fn test_input_size_limit_enforced() {
5243 let limits = ExecutionLimits::new().max_input_bytes(10);
5245 let mut bash = Bash::builder().limits(limits).build();
5246
5247 let result = bash.exec("echo hello world").await;
5249 assert!(result.is_err());
5250 let err = result.unwrap_err();
5251 assert!(
5252 err.to_string().contains("input too large"),
5253 "Expected input size error, got: {}",
5254 err
5255 );
5256 }
5257
5258 #[tokio::test]
5259 async fn test_input_size_limit_normal_script() {
5260 let limits = ExecutionLimits::new().max_input_bytes(1000);
5262 let mut bash = Bash::builder().limits(limits).build();
5263 let result = bash.exec("echo hello").await.unwrap();
5264 assert_eq!(result.stdout, "hello\n");
5265 }
5266
5267 #[tokio::test]
5270 async fn test_ast_depth_limit_default() {
5271 let limits = ExecutionLimits::default();
5273 assert_eq!(limits.max_ast_depth, 100);
5274 }
5275
5276 #[tokio::test]
5277 async fn test_ast_depth_limit_custom() {
5278 let limits = ExecutionLimits::new().max_ast_depth(10);
5280 assert_eq!(limits.max_ast_depth, 10);
5281 }
5282
5283 #[tokio::test]
5284 async fn test_ast_depth_limit_normal_script() {
5285 let limits = ExecutionLimits::new().max_ast_depth(10);
5287 let mut bash = Bash::builder().limits(limits).build();
5288 let result = bash.exec("if true; then echo ok; fi").await.unwrap();
5289 assert_eq!(result.stdout, "ok\n");
5290 }
5291
5292 #[tokio::test]
5293 async fn test_ast_depth_limit_enforced() {
5294 let limits = ExecutionLimits::new().max_ast_depth(2);
5296 let mut bash = Bash::builder().limits(limits).build();
5297
5298 let result = bash
5300 .exec("if true; then if true; then if true; then echo nested; fi; fi; fi")
5301 .await;
5302 assert!(result.is_err());
5303 let err = result.unwrap_err();
5304 assert!(
5305 err.to_string().contains("AST nesting too deep"),
5306 "Expected AST depth error, got: {}",
5307 err
5308 );
5309 }
5310
5311 #[tokio::test]
5312 async fn test_parser_fuel_enforced() {
5313 let limits = ExecutionLimits::new().max_parser_operations(3);
5316 let mut bash = Bash::builder().limits(limits).build();
5317
5318 let result = bash.exec("echo a; echo b; echo c").await;
5320 assert!(result.is_err());
5321 let err = result.unwrap_err();
5322 assert!(
5323 err.to_string().contains("parser fuel exhausted"),
5324 "Expected parser fuel error, got: {}",
5325 err
5326 );
5327 }
5328
5329 #[tokio::test]
5332 async fn test_set_e_basic() {
5333 let mut bash = Bash::new();
5335 let result = bash
5336 .exec("set -e; true; false; echo should_not_reach")
5337 .await
5338 .unwrap();
5339 assert_eq!(result.stdout, "");
5340 assert_eq!(result.exit_code, 1);
5341 }
5342
5343 #[tokio::test]
5344 async fn test_set_e_after_failing_cmd() {
5345 let mut bash = Bash::new();
5347 let result = bash
5348 .exec("set -e; echo before; false; echo after")
5349 .await
5350 .unwrap();
5351 assert_eq!(result.stdout, "before\n");
5352 assert_eq!(result.exit_code, 1);
5353 }
5354
5355 #[tokio::test]
5356 async fn test_set_e_disabled() {
5357 let mut bash = Bash::new();
5359 let result = bash
5360 .exec("set -e; set +e; false; echo still_running")
5361 .await
5362 .unwrap();
5363 assert_eq!(result.stdout, "still_running\n");
5364 }
5365
5366 #[tokio::test]
5367 async fn test_set_e_in_pipeline_last() {
5368 let mut bash = Bash::new();
5370 let result = bash
5371 .exec("set -e; false | true; echo reached")
5372 .await
5373 .unwrap();
5374 assert_eq!(result.stdout, "reached\n");
5375 }
5376
5377 #[tokio::test]
5378 async fn test_set_e_in_if_condition() {
5379 let mut bash = Bash::new();
5381 let result = bash
5382 .exec("set -e; if false; then echo yes; else echo no; fi; echo done")
5383 .await
5384 .unwrap();
5385 assert_eq!(result.stdout, "no\ndone\n");
5386 }
5387
5388 #[tokio::test]
5389 async fn test_set_e_in_while_condition() {
5390 let mut bash = Bash::new();
5392 let result = bash
5393 .exec("set -e; x=0; while [ \"$x\" -lt 2 ]; do echo \"x=$x\"; x=$((x + 1)); done; echo done")
5394 .await
5395 .unwrap();
5396 assert_eq!(result.stdout, "x=0\nx=1\ndone\n");
5397 }
5398
5399 #[tokio::test]
5400 async fn test_set_e_in_brace_group() {
5401 let mut bash = Bash::new();
5403 let result = bash
5404 .exec("set -e; { echo start; false; echo unreached; }; echo after")
5405 .await
5406 .unwrap();
5407 assert_eq!(result.stdout, "start\n");
5408 assert_eq!(result.exit_code, 1);
5409 }
5410
5411 #[tokio::test]
5412 async fn test_set_e_and_chain() {
5413 let mut bash = Bash::new();
5415 let result = bash
5416 .exec("set -e; false && echo one; echo reached")
5417 .await
5418 .unwrap();
5419 assert_eq!(result.stdout, "reached\n");
5420 }
5421
5422 #[tokio::test]
5423 async fn test_set_e_or_chain() {
5424 let mut bash = Bash::new();
5426 let result = bash
5427 .exec("set -e; true || false; echo reached")
5428 .await
5429 .unwrap();
5430 assert_eq!(result.stdout, "reached\n");
5431 }
5432
5433 #[tokio::test]
5436 async fn test_tilde_expansion_basic() {
5437 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
5439 let result = bash.exec("echo ~").await.unwrap();
5440 assert_eq!(result.stdout, "/home/testuser\n");
5441 }
5442
5443 #[tokio::test]
5444 async fn test_tilde_expansion_with_path() {
5445 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
5447 let result = bash.exec("echo ~/documents/file.txt").await.unwrap();
5448 assert_eq!(result.stdout, "/home/testuser/documents/file.txt\n");
5449 }
5450
5451 #[tokio::test]
5452 async fn test_tilde_expansion_in_assignment() {
5453 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
5455 let result = bash.exec("DIR=~/data; echo $DIR").await.unwrap();
5456 assert_eq!(result.stdout, "/home/testuser/data\n");
5457 }
5458
5459 #[tokio::test]
5460 async fn test_tilde_expansion_default_home() {
5461 let mut bash = Bash::new();
5463 let result = bash.exec("echo ~").await.unwrap();
5464 assert_eq!(result.stdout, "/home/sandbox\n");
5465 }
5466
5467 #[tokio::test]
5468 async fn test_tilde_not_at_start() {
5469 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
5471 let result = bash.exec("echo foo~bar").await.unwrap();
5472 assert_eq!(result.stdout, "foo~bar\n");
5473 }
5474
5475 #[tokio::test]
5478 async fn test_special_var_dollar_dollar() {
5479 let mut bash = Bash::new();
5481 let result = bash.exec("echo $$").await.unwrap();
5482 let pid: u32 = result.stdout.trim().parse().expect("$$ should be a number");
5484 assert!(pid > 0, "$$ should be a positive number");
5485 }
5486
5487 #[tokio::test]
5488 async fn test_special_var_random() {
5489 let mut bash = Bash::new();
5491 let result = bash.exec("echo $RANDOM").await.unwrap();
5492 let random: u32 = result
5493 .stdout
5494 .trim()
5495 .parse()
5496 .expect("$RANDOM should be a number");
5497 assert!(random < 32768, "$RANDOM should be < 32768");
5498 }
5499
5500 #[tokio::test]
5501 async fn test_special_var_random_varies() {
5502 let mut bash = Bash::new();
5504 let result1 = bash.exec("echo $RANDOM").await.unwrap();
5505 let result2 = bash.exec("echo $RANDOM").await.unwrap();
5506 let _: u32 = result1
5510 .stdout
5511 .trim()
5512 .parse()
5513 .expect("$RANDOM should be a number");
5514 let _: u32 = result2
5515 .stdout
5516 .trim()
5517 .parse()
5518 .expect("$RANDOM should be a number");
5519 }
5520
5521 #[tokio::test]
5522 async fn test_random_different_instances() {
5523 let mut bash1 = Bash::new();
5526 let mut bash2 = Bash::new();
5527 let r1 = bash1.exec("echo $RANDOM").await.unwrap();
5528 let r2 = bash2.exec("echo $RANDOM").await.unwrap();
5529 let v1: u32 = r1.stdout.trim().parse().expect("should be a number");
5530 let v2: u32 = r2.stdout.trim().parse().expect("should be a number");
5531 assert!(v1 < 32768);
5532 assert!(v2 < 32768);
5533 assert_ne!(v1, v2, "separate instances should produce different values");
5535 }
5536
5537 #[tokio::test]
5538 async fn test_random_reseed() {
5539 let mut bash1 = Bash::new();
5541 let mut bash2 = Bash::new();
5542 bash1.exec("RANDOM=42").await.unwrap();
5543 bash2.exec("RANDOM=42").await.unwrap();
5544 let r1 = bash1.exec("echo $RANDOM").await.unwrap();
5545 let r2 = bash2.exec("echo $RANDOM").await.unwrap();
5546 assert_eq!(
5547 r1.stdout, r2.stdout,
5548 "same seed should produce same first value"
5549 );
5550 }
5551
5552 #[tokio::test]
5553 async fn test_random_sequential_varies() {
5554 let mut bash = Bash::new();
5556 let result = bash.exec("echo $RANDOM $RANDOM $RANDOM").await.unwrap();
5557 let values: Vec<u32> = result
5558 .stdout
5559 .split_whitespace()
5560 .map(|s| s.parse().expect("should be a number"))
5561 .collect();
5562 assert_eq!(values.len(), 3);
5563 assert!(
5565 values[0] != values[1] || values[1] != values[2],
5566 "sequential RANDOM calls should produce different values"
5567 );
5568 }
5569
5570 #[tokio::test]
5571 async fn test_special_var_lineno() {
5572 let mut bash = Bash::new();
5574 let result = bash.exec("echo $LINENO").await.unwrap();
5575 assert_eq!(result.stdout, "1\n");
5576 }
5577
5578 #[tokio::test]
5579 async fn test_lineno_multiline() {
5580 let mut bash = Bash::new();
5582 let result = bash
5583 .exec(
5584 r#"echo "line $LINENO"
5585echo "line $LINENO"
5586echo "line $LINENO""#,
5587 )
5588 .await
5589 .unwrap();
5590 assert_eq!(result.stdout, "line 1\nline 2\nline 3\n");
5591 }
5592
5593 #[tokio::test]
5594 async fn test_lineno_in_loop() {
5595 let mut bash = Bash::new();
5597 let result = bash
5598 .exec(
5599 r#"for i in 1 2; do
5600 echo "loop $LINENO"
5601done"#,
5602 )
5603 .await
5604 .unwrap();
5605 assert_eq!(result.stdout, "loop 2\nloop 2\n");
5607 }
5608
5609 #[tokio::test]
5612 async fn test_file_test_r_readable() {
5613 let mut bash = Bash::new();
5615 bash.exec("echo hello > /tmp/readable.txt").await.unwrap();
5616 let result = bash
5617 .exec("test -r /tmp/readable.txt && echo yes")
5618 .await
5619 .unwrap();
5620 assert_eq!(result.stdout, "yes\n");
5621 }
5622
5623 #[tokio::test]
5624 async fn test_file_test_r_not_exists() {
5625 let mut bash = Bash::new();
5627 let result = bash
5628 .exec("test -r /tmp/nonexistent.txt && echo yes || echo no")
5629 .await
5630 .unwrap();
5631 assert_eq!(result.stdout, "no\n");
5632 }
5633
5634 #[tokio::test]
5635 async fn test_file_test_w_writable() {
5636 let mut bash = Bash::new();
5638 bash.exec("echo hello > /tmp/writable.txt").await.unwrap();
5639 let result = bash
5640 .exec("test -w /tmp/writable.txt && echo yes")
5641 .await
5642 .unwrap();
5643 assert_eq!(result.stdout, "yes\n");
5644 }
5645
5646 #[tokio::test]
5647 async fn test_file_test_x_executable() {
5648 let mut bash = Bash::new();
5650 bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
5651 .await
5652 .unwrap();
5653 bash.exec("chmod 755 /tmp/script.sh").await.unwrap();
5654 let result = bash
5655 .exec("test -x /tmp/script.sh && echo yes")
5656 .await
5657 .unwrap();
5658 assert_eq!(result.stdout, "yes\n");
5659 }
5660
5661 #[tokio::test]
5662 async fn test_file_test_x_not_executable() {
5663 let mut bash = Bash::new();
5665 bash.exec("echo 'data' > /tmp/noexec.txt").await.unwrap();
5666 bash.exec("chmod 644 /tmp/noexec.txt").await.unwrap();
5667 let result = bash
5668 .exec("test -x /tmp/noexec.txt && echo yes || echo no")
5669 .await
5670 .unwrap();
5671 assert_eq!(result.stdout, "no\n");
5672 }
5673
5674 #[tokio::test]
5675 async fn test_file_test_e_exists() {
5676 let mut bash = Bash::new();
5678 bash.exec("echo hello > /tmp/exists.txt").await.unwrap();
5679 let result = bash
5680 .exec("test -e /tmp/exists.txt && echo yes")
5681 .await
5682 .unwrap();
5683 assert_eq!(result.stdout, "yes\n");
5684 }
5685
5686 #[tokio::test]
5687 async fn test_file_test_f_regular() {
5688 let mut bash = Bash::new();
5690 bash.exec("echo hello > /tmp/regular.txt").await.unwrap();
5691 let result = bash
5692 .exec("test -f /tmp/regular.txt && echo yes")
5693 .await
5694 .unwrap();
5695 assert_eq!(result.stdout, "yes\n");
5696 }
5697
5698 #[tokio::test]
5699 async fn test_file_test_d_directory() {
5700 let mut bash = Bash::new();
5702 bash.exec("mkdir -p /tmp/mydir").await.unwrap();
5703 let result = bash.exec("test -d /tmp/mydir && echo yes").await.unwrap();
5704 assert_eq!(result.stdout, "yes\n");
5705 }
5706
5707 #[tokio::test]
5708 async fn test_file_test_s_size() {
5709 let mut bash = Bash::new();
5711 bash.exec("echo hello > /tmp/nonempty.txt").await.unwrap();
5712 let result = bash
5713 .exec("test -s /tmp/nonempty.txt && echo yes")
5714 .await
5715 .unwrap();
5716 assert_eq!(result.stdout, "yes\n");
5717 }
5718
5719 #[tokio::test]
5724 async fn test_redirect_both_stdout_stderr() {
5725 let mut bash = Bash::new();
5727 let result = bash.exec("echo hello &> /tmp/out.txt").await.unwrap();
5729 assert_eq!(result.stdout, "");
5731 let check = bash.exec("cat /tmp/out.txt").await.unwrap();
5733 assert_eq!(check.stdout, "hello\n");
5734 }
5735
5736 #[tokio::test]
5737 async fn test_stderr_redirect_to_file() {
5738 let mut bash = Bash::new();
5742 bash.exec("echo stdout; echo stderr 2> /tmp/err.txt")
5744 .await
5745 .unwrap();
5746 }
5749
5750 #[tokio::test]
5751 async fn test_fd_redirect_parsing() {
5752 let mut bash = Bash::new();
5754 let result = bash.exec("true 2> /tmp/err.txt").await.unwrap();
5756 assert_eq!(result.exit_code, 0);
5757 }
5758
5759 #[tokio::test]
5760 async fn test_fd_redirect_append_parsing() {
5761 let mut bash = Bash::new();
5763 let result = bash.exec("true 2>> /tmp/err.txt").await.unwrap();
5764 assert_eq!(result.exit_code, 0);
5765 }
5766
5767 #[tokio::test]
5768 async fn test_fd_dup_parsing() {
5769 let mut bash = Bash::new();
5771 let result = bash.exec("echo hello 2>&1").await.unwrap();
5772 assert_eq!(result.stdout, "hello\n");
5773 assert_eq!(result.exit_code, 0);
5774 }
5775
5776 #[tokio::test]
5777 async fn test_dup_output_redirect_stdout_to_stderr() {
5778 let mut bash = Bash::new();
5780 let result = bash.exec("echo hello >&2").await.unwrap();
5781 assert_eq!(result.stdout, "");
5783 assert_eq!(result.stderr, "hello\n");
5784 }
5785
5786 #[tokio::test]
5787 async fn test_lexer_redirect_both() {
5788 let mut bash = Bash::new();
5790 let result = bash.exec("echo test &> /tmp/both.txt").await.unwrap();
5792 assert_eq!(result.stdout, "");
5793 let check = bash.exec("cat /tmp/both.txt").await.unwrap();
5794 assert_eq!(check.stdout, "test\n");
5795 }
5796
5797 #[tokio::test]
5798 async fn test_lexer_dup_output() {
5799 let mut bash = Bash::new();
5801 let result = bash.exec("echo test >&2").await.unwrap();
5802 assert_eq!(result.stdout, "");
5803 assert_eq!(result.stderr, "test\n");
5804 }
5805
5806 #[tokio::test]
5807 async fn test_digit_before_redirect() {
5808 let mut bash = Bash::new();
5810 let result = bash.exec("echo hello 2> /tmp/err.txt").await.unwrap();
5812 assert_eq!(result.exit_code, 0);
5813 assert_eq!(result.stdout, "hello\n");
5815 }
5816
5817 #[tokio::test]
5822 async fn test_arithmetic_logical_and_true() {
5823 let mut bash = Bash::new();
5825 let result = bash.exec("echo $((1 && 1))").await.unwrap();
5826 assert_eq!(result.stdout, "1\n");
5827 }
5828
5829 #[tokio::test]
5830 async fn test_arithmetic_logical_and_false_left() {
5831 let mut bash = Bash::new();
5833 let result = bash.exec("echo $((0 && 1))").await.unwrap();
5834 assert_eq!(result.stdout, "0\n");
5835 }
5836
5837 #[tokio::test]
5838 async fn test_arithmetic_logical_and_false_right() {
5839 let mut bash = Bash::new();
5841 let result = bash.exec("echo $((1 && 0))").await.unwrap();
5842 assert_eq!(result.stdout, "0\n");
5843 }
5844
5845 #[tokio::test]
5846 async fn test_arithmetic_logical_or_false() {
5847 let mut bash = Bash::new();
5849 let result = bash.exec("echo $((0 || 0))").await.unwrap();
5850 assert_eq!(result.stdout, "0\n");
5851 }
5852
5853 #[tokio::test]
5854 async fn test_arithmetic_logical_or_true_left() {
5855 let mut bash = Bash::new();
5857 let result = bash.exec("echo $((1 || 0))").await.unwrap();
5858 assert_eq!(result.stdout, "1\n");
5859 }
5860
5861 #[tokio::test]
5862 async fn test_arithmetic_logical_or_true_right() {
5863 let mut bash = Bash::new();
5865 let result = bash.exec("echo $((0 || 1))").await.unwrap();
5866 assert_eq!(result.stdout, "1\n");
5867 }
5868
5869 #[tokio::test]
5870 async fn test_arithmetic_logical_combined() {
5871 let mut bash = Bash::new();
5873 let result = bash.exec("echo $((5 > 3 && 2 < 4))").await.unwrap();
5875 assert_eq!(result.stdout, "1\n");
5876 }
5877
5878 #[tokio::test]
5879 async fn test_arithmetic_logical_with_comparison() {
5880 let mut bash = Bash::new();
5882 let result = bash.exec("echo $((5 < 3 || 2 < 4))").await.unwrap();
5884 assert_eq!(result.stdout, "1\n");
5885 }
5886
5887 #[tokio::test]
5888 async fn test_arithmetic_multibyte_no_panic() {
5889 let mut bash = Bash::new();
5891 let result = bash.exec("echo $((0,1))").await.unwrap();
5893 assert_eq!(result.stdout, "1\n");
5894 let _ = bash.exec("echo $((\u{00e9}+1))").await;
5896 }
5897
5898 #[tokio::test]
5903 async fn test_brace_expansion_list() {
5904 let mut bash = Bash::new();
5906 let result = bash.exec("echo {a,b,c}").await.unwrap();
5907 assert_eq!(result.stdout, "a b c\n");
5908 }
5909
5910 #[tokio::test]
5911 async fn test_brace_expansion_with_prefix() {
5912 let mut bash = Bash::new();
5914 let result = bash.exec("echo file{1,2,3}.txt").await.unwrap();
5915 assert_eq!(result.stdout, "file1.txt file2.txt file3.txt\n");
5916 }
5917
5918 #[tokio::test]
5919 async fn test_brace_expansion_numeric_range() {
5920 let mut bash = Bash::new();
5922 let result = bash.exec("echo {1..5}").await.unwrap();
5923 assert_eq!(result.stdout, "1 2 3 4 5\n");
5924 }
5925
5926 #[tokio::test]
5927 async fn test_brace_expansion_char_range() {
5928 let mut bash = Bash::new();
5930 let result = bash.exec("echo {a..e}").await.unwrap();
5931 assert_eq!(result.stdout, "a b c d e\n");
5932 }
5933
5934 #[tokio::test]
5935 async fn test_brace_expansion_reverse_range() {
5936 let mut bash = Bash::new();
5938 let result = bash.exec("echo {5..1}").await.unwrap();
5939 assert_eq!(result.stdout, "5 4 3 2 1\n");
5940 }
5941
5942 #[tokio::test]
5943 async fn test_brace_expansion_nested() {
5944 let mut bash = Bash::new();
5946 let result = bash.exec("echo {a,b}{1,2}").await.unwrap();
5947 assert_eq!(result.stdout, "a1 a2 b1 b2\n");
5948 }
5949
5950 #[tokio::test]
5951 async fn test_brace_expansion_with_suffix() {
5952 let mut bash = Bash::new();
5954 let result = bash.exec("echo pre{x,y}suf").await.unwrap();
5955 assert_eq!(result.stdout, "prexsuf preysuf\n");
5956 }
5957
5958 #[tokio::test]
5959 async fn test_brace_expansion_empty_item() {
5960 let mut bash = Bash::new();
5962 let result = bash.exec("echo x{,y}z").await.unwrap();
5963 assert_eq!(result.stdout, "xz xyz\n");
5964 }
5965
5966 #[tokio::test]
5971 async fn test_string_less_than() {
5972 let mut bash = Bash::new();
5973 let result = bash
5974 .exec("test apple '<' banana && echo yes")
5975 .await
5976 .unwrap();
5977 assert_eq!(result.stdout, "yes\n");
5978 }
5979
5980 #[tokio::test]
5981 async fn test_string_greater_than() {
5982 let mut bash = Bash::new();
5983 let result = bash
5984 .exec("test banana '>' apple && echo yes")
5985 .await
5986 .unwrap();
5987 assert_eq!(result.stdout, "yes\n");
5988 }
5989
5990 #[tokio::test]
5991 async fn test_string_less_than_false() {
5992 let mut bash = Bash::new();
5993 let result = bash
5994 .exec("test banana '<' apple && echo yes || echo no")
5995 .await
5996 .unwrap();
5997 assert_eq!(result.stdout, "no\n");
5998 }
5999
6000 #[tokio::test]
6005 async fn test_array_indices_basic() {
6006 let mut bash = Bash::new();
6008 let result = bash.exec("arr=(a b c); echo ${!arr[@]}").await.unwrap();
6009 assert_eq!(result.stdout, "0 1 2\n");
6010 }
6011
6012 #[tokio::test]
6013 async fn test_array_indices_sparse() {
6014 let mut bash = Bash::new();
6016 let result = bash
6017 .exec("arr[0]=a; arr[5]=b; arr[10]=c; echo ${!arr[@]}")
6018 .await
6019 .unwrap();
6020 assert_eq!(result.stdout, "0 5 10\n");
6021 }
6022
6023 #[tokio::test]
6024 async fn test_array_indices_star() {
6025 let mut bash = Bash::new();
6027 let result = bash.exec("arr=(x y z); echo ${!arr[*]}").await.unwrap();
6028 assert_eq!(result.stdout, "0 1 2\n");
6029 }
6030
6031 #[tokio::test]
6032 async fn test_array_indices_empty() {
6033 let mut bash = Bash::new();
6035 let result = bash.exec("arr=(); echo \"${!arr[@]}\"").await.unwrap();
6036 assert_eq!(result.stdout, "\n");
6037 }
6038
6039 #[tokio::test]
6044 async fn test_text_file_basic() {
6045 let mut bash = Bash::builder()
6046 .mount_text("/config/app.conf", "debug=true\nport=8080\n")
6047 .build();
6048
6049 let result = bash.exec("cat /config/app.conf").await.unwrap();
6050 assert_eq!(result.stdout, "debug=true\nport=8080\n");
6051 }
6052
6053 #[tokio::test]
6054 async fn test_text_file_multiple() {
6055 let mut bash = Bash::builder()
6056 .mount_text("/data/file1.txt", "content one")
6057 .mount_text("/data/file2.txt", "content two")
6058 .mount_text("/other/file3.txt", "content three")
6059 .build();
6060
6061 let result = bash.exec("cat /data/file1.txt").await.unwrap();
6062 assert_eq!(result.stdout, "content one");
6063
6064 let result = bash.exec("cat /data/file2.txt").await.unwrap();
6065 assert_eq!(result.stdout, "content two");
6066
6067 let result = bash.exec("cat /other/file3.txt").await.unwrap();
6068 assert_eq!(result.stdout, "content three");
6069 }
6070
6071 #[tokio::test]
6072 async fn test_text_file_nested_directory() {
6073 let mut bash = Bash::builder()
6075 .mount_text("/a/b/c/d/file.txt", "nested content")
6076 .build();
6077
6078 let result = bash.exec("cat /a/b/c/d/file.txt").await.unwrap();
6079 assert_eq!(result.stdout, "nested content");
6080 }
6081
6082 #[tokio::test]
6083 async fn test_text_file_mode() {
6084 let bash = Bash::builder()
6085 .mount_text("/tmp/writable.txt", "content")
6086 .build();
6087
6088 let stat = bash
6089 .fs()
6090 .stat(std::path::Path::new("/tmp/writable.txt"))
6091 .await
6092 .unwrap();
6093 assert_eq!(stat.mode, 0o644);
6094 }
6095
6096 #[tokio::test]
6097 async fn test_readonly_text_basic() {
6098 let mut bash = Bash::builder()
6099 .mount_readonly_text("/etc/version", "1.2.3")
6100 .build();
6101
6102 let result = bash.exec("cat /etc/version").await.unwrap();
6103 assert_eq!(result.stdout, "1.2.3");
6104 }
6105
6106 #[tokio::test]
6107 async fn test_readonly_text_mode() {
6108 let bash = Bash::builder()
6109 .mount_readonly_text("/etc/readonly.conf", "immutable")
6110 .build();
6111
6112 let stat = bash
6113 .fs()
6114 .stat(std::path::Path::new("/etc/readonly.conf"))
6115 .await
6116 .unwrap();
6117 assert_eq!(stat.mode, 0o444);
6118 }
6119
6120 #[tokio::test]
6121 async fn test_text_file_mixed_readonly_writable() {
6122 let bash = Bash::builder()
6123 .mount_text("/data/writable.txt", "can edit")
6124 .mount_readonly_text("/data/readonly.txt", "cannot edit")
6125 .build();
6126
6127 let writable_stat = bash
6128 .fs()
6129 .stat(std::path::Path::new("/data/writable.txt"))
6130 .await
6131 .unwrap();
6132 let readonly_stat = bash
6133 .fs()
6134 .stat(std::path::Path::new("/data/readonly.txt"))
6135 .await
6136 .unwrap();
6137
6138 assert_eq!(writable_stat.mode, 0o644);
6139 assert_eq!(readonly_stat.mode, 0o444);
6140 }
6141
6142 #[tokio::test]
6143 async fn test_text_file_with_env() {
6144 let mut bash = Bash::builder()
6146 .env("APP_NAME", "testapp")
6147 .mount_text("/config/app.conf", "name=${APP_NAME}")
6148 .build();
6149
6150 let result = bash.exec("echo $APP_NAME").await.unwrap();
6151 assert_eq!(result.stdout, "testapp\n");
6152
6153 let result = bash.exec("cat /config/app.conf").await.unwrap();
6154 assert_eq!(result.stdout, "name=${APP_NAME}");
6155 }
6156
6157 #[tokio::test]
6158 #[cfg(feature = "jq")]
6159 async fn test_text_file_json() {
6160 let mut bash = Bash::builder()
6161 .mount_text("/data/users.json", r#"["alice", "bob", "charlie"]"#)
6162 .build();
6163
6164 let result = bash.exec("cat /data/users.json | jq '.[0]'").await.unwrap();
6165 assert_eq!(result.stdout, "\"alice\"\n");
6166 }
6167
6168 #[tokio::test]
6169 async fn test_mount_with_custom_filesystem() {
6170 let custom_fs = std::sync::Arc::new(InMemoryFs::new());
6172
6173 custom_fs
6175 .write_file(std::path::Path::new("/base.txt"), b"from base")
6176 .await
6177 .unwrap();
6178
6179 let mut bash = Bash::builder()
6180 .fs(custom_fs)
6181 .mount_text("/mounted.txt", "from mount")
6182 .mount_readonly_text("/readonly.txt", "immutable")
6183 .build();
6184
6185 let result = bash.exec("cat /base.txt").await.unwrap();
6187 assert_eq!(result.stdout, "from base");
6188
6189 let result = bash.exec("cat /mounted.txt").await.unwrap();
6191 assert_eq!(result.stdout, "from mount");
6192
6193 let result = bash.exec("cat /readonly.txt").await.unwrap();
6194 assert_eq!(result.stdout, "immutable");
6195
6196 let stat = bash
6198 .fs()
6199 .stat(std::path::Path::new("/readonly.txt"))
6200 .await
6201 .unwrap();
6202 assert_eq!(stat.mode, 0o444);
6203 }
6204
6205 #[tokio::test]
6206 async fn test_mount_overwrites_base_file() {
6207 let custom_fs = std::sync::Arc::new(InMemoryFs::new());
6209 custom_fs
6210 .write_file(std::path::Path::new("/config.txt"), b"original")
6211 .await
6212 .unwrap();
6213
6214 let mut bash = Bash::builder()
6215 .fs(custom_fs)
6216 .mount_text("/config.txt", "overwritten")
6217 .build();
6218
6219 let result = bash.exec("cat /config.txt").await.unwrap();
6220 assert_eq!(result.stdout, "overwritten");
6221 }
6222
6223 #[tokio::test]
6224 async fn test_mount_preserves_custom_fs_limits() {
6225 let limited_fs =
6226 std::sync::Arc::new(InMemoryFs::with_limits(FsLimits::new().max_total_bytes(32)));
6227
6228 let bash = Bash::builder()
6229 .fs(limited_fs)
6230 .mount_text("/mounted.txt", "seed")
6231 .build();
6232
6233 let write_err = bash
6234 .fs()
6235 .write_file(
6236 std::path::Path::new("/too-big.txt"),
6237 b"this payload should exceed thirty-two bytes",
6238 )
6239 .await;
6240 assert!(write_err.is_err(), "custom fs limits should still apply");
6241 }
6242
6243 #[tokio::test]
6244 async fn test_mount_text_respects_filesystem_limits() {
6245 let limited_fs = std::sync::Arc::new(InMemoryFs::with_limits(
6246 FsLimits::new().max_total_bytes(5).max_file_size(5),
6247 ));
6248
6249 let bash = Bash::builder()
6250 .fs(limited_fs)
6251 .mount_text("/too-large.txt", "123456")
6252 .build();
6253
6254 let exists = bash
6255 .fs()
6256 .exists(std::path::Path::new("/too-large.txt"))
6257 .await
6258 .unwrap();
6259 assert!(!exists, "mount_text should not bypass configured FsLimits");
6260 }
6261
6262 #[tokio::test]
6267 async fn test_parse_error_includes_line_number() {
6268 let mut bash = Bash::new();
6270 let result = bash
6271 .exec(
6272 r#"echo ok
6273if true; then
6274echo missing fi"#,
6275 )
6276 .await;
6277 assert!(result.is_err());
6279 let err = result.unwrap_err();
6280 let err_msg = format!("{}", err);
6281 assert!(
6283 err_msg.contains("line") || err_msg.contains("parse"),
6284 "Error should be a parse error: {}",
6285 err_msg
6286 );
6287 }
6288
6289 #[tokio::test]
6290 async fn test_parse_error_on_specific_line() {
6291 use crate::parser::Parser;
6293 let script = "echo line1\necho line2\nif true; then\n";
6294 let result = Parser::new(script).parse();
6295 assert!(result.is_err());
6296 let err = result.unwrap_err();
6297 let err_msg = format!("{}", err);
6298 assert!(
6300 err_msg.contains("expected") || err_msg.contains("syntax error"),
6301 "Error should be a parse error: {}",
6302 err_msg
6303 );
6304 }
6305
6306 #[tokio::test]
6309 async fn test_cd_to_root_and_ls() {
6310 let mut bash = Bash::new();
6312 let result = bash.exec("cd / && ls").await.unwrap();
6313 assert_eq!(
6314 result.exit_code, 0,
6315 "cd / && ls should succeed: {}",
6316 result.stderr
6317 );
6318 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6319 assert!(result.stdout.contains("home"), "Root should contain home");
6320 }
6321
6322 #[tokio::test]
6323 async fn test_cd_to_root_and_pwd() {
6324 let mut bash = Bash::new();
6326 let result = bash.exec("cd / && pwd").await.unwrap();
6327 assert_eq!(result.exit_code, 0, "cd / && pwd should succeed");
6328 assert_eq!(result.stdout.trim(), "/");
6329 }
6330
6331 #[tokio::test]
6332 async fn test_cd_to_root_and_ls_dot() {
6333 let mut bash = Bash::new();
6335 let result = bash.exec("cd / && ls .").await.unwrap();
6336 assert_eq!(
6337 result.exit_code, 0,
6338 "cd / && ls . should succeed: {}",
6339 result.stderr
6340 );
6341 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6342 assert!(result.stdout.contains("home"), "Root should contain home");
6343 }
6344
6345 #[tokio::test]
6346 async fn test_ls_root_directly() {
6347 let mut bash = Bash::new();
6349 let result = bash.exec("ls /").await.unwrap();
6350 assert_eq!(
6351 result.exit_code, 0,
6352 "ls / should succeed: {}",
6353 result.stderr
6354 );
6355 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6356 assert!(result.stdout.contains("home"), "Root should contain home");
6357 assert!(result.stdout.contains("dev"), "Root should contain dev");
6358 }
6359
6360 #[tokio::test]
6361 async fn test_ls_root_long_format() {
6362 let mut bash = Bash::new();
6364 let result = bash.exec("ls -la /").await.unwrap();
6365 assert_eq!(
6366 result.exit_code, 0,
6367 "ls -la / should succeed: {}",
6368 result.stderr
6369 );
6370 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6371 assert!(
6372 result.stdout.contains("drw"),
6373 "Should show directory permissions"
6374 );
6375 }
6376
6377 #[tokio::test]
6380 async fn test_heredoc_redirect_to_file() {
6381 let mut bash = Bash::new();
6383 let result = bash
6384 .exec("cat > /tmp/out.txt <<'EOF'\nhello\nworld\nEOF\ncat /tmp/out.txt")
6385 .await
6386 .unwrap();
6387 assert_eq!(result.stdout, "hello\nworld\n");
6388 assert_eq!(result.exit_code, 0);
6389 }
6390
6391 #[tokio::test]
6392 async fn test_heredoc_redirect_to_file_unquoted() {
6393 let mut bash = Bash::new();
6394 let result = bash
6395 .exec("cat > /tmp/out.txt <<EOF\nhello\nworld\nEOF\ncat /tmp/out.txt")
6396 .await
6397 .unwrap();
6398 assert_eq!(result.stdout, "hello\nworld\n");
6399 assert_eq!(result.exit_code, 0);
6400 }
6401
6402 #[tokio::test]
6405 async fn test_pipe_to_while_read() {
6406 let mut bash = Bash::new();
6408 let result = bash
6409 .exec("echo -e 'a\\nb\\nc' | while read line; do echo \"got: $line\"; done")
6410 .await
6411 .unwrap();
6412 assert!(
6413 result.stdout.contains("got: a"),
6414 "stdout: {}",
6415 result.stdout
6416 );
6417 assert!(
6418 result.stdout.contains("got: b"),
6419 "stdout: {}",
6420 result.stdout
6421 );
6422 assert!(
6423 result.stdout.contains("got: c"),
6424 "stdout: {}",
6425 result.stdout
6426 );
6427 }
6428
6429 #[tokio::test]
6430 async fn test_pipe_to_while_read_count() {
6431 let mut bash = Bash::new();
6432 let result = bash
6433 .exec("printf 'x\\ny\\nz\\n' | while read line; do echo $line; done")
6434 .await
6435 .unwrap();
6436 assert_eq!(result.stdout, "x\ny\nz\n");
6437 }
6438
6439 #[tokio::test]
6442 async fn test_source_loads_functions() {
6443 let mut bash = Bash::new();
6444 bash.exec("cat > /tmp/lib.sh <<'EOF'\ngreet() { echo \"hello $1\"; }\nEOF")
6446 .await
6447 .unwrap();
6448 let result = bash.exec("source /tmp/lib.sh; greet world").await.unwrap();
6449 assert_eq!(result.stdout, "hello world\n");
6450 assert_eq!(result.exit_code, 0);
6451 }
6452
6453 #[tokio::test]
6454 async fn test_source_loads_variables() {
6455 let mut bash = Bash::new();
6456 bash.exec("echo 'MY_VAR=loaded' > /tmp/vars.sh")
6457 .await
6458 .unwrap();
6459 let result = bash
6460 .exec("source /tmp/vars.sh; echo $MY_VAR")
6461 .await
6462 .unwrap();
6463 assert_eq!(result.stdout, "loaded\n");
6464 }
6465
6466 #[tokio::test]
6469 async fn test_chmod_symbolic_plus_x() {
6470 let mut bash = Bash::new();
6471 bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
6472 .await
6473 .unwrap();
6474 let result = bash.exec("chmod +x /tmp/script.sh").await.unwrap();
6475 assert_eq!(
6476 result.exit_code, 0,
6477 "chmod +x should succeed: {}",
6478 result.stderr
6479 );
6480 }
6481
6482 #[tokio::test]
6483 async fn test_chmod_symbolic_u_plus_x() {
6484 let mut bash = Bash::new();
6485 bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
6486 let result = bash.exec("chmod u+x /tmp/file.txt").await.unwrap();
6487 assert_eq!(
6488 result.exit_code, 0,
6489 "chmod u+x should succeed: {}",
6490 result.stderr
6491 );
6492 }
6493
6494 #[tokio::test]
6495 async fn test_chmod_symbolic_a_plus_r() {
6496 let mut bash = Bash::new();
6497 bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
6498 let result = bash.exec("chmod a+r /tmp/file.txt").await.unwrap();
6499 assert_eq!(
6500 result.exit_code, 0,
6501 "chmod a+r should succeed: {}",
6502 result.stderr
6503 );
6504 }
6505
6506 #[tokio::test]
6509 async fn test_awk_array_length() {
6510 let mut bash = Bash::new();
6512 let result = bash
6513 .exec(r#"echo "" | awk 'BEGIN{a[1]="x"; a[2]="y"; a[3]="z"} END{print length(a)}'"#)
6514 .await
6515 .unwrap();
6516 assert_eq!(result.stdout, "3\n");
6517 }
6518
6519 #[tokio::test]
6520 async fn test_awk_array_read_after_split() {
6521 let mut bash = Bash::new();
6523 let result = bash
6524 .exec(r#"echo "a:b:c" | awk '{n=split($0,arr,":"); for(i=1;i<=n;i++) print arr[i]}'"#)
6525 .await
6526 .unwrap();
6527 assert_eq!(result.stdout, "a\nb\nc\n");
6528 }
6529
6530 #[tokio::test]
6531 async fn test_awk_array_word_count_pattern() {
6532 let mut bash = Bash::new();
6534 let result = bash
6535 .exec(
6536 r#"printf "apple\nbanana\napple\ncherry\nbanana\napple" | awk '{count[$1]++} END{for(w in count) print w, count[w]}'"#,
6537 )
6538 .await
6539 .unwrap();
6540 assert!(
6541 result.stdout.contains("apple 3"),
6542 "stdout: {}",
6543 result.stdout
6544 );
6545 assert!(
6546 result.stdout.contains("banana 2"),
6547 "stdout: {}",
6548 result.stdout
6549 );
6550 assert!(
6551 result.stdout.contains("cherry 1"),
6552 "stdout: {}",
6553 result.stdout
6554 );
6555 }
6556
6557 #[tokio::test]
6560 async fn test_exec_streaming_for_loop() {
6561 let chunks = Arc::new(Mutex::new(Vec::new()));
6562 let chunks_cb = chunks.clone();
6563 let mut bash = Bash::new();
6564
6565 let result = bash
6566 .exec_streaming(
6567 "for i in 1 2 3; do echo $i; done",
6568 Box::new(move |stdout, _stderr| {
6569 chunks_cb.lock().unwrap().push(stdout.to_string());
6570 }),
6571 )
6572 .await
6573 .unwrap();
6574
6575 assert_eq!(result.stdout, "1\n2\n3\n");
6576 assert_eq!(
6577 *chunks.lock().unwrap(),
6578 vec!["1\n", "2\n", "3\n"],
6579 "each loop iteration should stream separately"
6580 );
6581 }
6582
6583 #[tokio::test]
6584 async fn test_exec_streaming_while_loop() {
6585 let chunks = Arc::new(Mutex::new(Vec::new()));
6586 let chunks_cb = chunks.clone();
6587 let mut bash = Bash::new();
6588
6589 let result = bash
6590 .exec_streaming(
6591 "i=0; while [ $i -lt 3 ]; do i=$((i+1)); echo $i; done",
6592 Box::new(move |stdout, _stderr| {
6593 chunks_cb.lock().unwrap().push(stdout.to_string());
6594 }),
6595 )
6596 .await
6597 .unwrap();
6598
6599 assert_eq!(result.stdout, "1\n2\n3\n");
6600 let chunks = chunks.lock().unwrap();
6601 assert!(
6603 chunks.contains(&"1\n".to_string()),
6604 "should contain first iteration output"
6605 );
6606 assert!(
6607 chunks.contains(&"2\n".to_string()),
6608 "should contain second iteration output"
6609 );
6610 assert!(
6611 chunks.contains(&"3\n".to_string()),
6612 "should contain third iteration output"
6613 );
6614 }
6615
6616 #[tokio::test]
6617 async fn test_exec_streaming_no_callback_still_works() {
6618 let mut bash = Bash::new();
6620 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
6621 assert_eq!(result.stdout, "a\nb\nc\n");
6622 }
6623
6624 #[tokio::test]
6625 async fn test_exec_streaming_cancel_clears_callback() {
6626 use std::time::Duration;
6627
6628 let chunks = Arc::new(Mutex::new(Vec::new()));
6629 let chunks_cb = chunks.clone();
6630 let mut bash = Bash::new();
6631
6632 let timed_out = tokio::time::timeout(
6633 Duration::from_millis(10),
6634 bash.exec_streaming(
6635 "sleep 1; echo should-not-run",
6636 Box::new(move |stdout, stderr| {
6637 chunks_cb
6638 .lock()
6639 .unwrap()
6640 .push((stdout.to_string(), stderr.to_string()));
6641 }),
6642 ),
6643 )
6644 .await;
6645
6646 assert!(timed_out.is_err(), "streaming execution should time out");
6647
6648 let result = bash.exec("echo later-run").await.unwrap();
6649
6650 assert_eq!(result.stdout, "later-run\n");
6651 assert_eq!(
6652 *chunks.lock().unwrap(),
6653 Vec::<(String, String)>::new(),
6654 "cancelled streaming callback must not receive later output"
6655 );
6656 }
6657
6658 #[tokio::test]
6659 async fn test_exec_streaming_nested_loops_no_duplicates() {
6660 let chunks = Arc::new(Mutex::new(Vec::new()));
6661 let chunks_cb = chunks.clone();
6662 let mut bash = Bash::new();
6663
6664 let result = bash
6665 .exec_streaming(
6666 "for i in 1 2; do for j in a b; do echo \"$i$j\"; done; done",
6667 Box::new(move |stdout, _stderr| {
6668 chunks_cb.lock().unwrap().push(stdout.to_string());
6669 }),
6670 )
6671 .await
6672 .unwrap();
6673
6674 assert_eq!(result.stdout, "1a\n1b\n2a\n2b\n");
6675 let chunks = chunks.lock().unwrap();
6676 let total_chars: usize = chunks.iter().map(|c| c.len()).sum();
6678 assert_eq!(
6679 total_chars,
6680 result.stdout.len(),
6681 "total streamed bytes should match final output: chunks={:?}",
6682 *chunks
6683 );
6684 }
6685
6686 #[tokio::test]
6687 async fn test_exec_streaming_mixed_list_and_loop() {
6688 let chunks = Arc::new(Mutex::new(Vec::new()));
6689 let chunks_cb = chunks.clone();
6690 let mut bash = Bash::new();
6691
6692 let result = bash
6693 .exec_streaming(
6694 "echo start; for i in 1 2; do echo $i; done; echo end",
6695 Box::new(move |stdout, _stderr| {
6696 chunks_cb.lock().unwrap().push(stdout.to_string());
6697 }),
6698 )
6699 .await
6700 .unwrap();
6701
6702 assert_eq!(result.stdout, "start\n1\n2\nend\n");
6703 let chunks = chunks.lock().unwrap();
6704 assert_eq!(
6705 *chunks,
6706 vec!["start\n", "1\n", "2\n", "end\n"],
6707 "mixed list+loop should produce exactly 4 events"
6708 );
6709 }
6710
6711 #[tokio::test]
6712 async fn test_exec_streaming_stderr() {
6713 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
6714 let stderr_cb = stderr_chunks.clone();
6715 let mut bash = Bash::new();
6716
6717 let result = bash
6718 .exec_streaming(
6719 "echo ok; echo err >&2; echo ok2",
6720 Box::new(move |_stdout, stderr| {
6721 if !stderr.is_empty() {
6722 stderr_cb.lock().unwrap().push(stderr.to_string());
6723 }
6724 }),
6725 )
6726 .await
6727 .unwrap();
6728
6729 assert_eq!(result.stdout, "ok\nok2\n");
6730 assert_eq!(result.stderr, "err\n");
6731 let stderr_chunks = stderr_chunks.lock().unwrap();
6732 assert!(
6733 stderr_chunks.contains(&"err\n".to_string()),
6734 "stderr should be streamed: {:?}",
6735 *stderr_chunks
6736 );
6737 }
6738
6739 async fn assert_streaming_equivalence(script: &str) {
6746 let mut bash_plain = Bash::new();
6748 let plain = bash_plain.exec(script).await.unwrap();
6749
6750 let stdout_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
6752 let stderr_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
6753 let so = stdout_chunks.clone();
6754 let se = stderr_chunks.clone();
6755 let mut bash_stream = Bash::new();
6756 let streamed = bash_stream
6757 .exec_streaming(
6758 script,
6759 Box::new(move |stdout, stderr| {
6760 if !stdout.is_empty() {
6761 so.lock().unwrap().push(stdout.to_string());
6762 }
6763 if !stderr.is_empty() {
6764 se.lock().unwrap().push(stderr.to_string());
6765 }
6766 }),
6767 )
6768 .await
6769 .unwrap();
6770
6771 assert_eq!(
6773 plain.stdout, streamed.stdout,
6774 "stdout mismatch for: {script}"
6775 );
6776 assert_eq!(
6777 plain.stderr, streamed.stderr,
6778 "stderr mismatch for: {script}"
6779 );
6780 assert_eq!(
6781 plain.exit_code, streamed.exit_code,
6782 "exit_code mismatch for: {script}"
6783 );
6784
6785 let reassembled_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
6787 assert_eq!(
6788 reassembled_stdout, streamed.stdout,
6789 "reassembled stdout chunks != final stdout for: {script}"
6790 );
6791 let reassembled_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
6792 assert_eq!(
6793 reassembled_stderr, streamed.stderr,
6794 "reassembled stderr chunks != final stderr for: {script}"
6795 );
6796 }
6797
6798 #[tokio::test]
6799 async fn test_exec_streaming_respects_stdout_stderr_limits() {
6800 let stdout_chunks = Arc::new(Mutex::new(Vec::new()));
6801 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
6802 let so = stdout_chunks.clone();
6803 let se = stderr_chunks.clone();
6804 let mut bash = Bash::builder()
6805 .limits(
6806 ExecutionLimits::new()
6807 .max_stdout_bytes(10)
6808 .max_stderr_bytes(8),
6809 )
6810 .build();
6811
6812 let result = bash
6813 .exec_streaming(
6814 "echo hello; echo world; echo err1 >&2; echo err2 >&2",
6815 Box::new(move |stdout, stderr| {
6816 if !stdout.is_empty() {
6817 so.lock().unwrap().push(stdout.to_string());
6818 }
6819 if !stderr.is_empty() {
6820 se.lock().unwrap().push(stderr.to_string());
6821 }
6822 }),
6823 )
6824 .await
6825 .unwrap();
6826
6827 assert_eq!(result.stdout, "hello\nworl");
6828 assert_eq!(result.stderr, "err1\nerr");
6829 assert!(result.stdout_truncated);
6830 assert!(result.stderr_truncated);
6831 let streamed_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
6832 let streamed_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
6833 assert_eq!(streamed_stdout, result.stdout);
6834 assert_eq!(streamed_stderr, result.stderr);
6835 }
6836
6837 #[tokio::test]
6838 async fn test_streaming_equivalence_for_loop() {
6839 assert_streaming_equivalence("for i in 1 2 3; do echo $i; done").await;
6840 }
6841
6842 #[tokio::test]
6843 async fn test_streaming_equivalence_while_loop() {
6844 assert_streaming_equivalence("i=0; while [ $i -lt 4 ]; do i=$((i+1)); echo $i; done").await;
6845 }
6846
6847 #[tokio::test]
6848 async fn test_streaming_equivalence_nested_loops() {
6849 assert_streaming_equivalence("for i in a b; do for j in 1 2; do echo \"$i$j\"; done; done")
6850 .await;
6851 }
6852
6853 #[tokio::test]
6854 async fn test_streaming_equivalence_mixed_list() {
6855 assert_streaming_equivalence("echo start; for i in x y; do echo $i; done; echo end").await;
6856 }
6857
6858 #[tokio::test]
6859 async fn test_streaming_equivalence_stderr() {
6860 assert_streaming_equivalence("echo out; echo err >&2; echo out2").await;
6861 }
6862
6863 #[tokio::test]
6864 async fn test_streaming_equivalence_pipeline() {
6865 assert_streaming_equivalence("echo -e 'a\\nb\\nc' | grep b").await;
6866 }
6867
6868 #[tokio::test]
6869 async fn test_streaming_equivalence_conditionals() {
6870 assert_streaming_equivalence("if true; then echo yes; else echo no; fi; echo done").await;
6871 }
6872
6873 #[tokio::test]
6874 async fn test_streaming_equivalence_subshell() {
6875 assert_streaming_equivalence("x=$(echo hello); echo $x").await;
6876 }
6877
6878 #[tokio::test]
6879 async fn test_streaming_equivalence_command_substitution_exit_trap() {
6880 assert_streaming_equivalence("secret=$(trap 'echo TOKEN' EXIT); trap - EXIT; echo ok")
6881 .await;
6882 }
6883
6884 #[tokio::test]
6885 async fn test_max_memory_caps_string_growth() {
6886 let mut bash = Bash::builder()
6887 .max_memory(1024)
6888 .limits(
6889 ExecutionLimits::new()
6890 .max_commands(10_000)
6891 .max_loop_iterations(10_000),
6892 )
6893 .build();
6894 let result = bash
6895 .exec(r#"x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}"#)
6896 .await
6897 .unwrap();
6898 let len: usize = result.stdout.trim().parse().unwrap();
6899 assert!(len <= 1024, "string length {len} must be ≤ 1024");
6901 }
6902
6903 #[tokio::test]
6905 async fn test_stderr_redirect_devnull_streaming() {
6906 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
6907 let stderr_cb = stderr_chunks.clone();
6908 let mut bash = Bash::new();
6909
6910 let result = bash
6912 .exec_streaming(
6913 "{ ls /nonexistent; } 2>/dev/null; echo exit:$?",
6914 Box::new(move |_stdout, stderr| {
6915 if !stderr.is_empty() {
6916 stderr_cb.lock().unwrap().push(stderr.to_string());
6917 }
6918 }),
6919 )
6920 .await
6921 .unwrap();
6922
6923 assert_eq!(result.stderr, "", "final stderr should be empty");
6924 let stderr_chunks = stderr_chunks.lock().unwrap();
6925 assert!(
6926 stderr_chunks.is_empty(),
6927 "no stderr should be streamed when 2>/dev/null is used, got: {:?}",
6928 *stderr_chunks
6929 );
6930 }
6931
6932 #[tokio::test]
6933 async fn test_dot_slash_prefix_ls() {
6934 let mut bash = Bash::new();
6936 bash.exec("mkdir -p /tmp/blogtest && cd /tmp/blogtest && echo hello > tag_hello.html")
6937 .await
6938 .unwrap();
6939
6940 let result = bash
6942 .exec("cd /tmp/blogtest && ls tag_hello.html")
6943 .await
6944 .unwrap();
6945 assert_eq!(
6946 result.exit_code, 0,
6947 "ls tag_hello.html should succeed: {}",
6948 result.stderr
6949 );
6950 assert!(result.stdout.contains("tag_hello.html"));
6951
6952 let result = bash
6954 .exec("cd /tmp/blogtest && ls ./tag_hello.html")
6955 .await
6956 .unwrap();
6957 assert_eq!(
6958 result.exit_code, 0,
6959 "ls ./tag_hello.html should succeed: {}",
6960 result.stderr
6961 );
6962 assert!(result.stdout.contains("tag_hello.html"));
6963 }
6964
6965 #[tokio::test]
6966 async fn test_dot_slash_prefix_glob() {
6967 let mut bash = Bash::new();
6969 bash.exec("mkdir -p /tmp/globtest && cd /tmp/globtest && echo hello > tag_hello.html")
6970 .await
6971 .unwrap();
6972
6973 let result = bash.exec("cd /tmp/globtest && echo *.html").await.unwrap();
6975 assert_eq!(
6976 result.exit_code, 0,
6977 "echo *.html should succeed: {}",
6978 result.stderr
6979 );
6980 assert!(result.stdout.contains("tag_hello.html"));
6981
6982 let result = bash
6984 .exec("cd /tmp/globtest && echo ./*.html")
6985 .await
6986 .unwrap();
6987 assert_eq!(
6988 result.exit_code, 0,
6989 "echo ./*.html should succeed: {}",
6990 result.stderr
6991 );
6992 assert!(result.stdout.contains("tag_hello.html"));
6993 }
6994
6995 #[tokio::test]
6996 async fn test_dot_slash_prefix_cat() {
6997 let mut bash = Bash::new();
6999 bash.exec("mkdir -p /tmp/cattest && cd /tmp/cattest && echo content123 > myfile.txt")
7000 .await
7001 .unwrap();
7002
7003 let result = bash
7004 .exec("cd /tmp/cattest && cat ./myfile.txt")
7005 .await
7006 .unwrap();
7007 assert_eq!(
7008 result.exit_code, 0,
7009 "cat ./myfile.txt should succeed: {}",
7010 result.stderr
7011 );
7012 assert!(result.stdout.contains("content123"));
7013 }
7014
7015 #[tokio::test]
7016 async fn test_dot_slash_prefix_redirect() {
7017 let mut bash = Bash::new();
7019 bash.exec("mkdir -p /tmp/redirtest && cd /tmp/redirtest")
7020 .await
7021 .unwrap();
7022
7023 let result = bash
7024 .exec("cd /tmp/redirtest && echo hello > ./output.txt && cat ./output.txt")
7025 .await
7026 .unwrap();
7027 assert_eq!(
7028 result.exit_code, 0,
7029 "redirect to ./output.txt should succeed: {}",
7030 result.stderr
7031 );
7032 assert!(result.stdout.contains("hello"));
7033 }
7034
7035 #[tokio::test]
7036 async fn test_dot_slash_prefix_test_builtin() {
7037 let mut bash = Bash::new();
7039 bash.exec("mkdir -p /tmp/testbuiltin && cd /tmp/testbuiltin && echo x > myfile.txt")
7040 .await
7041 .unwrap();
7042
7043 let result = bash
7044 .exec("cd /tmp/testbuiltin && test -f ./myfile.txt && echo yes")
7045 .await
7046 .unwrap();
7047 assert_eq!(
7048 result.exit_code, 0,
7049 "test -f ./myfile.txt should succeed: {}",
7050 result.stderr
7051 );
7052 assert!(result.stdout.contains("yes"));
7053 }
7054
7055 #[tokio::test]
7058 async fn test_before_exec_hook_modifies_script() {
7059 use std::sync::Arc;
7060 use std::sync::atomic::{AtomicBool, Ordering};
7061
7062 let called = Arc::new(AtomicBool::new(false));
7063 let called_clone = called.clone();
7064
7065 let mut bash = Bash::builder()
7066 .before_exec(Box::new(move |mut input| {
7067 called_clone.store(true, Ordering::Relaxed);
7068 input.script = "echo intercepted".to_string();
7070 hooks::HookAction::Continue(input)
7071 }))
7072 .build();
7073
7074 let result = bash.exec("echo original").await.unwrap();
7075 assert!(called.load(Ordering::Relaxed));
7076 assert_eq!(result.stdout.trim(), "intercepted");
7077 }
7078
7079 #[tokio::test]
7080 async fn test_before_exec_hook_cancels() {
7081 let mut bash = Bash::builder()
7082 .before_exec(Box::new(|_input| {
7083 hooks::HookAction::Cancel("blocked".to_string())
7084 }))
7085 .build();
7086
7087 let result = bash.exec("echo should-not-run").await.unwrap();
7088 assert_eq!(result.exit_code, 1);
7089 assert!(result.stdout.is_empty());
7090 }
7091
7092 #[tokio::test]
7093 async fn test_input_size_limit_rejects_before_before_exec_hook() {
7094 use std::sync::Arc;
7095 use std::sync::atomic::{AtomicBool, Ordering};
7096
7097 let called = Arc::new(AtomicBool::new(false));
7098 let called_clone = called.clone();
7099
7100 let limits = ExecutionLimits::new().max_input_bytes(8);
7101 let mut bash = Bash::builder()
7102 .limits(limits)
7103 .before_exec(Box::new(move |_input| {
7104 called_clone.store(true, Ordering::Relaxed);
7105 unreachable!("before_exec hook must not run for oversized input");
7106 }))
7107 .build();
7108
7109 let result = bash.exec("echo way-too-long").await;
7110 assert!(result.is_err());
7111 assert!(!called.load(Ordering::Relaxed));
7112 }
7113
7114 #[tokio::test]
7115 async fn test_after_exec_hook_observes_output() {
7116 use std::sync::{Arc, Mutex};
7117
7118 let captured = Arc::new(Mutex::new(String::new()));
7119 let captured_clone = captured.clone();
7120
7121 let mut bash = Bash::builder()
7122 .after_exec(Box::new(move |output| {
7123 *captured_clone.lock().unwrap() = output.stdout.clone();
7124 hooks::HookAction::Continue(output)
7125 }))
7126 .build();
7127
7128 bash.exec("echo hello-hooks").await.unwrap();
7129 assert_eq!(captured.lock().unwrap().trim(), "hello-hooks");
7130 }
7131
7132 #[tokio::test]
7133 async fn test_after_exec_hook_can_modify_output() {
7134 let mut bash = Bash::builder()
7135 .after_exec(Box::new(|mut output| {
7136 output.stdout = output.stdout.replace("SECRET", "[redacted]");
7137 output.stderr = "policy stderr\n".to_string();
7138 output.exit_code = 7;
7139 hooks::HookAction::Continue(output)
7140 }))
7141 .build();
7142
7143 let result = bash.exec("echo SECRET").await.unwrap();
7144 assert_eq!(result.stdout, "[redacted]\n");
7145 assert_eq!(result.stderr, "policy stderr\n");
7146 assert_eq!(result.exit_code, 7);
7147 }
7148
7149 #[tokio::test]
7150 async fn test_after_exec_hook_can_cancel_result() {
7151 let mut bash = Bash::builder()
7152 .after_exec(Box::new(|_output| {
7153 hooks::HookAction::Cancel("blocked".to_string())
7154 }))
7155 .build();
7156
7157 let result = bash.exec("echo SECRET").await.unwrap();
7158 assert_eq!(result.stdout, "");
7159 assert_eq!(result.stderr, "cancelled by after_exec hook");
7160 assert_eq!(result.exit_code, 1);
7161 }
7162
7163 #[tokio::test]
7164 async fn test_before_tool_hook_can_cancel_special_builtin() {
7165 let mut bash = Bash::builder()
7166 .before_tool(Box::new(|event| {
7167 if event.name == "source" {
7168 hooks::HookAction::Cancel("source blocked".to_string())
7169 } else {
7170 hooks::HookAction::Continue(event)
7171 }
7172 }))
7173 .build();
7174
7175 let result = bash.exec("source missing.sh").await.unwrap();
7176 assert_eq!(result.exit_code, 1);
7177 assert!(result.stderr.contains("cancelled by before_tool hook"));
7178 }
7179
7180 #[tokio::test]
7181 async fn test_after_tool_hook_can_modify_builtin_result() {
7182 let mut bash = Bash::builder()
7183 .after_tool(Box::new(|mut result| {
7184 if result.name == "echo" {
7185 result.stdout = result.stdout.replace("SECRET", "[redacted]");
7186 result.exit_code = 9;
7187 }
7188 hooks::HookAction::Continue(result)
7189 }))
7190 .build();
7191
7192 let result = bash.exec("echo SECRET").await.unwrap();
7193 assert_eq!(result.stdout, "[redacted]\n");
7194 assert_eq!(result.exit_code, 9);
7195 }
7196
7197 #[tokio::test]
7198 async fn test_after_tool_hook_can_cancel_builtin_result() {
7199 let mut bash = Bash::builder()
7200 .after_tool(Box::new(|result| {
7201 if result.name == "echo" {
7202 hooks::HookAction::Cancel("blocked".to_string())
7203 } else {
7204 hooks::HookAction::Continue(result)
7205 }
7206 }))
7207 .build();
7208
7209 let result = bash.exec("echo SECRET").await.unwrap();
7210 assert_eq!(result.stdout, "");
7211 assert!(result.stderr.contains("cancelled by after_tool hook"));
7212 assert_eq!(result.exit_code, 1);
7213 }
7214
7215 #[tokio::test]
7216 async fn test_multiple_hooks_chain() {
7217 let mut bash = Bash::builder()
7218 .before_exec(Box::new(|mut input| {
7219 input.script = input.script.replace("world", "hooks");
7220 hooks::HookAction::Continue(input)
7221 }))
7222 .before_exec(Box::new(|mut input| {
7223 input.script = input.script.replace("hello", "greetings");
7224 hooks::HookAction::Continue(input)
7225 }))
7226 .build();
7227
7228 let result = bash.exec("echo hello world").await.unwrap();
7229 assert_eq!(result.stdout.trim(), "greetings hooks");
7230 }
7231
7232 #[tokio::test]
7233 async fn test_on_exit_hook_not_fired_for_path_script_exit() {
7234 use std::path::Path;
7235 use std::sync::Arc;
7236 use std::sync::atomic::{AtomicU32, Ordering};
7237
7238 let count = Arc::new(AtomicU32::new(0));
7239 let count_clone = count.clone();
7240
7241 let mut bash = Bash::builder()
7242 .on_exit(Box::new(move |event| {
7243 count_clone.fetch_add(1, Ordering::Relaxed);
7244 hooks::HookAction::Continue(event)
7245 }))
7246 .build();
7247
7248 let fs = bash.fs();
7249 fs.mkdir(Path::new("/bin"), false).await.unwrap();
7250 fs.write_file(Path::new("/bin/child-exit"), b"#!/usr/bin/env bash\nexit 7")
7251 .await
7252 .unwrap();
7253 fs.chmod(Path::new("/bin/child-exit"), 0o755).await.unwrap();
7254
7255 let result = bash
7256 .exec("PATH=/bin:$PATH\nchild-exit\necho after:$?")
7257 .await
7258 .unwrap();
7259
7260 assert_eq!(result.stdout.trim(), "after:7");
7261 assert_eq!(count.load(Ordering::Relaxed), 0);
7262 }
7263
7264 #[tokio::test]
7265 async fn test_on_exit_hook_not_fired_for_direct_script_exit() {
7266 use std::path::Path;
7267 use std::sync::Arc;
7268 use std::sync::atomic::{AtomicU32, Ordering};
7269
7270 let count = Arc::new(AtomicU32::new(0));
7271 let count_clone = count.clone();
7272
7273 let mut bash = Bash::builder()
7274 .on_exit(Box::new(move |event| {
7275 count_clone.fetch_add(1, Ordering::Relaxed);
7276 hooks::HookAction::Continue(event)
7277 }))
7278 .build();
7279
7280 let fs = bash.fs();
7281 fs.write_file(
7282 Path::new("/tmp/child-exit.sh"),
7283 b"#!/usr/bin/env bash\nexit 8",
7284 )
7285 .await
7286 .unwrap();
7287 fs.chmod(Path::new("/tmp/child-exit.sh"), 0o755)
7288 .await
7289 .unwrap();
7290
7291 let result = bash
7292 .exec("/tmp/child-exit.sh\necho after:$?")
7293 .await
7294 .unwrap();
7295
7296 assert_eq!(result.stdout.trim(), "after:8");
7297 assert_eq!(count.load(Ordering::Relaxed), 0);
7298 }
7299
7300 #[tokio::test]
7301 async fn test_on_exit_hook_not_fired_for_nested_bash_exit() {
7302 use std::sync::Arc;
7303 use std::sync::atomic::{AtomicU32, Ordering};
7304
7305 let count = Arc::new(AtomicU32::new(0));
7306 let count_clone = count.clone();
7307
7308 let mut bash = Bash::builder()
7309 .on_exit(Box::new(move |event| {
7310 count_clone.fetch_add(1, Ordering::Relaxed);
7311 hooks::HookAction::Continue(event)
7312 }))
7313 .build();
7314
7315 let result = bash.exec("bash -c 'exit 9'\necho after:$?").await.unwrap();
7316
7317 assert_eq!(result.stdout.trim(), "after:9");
7318 assert_eq!(count.load(Ordering::Relaxed), 0);
7319 }
7320
7321 #[tokio::test]
7322 async fn test_path_script_exit_runs_child_exit_trap() {
7323 use std::path::Path;
7324
7325 let mut bash = Bash::new();
7326 let fs = bash.fs();
7327 fs.write_file(
7328 Path::new("/tmp/child-trap.sh"),
7329 b"#!/usr/bin/env bash\ntrap 'echo child-trap' EXIT\nexit 4",
7330 )
7331 .await
7332 .unwrap();
7333 fs.chmod(Path::new("/tmp/child-trap.sh"), 0o755)
7334 .await
7335 .unwrap();
7336
7337 let result = bash
7338 .exec("/tmp/child-trap.sh\necho after:$?")
7339 .await
7340 .unwrap();
7341
7342 assert_eq!(result.stdout.trim(), "child-trap\nafter:4");
7343 }
7344
7345 #[tokio::test]
7346 async fn test_on_exit_hook_still_fires_for_source_exit() {
7347 use std::path::Path;
7348 use std::sync::Arc;
7349 use std::sync::atomic::{AtomicU32, Ordering};
7350
7351 let count = Arc::new(AtomicU32::new(0));
7352 let count_clone = count.clone();
7353
7354 let mut bash = Bash::builder()
7355 .on_exit(Box::new(move |event| {
7356 count_clone.fetch_add(1, Ordering::Relaxed);
7357 hooks::HookAction::Continue(event)
7358 }))
7359 .build();
7360
7361 let fs = bash.fs();
7362 fs.write_file(Path::new("/tmp/source-exit.sh"), b"exit 5")
7363 .await
7364 .unwrap();
7365
7366 let result = bash.exec("source /tmp/source-exit.sh").await.unwrap();
7367
7368 assert_eq!(result.exit_code, 5);
7369 assert_eq!(count.load(Ordering::Relaxed), 1);
7370 }
7371
7372 #[tokio::test]
7373 async fn test_on_exit_hook_cancel_prevents_exit() {
7374 let mut bash = Bash::builder()
7375 .on_exit(Box::new(|_event| {
7376 hooks::HookAction::Cancel("blocked by policy".to_string())
7377 }))
7378 .build();
7379
7380 let result = bash.exec("echo before\nexit 5\necho after").await.unwrap();
7381 assert_eq!(result.stdout.trim(), "before\nafter");
7382 assert_eq!(result.exit_code, 0);
7383 }
7384
7385 #[tokio::test]
7386 async fn test_on_exit_hook_can_modify_exit_code() {
7387 let mut bash = Bash::builder()
7388 .on_exit(Box::new(|mut event| {
7389 event.code = 17;
7390 hooks::HookAction::Continue(event)
7391 }))
7392 .build();
7393
7394 let result = bash.exec("exit 5").await.unwrap();
7395 assert_eq!(result.exit_code, 17);
7396 }
7397
7398 #[tokio::test]
7399 async fn test_bash_versinfo_reports_bash_compatible_major() {
7400 let mut bash = Bash::new();
7401
7402 let result = bash
7403 .exec(r#"[[ ${BASH_VERSINFO[0]} -ge 4 ]] && echo bash4plus"#)
7404 .await
7405 .unwrap();
7406
7407 assert_eq!(result.stdout.trim(), "bash4plus");
7408 }
7409
7410 #[tokio::test]
7411 async fn test_bash_version_surface_matches_bash_compatible_tuple() {
7412 let mut bash = Bash::new();
7413
7414 let result = bash
7415 .exec(
7416 r#"printf '%s\n' "$BASH_VERSION" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}" "${BASH_VERSINFO[2]}" "${BASH_VERSINFO[3]}" "${BASH_VERSINFO[4]}" "${BASH_VERSINFO[5]}""#,
7417 )
7418 .await
7419 .unwrap();
7420
7421 assert_eq!(
7422 result.stdout,
7423 "5.2.15(1)-release\n5\n2\n15\n1\nrelease\nvirtual\n"
7424 );
7425 }
7426
7427 #[tokio::test]
7428 async fn test_path_script_retains_bash_versinfo_array() {
7429 use std::path::Path;
7430
7431 let mut bash = Bash::new();
7432 let fs = bash.fs();
7433 fs.write_file(
7434 Path::new("/tmp/bash-version-check.sh"),
7435 b"#!/usr/bin/env bash\nprintf '%s\\n' \"${BASH_VERSINFO[0]}\"",
7436 )
7437 .await
7438 .unwrap();
7439 fs.chmod(Path::new("/tmp/bash-version-check.sh"), 0o755)
7440 .await
7441 .unwrap();
7442
7443 let result = bash.exec("/tmp/bash-version-check.sh").await.unwrap();
7444
7445 assert_eq!(result.stdout.trim(), "5");
7446 }
7447
7448 #[tokio::test]
7449 async fn test_path_script_bash_versinfo_satisfies_bash4_guard() {
7450 use std::path::Path;
7451
7452 let mut bash = Bash::new();
7453 let fs = bash.fs();
7454 fs.write_file(
7455 Path::new("/tmp/bash-version-guard.sh"),
7456 b"#!/usr/bin/env bash\nif (( BASH_VERSINFO[0] < 4 )); then echo too-old; else echo ok; fi",
7457 )
7458 .await
7459 .unwrap();
7460 fs.chmod(Path::new("/tmp/bash-version-guard.sh"), 0o755)
7461 .await
7462 .unwrap();
7463
7464 let result = bash.exec("/tmp/bash-version-guard.sh").await.unwrap();
7465
7466 assert_eq!(result.stdout.trim(), "ok");
7467 }
7468
7469 #[tokio::test]
7470 async fn test_before_tool_hook_modifies_args() {
7471 use std::sync::Arc;
7472 use std::sync::atomic::{AtomicBool, Ordering};
7473
7474 let called = Arc::new(AtomicBool::new(false));
7475 let called_clone = called.clone();
7476
7477 let mut bash = Bash::builder()
7478 .before_tool(Box::new(move |mut event| {
7479 called_clone.store(true, Ordering::Relaxed);
7480 if !event.args.is_empty() {
7482 event.args = vec!["intercepted".to_string()];
7483 }
7484 hooks::HookAction::Continue(event)
7485 }))
7486 .build();
7487
7488 let result = bash.exec("echo original").await.unwrap();
7489 assert!(called.load(Ordering::Relaxed));
7490 assert_eq!(result.stdout.trim(), "intercepted");
7491 }
7492
7493 #[tokio::test]
7494 async fn test_before_tool_hook_cancels() {
7495 let mut bash = Bash::builder()
7496 .before_tool(Box::new(|event| {
7497 if event.name == "echo" {
7498 hooks::HookAction::Cancel("echo blocked".to_string())
7499 } else {
7500 hooks::HookAction::Continue(event)
7501 }
7502 }))
7503 .build();
7504
7505 let result = bash.exec("echo should-not-run").await.unwrap();
7506 assert_eq!(result.exit_code, 1);
7507 assert!(result.stderr.contains("cancelled by before_tool hook"));
7508 }
7509
7510 #[tokio::test]
7511 async fn test_after_tool_hook_observes_result() {
7512 use std::sync::{Arc, Mutex};
7513
7514 let captured = Arc::new(Mutex::new(Vec::new()));
7515 let captured_clone = captured.clone();
7516
7517 let mut bash = Bash::builder()
7518 .after_tool(Box::new(move |result| {
7519 captured_clone.lock().unwrap().push((
7520 result.name.clone(),
7521 result.stdout.clone(),
7522 result.exit_code,
7523 ));
7524 hooks::HookAction::Continue(result)
7525 }))
7526 .build();
7527
7528 bash.exec("echo hello-tool").await.unwrap();
7529 let results = captured.lock().unwrap();
7530 assert!(!results.is_empty());
7531 assert_eq!(results[0].0, "echo");
7532 assert!(results[0].1.contains("hello-tool"));
7533 assert_eq!(results[0].2, 0);
7534 }
7535
7536 #[tokio::test]
7537 async fn test_before_tool_hook_fires_for_special_and_registered_builtins() {
7538 use std::sync::Arc;
7541 use std::sync::atomic::{AtomicU32, Ordering};
7542
7543 let count = Arc::new(AtomicU32::new(0));
7544 let count_clone = count.clone();
7545
7546 let mut bash = Bash::builder()
7547 .before_tool(Box::new(move |event| {
7548 count_clone.fetch_add(1, Ordering::Relaxed);
7549 hooks::HookAction::Continue(event)
7550 }))
7551 .build();
7552
7553 bash.exec("declare x=1").await.unwrap();
7555 assert_eq!(count.load(Ordering::Relaxed), 1);
7556
7557 bash.exec("echo hi").await.unwrap();
7559 assert_eq!(count.load(Ordering::Relaxed), 2);
7560 }
7561
7562 #[cfg(feature = "http_client")]
7563 #[tokio::test]
7564 async fn test_before_http_hook_cancels_request() {
7565 use crate::NetworkAllowlist;
7566
7567 let mut bash = Bash::builder()
7568 .network(NetworkAllowlist::allow_all())
7569 .before_http(Box::new(|req| {
7570 if req.url.contains("blocked.example.com") {
7571 hooks::HookAction::Cancel("blocked by policy".to_string())
7572 } else {
7573 hooks::HookAction::Continue(req)
7574 }
7575 }))
7576 .build();
7577
7578 let result = bash
7580 .exec("curl -s https://blocked.example.com/data")
7581 .await
7582 .unwrap();
7583 assert_ne!(result.exit_code, 0);
7584 assert!(result.stderr.contains("cancelled by before_http hook"));
7585 }
7586
7587 #[cfg(feature = "http_client")]
7588 #[tokio::test]
7589 async fn test_after_http_hook_observes_response() {
7590 use std::sync::{Arc, Mutex};
7591
7592 use crate::NetworkAllowlist;
7593
7594 let captured = Arc::new(Mutex::new(Vec::new()));
7595 let captured_clone = captured.clone();
7596
7597 let mut bash = Bash::builder()
7598 .network(NetworkAllowlist::allow_all())
7599 .after_http(Box::new(move |event| {
7600 captured_clone
7601 .lock()
7602 .unwrap()
7603 .push((event.url.clone(), event.status));
7604 hooks::HookAction::Continue(event)
7605 }))
7606 .build();
7607
7608 let _result = bash.exec("curl -s https://httpbin.org/get").await;
7612 }
7615}