Skip to main content

bashkit/
lib.rs

1//! Bashkit - Awesomely fast virtual sandbox with bash and file system
2//!
3//! Virtual bash interpreter for AI agents, CI/CD pipelines, and code sandboxes.
4//! Written in Rust.
5//!
6//! # Features
7//!
8//! - **POSIX compliant** - Substantial IEEE 1003.1-2024 Shell Command Language compliance
9//! - **Sandboxed, in-process execution** - No real filesystem access by default
10//! - **Virtual filesystem** - [`InMemoryFs`], [`OverlayFs`], [`MountableFs`]
11//! - **Resource limits** - Command count, loop iterations, function depth
12//! - **Network allowlist** - Control HTTP access per-domain
13//! - **Custom builtins** - Extend with domain-specific commands
14//! - **Async-first** - Built on tokio
15//! - **Experimental: Git** - Virtual git operations on the VFS (`git` feature)
16//! - **Experimental: Python** - Embedded Python via [Monty](https://github.com/pydantic/monty) (`python` feature)
17//! - **Experimental: SQLite** - Embedded SQLite-compatible engine via [Turso](https://github.com/tursodatabase/turso) (`sqlite` feature)
18//!
19//! # Built-in Commands (160)
20//!
21//! | Category | Commands |
22//! |----------|----------|
23//! | Core | `echo`, `printf`, `cat`, `nl`, `read`, `log` |
24//! | Navigation | `cd`, `pwd`, `ls`, `find`, `tree`, `pushd`, `popd`, `dirs` |
25//! | Flow control | `true`, `false`, `exit`, `return`, `break`, `continue`, `test`, `[`, `assert` |
26//! | Variables | `export`, `set`, `unset`, `local`, `shift`, `source`, `.`, `eval`, `readonly`, `times`, `declare`, `typeset`, `let`, `dotenv`, `envsubst` |
27//! | Shell | `bash`, `sh` (virtual re-invocation), `:`, `trap`, `caller`, `getopts`, `shopt`, `alias`, `unalias`, `compgen`, `fc`, `help` |
28//! | Text processing | `grep`, `rg`, `sed`, `awk`, `jq`, `head`, `tail`, `sort`, `uniq`, `cut`, `tr`, `wc`, `paste`, `column`, `diff`, `comm`, `strings`, `tac`, `rev`, `seq`, `expr`, `fold`, `expand`, `unexpand`, `join`, `split`, `iconv`, `template` |
29//! | File operations | `mkdir`, `mktemp`, `mkfifo`, `rm`, `cp`, `mv`, `touch`, `chmod`, `chown`, `ln`, `rmdir`, `realpath`, `readlink`, `glob`, `patch` |
30//! | File inspection | `file`, `stat`, `less` |
31//! | Archives | `tar`, `gzip`, `gunzip`, `zip`, `unzip` |
32//! | Byte tools | `od`, `xxd`, `hexdump`, `base64` |
33//! | Checksums | `md5sum`, `sha1sum`, `sha256sum`, `verify` |
34//! | Utilities | `sleep`, `date`, `basename`, `dirname`, `timeout`, `wait`, `watch`, `yes`, `kill`, `clear`, `retry`, `parallel` |
35//! | Disk | `df`, `du` |
36//! | Pipeline | `xargs`, `tee` |
37//! | System info | `whoami`, `hostname`, `uname`, `id`, `env`, `printenv`, `history` |
38//! | Structured data | `json`, `csv`, `yaml`, `tomlq`, `semver` |
39//! | Network | `curl`, `wget`, `http` (requires [`NetworkAllowlist`])
40//! | Arithmetic | `bc` |
41//! | Experimental | `python`, `python3` (requires `python` feature), `git` (requires `git` feature), `ts`, `typescript`, `node`, `deno`, `bun` (requires `typescript` feature), `ssh`, `scp`, `sftp` (requires `ssh` feature), `sqlite`, `sqlite3` (requires `sqlite` feature)
42//!
43//! # Shell Features
44//!
45//! - Variables and parameter expansion (`$VAR`, `${VAR:-default}`, `${#VAR}`)
46//! - Command substitution (`$(cmd)`)
47//! - Arithmetic expansion (`$((1 + 2))`)
48//! - Pipelines and redirections (`|`, `>`, `>>`, `<`, `<<<`, `2>&1`)
49//! - Control flow (`if`/`elif`/`else`, `for`, `while`, `case`)
50//! - Functions (POSIX and bash-style)
51//! - Arrays (`arr=(a b c)`, `${arr[@]}`, `${#arr[@]}`)
52//! - Glob expansion (`*`, `?`)
53//! - Here documents (`<<EOF`)
54//!
55//! - [`compatibility_scorecard`] - Full compatibility status
56//!
57//! # Quick Start
58//!
59//! ```rust
60//! use bashkit::Bash;
61//!
62//! # #[tokio::main]
63//! # async fn main() -> bashkit::Result<()> {
64//! let mut bash = Bash::new();
65//! let result = bash.exec("echo 'Hello, World!'").await?;
66//! assert_eq!(result.stdout, "Hello, World!\n");
67//! assert_eq!(result.exit_code, 0);
68//! # Ok(())
69//! # }
70//! ```
71//!
72//! # Basic Usage
73//!
74//! ## Simple Commands
75//!
76//! ```rust
77//! use bashkit::Bash;
78//!
79//! # #[tokio::main]
80//! # async fn main() -> bashkit::Result<()> {
81//! let mut bash = Bash::new();
82//!
83//! // Echo with variables
84//! let result = bash.exec("NAME=World; echo \"Hello, $NAME!\"").await?;
85//! assert_eq!(result.stdout, "Hello, World!\n");
86//!
87//! // Pipelines
88//! let result = bash.exec("echo -e 'apple\\nbanana\\ncherry' | grep a").await?;
89//! assert_eq!(result.stdout, "apple\nbanana\n");
90//!
91//! // Arithmetic
92//! let result = bash.exec("echo $((2 + 2 * 3))").await?;
93//! assert_eq!(result.stdout, "8\n");
94//! # Ok(())
95//! # }
96//! ```
97//!
98//! ## Control Flow
99//!
100//! ```rust
101//! use bashkit::Bash;
102//!
103//! # #[tokio::main]
104//! # async fn main() -> bashkit::Result<()> {
105//! let mut bash = Bash::new();
106//!
107//! // For loops
108//! let result = bash.exec("for i in 1 2 3; do echo $i; done").await?;
109//! assert_eq!(result.stdout, "1\n2\n3\n");
110//!
111//! // If statements
112//! let result = bash.exec("if [ 5 -gt 3 ]; then echo bigger; fi").await?;
113//! assert_eq!(result.stdout, "bigger\n");
114//!
115//! // Functions
116//! let result = bash.exec("greet() { echo \"Hello, $1!\"; }; greet World").await?;
117//! assert_eq!(result.stdout, "Hello, World!\n");
118//! # Ok(())
119//! # }
120//! ```
121//!
122//! ## File Operations
123//!
124//! All file operations happen in the virtual filesystem:
125//!
126//! ```rust
127//! use bashkit::Bash;
128//!
129//! # #[tokio::main]
130//! # async fn main() -> bashkit::Result<()> {
131//! let mut bash = Bash::new();
132//!
133//! // Create and read files
134//! bash.exec("echo 'Hello' > /tmp/test.txt").await?;
135//! bash.exec("echo 'World' >> /tmp/test.txt").await?;
136//!
137//! let result = bash.exec("cat /tmp/test.txt").await?;
138//! assert_eq!(result.stdout, "Hello\nWorld\n");
139//!
140//! // Directory operations
141//! bash.exec("mkdir -p /data/nested/dir").await?;
142//! bash.exec("echo 'content' > /data/nested/dir/file.txt").await?;
143//! # Ok(())
144//! # }
145//! ```
146//!
147//! # Configuration with Builder
148//!
149//! Use [`Bash::builder()`] for advanced configuration:
150//!
151//! ```rust
152//! use bashkit::{Bash, ExecutionLimits};
153//!
154//! # #[tokio::main]
155//! # async fn main() -> bashkit::Result<()> {
156//! let mut bash = Bash::builder()
157//!     .env("API_KEY", "secret123")
158//!     .username("deploy")
159//!     .hostname("prod-server")
160//!     .limits(ExecutionLimits::new().max_commands(100))
161//!     .build();
162//!
163//! let result = bash.exec("whoami && hostname").await?;
164//! assert_eq!(result.stdout, "deploy\nprod-server\n");
165//! # Ok(())
166//! # }
167//! ```
168//!
169//! # LLM Tool Integration
170//!
171//! Use [`BashTool`] when the host needs schemas, Markdown help, a compact system prompt,
172//! and validated single-use executions.
173//!
174//! ```rust
175//! use bashkit::{BashTool, Tool};
176//!
177//! # #[tokio::main]
178//! # async fn main() -> anyhow::Result<()> {
179//! let tool = BashTool::builder()
180//!     .username("agent")
181//!     .hostname("sandbox")
182//!     .build();
183//!
184//! let output = tool
185//!     .execution(serde_json::json!({
186//!         "commands": "echo hello from bashkit",
187//!         "timeout_ms": 1000
188//!     }))?
189//!     .execute()
190//!     .await?;
191//!
192//! assert_eq!(output.result["stdout"], "hello from bashkit\n");
193//! assert!(tool.help().contains("## Parameters"));
194//! # Ok(())
195//! # }
196//! ```
197//!
198//! # Custom Builtins
199//!
200//! Register custom commands to extend Bashkit with domain-specific functionality:
201//!
202//! ```rust
203//! use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
204//!
205//! struct Greet;
206//!
207//! #[async_trait]
208//! impl Builtin for Greet {
209//!     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
210//!         let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World");
211//!         Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
212//!     }
213//! }
214//!
215//! # #[tokio::main]
216//! # async fn main() -> bashkit::Result<()> {
217//! let mut bash = Bash::builder()
218//!     .builtin("greet", Box::new(Greet))
219//!     .build();
220//!
221//! let result = bash.exec("greet Alice").await?;
222//! assert_eq!(result.stdout, "Hello, Alice!\n");
223//! # Ok(())
224//! # }
225//! ```
226//!
227//! Custom builtins have access to:
228//! - Command arguments (`ctx.args`)
229//! - Environment variables (`ctx.env`)
230//! - Shell variables (`ctx.variables`)
231//! - Virtual filesystem (`ctx.fs`)
232//! - Pipeline stdin (`ctx.stdin`)
233//!
234//! See [`BashBuilder::builtin`] for more details.
235//!
236//! # Virtual Filesystem
237//!
238//! Bashkit provides three filesystem implementations:
239//!
240//! - [`InMemoryFs`]: Simple in-memory filesystem (default)
241//! - [`OverlayFs`]: Copy-on-write overlay for layered storage
242//! - [`MountableFs`]: Mount multiple filesystems at different paths
243//!
244//! See the `fs` module documentation for details and examples.
245//!
246//! # Direct Filesystem Access
247//!
248//! Access the filesystem directly via [`Bash::fs()`]:
249//!
250//! ```rust
251//! use bashkit::{Bash, FileSystem};
252//! use std::path::Path;
253//!
254//! # #[tokio::main]
255//! # async fn main() -> bashkit::Result<()> {
256//! let mut bash = Bash::new();
257//! let fs = bash.fs();
258//!
259//! // Pre-populate files before running scripts
260//! fs.mkdir(Path::new("/config"), false).await?;
261//! fs.write_file(Path::new("/config/app.conf"), b"debug=true").await?;
262//!
263//! // Run a script that reads the config
264//! let result = bash.exec("cat /config/app.conf").await?;
265//! assert_eq!(result.stdout, "debug=true");
266//!
267//! // Read script output directly
268//! bash.exec("echo 'result' > /output.txt").await?;
269//! let output = fs.read_file(Path::new("/output.txt")).await?;
270//! assert_eq!(output, b"result\n");
271//! # Ok(())
272//! # }
273//! ```
274//!
275//! # HTTP Access (curl/wget)
276//!
277//! Enable the `http_client` feature and configure an allowlist for network access:
278//!
279//! ```rust,ignore
280//! use bashkit::{Bash, NetworkAllowlist};
281//!
282//! let mut bash = Bash::builder()
283//!     .network(NetworkAllowlist::new()
284//!         .allow("https://httpbin.org"))
285//!     .build();
286//!
287//! // curl and wget now work for allowed URLs
288//! let result = bash.exec("curl -s https://httpbin.org/get").await?;
289//! assert!(result.stdout.contains("httpbin.org"));
290//! ```
291//!
292//! Security features:
293//! - URL allowlist enforcement (no access without explicit configuration)
294//! - 10MB response size limit (prevents memory exhaustion)
295//! - 30 second timeout (prevents hanging)
296//! - No automatic redirects (prevents allowlist bypass)
297//! - Zip bomb protection for compressed responses
298//!
299//! See [`NetworkAllowlist`] for allowlist configuration options.
300//!
301//! # Experimental: Git Support
302//!
303//! Enable the `git` feature for virtual git operations. All git data lives in
304//! the virtual filesystem.
305//!
306//! ```toml
307//! [dependencies]
308//! bashkit = { version = "0.1", features = ["git"] }
309//! ```
310//!
311//! ```rust,ignore
312//! use bashkit::{Bash, GitConfig};
313//!
314//! let mut bash = Bash::builder()
315//!     .git(GitConfig::new()
316//!         .author("Deploy Bot", "deploy@example.com"))
317//!     .build();
318//!
319//! bash.exec("git init").await?;
320//! bash.exec("echo 'hello' > file.txt").await?;
321//! bash.exec("git add file.txt").await?;
322//! bash.exec("git commit -m 'initial'").await?;
323//! bash.exec("git log").await?;
324//! ```
325//!
326//! Supported: `init`, `config`, `add`, `commit`, `status`, `log`, `branch`,
327//! `checkout`, `diff`, `reset`, `remote`, `clone`/`push`/`pull`/`fetch` (virtual mode).
328//!
329//! See [`GitConfig`] for configuration options.
330//!
331//! # Experimental: Python Support
332//!
333//! Enable the `python` feature to embed the [Monty](https://github.com/pydantic/monty)
334//! Python interpreter (pure Rust, Python 3.12). Python `pathlib.Path` operations are
335//! bridged to the virtual filesystem.
336//!
337//! ```toml
338//! [dependencies]
339//! bashkit = { version = "0.1", features = ["python"] }
340//! ```
341//!
342//! ```rust,ignore
343//! use bashkit::Bash;
344//!
345//! let mut bash = Bash::builder().python().build();
346//!
347//! // Inline code
348//! bash.exec("python3 -c \"print(2 ** 10)\"").await?;
349//!
350//! // VFS bridging — files shared between bash and Python
351//! bash.exec("echo 'data' > /tmp/shared.txt").await?;
352//! bash.exec(r#"python3 -c "
353//! from pathlib import Path
354//! print(Path('/tmp/shared.txt').read_text().strip())
355//! ""#).await?;
356//! ```
357//!
358//! Stdlib modules: `math`, `pathlib`, `os` (getenv/environ), `sys`, `typing`.
359//! Security note: `re` is disabled due to regex backtracking DoS risk.
360//! Limitations: no `open()` (use `pathlib.Path`), no network, no classes,
361//! no third-party imports.
362//!
363//! See `PythonLimits` for resource limit configuration.
364//!
365//! See the `python_guide` module docs (requires `python` feature).
366//!
367//! # Examples
368//!
369//! See the `examples/` directory for complete working examples:
370//!
371//! - `basic.rs` - Getting started with Bashkit
372//! - `custom_fs.rs` - Using different filesystem implementations
373//! - `custom_filesystem_impl.rs` - Implementing the [`FileSystem`] trait
374//! - `resource_limits.rs` - Setting execution limits
375//! - `virtual_identity.rs` - Customizing username/hostname
376//! - `text_processing.rs` - Using grep, sed, awk, and jq
377//! - `agent_tool.rs` - LLM agent integration
378//! - `git_workflow.rs` - Git operations on the virtual filesystem
379//! - `python_scripts.rs` - Embedded Python with VFS bridging
380//! - `python_external_functions.rs` - Python callbacks into host functions
381//!
382//! # Guides
383//!
384//! - [`custom_builtins_guide`] - Creating custom builtins
385//! - [`compatibility_scorecard`] - Feature parity tracking
386//! - [`live_mounts_guide`] - Live mount/unmount on running instances
387//! - `python_guide` - Embedded Python (Monty) guide (requires `python` feature)
388//! - `logging_guide` - Structured logging with security (requires `logging` feature)
389//!
390//! # Resources
391//!
392//! - [`threat_model`] - Security threats and mitigations
393//!
394//! # Ecosystem
395//!
396//! Bashkit is part of the [Everruns](https://everruns.com) ecosystem.
397
398// Stricter panic prevention - prefer proper error handling over unwrap()
399#![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;
407/// Interceptor hooks for the execution pipeline.
408pub mod hooks;
409#[cfg(feature = "interop")]
410pub mod interop;
411mod interpreter;
412mod limits;
413#[cfg(feature = "logging")]
414mod logging_impl;
415mod network;
416/// Parser module - exposed for fuzzing and testing
417pub mod parser;
418/// Scripted tool: compose ToolDef+callback pairs into a single Tool via bash scripts.
419/// Requires the `scripted_tool` feature.
420#[cfg(feature = "scripted_tool")]
421pub mod scripted_tool;
422mod snapshot;
423/// Test-only helpers shared between internal `#[cfg(test)]` modules,
424/// integration tests in `tests/*.rs`, and cargo-fuzz targets in
425/// `fuzz/fuzz_targets/*.rs`. See `specs/threat-model.md` for the
426/// invariants enforced (TM-INF-013, TM-INF-016, TM-INF-022).
427#[doc(hidden)]
428pub mod testing;
429/// Tool contract for LLM integration
430pub mod tool;
431/// Reusable tool primitives: ToolDef, ToolArgs, ToolImpl, exec types.
432#[cfg(feature = "scripted_tool")]
433pub(crate) mod tool_def;
434/// Structured execution trace events.
435pub 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/// Re-exported network response type for custom HTTP handler implementations.
487#[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// Re-export monty types needed by external handler consumers.
505// **Unstable:** These types come from monty (git-pinned, not on crates.io).
506// They may change in breaking ways between bashkit releases.
507#[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// Re-export zapcode-core types needed by external handler consumers.
516#[cfg(feature = "typescript")]
517pub use zapcode_core::Value as ZapcodeValue;
518
519/// Logging utilities module
520///
521/// Provides structured logging with security features including sensitive data redaction.
522/// Only available when the `logging` feature is enabled.
523#[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
547/// Main entry point for Bashkit.
548///
549/// Provides a virtual bash interpreter with an in-memory virtual filesystem.
550pub struct Bash {
551    fs: Arc<dyn FileSystem>,
552    /// Outermost MountableFs layer for live mount/unmount after build.
553    mountable: Arc<MountableFs>,
554    interpreter: Interpreter,
555    /// Parser timeout (stored separately for use before interpreter runs)
556    parser_timeout: std::time::Duration,
557    /// Maximum input script size in bytes
558    max_input_bytes: usize,
559    /// Maximum AST nesting depth for parsing
560    max_ast_depth: usize,
561    /// Maximum parser operations (fuel)
562    max_parser_operations: usize,
563    /// Logging configuration
564    #[cfg(feature = "logging")]
565    log_config: logging::LogConfig,
566    /// Operator-approved in-process Python opt-in captured at build time.
567    #[cfg(feature = "python")]
568    python_inprocess_opt_in: bool,
569    /// Operator-approved in-process SQLite opt-in captured at build time.
570    #[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    /// Create a new Bash instance with default settings.
582    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    /// Create a new BashBuilder for customized configuration.
609    pub fn builder() -> BashBuilder {
610        BashBuilder::default()
611    }
612
613    /// Execute a bash script and return the result.
614    ///
615    /// This method first validates that the script does not exceed the maximum
616    /// input size, then parses the script with a timeout, AST depth limit, and fuel limit,
617    /// then executes the resulting AST.
618    pub async fn exec(&mut self, script: &str) -> Result<ExecResult> {
619        self.exec_with_extensions(script, ExecutionExtensions::new())
620            .await
621    }
622
623    /// Execute a bash script with per-execution builtin extensions.
624    pub async fn exec_with_extensions(
625        &mut self,
626        script: &str,
627        mut extensions: ExecutionExtensions,
628    ) -> Result<ExecResult> {
629        // Expose active execution limits to builtins that need to honor
630        // per-execution sandbox settings.
631        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        // THREAT[TM-ISO-005/006/007]: Reset transient state between exec() calls
642        self.interpreter.reset_transient_state();
643
644        // Check raw input size before hooks to avoid allocating/copying oversized
645        // untrusted scripts in hook payloads.
646        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        // THREAT[TM-LOG-001]: Sensitive data in logs
662        // Mitigation: Use LogConfig to redact sensitive script content
663        #[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        // Fire before_exec hooks — may modify or cancel the script
670        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        // Re-check size after hooks in case the hook rewrites to a larger script.
686        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        // On WASM, tokio::task::spawn_blocking and tokio::time::timeout don't
716        // work (no blocking thread pool, timer driver unreliable). Parse inline.
717        #[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        // On native targets, parse with timeout using spawn_blocking since
729        // parsing is sync and we don't want to block the async runtime.
730        #[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        // Static budget validation: reject obviously expensive scripts before execution
783        parser::validate_budget(&ast, self.interpreter.limits())
784            .map_err(|e| Error::Execution(format!("budget validation failed: {e}")))?;
785
786        // Load persisted history on first exec (no-op if already loaded)
787        self.interpreter.load_history().await;
788
789        let exec_start = std::time::Instant::now();
790        // THREAT[TM-DOS-057]: Wrap execution with timeout to prevent sleep/blocking bypass
791        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        // Issue #1184: clean up process substitution temp files after execution.
803        // Done here (outside Interpreter::execute) to avoid increasing the
804        // recursive async state machine size which causes stack overflow.
805        self.interpreter.cleanup_proc_sub_files().await;
806        let duration_ms = exec_start.elapsed().as_millis() as u64;
807
808        // Record history entry for each line of the script
809        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            // Persist history to VFS if configured
825            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        // Fire after_exec hooks
850        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        // Fire on_error hooks for execution errors
863        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    /// Execute a bash script with streaming output.
876    ///
877    /// Like [`exec`](Self::exec), but calls `output_callback` with incremental
878    /// `(stdout_chunk, stderr_chunk)` pairs as output is produced. Callbacks fire
879    /// after each loop iteration, command list element, and top-level command.
880    ///
881    /// The full result is still returned in [`ExecResult`] for callers that need it.
882    ///
883    /// # Example
884    ///
885    /// ```rust
886    /// use bashkit::Bash;
887    /// use std::sync::{Arc, Mutex};
888    ///
889    /// # #[tokio::main]
890    /// # async fn main() -> bashkit::Result<()> {
891    /// let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
892    /// let chunks_cb = chunks.clone();
893    /// let mut bash = Bash::new();
894    /// let result = bash.exec_streaming(
895    ///     "for i in 1 2 3; do echo $i; done",
896    ///     Box::new(move |stdout, _stderr| {
897    ///         chunks_cb.lock().unwrap().push(stdout.to_string());
898    ///     }),
899    /// ).await?;
900    /// assert_eq!(result.stdout, "1\n2\n3\n");
901    /// assert_eq!(*chunks.lock().unwrap(), vec!["1\n", "2\n", "3\n"]);
902    /// # Ok(())
903    /// # }
904    /// ```
905    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    /// Execute a bash script with streaming output and per-execution builtin extensions.
915    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    /// Return a shared cancellation token.
928    ///
929    /// Set the token to `true` from any thread to abort execution at the next
930    /// command boundary with [`Error::Cancelled`].
931    ///
932    /// The caller is responsible for resetting the flag to `false` before
933    /// calling `exec()` again.
934    pub fn cancellation_token(&self) -> Arc<std::sync::atomic::AtomicBool> {
935        self.interpreter.cancellation_token()
936    }
937
938    /// Return the hooks registry (read-only after build).
939    ///
940    /// Hooks are registered via [`BashBuilder`] methods (`on_exit`,
941    /// `before_exec`, `after_exec`, `before_tool`, `after_tool`,
942    /// `on_error`) and frozen at build time.
943    ///
944    /// HTTP hooks (`before_http`, `after_http`) live on the
945    /// `HttpClient` (requires `http_client` feature) and are set via
946    /// the builder as well.
947    pub fn hooks(&self) -> &hooks::Hooks {
948        self.interpreter.hooks()
949    }
950
951    /// Get a clone of the underlying filesystem.
952    ///
953    /// Provides direct access to the virtual filesystem for:
954    /// - Pre-populating files before script execution
955    /// - Reading binary file outputs after execution
956    /// - Injecting test data or configuration
957    ///
958    /// # Example
959    /// ```rust,no_run
960    /// use bashkit::Bash;
961    /// use std::path::Path;
962    ///
963    /// #[tokio::main]
964    /// async fn main() -> anyhow::Result<()> {
965    ///     let mut bash = Bash::new();
966    ///     let fs = bash.fs();
967    ///
968    ///     // Pre-populate config file
969    ///     fs.mkdir(Path::new("/config"), false).await?;
970    ///     fs.write_file(Path::new("/config/app.txt"), b"debug=true\n").await?;
971    ///
972    ///     // Bash script can read pre-populated files
973    ///     let result = bash.exec("cat /config/app.txt").await?;
974    ///     assert_eq!(result.stdout, "debug=true\n");
975    ///
976    ///     // Bash creates output, read it directly
977    ///     bash.exec("echo 'done' > /output.txt").await?;
978    ///     let output = fs.read_file(Path::new("/output.txt")).await?;
979    ///     assert_eq!(output, b"done\n");
980    ///     Ok(())
981    /// }
982    /// ```
983    pub fn fs(&self) -> Arc<dyn FileSystem> {
984        Arc::clone(&self.fs)
985    }
986
987    /// Mount a filesystem at `vfs_path` on a live interpreter.
988    ///
989    /// Unlike [`BashBuilder`] mount methods which configure mounts before build,
990    /// this method attaches a filesystem **after** the interpreter is running.
991    /// Shell state (env vars, cwd, history) is preserved — no rebuild needed.
992    ///
993    /// The mount takes effect immediately: subsequent `exec()` calls will see
994    /// files from the mounted filesystem at the given path.
995    ///
996    /// # Arguments
997    ///
998    /// * `vfs_path` - Absolute path where the filesystem will appear (e.g. `/mnt/data`)
999    /// * `fs` - The filesystem to mount
1000    ///
1001    /// # Errors
1002    ///
1003    /// Returns an error if `vfs_path` is not absolute.
1004    ///
1005    /// # Example
1006    ///
1007    /// ```rust
1008    /// use bashkit::{Bash, FileSystem, InMemoryFs};
1009    /// use std::path::Path;
1010    /// use std::sync::Arc;
1011    ///
1012    /// # #[tokio::main]
1013    /// # async fn main() -> bashkit::Result<()> {
1014    /// let mut bash = Bash::new();
1015    ///
1016    /// // Create and populate a filesystem
1017    /// let data_fs = Arc::new(InMemoryFs::new());
1018    /// data_fs.write_file(Path::new("/users.json"), br#"["alice"]"#).await?;
1019    ///
1020    /// // Mount it live — no rebuild, no state loss
1021    /// bash.mount("/mnt/data", data_fs)?;
1022    ///
1023    /// let result = bash.exec("cat /mnt/data/users.json").await?;
1024    /// assert!(result.stdout.contains("alice"));
1025    /// # Ok(())
1026    /// # }
1027    /// ```
1028    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    /// Unmount a previously mounted filesystem.
1037    ///
1038    /// After unmounting, paths under `vfs_path` fall back to the root filesystem
1039    /// or the next shorter mount prefix. Shell state is preserved.
1040    ///
1041    /// # Errors
1042    ///
1043    /// Returns an error if nothing is mounted at `vfs_path`.
1044    ///
1045    /// # Example
1046    ///
1047    /// ```rust
1048    /// use bashkit::{Bash, FileSystem, InMemoryFs};
1049    /// use std::path::Path;
1050    /// use std::sync::Arc;
1051    ///
1052    /// # #[tokio::main]
1053    /// # async fn main() -> bashkit::Result<()> {
1054    /// let mut bash = Bash::new();
1055    ///
1056    /// let tmp_fs = Arc::new(InMemoryFs::new());
1057    /// tmp_fs.write_file(Path::new("/data.txt"), b"temp").await?;
1058    ///
1059    /// bash.mount("/scratch", tmp_fs)?;
1060    /// let result = bash.exec("cat /scratch/data.txt").await?;
1061    /// assert_eq!(result.stdout, "temp");
1062    ///
1063    /// bash.unmount("/scratch")?;
1064    /// // /scratch/data.txt is no longer accessible
1065    /// # Ok(())
1066    /// # }
1067    /// ```
1068    pub fn unmount(&self, vfs_path: impl AsRef<std::path::Path>) -> Result<()> {
1069        self.mountable.unmount(vfs_path)
1070    }
1071
1072    /// Capture the current shell state (variables, env, cwd, options).
1073    ///
1074    /// Returns a serializable snapshot of the interpreter state. Combine with
1075    /// [`InMemoryFs::snapshot()`] for full session persistence.
1076    ///
1077    /// # Example
1078    ///
1079    /// ```rust
1080    /// use bashkit::Bash;
1081    ///
1082    /// # #[tokio::main]
1083    /// # async fn main() -> bashkit::Result<()> {
1084    /// let mut bash = Bash::new();
1085    /// bash.exec("x=42").await?;
1086    ///
1087    /// let state = bash.shell_state();
1088    ///
1089    /// bash.exec("x=99").await?;
1090    /// bash.restore_shell_state(&state);
1091    ///
1092    /// let result = bash.exec("echo $x").await?;
1093    /// assert_eq!(result.stdout, "42\n");
1094    /// # Ok(())
1095    /// # }
1096    /// ```
1097    pub fn shell_state(&self) -> ShellState {
1098        self.interpreter.shell_state()
1099    }
1100
1101    /// Capture a lightweight shell-state view for prompt/UI inspection.
1102    ///
1103    /// Unlike [`shell_state()`](Self::shell_state), this omits function
1104    /// definitions so callers that only need prompt/completion data avoid
1105    /// cloning AST-heavy state.
1106    pub fn shell_state_view(&self) -> ShellStateView {
1107        self.interpreter.shell_state_view()
1108    }
1109
1110    /// Restore shell state from a previous snapshot.
1111    ///
1112    /// Restores variables, env, cwd, arrays, functions, aliases, traps, and
1113    /// options. Does not restore builtins or VFS contents.
1114    pub fn restore_shell_state(&mut self, state: &ShellState) {
1115        self.interpreter.restore_shell_state(state);
1116    }
1117
1118    /// Get the current session-level counters (cumulative across exec() calls).
1119    ///
1120    /// Returns `(session_commands, session_exec_calls)`.
1121    pub fn session_counters(&self) -> (u64, u64) {
1122        let c = self.interpreter.counters();
1123        (c.session_commands, c.session_exec_calls)
1124    }
1125
1126    /// Restore session-level counters to resume a session across Bash instances.
1127    ///
1128    /// This is used by the MCP server to persist cumulative session counters
1129    /// across fresh Bash instances created per tool call.
1130    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
1136/// Builder for customized Bash configuration.
1137///
1138/// # Example
1139///
1140/// ```rust
1141/// use bashkit::{Bash, ExecutionLimits};
1142///
1143/// let bash = Bash::builder()
1144///     .env("HOME", "/home/user")
1145///     .username("deploy")
1146///     .hostname("prod-server")
1147///     .limits(ExecutionLimits::new().max_commands(1000))
1148///     .build();
1149/// ```
1150///
1151/// ## Custom Builtins
1152///
1153/// You can register custom builtins to extend bashkit with domain-specific commands:
1154///
1155/// ```rust
1156/// use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
1157///
1158/// struct MyCommand;
1159///
1160/// #[async_trait]
1161/// impl Builtin for MyCommand {
1162///     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
1163///         Ok(ExecResult::ok(format!("Hello from custom command!\n")))
1164///     }
1165/// }
1166///
1167/// let bash = Bash::builder()
1168///     .builtin("mycommand", Box::new(MyCommand))
1169///     .build();
1170/// ```
1171/// A file to be mounted during builder construction.
1172struct 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/// A real host directory to mount in the VFS during builder construction.
1186#[cfg(feature = "realfs")]
1187struct MountedRealDir {
1188    /// Path on the host filesystem.
1189    host_path: PathBuf,
1190    /// Mount point inside the VFS (e.g. "/mnt/data"). None = overlay at root.
1191    vfs_mount: Option<PathBuf>,
1192    /// Access mode.
1193    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 for virtualizing the `date` builtin (TM-INF-018)
1209    fixed_epoch: Option<i64>,
1210    shell_profile: interpreter::ShellProfile,
1211    custom_builtins: HashMap<String, Box<dyn Builtin>>,
1212    /// Files to mount in the virtual filesystem
1213    mounted_files: Vec<MountedFile>,
1214    /// Lazy files to mount (loaded on first read)
1215    mounted_lazy_files: Vec<MountedLazyFile>,
1216    /// Network allowlist for curl/wget builtins
1217    #[cfg(feature = "http_client")]
1218    network_allowlist: Option<NetworkAllowlist>,
1219    /// Custom HTTP handler for request interception
1220    #[cfg(feature = "http_client")]
1221    http_handler: Option<Box<dyn network::HttpHandler>>,
1222    /// Bot-auth config for transparent request signing
1223    #[cfg(feature = "bot-auth")]
1224    bot_auth_config: Option<network::BotAuthConfig>,
1225    /// Logging configuration
1226    #[cfg(feature = "logging")]
1227    log_config: Option<logging::LogConfig>,
1228    /// Git configuration for git builtins
1229    #[cfg(feature = "git")]
1230    git_config: Option<GitConfig>,
1231    /// SSH configuration for ssh/scp/sftp builtins
1232    #[cfg(feature = "ssh")]
1233    ssh_config: Option<SshConfig>,
1234    /// Custom SSH handler for transport interception
1235    #[cfg(feature = "ssh")]
1236    ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
1237    /// Real host directories to mount in the VFS
1238    #[cfg(feature = "realfs")]
1239    real_mounts: Vec<MountedRealDir>,
1240    /// Optional allowlist of host paths that may be mounted.
1241    /// When set, only paths starting with an allowed prefix are accepted.
1242    #[cfg(feature = "realfs")]
1243    mount_path_allowlist: Option<Vec<PathBuf>>,
1244    /// Optional VFS path for persistent history
1245    history_file: Option<PathBuf>,
1246    /// Interceptor hooks
1247    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    /// Credential injection policy
1258    #[cfg(feature = "http_client")]
1259    credential_policy: Option<credential::CredentialPolicy>,
1260}
1261
1262impl BashBuilder {
1263    /// Set a custom filesystem.
1264    pub fn fs(mut self, fs: Arc<dyn FileSystem>) -> Self {
1265        self.fs = Some(fs);
1266        self
1267    }
1268
1269    /// Set an environment variable.
1270    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    /// Set the current working directory.
1276    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
1277        self.cwd = Some(cwd.into());
1278        self
1279    }
1280
1281    /// Set execution limits.
1282    pub fn limits(mut self, limits: ExecutionLimits) -> Self {
1283        self.limits = limits;
1284        self
1285    }
1286
1287    /// Restrict this shell to logic/data-flow commands and custom builtins.
1288    #[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    /// Set session-level resource limits.
1295    ///
1296    /// Session limits persist across `exec()` calls and prevent tenants
1297    /// from circumventing per-execution limits by splitting work.
1298    pub fn session_limits(mut self, limits: SessionLimits) -> Self {
1299        self.session_limits = limits;
1300        self
1301    }
1302
1303    /// Set per-instance memory limits.
1304    ///
1305    /// Controls the maximum variables, arrays, and functions a Bash
1306    /// instance can hold. Prevents memory exhaustion in multi-tenant use.
1307    pub fn memory_limits(mut self, limits: MemoryLimits) -> Self {
1308        self.memory_limits = limits;
1309        self
1310    }
1311
1312    /// Cap total interpreter memory to `bytes`.
1313    ///
1314    /// Convenience wrapper over [`memory_limits`](Self::memory_limits) that
1315    /// sets `max_total_variable_bytes` to `bytes` and clamps
1316    /// `max_function_body_bytes` to `min(bytes, default)`. Count-based
1317    /// sub-limits (variable count, array entries, function count) stay at
1318    /// their defaults.
1319    ///
1320    /// # Example
1321    /// ```
1322    /// # use bashkit::Bash;
1323    /// let bash = Bash::builder()
1324    ///     .max_memory(10 * 1024 * 1024)   // 10 MB
1325    ///     .build();
1326    /// ```
1327    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    /// Set the trace mode for structured execution tracing.
1337    ///
1338    /// - `TraceMode::Off` (default): No events, zero overhead
1339    /// - `TraceMode::Redacted`: Events with secrets scrubbed
1340    /// - `TraceMode::Full`: Raw events, no redaction
1341    pub fn trace_mode(mut self, mode: TraceMode) -> Self {
1342        self.trace_mode = mode;
1343        self
1344    }
1345
1346    /// Set a real-time callback for trace events.
1347    ///
1348    /// The callback is invoked for each trace event as it occurs.
1349    /// Requires `trace_mode` to be set to `Redacted` or `Full`.
1350    pub fn on_trace_event(mut self, callback: TraceCallback) -> Self {
1351        self.trace_callback = Some(callback);
1352        self
1353    }
1354
1355    /// Set the sandbox username.
1356    ///
1357    /// This configures `whoami` and `id` builtins to return this username,
1358    /// and automatically sets the `USER` environment variable.
1359    pub fn username(mut self, username: impl Into<String>) -> Self {
1360        self.username = Some(username.into());
1361        self
1362    }
1363
1364    /// Set the sandbox hostname.
1365    ///
1366    /// This configures `hostname` and `uname -n` builtins to return this hostname.
1367    pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
1368        self.hostname = Some(hostname.into());
1369        self
1370    }
1371
1372    /// Configure whether a file descriptor is reported as a terminal by `[ -t fd ]`.
1373    ///
1374    /// In a sandboxed VFS environment, all FDs default to non-terminal (false).
1375    /// Use this to simulate interactive mode for scripts that check `[ -t 0 ]`
1376    /// (stdin), `[ -t 1 ]` (stdout), or `[ -t 2 ]` (stderr).
1377    ///
1378    /// ```rust
1379    /// # use bashkit::Bash;
1380    /// let bash = Bash::builder()
1381    ///     .tty(0, true)  // stdin is a terminal
1382    ///     .tty(1, true)  // stdout is a terminal
1383    ///     .build();
1384    /// ```
1385    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    /// Set a fixed Unix epoch for the `date` builtin.
1393    ///
1394    /// THREAT[TM-INF-018]: Prevents `date` from leaking real host time.
1395    /// When set, `date` returns this fixed time instead of the real clock.
1396    pub fn fixed_epoch(mut self, epoch: i64) -> Self {
1397        self.fixed_epoch = Some(epoch);
1398        self
1399    }
1400
1401    /// Enable persistent history stored at the given VFS path.
1402    ///
1403    /// History entries are loaded from this file at startup and saved after each
1404    /// `exec()` call. The file is stored in the virtual filesystem.
1405    pub fn history_file(mut self, path: impl Into<PathBuf>) -> Self {
1406        self.history_file = Some(path.into());
1407        self
1408    }
1409
1410    /// Configure network access for curl/wget builtins.
1411    ///
1412    /// Network access is disabled by default. Use this method to enable HTTP
1413    /// requests from scripts with a URL allowlist for security.
1414    ///
1415    /// # Security
1416    ///
1417    /// The allowlist uses a default-deny model:
1418    /// - Only URLs matching allowlist patterns can be accessed
1419    /// - Pattern matching is literal (no DNS resolution) to prevent DNS rebinding
1420    /// - Scheme, host, port, and path prefix are all validated
1421    ///
1422    /// # Example
1423    ///
1424    /// ```rust
1425    /// use bashkit::{Bash, NetworkAllowlist};
1426    ///
1427    /// // Allow access to specific APIs only
1428    /// let allowlist = NetworkAllowlist::new()
1429    ///     .allow("https://api.example.com")
1430    ///     .allow("https://cdn.example.com/assets");
1431    ///
1432    /// let bash = Bash::builder()
1433    ///     .network(allowlist)
1434    ///     .build();
1435    /// ```
1436    ///
1437    /// # Warning
1438    ///
1439    /// Using [`NetworkAllowlist::allow_all()`] is dangerous and should only be
1440    /// used for testing or when the script is fully trusted.
1441    #[cfg(feature = "http_client")]
1442    pub fn network(mut self, allowlist: NetworkAllowlist) -> Self {
1443        self.network_allowlist = Some(allowlist);
1444        self
1445    }
1446
1447    /// Set a custom HTTP handler for request interception.
1448    ///
1449    /// The handler is called after the URL allowlist check, so the security
1450    /// boundary stays in bashkit. Use this for:
1451    /// - Corporate proxies
1452    /// - Logging/auditing
1453    /// - Caching responses
1454    /// - Rate limiting
1455    /// - Mocking HTTP responses in tests
1456    ///
1457    /// # Example
1458    ///
1459    /// ```ignore
1460    /// use bashkit::network::HttpHandler;
1461    ///
1462    /// struct MyHandler;
1463    ///
1464    /// #[async_trait::async_trait]
1465    /// impl HttpHandler for MyHandler {
1466    ///     async fn request(
1467    ///         &self,
1468    ///         method: &str,
1469    ///         url: &str,
1470    ///         body: Option<&[u8]>,
1471    ///         headers: &[(String, String)],
1472    ///     ) -> Result<bashkit::network::Response, String> {
1473    ///         Ok(bashkit::network::Response {
1474    ///             status: 200,
1475    ///             headers: vec![],
1476    ///             body: b"mocked".to_vec(),
1477    ///         })
1478    ///     }
1479    /// }
1480    ///
1481    /// let bash = Bash::builder()
1482    ///     .network(NetworkAllowlist::allow_all())
1483    ///     .http_handler(Box::new(MyHandler))
1484    ///     .build();
1485    /// ```
1486    #[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    /// Enable transparent request signing for all outbound HTTP requests.
1493    ///
1494    /// When configured, every HTTP request made by curl/wget/http builtins
1495    /// is signed with Ed25519 per RFC 9421 / web-bot-auth profile. No CLI
1496    /// arguments or script changes needed — signing is fully transparent.
1497    ///
1498    /// Signing failures are non-blocking: the request is sent unsigned.
1499    ///
1500    /// # Example
1501    ///
1502    /// ```rust,ignore
1503    /// use bashkit::{Bash, NetworkAllowlist};
1504    /// use bashkit::network::BotAuthConfig;
1505    ///
1506    /// let bash = Bash::builder()
1507    ///     .network(NetworkAllowlist::new().allow("https://api.example.com"))
1508    ///     .bot_auth(BotAuthConfig::from_seed([42u8; 32])
1509    ///         .with_agent_fqdn("bot.example.com"))
1510    ///     .build();
1511    /// ```
1512    #[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    /// Configure logging behavior.
1519    ///
1520    /// When the `logging` feature is enabled, Bashkit can emit structured logs
1521    /// at various levels (error, warn, info, debug, trace) during execution.
1522    ///
1523    /// # Log Levels
1524    ///
1525    /// - **ERROR**: Unrecoverable failures, exceptions, security violations
1526    /// - **WARN**: Recoverable issues, limit warnings, deprecated usage
1527    /// - **INFO**: Session lifecycle (start/end), high-level execution flow
1528    /// - **DEBUG**: Command execution, variable expansion, control flow
1529    /// - **TRACE**: Internal parser/interpreter state, detailed data flow
1530    ///
1531    /// # Security (TM-LOG-001)
1532    ///
1533    /// By default, sensitive data is redacted from logs:
1534    /// - Environment variables matching secret patterns (PASSWORD, TOKEN, etc.)
1535    /// - URL credentials (user:pass@host)
1536    /// - Values that look like API keys or JWTs
1537    ///
1538    /// # Example
1539    ///
1540    /// ```rust
1541    /// use bashkit::{Bash, LogConfig};
1542    ///
1543    /// let bash = Bash::builder()
1544    ///     .log_config(LogConfig::new()
1545    ///         .redact_env("MY_CUSTOM_SECRET"))
1546    ///     .build();
1547    /// ```
1548    ///
1549    /// # Warning
1550    ///
1551    /// Do not use `LogConfig::unsafe_disable_redaction()` or
1552    /// `LogConfig::unsafe_log_scripts()` in production, as they may expose
1553    /// sensitive data in logs.
1554    #[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    /// Configure git support for git commands.
1561    ///
1562    /// Git access is disabled by default. Use this method to enable git
1563    /// commands with the specified configuration.
1564    ///
1565    /// # Security
1566    ///
1567    /// - All operations are confined to the virtual filesystem
1568    /// - Author identity is sandboxed (configurable, never from host)
1569    /// - Remote operations (Phase 2) require URL allowlist
1570    /// - No access to host git config or credentials
1571    ///
1572    /// # Example
1573    ///
1574    /// ```rust
1575    /// use bashkit::{Bash, GitConfig};
1576    ///
1577    /// let bash = Bash::builder()
1578    ///     .git(GitConfig::new()
1579    ///         .author("CI Bot", "ci@example.com"))
1580    ///     .build();
1581    /// ```
1582    ///
1583    /// # Threat Mitigations
1584    ///
1585    /// - TM-GIT-002: Host identity leak - uses configured author, never host
1586    /// - TM-GIT-003: Host config access - no filesystem access outside VFS
1587    /// - TM-GIT-005: Repository escape - all paths within VFS
1588    #[cfg(feature = "git")]
1589    pub fn git(mut self, config: GitConfig) -> Self {
1590        self.git_config = Some(config);
1591        self
1592    }
1593
1594    /// Configure SSH access for ssh/scp/sftp builtins.
1595    ///
1596    /// # Example
1597    ///
1598    /// ```rust
1599    /// use bashkit::{Bash, SshConfig};
1600    ///
1601    /// let bash = Bash::builder()
1602    ///     .ssh(SshConfig::new()
1603    ///         .allow("*.supabase.co")
1604    ///         .default_user("root"))
1605    ///     .build();
1606    /// ```
1607    ///
1608    /// # Threat Mitigations
1609    ///
1610    /// - TM-SSH-001: Unauthorized host access - host allowlist (default-deny)
1611    /// - TM-SSH-002: Credential leakage - keys from VFS only
1612    /// - TM-SSH-005: Connection hang - configurable timeouts
1613    #[cfg(feature = "ssh")]
1614    pub fn ssh(mut self, config: SshConfig) -> Self {
1615        self.ssh_config = Some(config);
1616        self
1617    }
1618
1619    /// Set a custom SSH handler for transport interception.
1620    ///
1621    /// Embedders can implement [`SshHandler`] to mock, proxy, log, or
1622    /// rate-limit SSH operations. The allowlist check happens before
1623    /// the handler is called.
1624    #[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    /// Enable embedded Python (`python`/`python3` builtins) via Monty interpreter
1631    /// with default resource limits.
1632    ///
1633    /// Monty runs directly in the host process with resource limits enforced
1634    /// by Monty's runtime (memory, allocations, time, recursion).
1635    ///
1636    /// For security, execution is runtime-gated: set
1637    /// `BASHKIT_ALLOW_INPROCESS_PYTHON=1` via builder `.env(...)` before
1638    /// invoking `python`/`python3`.
1639    ///
1640    /// Requires the `python` feature flag. Python `pathlib.Path` operations are
1641    /// bridged to the virtual filesystem.
1642    ///
1643    /// # Example
1644    ///
1645    /// ```rust,ignore
1646    /// let bash = Bash::builder().python().build();
1647    /// ```
1648    #[cfg(feature = "python")]
1649    pub fn python(self) -> Self {
1650        self.python_with_limits(builtins::PythonLimits::default())
1651    }
1652
1653    /// Enable embedded SQLite (`sqlite`/`sqlite3` builtins) via Turso.
1654    ///
1655    /// Registers both names with the default [`SqliteLimits`]. The Turso
1656    /// engine is BETA upstream — for security, execution is runtime-gated:
1657    /// set `BASHKIT_ALLOW_INPROCESS_SQLITE=1` via builder `.env(...)` (or
1658    /// `export`) before invoking `sqlite`.
1659    ///
1660    /// Requires the `sqlite` feature flag. Database files are loaded from /
1661    /// flushed to the virtual filesystem at command boundaries.
1662    ///
1663    /// # Example
1664    ///
1665    /// ```rust,ignore
1666    /// let bash = Bash::builder()
1667    ///     .sqlite()
1668    ///     .env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1")
1669    ///     .build();
1670    /// ```
1671    #[cfg(feature = "sqlite")]
1672    pub fn sqlite(self) -> Self {
1673        self.sqlite_with_limits(builtins::SqliteLimits::default())
1674    }
1675
1676    /// Enable embedded SQLite with custom limits and backend selection.
1677    ///
1678    /// See [`BashBuilder::sqlite`] for details. Use [`SqliteLimits::backend`]
1679    /// to switch between the in-memory shim (Phase 1, default) and the
1680    /// VFS-backed adapter (Phase 2).
1681    ///
1682    /// # Example
1683    ///
1684    /// ```rust,ignore
1685    /// use bashkit::{SqliteBackend, SqliteLimits};
1686    ///
1687    /// let bash = Bash::builder()
1688    ///     .sqlite_with_limits(
1689    ///         SqliteLimits::default()
1690    ///             .backend(SqliteBackend::Vfs)
1691    ///             .max_db_bytes(8 * 1024 * 1024),
1692    ///     )
1693    ///     .build();
1694    /// ```
1695    #[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    /// Enable embedded Python with custom resource limits.
1705    ///
1706    /// See [`BashBuilder::python`] for details.
1707    ///
1708    /// # Example
1709    ///
1710    /// ```rust,ignore
1711    /// use bashkit::PythonLimits;
1712    /// use std::time::Duration;
1713    ///
1714    /// let bash = Bash::builder()
1715    ///     .python_with_limits(PythonLimits::default().max_duration(Duration::from_secs(5)))
1716    ///     .build();
1717    /// ```
1718    #[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    /// Enable embedded Python with external function handlers.
1728    ///
1729    /// See [`PythonExternalFnHandler`] for handler details.
1730    #[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    /// Enable embedded TypeScript/JavaScript execution via ZapCode with defaults.
1753    ///
1754    /// Registers `ts`, `typescript`, `node`, `deno`, and `bun` builtins.
1755    /// Requires the `typescript` feature.
1756    ///
1757    /// # Example
1758    ///
1759    /// ```rust,ignore
1760    /// let bash = Bash::builder().typescript().build();
1761    /// bash.exec("ts -c \"console.log('hello')\"").await?;
1762    /// ```
1763    #[cfg(feature = "typescript")]
1764    pub fn typescript(self) -> Self {
1765        self.typescript_with_config(builtins::TypeScriptConfig::default())
1766    }
1767
1768    /// Enable embedded TypeScript with custom resource limits.
1769    ///
1770    /// See [`BashBuilder::typescript`] for details.
1771    #[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    /// Enable embedded TypeScript with full configuration control.
1777    ///
1778    /// # Example
1779    ///
1780    /// ```rust,ignore
1781    /// use bashkit::{TypeScriptConfig, TypeScriptLimits};
1782    /// use std::time::Duration;
1783    ///
1784    /// // Only ts/typescript commands, no node/deno/bun aliases
1785    /// let bash = Bash::builder()
1786    ///     .typescript_with_config(TypeScriptConfig::default().compat_aliases(false))
1787    ///     .build();
1788    ///
1789    /// // Disable unsupported-mode hints
1790    /// let bash = Bash::builder()
1791    ///     .typescript_with_config(TypeScriptConfig::default().unsupported_mode_hint(false))
1792    ///     .build();
1793    ///
1794    /// // Custom limits + no compat aliases
1795    /// let bash = Bash::builder()
1796    ///     .typescript_with_config(
1797    ///         TypeScriptConfig::default()
1798    ///             .limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5)))
1799    ///             .compat_aliases(false)
1800    ///     )
1801    ///     .build();
1802    /// ```
1803    #[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    /// Enable embedded TypeScript with external function handlers.
1809    ///
1810    /// See [`TypeScriptExternalFnHandler`] for handler details.
1811    #[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    /// Register a custom builtin command.
1826    ///
1827    /// Custom builtins extend bashkit with domain-specific commands that can be
1828    /// invoked from bash scripts. They have full access to the execution context
1829    /// including arguments, environment, shell variables, and the virtual filesystem.
1830    ///
1831    /// Custom builtins can override default builtins if registered with the same name.
1832    ///
1833    /// # Arguments
1834    ///
1835    /// * `name` - The command name (e.g., "psql", "kubectl")
1836    /// * `builtin` - A boxed implementation of the [`Builtin`] trait
1837    ///
1838    /// # Example
1839    ///
1840    /// ```rust
1841    /// use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
1842    ///
1843    /// struct Greet {
1844    ///     default_name: String,
1845    /// }
1846    ///
1847    /// #[async_trait]
1848    /// impl Builtin for Greet {
1849    ///     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
1850    ///         let name = ctx.args.first()
1851    ///             .map(|s| s.as_str())
1852    ///             .unwrap_or(&self.default_name);
1853    ///         Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
1854    ///     }
1855    /// }
1856    ///
1857    /// let bash = Bash::builder()
1858    ///     .builtin("greet", Box::new(Greet { default_name: "World".into() }))
1859    ///     .build();
1860    /// ```
1861    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    /// Register a capability extension.
1867    ///
1868    /// Extensions contribute a related set of builtins as one unit. Commands
1869    /// registered by an extension follow the same override rules as
1870    /// [`BashBuilder::builtin`]: later registrations replace earlier ones with
1871    /// the same name.
1872    ///
1873    /// # Example
1874    ///
1875    /// ```rust
1876    /// use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, Extension, async_trait};
1877    ///
1878    /// struct Hello;
1879    ///
1880    /// #[async_trait]
1881    /// impl Builtin for Hello {
1882    ///     async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
1883    ///         Ok(ExecResult::ok("hello\n".to_string()))
1884    ///     }
1885    /// }
1886    ///
1887    /// struct HelloExtension;
1888    ///
1889    /// impl Extension for HelloExtension {
1890    ///     fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
1891    ///         vec![("hello".to_string(), Box::new(Hello))]
1892    ///     }
1893    /// }
1894    ///
1895    /// let bash = Bash::builder().extension(HelloExtension).build();
1896    /// ```
1897    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    /// Register an `on_exit` interceptor hook.
1908    ///
1909    /// Fired when the `exit` builtin runs.  The hook can inspect or
1910    /// modify the [`ExitEvent`](hooks::ExitEvent), or cancel the exit.
1911    /// Multiple hooks run in registration order.
1912    ///
1913    /// # Example
1914    ///
1915    /// ```rust
1916    /// use bashkit::hooks::{HookAction, ExitEvent};
1917    /// use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
1918    ///
1919    /// let exited = Arc::new(AtomicBool::new(false));
1920    /// let flag = exited.clone();
1921    ///
1922    /// let bash = bashkit::Bash::builder()
1923    ///     .on_exit(Box::new(move |event: ExitEvent| {
1924    ///         flag.store(true, Ordering::Relaxed);
1925    ///         HookAction::Continue(event)
1926    ///     }))
1927    ///     .build();
1928    /// ```
1929    pub fn on_exit(mut self, hook: hooks::Interceptor<hooks::ExitEvent>) -> Self {
1930        self.hooks_on_exit.push(hook);
1931        self
1932    }
1933
1934    /// Register a `before_exec` interceptor hook.
1935    ///
1936    /// Fires before a script is executed. Can modify the script text
1937    /// or cancel execution entirely.
1938    pub fn before_exec(mut self, hook: hooks::Interceptor<hooks::ExecInput>) -> Self {
1939        self.hooks_before_exec.push(hook);
1940        self
1941    }
1942
1943    /// Register an `after_exec` interceptor hook.
1944    ///
1945    /// Fires after script execution completes. Can modify or inspect
1946    /// the output (stdout, stderr, exit code).
1947    pub fn after_exec(mut self, hook: hooks::Interceptor<hooks::ExecOutput>) -> Self {
1948        self.hooks_after_exec.push(hook);
1949        self
1950    }
1951
1952    /// Register a `before_tool` interceptor hook.
1953    ///
1954    /// Fires before a builtin command is executed. Can modify args or
1955    /// cancel the tool invocation.
1956    pub fn before_tool(mut self, hook: hooks::Interceptor<hooks::ToolEvent>) -> Self {
1957        self.hooks_before_tool.push(hook);
1958        self
1959    }
1960
1961    /// Register an `after_tool` interceptor hook.
1962    ///
1963    /// Fires after a builtin command completes.
1964    pub fn after_tool(mut self, hook: hooks::Interceptor<hooks::ToolResult>) -> Self {
1965        self.hooks_after_tool.push(hook);
1966        self
1967    }
1968
1969    /// Register an `on_error` interceptor hook.
1970    ///
1971    /// Fires when the interpreter encounters an error.
1972    pub fn on_error(mut self, hook: hooks::Interceptor<hooks::ErrorEvent>) -> Self {
1973        self.hooks_on_error.push(hook);
1974        self
1975    }
1976
1977    /// Register a `before_http` interceptor hook.
1978    ///
1979    /// Fires before each HTTP request (after allowlist validation).
1980    /// Can modify the URL/headers or cancel the request.
1981    ///
1982    /// # Example
1983    ///
1984    /// ```
1985    /// use bashkit::{Bash, hooks::{HookAction, HttpRequestEvent}};
1986    ///
1987    /// let bash = Bash::builder()
1988    ///     .before_http(Box::new(|req: HttpRequestEvent| {
1989    ///         if req.url.contains("blocked") {
1990    ///             HookAction::Cancel("blocked by policy".into())
1991    ///         } else {
1992    ///             HookAction::Continue(req)
1993    ///         }
1994    ///     }))
1995    ///     .build();
1996    /// ```
1997    #[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    /// Register an `after_http` interceptor hook.
2004    ///
2005    /// Fires after each HTTP response is received. Can inspect
2006    /// response status and headers.
2007    #[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    /// Inject credentials for outbound HTTP requests matching the given URL pattern.
2014    ///
2015    /// The pattern uses the same matching as [`NetworkAllowlist`]
2016    /// (scheme + host + port + path prefix). Injected headers **overwrite**
2017    /// any existing headers with the same name set by the script, preventing
2018    /// credential spoofing.
2019    ///
2020    /// The script never sees the real credential — it is injected transparently
2021    /// by a `before_http` hook after the allowlist check.
2022    ///
2023    /// # Example
2024    ///
2025    /// ```rust
2026    /// use bashkit::{Bash, Credential, NetworkAllowlist};
2027    ///
2028    /// let bash = Bash::builder()
2029    ///     .network(NetworkAllowlist::new()
2030    ///         .allow("https://api.github.com"))
2031    ///     .credential("https://api.github.com",
2032    ///         Credential::bearer("ghp_xxxx"))
2033    ///     .build();
2034    /// // Scripts can now: curl -s https://api.github.com/repos/foo/bar
2035    /// // Authorization: Bearer ghp_xxxx is added transparently.
2036    /// ```
2037    ///
2038    /// See [`credential_injection_guide`] for the full guide.
2039    #[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    /// Inject credentials via a placeholder env var visible to scripts.
2048    ///
2049    /// Sets environment variable `env_name` to an opaque placeholder string.
2050    /// When a request to `pattern` contains the placeholder in any header
2051    /// value, it is replaced with the real credential on the wire.
2052    ///
2053    /// The placeholder is a random string (`bk_placeholder_<hex>`) that:
2054    /// - Cannot be reversed to the real credential
2055    /// - Is only replaced for requests matching the URL pattern
2056    /// - Passes most SDK non-empty validation checks
2057    ///
2058    /// # Example
2059    ///
2060    /// ```rust
2061    /// use bashkit::{Bash, Credential, NetworkAllowlist};
2062    ///
2063    /// let bash = Bash::builder()
2064    ///     .network(NetworkAllowlist::new()
2065    ///         .allow("https://api.openai.com"))
2066    ///     .credential_placeholder("OPENAI_API_KEY",
2067    ///         "https://api.openai.com",
2068    ///         Credential::bearer("sk-real-key"))
2069    ///     .build();
2070    /// // Scripts see $OPENAI_API_KEY as "bk_placeholder_..." and use it normally.
2071    /// // The placeholder is replaced with the real key in outbound headers.
2072    /// ```
2073    ///
2074    /// See [`credential_injection_guide`] for the full guide.
2075    #[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    /// Mount a text file in the virtual filesystem.
2091    ///
2092    /// This creates a regular file (mode `0o644`) with the specified content at
2093    /// the given path. Parent directories are created automatically.
2094    ///
2095    /// Mounted files are added via an [`OverlayFs`] layer on top of the base
2096    /// filesystem. This means:
2097    /// - The base filesystem remains unchanged
2098    /// - Mounted files take precedence over base filesystem files
2099    /// - Works with any filesystem implementation
2100    ///
2101    /// # Example
2102    ///
2103    /// ```rust
2104    /// use bashkit::Bash;
2105    ///
2106    /// # #[tokio::main]
2107    /// # async fn main() -> bashkit::Result<()> {
2108    /// let mut bash = Bash::builder()
2109    ///     .mount_text("/config/app.conf", "debug=true\nport=8080\n")
2110    ///     .mount_text("/data/users.json", r#"["alice", "bob"]"#)
2111    ///     .build();
2112    ///
2113    /// let result = bash.exec("cat /config/app.conf").await?;
2114    /// assert_eq!(result.stdout, "debug=true\nport=8080\n");
2115    /// # Ok(())
2116    /// # }
2117    /// ```
2118    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    /// Mount a readonly text file in the virtual filesystem.
2128    ///
2129    /// This creates a readonly file (mode `0o444`) with the specified content.
2130    /// Parent directories are created automatically.
2131    ///
2132    /// Readonly files are useful for:
2133    /// - Configuration that shouldn't be modified by scripts
2134    /// - Reference data that should remain immutable
2135    /// - Simulating system files like `/etc/passwd`
2136    ///
2137    /// Mounted files are added via an [`OverlayFs`] layer on top of the base
2138    /// filesystem. This means:
2139    /// - The base filesystem remains unchanged
2140    /// - Mounted files take precedence over base filesystem files
2141    /// - Works with any filesystem implementation
2142    ///
2143    /// # Example
2144    ///
2145    /// ```rust
2146    /// use bashkit::Bash;
2147    ///
2148    /// # #[tokio::main]
2149    /// # async fn main() -> bashkit::Result<()> {
2150    /// let mut bash = Bash::builder()
2151    ///     .mount_readonly_text("/etc/version", "1.2.3")
2152    ///     .mount_readonly_text("/etc/app.conf", "production=true\n")
2153    ///     .build();
2154    ///
2155    /// // Can read the file
2156    /// let result = bash.exec("cat /etc/version").await?;
2157    /// assert_eq!(result.stdout, "1.2.3");
2158    ///
2159    /// // File has readonly permissions
2160    /// let stat = bash.fs().stat(std::path::Path::new("/etc/version")).await?;
2161    /// assert_eq!(stat.mode, 0o444);
2162    /// # Ok(())
2163    /// # }
2164    /// ```
2165    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    /// Mount a lazy file whose content is loaded on first read.
2179    ///
2180    /// The `loader` closure is called at most once when the file is first read.
2181    /// If the file is overwritten before being read, the loader is never called.
2182    /// `stat()` returns metadata using `size_hint` without triggering the load.
2183    ///
2184    /// # Example
2185    ///
2186    /// ```rust
2187    /// use bashkit::Bash;
2188    /// use std::sync::Arc;
2189    ///
2190    /// # #[tokio::main]
2191    /// # async fn main() -> bashkit::Result<()> {
2192    /// let mut bash = Bash::builder()
2193    ///     .mount_lazy("/data/large.csv", 1024, Arc::new(|| {
2194    ///         b"id,name\n1,Alice\n".to_vec()
2195    ///     }))
2196    ///     .build();
2197    ///
2198    /// let result = bash.exec("cat /data/large.csv").await?;
2199    /// assert_eq!(result.stdout, "id,name\n1,Alice\n");
2200    /// # Ok(())
2201    /// # }
2202    /// ```
2203    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    /// Mount a real host directory as a readonly overlay at the VFS root.
2219    ///
2220    /// Files from `host_path` become visible at the same paths inside the VFS.
2221    /// For example, if the host directory contains `src/main.rs`, it will be
2222    /// available as `/src/main.rs` inside the virtual bash session.
2223    ///
2224    /// The host directory is read-only: scripts cannot modify host files.
2225    ///
2226    /// Requires the `realfs` feature flag.
2227    ///
2228    /// # Example
2229    ///
2230    /// ```rust,ignore
2231    /// let bash = Bash::builder()
2232    ///     .mount_real_readonly("/path/to/project")
2233    ///     .build();
2234    /// ```
2235    #[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    /// Mount a real host directory as a readonly filesystem at a specific VFS path.
2246    ///
2247    /// Files from `host_path` become visible under `vfs_mount` inside the VFS.
2248    /// For example, mounting `/home/user/data` at `/mnt/data` makes
2249    /// `/home/user/data/file.txt` available as `/mnt/data/file.txt`.
2250    ///
2251    /// The host directory is read-only: scripts cannot modify host files.
2252    ///
2253    /// Requires the `realfs` feature flag.
2254    ///
2255    /// # Example
2256    ///
2257    /// ```rust,ignore
2258    /// let bash = Bash::builder()
2259    ///     .mount_real_readonly_at("/path/to/data", "/mnt/data")
2260    ///     .build();
2261    /// ```
2262    #[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    /// Mount a real host directory with read-write access at the VFS root.
2277    ///
2278    /// **WARNING**: This breaks the sandbox boundary. Scripts can modify files
2279    /// on the host filesystem. Only use when:
2280    /// - The script is fully trusted
2281    /// - The host directory is appropriately scoped
2282    ///
2283    /// Requires the `realfs` feature flag.
2284    ///
2285    /// # Example
2286    ///
2287    /// ```rust,ignore
2288    /// let bash = Bash::builder()
2289    ///     .mount_real_readwrite("/path/to/workspace")
2290    ///     .build();
2291    /// ```
2292    #[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    /// Mount a real host directory with read-write access at a specific VFS path.
2303    ///
2304    /// **WARNING**: This breaks the sandbox boundary. Scripts can modify files
2305    /// on the host filesystem.
2306    ///
2307    /// Requires the `realfs` feature flag.
2308    ///
2309    /// # Example
2310    ///
2311    /// ```rust,ignore
2312    /// let bash = Bash::builder()
2313    ///     .mount_real_readwrite_at("/path/to/workspace", "/mnt/workspace")
2314    ///     .build();
2315    /// ```
2316    #[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    /// Set an allowlist of host paths that may be mounted.
2331    ///
2332    /// When set, only host paths starting with an allowed prefix are accepted
2333    /// by `mount_real_*` methods. Paths outside the allowlist are rejected with
2334    /// a warning at build time.
2335    ///
2336    /// # Example
2337    ///
2338    /// ```rust,ignore
2339    /// let bash = Bash::builder()
2340    ///     .allowed_mount_paths(["/home/user/projects", "/tmp"])
2341    ///     .mount_real_readonly("/home/user/projects/data")  // OK
2342    ///     .mount_real_readonly("/etc/passwd")                // rejected
2343    ///     .build();
2344    /// ```
2345    #[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    /// Build the Bash instance.
2355    ///
2356    /// If mounted files are specified, they are added via an [`OverlayFs`] layer
2357    /// on top of the base filesystem. This means:
2358    /// - The base filesystem remains unchanged
2359    /// - Mounted files take precedence over base filesystem files
2360    /// - Works with any filesystem implementation
2361    ///
2362    /// # Example
2363    ///
2364    /// ```rust
2365    /// use bashkit::{Bash, InMemoryFs};
2366    /// use std::sync::Arc;
2367    ///
2368    /// # #[tokio::main]
2369    /// # async fn main() -> bashkit::Result<()> {
2370    /// // Works with default InMemoryFs
2371    /// let mut bash = Bash::builder()
2372    ///     .mount_text("/config/app.conf", "debug=true\n")
2373    ///     .build();
2374    ///
2375    /// // Also works with custom filesystems
2376    /// let custom_fs = Arc::new(InMemoryFs::new());
2377    /// let mut bash = Bash::builder()
2378    ///     .fs(custom_fs)
2379    ///     .mount_text("/config/app.conf", "debug=true\n")
2380    ///     .mount_readonly_text("/etc/version", "1.0.0")
2381    ///     .build();
2382    ///
2383    /// let result = bash.exec("cat /config/app.conf").await?;
2384    /// assert_eq!(result.stdout, "debug=true\n");
2385    /// # Ok(())
2386    /// # }
2387    /// ```
2388    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        // Layer 1: Apply real filesystem mounts (if any)
2396        #[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        // Layer 2: If there are mounted text/lazy files, wrap in an OverlayFs
2404        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        // Layer 3: Wrap in MountableFs for post-build live mount/unmount
2421        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        // Set hooks after build — avoids adding another arg to build_with_fs.
2457        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        // Convert credential policy into a before_http hook.
2470        // Credential hook runs FIRST so subsequent hooks see injected headers.
2471        #[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        // Set HTTP hooks on the HttpClient (transport-level, not interpreter-level)
2483        #[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    /// Sensitive host paths that are blocked from mounting by default.
2499    #[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            // Warn on writable mounts
2533            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            // Block sensitive paths
2553            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            // Check allowlist if configured
2565            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                    // Overlay at root: real fs becomes the lower layer,
2593                    // existing VFS content overlaid on top
2594                    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 there are specific mount points, wrap in MountableFs
2603        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    /// Internal helper to build Bash with a configured filesystem.
2621    #[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        // Set environment variables (also override shell variable defaults)
2667        for (key, value) in &env {
2668            interpreter.set_env(key, value);
2669            // Shell variables like HOME, USER should also be set as variables
2670            // so they take precedence over the defaults
2671            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 username is set, automatically set USER env var
2680        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        // Configure HTTP client for network builtins
2690        #[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        // Configure git client for git builtins
2704        #[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        // Configure SSH client for ssh/scp/sftp builtins
2711        #[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        // Configure persistent history file
2721        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// =============================================================================
2756// Documentation Modules
2757// =============================================================================
2758// These modules embed external markdown guides into rustdoc.
2759// Source files live in crates/bashkit/docs/ - edit there, not here.
2760// See specs/documentation.md for the documentation approach.
2761
2762/// Guide for transparent credential injection in outbound HTTP requests.
2763///
2764/// Two modes: **injection** (script unaware) and **placeholder** (opaque
2765/// env var replaced on the wire). Credentials are scoped per URL pattern
2766/// and never visible to sandboxed scripts.
2767///
2768/// **Related:** [`BashBuilder::credential`], [`BashBuilder::credential_placeholder`],
2769/// [`Credential`], [`NetworkAllowlist`], [`threat_model`]
2770#[cfg(feature = "http_client")]
2771#[doc = include_str!("../docs/credential-injection.md")]
2772pub mod credential_injection_guide {}
2773
2774/// Guide for creating custom builtins to extend Bashkit.
2775///
2776/// This guide covers:
2777/// - Implementing the [`Builtin`] trait
2778/// - Accessing execution context ([`BuiltinContext`])
2779/// - Working with arguments, environment, and filesystem
2780/// - Best practices and examples
2781///
2782/// **Related:** [`BashBuilder::builtin`], [`compatibility_scorecard`]
2783#[doc = include_str!("../docs/custom_builtins.md")]
2784pub mod custom_builtins_guide {}
2785
2786/// Public guide for clap-backed custom builtins.
2787///
2788/// This guide covers:
2789/// - Implementing [`ClapBuiltin`] with `#[derive(clap::Parser)]`
2790/// - Writing stdout/stderr through [`BashkitContext`]
2791/// - Help, version, and parse-error behavior
2792/// - Subcommands and pipeline stdin
2793///
2794/// **Related:** [`ClapBuiltin`], [`BashkitContext`], [`BashBuilder::builtin`], [`custom_builtins_guide`]
2795#[doc = include_str!("../docs/clap-builtins.md")]
2796pub mod clap_builtins_guide {}
2797
2798/// Bash compatibility scorecard.
2799///
2800/// Tracks feature parity with real bash:
2801/// - Implemented vs missing features
2802/// - Builtins, syntax, expansions
2803/// - POSIX compliance status
2804/// - Resource limits
2805///
2806/// **Related:** [`custom_builtins_guide`], [`threat_model`]
2807#[doc = include_str!("../docs/compatibility.md")]
2808pub mod compatibility_scorecard {}
2809
2810/// jq builtin: supported filters, flags, and variables.
2811///
2812/// **Topics covered:**
2813/// - Implemented command-line flags
2814/// - Variables (including `$ENV`)
2815/// - Notable filters and the bashkit compatibility shim
2816/// - Known gaps where bashkit's input model differs from upstream jq
2817///
2818/// **Related:** [`compatibility_scorecard`], [`threat_model`]
2819#[doc = include_str!("../docs/jq.md")]
2820pub mod jq_guide {}
2821
2822/// Security threat model guide.
2823///
2824/// This guide documents security threats addressed by Bashkit and their mitigations.
2825/// All threats use stable IDs for tracking and code references.
2826///
2827/// **Topics covered:**
2828/// - Denial of Service mitigations (TM-DOS-*)
2829/// - Sandbox escape prevention (TM-ESC-*)
2830/// - Information disclosure protection (TM-INF-*)
2831/// - Network security controls (TM-NET-*)
2832/// - Multi-tenant isolation (TM-ISO-*)
2833///
2834/// **Related:** [`ExecutionLimits`], [`FsLimits`], [`NetworkAllowlist`]
2835#[doc = include_str!("../docs/threat-model.md")]
2836pub mod threat_model {}
2837
2838/// Guide for embedded Python via the Monty interpreter.
2839///
2840/// **Experimental:** The Monty integration is experimental with known security
2841/// issues. See the guide below and [`threat_model`] for details.
2842///
2843/// This guide covers:
2844/// - Enabling Python with [`BashBuilder::python`]
2845/// - VFS bridging (`pathlib.Path` → virtual filesystem)
2846/// - Configuring resource limits with [`PythonLimits`]
2847/// - LLM tool integration via [`BashToolBuilder::python`]
2848/// - Known limitations (no `open()`, no HTTP, no classes)
2849///
2850/// **Related:** [`BashBuilder::python`], [`PythonLimits`], [`threat_model`]
2851#[cfg(feature = "python")]
2852#[doc = include_str!("../docs/python.md")]
2853pub mod python_guide {}
2854
2855/// Guide for the embedded SQLite builtin (Turso).
2856///
2857/// Topics covered:
2858/// - Quick start with `Bash::builder().sqlite()`
2859/// - Memory vs VFS backends
2860/// - `:memory:` databases
2861/// - Output modes (list, csv, tabs, line, column, box, json, markdown)
2862/// - Dot-commands (`.tables`, `.schema`, `.dump`, `.read`, …)
2863/// - Resource limits and security model
2864///
2865/// **Related:** [`BashBuilder::sqlite`], [`SqliteLimits`], [`SqliteBackend`], [`threat_model`]
2866#[cfg(feature = "sqlite")]
2867#[doc = include_str!("../docs/sqlite.md")]
2868pub mod sqlite_guide {}
2869
2870/// Guide for embedded TypeScript execution via the ZapCode interpreter.
2871///
2872/// This guide covers:
2873/// - Quick start with `Bash::builder().typescript()`
2874/// - Inline code, script files, pipelines
2875/// - VFS bridging via `readFile()`/`writeFile()` external functions
2876/// - Resource limits via `TypeScriptLimits`
2877/// - Configuration via `TypeScriptConfig` (compat aliases, unsupported-mode hints)
2878/// - LLM tool integration
2879///
2880/// **Related:** [`BashBuilder::typescript`], [`TypeScriptLimits`], [`TypeScriptConfig`], [`threat_model`]
2881#[cfg(feature = "typescript")]
2882#[doc = include_str!("../docs/typescript.md")]
2883pub mod typescript_guide {}
2884
2885/// Guide for SSH/SCP/SFTP remote operations.
2886///
2887/// **Related:** [`BashBuilder::ssh`], [`SshConfig`], [`SshAllowlist`], [`threat_model`]
2888#[cfg(feature = "ssh")]
2889#[doc = include_str!("../docs/ssh.md")]
2890pub mod ssh_guide {}
2891
2892/// Guide for live mount/unmount on a running Bash instance.
2893///
2894/// This guide covers:
2895/// - Attaching/detaching filesystems post-build
2896/// - State preservation across mount operations
2897/// - Hot-swapping mounted filesystems
2898/// - Layered filesystem architecture
2899///
2900/// **Related:** [`Bash::mount`], [`Bash::unmount`], [`MountableFs`], [`BashBuilder::mount_text`]
2901#[doc = include_str!("../docs/live_mounts.md")]
2902pub mod live_mounts_guide {}
2903
2904/// Logging guide for Bashkit.
2905///
2906/// This guide covers configuring structured logging, log levels, security
2907/// considerations, and integration with tracing subscribers.
2908///
2909/// **Topics covered:**
2910/// - Enabling the `logging` feature
2911/// - Log levels and targets
2912/// - Security: sensitive data redaction (TM-LOG-*)
2913/// - Integration with tracing-subscriber
2914///
2915/// **Related:** [`LogConfig`], [`threat_model`]
2916#[cfg(feature = "logging")]
2917#[doc = include_str!("../docs/logging.md")]
2918pub mod logging_guide {}
2919
2920/// Interceptor hooks guide for Bashkit.
2921///
2922/// This guide covers the hook system for observing, modifying, and cancelling
2923/// operations at key points in the execution pipeline.
2924///
2925/// **Topics covered:**
2926/// - Execution hooks (`before_exec`, `after_exec`)
2927/// - Tool hooks (`before_tool`, `after_tool`)
2928/// - Lifecycle hooks (`on_exit`, `on_error`)
2929/// - HTTP hooks (`before_http`, `after_http`)
2930/// - Chaining multiple hooks
2931/// - Event payloads and thread safety
2932///
2933/// **Related:** [`BashBuilder`], [`hooks`], [`custom_builtins_guide`]
2934#[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        // Read the file back
3002        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    /// Phase 1 target test: `echo $HOME | cat > /tmp/out && cat /tmp/out`
3047    #[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        // Create a file first
3064        bash.exec("echo hello > /tmp/input.txt").await.unwrap();
3065
3066        // Read it using input redirection
3067        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        // for x; do ... done iterates over positional parameters inside a function
3123        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        // While with false condition - executes 0 times
3134        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        // Test [abc] bracket expression
3230        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        // Test [a-z] range expression
3241        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        // Test [!abc] negation
3262        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        // Just run break alone - should not error
3273        let result = bash.exec("break").await.unwrap();
3274        // break outside of loop returns success with no output
3275        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        // Simple for loop with one item
3282        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        // For loop with break
3290        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        // For loop with echo then break - tests the semicolon command list in body
3299        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        // Assignment before command
3376        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        // Assignment without command should succeed silently
3384        let result = bash.exec("FOO=bar").await.unwrap();
3385        assert_eq!(result.stdout, "");
3386        assert_eq!(result.exit_code, 0);
3387
3388        // Verify the variable was set
3389        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        // VAR=value command should make VAR visible in the command's environment
3404        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        // Prefix assignment should NOT persist after the command
3412        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        // Duplicate prefix assignments should still restore original env.
3421        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        // Set up existing env var
3431        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        // Multiple prefix assignments on same command
3442        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        // Empty value is still set in environment
3451        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        // printenv for a var that was never set should fail
3460        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        // After prefix assignment with command, var should not be in shell scope
3469        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        // Assignment without a command should persist (not a prefix assignment)
3478        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        // Regression: `local k; read -r k <<< "val"` must set k in local scope
3538        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        // Regression: local IFS=":" must affect "${arr[*]}" joining
3554        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        // Create some files
3575        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        // Glob for *.txt files
3580        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        // Create some files
3588        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        // Glob for a?.txt (single character)
3593        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        // Glob that doesn't match anything should return the pattern
3601        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        // Test that local command runs without error
3689        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        // ${var:-default} when unset
3704        let result = bash.exec("echo ${UNSET:-default}").await.unwrap();
3705        assert_eq!(result.stdout, "default\n");
3706
3707        // ${var:-default} when set
3708        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        // ${var:=default} assigns when unset
3716        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        // ${var#pattern} - remove shortest prefix
3731        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        // ${var#./"$other"} - pattern mixing literal and quoted variable
3739        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        // ${var%pattern} - remove shortest suffix
3750        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        // ${@/#/prefix} should prepend prefix to each positional parameter
3758        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        // ${@/%/suffix} should append suffix to each positional parameter
3769        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        // ${@/#/$var} should prepend var value to each positional parameter
3780        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        // ${@#prefix} should strip prefix from each positional parameter
3791        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        // Basic array declaration and access
3802        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        // ${arr[@]} - all elements
3810        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        // ${#arr[@]} - number of elements
3821        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        // arr[n]=value assignment
3829        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        // Regression: single quote char as array index caused begin > end slice panic
3839        let mut bash = Bash::new();
3840        // Should not panic on malformed subscript with lone quote
3841        let _ = bash.exec("echo ${arr[\"]}").await;
3842    }
3843
3844    // Resource limit tests
3845
3846    #[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        // Run 6 commands - should fail on the 6th
3852        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        // Run 5 commands - should succeed
3868        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        // Loop that tries to run 10 times
3878        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        // Loop that runs 5 times - should succeed
3896        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        // Recursive function that would go 5 deep
3909        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        // Simple function call - should succeed
3927        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        // While loop with counter
3937        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        // Default limits should allow typical scripts to run
3974        let mut bash = Bash::new();
3975        // Avoid using "done" as a word after a for loop - it causes parsing ambiguity
3976        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    // Filesystem access tests
3994
3995    #[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        // Write binary data with null bytes and high bytes
4002        let binary_data: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE, 0x42, 0x00, 0x7F];
4003        fs.write_file(path, &binary_data).await.unwrap();
4004
4005        // Read it back
4006        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        // Pre-populate a file before running bash
4016        bash.fs()
4017            .write_file(path, b"Hello from Rust!\n")
4018            .await
4019            .unwrap();
4020
4021        // Access it from bash
4022        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        // Create file via bash
4032        bash.exec("echo 'Created by bash' > /tmp/from_bash.txt")
4033            .await
4034            .unwrap();
4035
4036        // Read it directly
4037        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        // File doesn't exist yet
4048        assert!(!fs.exists(path).await.unwrap());
4049
4050        // Create it
4051        fs.write_file(path, b"content").await.unwrap();
4052
4053        // Now exists
4054        assert!(fs.exists(path).await.unwrap());
4055
4056        // Check metadata
4057        let stat = fs.stat(path).await.unwrap();
4058        assert!(stat.file_type.is_file());
4059        assert_eq!(stat.size, 7); // "content" = 7 bytes
4060    }
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        // Create nested directories
4068        fs.mkdir(std::path::Path::new("/data/nested/dir"), true)
4069            .await
4070            .unwrap();
4071
4072        // Create some files
4073        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        // Read directory
4081        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        // Copy
4112        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        // Rename
4120        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        // Verify
4128        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    // Bug fix tests
4141
4142    #[tokio::test]
4143    async fn test_echo_done_as_argument() {
4144        // BUG: "done" should be parsed as a regular argument when not in loop context
4145        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        // Simple echo done without any loop
4156        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        // BUG: Redirecting to /dev/null should discard output silently
4164        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        // Test string concatenation in a loop
4172        let mut bash = Bash::new();
4173        // First test: basic for loop still works
4174        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        // Test variable assignment followed by for loop
4178        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        // Test string concatenation in a loop
4186        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    // Negative/edge case tests for reserved word handling
4195
4196    #[tokio::test]
4197    async fn test_done_still_terminates_loop() {
4198        // Ensure "done" still works as a loop terminator
4199        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        // Ensure "fi" still works as an if terminator
4207        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        // "fi" should be a valid argument outside of if context
4215        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        // "then" should be a valid argument outside of if context
4223        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        // Reserved words in quotes should always be arguments
4231        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        // Nested loops should properly match done keywords
4239        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    // Negative/edge case tests for /dev/null
4248
4249    #[tokio::test]
4250    async fn test_dev_null_read_returns_empty() {
4251        // Reading from /dev/null should return empty
4252        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        // Appending to /dev/null should work silently
4260        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        // /dev/null in a pipeline should work
4268        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        // /dev/null should exist and be readable
4279        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    // Custom username/hostname tests
4285
4286    #[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        // Default values should still work
4332        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    // Custom builtins tests
4355
4356    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        /// A simple custom builtin that outputs a static string
4363        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        /// A custom builtin that uses arguments
4414        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        /// A custom builtin that reads from stdin
4439        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        /// A custom builtin that interacts with the filesystem
4458        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        /// A custom builtin that overrides a default builtin
4491        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        /// Test multiple custom builtins
4512        #[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        /// A custom builtin with internal state
4553        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        /// A custom builtin that returns an error
4589        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        /// A custom builtin that reads environment variables
4648        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    // Parser timeout tests
4679
4680    #[tokio::test]
4681    async fn test_parser_timeout_default() {
4682        // Default parser timeout should be 5 seconds
4683        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        // Parser timeout can be customized
4690        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        // Normal scripts should complete well within timeout
4697        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    // Parser fuel tests
4704
4705    #[tokio::test]
4706    async fn test_parser_fuel_default() {
4707        // Default parser fuel should be 100,000
4708        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        // Parser fuel can be customized
4715        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        // Normal scripts should parse within fuel limit
4722        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    // Input size limit tests
4729
4730    #[tokio::test]
4731    async fn test_input_size_limit_default() {
4732        // Default input size limit should be 10MB
4733        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        // Input size limit can be customized
4740        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        // Scripts exceeding the limit should be rejected
4747        let limits = ExecutionLimits::new().max_input_bytes(10);
4748        let mut bash = Bash::builder().limits(limits).build();
4749
4750        // This script is longer than 10 bytes
4751        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        // Normal scripts should complete within limit
4764        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    // AST depth limit tests
4771
4772    #[tokio::test]
4773    async fn test_ast_depth_limit_default() {
4774        // Default AST depth limit should be 100
4775        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        // AST depth limit can be customized
4782        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        // Normal scripts should parse within limit
4789        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        // Deeply nested scripts should be rejected
4798        let limits = ExecutionLimits::new().max_ast_depth(2);
4799        let mut bash = Bash::builder().limits(limits).build();
4800
4801        // This script has 3 levels of nesting (exceeds limit of 2)
4802        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        // Scripts exceeding fuel limit should be rejected
4817        // With fuel of 3, parsing "echo a" should fail (needs multiple operations)
4818        let limits = ExecutionLimits::new().max_parser_operations(3);
4819        let mut bash = Bash::builder().limits(limits).build();
4820
4821        // Even a simple script needs more than 3 parsing operations
4822        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    // set -e (errexit) tests
4833
4834    #[tokio::test]
4835    async fn test_set_e_basic() {
4836        // set -e should exit on non-zero return
4837        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        // set -e exits immediately on failed command
4849        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        // set +e disables errexit
4861        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        // set -e only checks last command in pipeline
4872        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        // set -e should not trigger on if condition failure
4883        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        // set -e should not trigger on while condition failure
4894        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        // set -e should work inside brace groups
4905        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        // set -e should not trigger on && chain (false && ... is expected to not run second)
4917        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        // set -e should not trigger on || chain (true || false is expected to short circuit)
4928        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    // Tilde expansion tests
4937
4938    #[tokio::test]
4939    async fn test_tilde_expansion_basic() {
4940        // ~ should expand to $HOME
4941        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        // ~/path should expand to $HOME/path
4949        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        // Tilde expansion should work in variable assignments
4957        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        // ~ should default to /home/sandbox (DEFAULT_USERNAME is "sandbox")
4965        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        // ~ not at start of word should not expand
4973        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    // Special variables tests
4979
4980    #[tokio::test]
4981    async fn test_special_var_dollar_dollar() {
4982        // $$ - current process ID
4983        let mut bash = Bash::new();
4984        let result = bash.exec("echo $$").await.unwrap();
4985        // Should be a numeric value
4986        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        // $RANDOM - random number between 0 and 32767
4993        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        // $RANDOM should return different values on different calls
5006        let mut bash = Bash::new();
5007        let result1 = bash.exec("echo $RANDOM").await.unwrap();
5008        let result2 = bash.exec("echo $RANDOM").await.unwrap();
5009        // With high probability, they should be different
5010        // (small chance they're the same, so this test may rarely fail)
5011        // We'll just check they're both valid numbers
5012        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        // Two separate Bash instances should produce different PRNG sequences
5027        // (with very high probability, since each is seeded from OS entropy)
5028        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        // Extremely unlikely to collide with independent OS-entropy seeds
5037        assert_ne!(v1, v2, "separate instances should produce different values");
5038    }
5039
5040    #[tokio::test]
5041    async fn test_random_reseed() {
5042        // RANDOM=N should reseed the PRNG, producing a deterministic sequence
5043        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        // Sequential $RANDOM calls within a single instance should differ
5058        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        // At least two of three should differ (LCG never produces same value twice in a row)
5067        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        // $LINENO - current line number
5076        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        // $LINENO tracks line numbers across multiple lines
5084        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        // $LINENO inside a for loop
5099        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        // Loop body is on line 2
5109        assert_eq!(result.stdout, "loop 2\nloop 2\n");
5110    }
5111
5112    // File test operator tests
5113
5114    #[tokio::test]
5115    async fn test_file_test_r_readable() {
5116        // -r file: true if file exists (readable in virtual fs)
5117        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        // -r file: false if file doesn't exist
5129        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        // -w file: true if file exists (writable in virtual fs)
5140        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        // -x file: true if file exists and has execute permission
5152        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        // -x file: false if file has no execute permission
5167        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        // -e file: true if file exists
5180        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        // -f file: true if regular file
5192        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        // -d file: true if directory
5204        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        // -s file: true if file has size > 0
5213        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    // ============================================================
5223    // Stderr Redirection Tests
5224    // ============================================================
5225
5226    #[tokio::test]
5227    async fn test_redirect_both_stdout_stderr() {
5228        // &> redirects both stdout and stderr to file
5229        let mut bash = Bash::new();
5230        // echo outputs to stdout, we use &> to redirect both to file
5231        let result = bash.exec("echo hello &> /tmp/out.txt").await.unwrap();
5232        // stdout should be empty (redirected to file)
5233        assert_eq!(result.stdout, "");
5234        // Verify file contents
5235        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        // 2> redirects stderr to file
5242        // We need a command that outputs to stderr - let's use a command that fails
5243        // Or use a subshell with explicit stderr output
5244        let mut bash = Bash::new();
5245        // Create a test script that outputs to both stdout and stderr
5246        bash.exec("echo stdout; echo stderr 2> /tmp/err.txt")
5247            .await
5248            .unwrap();
5249        // Note: echo stderr doesn't actually output to stderr, it outputs to stdout
5250        // We need to test with actual stderr output
5251    }
5252
5253    #[tokio::test]
5254    async fn test_fd_redirect_parsing() {
5255        // Test that 2> is parsed correctly
5256        let mut bash = Bash::new();
5257        // Just test the parsing doesn't error
5258        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        // Test that 2>> is parsed correctly
5265        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        // Test that 2>&1 is parsed correctly
5273        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        // >&2 redirects stdout to stderr
5282        let mut bash = Bash::new();
5283        let result = bash.exec("echo hello >&2").await.unwrap();
5284        // stdout should be moved to stderr
5285        assert_eq!(result.stdout, "");
5286        assert_eq!(result.stderr, "hello\n");
5287    }
5288
5289    #[tokio::test]
5290    async fn test_lexer_redirect_both() {
5291        // Test that &> is lexed as a single token, not & followed by >
5292        let mut bash = Bash::new();
5293        // Without proper lexing, this would be parsed as background + redirect
5294        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        // Test that >& is lexed correctly
5303        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        // Test that 2> works with digits
5312        let mut bash = Bash::new();
5313        // 2> should be recognized as stderr redirect
5314        let result = bash.exec("echo hello 2> /tmp/err.txt").await.unwrap();
5315        assert_eq!(result.exit_code, 0);
5316        // stdout should still have the output since echo doesn't write to stderr
5317        assert_eq!(result.stdout, "hello\n");
5318    }
5319
5320    // ============================================================
5321    // Arithmetic Logical Operator Tests
5322    // ============================================================
5323
5324    #[tokio::test]
5325    async fn test_arithmetic_logical_and_true() {
5326        // Both sides true
5327        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        // Left side false - short circuits
5335        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        // Right side false
5343        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        // Both sides false
5351        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        // Left side true - short circuits
5359        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        // Right side true
5367        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        // Combined && and || with expressions
5375        let mut bash = Bash::new();
5376        // (5 > 3) && (2 < 4) => 1 && 1 => 1
5377        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        // || with comparison
5384        let mut bash = Bash::new();
5385        // (5 < 3) || (2 < 4) => 0 || 1 => 1
5386        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        // Regression: multi-byte chars caused char-index/byte-index mismatch panic
5393        let mut bash = Bash::new();
5394        // Multi-byte char in comma expression - should not panic
5395        let result = bash.exec("echo $((0,1))").await.unwrap();
5396        assert_eq!(result.stdout, "1\n");
5397        // Ensure multi-byte input doesn't panic (treated as 0 / error)
5398        let _ = bash.exec("echo $((\u{00e9}+1))").await;
5399    }
5400
5401    // ============================================================
5402    // Brace Expansion Tests
5403    // ============================================================
5404
5405    #[tokio::test]
5406    async fn test_brace_expansion_list() {
5407        // {a,b,c} expands to a b c
5408        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        // file{1,2,3}.txt expands to file1.txt file2.txt file3.txt
5416        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        // {1..5} expands to 1 2 3 4 5
5424        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        // {a..e} expands to a b c d e
5432        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        // {5..1} expands to 5 4 3 2 1
5440        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        // Nested brace expansion: {a,b}{1,2}
5448        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        // Prefix and suffix: pre{x,y}suf
5456        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        // {,foo} expands to (empty) foo
5464        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    // ============================================================
5470    // String Comparison Tests
5471    // ============================================================
5472
5473    #[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    // ============================================================
5504    // Array Indices Tests
5505    // ============================================================
5506
5507    #[tokio::test]
5508    async fn test_array_indices_basic() {
5509        // ${!arr[@]} returns the indices of the array
5510        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        // ${!arr[@]} should show indices even for sparse arrays
5518        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        // ${!arr[*]} should also work
5529        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        // Empty array should return empty string
5537        let mut bash = Bash::new();
5538        let result = bash.exec("arr=(); echo \"${!arr[@]}\"").await.unwrap();
5539        assert_eq!(result.stdout, "\n");
5540    }
5541
5542    // ============================================================
5543    // Text file builder methods
5544    // ============================================================
5545
5546    #[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        // Parent directories should be created automatically
5577        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        // text_file should work alongside other builder methods
5648        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        // Mount files work with custom filesystems via OverlayFs
5674        let custom_fs = std::sync::Arc::new(InMemoryFs::new());
5675
5676        // Pre-populate the base filesystem
5677        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        // Can read base file
5689        let result = bash.exec("cat /base.txt").await.unwrap();
5690        assert_eq!(result.stdout, "from base");
5691
5692        // Can read mounted files
5693        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        // Mounted readonly file has correct permissions
5700        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        // Mounted files take precedence over base filesystem
5711        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    // ============================================================
5766    // Parser Error Location Tests
5767    // ============================================================
5768
5769    #[tokio::test]
5770    async fn test_parse_error_includes_line_number() {
5771        // Parse errors should include line/column info
5772        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        // Should fail to parse due to missing 'fi'
5781        assert!(result.is_err());
5782        let err = result.unwrap_err();
5783        let err_msg = format!("{}", err);
5784        // Error should mention line number
5785        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        // Syntax error on line 3 should report line 3
5795        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        // Error should mention the problem (either "expected" or "syntax error")
5802        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    // ==================== Root directory access tests ====================
5810
5811    #[tokio::test]
5812    async fn test_cd_to_root_and_ls() {
5813        // Test: cd / && ls should work
5814        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        // Test: cd / && pwd should show /
5828        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        // Test: cd / && ls . should list root contents
5837        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        // Test: ls / should work
5851        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        // Test: ls -la / should work
5866        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    // === Issue 1: Heredoc file writes ===
5881
5882    #[tokio::test]
5883    async fn test_heredoc_redirect_to_file() {
5884        // cat > file <<'EOF' is the #1 way LLMs create multi-line files
5885        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    // === Issue 2: Compound pipelines ===
5906
5907    #[tokio::test]
5908    async fn test_pipe_to_while_read() {
5909        // cmd | while read ...; do ... done is extremely common
5910        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    // === Issue 3: Source loading functions ===
5943
5944    #[tokio::test]
5945    async fn test_source_loads_functions() {
5946        let mut bash = Bash::new();
5947        // Write a function library, then source it and call the function
5948        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    // === Issue 4: chmod +x symbolic mode ===
5970
5971    #[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    // === Issue 5: Awk arrays ===
6010
6011    #[tokio::test]
6012    async fn test_awk_array_length() {
6013        // length(arr) should return element count
6014        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        // split() + reading elements back
6025        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        // Classic word frequency count - the most common awk array pattern
6036        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    // ---- Streaming output tests ----
6061
6062    #[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        // The while loop emits each iteration; surrounding list may add events too
6105        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        // exec (non-streaming) should still work fine
6122        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        // Inner loop should emit each iteration; outer should not duplicate
6146        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    // ---- Streamed vs non-streamed equivalence tests ----
6209    //
6210    // These run the same script through exec() and exec_streaming() and assert
6211    // that the final ExecResult is identical, plus concatenated chunks == stdout.
6212
6213    /// Helper: run script both ways, assert equivalence.
6214    async fn assert_streaming_equivalence(script: &str) {
6215        // Non-streaming
6216        let mut bash_plain = Bash::new();
6217        let plain = bash_plain.exec(script).await.unwrap();
6218
6219        // Streaming
6220        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        // Final results must match
6241        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        // Concatenated chunks must equal full stdout/stderr
6255        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        // 25 doublings of 10 bytes = 335 544 320 without limits; must be capped ≤ 1024
6324        assert!(len <= 1024, "string length {len} must be ≤ 1024");
6325    }
6326
6327    /// Issue #1116: 2>/dev/null must suppress stderr in streaming mode
6328    #[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        // Compound command — the main bug: callback fired before redirect applied
6335        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        // Issue #1114: ./filename should resolve identically to filename
6359        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        // ls without ./ prefix should work
6365        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        // ls with ./ prefix should also work
6377        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        // Issue #1114: ./*.html should resolve identically to *.html
6392        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        // glob without ./ prefix
6398        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        // glob with ./ prefix
6407        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        // Issue #1114: cat ./filename should work
6422        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        // Issue #1114: redirecting to ./filename should work
6442        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        // Issue #1114: test -f ./filename should work
6462        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    // === Hooks system tests ===
6480
6481    #[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                // Rewrite the script
6493                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                // Rewrite args: replace first arg with "intercepted"
6822                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        // Special builtins (declare, local, etc.) dispatch through
6880        // dispatch_special_builtin, not execute_registered_builtin,
6881        // so before_tool should not fire for them.
6882        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        // declare is a special builtin — should NOT trigger before_tool
6896        bash.exec("declare x=1").await.unwrap();
6897        assert_eq!(count.load(Ordering::Relaxed), 0);
6898
6899        // echo is a registered builtin — should trigger before_tool
6900        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        // The before_http hook should cancel this request
6921        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        // Even though the request will fail (no real server), the hook
6951        // infrastructure is wired correctly if it doesn't panic.
6952        // A successful test is that the builder accepts the hook and builds.
6953        let _result = bash.exec("curl -s https://httpbin.org/get").await.unwrap();
6954        // We can't assert on captured content since there's no real HTTP
6955        // server, but the hook is wired and the build succeeded.
6956    }
6957}