1#![warn(clippy::unwrap_used)]
400#![cfg_attr(test, allow(clippy::unwrap_used))]
401
402mod builtins;
403#[cfg(feature = "http_client")]
404mod credential;
405mod error;
406mod fs;
407pub mod hooks;
409#[cfg(feature = "interop")]
410pub mod interop;
411mod interpreter;
412mod limits;
413#[cfg(feature = "logging")]
414mod logging_impl;
415mod network;
416pub mod parser;
418#[cfg(feature = "scripted_tool")]
421pub mod scripted_tool;
422mod snapshot;
423#[doc(hidden)]
428pub mod testing;
429pub mod tool;
431#[cfg(feature = "scripted_tool")]
433pub(crate) mod tool_def;
434pub mod trace;
436
437pub use async_trait::async_trait;
438pub use builtins::git::GitConfig;
439pub use builtins::ssh::{SshAllowlist, SshConfig, TrustedHostKey};
440pub use builtins::{
441 BashkitContext, Builtin, ClapBuiltin, Context as BuiltinContext, ExecutionExtensions, Extension,
442};
443pub use clap;
444#[cfg(feature = "http_client")]
445pub use credential::Credential;
446pub use error::{Error, Result};
447pub use fs::{
448 DirEntry, FileSystem, FileSystemExt, FileType, FsBackend, FsLimitExceeded, FsLimits, FsUsage,
449 InMemoryFs, LazyLoader, Metadata, MountableFs, OverlayFs, PosixFs, SearchCapabilities,
450 SearchCapable, SearchMatch, SearchProvider, SearchQuery, SearchResults, VfsSnapshot,
451 normalize_path, verify_filesystem_requirements,
452};
453#[cfg(feature = "realfs")]
454pub use fs::{RealFs, RealFsMode};
455pub use interpreter::{
456 ControlFlow, ExecResult, HistoryEntry, OutputCallback, ShellState, ShellStateView,
457};
458pub use limits::{
459 ExecutionCounters, ExecutionLimits, LimitExceeded, MemoryBudget, MemoryLimits, SessionLimits,
460};
461pub use network::NetworkAllowlist;
462pub use snapshot::{Snapshot, SnapshotOptions};
463pub use tool::BashToolBuilder as ToolBuilder;
464pub use tool::{
465 BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput,
466 ToolOutputChunk, ToolOutputMetadata, ToolRequest, ToolResponse, ToolService, ToolStatus,
467 VERSION,
468};
469pub use trace::{
470 TraceCallback, TraceCollector, TraceEvent, TraceEventDetails, TraceEventKind, TraceMode,
471};
472
473#[cfg(feature = "scripted_tool")]
474pub use scripted_tool::{
475 AsyncToolCallback, CallbackKind, DiscoverTool, DiscoveryMode, ScriptedCommandInvocation,
476 ScriptedCommandKind, ScriptedExecutionTrace, ScriptedTool, ScriptedToolBuilder,
477 ScriptingToolSet, ScriptingToolSetBuilder, ToolArgs, ToolCallback, ToolDef, ToolDefExtension,
478 ToolDefExtensionBuilder,
479};
480#[cfg(feature = "scripted_tool")]
481pub use tool_def::{AsyncToolExec, SyncToolExec, ToolImpl};
482
483#[cfg(feature = "http_client")]
484pub use network::{HttpClient, HttpHandler};
485
486#[cfg(feature = "http_client")]
488pub use network::Response as HttpResponse;
489
490#[cfg(feature = "bot-auth")]
491pub use network::{BotAuthConfig, BotAuthError, BotAuthPublicKey, derive_bot_auth_public_key};
492
493#[cfg(feature = "git")]
494pub use builtins::git::GitClient;
495
496#[cfg(feature = "ssh")]
497pub use builtins::ssh::{SshClient, SshHandler, SshOutput, SshTarget};
498
499#[cfg(feature = "python")]
500pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits};
501
502#[cfg(feature = "sqlite")]
503pub use builtins::{Sqlite, SqliteBackend, SqliteLimits};
504#[cfg(feature = "python")]
508pub use monty::{ExcType, ExtFunctionResult, MontyException, MontyObject};
509
510#[cfg(feature = "typescript")]
511pub use builtins::{
512 TypeScriptConfig, TypeScriptExtension, TypeScriptExternalFnHandler, TypeScriptExternalFns,
513 TypeScriptLimits,
514};
515#[cfg(feature = "typescript")]
517pub use zapcode_core::Value as ZapcodeValue;
518
519#[cfg(feature = "logging")]
524pub mod logging {
525 pub use crate::logging_impl::{
526 LogConfig, format_error_for_log, format_script_for_log, sanitize_for_log,
527 };
528}
529
530#[cfg(feature = "logging")]
531pub use logging::LogConfig;
532
533use interpreter::Interpreter;
534use parser::Parser;
535use std::collections::HashMap;
536#[cfg(feature = "realfs")]
537use std::path::Path;
538use std::path::PathBuf;
539use std::sync::Arc;
540
541#[cfg(any(feature = "python", feature = "sqlite"))]
542fn env_opt_in_enabled(env: &HashMap<String, String>, key: &str) -> bool {
543 env.get(key)
544 .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
545}
546
547pub struct Bash {
551 fs: Arc<dyn FileSystem>,
552 mountable: Arc<MountableFs>,
554 interpreter: Interpreter,
555 parser_timeout: std::time::Duration,
557 max_input_bytes: usize,
559 max_ast_depth: usize,
561 max_parser_operations: usize,
563 #[cfg(feature = "logging")]
565 log_config: logging::LogConfig,
566 #[cfg(feature = "python")]
568 python_inprocess_opt_in: bool,
569 #[cfg(feature = "sqlite")]
571 sqlite_inprocess_opt_in: bool,
572}
573
574impl Default for Bash {
575 fn default() -> Self {
576 Self::new()
577 }
578}
579
580impl Bash {
581 pub fn new() -> Self {
583 let base_fs: Arc<dyn FileSystem> = Arc::new(InMemoryFs::new());
584 let mountable = Arc::new(MountableFs::new(base_fs));
585 let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
586 let interpreter = Interpreter::new(Arc::clone(&fs));
587 let parser_timeout = ExecutionLimits::default().parser_timeout;
588 let max_input_bytes = ExecutionLimits::default().max_input_bytes;
589 let max_ast_depth = ExecutionLimits::default().max_ast_depth;
590 let max_parser_operations = ExecutionLimits::default().max_parser_operations;
591 Self {
592 fs,
593 mountable,
594 interpreter,
595 parser_timeout,
596 max_input_bytes,
597 max_ast_depth,
598 max_parser_operations,
599 #[cfg(feature = "logging")]
600 log_config: logging::LogConfig::default(),
601 #[cfg(feature = "python")]
602 python_inprocess_opt_in: false,
603 #[cfg(feature = "sqlite")]
604 sqlite_inprocess_opt_in: false,
605 }
606 }
607
608 pub fn builder() -> BashBuilder {
610 BashBuilder::default()
611 }
612
613 pub async fn exec(&mut self, script: &str) -> Result<ExecResult> {
619 self.exec_with_extensions(script, ExecutionExtensions::new())
620 .await
621 }
622
623 pub async fn exec_with_extensions(
625 &mut self,
626 script: &str,
627 mut extensions: ExecutionExtensions,
628 ) -> Result<ExecResult> {
629 let _ = extensions.insert(self.interpreter.limits().clone());
632 #[cfg(feature = "python")]
633 let _ = extensions.insert(builtins::PythonInprocessOptIn(self.python_inprocess_opt_in));
634 #[cfg(feature = "sqlite")]
635 let _ = extensions.insert(builtins::SqliteInprocessOptIn(self.sqlite_inprocess_opt_in));
636 let _extensions_guard = self.interpreter.scoped_execution_extensions(extensions);
637 self.exec_impl(script).await
638 }
639
640 async fn exec_impl(&mut self, script: &str) -> Result<ExecResult> {
641 self.interpreter.reset_transient_state();
643
644 let input_len = script.len();
647 if input_len > self.max_input_bytes {
648 #[cfg(feature = "logging")]
649 tracing::error!(
650 target: "bashkit::session",
651 input_len = input_len,
652 max_bytes = self.max_input_bytes,
653 "Script exceeds maximum input size"
654 );
655 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
656 input_len,
657 self.max_input_bytes,
658 )));
659 }
660
661 #[cfg(feature = "logging")]
664 {
665 let script_info = logging::format_script_for_log(script, &self.log_config);
666 tracing::info!(target: "bashkit::session", script = %script_info, "Starting script execution");
667 }
668
669 let script = if !self.interpreter.hooks().before_exec.is_empty() {
671 let input = hooks::ExecInput {
672 script: script.to_string(),
673 };
674 match self.interpreter.hooks().fire_before_exec(input) {
675 Some(modified) => std::borrow::Cow::Owned(modified.script),
676 None => {
677 return Ok(ExecResult::err("cancelled by before_exec hook", 1));
678 }
679 }
680 } else {
681 std::borrow::Cow::Borrowed(script)
682 };
683 let script = script.as_ref();
684
685 let input_len = script.len();
687 if input_len > self.max_input_bytes {
688 #[cfg(feature = "logging")]
689 tracing::error!(
690 target: "bashkit::session",
691 input_len = input_len,
692 max_bytes = self.max_input_bytes,
693 "Script exceeds maximum input size"
694 );
695 return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
696 input_len,
697 self.max_input_bytes,
698 )));
699 }
700
701 let parser_timeout = self.parser_timeout;
702 let max_ast_depth = self.max_ast_depth;
703 let max_parser_operations = self.max_parser_operations;
704 let script_owned = script.to_owned();
705
706 #[cfg(feature = "logging")]
707 tracing::debug!(
708 target: "bashkit::parser",
709 input_len = input_len,
710 max_ast_depth = max_ast_depth,
711 max_operations = max_parser_operations,
712 "Parsing script"
713 );
714
715 #[cfg(target_family = "wasm")]
718 let ast = {
719 let parser = Parser::with_limits_and_timeout(
720 &script_owned,
721 max_ast_depth,
722 max_parser_operations,
723 Some(parser_timeout),
724 );
725 parser.parse()?
726 };
727
728 #[cfg(not(target_family = "wasm"))]
731 let ast = {
732 let parse_result = tokio::time::timeout(parser_timeout, async {
733 tokio::task::spawn_blocking(move || {
734 let parser =
735 Parser::with_limits(&script_owned, max_ast_depth, max_parser_operations);
736 parser.parse()
737 })
738 .await
739 })
740 .await;
741
742 match parse_result {
743 Ok(Ok(result)) => {
744 match &result {
745 Ok(_) => {
746 #[cfg(feature = "logging")]
747 tracing::debug!(target: "bashkit::parser", "Parse completed successfully");
748 }
749 Err(_e) => {
750 #[cfg(feature = "logging")]
751 tracing::warn!(target: "bashkit::parser", error = %_e, "Parse error");
752 }
753 }
754 result?
755 }
756 Ok(Err(join_error)) => {
757 #[cfg(feature = "logging")]
758 tracing::error!(
759 target: "bashkit::parser",
760 error = %join_error,
761 "Parser task failed"
762 );
763 return Err(Error::parse(format!("parser task failed: {}", join_error)));
764 }
765 Err(_elapsed) => {
766 #[cfg(feature = "logging")]
767 tracing::error!(
768 target: "bashkit::parser",
769 timeout_ms = parser_timeout.as_millis() as u64,
770 "Parser timeout exceeded"
771 );
772 return Err(Error::ResourceLimit(LimitExceeded::ParserTimeout(
773 parser_timeout,
774 )));
775 }
776 }
777 };
778
779 #[cfg(feature = "logging")]
780 tracing::debug!(target: "bashkit::interpreter", "Starting interpretation");
781
782 parser::validate_budget(&ast, self.interpreter.limits())
784 .map_err(|e| Error::Execution(format!("budget validation failed: {e}")))?;
785
786 self.interpreter.load_history().await;
788
789 let exec_start = std::time::Instant::now();
790 let execution_timeout = self.interpreter.limits().timeout;
792 #[cfg(not(target_family = "wasm"))]
793 let result =
794 match tokio::time::timeout(execution_timeout, self.interpreter.execute(&ast)).await {
795 Ok(r) => r,
796 Err(_elapsed) => Err(Error::ResourceLimit(LimitExceeded::Timeout(
797 execution_timeout,
798 ))),
799 };
800 #[cfg(target_family = "wasm")]
801 let result = self.interpreter.execute(&ast).await;
802 self.interpreter.cleanup_proc_sub_files().await;
806 let duration_ms = exec_start.elapsed().as_millis() as u64;
807
808 if let Ok(ref exec_result) = result {
810 let cwd = self.interpreter.cwd().to_string_lossy().to_string();
811 let timestamp = chrono::Utc::now().timestamp();
812 for line in script.lines() {
813 let trimmed = line.trim();
814 if !trimmed.is_empty() && !trimmed.starts_with('#') {
815 self.interpreter.record_history(
816 trimmed.to_string(),
817 timestamp,
818 cwd.clone(),
819 exec_result.exit_code,
820 duration_ms,
821 );
822 }
823 }
824 self.interpreter.save_history().await;
826 }
827
828 #[cfg(feature = "logging")]
829 match &result {
830 Ok(exec_result) => {
831 tracing::info!(
832 target: "bashkit::session",
833 exit_code = exec_result.exit_code,
834 stdout_len = exec_result.stdout.len(),
835 stderr_len = exec_result.stderr.len(),
836 "Script execution completed"
837 );
838 }
839 Err(e) => {
840 let error = logging::format_error_for_log(&e.to_string(), &self.log_config);
841 tracing::error!(
842 target: "bashkit::session",
843 error = %error,
844 "Script execution failed"
845 );
846 }
847 }
848
849 if let Ok(ref exec_result) = result
851 && !self.interpreter.hooks().after_exec.is_empty()
852 {
853 let output = hooks::ExecOutput {
854 script: script.to_string(),
855 stdout: exec_result.stdout.clone(),
856 stderr: exec_result.stderr.clone(),
857 exit_code: exec_result.exit_code,
858 };
859 self.interpreter.hooks().fire_after_exec(output);
860 }
861
862 if let Err(ref e) = result
864 && !self.interpreter.hooks().on_error.is_empty()
865 {
866 let error_event = hooks::ErrorEvent {
867 message: e.to_string(),
868 };
869 self.interpreter.hooks().fire_on_error(error_event);
870 }
871
872 result
873 }
874
875 pub async fn exec_streaming(
906 &mut self,
907 script: &str,
908 output_callback: OutputCallback,
909 ) -> Result<ExecResult> {
910 self.exec_streaming_with_extensions(script, output_callback, ExecutionExtensions::new())
911 .await
912 }
913
914 pub async fn exec_streaming_with_extensions(
916 &mut self,
917 script: &str,
918 output_callback: OutputCallback,
919 extensions: ExecutionExtensions,
920 ) -> Result<ExecResult> {
921 self.interpreter.set_output_callback(output_callback);
922 let result = self.exec_with_extensions(script, extensions).await;
923 self.interpreter.clear_output_callback();
924 result
925 }
926
927 pub fn cancellation_token(&self) -> Arc<std::sync::atomic::AtomicBool> {
935 self.interpreter.cancellation_token()
936 }
937
938 pub fn hooks(&self) -> &hooks::Hooks {
948 self.interpreter.hooks()
949 }
950
951 pub fn fs(&self) -> Arc<dyn FileSystem> {
984 Arc::clone(&self.fs)
985 }
986
987 pub fn mount(
1029 &self,
1030 vfs_path: impl AsRef<std::path::Path>,
1031 fs: Arc<dyn FileSystem>,
1032 ) -> Result<()> {
1033 self.mountable.mount(vfs_path, fs)
1034 }
1035
1036 pub fn unmount(&self, vfs_path: impl AsRef<std::path::Path>) -> Result<()> {
1069 self.mountable.unmount(vfs_path)
1070 }
1071
1072 pub fn shell_state(&self) -> ShellState {
1098 self.interpreter.shell_state()
1099 }
1100
1101 pub fn shell_state_view(&self) -> ShellStateView {
1107 self.interpreter.shell_state_view()
1108 }
1109
1110 pub fn restore_shell_state(&mut self, state: &ShellState) {
1115 self.interpreter.restore_shell_state(state);
1116 }
1117
1118 pub fn session_counters(&self) -> (u64, u64) {
1122 let c = self.interpreter.counters();
1123 (c.session_commands, c.session_exec_calls)
1124 }
1125
1126 pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
1131 self.interpreter
1132 .restore_session_counters(session_commands, session_exec_calls);
1133 }
1134}
1135
1136struct MountedFile {
1173 path: PathBuf,
1174 content: String,
1175 mode: u32,
1176}
1177
1178struct MountedLazyFile {
1179 path: PathBuf,
1180 size_hint: u64,
1181 mode: u32,
1182 loader: LazyLoader,
1183}
1184
1185#[cfg(feature = "realfs")]
1187struct MountedRealDir {
1188 host_path: PathBuf,
1190 vfs_mount: Option<PathBuf>,
1192 mode: fs::RealFsMode,
1194}
1195
1196#[derive(Default)]
1197pub struct BashBuilder {
1198 fs: Option<Arc<dyn FileSystem>>,
1199 env: HashMap<String, String>,
1200 cwd: Option<PathBuf>,
1201 limits: ExecutionLimits,
1202 session_limits: SessionLimits,
1203 memory_limits: MemoryLimits,
1204 trace_mode: TraceMode,
1205 trace_callback: Option<TraceCallback>,
1206 username: Option<String>,
1207 hostname: Option<String>,
1208 fixed_epoch: Option<i64>,
1210 shell_profile: interpreter::ShellProfile,
1211 custom_builtins: HashMap<String, Box<dyn Builtin>>,
1212 mounted_files: Vec<MountedFile>,
1214 mounted_lazy_files: Vec<MountedLazyFile>,
1216 #[cfg(feature = "http_client")]
1218 network_allowlist: Option<NetworkAllowlist>,
1219 #[cfg(feature = "http_client")]
1221 http_handler: Option<Box<dyn network::HttpHandler>>,
1222 #[cfg(feature = "bot-auth")]
1224 bot_auth_config: Option<network::BotAuthConfig>,
1225 #[cfg(feature = "logging")]
1227 log_config: Option<logging::LogConfig>,
1228 #[cfg(feature = "git")]
1230 git_config: Option<GitConfig>,
1231 #[cfg(feature = "ssh")]
1233 ssh_config: Option<SshConfig>,
1234 #[cfg(feature = "ssh")]
1236 ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
1237 #[cfg(feature = "realfs")]
1239 real_mounts: Vec<MountedRealDir>,
1240 #[cfg(feature = "realfs")]
1243 mount_path_allowlist: Option<Vec<PathBuf>>,
1244 history_file: Option<PathBuf>,
1246 hooks_on_exit: Vec<hooks::Interceptor<hooks::ExitEvent>>,
1248 hooks_before_exec: Vec<hooks::Interceptor<hooks::ExecInput>>,
1249 hooks_after_exec: Vec<hooks::Interceptor<hooks::ExecOutput>>,
1250 hooks_before_tool: Vec<hooks::Interceptor<hooks::ToolEvent>>,
1251 hooks_after_tool: Vec<hooks::Interceptor<hooks::ToolResult>>,
1252 hooks_on_error: Vec<hooks::Interceptor<hooks::ErrorEvent>>,
1253 #[cfg(feature = "http_client")]
1254 hooks_before_http: Vec<hooks::Interceptor<hooks::HttpRequestEvent>>,
1255 #[cfg(feature = "http_client")]
1256 hooks_after_http: Vec<hooks::Interceptor<hooks::HttpResponseEvent>>,
1257 #[cfg(feature = "http_client")]
1259 credential_policy: Option<credential::CredentialPolicy>,
1260}
1261
1262impl BashBuilder {
1263 pub fn fs(mut self, fs: Arc<dyn FileSystem>) -> Self {
1265 self.fs = Some(fs);
1266 self
1267 }
1268
1269 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1271 self.env.insert(key.into(), value.into());
1272 self
1273 }
1274
1275 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
1277 self.cwd = Some(cwd.into());
1278 self
1279 }
1280
1281 pub fn limits(mut self, limits: ExecutionLimits) -> Self {
1283 self.limits = limits;
1284 self
1285 }
1286
1287 #[cfg(feature = "scripted_tool")]
1289 pub(crate) fn logic_only(mut self) -> Self {
1290 self.shell_profile = interpreter::ShellProfile::LogicOnly;
1291 self
1292 }
1293
1294 pub fn session_limits(mut self, limits: SessionLimits) -> Self {
1299 self.session_limits = limits;
1300 self
1301 }
1302
1303 pub fn memory_limits(mut self, limits: MemoryLimits) -> Self {
1308 self.memory_limits = limits;
1309 self
1310 }
1311
1312 pub fn max_memory(self, bytes: usize) -> Self {
1328 let defaults = MemoryLimits::default();
1329 self.memory_limits(
1330 MemoryLimits::new()
1331 .max_total_variable_bytes(bytes)
1332 .max_function_body_bytes(bytes.min(defaults.max_function_body_bytes)),
1333 )
1334 }
1335
1336 pub fn trace_mode(mut self, mode: TraceMode) -> Self {
1342 self.trace_mode = mode;
1343 self
1344 }
1345
1346 pub fn on_trace_event(mut self, callback: TraceCallback) -> Self {
1351 self.trace_callback = Some(callback);
1352 self
1353 }
1354
1355 pub fn username(mut self, username: impl Into<String>) -> Self {
1360 self.username = Some(username.into());
1361 self
1362 }
1363
1364 pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
1368 self.hostname = Some(hostname.into());
1369 self
1370 }
1371
1372 pub fn tty(mut self, fd: u32, is_terminal: bool) -> Self {
1386 if is_terminal {
1387 self.env.insert(format!("_TTY_{}", fd), "1".to_string());
1388 }
1389 self
1390 }
1391
1392 pub fn fixed_epoch(mut self, epoch: i64) -> Self {
1397 self.fixed_epoch = Some(epoch);
1398 self
1399 }
1400
1401 pub fn history_file(mut self, path: impl Into<PathBuf>) -> Self {
1406 self.history_file = Some(path.into());
1407 self
1408 }
1409
1410 #[cfg(feature = "http_client")]
1442 pub fn network(mut self, allowlist: NetworkAllowlist) -> Self {
1443 self.network_allowlist = Some(allowlist);
1444 self
1445 }
1446
1447 #[cfg(feature = "http_client")]
1487 pub fn http_handler(mut self, handler: Box<dyn network::HttpHandler>) -> Self {
1488 self.http_handler = Some(handler);
1489 self
1490 }
1491
1492 #[cfg(feature = "bot-auth")]
1513 pub fn bot_auth(mut self, config: network::BotAuthConfig) -> Self {
1514 self.bot_auth_config = Some(config);
1515 self
1516 }
1517
1518 #[cfg(feature = "logging")]
1555 pub fn log_config(mut self, config: logging::LogConfig) -> Self {
1556 self.log_config = Some(config);
1557 self
1558 }
1559
1560 #[cfg(feature = "git")]
1589 pub fn git(mut self, config: GitConfig) -> Self {
1590 self.git_config = Some(config);
1591 self
1592 }
1593
1594 #[cfg(feature = "ssh")]
1614 pub fn ssh(mut self, config: SshConfig) -> Self {
1615 self.ssh_config = Some(config);
1616 self
1617 }
1618
1619 #[cfg(feature = "ssh")]
1625 pub fn ssh_handler(mut self, handler: Box<dyn builtins::ssh::SshHandler>) -> Self {
1626 self.ssh_handler = Some(handler);
1627 self
1628 }
1629
1630 #[cfg(feature = "python")]
1649 pub fn python(self) -> Self {
1650 self.python_with_limits(builtins::PythonLimits::default())
1651 }
1652
1653 #[cfg(feature = "sqlite")]
1672 pub fn sqlite(self) -> Self {
1673 self.sqlite_with_limits(builtins::SqliteLimits::default())
1674 }
1675
1676 #[cfg(feature = "sqlite")]
1696 pub fn sqlite_with_limits(self, limits: builtins::SqliteLimits) -> Self {
1697 self.builtin(
1698 "sqlite",
1699 Box::new(builtins::Sqlite::with_limits(limits.clone())),
1700 )
1701 .builtin("sqlite3", Box::new(builtins::Sqlite::with_limits(limits)))
1702 }
1703
1704 #[cfg(feature = "python")]
1719 pub fn python_with_limits(self, limits: builtins::PythonLimits) -> Self {
1720 self.builtin(
1721 "python",
1722 Box::new(builtins::Python::with_limits(limits.clone())),
1723 )
1724 .builtin("python3", Box::new(builtins::Python::with_limits(limits)))
1725 }
1726
1727 #[cfg(feature = "python")]
1731 pub fn python_with_external_handler(
1732 self,
1733 limits: builtins::PythonLimits,
1734 external_fns: Vec<String>,
1735 handler: builtins::PythonExternalFnHandler,
1736 ) -> Self {
1737 self.builtin(
1738 "python",
1739 Box::new(
1740 builtins::Python::with_limits(limits.clone())
1741 .with_external_handler(external_fns.clone(), handler.clone()),
1742 ),
1743 )
1744 .builtin(
1745 "python3",
1746 Box::new(
1747 builtins::Python::with_limits(limits).with_external_handler(external_fns, handler),
1748 ),
1749 )
1750 }
1751
1752 #[cfg(feature = "typescript")]
1764 pub fn typescript(self) -> Self {
1765 self.typescript_with_config(builtins::TypeScriptConfig::default())
1766 }
1767
1768 #[cfg(feature = "typescript")]
1772 pub fn typescript_with_limits(self, limits: builtins::TypeScriptLimits) -> Self {
1773 self.typescript_with_config(builtins::TypeScriptConfig::default().limits(limits))
1774 }
1775
1776 #[cfg(feature = "typescript")]
1804 pub fn typescript_with_config(self, config: builtins::TypeScriptConfig) -> Self {
1805 self.extension(builtins::TypeScriptExtension::with_config(config))
1806 }
1807
1808 #[cfg(feature = "typescript")]
1812 pub fn typescript_with_external_handler(
1813 self,
1814 limits: builtins::TypeScriptLimits,
1815 external_fns: Vec<String>,
1816 handler: builtins::TypeScriptExternalFnHandler,
1817 ) -> Self {
1818 self.extension(builtins::TypeScriptExtension::with_external_handler(
1819 limits,
1820 external_fns,
1821 handler,
1822 ))
1823 }
1824
1825 pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
1862 self.custom_builtins.insert(name.into(), builtin);
1863 self
1864 }
1865
1866 pub fn extension<E>(mut self, extension: E) -> Self
1898 where
1899 E: builtins::Extension,
1900 {
1901 for (name, builtin) in extension.builtins() {
1902 self.custom_builtins.insert(name, builtin);
1903 }
1904 self
1905 }
1906
1907 pub fn on_exit(mut self, hook: hooks::Interceptor<hooks::ExitEvent>) -> Self {
1930 self.hooks_on_exit.push(hook);
1931 self
1932 }
1933
1934 pub fn before_exec(mut self, hook: hooks::Interceptor<hooks::ExecInput>) -> Self {
1939 self.hooks_before_exec.push(hook);
1940 self
1941 }
1942
1943 pub fn after_exec(mut self, hook: hooks::Interceptor<hooks::ExecOutput>) -> Self {
1948 self.hooks_after_exec.push(hook);
1949 self
1950 }
1951
1952 pub fn before_tool(mut self, hook: hooks::Interceptor<hooks::ToolEvent>) -> Self {
1957 self.hooks_before_tool.push(hook);
1958 self
1959 }
1960
1961 pub fn after_tool(mut self, hook: hooks::Interceptor<hooks::ToolResult>) -> Self {
1965 self.hooks_after_tool.push(hook);
1966 self
1967 }
1968
1969 pub fn on_error(mut self, hook: hooks::Interceptor<hooks::ErrorEvent>) -> Self {
1973 self.hooks_on_error.push(hook);
1974 self
1975 }
1976
1977 #[cfg(feature = "http_client")]
1998 pub fn before_http(mut self, hook: hooks::Interceptor<hooks::HttpRequestEvent>) -> Self {
1999 self.hooks_before_http.push(hook);
2000 self
2001 }
2002
2003 #[cfg(feature = "http_client")]
2008 pub fn after_http(mut self, hook: hooks::Interceptor<hooks::HttpResponseEvent>) -> Self {
2009 self.hooks_after_http.push(hook);
2010 self
2011 }
2012
2013 #[cfg(feature = "http_client")]
2040 pub fn credential(mut self, pattern: &str, cred: credential::Credential) -> Self {
2041 self.credential_policy
2042 .get_or_insert_with(credential::CredentialPolicy::new)
2043 .add_injection(pattern, cred);
2044 self
2045 }
2046
2047 #[cfg(feature = "http_client")]
2076 pub fn credential_placeholder(
2077 mut self,
2078 env_name: &str,
2079 pattern: &str,
2080 cred: credential::Credential,
2081 ) -> Self {
2082 let placeholder = self
2083 .credential_policy
2084 .get_or_insert_with(credential::CredentialPolicy::new)
2085 .add_placeholder(pattern, cred);
2086 self.env.insert(env_name.to_string(), placeholder);
2087 self
2088 }
2089
2090 pub fn mount_text(mut self, path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
2119 self.mounted_files.push(MountedFile {
2120 path: path.into(),
2121 content: content.into(),
2122 mode: 0o644,
2123 });
2124 self
2125 }
2126
2127 pub fn mount_readonly_text(
2166 mut self,
2167 path: impl Into<PathBuf>,
2168 content: impl Into<String>,
2169 ) -> Self {
2170 self.mounted_files.push(MountedFile {
2171 path: path.into(),
2172 content: content.into(),
2173 mode: 0o444,
2174 });
2175 self
2176 }
2177
2178 pub fn mount_lazy(
2204 mut self,
2205 path: impl Into<PathBuf>,
2206 size_hint: u64,
2207 loader: LazyLoader,
2208 ) -> Self {
2209 self.mounted_lazy_files.push(MountedLazyFile {
2210 path: path.into(),
2211 size_hint,
2212 mode: 0o644,
2213 loader,
2214 });
2215 self
2216 }
2217
2218 #[cfg(feature = "realfs")]
2236 pub fn mount_real_readonly(mut self, host_path: impl Into<PathBuf>) -> Self {
2237 self.real_mounts.push(MountedRealDir {
2238 host_path: host_path.into(),
2239 vfs_mount: None,
2240 mode: fs::RealFsMode::ReadOnly,
2241 });
2242 self
2243 }
2244
2245 #[cfg(feature = "realfs")]
2263 pub fn mount_real_readonly_at(
2264 mut self,
2265 host_path: impl Into<PathBuf>,
2266 vfs_mount: impl Into<PathBuf>,
2267 ) -> Self {
2268 self.real_mounts.push(MountedRealDir {
2269 host_path: host_path.into(),
2270 vfs_mount: Some(vfs_mount.into()),
2271 mode: fs::RealFsMode::ReadOnly,
2272 });
2273 self
2274 }
2275
2276 #[cfg(feature = "realfs")]
2293 pub fn mount_real_readwrite(mut self, host_path: impl Into<PathBuf>) -> Self {
2294 self.real_mounts.push(MountedRealDir {
2295 host_path: host_path.into(),
2296 vfs_mount: None,
2297 mode: fs::RealFsMode::ReadWrite,
2298 });
2299 self
2300 }
2301
2302 #[cfg(feature = "realfs")]
2317 pub fn mount_real_readwrite_at(
2318 mut self,
2319 host_path: impl Into<PathBuf>,
2320 vfs_mount: impl Into<PathBuf>,
2321 ) -> Self {
2322 self.real_mounts.push(MountedRealDir {
2323 host_path: host_path.into(),
2324 vfs_mount: Some(vfs_mount.into()),
2325 mode: fs::RealFsMode::ReadWrite,
2326 });
2327 self
2328 }
2329
2330 #[cfg(feature = "realfs")]
2346 pub fn allowed_mount_paths(
2347 mut self,
2348 paths: impl IntoIterator<Item = impl Into<PathBuf>>,
2349 ) -> Self {
2350 self.mount_path_allowlist = Some(paths.into_iter().map(|p| p.into()).collect());
2351 self
2352 }
2353
2354 pub fn build(self) -> Bash {
2389 let base_fs: Arc<dyn FileSystem> = if self.shell_profile.is_logic_only() {
2390 Arc::new(fs::DisabledFs)
2391 } else {
2392 self.fs.unwrap_or_else(|| Arc::new(InMemoryFs::new()))
2393 };
2394
2395 #[cfg(feature = "realfs")]
2397 let base_fs = Self::apply_real_mounts(
2398 &self.real_mounts,
2399 self.mount_path_allowlist.as_deref(),
2400 base_fs,
2401 );
2402
2403 let has_mounts = !self.mounted_files.is_empty() || !self.mounted_lazy_files.is_empty();
2405 let base_fs: Arc<dyn FileSystem> = if has_mounts {
2406 let overlay = OverlayFs::with_limits(base_fs.clone(), base_fs.limits());
2407 for mf in &self.mounted_files {
2408 overlay.upper().add_file(&mf.path, &mf.content, mf.mode);
2409 }
2410 for lf in self.mounted_lazy_files {
2411 overlay
2412 .upper()
2413 .add_lazy_file(&lf.path, lf.size_hint, lf.mode, lf.loader);
2414 }
2415 Arc::new(overlay)
2416 } else {
2417 base_fs
2418 };
2419
2420 let mountable = Arc::new(MountableFs::new(base_fs));
2422 let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
2423
2424 let mut result = Self::build_with_fs(
2425 fs,
2426 mountable,
2427 self.env,
2428 self.username,
2429 self.hostname,
2430 self.fixed_epoch,
2431 self.cwd,
2432 self.shell_profile,
2433 self.limits,
2434 self.session_limits,
2435 self.memory_limits,
2436 self.trace_mode,
2437 self.trace_callback,
2438 self.custom_builtins,
2439 self.history_file,
2440 #[cfg(feature = "http_client")]
2441 self.network_allowlist,
2442 #[cfg(feature = "http_client")]
2443 self.http_handler,
2444 #[cfg(feature = "bot-auth")]
2445 self.bot_auth_config,
2446 #[cfg(feature = "logging")]
2447 self.log_config,
2448 #[cfg(feature = "git")]
2449 self.git_config,
2450 #[cfg(feature = "ssh")]
2451 self.ssh_config,
2452 #[cfg(feature = "ssh")]
2453 self.ssh_handler,
2454 );
2455
2456 let hooks = hooks::Hooks {
2458 on_exit: self.hooks_on_exit,
2459 before_exec: self.hooks_before_exec,
2460 after_exec: self.hooks_after_exec,
2461 before_tool: self.hooks_before_tool,
2462 after_tool: self.hooks_after_tool,
2463 on_error: self.hooks_on_error,
2464 };
2465 if hooks.has_hooks() {
2466 result.interpreter.set_hooks(hooks);
2467 }
2468
2469 #[cfg(feature = "http_client")]
2472 let mut hooks_before_http = Vec::new();
2473 #[cfg(feature = "http_client")]
2474 if let Some(policy) = self.credential_policy
2475 && !policy.is_empty()
2476 {
2477 hooks_before_http.push(policy.into_hook());
2478 }
2479 #[cfg(feature = "http_client")]
2480 hooks_before_http.extend(self.hooks_before_http);
2481
2482 #[cfg(feature = "http_client")]
2484 if (!hooks_before_http.is_empty() || !self.hooks_after_http.is_empty())
2485 && let Some(client) = result.interpreter.http_client_mut()
2486 {
2487 if !hooks_before_http.is_empty() {
2488 client.set_before_http(hooks_before_http);
2489 }
2490 if !self.hooks_after_http.is_empty() {
2491 client.set_after_http(self.hooks_after_http);
2492 }
2493 }
2494
2495 result
2496 }
2497
2498 #[cfg(feature = "realfs")]
2500 const SENSITIVE_MOUNT_PATHS: &[&str] = &["/etc/shadow", "/etc/sudoers", "/proc", "/sys"];
2501
2502 #[cfg(feature = "realfs")]
2503 fn apply_real_mounts(
2504 real_mounts: &[MountedRealDir],
2505 mount_allowlist: Option<&[PathBuf]>,
2506 base_fs: Arc<dyn FileSystem>,
2507 ) -> Arc<dyn FileSystem> {
2508 if real_mounts.is_empty() {
2509 return base_fs;
2510 }
2511
2512 let mut current_fs = base_fs;
2513 let mut mount_points: Vec<(PathBuf, Arc<dyn FileSystem>)> = Vec::new();
2514 let canonical_allowlist: Option<Vec<PathBuf>> = mount_allowlist.map(|allowlist| {
2515 allowlist
2516 .iter()
2517 .filter_map(|allowed| match std::fs::canonicalize(allowed) {
2518 Ok(path) => Some(path),
2519 Err(e) => {
2520 eprintln!(
2521 "bashkit: warning: failed to canonicalize allowlist path {}: {}",
2522 allowed.display(),
2523 e
2524 );
2525 None
2526 }
2527 })
2528 .collect()
2529 });
2530
2531 for m in real_mounts {
2532 if m.mode == fs::RealFsMode::ReadWrite {
2534 eprintln!(
2535 "bashkit: warning: writable mount at {} — scripts can modify host files",
2536 m.host_path.display()
2537 );
2538 }
2539
2540 let canonical_host = match std::fs::canonicalize(&m.host_path) {
2541 Ok(path) => path,
2542 Err(e) => {
2543 eprintln!(
2544 "bashkit: warning: failed to canonicalize mount path {}: {}",
2545 m.host_path.display(),
2546 e
2547 );
2548 continue;
2549 }
2550 };
2551
2552 if Self::SENSITIVE_MOUNT_PATHS
2554 .iter()
2555 .any(|s| canonical_host.starts_with(Path::new(s)))
2556 {
2557 eprintln!(
2558 "bashkit: warning: refusing to mount sensitive path {}",
2559 m.host_path.display()
2560 );
2561 continue;
2562 }
2563
2564 if let Some(allowlist) = &canonical_allowlist
2566 && !allowlist
2567 .iter()
2568 .any(|allowed| canonical_host.starts_with(allowed))
2569 {
2570 eprintln!(
2571 "bashkit: warning: mount path {} not in allowlist, skipping",
2572 m.host_path.display()
2573 );
2574 continue;
2575 }
2576
2577 let real_backend = match fs::RealFs::new(&canonical_host, m.mode) {
2578 Ok(b) => b,
2579 Err(e) => {
2580 eprintln!(
2581 "bashkit: warning: failed to mount {}: {}",
2582 m.host_path.display(),
2583 e
2584 );
2585 continue;
2586 }
2587 };
2588 let real_fs: Arc<dyn FileSystem> = Arc::new(PosixFs::new(real_backend));
2589
2590 match &m.vfs_mount {
2591 None => {
2592 current_fs = Arc::new(OverlayFs::new(real_fs));
2595 }
2596 Some(mount_point) => {
2597 mount_points.push((mount_point.clone(), real_fs));
2598 }
2599 }
2600 }
2601
2602 if !mount_points.is_empty() {
2604 let mountable = MountableFs::new(current_fs);
2605 for (path, fs) in mount_points {
2606 if let Err(e) = mountable.mount(&path, fs) {
2607 eprintln!(
2608 "bashkit: warning: failed to mount at {}: {}",
2609 path.display(),
2610 e
2611 );
2612 }
2613 }
2614 Arc::new(mountable)
2615 } else {
2616 current_fs
2617 }
2618 }
2619
2620 #[allow(clippy::too_many_arguments)]
2622 fn build_with_fs(
2623 fs: Arc<dyn FileSystem>,
2624 mountable: Arc<MountableFs>,
2625 env: HashMap<String, String>,
2626 username: Option<String>,
2627 hostname: Option<String>,
2628 fixed_epoch: Option<i64>,
2629 cwd: Option<PathBuf>,
2630 shell_profile: interpreter::ShellProfile,
2631 limits: ExecutionLimits,
2632 session_limits: SessionLimits,
2633 memory_limits: MemoryLimits,
2634 trace_mode: TraceMode,
2635 trace_callback: Option<TraceCallback>,
2636 custom_builtins: HashMap<String, Box<dyn Builtin>>,
2637 history_file: Option<PathBuf>,
2638 #[cfg(feature = "http_client")] network_allowlist: Option<NetworkAllowlist>,
2639 #[cfg(feature = "http_client")] http_handler: Option<Box<dyn network::HttpHandler>>,
2640 #[cfg(feature = "bot-auth")] bot_auth_config: Option<network::BotAuthConfig>,
2641 #[cfg(feature = "logging")] log_config: Option<logging::LogConfig>,
2642 #[cfg(feature = "git")] git_config: Option<GitConfig>,
2643 #[cfg(feature = "ssh")] ssh_config: Option<SshConfig>,
2644 #[cfg(feature = "ssh")] ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
2645 ) -> Bash {
2646 #[cfg(feature = "logging")]
2647 let log_config = log_config.unwrap_or_default();
2648
2649 #[cfg(feature = "logging")]
2650 tracing::debug!(
2651 target: "bashkit::config",
2652 redact_sensitive = log_config.redact_sensitive,
2653 log_scripts = log_config.log_script_content,
2654 "Bash instance configured"
2655 );
2656
2657 let mut interpreter = Interpreter::with_config(
2658 Arc::clone(&fs),
2659 username.clone(),
2660 hostname,
2661 fixed_epoch,
2662 custom_builtins,
2663 shell_profile,
2664 );
2665
2666 for (key, value) in &env {
2668 interpreter.set_env(key, value);
2669 interpreter.set_var(key, value);
2672 }
2673 #[cfg(feature = "python")]
2674 let python_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_PYTHON");
2675 #[cfg(feature = "sqlite")]
2676 let sqlite_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_SQLITE");
2677 drop(env);
2678
2679 if let Some(ref username) = username {
2681 interpreter.set_env("USER", username);
2682 interpreter.set_var("USER", username);
2683 }
2684
2685 if let Some(cwd) = cwd {
2686 interpreter.set_cwd(cwd);
2687 }
2688
2689 #[cfg(feature = "http_client")]
2691 if let Some(allowlist) = network_allowlist {
2692 let mut client = network::HttpClient::new(allowlist);
2693 if let Some(handler) = http_handler {
2694 client.set_handler(handler);
2695 }
2696 #[cfg(feature = "bot-auth")]
2697 if let Some(bot_auth) = bot_auth_config {
2698 client.set_bot_auth(bot_auth);
2699 }
2700 interpreter.set_http_client(client);
2701 }
2702
2703 #[cfg(feature = "git")]
2705 if let Some(config) = git_config {
2706 let client = builtins::git::GitClient::new(config);
2707 interpreter.set_git_client(client);
2708 }
2709
2710 #[cfg(feature = "ssh")]
2712 if let Some(config) = ssh_config {
2713 let mut client = builtins::ssh::SshClient::new(config);
2714 if let Some(handler) = ssh_handler {
2715 client.set_handler(handler);
2716 }
2717 interpreter.set_ssh_client(client);
2718 }
2719
2720 if let Some(hf) = history_file {
2722 interpreter.set_history_file(hf);
2723 }
2724
2725 let parser_timeout = limits.parser_timeout;
2726 let max_input_bytes = limits.max_input_bytes;
2727 let max_ast_depth = limits.max_ast_depth;
2728 let max_parser_operations = limits.max_parser_operations;
2729 interpreter.set_limits(limits);
2730 interpreter.set_session_limits(session_limits);
2731 interpreter.set_memory_limits(memory_limits);
2732 let mut trace_collector = TraceCollector::new(trace_mode);
2733 if let Some(cb) = trace_callback {
2734 trace_collector.set_callback(cb);
2735 }
2736 interpreter.set_trace(trace_collector);
2737 Bash {
2738 fs,
2739 mountable,
2740 interpreter,
2741 parser_timeout,
2742 max_input_bytes,
2743 max_ast_depth,
2744 max_parser_operations,
2745 #[cfg(feature = "logging")]
2746 log_config,
2747 #[cfg(feature = "python")]
2748 python_inprocess_opt_in,
2749 #[cfg(feature = "sqlite")]
2750 sqlite_inprocess_opt_in,
2751 }
2752 }
2753}
2754
2755#[cfg(feature = "http_client")]
2771#[doc = include_str!("../docs/credential-injection.md")]
2772pub mod credential_injection_guide {}
2773
2774#[doc = include_str!("../docs/custom_builtins.md")]
2784pub mod custom_builtins_guide {}
2785
2786#[doc = include_str!("../docs/clap-builtins.md")]
2796pub mod clap_builtins_guide {}
2797
2798#[doc = include_str!("../docs/compatibility.md")]
2808pub mod compatibility_scorecard {}
2809
2810#[doc = include_str!("../docs/jq.md")]
2820pub mod jq_guide {}
2821
2822#[doc = include_str!("../docs/threat-model.md")]
2836pub mod threat_model {}
2837
2838#[cfg(feature = "python")]
2852#[doc = include_str!("../docs/python.md")]
2853pub mod python_guide {}
2854
2855#[cfg(feature = "sqlite")]
2867#[doc = include_str!("../docs/sqlite.md")]
2868pub mod sqlite_guide {}
2869
2870#[cfg(feature = "typescript")]
2882#[doc = include_str!("../docs/typescript.md")]
2883pub mod typescript_guide {}
2884
2885#[cfg(feature = "ssh")]
2889#[doc = include_str!("../docs/ssh.md")]
2890pub mod ssh_guide {}
2891
2892#[doc = include_str!("../docs/live_mounts.md")]
2902pub mod live_mounts_guide {}
2903
2904#[cfg(feature = "logging")]
2917#[doc = include_str!("../docs/logging.md")]
2918pub mod logging_guide {}
2919
2920#[doc = include_str!("../docs/hooks.md")]
2935pub mod hooks_guide {}
2936
2937#[cfg(test)]
2938mod tests {
2939 use super::*;
2940 use std::sync::{Arc, Mutex};
2941
2942 #[tokio::test]
2943 async fn test_echo_hello() {
2944 let mut bash = Bash::new();
2945 let result = bash.exec("echo hello").await.unwrap();
2946 assert_eq!(result.stdout, "hello\n");
2947 assert_eq!(result.exit_code, 0);
2948 }
2949
2950 #[tokio::test]
2951 async fn test_echo_multiple_args() {
2952 let mut bash = Bash::new();
2953 let result = bash.exec("echo hello world").await.unwrap();
2954 assert_eq!(result.stdout, "hello world\n");
2955 assert_eq!(result.exit_code, 0);
2956 }
2957
2958 #[tokio::test]
2959 async fn test_variable_expansion() {
2960 let mut bash = Bash::builder().env("HOME", "/home/user").build();
2961 let result = bash.exec("echo $HOME").await.unwrap();
2962 assert_eq!(result.stdout, "/home/user\n");
2963 assert_eq!(result.exit_code, 0);
2964 }
2965
2966 #[tokio::test]
2967 async fn test_variable_brace_expansion() {
2968 let mut bash = Bash::builder().env("USER", "testuser").build();
2969 let result = bash.exec("echo ${USER}").await.unwrap();
2970 assert_eq!(result.stdout, "testuser\n");
2971 }
2972
2973 #[tokio::test]
2974 async fn test_undefined_variable_expands_to_empty() {
2975 let mut bash = Bash::new();
2976 let result = bash.exec("echo $UNDEFINED_VAR").await.unwrap();
2977 assert_eq!(result.stdout, "\n");
2978 }
2979
2980 #[tokio::test]
2981 async fn test_pipeline() {
2982 let mut bash = Bash::new();
2983 let result = bash.exec("echo hello | cat").await.unwrap();
2984 assert_eq!(result.stdout, "hello\n");
2985 }
2986
2987 #[tokio::test]
2988 async fn test_pipeline_three_commands() {
2989 let mut bash = Bash::new();
2990 let result = bash.exec("echo hello | cat | cat").await.unwrap();
2991 assert_eq!(result.stdout, "hello\n");
2992 }
2993
2994 #[tokio::test]
2995 async fn test_redirect_output() {
2996 let mut bash = Bash::new();
2997 let result = bash.exec("echo hello > /tmp/test.txt").await.unwrap();
2998 assert_eq!(result.stdout, "");
2999 assert_eq!(result.exit_code, 0);
3000
3001 let result = bash.exec("cat /tmp/test.txt").await.unwrap();
3003 assert_eq!(result.stdout, "hello\n");
3004 }
3005
3006 #[tokio::test]
3007 async fn test_redirect_append() {
3008 let mut bash = Bash::new();
3009 bash.exec("echo hello > /tmp/append.txt").await.unwrap();
3010 bash.exec("echo world >> /tmp/append.txt").await.unwrap();
3011
3012 let result = bash.exec("cat /tmp/append.txt").await.unwrap();
3013 assert_eq!(result.stdout, "hello\nworld\n");
3014 }
3015
3016 #[tokio::test]
3017 async fn test_command_list_and() {
3018 let mut bash = Bash::new();
3019 let result = bash.exec("true && echo success").await.unwrap();
3020 assert_eq!(result.stdout, "success\n");
3021 }
3022
3023 #[tokio::test]
3024 async fn test_command_list_and_short_circuit() {
3025 let mut bash = Bash::new();
3026 let result = bash.exec("false && echo should_not_print").await.unwrap();
3027 assert_eq!(result.stdout, "");
3028 assert_eq!(result.exit_code, 1);
3029 }
3030
3031 #[tokio::test]
3032 async fn test_command_list_or() {
3033 let mut bash = Bash::new();
3034 let result = bash.exec("false || echo fallback").await.unwrap();
3035 assert_eq!(result.stdout, "fallback\n");
3036 }
3037
3038 #[tokio::test]
3039 async fn test_command_list_or_short_circuit() {
3040 let mut bash = Bash::new();
3041 let result = bash.exec("true || echo should_not_print").await.unwrap();
3042 assert_eq!(result.stdout, "");
3043 assert_eq!(result.exit_code, 0);
3044 }
3045
3046 #[tokio::test]
3048 async fn test_phase1_target() {
3049 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
3050
3051 let result = bash
3052 .exec("echo $HOME | cat > /tmp/out && cat /tmp/out")
3053 .await
3054 .unwrap();
3055
3056 assert_eq!(result.stdout, "/home/testuser\n");
3057 assert_eq!(result.exit_code, 0);
3058 }
3059
3060 #[tokio::test]
3061 async fn test_redirect_input() {
3062 let mut bash = Bash::new();
3063 bash.exec("echo hello > /tmp/input.txt").await.unwrap();
3065
3066 let result = bash.exec("cat < /tmp/input.txt").await.unwrap();
3068 assert_eq!(result.stdout, "hello\n");
3069 }
3070
3071 #[tokio::test]
3072 async fn test_here_string() {
3073 let mut bash = Bash::new();
3074 let result = bash.exec("cat <<< hello").await.unwrap();
3075 assert_eq!(result.stdout, "hello\n");
3076 }
3077
3078 #[tokio::test]
3079 async fn test_if_true() {
3080 let mut bash = Bash::new();
3081 let result = bash.exec("if true; then echo yes; fi").await.unwrap();
3082 assert_eq!(result.stdout, "yes\n");
3083 }
3084
3085 #[tokio::test]
3086 async fn test_if_false() {
3087 let mut bash = Bash::new();
3088 let result = bash.exec("if false; then echo yes; fi").await.unwrap();
3089 assert_eq!(result.stdout, "");
3090 }
3091
3092 #[tokio::test]
3093 async fn test_if_else() {
3094 let mut bash = Bash::new();
3095 let result = bash
3096 .exec("if false; then echo yes; else echo no; fi")
3097 .await
3098 .unwrap();
3099 assert_eq!(result.stdout, "no\n");
3100 }
3101
3102 #[tokio::test]
3103 async fn test_if_elif() {
3104 let mut bash = Bash::new();
3105 let result = bash
3106 .exec("if false; then echo one; elif true; then echo two; else echo three; fi")
3107 .await
3108 .unwrap();
3109 assert_eq!(result.stdout, "two\n");
3110 }
3111
3112 #[tokio::test]
3113 async fn test_for_loop() {
3114 let mut bash = Bash::new();
3115 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
3116 assert_eq!(result.stdout, "a\nb\nc\n");
3117 }
3118
3119 #[tokio::test]
3120 async fn test_for_loop_positional_params() {
3121 let mut bash = Bash::new();
3122 let result = bash
3124 .exec("f() { for x; do echo $x; done; }; f one two three")
3125 .await
3126 .unwrap();
3127 assert_eq!(result.stdout, "one\ntwo\nthree\n");
3128 }
3129
3130 #[tokio::test]
3131 async fn test_while_loop() {
3132 let mut bash = Bash::new();
3133 let result = bash.exec("while false; do echo loop; done").await.unwrap();
3135 assert_eq!(result.stdout, "");
3136 }
3137
3138 #[tokio::test]
3139 async fn test_subshell() {
3140 let mut bash = Bash::new();
3141 let result = bash.exec("(echo hello)").await.unwrap();
3142 assert_eq!(result.stdout, "hello\n");
3143 }
3144
3145 #[tokio::test]
3146 async fn test_brace_group() {
3147 let mut bash = Bash::new();
3148 let result = bash.exec("{ echo hello; }").await.unwrap();
3149 assert_eq!(result.stdout, "hello\n");
3150 }
3151
3152 #[tokio::test]
3153 async fn test_function_keyword() {
3154 let mut bash = Bash::new();
3155 let result = bash
3156 .exec("function greet { echo hello; }; greet")
3157 .await
3158 .unwrap();
3159 assert_eq!(result.stdout, "hello\n");
3160 }
3161
3162 #[tokio::test]
3163 async fn test_function_posix() {
3164 let mut bash = Bash::new();
3165 let result = bash.exec("greet() { echo hello; }; greet").await.unwrap();
3166 assert_eq!(result.stdout, "hello\n");
3167 }
3168
3169 #[tokio::test]
3170 async fn test_function_args() {
3171 let mut bash = Bash::new();
3172 let result = bash
3173 .exec("greet() { echo $1 $2; }; greet world foo")
3174 .await
3175 .unwrap();
3176 assert_eq!(result.stdout, "world foo\n");
3177 }
3178
3179 #[tokio::test]
3180 async fn test_function_arg_count() {
3181 let mut bash = Bash::new();
3182 let result = bash
3183 .exec("count() { echo $#; }; count a b c")
3184 .await
3185 .unwrap();
3186 assert_eq!(result.stdout, "3\n");
3187 }
3188
3189 #[tokio::test]
3190 async fn test_case_literal() {
3191 let mut bash = Bash::new();
3192 let result = bash
3193 .exec("case foo in foo) echo matched ;; esac")
3194 .await
3195 .unwrap();
3196 assert_eq!(result.stdout, "matched\n");
3197 }
3198
3199 #[tokio::test]
3200 async fn test_case_wildcard() {
3201 let mut bash = Bash::new();
3202 let result = bash
3203 .exec("case bar in *) echo default ;; esac")
3204 .await
3205 .unwrap();
3206 assert_eq!(result.stdout, "default\n");
3207 }
3208
3209 #[tokio::test]
3210 async fn test_case_no_match() {
3211 let mut bash = Bash::new();
3212 let result = bash.exec("case foo in bar) echo no ;; esac").await.unwrap();
3213 assert_eq!(result.stdout, "");
3214 }
3215
3216 #[tokio::test]
3217 async fn test_case_multiple_patterns() {
3218 let mut bash = Bash::new();
3219 let result = bash
3220 .exec("case foo in bar|foo|baz) echo matched ;; esac")
3221 .await
3222 .unwrap();
3223 assert_eq!(result.stdout, "matched\n");
3224 }
3225
3226 #[tokio::test]
3227 async fn test_case_bracket_expr() {
3228 let mut bash = Bash::new();
3229 let result = bash
3231 .exec("case b in [abc]) echo matched ;; esac")
3232 .await
3233 .unwrap();
3234 assert_eq!(result.stdout, "matched\n");
3235 }
3236
3237 #[tokio::test]
3238 async fn test_case_bracket_range() {
3239 let mut bash = Bash::new();
3240 let result = bash
3242 .exec("case m in [a-z]) echo letter ;; esac")
3243 .await
3244 .unwrap();
3245 assert_eq!(result.stdout, "letter\n");
3246 }
3247
3248 #[tokio::test]
3249 async fn test_case_bracket_wide_unicode_range() {
3250 let mut bash = Bash::new();
3251 let result = bash
3252 .exec("case z in [a-\u{10ffff}]) echo wide ;; esac")
3253 .await
3254 .unwrap();
3255 assert_eq!(result.stdout, "wide\n");
3256 }
3257
3258 #[tokio::test]
3259 async fn test_case_bracket_negation() {
3260 let mut bash = Bash::new();
3261 let result = bash
3263 .exec("case x in [!abc]) echo not_abc ;; esac")
3264 .await
3265 .unwrap();
3266 assert_eq!(result.stdout, "not_abc\n");
3267 }
3268
3269 #[tokio::test]
3270 async fn test_break_as_command() {
3271 let mut bash = Bash::new();
3272 let result = bash.exec("break").await.unwrap();
3274 assert_eq!(result.exit_code, 0);
3276 }
3277
3278 #[tokio::test]
3279 async fn test_for_one_item() {
3280 let mut bash = Bash::new();
3281 let result = bash.exec("for i in a; do echo $i; done").await.unwrap();
3283 assert_eq!(result.stdout, "a\n");
3284 }
3285
3286 #[tokio::test]
3287 async fn test_for_with_break() {
3288 let mut bash = Bash::new();
3289 let result = bash.exec("for i in a; do break; done").await.unwrap();
3291 assert_eq!(result.stdout, "");
3292 assert_eq!(result.exit_code, 0);
3293 }
3294
3295 #[tokio::test]
3296 async fn test_for_echo_break() {
3297 let mut bash = Bash::new();
3298 let result = bash
3300 .exec("for i in a b c; do echo $i; break; done")
3301 .await
3302 .unwrap();
3303 assert_eq!(result.stdout, "a\n");
3304 }
3305
3306 #[tokio::test]
3307 async fn test_test_string_empty() {
3308 let mut bash = Bash::new();
3309 let result = bash.exec("test -z '' && echo yes").await.unwrap();
3310 assert_eq!(result.stdout, "yes\n");
3311 }
3312
3313 #[tokio::test]
3314 async fn test_test_string_not_empty() {
3315 let mut bash = Bash::new();
3316 let result = bash.exec("test -n 'hello' && echo yes").await.unwrap();
3317 assert_eq!(result.stdout, "yes\n");
3318 }
3319
3320 #[tokio::test]
3321 async fn test_test_string_equal() {
3322 let mut bash = Bash::new();
3323 let result = bash.exec("test foo = foo && echo yes").await.unwrap();
3324 assert_eq!(result.stdout, "yes\n");
3325 }
3326
3327 #[tokio::test]
3328 async fn test_test_string_not_equal() {
3329 let mut bash = Bash::new();
3330 let result = bash.exec("test foo != bar && echo yes").await.unwrap();
3331 assert_eq!(result.stdout, "yes\n");
3332 }
3333
3334 #[tokio::test]
3335 async fn test_test_numeric_equal() {
3336 let mut bash = Bash::new();
3337 let result = bash.exec("test 5 -eq 5 && echo yes").await.unwrap();
3338 assert_eq!(result.stdout, "yes\n");
3339 }
3340
3341 #[tokio::test]
3342 async fn test_test_numeric_less_than() {
3343 let mut bash = Bash::new();
3344 let result = bash.exec("test 3 -lt 5 && echo yes").await.unwrap();
3345 assert_eq!(result.stdout, "yes\n");
3346 }
3347
3348 #[tokio::test]
3349 async fn test_bracket_form() {
3350 let mut bash = Bash::new();
3351 let result = bash.exec("[ foo = foo ] && echo yes").await.unwrap();
3352 assert_eq!(result.stdout, "yes\n");
3353 }
3354
3355 #[tokio::test]
3356 async fn test_if_with_test() {
3357 let mut bash = Bash::new();
3358 let result = bash
3359 .exec("if [ 5 -gt 3 ]; then echo bigger; fi")
3360 .await
3361 .unwrap();
3362 assert_eq!(result.stdout, "bigger\n");
3363 }
3364
3365 #[tokio::test]
3366 async fn test_variable_assignment() {
3367 let mut bash = Bash::new();
3368 let result = bash.exec("FOO=bar; echo $FOO").await.unwrap();
3369 assert_eq!(result.stdout, "bar\n");
3370 }
3371
3372 #[tokio::test]
3373 async fn test_variable_assignment_inline() {
3374 let mut bash = Bash::new();
3375 let result = bash.exec("MSG=hello; echo $MSG world").await.unwrap();
3377 assert_eq!(result.stdout, "hello world\n");
3378 }
3379
3380 #[tokio::test]
3381 async fn test_variable_assignment_only() {
3382 let mut bash = Bash::new();
3383 let result = bash.exec("FOO=bar").await.unwrap();
3385 assert_eq!(result.stdout, "");
3386 assert_eq!(result.exit_code, 0);
3387
3388 let result = bash.exec("echo $FOO").await.unwrap();
3390 assert_eq!(result.stdout, "bar\n");
3391 }
3392
3393 #[tokio::test]
3394 async fn test_multiple_assignments() {
3395 let mut bash = Bash::new();
3396 let result = bash.exec("A=1; B=2; C=3; echo $A $B $C").await.unwrap();
3397 assert_eq!(result.stdout, "1 2 3\n");
3398 }
3399
3400 #[tokio::test]
3401 async fn test_prefix_assignment_visible_in_env() {
3402 let mut bash = Bash::new();
3403 let result = bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
3405 assert_eq!(result.stdout, "hello\n");
3406 }
3407
3408 #[tokio::test]
3409 async fn test_prefix_assignment_temporary() {
3410 let mut bash = Bash::new();
3411 bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
3413 let result = bash.exec("echo ${MYVAR:-unset}").await.unwrap();
3414 assert_eq!(result.stdout, "unset\n");
3415 }
3416
3417 #[tokio::test]
3418 async fn test_prefix_assignment_duplicate_name_temporary() {
3419 let mut bash = Bash::new();
3420 let result = bash.exec("A=1 A=2 printenv A").await.unwrap();
3422 assert_eq!(result.stdout, "2\n");
3423 let result = bash.exec("echo ${A:-unset}").await.unwrap();
3424 assert_eq!(result.stdout, "unset\n");
3425 }
3426
3427 #[tokio::test]
3428 async fn test_prefix_assignment_does_not_clobber_existing_env() {
3429 let mut bash = Bash::new();
3430 let result = bash
3432 .exec("EXISTING=original; export EXISTING; EXISTING=temp printenv EXISTING")
3433 .await
3434 .unwrap();
3435 assert_eq!(result.stdout, "temp\n");
3436 }
3437
3438 #[tokio::test]
3439 async fn test_prefix_assignment_multiple_vars() {
3440 let mut bash = Bash::new();
3441 let result = bash.exec("A=one B=two printenv A").await.unwrap();
3443 assert_eq!(result.stdout, "one\n");
3444 assert_eq!(result.exit_code, 0);
3445 }
3446
3447 #[tokio::test]
3448 async fn test_prefix_assignment_empty_value() {
3449 let mut bash = Bash::new();
3450 let result = bash.exec("MYVAR= printenv MYVAR").await.unwrap();
3452 assert_eq!(result.stdout, "\n");
3453 assert_eq!(result.exit_code, 0);
3454 }
3455
3456 #[tokio::test]
3457 async fn test_prefix_assignment_not_found_without_prefix() {
3458 let mut bash = Bash::new();
3459 let result = bash.exec("printenv NONEXISTENT").await.unwrap();
3461 assert_eq!(result.stdout, "");
3462 assert_eq!(result.exit_code, 1);
3463 }
3464
3465 #[tokio::test]
3466 async fn test_prefix_assignment_does_not_persist_in_variables() {
3467 let mut bash = Bash::new();
3468 bash.exec("TMPVAR=gone echo ok").await.unwrap();
3470 let result = bash.exec("echo \"${TMPVAR:-unset}\"").await.unwrap();
3471 assert_eq!(result.stdout, "unset\n");
3472 }
3473
3474 #[tokio::test]
3475 async fn test_assignment_only_persists() {
3476 let mut bash = Bash::new();
3477 bash.exec("PERSIST=yes").await.unwrap();
3479 let result = bash.exec("echo $PERSIST").await.unwrap();
3480 assert_eq!(result.stdout, "yes\n");
3481 }
3482
3483 #[tokio::test]
3484 async fn test_printf_string() {
3485 let mut bash = Bash::new();
3486 let result = bash.exec("printf '%s' hello").await.unwrap();
3487 assert_eq!(result.stdout, "hello");
3488 }
3489
3490 #[tokio::test]
3491 async fn test_printf_newline() {
3492 let mut bash = Bash::new();
3493 let result = bash.exec("printf 'hello\\n'").await.unwrap();
3494 assert_eq!(result.stdout, "hello\n");
3495 }
3496
3497 #[tokio::test]
3498 async fn test_printf_multiple_args() {
3499 let mut bash = Bash::new();
3500 let result = bash.exec("printf '%s %s\\n' hello world").await.unwrap();
3501 assert_eq!(result.stdout, "hello world\n");
3502 }
3503
3504 #[tokio::test]
3505 async fn test_printf_integer() {
3506 let mut bash = Bash::new();
3507 let result = bash.exec("printf '%d' 42").await.unwrap();
3508 assert_eq!(result.stdout, "42");
3509 }
3510
3511 #[tokio::test]
3512 async fn test_export() {
3513 let mut bash = Bash::new();
3514 let result = bash.exec("export FOO=bar; echo $FOO").await.unwrap();
3515 assert_eq!(result.stdout, "bar\n");
3516 }
3517
3518 #[tokio::test]
3519 async fn test_read_basic() {
3520 let mut bash = Bash::new();
3521 let result = bash.exec("echo hello | read VAR; echo $VAR").await.unwrap();
3522 assert_eq!(result.stdout, "hello\n");
3523 }
3524
3525 #[tokio::test]
3526 async fn test_read_multiple_vars() {
3527 let mut bash = Bash::new();
3528 let result = bash
3529 .exec("echo 'a b c' | read X Y Z; echo $X $Y $Z")
3530 .await
3531 .unwrap();
3532 assert_eq!(result.stdout, "a b c\n");
3533 }
3534
3535 #[tokio::test]
3536 async fn test_read_respects_local_scope() {
3537 let mut bash = Bash::new();
3539 let result = bash
3540 .exec(
3541 r#"
3542fn() { local k; read -r k <<< "test"; echo "$k"; }
3543fn
3544"#,
3545 )
3546 .await
3547 .unwrap();
3548 assert_eq!(result.stdout, "test\n");
3549 }
3550
3551 #[tokio::test]
3552 async fn test_local_ifs_array_join() {
3553 let mut bash = Bash::new();
3555 let result = bash
3556 .exec(
3557 r#"
3558fn() {
3559 local arr=(a b c)
3560 local IFS=":"
3561 echo "${arr[*]}"
3562}
3563fn
3564"#,
3565 )
3566 .await
3567 .unwrap();
3568 assert_eq!(result.stdout, "a:b:c\n");
3569 }
3570
3571 #[tokio::test]
3572 async fn test_glob_star() {
3573 let mut bash = Bash::new();
3574 bash.exec("echo a > /tmp/file1.txt").await.unwrap();
3576 bash.exec("echo b > /tmp/file2.txt").await.unwrap();
3577 bash.exec("echo c > /tmp/other.log").await.unwrap();
3578
3579 let result = bash.exec("echo /tmp/*.txt").await.unwrap();
3581 assert_eq!(result.stdout, "/tmp/file1.txt /tmp/file2.txt\n");
3582 }
3583
3584 #[tokio::test]
3585 async fn test_glob_question_mark() {
3586 let mut bash = Bash::new();
3587 bash.exec("echo a > /tmp/a1.txt").await.unwrap();
3589 bash.exec("echo b > /tmp/a2.txt").await.unwrap();
3590 bash.exec("echo c > /tmp/a10.txt").await.unwrap();
3591
3592 let result = bash.exec("echo /tmp/a?.txt").await.unwrap();
3594 assert_eq!(result.stdout, "/tmp/a1.txt /tmp/a2.txt\n");
3595 }
3596
3597 #[tokio::test]
3598 async fn test_glob_no_match() {
3599 let mut bash = Bash::new();
3600 let result = bash.exec("echo /nonexistent/*.xyz").await.unwrap();
3602 assert_eq!(result.stdout, "/nonexistent/*.xyz\n");
3603 }
3604
3605 #[tokio::test]
3606 async fn test_command_substitution() {
3607 let mut bash = Bash::new();
3608 let result = bash.exec("echo $(echo hello)").await.unwrap();
3609 assert_eq!(result.stdout, "hello\n");
3610 }
3611
3612 #[tokio::test]
3613 async fn test_command_substitution_in_string() {
3614 let mut bash = Bash::new();
3615 let result = bash.exec("echo \"result: $(echo 42)\"").await.unwrap();
3616 assert_eq!(result.stdout, "result: 42\n");
3617 }
3618
3619 #[tokio::test]
3620 async fn test_command_substitution_pipeline() {
3621 let mut bash = Bash::new();
3622 let result = bash.exec("echo $(echo hello | cat)").await.unwrap();
3623 assert_eq!(result.stdout, "hello\n");
3624 }
3625
3626 #[tokio::test]
3627 async fn test_command_substitution_variable() {
3628 let mut bash = Bash::new();
3629 let result = bash.exec("VAR=$(echo test); echo $VAR").await.unwrap();
3630 assert_eq!(result.stdout, "test\n");
3631 }
3632
3633 #[tokio::test]
3634 async fn test_arithmetic_simple() {
3635 let mut bash = Bash::new();
3636 let result = bash.exec("echo $((1 + 2))").await.unwrap();
3637 assert_eq!(result.stdout, "3\n");
3638 }
3639
3640 #[tokio::test]
3641 async fn test_arithmetic_multiply() {
3642 let mut bash = Bash::new();
3643 let result = bash.exec("echo $((3 * 4))").await.unwrap();
3644 assert_eq!(result.stdout, "12\n");
3645 }
3646
3647 #[tokio::test]
3648 async fn test_arithmetic_with_variable() {
3649 let mut bash = Bash::new();
3650 let result = bash.exec("X=5; echo $((X + 3))").await.unwrap();
3651 assert_eq!(result.stdout, "8\n");
3652 }
3653
3654 #[tokio::test]
3655 async fn test_arithmetic_complex() {
3656 let mut bash = Bash::new();
3657 let result = bash.exec("echo $((2 + 3 * 4))").await.unwrap();
3658 assert_eq!(result.stdout, "14\n");
3659 }
3660
3661 #[tokio::test]
3662 async fn test_heredoc_simple() {
3663 let mut bash = Bash::new();
3664 let result = bash.exec("cat <<EOF\nhello\nworld\nEOF").await.unwrap();
3665 assert_eq!(result.stdout, "hello\nworld\n");
3666 }
3667
3668 #[tokio::test]
3669 async fn test_heredoc_single_line() {
3670 let mut bash = Bash::new();
3671 let result = bash.exec("cat <<END\ntest\nEND").await.unwrap();
3672 assert_eq!(result.stdout, "test\n");
3673 }
3674
3675 #[tokio::test]
3676 async fn test_unset() {
3677 let mut bash = Bash::new();
3678 let result = bash
3679 .exec("FOO=bar; unset FOO; echo \"x${FOO}y\"")
3680 .await
3681 .unwrap();
3682 assert_eq!(result.stdout, "xy\n");
3683 }
3684
3685 #[tokio::test]
3686 async fn test_local_basic() {
3687 let mut bash = Bash::new();
3688 let result = bash.exec("local X=test; echo $X").await.unwrap();
3690 assert_eq!(result.stdout, "test\n");
3691 }
3692
3693 #[tokio::test]
3694 async fn test_set_option() {
3695 let mut bash = Bash::new();
3696 let result = bash.exec("set -e; echo ok").await.unwrap();
3697 assert_eq!(result.stdout, "ok\n");
3698 }
3699
3700 #[tokio::test]
3701 async fn test_param_default() {
3702 let mut bash = Bash::new();
3703 let result = bash.exec("echo ${UNSET:-default}").await.unwrap();
3705 assert_eq!(result.stdout, "default\n");
3706
3707 let result = bash.exec("X=value; echo ${X:-default}").await.unwrap();
3709 assert_eq!(result.stdout, "value\n");
3710 }
3711
3712 #[tokio::test]
3713 async fn test_param_assign_default() {
3714 let mut bash = Bash::new();
3715 let result = bash.exec("echo ${NEW:=assigned}; echo $NEW").await.unwrap();
3717 assert_eq!(result.stdout, "assigned\nassigned\n");
3718 }
3719
3720 #[tokio::test]
3721 async fn test_param_length() {
3722 let mut bash = Bash::new();
3723 let result = bash.exec("X=hello; echo ${#X}").await.unwrap();
3724 assert_eq!(result.stdout, "5\n");
3725 }
3726
3727 #[tokio::test]
3728 async fn test_param_remove_prefix() {
3729 let mut bash = Bash::new();
3730 let result = bash.exec("X=hello.world.txt; echo ${X#*.}").await.unwrap();
3732 assert_eq!(result.stdout, "world.txt\n");
3733 }
3734
3735 #[tokio::test]
3736 async fn test_param_remove_prefix_mixed_pattern() {
3737 let mut bash = Bash::new();
3738 let result = bash
3740 .exec(r#"i="./tag_hello.tmp.html"; prefix_tags="tag_"; echo ${i#./"$prefix_tags"}"#)
3741 .await
3742 .unwrap();
3743 assert_eq!(result.stdout, "hello.tmp.html\n");
3744 }
3745
3746 #[tokio::test]
3747 async fn test_param_remove_suffix() {
3748 let mut bash = Bash::new();
3749 let result = bash.exec("X=file.tar.gz; echo ${X%.*}").await.unwrap();
3751 assert_eq!(result.stdout, "file.tar\n");
3752 }
3753
3754 #[tokio::test]
3755 async fn test_positional_param_prefix_replace() {
3756 let mut bash = Bash::new();
3757 let result = bash
3759 .exec(r#"f() { set -- "${@/#/tag_}"; echo "$@"; }; f hello world"#)
3760 .await
3761 .unwrap();
3762 assert_eq!(result.stdout, "tag_hello tag_world\n");
3763 }
3764
3765 #[tokio::test]
3766 async fn test_positional_param_suffix_replace() {
3767 let mut bash = Bash::new();
3768 let result = bash
3770 .exec(r#"f() { set -- "${@/%/.html}"; echo "$@"; }; f hello world"#)
3771 .await
3772 .unwrap();
3773 assert_eq!(result.stdout, "hello.html world.html\n");
3774 }
3775
3776 #[tokio::test]
3777 async fn test_positional_param_prefix_var_replace() {
3778 let mut bash = Bash::new();
3779 let result = bash
3781 .exec(r#"f() { p="tag_"; set -- "${@/#/$p}"; echo "$@"; }; f hello world"#)
3782 .await
3783 .unwrap();
3784 assert_eq!(result.stdout, "tag_hello tag_world\n");
3785 }
3786
3787 #[tokio::test]
3788 async fn test_positional_param_prefix_strip() {
3789 let mut bash = Bash::new();
3790 let result = bash
3792 .exec(r#"f() { set -- "${@#tag_}"; echo "$@"; }; f tag_hello tag_world"#)
3793 .await
3794 .unwrap();
3795 assert_eq!(result.stdout, "hello world\n");
3796 }
3797
3798 #[tokio::test]
3799 async fn test_array_basic() {
3800 let mut bash = Bash::new();
3801 let result = bash.exec("arr=(a b c); echo ${arr[1]}").await.unwrap();
3803 assert_eq!(result.stdout, "b\n");
3804 }
3805
3806 #[tokio::test]
3807 async fn test_array_all_elements() {
3808 let mut bash = Bash::new();
3809 let result = bash
3811 .exec("arr=(one two three); echo ${arr[@]}")
3812 .await
3813 .unwrap();
3814 assert_eq!(result.stdout, "one two three\n");
3815 }
3816
3817 #[tokio::test]
3818 async fn test_array_length() {
3819 let mut bash = Bash::new();
3820 let result = bash.exec("arr=(a b c d e); echo ${#arr[@]}").await.unwrap();
3822 assert_eq!(result.stdout, "5\n");
3823 }
3824
3825 #[tokio::test]
3826 async fn test_array_indexed_assignment() {
3827 let mut bash = Bash::new();
3828 let result = bash
3830 .exec("arr[0]=first; arr[1]=second; echo ${arr[0]} ${arr[1]}")
3831 .await
3832 .unwrap();
3833 assert_eq!(result.stdout, "first second\n");
3834 }
3835
3836 #[tokio::test]
3837 async fn test_array_single_quote_subscript_no_panic() {
3838 let mut bash = Bash::new();
3840 let _ = bash.exec("echo ${arr[\"]}").await;
3842 }
3843
3844 #[tokio::test]
3847 async fn test_command_limit() {
3848 let limits = ExecutionLimits::new().max_commands(5);
3849 let mut bash = Bash::builder().limits(limits).build();
3850
3851 let result = bash.exec("true; true; true; true; true; true").await;
3853 assert!(result.is_err());
3854 let err = result.unwrap_err();
3855 assert!(
3856 err.to_string().contains("maximum command count exceeded"),
3857 "Expected command limit error, got: {}",
3858 err
3859 );
3860 }
3861
3862 #[tokio::test]
3863 async fn test_command_limit_not_exceeded() {
3864 let limits = ExecutionLimits::new().max_commands(10);
3865 let mut bash = Bash::builder().limits(limits).build();
3866
3867 let result = bash.exec("true; true; true; true; true").await.unwrap();
3869 assert_eq!(result.exit_code, 0);
3870 }
3871
3872 #[tokio::test]
3873 async fn test_loop_iteration_limit() {
3874 let limits = ExecutionLimits::new().max_loop_iterations(5);
3875 let mut bash = Bash::builder().limits(limits).build();
3876
3877 let result = bash
3879 .exec("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done")
3880 .await;
3881 assert!(result.is_err());
3882 let err = result.unwrap_err();
3883 assert!(
3884 err.to_string().contains("maximum loop iterations exceeded"),
3885 "Expected loop limit error, got: {}",
3886 err
3887 );
3888 }
3889
3890 #[tokio::test]
3891 async fn test_loop_iteration_limit_not_exceeded() {
3892 let limits = ExecutionLimits::new().max_loop_iterations(10);
3893 let mut bash = Bash::builder().limits(limits).build();
3894
3895 let result = bash
3897 .exec("for i in 1 2 3 4 5; do echo $i; done")
3898 .await
3899 .unwrap();
3900 assert_eq!(result.stdout, "1\n2\n3\n4\n5\n");
3901 }
3902
3903 #[tokio::test]
3904 async fn test_function_depth_limit() {
3905 let limits = ExecutionLimits::new().max_function_depth(3);
3906 let mut bash = Bash::builder().limits(limits).build();
3907
3908 let result = bash
3910 .exec("f() { echo $1; if [ $1 -lt 5 ]; then f $(($1 + 1)); fi; }; f 1")
3911 .await;
3912 assert!(result.is_err());
3913 let err = result.unwrap_err();
3914 assert!(
3915 err.to_string().contains("maximum function depth exceeded"),
3916 "Expected function depth error, got: {}",
3917 err
3918 );
3919 }
3920
3921 #[tokio::test]
3922 async fn test_function_depth_limit_not_exceeded() {
3923 let limits = ExecutionLimits::new().max_function_depth(10);
3924 let mut bash = Bash::builder().limits(limits).build();
3925
3926 let result = bash.exec("f() { echo hello; }; f").await.unwrap();
3928 assert_eq!(result.stdout, "hello\n");
3929 }
3930
3931 #[tokio::test]
3932 async fn test_while_loop_limit() {
3933 let limits = ExecutionLimits::new().max_loop_iterations(3);
3934 let mut bash = Bash::builder().limits(limits).build();
3935
3936 let result = bash
3938 .exec("i=0; while [ $i -lt 10 ]; do echo $i; i=$((i + 1)); done")
3939 .await;
3940 assert!(result.is_err());
3941 let err = result.unwrap_err();
3942 assert!(
3943 err.to_string().contains("maximum loop iterations exceeded"),
3944 "Expected loop limit error, got: {}",
3945 err
3946 );
3947 }
3948
3949 #[tokio::test]
3950 async fn test_awk_respects_loop_iteration_limit() {
3951 let limits = ExecutionLimits::new().max_loop_iterations(5);
3952 let mut bash = Bash::builder().limits(limits).build();
3953 let result = bash
3954 .exec("awk 'BEGIN { i=0; while(1) { i++; if(i>999) break } print i }'")
3955 .await
3956 .unwrap();
3957 assert_eq!(result.stdout.trim(), "5");
3958 }
3959
3960 #[tokio::test]
3961 async fn test_awk_for_in_respects_loop_iteration_limit() {
3962 let limits = ExecutionLimits::new().max_loop_iterations(3);
3963 let mut bash = Bash::builder().limits(limits).build();
3964 let result = bash
3965 .exec("awk 'BEGIN { for(i=1;i<=10;i++) a[i]=i; c=0; for(k in a) c++; print c }'")
3966 .await
3967 .unwrap();
3968 assert_eq!(result.stdout.trim(), "3");
3969 }
3970
3971 #[tokio::test]
3972 async fn test_default_limits_allow_normal_scripts() {
3973 let mut bash = Bash::new();
3975 let result = bash
3977 .exec("for i in 1 2 3 4 5; do echo $i; done && echo finished")
3978 .await
3979 .unwrap();
3980 assert_eq!(result.stdout, "1\n2\n3\n4\n5\nfinished\n");
3981 }
3982
3983 #[tokio::test]
3984 async fn test_for_followed_by_echo_done() {
3985 let mut bash = Bash::new();
3986 let result = bash
3987 .exec("for i in 1; do echo $i; done; echo ok")
3988 .await
3989 .unwrap();
3990 assert_eq!(result.stdout, "1\nok\n");
3991 }
3992
3993 #[tokio::test]
3996 async fn test_fs_read_write_binary() {
3997 let bash = Bash::new();
3998 let fs = bash.fs();
3999 let path = std::path::Path::new("/tmp/binary.bin");
4000
4001 let binary_data: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE, 0x42, 0x00, 0x7F];
4003 fs.write_file(path, &binary_data).await.unwrap();
4004
4005 let content = fs.read_file(path).await.unwrap();
4007 assert_eq!(content, binary_data);
4008 }
4009
4010 #[tokio::test]
4011 async fn test_fs_write_then_exec_cat() {
4012 let mut bash = Bash::new();
4013 let path = std::path::Path::new("/tmp/prepopulated.txt");
4014
4015 bash.fs()
4017 .write_file(path, b"Hello from Rust!\n")
4018 .await
4019 .unwrap();
4020
4021 let result = bash.exec("cat /tmp/prepopulated.txt").await.unwrap();
4023 assert_eq!(result.stdout, "Hello from Rust!\n");
4024 }
4025
4026 #[tokio::test]
4027 async fn test_fs_exec_then_read() {
4028 let mut bash = Bash::new();
4029 let path = std::path::Path::new("/tmp/from_bash.txt");
4030
4031 bash.exec("echo 'Created by bash' > /tmp/from_bash.txt")
4033 .await
4034 .unwrap();
4035
4036 let content = bash.fs().read_file(path).await.unwrap();
4038 assert_eq!(content, b"Created by bash\n");
4039 }
4040
4041 #[tokio::test]
4042 async fn test_fs_exists_and_stat() {
4043 let bash = Bash::new();
4044 let fs = bash.fs();
4045 let path = std::path::Path::new("/tmp/testfile.txt");
4046
4047 assert!(!fs.exists(path).await.unwrap());
4049
4050 fs.write_file(path, b"content").await.unwrap();
4052
4053 assert!(fs.exists(path).await.unwrap());
4055
4056 let stat = fs.stat(path).await.unwrap();
4058 assert!(stat.file_type.is_file());
4059 assert_eq!(stat.size, 7); }
4061
4062 #[tokio::test]
4063 async fn test_fs_mkdir_and_read_dir() {
4064 let bash = Bash::new();
4065 let fs = bash.fs();
4066
4067 fs.mkdir(std::path::Path::new("/data/nested/dir"), true)
4069 .await
4070 .unwrap();
4071
4072 fs.write_file(std::path::Path::new("/data/file1.txt"), b"1")
4074 .await
4075 .unwrap();
4076 fs.write_file(std::path::Path::new("/data/file2.txt"), b"2")
4077 .await
4078 .unwrap();
4079
4080 let entries = fs.read_dir(std::path::Path::new("/data")).await.unwrap();
4082 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
4083 assert!(names.contains(&"nested"));
4084 assert!(names.contains(&"file1.txt"));
4085 assert!(names.contains(&"file2.txt"));
4086 }
4087
4088 #[tokio::test]
4089 async fn test_fs_append() {
4090 let bash = Bash::new();
4091 let fs = bash.fs();
4092 let path = std::path::Path::new("/tmp/append.txt");
4093
4094 fs.write_file(path, b"line1\n").await.unwrap();
4095 fs.append_file(path, b"line2\n").await.unwrap();
4096 fs.append_file(path, b"line3\n").await.unwrap();
4097
4098 let content = fs.read_file(path).await.unwrap();
4099 assert_eq!(content, b"line1\nline2\nline3\n");
4100 }
4101
4102 #[tokio::test]
4103 async fn test_fs_copy_and_rename() {
4104 let bash = Bash::new();
4105 let fs = bash.fs();
4106
4107 fs.write_file(std::path::Path::new("/tmp/original.txt"), b"data")
4108 .await
4109 .unwrap();
4110
4111 fs.copy(
4113 std::path::Path::new("/tmp/original.txt"),
4114 std::path::Path::new("/tmp/copied.txt"),
4115 )
4116 .await
4117 .unwrap();
4118
4119 fs.rename(
4121 std::path::Path::new("/tmp/copied.txt"),
4122 std::path::Path::new("/tmp/renamed.txt"),
4123 )
4124 .await
4125 .unwrap();
4126
4127 let content = fs
4129 .read_file(std::path::Path::new("/tmp/renamed.txt"))
4130 .await
4131 .unwrap();
4132 assert_eq!(content, b"data");
4133 assert!(
4134 !fs.exists(std::path::Path::new("/tmp/copied.txt"))
4135 .await
4136 .unwrap()
4137 );
4138 }
4139
4140 #[tokio::test]
4143 async fn test_echo_done_as_argument() {
4144 let mut bash = Bash::new();
4146 let result = bash
4147 .exec("for i in 1; do echo $i; done; echo done")
4148 .await
4149 .unwrap();
4150 assert_eq!(result.stdout, "1\ndone\n");
4151 }
4152
4153 #[tokio::test]
4154 async fn test_simple_echo_done() {
4155 let mut bash = Bash::new();
4157 let result = bash.exec("echo done").await.unwrap();
4158 assert_eq!(result.stdout, "done\n");
4159 }
4160
4161 #[tokio::test]
4162 async fn test_dev_null_redirect() {
4163 let mut bash = Bash::new();
4165 let result = bash.exec("echo hello > /dev/null; echo ok").await.unwrap();
4166 assert_eq!(result.stdout, "ok\n");
4167 }
4168
4169 #[tokio::test]
4170 async fn test_string_concatenation_in_loop() {
4171 let mut bash = Bash::new();
4173 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
4175 assert_eq!(result.stdout, "a\nb\nc\n");
4176
4177 let mut bash = Bash::new();
4179 let result = bash
4180 .exec("result=x; for i in a b c; do echo $i; done; echo $result")
4181 .await
4182 .unwrap();
4183 assert_eq!(result.stdout, "a\nb\nc\nx\n");
4184
4185 let mut bash = Bash::new();
4187 let result = bash
4188 .exec("result=start; for i in a b c; do result=${result}$i; done; echo $result")
4189 .await
4190 .unwrap();
4191 assert_eq!(result.stdout, "startabc\n");
4192 }
4193
4194 #[tokio::test]
4197 async fn test_done_still_terminates_loop() {
4198 let mut bash = Bash::new();
4200 let result = bash.exec("for i in 1 2; do echo $i; done").await.unwrap();
4201 assert_eq!(result.stdout, "1\n2\n");
4202 }
4203
4204 #[tokio::test]
4205 async fn test_fi_still_terminates_if() {
4206 let mut bash = Bash::new();
4208 let result = bash.exec("if true; then echo yes; fi").await.unwrap();
4209 assert_eq!(result.stdout, "yes\n");
4210 }
4211
4212 #[tokio::test]
4213 async fn test_echo_fi_as_argument() {
4214 let mut bash = Bash::new();
4216 let result = bash.exec("echo fi").await.unwrap();
4217 assert_eq!(result.stdout, "fi\n");
4218 }
4219
4220 #[tokio::test]
4221 async fn test_echo_then_as_argument() {
4222 let mut bash = Bash::new();
4224 let result = bash.exec("echo then").await.unwrap();
4225 assert_eq!(result.stdout, "then\n");
4226 }
4227
4228 #[tokio::test]
4229 async fn test_reserved_words_in_quotes_are_arguments() {
4230 let mut bash = Bash::new();
4232 let result = bash.exec("echo 'done' 'fi' 'then'").await.unwrap();
4233 assert_eq!(result.stdout, "done fi then\n");
4234 }
4235
4236 #[tokio::test]
4237 async fn test_nested_loops_done_keyword() {
4238 let mut bash = Bash::new();
4240 let result = bash
4241 .exec("for i in 1; do for j in a; do echo $i$j; done; done")
4242 .await
4243 .unwrap();
4244 assert_eq!(result.stdout, "1a\n");
4245 }
4246
4247 #[tokio::test]
4250 async fn test_dev_null_read_returns_empty() {
4251 let mut bash = Bash::new();
4253 let result = bash.exec("cat /dev/null").await.unwrap();
4254 assert_eq!(result.stdout, "");
4255 }
4256
4257 #[tokio::test]
4258 async fn test_dev_null_append() {
4259 let mut bash = Bash::new();
4261 let result = bash.exec("echo hello >> /dev/null; echo ok").await.unwrap();
4262 assert_eq!(result.stdout, "ok\n");
4263 }
4264
4265 #[tokio::test]
4266 async fn test_dev_null_in_pipeline() {
4267 let mut bash = Bash::new();
4269 let result = bash
4270 .exec("echo hello | cat > /dev/null; echo ok")
4271 .await
4272 .unwrap();
4273 assert_eq!(result.stdout, "ok\n");
4274 }
4275
4276 #[tokio::test]
4277 async fn test_dev_null_exists() {
4278 let mut bash = Bash::new();
4280 let result = bash.exec("cat /dev/null; echo exit_$?").await.unwrap();
4281 assert_eq!(result.stdout, "exit_0\n");
4282 }
4283
4284 #[tokio::test]
4287 async fn test_custom_username_whoami() {
4288 let mut bash = Bash::builder().username("alice").build();
4289 let result = bash.exec("whoami").await.unwrap();
4290 assert_eq!(result.stdout, "alice\n");
4291 }
4292
4293 #[tokio::test]
4294 async fn test_custom_username_id() {
4295 let mut bash = Bash::builder().username("bob").build();
4296 let result = bash.exec("id").await.unwrap();
4297 assert!(result.stdout.contains("uid=1000(bob)"));
4298 assert!(result.stdout.contains("gid=1000(bob)"));
4299 }
4300
4301 #[tokio::test]
4302 async fn test_custom_username_sets_user_env() {
4303 let mut bash = Bash::builder().username("charlie").build();
4304 let result = bash.exec("echo $USER").await.unwrap();
4305 assert_eq!(result.stdout, "charlie\n");
4306 }
4307
4308 #[tokio::test]
4309 async fn test_default_ppid_is_sandboxed() {
4310 let mut bash = Bash::new();
4311 let result = bash.exec("echo $PPID").await.unwrap();
4312 assert_eq!(result.stdout, "0\n");
4313 }
4314
4315 #[tokio::test]
4316 async fn test_custom_hostname() {
4317 let mut bash = Bash::builder().hostname("my-server").build();
4318 let result = bash.exec("hostname").await.unwrap();
4319 assert_eq!(result.stdout, "my-server\n");
4320 }
4321
4322 #[tokio::test]
4323 async fn test_custom_hostname_uname() {
4324 let mut bash = Bash::builder().hostname("custom-host").build();
4325 let result = bash.exec("uname -n").await.unwrap();
4326 assert_eq!(result.stdout, "custom-host\n");
4327 }
4328
4329 #[tokio::test]
4330 async fn test_default_username_and_hostname() {
4331 let mut bash = Bash::new();
4333 let result = bash.exec("whoami").await.unwrap();
4334 assert_eq!(result.stdout, "sandbox\n");
4335
4336 let result = bash.exec("hostname").await.unwrap();
4337 assert_eq!(result.stdout, "bashkit-sandbox\n");
4338 }
4339
4340 #[tokio::test]
4341 async fn test_custom_username_and_hostname_combined() {
4342 let mut bash = Bash::builder()
4343 .username("deploy")
4344 .hostname("prod-server-01")
4345 .build();
4346
4347 let result = bash.exec("whoami && hostname").await.unwrap();
4348 assert_eq!(result.stdout, "deploy\nprod-server-01\n");
4349
4350 let result = bash.exec("echo $USER").await.unwrap();
4351 assert_eq!(result.stdout, "deploy\n");
4352 }
4353
4354 mod custom_builtins {
4357 use super::*;
4358 use crate::builtins::{Builtin, Context};
4359 use crate::{ExecResult, ExecutionExtensions, Extension};
4360 use async_trait::async_trait;
4361
4362 struct Hello;
4364
4365 #[async_trait]
4366 impl Builtin for Hello {
4367 async fn execute(&self, _ctx: Context<'_>) -> crate::Result<ExecResult> {
4368 Ok(ExecResult::ok("Hello from custom builtin!\n".to_string()))
4369 }
4370 }
4371
4372 #[tokio::test]
4373 async fn test_custom_builtin_basic() {
4374 let mut bash = Bash::builder().builtin("hello", Box::new(Hello)).build();
4375
4376 let result = bash.exec("hello").await.unwrap();
4377 assert_eq!(result.stdout, "Hello from custom builtin!\n");
4378 assert_eq!(result.exit_code, 0);
4379 }
4380
4381 struct ExecutionScoped;
4382
4383 #[async_trait]
4384 impl Builtin for ExecutionScoped {
4385 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4386 let value = ctx
4387 .execution_extension::<String>()
4388 .cloned()
4389 .unwrap_or_else(|| "missing".to_string());
4390 Ok(ExecResult::ok(format!("{value}\n")))
4391 }
4392 }
4393
4394 #[tokio::test]
4395 async fn test_custom_builtin_execution_extensions_are_per_call() {
4396 let mut bash = Bash::builder()
4397 .builtin("read-ext", Box::new(ExecutionScoped))
4398 .build();
4399
4400 let result = bash
4401 .exec_with_extensions(
4402 "read-ext",
4403 ExecutionExtensions::new().with("scoped".to_string()),
4404 )
4405 .await
4406 .unwrap();
4407 assert_eq!(result.stdout, "scoped\n");
4408
4409 let result = bash.exec("read-ext").await.unwrap();
4410 assert_eq!(result.stdout, "missing\n");
4411 }
4412
4413 struct Greet;
4415
4416 #[async_trait]
4417 impl Builtin for Greet {
4418 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4419 let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World");
4420 Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
4421 }
4422 }
4423
4424 #[tokio::test]
4425 async fn test_custom_builtin_with_args() {
4426 let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
4427
4428 let result = bash.exec("greet").await.unwrap();
4429 assert_eq!(result.stdout, "Hello, World!\n");
4430
4431 let result = bash.exec("greet Alice").await.unwrap();
4432 assert_eq!(result.stdout, "Hello, Alice!\n");
4433
4434 let result = bash.exec("greet Bob Charlie").await.unwrap();
4435 assert_eq!(result.stdout, "Hello, Bob!\n");
4436 }
4437
4438 struct Upper;
4440
4441 #[async_trait]
4442 impl Builtin for Upper {
4443 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4444 let input = ctx.stdin.unwrap_or("");
4445 Ok(ExecResult::ok(input.to_uppercase()))
4446 }
4447 }
4448
4449 #[tokio::test]
4450 async fn test_custom_builtin_with_stdin() {
4451 let mut bash = Bash::builder().builtin("upper", Box::new(Upper)).build();
4452
4453 let result = bash.exec("echo hello | upper").await.unwrap();
4454 assert_eq!(result.stdout, "HELLO\n");
4455 }
4456
4457 struct WriteFile;
4459
4460 #[async_trait]
4461 impl Builtin for WriteFile {
4462 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4463 if ctx.args.len() < 2 {
4464 return Ok(ExecResult::err(
4465 "Usage: writefile <path> <content>\n".to_string(),
4466 1,
4467 ));
4468 }
4469 let path = std::path::Path::new(&ctx.args[0]);
4470 let content = ctx.args[1..].join(" ");
4471 ctx.fs.write_file(path, content.as_bytes()).await?;
4472 Ok(ExecResult::ok(String::new()))
4473 }
4474 }
4475
4476 #[tokio::test]
4477 async fn test_custom_builtin_with_filesystem() {
4478 let mut bash = Bash::builder()
4479 .builtin("writefile", Box::new(WriteFile))
4480 .build();
4481
4482 bash.exec("writefile /tmp/test.txt custom content here")
4483 .await
4484 .unwrap();
4485
4486 let result = bash.exec("cat /tmp/test.txt").await.unwrap();
4487 assert_eq!(result.stdout, "custom content here");
4488 }
4489
4490 struct CustomEcho;
4492
4493 #[async_trait]
4494 impl Builtin for CustomEcho {
4495 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4496 let msg = ctx.args.join(" ");
4497 Ok(ExecResult::ok(format!("[CUSTOM] {}\n", msg)))
4498 }
4499 }
4500
4501 #[tokio::test]
4502 async fn test_custom_builtin_override_default() {
4503 let mut bash = Bash::builder()
4504 .builtin("echo", Box::new(CustomEcho))
4505 .build();
4506
4507 let result = bash.exec("echo hello world").await.unwrap();
4508 assert_eq!(result.stdout, "[CUSTOM] hello world\n");
4509 }
4510
4511 #[tokio::test]
4513 async fn test_multiple_custom_builtins() {
4514 let mut bash = Bash::builder()
4515 .builtin("hello", Box::new(Hello))
4516 .builtin("greet", Box::new(Greet))
4517 .builtin("upper", Box::new(Upper))
4518 .build();
4519
4520 let result = bash.exec("hello").await.unwrap();
4521 assert_eq!(result.stdout, "Hello from custom builtin!\n");
4522
4523 let result = bash.exec("greet Test").await.unwrap();
4524 assert_eq!(result.stdout, "Hello, Test!\n");
4525
4526 let result = bash.exec("echo foo | upper").await.unwrap();
4527 assert_eq!(result.stdout, "FOO\n");
4528 }
4529
4530 struct GreetingExtension;
4531
4532 impl Extension for GreetingExtension {
4533 fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
4534 vec![
4535 ("hello-ext".to_string(), Box::new(Hello)),
4536 ("greet-ext".to_string(), Box::new(Greet)),
4537 ]
4538 }
4539 }
4540
4541 #[tokio::test]
4542 async fn test_extension_registers_multiple_builtins() {
4543 let mut bash = Bash::builder().extension(GreetingExtension).build();
4544
4545 let result = bash.exec("hello-ext").await.unwrap();
4546 assert_eq!(result.stdout, "Hello from custom builtin!\n");
4547
4548 let result = bash.exec("greet-ext Extension").await.unwrap();
4549 assert_eq!(result.stdout, "Hello, Extension!\n");
4550 }
4551
4552 struct Counter {
4554 prefix: String,
4555 }
4556
4557 #[async_trait]
4558 impl Builtin for Counter {
4559 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4560 let count = ctx
4561 .args
4562 .first()
4563 .and_then(|s| s.parse::<i32>().ok())
4564 .unwrap_or(1);
4565 let mut output = String::new();
4566 for i in 1..=count {
4567 output.push_str(&format!("{}{}\n", self.prefix, i));
4568 }
4569 Ok(ExecResult::ok(output))
4570 }
4571 }
4572
4573 #[tokio::test]
4574 async fn test_custom_builtin_with_state() {
4575 let mut bash = Bash::builder()
4576 .builtin(
4577 "count",
4578 Box::new(Counter {
4579 prefix: "Item ".to_string(),
4580 }),
4581 )
4582 .build();
4583
4584 let result = bash.exec("count 3").await.unwrap();
4585 assert_eq!(result.stdout, "Item 1\nItem 2\nItem 3\n");
4586 }
4587
4588 struct Fail;
4590
4591 #[async_trait]
4592 impl Builtin for Fail {
4593 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4594 let code = ctx
4595 .args
4596 .first()
4597 .and_then(|s| s.parse::<i32>().ok())
4598 .unwrap_or(1);
4599 Ok(ExecResult::err(
4600 format!("Failed with code {}\n", code),
4601 code,
4602 ))
4603 }
4604 }
4605
4606 #[tokio::test]
4607 async fn test_custom_builtin_error() {
4608 let mut bash = Bash::builder().builtin("fail", Box::new(Fail)).build();
4609
4610 let result = bash.exec("fail 42").await.unwrap();
4611 assert_eq!(result.exit_code, 42);
4612 assert_eq!(result.stderr, "Failed with code 42\n");
4613 }
4614
4615 #[tokio::test]
4616 async fn test_custom_builtin_in_script() {
4617 let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
4618
4619 let script = r#"
4620 for name in Alice Bob Charlie; do
4621 greet $name
4622 done
4623 "#;
4624
4625 let result = bash.exec(script).await.unwrap();
4626 assert_eq!(
4627 result.stdout,
4628 "Hello, Alice!\nHello, Bob!\nHello, Charlie!\n"
4629 );
4630 }
4631
4632 #[tokio::test]
4633 async fn test_custom_builtin_with_conditionals() {
4634 let mut bash = Bash::builder()
4635 .builtin("fail", Box::new(Fail))
4636 .builtin("hello", Box::new(Hello))
4637 .build();
4638
4639 let result = bash.exec("fail 1 || hello").await.unwrap();
4640 assert_eq!(result.stdout, "Hello from custom builtin!\n");
4641 assert_eq!(result.exit_code, 0);
4642
4643 let result = bash.exec("hello && fail 5").await.unwrap();
4644 assert_eq!(result.exit_code, 5);
4645 }
4646
4647 struct EnvReader;
4649
4650 #[async_trait]
4651 impl Builtin for EnvReader {
4652 async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4653 let var_name = ctx.args.first().map(|s| s.as_str()).unwrap_or("HOME");
4654 let value = ctx
4655 .env
4656 .get(var_name)
4657 .map(|s| s.as_str())
4658 .unwrap_or("(not set)");
4659 Ok(ExecResult::ok(format!("{}={}\n", var_name, value)))
4660 }
4661 }
4662
4663 #[tokio::test]
4664 async fn test_custom_builtin_reads_env() {
4665 let mut bash = Bash::builder()
4666 .env("MY_VAR", "my_value")
4667 .builtin("readenv", Box::new(EnvReader))
4668 .build();
4669
4670 let result = bash.exec("readenv MY_VAR").await.unwrap();
4671 assert_eq!(result.stdout, "MY_VAR=my_value\n");
4672
4673 let result = bash.exec("readenv UNKNOWN").await.unwrap();
4674 assert_eq!(result.stdout, "UNKNOWN=(not set)\n");
4675 }
4676 }
4677
4678 #[tokio::test]
4681 async fn test_parser_timeout_default() {
4682 let limits = ExecutionLimits::default();
4684 assert_eq!(limits.parser_timeout, std::time::Duration::from_secs(5));
4685 }
4686
4687 #[tokio::test]
4688 async fn test_parser_timeout_custom() {
4689 let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_millis(100));
4691 assert_eq!(limits.parser_timeout, std::time::Duration::from_millis(100));
4692 }
4693
4694 #[tokio::test]
4695 async fn test_parser_timeout_normal_script() {
4696 let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_secs(1));
4698 let mut bash = Bash::builder().limits(limits).build();
4699 let result = bash.exec("echo hello").await.unwrap();
4700 assert_eq!(result.stdout, "hello\n");
4701 }
4702
4703 #[tokio::test]
4706 async fn test_parser_fuel_default() {
4707 let limits = ExecutionLimits::default();
4709 assert_eq!(limits.max_parser_operations, 100_000);
4710 }
4711
4712 #[tokio::test]
4713 async fn test_parser_fuel_custom() {
4714 let limits = ExecutionLimits::new().max_parser_operations(1000);
4716 assert_eq!(limits.max_parser_operations, 1000);
4717 }
4718
4719 #[tokio::test]
4720 async fn test_parser_fuel_normal_script() {
4721 let limits = ExecutionLimits::new().max_parser_operations(1000);
4723 let mut bash = Bash::builder().limits(limits).build();
4724 let result = bash.exec("echo hello").await.unwrap();
4725 assert_eq!(result.stdout, "hello\n");
4726 }
4727
4728 #[tokio::test]
4731 async fn test_input_size_limit_default() {
4732 let limits = ExecutionLimits::default();
4734 assert_eq!(limits.max_input_bytes, 10_000_000);
4735 }
4736
4737 #[tokio::test]
4738 async fn test_input_size_limit_custom() {
4739 let limits = ExecutionLimits::new().max_input_bytes(1000);
4741 assert_eq!(limits.max_input_bytes, 1000);
4742 }
4743
4744 #[tokio::test]
4745 async fn test_input_size_limit_enforced() {
4746 let limits = ExecutionLimits::new().max_input_bytes(10);
4748 let mut bash = Bash::builder().limits(limits).build();
4749
4750 let result = bash.exec("echo hello world").await;
4752 assert!(result.is_err());
4753 let err = result.unwrap_err();
4754 assert!(
4755 err.to_string().contains("input too large"),
4756 "Expected input size error, got: {}",
4757 err
4758 );
4759 }
4760
4761 #[tokio::test]
4762 async fn test_input_size_limit_normal_script() {
4763 let limits = ExecutionLimits::new().max_input_bytes(1000);
4765 let mut bash = Bash::builder().limits(limits).build();
4766 let result = bash.exec("echo hello").await.unwrap();
4767 assert_eq!(result.stdout, "hello\n");
4768 }
4769
4770 #[tokio::test]
4773 async fn test_ast_depth_limit_default() {
4774 let limits = ExecutionLimits::default();
4776 assert_eq!(limits.max_ast_depth, 100);
4777 }
4778
4779 #[tokio::test]
4780 async fn test_ast_depth_limit_custom() {
4781 let limits = ExecutionLimits::new().max_ast_depth(10);
4783 assert_eq!(limits.max_ast_depth, 10);
4784 }
4785
4786 #[tokio::test]
4787 async fn test_ast_depth_limit_normal_script() {
4788 let limits = ExecutionLimits::new().max_ast_depth(10);
4790 let mut bash = Bash::builder().limits(limits).build();
4791 let result = bash.exec("if true; then echo ok; fi").await.unwrap();
4792 assert_eq!(result.stdout, "ok\n");
4793 }
4794
4795 #[tokio::test]
4796 async fn test_ast_depth_limit_enforced() {
4797 let limits = ExecutionLimits::new().max_ast_depth(2);
4799 let mut bash = Bash::builder().limits(limits).build();
4800
4801 let result = bash
4803 .exec("if true; then if true; then if true; then echo nested; fi; fi; fi")
4804 .await;
4805 assert!(result.is_err());
4806 let err = result.unwrap_err();
4807 assert!(
4808 err.to_string().contains("AST nesting too deep"),
4809 "Expected AST depth error, got: {}",
4810 err
4811 );
4812 }
4813
4814 #[tokio::test]
4815 async fn test_parser_fuel_enforced() {
4816 let limits = ExecutionLimits::new().max_parser_operations(3);
4819 let mut bash = Bash::builder().limits(limits).build();
4820
4821 let result = bash.exec("echo a; echo b; echo c").await;
4823 assert!(result.is_err());
4824 let err = result.unwrap_err();
4825 assert!(
4826 err.to_string().contains("parser fuel exhausted"),
4827 "Expected parser fuel error, got: {}",
4828 err
4829 );
4830 }
4831
4832 #[tokio::test]
4835 async fn test_set_e_basic() {
4836 let mut bash = Bash::new();
4838 let result = bash
4839 .exec("set -e; true; false; echo should_not_reach")
4840 .await
4841 .unwrap();
4842 assert_eq!(result.stdout, "");
4843 assert_eq!(result.exit_code, 1);
4844 }
4845
4846 #[tokio::test]
4847 async fn test_set_e_after_failing_cmd() {
4848 let mut bash = Bash::new();
4850 let result = bash
4851 .exec("set -e; echo before; false; echo after")
4852 .await
4853 .unwrap();
4854 assert_eq!(result.stdout, "before\n");
4855 assert_eq!(result.exit_code, 1);
4856 }
4857
4858 #[tokio::test]
4859 async fn test_set_e_disabled() {
4860 let mut bash = Bash::new();
4862 let result = bash
4863 .exec("set -e; set +e; false; echo still_running")
4864 .await
4865 .unwrap();
4866 assert_eq!(result.stdout, "still_running\n");
4867 }
4868
4869 #[tokio::test]
4870 async fn test_set_e_in_pipeline_last() {
4871 let mut bash = Bash::new();
4873 let result = bash
4874 .exec("set -e; false | true; echo reached")
4875 .await
4876 .unwrap();
4877 assert_eq!(result.stdout, "reached\n");
4878 }
4879
4880 #[tokio::test]
4881 async fn test_set_e_in_if_condition() {
4882 let mut bash = Bash::new();
4884 let result = bash
4885 .exec("set -e; if false; then echo yes; else echo no; fi; echo done")
4886 .await
4887 .unwrap();
4888 assert_eq!(result.stdout, "no\ndone\n");
4889 }
4890
4891 #[tokio::test]
4892 async fn test_set_e_in_while_condition() {
4893 let mut bash = Bash::new();
4895 let result = bash
4896 .exec("set -e; x=0; while [ \"$x\" -lt 2 ]; do echo \"x=$x\"; x=$((x + 1)); done; echo done")
4897 .await
4898 .unwrap();
4899 assert_eq!(result.stdout, "x=0\nx=1\ndone\n");
4900 }
4901
4902 #[tokio::test]
4903 async fn test_set_e_in_brace_group() {
4904 let mut bash = Bash::new();
4906 let result = bash
4907 .exec("set -e; { echo start; false; echo unreached; }; echo after")
4908 .await
4909 .unwrap();
4910 assert_eq!(result.stdout, "start\n");
4911 assert_eq!(result.exit_code, 1);
4912 }
4913
4914 #[tokio::test]
4915 async fn test_set_e_and_chain() {
4916 let mut bash = Bash::new();
4918 let result = bash
4919 .exec("set -e; false && echo one; echo reached")
4920 .await
4921 .unwrap();
4922 assert_eq!(result.stdout, "reached\n");
4923 }
4924
4925 #[tokio::test]
4926 async fn test_set_e_or_chain() {
4927 let mut bash = Bash::new();
4929 let result = bash
4930 .exec("set -e; true || false; echo reached")
4931 .await
4932 .unwrap();
4933 assert_eq!(result.stdout, "reached\n");
4934 }
4935
4936 #[tokio::test]
4939 async fn test_tilde_expansion_basic() {
4940 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
4942 let result = bash.exec("echo ~").await.unwrap();
4943 assert_eq!(result.stdout, "/home/testuser\n");
4944 }
4945
4946 #[tokio::test]
4947 async fn test_tilde_expansion_with_path() {
4948 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
4950 let result = bash.exec("echo ~/documents/file.txt").await.unwrap();
4951 assert_eq!(result.stdout, "/home/testuser/documents/file.txt\n");
4952 }
4953
4954 #[tokio::test]
4955 async fn test_tilde_expansion_in_assignment() {
4956 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
4958 let result = bash.exec("DIR=~/data; echo $DIR").await.unwrap();
4959 assert_eq!(result.stdout, "/home/testuser/data\n");
4960 }
4961
4962 #[tokio::test]
4963 async fn test_tilde_expansion_default_home() {
4964 let mut bash = Bash::new();
4966 let result = bash.exec("echo ~").await.unwrap();
4967 assert_eq!(result.stdout, "/home/sandbox\n");
4968 }
4969
4970 #[tokio::test]
4971 async fn test_tilde_not_at_start() {
4972 let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
4974 let result = bash.exec("echo foo~bar").await.unwrap();
4975 assert_eq!(result.stdout, "foo~bar\n");
4976 }
4977
4978 #[tokio::test]
4981 async fn test_special_var_dollar_dollar() {
4982 let mut bash = Bash::new();
4984 let result = bash.exec("echo $$").await.unwrap();
4985 let pid: u32 = result.stdout.trim().parse().expect("$$ should be a number");
4987 assert!(pid > 0, "$$ should be a positive number");
4988 }
4989
4990 #[tokio::test]
4991 async fn test_special_var_random() {
4992 let mut bash = Bash::new();
4994 let result = bash.exec("echo $RANDOM").await.unwrap();
4995 let random: u32 = result
4996 .stdout
4997 .trim()
4998 .parse()
4999 .expect("$RANDOM should be a number");
5000 assert!(random < 32768, "$RANDOM should be < 32768");
5001 }
5002
5003 #[tokio::test]
5004 async fn test_special_var_random_varies() {
5005 let mut bash = Bash::new();
5007 let result1 = bash.exec("echo $RANDOM").await.unwrap();
5008 let result2 = bash.exec("echo $RANDOM").await.unwrap();
5009 let _: u32 = result1
5013 .stdout
5014 .trim()
5015 .parse()
5016 .expect("$RANDOM should be a number");
5017 let _: u32 = result2
5018 .stdout
5019 .trim()
5020 .parse()
5021 .expect("$RANDOM should be a number");
5022 }
5023
5024 #[tokio::test]
5025 async fn test_random_different_instances() {
5026 let mut bash1 = Bash::new();
5029 let mut bash2 = Bash::new();
5030 let r1 = bash1.exec("echo $RANDOM").await.unwrap();
5031 let r2 = bash2.exec("echo $RANDOM").await.unwrap();
5032 let v1: u32 = r1.stdout.trim().parse().expect("should be a number");
5033 let v2: u32 = r2.stdout.trim().parse().expect("should be a number");
5034 assert!(v1 < 32768);
5035 assert!(v2 < 32768);
5036 assert_ne!(v1, v2, "separate instances should produce different values");
5038 }
5039
5040 #[tokio::test]
5041 async fn test_random_reseed() {
5042 let mut bash1 = Bash::new();
5044 let mut bash2 = Bash::new();
5045 bash1.exec("RANDOM=42").await.unwrap();
5046 bash2.exec("RANDOM=42").await.unwrap();
5047 let r1 = bash1.exec("echo $RANDOM").await.unwrap();
5048 let r2 = bash2.exec("echo $RANDOM").await.unwrap();
5049 assert_eq!(
5050 r1.stdout, r2.stdout,
5051 "same seed should produce same first value"
5052 );
5053 }
5054
5055 #[tokio::test]
5056 async fn test_random_sequential_varies() {
5057 let mut bash = Bash::new();
5059 let result = bash.exec("echo $RANDOM $RANDOM $RANDOM").await.unwrap();
5060 let values: Vec<u32> = result
5061 .stdout
5062 .split_whitespace()
5063 .map(|s| s.parse().expect("should be a number"))
5064 .collect();
5065 assert_eq!(values.len(), 3);
5066 assert!(
5068 values[0] != values[1] || values[1] != values[2],
5069 "sequential RANDOM calls should produce different values"
5070 );
5071 }
5072
5073 #[tokio::test]
5074 async fn test_special_var_lineno() {
5075 let mut bash = Bash::new();
5077 let result = bash.exec("echo $LINENO").await.unwrap();
5078 assert_eq!(result.stdout, "1\n");
5079 }
5080
5081 #[tokio::test]
5082 async fn test_lineno_multiline() {
5083 let mut bash = Bash::new();
5085 let result = bash
5086 .exec(
5087 r#"echo "line $LINENO"
5088echo "line $LINENO"
5089echo "line $LINENO""#,
5090 )
5091 .await
5092 .unwrap();
5093 assert_eq!(result.stdout, "line 1\nline 2\nline 3\n");
5094 }
5095
5096 #[tokio::test]
5097 async fn test_lineno_in_loop() {
5098 let mut bash = Bash::new();
5100 let result = bash
5101 .exec(
5102 r#"for i in 1 2; do
5103 echo "loop $LINENO"
5104done"#,
5105 )
5106 .await
5107 .unwrap();
5108 assert_eq!(result.stdout, "loop 2\nloop 2\n");
5110 }
5111
5112 #[tokio::test]
5115 async fn test_file_test_r_readable() {
5116 let mut bash = Bash::new();
5118 bash.exec("echo hello > /tmp/readable.txt").await.unwrap();
5119 let result = bash
5120 .exec("test -r /tmp/readable.txt && echo yes")
5121 .await
5122 .unwrap();
5123 assert_eq!(result.stdout, "yes\n");
5124 }
5125
5126 #[tokio::test]
5127 async fn test_file_test_r_not_exists() {
5128 let mut bash = Bash::new();
5130 let result = bash
5131 .exec("test -r /tmp/nonexistent.txt && echo yes || echo no")
5132 .await
5133 .unwrap();
5134 assert_eq!(result.stdout, "no\n");
5135 }
5136
5137 #[tokio::test]
5138 async fn test_file_test_w_writable() {
5139 let mut bash = Bash::new();
5141 bash.exec("echo hello > /tmp/writable.txt").await.unwrap();
5142 let result = bash
5143 .exec("test -w /tmp/writable.txt && echo yes")
5144 .await
5145 .unwrap();
5146 assert_eq!(result.stdout, "yes\n");
5147 }
5148
5149 #[tokio::test]
5150 async fn test_file_test_x_executable() {
5151 let mut bash = Bash::new();
5153 bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
5154 .await
5155 .unwrap();
5156 bash.exec("chmod 755 /tmp/script.sh").await.unwrap();
5157 let result = bash
5158 .exec("test -x /tmp/script.sh && echo yes")
5159 .await
5160 .unwrap();
5161 assert_eq!(result.stdout, "yes\n");
5162 }
5163
5164 #[tokio::test]
5165 async fn test_file_test_x_not_executable() {
5166 let mut bash = Bash::new();
5168 bash.exec("echo 'data' > /tmp/noexec.txt").await.unwrap();
5169 bash.exec("chmod 644 /tmp/noexec.txt").await.unwrap();
5170 let result = bash
5171 .exec("test -x /tmp/noexec.txt && echo yes || echo no")
5172 .await
5173 .unwrap();
5174 assert_eq!(result.stdout, "no\n");
5175 }
5176
5177 #[tokio::test]
5178 async fn test_file_test_e_exists() {
5179 let mut bash = Bash::new();
5181 bash.exec("echo hello > /tmp/exists.txt").await.unwrap();
5182 let result = bash
5183 .exec("test -e /tmp/exists.txt && echo yes")
5184 .await
5185 .unwrap();
5186 assert_eq!(result.stdout, "yes\n");
5187 }
5188
5189 #[tokio::test]
5190 async fn test_file_test_f_regular() {
5191 let mut bash = Bash::new();
5193 bash.exec("echo hello > /tmp/regular.txt").await.unwrap();
5194 let result = bash
5195 .exec("test -f /tmp/regular.txt && echo yes")
5196 .await
5197 .unwrap();
5198 assert_eq!(result.stdout, "yes\n");
5199 }
5200
5201 #[tokio::test]
5202 async fn test_file_test_d_directory() {
5203 let mut bash = Bash::new();
5205 bash.exec("mkdir -p /tmp/mydir").await.unwrap();
5206 let result = bash.exec("test -d /tmp/mydir && echo yes").await.unwrap();
5207 assert_eq!(result.stdout, "yes\n");
5208 }
5209
5210 #[tokio::test]
5211 async fn test_file_test_s_size() {
5212 let mut bash = Bash::new();
5214 bash.exec("echo hello > /tmp/nonempty.txt").await.unwrap();
5215 let result = bash
5216 .exec("test -s /tmp/nonempty.txt && echo yes")
5217 .await
5218 .unwrap();
5219 assert_eq!(result.stdout, "yes\n");
5220 }
5221
5222 #[tokio::test]
5227 async fn test_redirect_both_stdout_stderr() {
5228 let mut bash = Bash::new();
5230 let result = bash.exec("echo hello &> /tmp/out.txt").await.unwrap();
5232 assert_eq!(result.stdout, "");
5234 let check = bash.exec("cat /tmp/out.txt").await.unwrap();
5236 assert_eq!(check.stdout, "hello\n");
5237 }
5238
5239 #[tokio::test]
5240 async fn test_stderr_redirect_to_file() {
5241 let mut bash = Bash::new();
5245 bash.exec("echo stdout; echo stderr 2> /tmp/err.txt")
5247 .await
5248 .unwrap();
5249 }
5252
5253 #[tokio::test]
5254 async fn test_fd_redirect_parsing() {
5255 let mut bash = Bash::new();
5257 let result = bash.exec("true 2> /tmp/err.txt").await.unwrap();
5259 assert_eq!(result.exit_code, 0);
5260 }
5261
5262 #[tokio::test]
5263 async fn test_fd_redirect_append_parsing() {
5264 let mut bash = Bash::new();
5266 let result = bash.exec("true 2>> /tmp/err.txt").await.unwrap();
5267 assert_eq!(result.exit_code, 0);
5268 }
5269
5270 #[tokio::test]
5271 async fn test_fd_dup_parsing() {
5272 let mut bash = Bash::new();
5274 let result = bash.exec("echo hello 2>&1").await.unwrap();
5275 assert_eq!(result.stdout, "hello\n");
5276 assert_eq!(result.exit_code, 0);
5277 }
5278
5279 #[tokio::test]
5280 async fn test_dup_output_redirect_stdout_to_stderr() {
5281 let mut bash = Bash::new();
5283 let result = bash.exec("echo hello >&2").await.unwrap();
5284 assert_eq!(result.stdout, "");
5286 assert_eq!(result.stderr, "hello\n");
5287 }
5288
5289 #[tokio::test]
5290 async fn test_lexer_redirect_both() {
5291 let mut bash = Bash::new();
5293 let result = bash.exec("echo test &> /tmp/both.txt").await.unwrap();
5295 assert_eq!(result.stdout, "");
5296 let check = bash.exec("cat /tmp/both.txt").await.unwrap();
5297 assert_eq!(check.stdout, "test\n");
5298 }
5299
5300 #[tokio::test]
5301 async fn test_lexer_dup_output() {
5302 let mut bash = Bash::new();
5304 let result = bash.exec("echo test >&2").await.unwrap();
5305 assert_eq!(result.stdout, "");
5306 assert_eq!(result.stderr, "test\n");
5307 }
5308
5309 #[tokio::test]
5310 async fn test_digit_before_redirect() {
5311 let mut bash = Bash::new();
5313 let result = bash.exec("echo hello 2> /tmp/err.txt").await.unwrap();
5315 assert_eq!(result.exit_code, 0);
5316 assert_eq!(result.stdout, "hello\n");
5318 }
5319
5320 #[tokio::test]
5325 async fn test_arithmetic_logical_and_true() {
5326 let mut bash = Bash::new();
5328 let result = bash.exec("echo $((1 && 1))").await.unwrap();
5329 assert_eq!(result.stdout, "1\n");
5330 }
5331
5332 #[tokio::test]
5333 async fn test_arithmetic_logical_and_false_left() {
5334 let mut bash = Bash::new();
5336 let result = bash.exec("echo $((0 && 1))").await.unwrap();
5337 assert_eq!(result.stdout, "0\n");
5338 }
5339
5340 #[tokio::test]
5341 async fn test_arithmetic_logical_and_false_right() {
5342 let mut bash = Bash::new();
5344 let result = bash.exec("echo $((1 && 0))").await.unwrap();
5345 assert_eq!(result.stdout, "0\n");
5346 }
5347
5348 #[tokio::test]
5349 async fn test_arithmetic_logical_or_false() {
5350 let mut bash = Bash::new();
5352 let result = bash.exec("echo $((0 || 0))").await.unwrap();
5353 assert_eq!(result.stdout, "0\n");
5354 }
5355
5356 #[tokio::test]
5357 async fn test_arithmetic_logical_or_true_left() {
5358 let mut bash = Bash::new();
5360 let result = bash.exec("echo $((1 || 0))").await.unwrap();
5361 assert_eq!(result.stdout, "1\n");
5362 }
5363
5364 #[tokio::test]
5365 async fn test_arithmetic_logical_or_true_right() {
5366 let mut bash = Bash::new();
5368 let result = bash.exec("echo $((0 || 1))").await.unwrap();
5369 assert_eq!(result.stdout, "1\n");
5370 }
5371
5372 #[tokio::test]
5373 async fn test_arithmetic_logical_combined() {
5374 let mut bash = Bash::new();
5376 let result = bash.exec("echo $((5 > 3 && 2 < 4))").await.unwrap();
5378 assert_eq!(result.stdout, "1\n");
5379 }
5380
5381 #[tokio::test]
5382 async fn test_arithmetic_logical_with_comparison() {
5383 let mut bash = Bash::new();
5385 let result = bash.exec("echo $((5 < 3 || 2 < 4))").await.unwrap();
5387 assert_eq!(result.stdout, "1\n");
5388 }
5389
5390 #[tokio::test]
5391 async fn test_arithmetic_multibyte_no_panic() {
5392 let mut bash = Bash::new();
5394 let result = bash.exec("echo $((0,1))").await.unwrap();
5396 assert_eq!(result.stdout, "1\n");
5397 let _ = bash.exec("echo $((\u{00e9}+1))").await;
5399 }
5400
5401 #[tokio::test]
5406 async fn test_brace_expansion_list() {
5407 let mut bash = Bash::new();
5409 let result = bash.exec("echo {a,b,c}").await.unwrap();
5410 assert_eq!(result.stdout, "a b c\n");
5411 }
5412
5413 #[tokio::test]
5414 async fn test_brace_expansion_with_prefix() {
5415 let mut bash = Bash::new();
5417 let result = bash.exec("echo file{1,2,3}.txt").await.unwrap();
5418 assert_eq!(result.stdout, "file1.txt file2.txt file3.txt\n");
5419 }
5420
5421 #[tokio::test]
5422 async fn test_brace_expansion_numeric_range() {
5423 let mut bash = Bash::new();
5425 let result = bash.exec("echo {1..5}").await.unwrap();
5426 assert_eq!(result.stdout, "1 2 3 4 5\n");
5427 }
5428
5429 #[tokio::test]
5430 async fn test_brace_expansion_char_range() {
5431 let mut bash = Bash::new();
5433 let result = bash.exec("echo {a..e}").await.unwrap();
5434 assert_eq!(result.stdout, "a b c d e\n");
5435 }
5436
5437 #[tokio::test]
5438 async fn test_brace_expansion_reverse_range() {
5439 let mut bash = Bash::new();
5441 let result = bash.exec("echo {5..1}").await.unwrap();
5442 assert_eq!(result.stdout, "5 4 3 2 1\n");
5443 }
5444
5445 #[tokio::test]
5446 async fn test_brace_expansion_nested() {
5447 let mut bash = Bash::new();
5449 let result = bash.exec("echo {a,b}{1,2}").await.unwrap();
5450 assert_eq!(result.stdout, "a1 a2 b1 b2\n");
5451 }
5452
5453 #[tokio::test]
5454 async fn test_brace_expansion_with_suffix() {
5455 let mut bash = Bash::new();
5457 let result = bash.exec("echo pre{x,y}suf").await.unwrap();
5458 assert_eq!(result.stdout, "prexsuf preysuf\n");
5459 }
5460
5461 #[tokio::test]
5462 async fn test_brace_expansion_empty_item() {
5463 let mut bash = Bash::new();
5465 let result = bash.exec("echo x{,y}z").await.unwrap();
5466 assert_eq!(result.stdout, "xz xyz\n");
5467 }
5468
5469 #[tokio::test]
5474 async fn test_string_less_than() {
5475 let mut bash = Bash::new();
5476 let result = bash
5477 .exec("test apple '<' banana && echo yes")
5478 .await
5479 .unwrap();
5480 assert_eq!(result.stdout, "yes\n");
5481 }
5482
5483 #[tokio::test]
5484 async fn test_string_greater_than() {
5485 let mut bash = Bash::new();
5486 let result = bash
5487 .exec("test banana '>' apple && echo yes")
5488 .await
5489 .unwrap();
5490 assert_eq!(result.stdout, "yes\n");
5491 }
5492
5493 #[tokio::test]
5494 async fn test_string_less_than_false() {
5495 let mut bash = Bash::new();
5496 let result = bash
5497 .exec("test banana '<' apple && echo yes || echo no")
5498 .await
5499 .unwrap();
5500 assert_eq!(result.stdout, "no\n");
5501 }
5502
5503 #[tokio::test]
5508 async fn test_array_indices_basic() {
5509 let mut bash = Bash::new();
5511 let result = bash.exec("arr=(a b c); echo ${!arr[@]}").await.unwrap();
5512 assert_eq!(result.stdout, "0 1 2\n");
5513 }
5514
5515 #[tokio::test]
5516 async fn test_array_indices_sparse() {
5517 let mut bash = Bash::new();
5519 let result = bash
5520 .exec("arr[0]=a; arr[5]=b; arr[10]=c; echo ${!arr[@]}")
5521 .await
5522 .unwrap();
5523 assert_eq!(result.stdout, "0 5 10\n");
5524 }
5525
5526 #[tokio::test]
5527 async fn test_array_indices_star() {
5528 let mut bash = Bash::new();
5530 let result = bash.exec("arr=(x y z); echo ${!arr[*]}").await.unwrap();
5531 assert_eq!(result.stdout, "0 1 2\n");
5532 }
5533
5534 #[tokio::test]
5535 async fn test_array_indices_empty() {
5536 let mut bash = Bash::new();
5538 let result = bash.exec("arr=(); echo \"${!arr[@]}\"").await.unwrap();
5539 assert_eq!(result.stdout, "\n");
5540 }
5541
5542 #[tokio::test]
5547 async fn test_text_file_basic() {
5548 let mut bash = Bash::builder()
5549 .mount_text("/config/app.conf", "debug=true\nport=8080\n")
5550 .build();
5551
5552 let result = bash.exec("cat /config/app.conf").await.unwrap();
5553 assert_eq!(result.stdout, "debug=true\nport=8080\n");
5554 }
5555
5556 #[tokio::test]
5557 async fn test_text_file_multiple() {
5558 let mut bash = Bash::builder()
5559 .mount_text("/data/file1.txt", "content one")
5560 .mount_text("/data/file2.txt", "content two")
5561 .mount_text("/other/file3.txt", "content three")
5562 .build();
5563
5564 let result = bash.exec("cat /data/file1.txt").await.unwrap();
5565 assert_eq!(result.stdout, "content one");
5566
5567 let result = bash.exec("cat /data/file2.txt").await.unwrap();
5568 assert_eq!(result.stdout, "content two");
5569
5570 let result = bash.exec("cat /other/file3.txt").await.unwrap();
5571 assert_eq!(result.stdout, "content three");
5572 }
5573
5574 #[tokio::test]
5575 async fn test_text_file_nested_directory() {
5576 let mut bash = Bash::builder()
5578 .mount_text("/a/b/c/d/file.txt", "nested content")
5579 .build();
5580
5581 let result = bash.exec("cat /a/b/c/d/file.txt").await.unwrap();
5582 assert_eq!(result.stdout, "nested content");
5583 }
5584
5585 #[tokio::test]
5586 async fn test_text_file_mode() {
5587 let bash = Bash::builder()
5588 .mount_text("/tmp/writable.txt", "content")
5589 .build();
5590
5591 let stat = bash
5592 .fs()
5593 .stat(std::path::Path::new("/tmp/writable.txt"))
5594 .await
5595 .unwrap();
5596 assert_eq!(stat.mode, 0o644);
5597 }
5598
5599 #[tokio::test]
5600 async fn test_readonly_text_basic() {
5601 let mut bash = Bash::builder()
5602 .mount_readonly_text("/etc/version", "1.2.3")
5603 .build();
5604
5605 let result = bash.exec("cat /etc/version").await.unwrap();
5606 assert_eq!(result.stdout, "1.2.3");
5607 }
5608
5609 #[tokio::test]
5610 async fn test_readonly_text_mode() {
5611 let bash = Bash::builder()
5612 .mount_readonly_text("/etc/readonly.conf", "immutable")
5613 .build();
5614
5615 let stat = bash
5616 .fs()
5617 .stat(std::path::Path::new("/etc/readonly.conf"))
5618 .await
5619 .unwrap();
5620 assert_eq!(stat.mode, 0o444);
5621 }
5622
5623 #[tokio::test]
5624 async fn test_text_file_mixed_readonly_writable() {
5625 let bash = Bash::builder()
5626 .mount_text("/data/writable.txt", "can edit")
5627 .mount_readonly_text("/data/readonly.txt", "cannot edit")
5628 .build();
5629
5630 let writable_stat = bash
5631 .fs()
5632 .stat(std::path::Path::new("/data/writable.txt"))
5633 .await
5634 .unwrap();
5635 let readonly_stat = bash
5636 .fs()
5637 .stat(std::path::Path::new("/data/readonly.txt"))
5638 .await
5639 .unwrap();
5640
5641 assert_eq!(writable_stat.mode, 0o644);
5642 assert_eq!(readonly_stat.mode, 0o444);
5643 }
5644
5645 #[tokio::test]
5646 async fn test_text_file_with_env() {
5647 let mut bash = Bash::builder()
5649 .env("APP_NAME", "testapp")
5650 .mount_text("/config/app.conf", "name=${APP_NAME}")
5651 .build();
5652
5653 let result = bash.exec("echo $APP_NAME").await.unwrap();
5654 assert_eq!(result.stdout, "testapp\n");
5655
5656 let result = bash.exec("cat /config/app.conf").await.unwrap();
5657 assert_eq!(result.stdout, "name=${APP_NAME}");
5658 }
5659
5660 #[tokio::test]
5661 #[cfg(feature = "jq")]
5662 async fn test_text_file_json() {
5663 let mut bash = Bash::builder()
5664 .mount_text("/data/users.json", r#"["alice", "bob", "charlie"]"#)
5665 .build();
5666
5667 let result = bash.exec("cat /data/users.json | jq '.[0]'").await.unwrap();
5668 assert_eq!(result.stdout, "\"alice\"\n");
5669 }
5670
5671 #[tokio::test]
5672 async fn test_mount_with_custom_filesystem() {
5673 let custom_fs = std::sync::Arc::new(InMemoryFs::new());
5675
5676 custom_fs
5678 .write_file(std::path::Path::new("/base.txt"), b"from base")
5679 .await
5680 .unwrap();
5681
5682 let mut bash = Bash::builder()
5683 .fs(custom_fs)
5684 .mount_text("/mounted.txt", "from mount")
5685 .mount_readonly_text("/readonly.txt", "immutable")
5686 .build();
5687
5688 let result = bash.exec("cat /base.txt").await.unwrap();
5690 assert_eq!(result.stdout, "from base");
5691
5692 let result = bash.exec("cat /mounted.txt").await.unwrap();
5694 assert_eq!(result.stdout, "from mount");
5695
5696 let result = bash.exec("cat /readonly.txt").await.unwrap();
5697 assert_eq!(result.stdout, "immutable");
5698
5699 let stat = bash
5701 .fs()
5702 .stat(std::path::Path::new("/readonly.txt"))
5703 .await
5704 .unwrap();
5705 assert_eq!(stat.mode, 0o444);
5706 }
5707
5708 #[tokio::test]
5709 async fn test_mount_overwrites_base_file() {
5710 let custom_fs = std::sync::Arc::new(InMemoryFs::new());
5712 custom_fs
5713 .write_file(std::path::Path::new("/config.txt"), b"original")
5714 .await
5715 .unwrap();
5716
5717 let mut bash = Bash::builder()
5718 .fs(custom_fs)
5719 .mount_text("/config.txt", "overwritten")
5720 .build();
5721
5722 let result = bash.exec("cat /config.txt").await.unwrap();
5723 assert_eq!(result.stdout, "overwritten");
5724 }
5725
5726 #[tokio::test]
5727 async fn test_mount_preserves_custom_fs_limits() {
5728 let limited_fs =
5729 std::sync::Arc::new(InMemoryFs::with_limits(FsLimits::new().max_total_bytes(32)));
5730
5731 let bash = Bash::builder()
5732 .fs(limited_fs)
5733 .mount_text("/mounted.txt", "seed")
5734 .build();
5735
5736 let write_err = bash
5737 .fs()
5738 .write_file(
5739 std::path::Path::new("/too-big.txt"),
5740 b"this payload should exceed thirty-two bytes",
5741 )
5742 .await;
5743 assert!(write_err.is_err(), "custom fs limits should still apply");
5744 }
5745
5746 #[tokio::test]
5747 async fn test_mount_text_respects_filesystem_limits() {
5748 let limited_fs = std::sync::Arc::new(InMemoryFs::with_limits(
5749 FsLimits::new().max_total_bytes(5).max_file_size(5),
5750 ));
5751
5752 let bash = Bash::builder()
5753 .fs(limited_fs)
5754 .mount_text("/too-large.txt", "123456")
5755 .build();
5756
5757 let exists = bash
5758 .fs()
5759 .exists(std::path::Path::new("/too-large.txt"))
5760 .await
5761 .unwrap();
5762 assert!(!exists, "mount_text should not bypass configured FsLimits");
5763 }
5764
5765 #[tokio::test]
5770 async fn test_parse_error_includes_line_number() {
5771 let mut bash = Bash::new();
5773 let result = bash
5774 .exec(
5775 r#"echo ok
5776if true; then
5777echo missing fi"#,
5778 )
5779 .await;
5780 assert!(result.is_err());
5782 let err = result.unwrap_err();
5783 let err_msg = format!("{}", err);
5784 assert!(
5786 err_msg.contains("line") || err_msg.contains("parse"),
5787 "Error should be a parse error: {}",
5788 err_msg
5789 );
5790 }
5791
5792 #[tokio::test]
5793 async fn test_parse_error_on_specific_line() {
5794 use crate::parser::Parser;
5796 let script = "echo line1\necho line2\nif true; then\n";
5797 let result = Parser::new(script).parse();
5798 assert!(result.is_err());
5799 let err = result.unwrap_err();
5800 let err_msg = format!("{}", err);
5801 assert!(
5803 err_msg.contains("expected") || err_msg.contains("syntax error"),
5804 "Error should be a parse error: {}",
5805 err_msg
5806 );
5807 }
5808
5809 #[tokio::test]
5812 async fn test_cd_to_root_and_ls() {
5813 let mut bash = Bash::new();
5815 let result = bash.exec("cd / && ls").await.unwrap();
5816 assert_eq!(
5817 result.exit_code, 0,
5818 "cd / && ls should succeed: {}",
5819 result.stderr
5820 );
5821 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
5822 assert!(result.stdout.contains("home"), "Root should contain home");
5823 }
5824
5825 #[tokio::test]
5826 async fn test_cd_to_root_and_pwd() {
5827 let mut bash = Bash::new();
5829 let result = bash.exec("cd / && pwd").await.unwrap();
5830 assert_eq!(result.exit_code, 0, "cd / && pwd should succeed");
5831 assert_eq!(result.stdout.trim(), "/");
5832 }
5833
5834 #[tokio::test]
5835 async fn test_cd_to_root_and_ls_dot() {
5836 let mut bash = Bash::new();
5838 let result = bash.exec("cd / && ls .").await.unwrap();
5839 assert_eq!(
5840 result.exit_code, 0,
5841 "cd / && ls . should succeed: {}",
5842 result.stderr
5843 );
5844 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
5845 assert!(result.stdout.contains("home"), "Root should contain home");
5846 }
5847
5848 #[tokio::test]
5849 async fn test_ls_root_directly() {
5850 let mut bash = Bash::new();
5852 let result = bash.exec("ls /").await.unwrap();
5853 assert_eq!(
5854 result.exit_code, 0,
5855 "ls / should succeed: {}",
5856 result.stderr
5857 );
5858 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
5859 assert!(result.stdout.contains("home"), "Root should contain home");
5860 assert!(result.stdout.contains("dev"), "Root should contain dev");
5861 }
5862
5863 #[tokio::test]
5864 async fn test_ls_root_long_format() {
5865 let mut bash = Bash::new();
5867 let result = bash.exec("ls -la /").await.unwrap();
5868 assert_eq!(
5869 result.exit_code, 0,
5870 "ls -la / should succeed: {}",
5871 result.stderr
5872 );
5873 assert!(result.stdout.contains("tmp"), "Root should contain tmp");
5874 assert!(
5875 result.stdout.contains("drw"),
5876 "Should show directory permissions"
5877 );
5878 }
5879
5880 #[tokio::test]
5883 async fn test_heredoc_redirect_to_file() {
5884 let mut bash = Bash::new();
5886 let result = bash
5887 .exec("cat > /tmp/out.txt <<'EOF'\nhello\nworld\nEOF\ncat /tmp/out.txt")
5888 .await
5889 .unwrap();
5890 assert_eq!(result.stdout, "hello\nworld\n");
5891 assert_eq!(result.exit_code, 0);
5892 }
5893
5894 #[tokio::test]
5895 async fn test_heredoc_redirect_to_file_unquoted() {
5896 let mut bash = Bash::new();
5897 let result = bash
5898 .exec("cat > /tmp/out.txt <<EOF\nhello\nworld\nEOF\ncat /tmp/out.txt")
5899 .await
5900 .unwrap();
5901 assert_eq!(result.stdout, "hello\nworld\n");
5902 assert_eq!(result.exit_code, 0);
5903 }
5904
5905 #[tokio::test]
5908 async fn test_pipe_to_while_read() {
5909 let mut bash = Bash::new();
5911 let result = bash
5912 .exec("echo -e 'a\\nb\\nc' | while read line; do echo \"got: $line\"; done")
5913 .await
5914 .unwrap();
5915 assert!(
5916 result.stdout.contains("got: a"),
5917 "stdout: {}",
5918 result.stdout
5919 );
5920 assert!(
5921 result.stdout.contains("got: b"),
5922 "stdout: {}",
5923 result.stdout
5924 );
5925 assert!(
5926 result.stdout.contains("got: c"),
5927 "stdout: {}",
5928 result.stdout
5929 );
5930 }
5931
5932 #[tokio::test]
5933 async fn test_pipe_to_while_read_count() {
5934 let mut bash = Bash::new();
5935 let result = bash
5936 .exec("printf 'x\\ny\\nz\\n' | while read line; do echo $line; done")
5937 .await
5938 .unwrap();
5939 assert_eq!(result.stdout, "x\ny\nz\n");
5940 }
5941
5942 #[tokio::test]
5945 async fn test_source_loads_functions() {
5946 let mut bash = Bash::new();
5947 bash.exec("cat > /tmp/lib.sh <<'EOF'\ngreet() { echo \"hello $1\"; }\nEOF")
5949 .await
5950 .unwrap();
5951 let result = bash.exec("source /tmp/lib.sh; greet world").await.unwrap();
5952 assert_eq!(result.stdout, "hello world\n");
5953 assert_eq!(result.exit_code, 0);
5954 }
5955
5956 #[tokio::test]
5957 async fn test_source_loads_variables() {
5958 let mut bash = Bash::new();
5959 bash.exec("echo 'MY_VAR=loaded' > /tmp/vars.sh")
5960 .await
5961 .unwrap();
5962 let result = bash
5963 .exec("source /tmp/vars.sh; echo $MY_VAR")
5964 .await
5965 .unwrap();
5966 assert_eq!(result.stdout, "loaded\n");
5967 }
5968
5969 #[tokio::test]
5972 async fn test_chmod_symbolic_plus_x() {
5973 let mut bash = Bash::new();
5974 bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
5975 .await
5976 .unwrap();
5977 let result = bash.exec("chmod +x /tmp/script.sh").await.unwrap();
5978 assert_eq!(
5979 result.exit_code, 0,
5980 "chmod +x should succeed: {}",
5981 result.stderr
5982 );
5983 }
5984
5985 #[tokio::test]
5986 async fn test_chmod_symbolic_u_plus_x() {
5987 let mut bash = Bash::new();
5988 bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
5989 let result = bash.exec("chmod u+x /tmp/file.txt").await.unwrap();
5990 assert_eq!(
5991 result.exit_code, 0,
5992 "chmod u+x should succeed: {}",
5993 result.stderr
5994 );
5995 }
5996
5997 #[tokio::test]
5998 async fn test_chmod_symbolic_a_plus_r() {
5999 let mut bash = Bash::new();
6000 bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
6001 let result = bash.exec("chmod a+r /tmp/file.txt").await.unwrap();
6002 assert_eq!(
6003 result.exit_code, 0,
6004 "chmod a+r should succeed: {}",
6005 result.stderr
6006 );
6007 }
6008
6009 #[tokio::test]
6012 async fn test_awk_array_length() {
6013 let mut bash = Bash::new();
6015 let result = bash
6016 .exec(r#"echo "" | awk 'BEGIN{a[1]="x"; a[2]="y"; a[3]="z"} END{print length(a)}'"#)
6017 .await
6018 .unwrap();
6019 assert_eq!(result.stdout, "3\n");
6020 }
6021
6022 #[tokio::test]
6023 async fn test_awk_array_read_after_split() {
6024 let mut bash = Bash::new();
6026 let result = bash
6027 .exec(r#"echo "a:b:c" | awk '{n=split($0,arr,":"); for(i=1;i<=n;i++) print arr[i]}'"#)
6028 .await
6029 .unwrap();
6030 assert_eq!(result.stdout, "a\nb\nc\n");
6031 }
6032
6033 #[tokio::test]
6034 async fn test_awk_array_word_count_pattern() {
6035 let mut bash = Bash::new();
6037 let result = bash
6038 .exec(
6039 r#"printf "apple\nbanana\napple\ncherry\nbanana\napple" | awk '{count[$1]++} END{for(w in count) print w, count[w]}'"#,
6040 )
6041 .await
6042 .unwrap();
6043 assert!(
6044 result.stdout.contains("apple 3"),
6045 "stdout: {}",
6046 result.stdout
6047 );
6048 assert!(
6049 result.stdout.contains("banana 2"),
6050 "stdout: {}",
6051 result.stdout
6052 );
6053 assert!(
6054 result.stdout.contains("cherry 1"),
6055 "stdout: {}",
6056 result.stdout
6057 );
6058 }
6059
6060 #[tokio::test]
6063 async fn test_exec_streaming_for_loop() {
6064 let chunks = Arc::new(Mutex::new(Vec::new()));
6065 let chunks_cb = chunks.clone();
6066 let mut bash = Bash::new();
6067
6068 let result = bash
6069 .exec_streaming(
6070 "for i in 1 2 3; do echo $i; done",
6071 Box::new(move |stdout, _stderr| {
6072 chunks_cb.lock().unwrap().push(stdout.to_string());
6073 }),
6074 )
6075 .await
6076 .unwrap();
6077
6078 assert_eq!(result.stdout, "1\n2\n3\n");
6079 assert_eq!(
6080 *chunks.lock().unwrap(),
6081 vec!["1\n", "2\n", "3\n"],
6082 "each loop iteration should stream separately"
6083 );
6084 }
6085
6086 #[tokio::test]
6087 async fn test_exec_streaming_while_loop() {
6088 let chunks = Arc::new(Mutex::new(Vec::new()));
6089 let chunks_cb = chunks.clone();
6090 let mut bash = Bash::new();
6091
6092 let result = bash
6093 .exec_streaming(
6094 "i=0; while [ $i -lt 3 ]; do i=$((i+1)); echo $i; done",
6095 Box::new(move |stdout, _stderr| {
6096 chunks_cb.lock().unwrap().push(stdout.to_string());
6097 }),
6098 )
6099 .await
6100 .unwrap();
6101
6102 assert_eq!(result.stdout, "1\n2\n3\n");
6103 let chunks = chunks.lock().unwrap();
6104 assert!(
6106 chunks.contains(&"1\n".to_string()),
6107 "should contain first iteration output"
6108 );
6109 assert!(
6110 chunks.contains(&"2\n".to_string()),
6111 "should contain second iteration output"
6112 );
6113 assert!(
6114 chunks.contains(&"3\n".to_string()),
6115 "should contain third iteration output"
6116 );
6117 }
6118
6119 #[tokio::test]
6120 async fn test_exec_streaming_no_callback_still_works() {
6121 let mut bash = Bash::new();
6123 let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
6124 assert_eq!(result.stdout, "a\nb\nc\n");
6125 }
6126
6127 #[tokio::test]
6128 async fn test_exec_streaming_nested_loops_no_duplicates() {
6129 let chunks = Arc::new(Mutex::new(Vec::new()));
6130 let chunks_cb = chunks.clone();
6131 let mut bash = Bash::new();
6132
6133 let result = bash
6134 .exec_streaming(
6135 "for i in 1 2; do for j in a b; do echo \"$i$j\"; done; done",
6136 Box::new(move |stdout, _stderr| {
6137 chunks_cb.lock().unwrap().push(stdout.to_string());
6138 }),
6139 )
6140 .await
6141 .unwrap();
6142
6143 assert_eq!(result.stdout, "1a\n1b\n2a\n2b\n");
6144 let chunks = chunks.lock().unwrap();
6145 let total_chars: usize = chunks.iter().map(|c| c.len()).sum();
6147 assert_eq!(
6148 total_chars,
6149 result.stdout.len(),
6150 "total streamed bytes should match final output: chunks={:?}",
6151 *chunks
6152 );
6153 }
6154
6155 #[tokio::test]
6156 async fn test_exec_streaming_mixed_list_and_loop() {
6157 let chunks = Arc::new(Mutex::new(Vec::new()));
6158 let chunks_cb = chunks.clone();
6159 let mut bash = Bash::new();
6160
6161 let result = bash
6162 .exec_streaming(
6163 "echo start; for i in 1 2; do echo $i; done; echo end",
6164 Box::new(move |stdout, _stderr| {
6165 chunks_cb.lock().unwrap().push(stdout.to_string());
6166 }),
6167 )
6168 .await
6169 .unwrap();
6170
6171 assert_eq!(result.stdout, "start\n1\n2\nend\n");
6172 let chunks = chunks.lock().unwrap();
6173 assert_eq!(
6174 *chunks,
6175 vec!["start\n", "1\n", "2\n", "end\n"],
6176 "mixed list+loop should produce exactly 4 events"
6177 );
6178 }
6179
6180 #[tokio::test]
6181 async fn test_exec_streaming_stderr() {
6182 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
6183 let stderr_cb = stderr_chunks.clone();
6184 let mut bash = Bash::new();
6185
6186 let result = bash
6187 .exec_streaming(
6188 "echo ok; echo err >&2; echo ok2",
6189 Box::new(move |_stdout, stderr| {
6190 if !stderr.is_empty() {
6191 stderr_cb.lock().unwrap().push(stderr.to_string());
6192 }
6193 }),
6194 )
6195 .await
6196 .unwrap();
6197
6198 assert_eq!(result.stdout, "ok\nok2\n");
6199 assert_eq!(result.stderr, "err\n");
6200 let stderr_chunks = stderr_chunks.lock().unwrap();
6201 assert!(
6202 stderr_chunks.contains(&"err\n".to_string()),
6203 "stderr should be streamed: {:?}",
6204 *stderr_chunks
6205 );
6206 }
6207
6208 async fn assert_streaming_equivalence(script: &str) {
6215 let mut bash_plain = Bash::new();
6217 let plain = bash_plain.exec(script).await.unwrap();
6218
6219 let stdout_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
6221 let stderr_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
6222 let so = stdout_chunks.clone();
6223 let se = stderr_chunks.clone();
6224 let mut bash_stream = Bash::new();
6225 let streamed = bash_stream
6226 .exec_streaming(
6227 script,
6228 Box::new(move |stdout, stderr| {
6229 if !stdout.is_empty() {
6230 so.lock().unwrap().push(stdout.to_string());
6231 }
6232 if !stderr.is_empty() {
6233 se.lock().unwrap().push(stderr.to_string());
6234 }
6235 }),
6236 )
6237 .await
6238 .unwrap();
6239
6240 assert_eq!(
6242 plain.stdout, streamed.stdout,
6243 "stdout mismatch for: {script}"
6244 );
6245 assert_eq!(
6246 plain.stderr, streamed.stderr,
6247 "stderr mismatch for: {script}"
6248 );
6249 assert_eq!(
6250 plain.exit_code, streamed.exit_code,
6251 "exit_code mismatch for: {script}"
6252 );
6253
6254 let reassembled_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
6256 assert_eq!(
6257 reassembled_stdout, streamed.stdout,
6258 "reassembled stdout chunks != final stdout for: {script}"
6259 );
6260 let reassembled_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
6261 assert_eq!(
6262 reassembled_stderr, streamed.stderr,
6263 "reassembled stderr chunks != final stderr for: {script}"
6264 );
6265 }
6266
6267 #[tokio::test]
6268 async fn test_streaming_equivalence_for_loop() {
6269 assert_streaming_equivalence("for i in 1 2 3; do echo $i; done").await;
6270 }
6271
6272 #[tokio::test]
6273 async fn test_streaming_equivalence_while_loop() {
6274 assert_streaming_equivalence("i=0; while [ $i -lt 4 ]; do i=$((i+1)); echo $i; done").await;
6275 }
6276
6277 #[tokio::test]
6278 async fn test_streaming_equivalence_nested_loops() {
6279 assert_streaming_equivalence("for i in a b; do for j in 1 2; do echo \"$i$j\"; done; done")
6280 .await;
6281 }
6282
6283 #[tokio::test]
6284 async fn test_streaming_equivalence_mixed_list() {
6285 assert_streaming_equivalence("echo start; for i in x y; do echo $i; done; echo end").await;
6286 }
6287
6288 #[tokio::test]
6289 async fn test_streaming_equivalence_stderr() {
6290 assert_streaming_equivalence("echo out; echo err >&2; echo out2").await;
6291 }
6292
6293 #[tokio::test]
6294 async fn test_streaming_equivalence_pipeline() {
6295 assert_streaming_equivalence("echo -e 'a\\nb\\nc' | grep b").await;
6296 }
6297
6298 #[tokio::test]
6299 async fn test_streaming_equivalence_conditionals() {
6300 assert_streaming_equivalence("if true; then echo yes; else echo no; fi; echo done").await;
6301 }
6302
6303 #[tokio::test]
6304 async fn test_streaming_equivalence_subshell() {
6305 assert_streaming_equivalence("x=$(echo hello); echo $x").await;
6306 }
6307
6308 #[tokio::test]
6309 async fn test_max_memory_caps_string_growth() {
6310 let mut bash = Bash::builder()
6311 .max_memory(1024)
6312 .limits(
6313 ExecutionLimits::new()
6314 .max_commands(10_000)
6315 .max_loop_iterations(10_000),
6316 )
6317 .build();
6318 let result = bash
6319 .exec(r#"x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}"#)
6320 .await
6321 .unwrap();
6322 let len: usize = result.stdout.trim().parse().unwrap();
6323 assert!(len <= 1024, "string length {len} must be ≤ 1024");
6325 }
6326
6327 #[tokio::test]
6329 async fn test_stderr_redirect_devnull_streaming() {
6330 let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
6331 let stderr_cb = stderr_chunks.clone();
6332 let mut bash = Bash::new();
6333
6334 let result = bash
6336 .exec_streaming(
6337 "{ ls /nonexistent; } 2>/dev/null; echo exit:$?",
6338 Box::new(move |_stdout, stderr| {
6339 if !stderr.is_empty() {
6340 stderr_cb.lock().unwrap().push(stderr.to_string());
6341 }
6342 }),
6343 )
6344 .await
6345 .unwrap();
6346
6347 assert_eq!(result.stderr, "", "final stderr should be empty");
6348 let stderr_chunks = stderr_chunks.lock().unwrap();
6349 assert!(
6350 stderr_chunks.is_empty(),
6351 "no stderr should be streamed when 2>/dev/null is used, got: {:?}",
6352 *stderr_chunks
6353 );
6354 }
6355
6356 #[tokio::test]
6357 async fn test_dot_slash_prefix_ls() {
6358 let mut bash = Bash::new();
6360 bash.exec("mkdir -p /tmp/blogtest && cd /tmp/blogtest && echo hello > tag_hello.html")
6361 .await
6362 .unwrap();
6363
6364 let result = bash
6366 .exec("cd /tmp/blogtest && ls tag_hello.html")
6367 .await
6368 .unwrap();
6369 assert_eq!(
6370 result.exit_code, 0,
6371 "ls tag_hello.html should succeed: {}",
6372 result.stderr
6373 );
6374 assert!(result.stdout.contains("tag_hello.html"));
6375
6376 let result = bash
6378 .exec("cd /tmp/blogtest && ls ./tag_hello.html")
6379 .await
6380 .unwrap();
6381 assert_eq!(
6382 result.exit_code, 0,
6383 "ls ./tag_hello.html should succeed: {}",
6384 result.stderr
6385 );
6386 assert!(result.stdout.contains("tag_hello.html"));
6387 }
6388
6389 #[tokio::test]
6390 async fn test_dot_slash_prefix_glob() {
6391 let mut bash = Bash::new();
6393 bash.exec("mkdir -p /tmp/globtest && cd /tmp/globtest && echo hello > tag_hello.html")
6394 .await
6395 .unwrap();
6396
6397 let result = bash.exec("cd /tmp/globtest && echo *.html").await.unwrap();
6399 assert_eq!(
6400 result.exit_code, 0,
6401 "echo *.html should succeed: {}",
6402 result.stderr
6403 );
6404 assert!(result.stdout.contains("tag_hello.html"));
6405
6406 let result = bash
6408 .exec("cd /tmp/globtest && echo ./*.html")
6409 .await
6410 .unwrap();
6411 assert_eq!(
6412 result.exit_code, 0,
6413 "echo ./*.html should succeed: {}",
6414 result.stderr
6415 );
6416 assert!(result.stdout.contains("tag_hello.html"));
6417 }
6418
6419 #[tokio::test]
6420 async fn test_dot_slash_prefix_cat() {
6421 let mut bash = Bash::new();
6423 bash.exec("mkdir -p /tmp/cattest && cd /tmp/cattest && echo content123 > myfile.txt")
6424 .await
6425 .unwrap();
6426
6427 let result = bash
6428 .exec("cd /tmp/cattest && cat ./myfile.txt")
6429 .await
6430 .unwrap();
6431 assert_eq!(
6432 result.exit_code, 0,
6433 "cat ./myfile.txt should succeed: {}",
6434 result.stderr
6435 );
6436 assert!(result.stdout.contains("content123"));
6437 }
6438
6439 #[tokio::test]
6440 async fn test_dot_slash_prefix_redirect() {
6441 let mut bash = Bash::new();
6443 bash.exec("mkdir -p /tmp/redirtest && cd /tmp/redirtest")
6444 .await
6445 .unwrap();
6446
6447 let result = bash
6448 .exec("cd /tmp/redirtest && echo hello > ./output.txt && cat ./output.txt")
6449 .await
6450 .unwrap();
6451 assert_eq!(
6452 result.exit_code, 0,
6453 "redirect to ./output.txt should succeed: {}",
6454 result.stderr
6455 );
6456 assert!(result.stdout.contains("hello"));
6457 }
6458
6459 #[tokio::test]
6460 async fn test_dot_slash_prefix_test_builtin() {
6461 let mut bash = Bash::new();
6463 bash.exec("mkdir -p /tmp/testbuiltin && cd /tmp/testbuiltin && echo x > myfile.txt")
6464 .await
6465 .unwrap();
6466
6467 let result = bash
6468 .exec("cd /tmp/testbuiltin && test -f ./myfile.txt && echo yes")
6469 .await
6470 .unwrap();
6471 assert_eq!(
6472 result.exit_code, 0,
6473 "test -f ./myfile.txt should succeed: {}",
6474 result.stderr
6475 );
6476 assert!(result.stdout.contains("yes"));
6477 }
6478
6479 #[tokio::test]
6482 async fn test_before_exec_hook_modifies_script() {
6483 use std::sync::Arc;
6484 use std::sync::atomic::{AtomicBool, Ordering};
6485
6486 let called = Arc::new(AtomicBool::new(false));
6487 let called_clone = called.clone();
6488
6489 let mut bash = Bash::builder()
6490 .before_exec(Box::new(move |mut input| {
6491 called_clone.store(true, Ordering::Relaxed);
6492 input.script = "echo intercepted".to_string();
6494 hooks::HookAction::Continue(input)
6495 }))
6496 .build();
6497
6498 let result = bash.exec("echo original").await.unwrap();
6499 assert!(called.load(Ordering::Relaxed));
6500 assert_eq!(result.stdout.trim(), "intercepted");
6501 }
6502
6503 #[tokio::test]
6504 async fn test_before_exec_hook_cancels() {
6505 let mut bash = Bash::builder()
6506 .before_exec(Box::new(|_input| {
6507 hooks::HookAction::Cancel("blocked".to_string())
6508 }))
6509 .build();
6510
6511 let result = bash.exec("echo should-not-run").await.unwrap();
6512 assert_eq!(result.exit_code, 1);
6513 assert!(result.stdout.is_empty());
6514 }
6515
6516 #[tokio::test]
6517 async fn test_input_size_limit_rejects_before_before_exec_hook() {
6518 use std::sync::Arc;
6519 use std::sync::atomic::{AtomicBool, Ordering};
6520
6521 let called = Arc::new(AtomicBool::new(false));
6522 let called_clone = called.clone();
6523
6524 let limits = ExecutionLimits::new().max_input_bytes(8);
6525 let mut bash = Bash::builder()
6526 .limits(limits)
6527 .before_exec(Box::new(move |_input| {
6528 called_clone.store(true, Ordering::Relaxed);
6529 unreachable!("before_exec hook must not run for oversized input");
6530 }))
6531 .build();
6532
6533 let result = bash.exec("echo way-too-long").await;
6534 assert!(result.is_err());
6535 assert!(!called.load(Ordering::Relaxed));
6536 }
6537
6538 #[tokio::test]
6539 async fn test_after_exec_hook_observes_output() {
6540 use std::sync::{Arc, Mutex};
6541
6542 let captured = Arc::new(Mutex::new(String::new()));
6543 let captured_clone = captured.clone();
6544
6545 let mut bash = Bash::builder()
6546 .after_exec(Box::new(move |output| {
6547 *captured_clone.lock().unwrap() = output.stdout.clone();
6548 hooks::HookAction::Continue(output)
6549 }))
6550 .build();
6551
6552 bash.exec("echo hello-hooks").await.unwrap();
6553 assert_eq!(captured.lock().unwrap().trim(), "hello-hooks");
6554 }
6555
6556 #[tokio::test]
6557 async fn test_multiple_hooks_chain() {
6558 let mut bash = Bash::builder()
6559 .before_exec(Box::new(|mut input| {
6560 input.script = input.script.replace("world", "hooks");
6561 hooks::HookAction::Continue(input)
6562 }))
6563 .before_exec(Box::new(|mut input| {
6564 input.script = input.script.replace("hello", "greetings");
6565 hooks::HookAction::Continue(input)
6566 }))
6567 .build();
6568
6569 let result = bash.exec("echo hello world").await.unwrap();
6570 assert_eq!(result.stdout.trim(), "greetings hooks");
6571 }
6572
6573 #[tokio::test]
6574 async fn test_on_exit_hook_not_fired_for_path_script_exit() {
6575 use std::path::Path;
6576 use std::sync::Arc;
6577 use std::sync::atomic::{AtomicU32, Ordering};
6578
6579 let count = Arc::new(AtomicU32::new(0));
6580 let count_clone = count.clone();
6581
6582 let mut bash = Bash::builder()
6583 .on_exit(Box::new(move |event| {
6584 count_clone.fetch_add(1, Ordering::Relaxed);
6585 hooks::HookAction::Continue(event)
6586 }))
6587 .build();
6588
6589 let fs = bash.fs();
6590 fs.mkdir(Path::new("/bin"), false).await.unwrap();
6591 fs.write_file(Path::new("/bin/child-exit"), b"#!/usr/bin/env bash\nexit 7")
6592 .await
6593 .unwrap();
6594 fs.chmod(Path::new("/bin/child-exit"), 0o755).await.unwrap();
6595
6596 let result = bash
6597 .exec("PATH=/bin:$PATH\nchild-exit\necho after:$?")
6598 .await
6599 .unwrap();
6600
6601 assert_eq!(result.stdout.trim(), "after:7");
6602 assert_eq!(count.load(Ordering::Relaxed), 0);
6603 }
6604
6605 #[tokio::test]
6606 async fn test_on_exit_hook_not_fired_for_direct_script_exit() {
6607 use std::path::Path;
6608 use std::sync::Arc;
6609 use std::sync::atomic::{AtomicU32, Ordering};
6610
6611 let count = Arc::new(AtomicU32::new(0));
6612 let count_clone = count.clone();
6613
6614 let mut bash = Bash::builder()
6615 .on_exit(Box::new(move |event| {
6616 count_clone.fetch_add(1, Ordering::Relaxed);
6617 hooks::HookAction::Continue(event)
6618 }))
6619 .build();
6620
6621 let fs = bash.fs();
6622 fs.write_file(
6623 Path::new("/tmp/child-exit.sh"),
6624 b"#!/usr/bin/env bash\nexit 8",
6625 )
6626 .await
6627 .unwrap();
6628 fs.chmod(Path::new("/tmp/child-exit.sh"), 0o755)
6629 .await
6630 .unwrap();
6631
6632 let result = bash
6633 .exec("/tmp/child-exit.sh\necho after:$?")
6634 .await
6635 .unwrap();
6636
6637 assert_eq!(result.stdout.trim(), "after:8");
6638 assert_eq!(count.load(Ordering::Relaxed), 0);
6639 }
6640
6641 #[tokio::test]
6642 async fn test_on_exit_hook_not_fired_for_nested_bash_exit() {
6643 use std::sync::Arc;
6644 use std::sync::atomic::{AtomicU32, Ordering};
6645
6646 let count = Arc::new(AtomicU32::new(0));
6647 let count_clone = count.clone();
6648
6649 let mut bash = Bash::builder()
6650 .on_exit(Box::new(move |event| {
6651 count_clone.fetch_add(1, Ordering::Relaxed);
6652 hooks::HookAction::Continue(event)
6653 }))
6654 .build();
6655
6656 let result = bash.exec("bash -c 'exit 9'\necho after:$?").await.unwrap();
6657
6658 assert_eq!(result.stdout.trim(), "after:9");
6659 assert_eq!(count.load(Ordering::Relaxed), 0);
6660 }
6661
6662 #[tokio::test]
6663 async fn test_path_script_exit_runs_child_exit_trap() {
6664 use std::path::Path;
6665
6666 let mut bash = Bash::new();
6667 let fs = bash.fs();
6668 fs.write_file(
6669 Path::new("/tmp/child-trap.sh"),
6670 b"#!/usr/bin/env bash\ntrap 'echo child-trap' EXIT\nexit 4",
6671 )
6672 .await
6673 .unwrap();
6674 fs.chmod(Path::new("/tmp/child-trap.sh"), 0o755)
6675 .await
6676 .unwrap();
6677
6678 let result = bash
6679 .exec("/tmp/child-trap.sh\necho after:$?")
6680 .await
6681 .unwrap();
6682
6683 assert_eq!(result.stdout.trim(), "child-trap\nafter:4");
6684 }
6685
6686 #[tokio::test]
6687 async fn test_on_exit_hook_still_fires_for_source_exit() {
6688 use std::path::Path;
6689 use std::sync::Arc;
6690 use std::sync::atomic::{AtomicU32, Ordering};
6691
6692 let count = Arc::new(AtomicU32::new(0));
6693 let count_clone = count.clone();
6694
6695 let mut bash = Bash::builder()
6696 .on_exit(Box::new(move |event| {
6697 count_clone.fetch_add(1, Ordering::Relaxed);
6698 hooks::HookAction::Continue(event)
6699 }))
6700 .build();
6701
6702 let fs = bash.fs();
6703 fs.write_file(Path::new("/tmp/source-exit.sh"), b"exit 5")
6704 .await
6705 .unwrap();
6706
6707 let result = bash.exec("source /tmp/source-exit.sh").await.unwrap();
6708
6709 assert_eq!(result.exit_code, 5);
6710 assert_eq!(count.load(Ordering::Relaxed), 1);
6711 }
6712
6713 #[tokio::test]
6714 async fn test_on_exit_hook_cancel_prevents_exit() {
6715 let mut bash = Bash::builder()
6716 .on_exit(Box::new(|_event| {
6717 hooks::HookAction::Cancel("blocked by policy".to_string())
6718 }))
6719 .build();
6720
6721 let result = bash.exec("echo before\nexit 5\necho after").await.unwrap();
6722 assert_eq!(result.stdout.trim(), "before\nafter");
6723 assert_eq!(result.exit_code, 0);
6724 }
6725
6726 #[tokio::test]
6727 async fn test_on_exit_hook_can_modify_exit_code() {
6728 let mut bash = Bash::builder()
6729 .on_exit(Box::new(|mut event| {
6730 event.code = 17;
6731 hooks::HookAction::Continue(event)
6732 }))
6733 .build();
6734
6735 let result = bash.exec("exit 5").await.unwrap();
6736 assert_eq!(result.exit_code, 17);
6737 }
6738
6739 #[tokio::test]
6740 async fn test_bash_versinfo_reports_bash_compatible_major() {
6741 let mut bash = Bash::new();
6742
6743 let result = bash
6744 .exec(r#"[[ ${BASH_VERSINFO[0]} -ge 4 ]] && echo bash4plus"#)
6745 .await
6746 .unwrap();
6747
6748 assert_eq!(result.stdout.trim(), "bash4plus");
6749 }
6750
6751 #[tokio::test]
6752 async fn test_bash_version_surface_matches_bash_compatible_tuple() {
6753 let mut bash = Bash::new();
6754
6755 let result = bash
6756 .exec(
6757 r#"printf '%s\n' "$BASH_VERSION" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}" "${BASH_VERSINFO[2]}" "${BASH_VERSINFO[3]}" "${BASH_VERSINFO[4]}" "${BASH_VERSINFO[5]}""#,
6758 )
6759 .await
6760 .unwrap();
6761
6762 assert_eq!(
6763 result.stdout,
6764 "5.2.15(1)-release\n5\n2\n15\n1\nrelease\nvirtual\n"
6765 );
6766 }
6767
6768 #[tokio::test]
6769 async fn test_path_script_retains_bash_versinfo_array() {
6770 use std::path::Path;
6771
6772 let mut bash = Bash::new();
6773 let fs = bash.fs();
6774 fs.write_file(
6775 Path::new("/tmp/bash-version-check.sh"),
6776 b"#!/usr/bin/env bash\nprintf '%s\\n' \"${BASH_VERSINFO[0]}\"",
6777 )
6778 .await
6779 .unwrap();
6780 fs.chmod(Path::new("/tmp/bash-version-check.sh"), 0o755)
6781 .await
6782 .unwrap();
6783
6784 let result = bash.exec("/tmp/bash-version-check.sh").await.unwrap();
6785
6786 assert_eq!(result.stdout.trim(), "5");
6787 }
6788
6789 #[tokio::test]
6790 async fn test_path_script_bash_versinfo_satisfies_bash4_guard() {
6791 use std::path::Path;
6792
6793 let mut bash = Bash::new();
6794 let fs = bash.fs();
6795 fs.write_file(
6796 Path::new("/tmp/bash-version-guard.sh"),
6797 b"#!/usr/bin/env bash\nif (( BASH_VERSINFO[0] < 4 )); then echo too-old; else echo ok; fi",
6798 )
6799 .await
6800 .unwrap();
6801 fs.chmod(Path::new("/tmp/bash-version-guard.sh"), 0o755)
6802 .await
6803 .unwrap();
6804
6805 let result = bash.exec("/tmp/bash-version-guard.sh").await.unwrap();
6806
6807 assert_eq!(result.stdout.trim(), "ok");
6808 }
6809
6810 #[tokio::test]
6811 async fn test_before_tool_hook_modifies_args() {
6812 use std::sync::Arc;
6813 use std::sync::atomic::{AtomicBool, Ordering};
6814
6815 let called = Arc::new(AtomicBool::new(false));
6816 let called_clone = called.clone();
6817
6818 let mut bash = Bash::builder()
6819 .before_tool(Box::new(move |mut event| {
6820 called_clone.store(true, Ordering::Relaxed);
6821 if !event.args.is_empty() {
6823 event.args = vec!["intercepted".to_string()];
6824 }
6825 hooks::HookAction::Continue(event)
6826 }))
6827 .build();
6828
6829 let result = bash.exec("echo original").await.unwrap();
6830 assert!(called.load(Ordering::Relaxed));
6831 assert_eq!(result.stdout.trim(), "intercepted");
6832 }
6833
6834 #[tokio::test]
6835 async fn test_before_tool_hook_cancels() {
6836 let mut bash = Bash::builder()
6837 .before_tool(Box::new(|event| {
6838 if event.name == "echo" {
6839 hooks::HookAction::Cancel("echo blocked".to_string())
6840 } else {
6841 hooks::HookAction::Continue(event)
6842 }
6843 }))
6844 .build();
6845
6846 let result = bash.exec("echo should-not-run").await.unwrap();
6847 assert_eq!(result.exit_code, 1);
6848 assert!(result.stderr.contains("cancelled by before_tool hook"));
6849 }
6850
6851 #[tokio::test]
6852 async fn test_after_tool_hook_observes_result() {
6853 use std::sync::{Arc, Mutex};
6854
6855 let captured = Arc::new(Mutex::new(Vec::new()));
6856 let captured_clone = captured.clone();
6857
6858 let mut bash = Bash::builder()
6859 .after_tool(Box::new(move |result| {
6860 captured_clone.lock().unwrap().push((
6861 result.name.clone(),
6862 result.stdout.clone(),
6863 result.exit_code,
6864 ));
6865 hooks::HookAction::Continue(result)
6866 }))
6867 .build();
6868
6869 bash.exec("echo hello-tool").await.unwrap();
6870 let results = captured.lock().unwrap();
6871 assert!(!results.is_empty());
6872 assert_eq!(results[0].0, "echo");
6873 assert!(results[0].1.contains("hello-tool"));
6874 assert_eq!(results[0].2, 0);
6875 }
6876
6877 #[tokio::test]
6878 async fn test_before_tool_hook_does_not_fire_for_special_builtins() {
6879 use std::sync::Arc;
6883 use std::sync::atomic::{AtomicU32, Ordering};
6884
6885 let count = Arc::new(AtomicU32::new(0));
6886 let count_clone = count.clone();
6887
6888 let mut bash = Bash::builder()
6889 .before_tool(Box::new(move |event| {
6890 count_clone.fetch_add(1, Ordering::Relaxed);
6891 hooks::HookAction::Continue(event)
6892 }))
6893 .build();
6894
6895 bash.exec("declare x=1").await.unwrap();
6897 assert_eq!(count.load(Ordering::Relaxed), 0);
6898
6899 bash.exec("echo hi").await.unwrap();
6901 assert_eq!(count.load(Ordering::Relaxed), 1);
6902 }
6903
6904 #[cfg(feature = "http_client")]
6905 #[tokio::test]
6906 async fn test_before_http_hook_cancels_request() {
6907 use crate::NetworkAllowlist;
6908
6909 let mut bash = Bash::builder()
6910 .network(NetworkAllowlist::allow_all())
6911 .before_http(Box::new(|req| {
6912 if req.url.contains("blocked.example.com") {
6913 hooks::HookAction::Cancel("blocked by policy".to_string())
6914 } else {
6915 hooks::HookAction::Continue(req)
6916 }
6917 }))
6918 .build();
6919
6920 let result = bash
6922 .exec("curl -s https://blocked.example.com/data")
6923 .await
6924 .unwrap();
6925 assert_ne!(result.exit_code, 0);
6926 assert!(result.stderr.contains("cancelled by before_http hook"));
6927 }
6928
6929 #[cfg(feature = "http_client")]
6930 #[tokio::test]
6931 async fn test_after_http_hook_observes_response() {
6932 use std::sync::{Arc, Mutex};
6933
6934 use crate::NetworkAllowlist;
6935
6936 let captured = Arc::new(Mutex::new(Vec::new()));
6937 let captured_clone = captured.clone();
6938
6939 let mut bash = Bash::builder()
6940 .network(NetworkAllowlist::allow_all())
6941 .after_http(Box::new(move |event| {
6942 captured_clone
6943 .lock()
6944 .unwrap()
6945 .push((event.url.clone(), event.status));
6946 hooks::HookAction::Continue(event)
6947 }))
6948 .build();
6949
6950 let _result = bash.exec("curl -s https://httpbin.org/get").await.unwrap();
6954 }
6957}