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//! Homepage: [bashkit.sh](https://bashkit.sh)
7//!
8//! # Features
9//!
10//! - **POSIX compliant** - Substantial IEEE 1003.1-2024 Shell Command Language compliance
11//! - **Sandboxed, in-process execution** - No real filesystem access by default
12//! - **Virtual filesystem** - [`InMemoryFs`], [`OverlayFs`], [`MountableFs`], [`NamespaceFs`]
13//! - **Resource limits** - Command count, loop iterations, function depth
14//! - **Network allowlist** - Control HTTP access per-domain
15//! - **Custom builtins** - Extend with domain-specific commands
16//! - **Async-first** - Built on tokio
17//! - **Experimental: Git** - Virtual git operations on the VFS (`git` feature)
18//! - **Experimental: Python** - Embedded Python via [Monty](https://github.com/pydantic/monty) (`python` feature)
19//! - **Experimental: SQLite** - Embedded SQLite-compatible engine via [Turso](https://github.com/tursodatabase/turso) (`sqlite` feature)
20//!
21//! # Built-in Commands (164)
22//!
23//! | Category | Commands |
24//! |----------|----------|
25//! | Core | `echo`, `printf`, `cat`, `nl`, `read`, `mapfile`, `readarray`, `log` |
26//! | Navigation | `cd`, `pwd`, `ls`, `find`, `tree`, `pushd`, `popd`, `dirs` |
27//! | Flow control | `true`, `false`, `exit`, `return`, `break`, `continue`, `test`, `[`, `assert` |
28//! | Variables | `export`, `set`, `unset`, `local`, `shift`, `source`, `.`, `eval`, `readonly`, `times`, `declare`, `typeset`, `let`, `dotenv`, `envsubst` |
29//! | Shell | `bash`, `sh` (virtual re-invocation), `exec`, `:`, `trap`, `caller`, `getopts`, `shopt`, `command`, `type`, `which`, `hash`, `alias`, `unalias`, `compgen`, `fc`, `help` |
30//! | Text processing | `grep`, `rg`, `sed`, `awk`, `jq` (with `jq` feature), `head`, `tail`, `sort`, `uniq`, `cut`, `tr`, `wc`, `paste`, `column`, `diff`, `comm`, `strings`, `tac`, `rev`, `seq`, `expr`, `fold`, `expand`, `unexpand`, `join`, `split`, `iconv`, `shuf`, `template` |
31//! | File operations | `mkdir`, `mktemp`, `mkfifo`, `rm`, `cp`, `mv`, `touch`, `chmod`, `chown`, `ln`, `rmdir`, `realpath`, `readlink`, `truncate`, `glob`, `patch` |
32//! | File inspection | `file`, `stat`, `less` |
33//! | Archives | `tar`, `gzip`, `gunzip`, `zip`, `unzip` |
34//! | Byte tools | `od`, `xxd`, `hexdump`, `base64` |
35//! | Checksums | `md5sum`, `sha1sum`, `sha256sum`, `verify` |
36//! | Utilities | `sleep`, `date`, `basename`, `dirname`, `timeout`, `wait`, `watch`, `yes`, `kill`, `clear`, `numfmt`, `retry`, `parallel` |
37//! | Disk | `df`, `du` |
38//! | Pipeline | `xargs`, `tee` |
39//! | System info | `whoami`, `hostname`, `uname`, `id`, `env`, `printenv`, `history` |
40//! | Structured data | `json`, `csv`, `yaml`, `tomlq`, `semver` |
41//! | Network | `curl`, `wget`, `http` (requires [`NetworkAllowlist`])
42//! | Arithmetic | `bc` |
43//! | 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)
44//!
45//! # Shell Features
46//!
47//! - Variables and parameter expansion (`$VAR`, `${VAR:-default}`, `${#VAR}`)
48//! - Command substitution (`$(cmd)`)
49//! - Arithmetic expansion (`$((1 + 2))`)
50//! - Pipelines and redirections (`|`, `>`, `>>`, `<`, `<<<`, `2>&1`)
51//! - Control flow (`if`/`elif`/`else`, `for`, `while`, `case`)
52//! - Functions (POSIX and bash-style)
53//! - Arrays (`arr=(a b c)`, `${arr[@]}`, `${#arr[@]}`)
54//! - Glob expansion (`*`, `?`)
55//! - Here documents (`<<EOF`)
56//!
57//! - [`compatibility_scorecard`] - Full compatibility status
58//!
59//! # Quick Start
60//!
61//! ```rust
62//! use bashkit::Bash;
63//!
64//! # #[tokio::main]
65//! # async fn main() -> bashkit::Result<()> {
66//! let mut bash = Bash::new();
67//! let result = bash.exec("echo 'Hello, World!'").await?;
68//! assert_eq!(result.stdout, "Hello, World!\n");
69//! assert_eq!(result.exit_code, 0);
70//! # Ok(())
71//! # }
72//! ```
73//!
74//! # Basic Usage
75//!
76//! ## Simple Commands
77//!
78//! ```rust
79//! use bashkit::Bash;
80//!
81//! # #[tokio::main]
82//! # async fn main() -> bashkit::Result<()> {
83//! let mut bash = Bash::new();
84//!
85//! // Echo with variables
86//! let result = bash.exec("NAME=World; echo \"Hello, $NAME!\"").await?;
87//! assert_eq!(result.stdout, "Hello, World!\n");
88//!
89//! // Pipelines
90//! let result = bash.exec("echo -e 'apple\\nbanana\\ncherry' | grep a").await?;
91//! assert_eq!(result.stdout, "apple\nbanana\n");
92//!
93//! // Arithmetic
94//! let result = bash.exec("echo $((2 + 2 * 3))").await?;
95//! assert_eq!(result.stdout, "8\n");
96//! # Ok(())
97//! # }
98//! ```
99//!
100//! ## Control Flow
101//!
102//! ```rust
103//! use bashkit::Bash;
104//!
105//! # #[tokio::main]
106//! # async fn main() -> bashkit::Result<()> {
107//! let mut bash = Bash::new();
108//!
109//! // For loops
110//! let result = bash.exec("for i in 1 2 3; do echo $i; done").await?;
111//! assert_eq!(result.stdout, "1\n2\n3\n");
112//!
113//! // If statements
114//! let result = bash.exec("if [ 5 -gt 3 ]; then echo bigger; fi").await?;
115//! assert_eq!(result.stdout, "bigger\n");
116//!
117//! // Functions
118//! let result = bash.exec("greet() { echo \"Hello, $1!\"; }; greet World").await?;
119//! assert_eq!(result.stdout, "Hello, World!\n");
120//! # Ok(())
121//! # }
122//! ```
123//!
124//! ## File Operations
125//!
126//! All file operations happen in the virtual filesystem:
127//!
128//! ```rust
129//! use bashkit::Bash;
130//!
131//! # #[tokio::main]
132//! # async fn main() -> bashkit::Result<()> {
133//! let mut bash = Bash::new();
134//!
135//! // Create and read files
136//! bash.exec("echo 'Hello' > /tmp/test.txt").await?;
137//! bash.exec("echo 'World' >> /tmp/test.txt").await?;
138//!
139//! let result = bash.exec("cat /tmp/test.txt").await?;
140//! assert_eq!(result.stdout, "Hello\nWorld\n");
141//!
142//! // Directory operations
143//! bash.exec("mkdir -p /data/nested/dir").await?;
144//! bash.exec("echo 'content' > /data/nested/dir/file.txt").await?;
145//! # Ok(())
146//! # }
147//! ```
148//!
149//! # Configuration with Builder
150//!
151//! Use [`Bash::builder()`] for advanced configuration:
152//!
153//! ```rust
154//! use bashkit::{Bash, ExecutionLimits};
155//!
156//! # #[tokio::main]
157//! # async fn main() -> bashkit::Result<()> {
158//! let mut bash = Bash::builder()
159//!     .env("API_KEY", "secret123")
160//!     .username("deploy")
161//!     .hostname("prod-server")
162//!     .limits(ExecutionLimits::new().max_commands(100))
163//!     .build();
164//!
165//! let result = bash.exec("whoami && hostname").await?;
166//! assert_eq!(result.stdout, "deploy\nprod-server\n");
167//! # Ok(())
168//! # }
169//! ```
170//!
171//! # LLM Tool Integration
172//!
173//! Use [`BashTool`] when the host needs schemas, Markdown help, a compact system prompt,
174//! and validated single-use executions.
175//!
176//! ```rust
177//! use bashkit::{BashTool, Tool};
178//!
179//! # #[tokio::main]
180//! # async fn main() -> anyhow::Result<()> {
181//! let tool = BashTool::builder()
182//!     .username("agent")
183//!     .hostname("sandbox")
184//!     .build();
185//!
186//! let output = tool
187//!     .execution(serde_json::json!({
188//!         "commands": "echo hello from bashkit",
189//!         "timeout_ms": 1000
190//!     }))?
191//!     .execute()
192//!     .await?;
193//!
194//! assert_eq!(output.result["stdout"], "hello from bashkit\n");
195//! assert!(tool.help().contains("## Parameters"));
196//! # Ok(())
197//! # }
198//! ```
199//!
200//! # Custom Builtins
201//!
202//! Register custom commands to extend Bashkit with domain-specific functionality:
203//!
204//! ```rust
205//! use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
206//!
207//! struct Greet;
208//!
209//! #[async_trait]
210//! impl Builtin for Greet {
211//!     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
212//!         let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World");
213//!         Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
214//!     }
215//! }
216//!
217//! # #[tokio::main]
218//! # async fn main() -> bashkit::Result<()> {
219//! let mut bash = Bash::builder()
220//!     .builtin("greet", Box::new(Greet))
221//!     .build();
222//!
223//! let result = bash.exec("greet Alice").await?;
224//! assert_eq!(result.stdout, "Hello, Alice!\n");
225//! # Ok(())
226//! # }
227//! ```
228//!
229//! Custom builtins have access to:
230//! - Command arguments (`ctx.args`)
231//! - Environment variables (`ctx.env`)
232//! - Shell variables (`ctx.variables`)
233//! - Virtual filesystem (`ctx.fs`)
234//! - Pipeline stdin (`ctx.stdin`)
235//!
236//! See [`BashBuilder::builtin`] for more details.
237//!
238//! # Virtual Filesystem
239//!
240//! Bashkit provides several filesystem implementations:
241//!
242//! - [`InMemoryFs`]: Simple in-memory filesystem (default)
243//! - [`OverlayFs`]: Copy-on-write overlay for layered storage
244//! - [`MountableFs`]: Mount multiple filesystems at different paths
245//! - [`NamespaceFs`]: Compose a static tree from rebased filesystem mounts
246//!
247//! See the `fs` module documentation for details and examples.
248//!
249//! # Direct Filesystem Access
250//!
251//! Access the filesystem directly via [`Bash::fs()`]:
252//!
253//! ```rust
254//! use bashkit::{Bash, FileSystem};
255//! use std::path::Path;
256//!
257//! # #[tokio::main]
258//! # async fn main() -> bashkit::Result<()> {
259//! let mut bash = Bash::new();
260//! let fs = bash.fs();
261//!
262//! // Pre-populate files before running scripts
263//! fs.mkdir(Path::new("/config"), false).await?;
264//! fs.write_file(Path::new("/config/app.conf"), b"debug=true").await?;
265//!
266//! // Run a script that reads the config
267//! let result = bash.exec("cat /config/app.conf").await?;
268//! assert_eq!(result.stdout, "debug=true");
269//!
270//! // Read script output directly
271//! bash.exec("echo 'result' > /output.txt").await?;
272//! let output = fs.read_file(Path::new("/output.txt")).await?;
273//! assert_eq!(output, b"result\n");
274//! # Ok(())
275//! # }
276//! ```
277//!
278//! # HTTP Access (curl/wget)
279//!
280//! Enable the `http_client` feature and configure an allowlist for network access:
281//!
282//! ```rust,ignore
283//! use bashkit::{Bash, NetworkAllowlist};
284//!
285//! let mut bash = Bash::builder()
286//!     .network(NetworkAllowlist::new()
287//!         .allow("https://httpbin.org"))
288//!     .build();
289//!
290//! // curl and wget now work for allowed URLs
291//! let result = bash.exec("curl -s https://httpbin.org/get").await?;
292//! assert!(result.stdout.contains("httpbin.org"));
293//! ```
294//!
295//! Security features:
296//! - URL allowlist enforcement (no access without explicit configuration)
297//! - 10MB response size limit (prevents memory exhaustion)
298//! - 30 second timeout (prevents hanging)
299//! - No automatic redirects (prevents allowlist bypass)
300//! - Zip bomb protection for compressed responses
301//!
302//! HTTP is **disabled by default**: the `http_client` feature must be
303//! compiled in *and* an allowlist must be configured via
304//! [`BashBuilder::network`]; otherwise curl/wget cannot reach the network at
305//! all.
306//!
307//! Embedding hosts can replace the built-in connectivity with their own —
308//! e.g. to route all sandbox traffic through an egress gateway — by
309//! injecting an [`HttpTransport`] via [`BashBuilder::http_transport`].
310//! Policy (allowlist, SSRF precheck, hooks, signing, size caps) stays in
311//! bashkit and runs before the transport is called.
312//!
313//! See [`NetworkAllowlist`] for allowlist configuration options.
314//!
315//! # Experimental: Git Support
316//!
317//! Enable the `git` feature for virtual git operations. All git data lives in
318//! the virtual filesystem.
319//!
320//! ```toml
321//! [dependencies]
322//! bashkit = { version = "0.1", features = ["git"] }
323//! ```
324//!
325//! ```rust,ignore
326//! use bashkit::{Bash, GitConfig};
327//!
328//! let mut bash = Bash::builder()
329//!     .git(GitConfig::new()
330//!         .author("Deploy Bot", "deploy@example.com"))
331//!     .build();
332//!
333//! bash.exec("git init").await?;
334//! bash.exec("echo 'hello' > file.txt").await?;
335//! bash.exec("git add file.txt").await?;
336//! bash.exec("git commit -m 'initial'").await?;
337//! bash.exec("git log").await?;
338//! ```
339//!
340//! Supported: `init`, `config`, `add`, `commit`, `status`, `log`, `branch`,
341//! `checkout`, `diff`, `reset`, `remote`, `clone`/`push`/`pull`/`fetch` (virtual mode).
342//!
343//! See [`GitConfig`] for configuration options.
344//!
345//! # Experimental: Python Support
346//!
347//! Enable the `python` feature to embed the [Monty](https://github.com/pydantic/monty)
348//! Python interpreter (pure Rust, Python 3.12). Python `pathlib.Path` operations are
349//! bridged to the virtual filesystem.
350//!
351//! ```toml
352//! [dependencies]
353//! bashkit = { version = "0.1", features = ["python"] }
354//! ```
355//!
356//! ```rust,ignore
357//! use bashkit::Bash;
358//!
359//! let mut bash = Bash::builder().python().build();
360//!
361//! // Inline code
362//! bash.exec("python3 -c \"print(2 ** 10)\"").await?;
363//!
364//! // VFS bridging — files shared between bash and Python
365//! bash.exec("echo 'data' > /tmp/shared.txt").await?;
366//! bash.exec(r#"python3 -c "
367//! from pathlib import Path
368//! print(Path('/tmp/shared.txt').read_text().strip())
369//! ""#).await?;
370//! ```
371//!
372//! Stdlib modules: `math`, `pathlib`, `os` (getenv/environ), `sys`, `typing`.
373//! Security note: `re` is disabled due to regex backtracking DoS risk.
374//! Limitations: no `open()` (use `pathlib.Path`), no network, no classes,
375//! no third-party imports.
376//!
377//! See `PythonLimits` for resource limit configuration.
378//!
379//! See the `python_guide` module docs (requires `python` feature).
380//!
381//! # Examples
382//!
383//! See the `examples/` directory for complete working examples:
384//!
385//! - `basic.rs` - Getting started with Bashkit
386//! - `custom_fs.rs` - Using different filesystem implementations
387//! - `custom_filesystem_impl.rs` - Implementing the [`FileSystem`] trait
388//! - `resource_limits.rs` - Setting execution limits
389//! - `virtual_identity.rs` - Customizing username/hostname
390//! - `text_processing.rs` - Using grep, sed, awk, and jq
391//! - `agent_tool.rs` - LLM agent integration
392//! - `git_workflow.rs` - Git operations on the virtual filesystem
393//! - `python_scripts.rs` - Embedded Python with VFS bridging
394//! - `python_external_functions.rs` - Python callbacks into host functions
395//! - `namespace_sandbox.rs` - Static read-only/read-write build namespace
396//! - `namespace_rebase.rs` - Source-root rebasing with a nested writable override
397//!
398//! # Guides
399//!
400//! - [`custom_builtins_guide`] - Creating custom builtins
401//! - [`compatibility_scorecard`] - Feature parity tracking
402//! - [`live_mounts_guide`] - Live mount/unmount on running instances
403//! - [`namespace_filesystems_guide`] - Static namespaces with rebasing and per-mount access
404//! - `python_guide` - Embedded Python (Monty) guide (requires `python` feature)
405//! - `logging_guide` - Structured logging with security (requires `logging` feature)
406//!
407//! # Resources
408//!
409//! - [`threat_model`] - Security threats and mitigations
410//!
411//! # Ecosystem
412//!
413//! Bashkit is part of the [Everruns](https://everruns.com) ecosystem.
414
415// Stricter panic prevention - prefer proper error handling over unwrap()
416#![warn(clippy::unwrap_used)]
417#![cfg_attr(test, allow(clippy::unwrap_used))]
418
419mod builtins;
420#[cfg(feature = "http_client")]
421mod credential;
422mod error;
423mod fs;
424/// Interceptor hooks for the execution pipeline.
425pub mod hooks;
426#[cfg(feature = "interop")]
427pub mod interop;
428mod interpreter;
429mod limits;
430#[cfg(feature = "logging")]
431mod logging_impl;
432mod network;
433/// Parser module - exposed for fuzzing and testing
434pub mod parser;
435/// Scripted tool: compose ToolDef+callback pairs into a single Tool via bash scripts.
436/// Requires the `scripted_tool` feature.
437#[cfg(feature = "scripted_tool")]
438pub mod scripted_tool;
439mod snapshot;
440/// Test-only helpers shared between internal `#[cfg(test)]` modules,
441/// integration tests in `tests/*.rs`, and cargo-fuzz targets in
442/// `fuzz/fuzz_targets/*.rs`. See `specs/threat-model.md` for the
443/// invariants enforced (TM-INF-013, TM-INF-016, TM-INF-022).
444#[doc(hidden)]
445pub mod testing;
446mod time_compat;
447/// Tool contract for LLM integration.
448/// Requires the `bash_tool` feature (enabled by default).
449#[cfg(feature = "bash_tool")]
450pub mod tool;
451/// Reusable tool primitives: ToolDef, ToolArgs, ToolImpl, exec types.
452#[cfg(feature = "scripted_tool")]
453pub(crate) mod tool_def;
454/// Structured execution trace events.
455pub mod trace;
456
457pub use async_trait::async_trait;
458pub use builtins::git::GitConfig;
459pub use builtins::ssh::{SshAllowlist, SshConfig, TrustedHostKey};
460pub use builtins::{
461    BashkitContext, Builtin, BuiltinRegistry, ClapBuiltin, Context as BuiltinContext,
462    ExecutionExtensions, Extension,
463};
464pub use clap;
465#[cfg(feature = "http_client")]
466pub use credential::Credential;
467pub use error::{Error, Result};
468pub use fs::{
469    DirEntry, FileSystem, FileSystemExt, FileType, FsBackend, FsLimitExceeded, FsLimits, FsUsage,
470    InMemoryFs, LazyLoader, Metadata, MountableFs, NamespaceAccess, NamespaceFs,
471    NamespaceFsBuilder, OverlayFs, PosixFs, ReadOnlyFs, SearchCapabilities, SearchCapable,
472    SearchMatch, SearchProvider, SearchQuery, SearchResults, VfsSnapshot, normalize_path,
473    verify_filesystem_requirements,
474};
475#[cfg(feature = "realfs")]
476pub use fs::{RealFs, RealFsMode};
477pub use interpreter::{
478    ControlFlow, ExecResult, HistoryEntry, OutputCallback, ShellState, ShellStateView,
479};
480pub use limits::{
481    ExecutionCounters, ExecutionLimits, LimitExceeded, MemoryBudget, MemoryLimits, SessionLimits,
482};
483pub use network::NetworkAllowlist;
484pub use snapshot::{Snapshot, SnapshotOptions};
485#[cfg(feature = "bash_tool")]
486pub use tool::BashToolBuilder as ToolBuilder;
487#[cfg(feature = "bash_tool")]
488pub use tool::{
489    BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput,
490    ToolOutputChunk, ToolOutputMetadata, ToolRequest, ToolResponse, ToolService, ToolStatus,
491    VERSION,
492};
493pub use trace::{
494    TraceCallback, TraceCollector, TraceEvent, TraceEventDetails, TraceEventKind, TraceMode,
495};
496
497#[cfg(feature = "scripted_tool")]
498pub use scripted_tool::{
499    AsyncToolCallback, CallbackKind, DiscoverTool, DiscoveryMode, ScriptedCommandInvocation,
500    ScriptedCommandKind, ScriptedExecutionTrace, ScriptedTool, ScriptedToolBuilder,
501    ScriptingToolSet, ScriptingToolSetBuilder, ToolArgs, ToolCallback, ToolDef, ToolDefExtension,
502    ToolDefExtensionBuilder, ToolDefInvocationTrace,
503};
504#[cfg(feature = "scripted_tool")]
505pub use tool_def::{AsyncToolExec, SyncToolExec, ToolImpl};
506
507#[cfg(feature = "http_client")]
508pub use network::HttpClient;
509
510#[cfg(feature = "http_client")]
511pub use network::{HttpTransport, HttpTransportError, HttpTransportRequest};
512
513/// Re-exported request method type for custom HTTP transport implementations.
514#[cfg(feature = "http_client")]
515pub use network::Method as HttpMethod;
516
517/// Re-exported network response type for custom HTTP transport implementations.
518#[cfg(feature = "http_client")]
519pub use network::Response as HttpResponse;
520
521#[cfg(feature = "bot-auth")]
522pub use network::{BotAuthConfig, BotAuthError, BotAuthPublicKey, derive_bot_auth_public_key};
523
524#[cfg(feature = "git")]
525pub use builtins::git::GitClient;
526
527#[cfg(feature = "ssh")]
528pub use builtins::ssh::{SshClient, SshHandler, SshOutput, SshTarget};
529
530#[cfg(feature = "python")]
531pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits};
532
533// Shared resource-limit core for embedded language VMs (Python, TypeScript).
534#[cfg(any(feature = "python", feature = "typescript"))]
535pub use builtins::RuntimeLimits;
536
537#[cfg(feature = "sqlite")]
538pub use builtins::{Sqlite, SqliteBackend, SqliteLimits};
539// Re-export monty types needed by external handler consumers.
540// **Unstable:** These types come from monty (git-pinned, not on crates.io).
541// They may change in breaking ways between bashkit releases.
542#[cfg(feature = "python")]
543pub use monty::{ExcType, ExtFunctionResult, MontyException, MontyObject};
544
545#[cfg(feature = "typescript")]
546pub use builtins::{
547    TypeScriptConfig, TypeScriptExtension, TypeScriptExternalFnHandler, TypeScriptExternalFns,
548    TypeScriptLimits,
549};
550// Re-export zapcode-core types needed by external handler consumers.
551#[cfg(feature = "typescript")]
552pub use zapcode_core::Value as ZapcodeValue;
553
554/// Logging utilities module
555///
556/// Provides structured logging with security features including sensitive data redaction.
557/// Only available when the `logging` feature is enabled.
558#[cfg(feature = "logging")]
559pub mod logging {
560    pub use crate::logging_impl::{
561        LogConfig, format_error_for_log, format_script_for_log, sanitize_for_log,
562    };
563}
564
565#[cfg(feature = "logging")]
566pub use logging::LogConfig;
567
568use interpreter::Interpreter;
569use parser::Parser;
570use std::collections::HashMap;
571#[cfg(feature = "realfs")]
572use std::path::Path;
573use std::path::PathBuf;
574use std::sync::Arc;
575
576#[cfg(any(feature = "python", feature = "sqlite"))]
577fn env_opt_in_enabled(env: &HashMap<String, String>, key: &str) -> bool {
578    env.get(key)
579        .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
580}
581
582// Keep streaming callback cleanup cancellation-safe: Python bindings expose this
583// future to cancellable asyncio tasks, so cleanup must run from Drop.
584struct OutputCallbackGuard {
585    interpreter: *mut Interpreter,
586}
587
588// SAFETY: the guard only clears the callback through the unique Bash execution
589// borrow that created it; moving the future between executor threads does not
590// create shared access to the interpreter.
591unsafe impl Send for OutputCallbackGuard {}
592
593impl OutputCallbackGuard {
594    fn install(interpreter: &mut Interpreter, callback: OutputCallback) -> Self {
595        interpreter.set_output_callback(callback);
596        Self { interpreter }
597    }
598}
599
600impl Drop for OutputCallbackGuard {
601    fn drop(&mut self) {
602        // SAFETY: the guard is created from `&mut self.interpreter` inside a
603        // Bash execution future. That future keeps exclusive access to the same
604        // Bash until it is completed or dropped, so clearing this field here does
605        // not race with another mutable interpreter access.
606        unsafe { (*self.interpreter).clear_output_callback() };
607    }
608}
609
610/// Per-call options for [`Bash::exec_with_options`].
611///
612/// Bundles the optional inputs to a single execution — streaming output and
613/// per-call builtin extensions — into one request value so new options can be
614/// added as fields without multiplying the number of `exec*` methods. The
615/// convenience methods ([`Bash::exec`], [`Bash::exec_with_extensions`],
616/// [`Bash::exec_streaming`], [`Bash::exec_streaming_with_extensions`]) are thin
617/// wrappers over `exec_with_options`.
618///
619/// # Example
620///
621/// ```rust
622/// use bashkit::{Bash, ExecOptions};
623/// use std::sync::{Arc, Mutex};
624///
625/// # #[tokio::main]
626/// # async fn main() -> bashkit::Result<()> {
627/// let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
628/// let chunks_cb = chunks.clone();
629/// let mut bash = Bash::new();
630/// let result = bash
631///     .exec_with_options(
632///         "for i in 1 2 3; do echo $i; done",
633///         ExecOptions::new().streaming(Box::new(move |stdout, _stderr| {
634///             chunks_cb.lock().unwrap().push(stdout.to_string());
635///         })),
636///     )
637///     .await?;
638/// assert_eq!(result.stdout, "1\n2\n3\n");
639/// assert_eq!(*chunks.lock().unwrap(), vec!["1\n", "2\n", "3\n"]);
640/// # Ok(())
641/// # }
642/// ```
643#[derive(Default)]
644pub struct ExecOptions {
645    extensions: ExecutionExtensions,
646    output_callback: Option<OutputCallback>,
647}
648
649impl ExecOptions {
650    /// Create an empty set of options (no streaming, no extensions).
651    pub fn new() -> Self {
652        Self::default()
653    }
654
655    /// Stream incremental `(stdout_chunk, stderr_chunk)` output to `callback`
656    /// as it is produced. See [`Bash::exec_streaming`] for callback semantics.
657    pub fn streaming(mut self, callback: OutputCallback) -> Self {
658        self.output_callback = Some(callback);
659        self
660    }
661
662    /// Attach per-execution builtin extensions (request-scoped typed data read
663    /// by builtins via `ctx.execution_extension::<T>()`).
664    pub fn extensions(mut self, extensions: ExecutionExtensions) -> Self {
665        self.extensions = extensions;
666        self
667    }
668}
669
670/// Main entry point for Bashkit.
671///
672/// Provides a virtual bash interpreter with an in-memory virtual filesystem.
673pub struct Bash {
674    fs: Arc<dyn FileSystem>,
675    /// Outermost MountableFs layer for live mount/unmount after build.
676    mountable: Arc<MountableFs>,
677    /// Whether runtime mounts are forced read-only.
678    readonly_filesystem: bool,
679    interpreter: Interpreter,
680    /// Parser timeout (stored separately for use before interpreter runs)
681    parser_timeout: std::time::Duration,
682    /// Maximum input script size in bytes
683    max_input_bytes: usize,
684    /// Maximum AST nesting depth for parsing
685    max_ast_depth: usize,
686    /// Maximum parser operations (fuel)
687    max_parser_operations: usize,
688    /// Logging configuration
689    #[cfg(feature = "logging")]
690    log_config: logging::LogConfig,
691    /// Operator-approved in-process Python opt-in captured at build time.
692    #[cfg(feature = "python")]
693    python_inprocess_opt_in: bool,
694    /// Operator-approved in-process SQLite opt-in captured at build time.
695    #[cfg(feature = "sqlite")]
696    sqlite_inprocess_opt_in: bool,
697}
698
699impl Default for Bash {
700    fn default() -> Self {
701        Self::new()
702    }
703}
704
705/// Build a fresh `InMemoryFs` with `username`'s home directory provisioned so
706/// `$HOME` / `~` is a real, writable directory. HOME defaults to
707/// `/home/<username>` (see Interpreter), which `InMemoryFs::new` does not create
708/// on its own. See issue #2128.
709fn inmem_fs_with_home(username: &str) -> InMemoryFs {
710    let fs = InMemoryFs::new();
711    fs.add_dir(format!("/home/{username}"), 0o755);
712    fs
713}
714
715impl Bash {
716    /// Create a new Bash instance with default settings.
717    pub fn new() -> Self {
718        let base_inmem = inmem_fs_with_home(builtins::DEFAULT_USERNAME);
719        let base_fs: Arc<dyn FileSystem> = Arc::new(base_inmem);
720        let mountable = Arc::new(MountableFs::new(base_fs));
721        let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
722        let interpreter = Interpreter::new(Arc::clone(&fs));
723        let parser_timeout = ExecutionLimits::default().parser_timeout;
724        let max_input_bytes = ExecutionLimits::default().max_input_bytes;
725        let max_ast_depth = ExecutionLimits::default().max_ast_depth;
726        let max_parser_operations = ExecutionLimits::default().max_parser_operations;
727        Self {
728            fs,
729            mountable,
730            readonly_filesystem: false,
731            interpreter,
732            parser_timeout,
733            max_input_bytes,
734            max_ast_depth,
735            max_parser_operations,
736            #[cfg(feature = "logging")]
737            log_config: logging::LogConfig::default(),
738            #[cfg(feature = "python")]
739            python_inprocess_opt_in: false,
740            #[cfg(feature = "sqlite")]
741            sqlite_inprocess_opt_in: false,
742        }
743    }
744
745    /// Create a new BashBuilder for customized configuration.
746    pub fn builder() -> BashBuilder {
747        BashBuilder::default()
748    }
749
750    /// Execute a bash script and return the result.
751    ///
752    /// This method first validates that the script does not exceed the maximum
753    /// input size, then parses the script with a timeout, AST depth limit, and fuel limit,
754    /// then executes the resulting AST.
755    pub async fn exec(&mut self, script: &str) -> Result<ExecResult> {
756        self.exec_with_options(script, ExecOptions::new()).await
757    }
758
759    /// Execute a bash script with per-execution builtin extensions.
760    ///
761    /// Convenience wrapper over [`exec_with_options`](Self::exec_with_options).
762    pub async fn exec_with_extensions(
763        &mut self,
764        script: &str,
765        extensions: ExecutionExtensions,
766    ) -> Result<ExecResult> {
767        self.exec_with_options(script, ExecOptions::new().extensions(extensions))
768            .await
769    }
770
771    /// Execute a bash script with a single [`ExecOptions`] request value.
772    ///
773    /// This is the canonical entry point: streaming output and per-call builtin
774    /// extensions are carried as fields of [`ExecOptions`] rather than as
775    /// separate method overloads, so future per-call options can be added
776    /// without multiplying `exec*` methods. The other `exec*` methods are thin
777    /// wrappers over this one.
778    pub async fn exec_with_options(
779        &mut self,
780        script: &str,
781        options: ExecOptions,
782    ) -> Result<ExecResult> {
783        let ExecOptions {
784            mut extensions,
785            output_callback,
786        } = options;
787        // Expose active execution limits and deadline to builtins that need to
788        // honor per-execution sandbox settings inside synchronous VM sections.
789        let active_limits = self.interpreter.limits().clone();
790        let _ = extensions.insert(active_limits.clone());
791        let _ = extensions.insert(builtins::ExecutionDeadline::new(active_limits.timeout));
792        #[cfg(feature = "python")]
793        let _ = extensions.insert(builtins::PythonInprocessOptIn(self.python_inprocess_opt_in));
794        #[cfg(feature = "sqlite")]
795        let _ = extensions.insert(builtins::SqliteInprocessOptIn(self.sqlite_inprocess_opt_in));
796        // Install the streaming callback for the duration of this execution, if
797        // any. The guard holds a raw pointer (not a borrow), so the mutable
798        // interpreter borrow is released before `exec_impl` runs and the
799        // callback is cleared on drop after the await completes.
800        let _stream_guard =
801            output_callback.map(|cb| OutputCallbackGuard::install(&mut self.interpreter, cb));
802        let _extensions_guard = self.interpreter.scoped_execution_extensions(extensions);
803        self.exec_impl(script).await
804    }
805
806    async fn exec_impl(&mut self, script: &str) -> Result<ExecResult> {
807        // THREAT[TM-ISO-005/006/007]: Reset transient state between exec() calls
808        self.interpreter.reset_transient_state();
809
810        // THREAT[TM-DOS-059]: Count every host exec() call at the boundary so
811        // malformed or parser-expensive scripts cannot bypass session limits.
812        self.interpreter.begin_exec_invocation()?;
813
814        // Check raw input size before hooks to avoid allocating/copying oversized
815        // untrusted scripts in hook payloads.
816        let input_len = script.len();
817        if input_len > self.max_input_bytes {
818            #[cfg(feature = "logging")]
819            tracing::error!(
820                target: "bashkit::session",
821                input_len = input_len,
822                max_bytes = self.max_input_bytes,
823                "Script exceeds maximum input size"
824            );
825            return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
826                input_len,
827                self.max_input_bytes,
828            )));
829        }
830
831        // THREAT[TM-LOG-001]: Sensitive data in logs
832        // Mitigation: Use LogConfig to redact sensitive script content
833        #[cfg(feature = "logging")]
834        {
835            let script_info = logging::format_script_for_log(script, &self.log_config);
836            tracing::info!(target: "bashkit::session", script = %script_info, "Starting script execution");
837        }
838
839        // Fire before_exec hooks — may modify or cancel the script
840        let script = if !self.interpreter.hooks().before_exec.is_empty() {
841            let input = hooks::ExecInput {
842                script: script.to_string(),
843            };
844            match self.interpreter.hooks().fire_before_exec(input) {
845                Some(modified) => std::borrow::Cow::Owned(modified.script),
846                None => {
847                    return Ok(ExecResult::err("cancelled by before_exec hook", 1));
848                }
849            }
850        } else {
851            std::borrow::Cow::Borrowed(script)
852        };
853        let script = script.as_ref();
854
855        // Re-check size after hooks in case the hook rewrites to a larger script.
856        let input_len = script.len();
857        if input_len > self.max_input_bytes {
858            #[cfg(feature = "logging")]
859            tracing::error!(
860                target: "bashkit::session",
861                input_len = input_len,
862                max_bytes = self.max_input_bytes,
863                "Script exceeds maximum input size"
864            );
865            return Err(Error::ResourceLimit(LimitExceeded::InputTooLarge(
866                input_len,
867                self.max_input_bytes,
868            )));
869        }
870
871        let parser_timeout = self.parser_timeout;
872        let max_ast_depth = self.max_ast_depth;
873        let max_parser_operations = self.max_parser_operations;
874
875        #[cfg(feature = "logging")]
876        tracing::debug!(
877            target: "bashkit::parser",
878            input_len = input_len,
879            max_ast_depth = max_ast_depth,
880            max_operations = max_parser_operations,
881            "Parsing script"
882        );
883
884        // Important decision: skip the tokio `spawn_blocking` + `time::timeout`
885        // round-trip for small scripts. The parser already enforces a fuel
886        // budget via `max_parser_operations`, so a runaway script still
887        // terminates without the timer-driven path. For ~99% of inline scripts
888        // (REPL, agent commands, short shell snippets) the threadpool hop
889        // dominated startup latency. The threshold matches the input byte
890        // size; above it we keep the original behavior so very large scripts
891        // can be pre-empted. Only consulted on native targets (the wasm path
892        // below always parses inline).
893        #[cfg(not(target_family = "wasm"))]
894        const SPAWN_BLOCKING_THRESHOLD: usize = 16 * 1024;
895
896        // On WASM, tokio::task::spawn_blocking and tokio::time::timeout don't
897        // work (no blocking thread pool, timer driver unreliable). Parse inline.
898        #[cfg(target_family = "wasm")]
899        let ast = {
900            let parser = Parser::with_limits_and_timeout(
901                script,
902                max_ast_depth,
903                max_parser_operations,
904                Some(parser_timeout),
905            );
906            parser.parse()?
907        };
908
909        // On native targets, parse inline for small scripts (avoid threadpool
910        // hop) and use spawn_blocking + timeout for larger ones so the async
911        // runtime can pre-empt a runaway parser.
912        #[cfg(not(target_family = "wasm"))]
913        let ast = if input_len <= SPAWN_BLOCKING_THRESHOLD {
914            let parser = Parser::with_limits(script, max_ast_depth, max_parser_operations);
915            match parser.parse() {
916                Ok(ast) => {
917                    #[cfg(feature = "logging")]
918                    tracing::debug!(target: "bashkit::parser", "Parse completed (inline)");
919                    ast
920                }
921                Err(e) => {
922                    #[cfg(feature = "logging")]
923                    tracing::warn!(target: "bashkit::parser", error = %e, "Parse error (inline)");
924                    return Err(e);
925                }
926            }
927        } else {
928            let script_owned = script.to_owned();
929            let parse_result = tokio::time::timeout(parser_timeout, async {
930                tokio::task::spawn_blocking(move || {
931                    let parser =
932                        Parser::with_limits(&script_owned, max_ast_depth, max_parser_operations);
933                    parser.parse()
934                })
935                .await
936            })
937            .await;
938
939            match parse_result {
940                Ok(Ok(result)) => {
941                    match &result {
942                        Ok(_) => {
943                            #[cfg(feature = "logging")]
944                            tracing::debug!(target: "bashkit::parser", "Parse completed successfully");
945                        }
946                        Err(_e) => {
947                            #[cfg(feature = "logging")]
948                            tracing::warn!(target: "bashkit::parser", error = %_e, "Parse error");
949                        }
950                    }
951                    result?
952                }
953                Ok(Err(join_error)) => {
954                    #[cfg(feature = "logging")]
955                    tracing::error!(
956                        target: "bashkit::parser",
957                        error = %join_error,
958                        "Parser task failed"
959                    );
960                    return Err(Error::parse(format!("parser task failed: {}", join_error)));
961                }
962                Err(_elapsed) => {
963                    #[cfg(feature = "logging")]
964                    tracing::error!(
965                        target: "bashkit::parser",
966                        timeout_ms = parser_timeout.as_millis() as u64,
967                        "Parser timeout exceeded"
968                    );
969                    return Err(Error::ResourceLimit(LimitExceeded::ParserTimeout(
970                        parser_timeout,
971                    )));
972                }
973            }
974        };
975
976        #[cfg(feature = "logging")]
977        tracing::debug!(target: "bashkit::interpreter", "Starting interpretation");
978
979        // Static budget validation: reject obviously expensive scripts before execution
980        parser::validate_budget(&ast, self.interpreter.limits())
981            .map_err(|e| Error::Execution(format!("budget validation failed: {e}")))?;
982
983        // Load persisted history on first exec (no-op if already loaded)
984        self.interpreter.load_history().await;
985
986        let exec_start = crate::time_compat::Instant::now();
987        // THREAT[TM-DOS-057]: Wrap execution with timeout to prevent sleep/blocking bypass.
988        // Only the native path arms the tokio timeout; wasm has no reliable timer driver.
989        #[cfg(not(target_family = "wasm"))]
990        let execution_timeout = self.interpreter.limits().timeout;
991        #[cfg(not(target_family = "wasm"))]
992        let result =
993            match tokio::time::timeout(execution_timeout, self.interpreter.execute(&ast)).await {
994                Ok(r) => r,
995                Err(_elapsed) => {
996                    self.interpreter.clear_cancelled_execution_state();
997                    Err(Error::ResourceLimit(LimitExceeded::Timeout(
998                        execution_timeout,
999                    )))
1000                }
1001            };
1002        #[cfg(target_family = "wasm")]
1003        let result = self.interpreter.execute(&ast).await;
1004        // Issue #1184: clean up process substitution temp files after execution.
1005        // Done here (outside Interpreter::execute) to avoid increasing the
1006        // recursive async state machine size which causes stack overflow.
1007        self.interpreter.cleanup_proc_sub_files().await;
1008        let duration_ms = exec_start.elapsed().as_millis() as u64;
1009
1010        // Record history entry for each line of the script
1011        if let Ok(ref exec_result) = result {
1012            let cwd = self.interpreter.cwd().to_string_lossy().to_string();
1013            let timestamp = chrono::Utc::now().timestamp();
1014            for line in script.lines() {
1015                let trimmed = line.trim();
1016                if !trimmed.is_empty() && !trimmed.starts_with('#') {
1017                    self.interpreter.record_history(
1018                        trimmed.to_string(),
1019                        timestamp,
1020                        cwd.clone(),
1021                        exec_result.exit_code,
1022                        duration_ms,
1023                    );
1024                }
1025            }
1026            // Persist history to VFS if configured
1027            self.interpreter.save_history().await;
1028        }
1029
1030        #[cfg(feature = "logging")]
1031        match &result {
1032            Ok(exec_result) => {
1033                tracing::info!(
1034                    target: "bashkit::session",
1035                    exit_code = exec_result.exit_code,
1036                    stdout_len = exec_result.stdout.len(),
1037                    stderr_len = exec_result.stderr.len(),
1038                    "Script execution completed"
1039                );
1040            }
1041            Err(e) => {
1042                let error = logging::format_error_for_log(&e.to_string(), &self.log_config);
1043                tracing::error!(
1044                    target: "bashkit::session",
1045                    error = %error,
1046                    "Script execution failed"
1047                );
1048            }
1049        }
1050
1051        // Fire after_exec hooks — interceptor decisions are part of the public policy API.
1052        let result = if let Ok(exec_result) = result {
1053            if !self.interpreter.hooks().after_exec.is_empty() {
1054                let output = hooks::ExecOutput {
1055                    script: script.to_string(),
1056                    stdout: exec_result.stdout.clone(),
1057                    stderr: exec_result.stderr.clone(),
1058                    exit_code: exec_result.exit_code,
1059                };
1060                match self.interpreter.hooks().fire_after_exec(output) {
1061                    Some(output) => Ok(ExecResult {
1062                        stdout: output.stdout,
1063                        stderr: output.stderr,
1064                        exit_code: output.exit_code,
1065                        ..exec_result
1066                    }),
1067                    None => Ok(ExecResult::err("cancelled by after_exec hook", 1)),
1068                }
1069            } else {
1070                Ok(exec_result)
1071            }
1072        } else {
1073            result
1074        };
1075
1076        // Fire on_error hooks for execution errors
1077        if let Err(ref e) = result
1078            && !self.interpreter.hooks().on_error.is_empty()
1079        {
1080            let error_event = hooks::ErrorEvent {
1081                message: e.to_string(),
1082            };
1083            self.interpreter.hooks().fire_on_error(error_event);
1084        }
1085
1086        result
1087    }
1088
1089    /// Execute a bash script with streaming output.
1090    ///
1091    /// Like [`exec`](Self::exec), but calls `output_callback` with incremental
1092    /// `(stdout_chunk, stderr_chunk)` pairs as output is produced. Callbacks fire
1093    /// after each loop iteration, command list element, and top-level command.
1094    ///
1095    /// The full result is still returned in [`ExecResult`] for callers that need it.
1096    ///
1097    /// # Example
1098    ///
1099    /// ```rust
1100    /// use bashkit::Bash;
1101    /// use std::sync::{Arc, Mutex};
1102    ///
1103    /// # #[tokio::main]
1104    /// # async fn main() -> bashkit::Result<()> {
1105    /// let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1106    /// let chunks_cb = chunks.clone();
1107    /// let mut bash = Bash::new();
1108    /// let result = bash.exec_streaming(
1109    ///     "for i in 1 2 3; do echo $i; done",
1110    ///     Box::new(move |stdout, _stderr| {
1111    ///         chunks_cb.lock().unwrap().push(stdout.to_string());
1112    ///     }),
1113    /// ).await?;
1114    /// assert_eq!(result.stdout, "1\n2\n3\n");
1115    /// assert_eq!(*chunks.lock().unwrap(), vec!["1\n", "2\n", "3\n"]);
1116    /// # Ok(())
1117    /// # }
1118    /// ```
1119    pub async fn exec_streaming(
1120        &mut self,
1121        script: &str,
1122        output_callback: OutputCallback,
1123    ) -> Result<ExecResult> {
1124        self.exec_with_options(script, ExecOptions::new().streaming(output_callback))
1125            .await
1126    }
1127
1128    /// Execute a bash script with streaming output and per-execution builtin extensions.
1129    ///
1130    /// Convenience wrapper over [`exec_with_options`](Self::exec_with_options).
1131    pub async fn exec_streaming_with_extensions(
1132        &mut self,
1133        script: &str,
1134        output_callback: OutputCallback,
1135        extensions: ExecutionExtensions,
1136    ) -> Result<ExecResult> {
1137        self.exec_with_options(
1138            script,
1139            ExecOptions::new()
1140                .streaming(output_callback)
1141                .extensions(extensions),
1142        )
1143        .await
1144    }
1145
1146    /// Return a shared cancellation token.
1147    ///
1148    /// Set the token to `true` from any thread to abort execution at the next
1149    /// command boundary with [`Error::Cancelled`].
1150    ///
1151    /// The caller is responsible for resetting the flag to `false` before
1152    /// calling `exec()` again.
1153    pub fn cancellation_token(&self) -> Arc<std::sync::atomic::AtomicBool> {
1154        self.interpreter.cancellation_token()
1155    }
1156
1157    /// Return the hooks registry (read-only after build).
1158    ///
1159    /// Hooks are registered via [`BashBuilder`] methods (`on_exit`,
1160    /// `before_exec`, `after_exec`, `before_tool`, `after_tool`,
1161    /// `on_error`) and frozen at build time.
1162    ///
1163    /// HTTP hooks (`before_http`, `after_http`) live on the
1164    /// `HttpClient` (requires `http_client` feature) and are set via
1165    /// the builder as well.
1166    pub fn hooks(&self) -> &hooks::Hooks {
1167        self.interpreter.hooks()
1168    }
1169
1170    /// Get a clone of the underlying filesystem.
1171    ///
1172    /// Provides direct access to the virtual filesystem for:
1173    /// - Pre-populating files before script execution
1174    /// - Reading binary file outputs after execution
1175    /// - Injecting test data or configuration
1176    ///
1177    /// # Example
1178    /// ```rust,no_run
1179    /// use bashkit::Bash;
1180    /// use std::path::Path;
1181    ///
1182    /// #[tokio::main]
1183    /// async fn main() -> anyhow::Result<()> {
1184    ///     let mut bash = Bash::new();
1185    ///     let fs = bash.fs();
1186    ///
1187    ///     // Pre-populate config file
1188    ///     fs.mkdir(Path::new("/config"), false).await?;
1189    ///     fs.write_file(Path::new("/config/app.txt"), b"debug=true\n").await?;
1190    ///
1191    ///     // Bash script can read pre-populated files
1192    ///     let result = bash.exec("cat /config/app.txt").await?;
1193    ///     assert_eq!(result.stdout, "debug=true\n");
1194    ///
1195    ///     // Bash creates output, read it directly
1196    ///     bash.exec("echo 'done' > /output.txt").await?;
1197    ///     let output = fs.read_file(Path::new("/output.txt")).await?;
1198    ///     assert_eq!(output, b"done\n");
1199    ///     Ok(())
1200    /// }
1201    /// ```
1202    pub fn fs(&self) -> Arc<dyn FileSystem> {
1203        Arc::clone(&self.fs)
1204    }
1205
1206    /// Mount a filesystem at `vfs_path` on a live interpreter.
1207    ///
1208    /// Unlike [`BashBuilder`] mount methods which configure mounts before build,
1209    /// this method attaches a filesystem **after** the interpreter is running.
1210    /// Shell state (env vars, cwd, history) is preserved — no rebuild needed.
1211    ///
1212    /// The mount takes effect immediately: subsequent `exec()` calls will see
1213    /// files from the mounted filesystem at the given path.
1214    ///
1215    /// # Arguments
1216    ///
1217    /// * `vfs_path` - Absolute path where the filesystem will appear (e.g. `/mnt/data`)
1218    /// * `fs` - The filesystem to mount
1219    ///
1220    /// # Errors
1221    ///
1222    /// Returns an error if `vfs_path` is not absolute.
1223    ///
1224    /// # Example
1225    ///
1226    /// ```rust
1227    /// use bashkit::{Bash, FileSystem, InMemoryFs};
1228    /// use std::path::Path;
1229    /// use std::sync::Arc;
1230    ///
1231    /// # #[tokio::main]
1232    /// # async fn main() -> bashkit::Result<()> {
1233    /// let mut bash = Bash::new();
1234    ///
1235    /// // Create and populate a filesystem
1236    /// let data_fs = Arc::new(InMemoryFs::new());
1237    /// data_fs.write_file(Path::new("/users.json"), br#"["alice"]"#).await?;
1238    ///
1239    /// // Mount it live — no rebuild, no state loss
1240    /// bash.mount("/mnt/data", data_fs)?;
1241    ///
1242    /// let result = bash.exec("cat /mnt/data/users.json").await?;
1243    /// assert!(result.stdout.contains("alice"));
1244    /// # Ok(())
1245    /// # }
1246    /// ```
1247    pub fn mount(
1248        &self,
1249        vfs_path: impl AsRef<std::path::Path>,
1250        fs: Arc<dyn FileSystem>,
1251    ) -> Result<()> {
1252        // THREAT[TM-DOS-058]: `Bash::fs()` exposes the live outer VFS handle;
1253        // reject mounting that handle back into this Bash before any wrappers
1254        // can hide pointer identity and recurse through delegated operations.
1255        if Arc::ptr_eq(&self.fs, &fs) {
1256            return Err(std::io::Error::other("cannot mount filesystem into itself").into());
1257        }
1258
1259        let fs: Arc<dyn FileSystem> = if self.readonly_filesystem {
1260            Arc::new(ReadOnlyFs::new(fs))
1261        } else {
1262            fs
1263        };
1264        self.mountable.mount(vfs_path, fs)
1265    }
1266
1267    /// Unmount a previously mounted filesystem.
1268    ///
1269    /// After unmounting, paths under `vfs_path` fall back to the root filesystem
1270    /// or the next shorter mount prefix. Shell state is preserved.
1271    ///
1272    /// # Errors
1273    ///
1274    /// Returns an error if nothing is mounted at `vfs_path`.
1275    ///
1276    /// # Example
1277    ///
1278    /// ```rust
1279    /// use bashkit::{Bash, FileSystem, InMemoryFs};
1280    /// use std::path::Path;
1281    /// use std::sync::Arc;
1282    ///
1283    /// # #[tokio::main]
1284    /// # async fn main() -> bashkit::Result<()> {
1285    /// let mut bash = Bash::new();
1286    ///
1287    /// let tmp_fs = Arc::new(InMemoryFs::new());
1288    /// tmp_fs.write_file(Path::new("/data.txt"), b"temp").await?;
1289    ///
1290    /// bash.mount("/scratch", tmp_fs)?;
1291    /// let result = bash.exec("cat /scratch/data.txt").await?;
1292    /// assert_eq!(result.stdout, "temp");
1293    ///
1294    /// bash.unmount("/scratch")?;
1295    /// // /scratch/data.txt is no longer accessible
1296    /// # Ok(())
1297    /// # }
1298    /// ```
1299    pub fn unmount(&self, vfs_path: impl AsRef<std::path::Path>) -> Result<()> {
1300        self.mountable.unmount(vfs_path)
1301    }
1302
1303    /// Capture the current shell state (variables, env, cwd, options).
1304    ///
1305    /// Returns a serializable snapshot of the interpreter state. Combine with
1306    /// [`InMemoryFs::snapshot()`] for full session persistence.
1307    ///
1308    /// # Example
1309    ///
1310    /// ```rust
1311    /// use bashkit::Bash;
1312    ///
1313    /// # #[tokio::main]
1314    /// # async fn main() -> bashkit::Result<()> {
1315    /// let mut bash = Bash::new();
1316    /// bash.exec("x=42").await?;
1317    ///
1318    /// let state = bash.shell_state();
1319    ///
1320    /// bash.exec("x=99").await?;
1321    /// bash.restore_shell_state(&state);
1322    ///
1323    /// let result = bash.exec("echo $x").await?;
1324    /// assert_eq!(result.stdout, "42\n");
1325    /// # Ok(())
1326    /// # }
1327    /// ```
1328    pub fn shell_state(&self) -> ShellState {
1329        self.interpreter.shell_state()
1330    }
1331
1332    /// Capture a lightweight shell-state view for prompt/UI inspection.
1333    ///
1334    /// Unlike [`shell_state()`](Self::shell_state), this omits function
1335    /// definitions so callers that only need prompt/completion data avoid
1336    /// cloning AST-heavy state.
1337    pub fn shell_state_view(&self) -> ShellStateView {
1338        self.interpreter.shell_state_view()
1339    }
1340
1341    /// Restore shell state from a previous snapshot.
1342    ///
1343    /// Restores variables, env, cwd, arrays, functions, aliases, traps, and
1344    /// options. Does not restore builtins or VFS contents.
1345    pub fn restore_shell_state(&mut self, state: &ShellState) {
1346        self.interpreter.restore_shell_state(state);
1347    }
1348
1349    /// Names of all builtins dispatchable in this instance, sorted.
1350    ///
1351    /// Reflects what this build + configuration actually dispatches:
1352    /// baked-in builtins (including compile-feature-gated ones like `jq`,
1353    /// `git`, `ssh`), interpreter-special builtins like `exec`, custom
1354    /// builtins registered at construction, and host-registry builtins.
1355    /// Canonical source for the generated builtins
1356    /// status (`just regen-builtins`, `specs/status/builtins.json`).
1357    pub fn builtin_names(&self) -> Vec<String> {
1358        self.interpreter.builtin_names()
1359    }
1360
1361    /// Get the current session-level counters (cumulative across exec() calls).
1362    ///
1363    /// Returns `(session_commands, session_exec_calls)`.
1364    pub fn session_counters(&self) -> (u64, u64) {
1365        let c = self.interpreter.counters();
1366        (c.session_commands, c.session_exec_calls)
1367    }
1368
1369    /// Merge session-level counters to resume a session across Bash instances.
1370    ///
1371    /// This is used by external tool hosts to persist cumulative session counters
1372    /// across fresh Bash instances created per tool call. Counters are monotonic:
1373    /// restoring lower values never reduces already-consumed session budget.
1374    pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
1375        self.interpreter
1376            .restore_session_counters(session_commands, session_exec_calls);
1377    }
1378}
1379
1380/// Builder for customized Bash configuration.
1381///
1382/// # Example
1383///
1384/// ```rust
1385/// use bashkit::{Bash, ExecutionLimits};
1386///
1387/// let bash = Bash::builder()
1388///     .env("HOME", "/home/user")
1389///     .username("deploy")
1390///     .hostname("prod-server")
1391///     .limits(ExecutionLimits::new().max_commands(1000))
1392///     .build();
1393/// ```
1394///
1395/// ## Custom Builtins
1396///
1397/// You can register custom builtins to extend bashkit with domain-specific commands:
1398///
1399/// ```rust
1400/// use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
1401///
1402/// struct MyCommand;
1403///
1404/// #[async_trait]
1405/// impl Builtin for MyCommand {
1406///     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
1407///         Ok(ExecResult::ok(format!("Hello from custom command!\n")))
1408///     }
1409/// }
1410///
1411/// let bash = Bash::builder()
1412///     .builtin("mycommand", Box::new(MyCommand))
1413///     .build();
1414/// ```
1415/// A file to be mounted during builder construction.
1416struct MountedFile {
1417    path: PathBuf,
1418    content: String,
1419    mode: u32,
1420}
1421
1422struct MountedLazyFile {
1423    path: PathBuf,
1424    size_hint: u64,
1425    mode: u32,
1426    loader: LazyLoader,
1427}
1428
1429/// A real host directory to mount in the VFS during builder construction.
1430#[cfg(feature = "realfs")]
1431struct MountedRealDir {
1432    /// Path on the host filesystem.
1433    host_path: PathBuf,
1434    /// Mount point inside the VFS (e.g. "/mnt/data"). None = overlay at root.
1435    vfs_mount: Option<PathBuf>,
1436    /// Access mode.
1437    mode: fs::RealFsMode,
1438}
1439
1440#[derive(Default)]
1441pub struct BashBuilder {
1442    fs: Option<Arc<dyn FileSystem>>,
1443    env: HashMap<String, String>,
1444    cwd: Option<PathBuf>,
1445    limits: ExecutionLimits,
1446    session_limits: SessionLimits,
1447    memory_limits: MemoryLimits,
1448    trace_mode: TraceMode,
1449    trace_callback: Option<TraceCallback>,
1450    username: Option<String>,
1451    hostname: Option<String>,
1452    /// Fixed epoch for virtualizing the `date` builtin (TM-INF-018)
1453    fixed_epoch: Option<i64>,
1454    /// Constant seconds offset applied to real-clock for `date` (TM-INF-018)
1455    epoch_offset: Option<i64>,
1456    shell_profile: interpreter::ShellProfile,
1457    custom_builtins: HashMap<String, Box<dyn Builtin>>,
1458    /// Optional host-owned mutable registry. Entries here are consulted at
1459    /// dispatch time, so embedders can register/remove builtins after build.
1460    host_builtins: Option<BuiltinRegistry>,
1461    /// Files to mount in the virtual filesystem
1462    mounted_files: Vec<MountedFile>,
1463    /// Lazy files to mount (loaded on first read)
1464    mounted_lazy_files: Vec<MountedLazyFile>,
1465    /// Network allowlist for curl/wget builtins
1466    #[cfg(feature = "http_client")]
1467    network_allowlist: Option<NetworkAllowlist>,
1468    /// Custom HTTP transport for curl/wget.
1469    #[cfg(feature = "http_client")]
1470    http_transport: Option<Arc<dyn network::HttpTransport>>,
1471    /// Bot-auth config for transparent request signing
1472    #[cfg(feature = "bot-auth")]
1473    bot_auth_config: Option<network::BotAuthConfig>,
1474    /// Logging configuration
1475    #[cfg(feature = "logging")]
1476    log_config: Option<logging::LogConfig>,
1477    /// Git configuration for git builtins
1478    #[cfg(feature = "git")]
1479    git_config: Option<GitConfig>,
1480    /// SSH configuration for ssh/scp/sftp builtins
1481    #[cfg(feature = "ssh")]
1482    ssh_config: Option<SshConfig>,
1483    /// Custom SSH handler for transport interception
1484    #[cfg(feature = "ssh")]
1485    ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
1486    /// Real host directories to mount in the VFS
1487    #[cfg(feature = "realfs")]
1488    real_mounts: Vec<MountedRealDir>,
1489    /// Optional allowlist of host paths that may be mounted.
1490    /// When set, only paths starting with an allowed prefix are accepted.
1491    #[cfg(feature = "realfs")]
1492    mount_path_allowlist: Option<Vec<PathBuf>>,
1493    /// Optional VFS path for persistent history
1494    history_file: Option<PathBuf>,
1495    /// When true, deny all filesystem mutations after configured mounts/files are applied.
1496    readonly_filesystem: bool,
1497    /// Interceptor hooks
1498    hooks_on_exit: Vec<hooks::Interceptor<hooks::ExitEvent>>,
1499    hooks_before_exec: Vec<hooks::Interceptor<hooks::ExecInput>>,
1500    hooks_after_exec: Vec<hooks::Interceptor<hooks::ExecOutput>>,
1501    hooks_before_tool: Vec<hooks::Interceptor<hooks::ToolEvent>>,
1502    hooks_after_tool: Vec<hooks::Interceptor<hooks::ToolResult>>,
1503    hooks_on_error: Vec<hooks::Interceptor<hooks::ErrorEvent>>,
1504    #[cfg(feature = "http_client")]
1505    hooks_before_http: Vec<hooks::Interceptor<hooks::HttpRequestEvent>>,
1506    #[cfg(feature = "http_client")]
1507    hooks_after_http: Vec<hooks::Interceptor<hooks::HttpResponseEvent>>,
1508    /// Credential injection policy
1509    #[cfg(feature = "http_client")]
1510    credential_policy: Option<credential::CredentialPolicy>,
1511}
1512
1513impl BashBuilder {
1514    /// Set a custom filesystem.
1515    pub fn fs(mut self, fs: Arc<dyn FileSystem>) -> Self {
1516        self.fs = Some(fs);
1517        self
1518    }
1519
1520    /// Set an environment variable.
1521    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1522        self.env.insert(key.into(), value.into());
1523        self
1524    }
1525
1526    /// Set the current working directory.
1527    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
1528        self.cwd = Some(cwd.into());
1529        self
1530    }
1531
1532    /// Set execution limits.
1533    pub fn limits(mut self, limits: ExecutionLimits) -> Self {
1534        self.limits = limits;
1535        self
1536    }
1537
1538    /// Restrict this shell to logic/data-flow commands and custom builtins.
1539    #[cfg(feature = "scripted_tool")]
1540    pub(crate) fn logic_only(mut self) -> Self {
1541        self.shell_profile = interpreter::ShellProfile::LogicOnly;
1542        self
1543    }
1544
1545    /// Set session-level resource limits.
1546    ///
1547    /// Session limits persist across `exec()` calls and prevent tenants
1548    /// from circumventing per-execution limits by splitting work.
1549    pub fn session_limits(mut self, limits: SessionLimits) -> Self {
1550        self.session_limits = limits;
1551        self
1552    }
1553
1554    /// Set per-instance memory limits.
1555    ///
1556    /// Controls the maximum variables, arrays, and functions a Bash
1557    /// instance can hold. Prevents memory exhaustion in multi-tenant use.
1558    pub fn memory_limits(mut self, limits: MemoryLimits) -> Self {
1559        self.memory_limits = limits;
1560        self
1561    }
1562
1563    /// Cap total interpreter memory to `bytes`.
1564    ///
1565    /// Convenience wrapper over [`memory_limits`](Self::memory_limits) that
1566    /// sets `max_total_variable_bytes` to `bytes` and clamps
1567    /// `max_function_body_bytes` to `min(bytes, default)`. Count-based
1568    /// sub-limits (variable count, array entries, function count) stay at
1569    /// their defaults.
1570    ///
1571    /// # Example
1572    /// ```
1573    /// # use bashkit::Bash;
1574    /// let bash = Bash::builder()
1575    ///     .max_memory(10 * 1024 * 1024)   // 10 MB
1576    ///     .build();
1577    /// ```
1578    pub fn max_memory(self, bytes: usize) -> Self {
1579        let defaults = MemoryLimits::default();
1580        self.memory_limits(
1581            MemoryLimits::new()
1582                .max_total_variable_bytes(bytes)
1583                .max_function_body_bytes(bytes.min(defaults.max_function_body_bytes)),
1584        )
1585    }
1586
1587    /// Set the trace mode for structured execution tracing.
1588    ///
1589    /// - `TraceMode::Off` (default): No events, zero overhead
1590    /// - `TraceMode::Redacted`: Events with secrets scrubbed
1591    /// - `TraceMode::Full`: Raw events, no redaction
1592    pub fn trace_mode(mut self, mode: TraceMode) -> Self {
1593        self.trace_mode = mode;
1594        self
1595    }
1596
1597    /// Set a real-time callback for trace events.
1598    ///
1599    /// The callback is invoked for each trace event as it occurs.
1600    /// Requires `trace_mode` to be set to `Redacted` or `Full`.
1601    pub fn on_trace_event(mut self, callback: TraceCallback) -> Self {
1602        self.trace_callback = Some(callback);
1603        self
1604    }
1605
1606    /// Set the sandbox username.
1607    ///
1608    /// This configures `whoami` and `id` builtins to return this username,
1609    /// and automatically sets the `USER` environment variable.
1610    pub fn username(mut self, username: impl Into<String>) -> Self {
1611        self.username = Some(username.into());
1612        self
1613    }
1614
1615    /// Set the sandbox hostname.
1616    ///
1617    /// This configures `hostname` and `uname -n` builtins to return this hostname.
1618    pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
1619        self.hostname = Some(hostname.into());
1620        self
1621    }
1622
1623    /// Configure whether a file descriptor is reported as a terminal by `[ -t fd ]`.
1624    ///
1625    /// In a sandboxed VFS environment, all FDs default to non-terminal (false).
1626    /// Use this to simulate interactive mode for scripts that check `[ -t 0 ]`
1627    /// (stdin), `[ -t 1 ]` (stdout), or `[ -t 2 ]` (stderr).
1628    ///
1629    /// ```rust
1630    /// # use bashkit::Bash;
1631    /// let bash = Bash::builder()
1632    ///     .tty(0, true)  // stdin is a terminal
1633    ///     .tty(1, true)  // stdout is a terminal
1634    ///     .build();
1635    /// ```
1636    pub fn tty(mut self, fd: u32, is_terminal: bool) -> Self {
1637        let key = format!("_TTY_{}", fd);
1638        if is_terminal {
1639            self.env.insert(key, "1".to_string());
1640        } else {
1641            self.env.remove(&key);
1642        }
1643        self
1644    }
1645
1646    /// Set a fixed Unix epoch for the `date` builtin.
1647    ///
1648    /// THREAT[TM-INF-018]: Prevents `date` from leaking real host time.
1649    /// When set, `date` returns this fixed time instead of the real clock.
1650    pub fn fixed_epoch(mut self, epoch: i64) -> Self {
1651        self.fixed_epoch = Some(epoch);
1652        self.epoch_offset = None;
1653        self
1654    }
1655
1656    /// Apply a constant offset (in seconds) to the real system clock for
1657    /// the `date` builtin. Use this when scripts need time to advance at
1658    /// real-clock rate but you want to obscure the absolute wall-clock
1659    /// time from the sandbox (timing-correlation resistance).
1660    ///
1661    /// THREAT[TM-INF-018]: A non-zero offset prevents `date` from
1662    /// exposing the host's exact wall-clock time while still letting
1663    /// time-sensitive scripts observe elapsed-time deltas.
1664    ///
1665    /// `fixed_epoch` and `epoch_offset` are mutually exclusive — the
1666    /// last builder call wins.
1667    pub fn epoch_offset(mut self, seconds: i64) -> Self {
1668        self.epoch_offset = Some(seconds);
1669        self.fixed_epoch = None;
1670        self
1671    }
1672
1673    /// Enable persistent history stored at the given VFS path.
1674    ///
1675    /// History entries are loaded from this file at startup and saved after each
1676    /// `exec()` call. The file is stored in the virtual filesystem.
1677    pub fn history_file(mut self, path: impl Into<PathBuf>) -> Self {
1678        self.history_file = Some(path.into());
1679        self
1680    }
1681
1682    /// Configure network access for curl/wget builtins.
1683    ///
1684    /// Network access is disabled by default. Use this method to enable HTTP
1685    /// requests from scripts with a URL allowlist for security.
1686    ///
1687    /// # Security
1688    ///
1689    /// The allowlist uses a default-deny model:
1690    /// - Only URLs matching allowlist patterns can be accessed
1691    /// - Pattern matching is literal (no DNS resolution) to prevent DNS rebinding
1692    /// - Scheme, host, port, and path prefix are all validated
1693    ///
1694    /// # Example
1695    ///
1696    /// ```rust
1697    /// use bashkit::{Bash, NetworkAllowlist};
1698    ///
1699    /// // Allow access to specific APIs only
1700    /// let allowlist = NetworkAllowlist::new()
1701    ///     .allow("https://api.example.com")
1702    ///     .allow("https://cdn.example.com/assets");
1703    ///
1704    /// let bash = Bash::builder()
1705    ///     .network(allowlist)
1706    ///     .build();
1707    /// ```
1708    ///
1709    /// # Warning
1710    ///
1711    /// Using [`NetworkAllowlist::allow_all()`] is dangerous and should only be
1712    /// used for testing or when the script is fully trusted.
1713    #[cfg(feature = "http_client")]
1714    pub fn network(mut self, allowlist: NetworkAllowlist) -> Self {
1715        self.network_allowlist = Some(allowlist);
1716        self
1717    }
1718
1719    /// Set a custom HTTP transport for all curl/wget/http traffic.
1720    ///
1721    /// The transport replaces the built-in reqwest connectivity while every
1722    /// policy step stays in bashkit and runs *before* the transport is
1723    /// called: URL allowlist check, DNS/private-IP SSRF precheck,
1724    /// `before_http` hooks (including credential injection), and bot-auth
1725    /// request signing. The [`HttpTransportRequest`] the transport receives
1726    /// carries the merged headers (signing + credentials), timeouts, the
1727    /// precheck's pinned addresses, and the response size cap. Redirects are
1728    /// followed manually by curl/wget, so every hop is re-validated,
1729    /// re-signed, and re-dispatched through the transport.
1730    ///
1731    /// Use this to direct sandbox traffic through a host-owned boundary:
1732    /// - an egress service or gateway (route, audit, and deny centrally)
1733    /// - corporate proxies
1734    /// - logging/auditing, caching, rate limiting
1735    /// - mocking HTTP responses in tests
1736    ///
1737    /// The `Arc` can be shared across many `Bash` instances, so hosts that
1738    /// build one interpreter per execution reuse a single transport.
1739    ///
1740    /// Network access remains **disabled by default**: without
1741    /// [`network`](Self::network) configuring an allowlist, no HTTP builtin
1742    /// can make requests and the transport is never called.
1743    ///
1744    /// # Errors and limits
1745    ///
1746    /// Return [`HttpTransportError::Denied`] for host-policy denials,
1747    /// [`HttpTransportError::Timeout`] / [`HttpTransportError::TooLarge`]
1748    /// for deadline and size violations — curl/wget map them to their
1749    /// native exit codes (7, 28, 63). See [`HttpTransportError`].
1750    ///
1751    /// # Example
1752    ///
1753    /// ```
1754    /// use bashkit::{
1755    ///     Bash, HttpResponse, HttpTransport, HttpTransportError, HttpTransportRequest,
1756    ///     NetworkAllowlist,
1757    /// };
1758    /// use std::sync::Arc;
1759    ///
1760    /// /// Routes every sandbox request through a host egress boundary.
1761    /// struct EgressTransport;
1762    ///
1763    /// #[async_trait::async_trait]
1764    /// impl HttpTransport for EgressTransport {
1765    ///     async fn execute(
1766    ///         &self,
1767    ///         request: HttpTransportRequest,
1768    ///     ) -> Result<HttpResponse, HttpTransportError> {
1769    ///         // Forward request.method/url/headers/body/timeout/pinned_addrs
1770    ///         // to the host's egress client; map policy denials to `Denied`.
1771    ///         Ok(HttpResponse { status: 200, headers: vec![], body: b"ok".to_vec() })
1772    ///     }
1773    /// }
1774    ///
1775    /// let bash = Bash::builder()
1776    ///     .network(NetworkAllowlist::allow_all())
1777    ///     .http_transport(Arc::new(EgressTransport))
1778    ///     .build();
1779    /// ```
1780    #[cfg(feature = "http_client")]
1781    pub fn http_transport(mut self, transport: Arc<dyn network::HttpTransport>) -> Self {
1782        self.http_transport = Some(transport);
1783        self
1784    }
1785
1786    /// Enable transparent request signing for all outbound HTTP requests.
1787    ///
1788    /// When configured, every HTTP request made by curl/wget/http builtins
1789    /// is signed with Ed25519 per RFC 9421 / web-bot-auth profile. No CLI
1790    /// arguments or script changes needed — signing is fully transparent.
1791    ///
1792    /// Signing failures are non-blocking: the request is sent unsigned.
1793    ///
1794    /// # Example
1795    ///
1796    /// ```rust,ignore
1797    /// use bashkit::{Bash, NetworkAllowlist};
1798    /// use bashkit::network::BotAuthConfig;
1799    ///
1800    /// let bash = Bash::builder()
1801    ///     .network(NetworkAllowlist::new().allow("https://api.example.com"))
1802    ///     .bot_auth(BotAuthConfig::from_seed([42u8; 32])
1803    ///         .with_agent_fqdn("bot.example.com"))
1804    ///     .build();
1805    /// ```
1806    #[cfg(feature = "bot-auth")]
1807    pub fn bot_auth(mut self, config: network::BotAuthConfig) -> Self {
1808        self.bot_auth_config = Some(config);
1809        self
1810    }
1811
1812    /// Configure logging behavior.
1813    ///
1814    /// When the `logging` feature is enabled, Bashkit can emit structured logs
1815    /// at various levels (error, warn, info, debug, trace) during execution.
1816    ///
1817    /// # Log Levels
1818    ///
1819    /// - **ERROR**: Unrecoverable failures, exceptions, security violations
1820    /// - **WARN**: Recoverable issues, limit warnings, deprecated usage
1821    /// - **INFO**: Session lifecycle (start/end), high-level execution flow
1822    /// - **DEBUG**: Command execution, variable expansion, control flow
1823    /// - **TRACE**: Internal parser/interpreter state, detailed data flow
1824    ///
1825    /// # Security (TM-LOG-001)
1826    ///
1827    /// By default, sensitive data is redacted from logs:
1828    /// - Environment variables matching secret patterns (PASSWORD, TOKEN, etc.)
1829    /// - URL credentials (user:pass@host)
1830    /// - Values that look like API keys or JWTs
1831    ///
1832    /// # Example
1833    ///
1834    /// ```rust
1835    /// use bashkit::{Bash, LogConfig};
1836    ///
1837    /// let bash = Bash::builder()
1838    ///     .log_config(LogConfig::new()
1839    ///         .redact_env("MY_CUSTOM_SECRET"))
1840    ///     .build();
1841    /// ```
1842    ///
1843    /// # Warning
1844    ///
1845    /// Do not use `LogConfig::unsafe_disable_redaction()` or
1846    /// `LogConfig::unsafe_log_scripts()` in production, as they may expose
1847    /// sensitive data in logs.
1848    #[cfg(feature = "logging")]
1849    pub fn log_config(mut self, config: logging::LogConfig) -> Self {
1850        self.log_config = Some(config);
1851        self
1852    }
1853
1854    /// Configure git support for git commands.
1855    ///
1856    /// Git access is disabled by default. Use this method to enable git
1857    /// commands with the specified configuration.
1858    ///
1859    /// # Security
1860    ///
1861    /// - All operations are confined to the virtual filesystem
1862    /// - Author identity is sandboxed (configurable, never from host)
1863    /// - Remote operations (Phase 2) require URL allowlist
1864    /// - No access to host git config or credentials
1865    ///
1866    /// # Example
1867    ///
1868    /// ```rust
1869    /// use bashkit::{Bash, GitConfig};
1870    ///
1871    /// let bash = Bash::builder()
1872    ///     .git(GitConfig::new()
1873    ///         .author("CI Bot", "ci@example.com"))
1874    ///     .build();
1875    /// ```
1876    ///
1877    /// # Threat Mitigations
1878    ///
1879    /// - TM-GIT-002: Host identity leak - uses configured author, never host
1880    /// - TM-GIT-003: Host config access - no filesystem access outside VFS
1881    /// - TM-GIT-005: Repository escape - all paths within VFS
1882    #[cfg(feature = "git")]
1883    pub fn git(mut self, config: GitConfig) -> Self {
1884        self.git_config = Some(config);
1885        self
1886    }
1887
1888    /// Configure SSH access for ssh/scp/sftp builtins.
1889    ///
1890    /// # Example
1891    ///
1892    /// ```rust
1893    /// use bashkit::{Bash, SshConfig};
1894    ///
1895    /// let bash = Bash::builder()
1896    ///     .ssh(SshConfig::new()
1897    ///         .allow("*.supabase.co")
1898    ///         .default_user("root"))
1899    ///     .build();
1900    /// ```
1901    ///
1902    /// # Threat Mitigations
1903    ///
1904    /// - TM-SSH-001: Unauthorized host access - host allowlist (default-deny)
1905    /// - TM-SSH-002: Credential leakage - keys from VFS only
1906    /// - TM-SSH-005: Connection hang - configurable timeouts
1907    #[cfg(feature = "ssh")]
1908    pub fn ssh(mut self, config: SshConfig) -> Self {
1909        self.ssh_config = Some(config);
1910        self
1911    }
1912
1913    /// Set a custom SSH handler for transport interception.
1914    ///
1915    /// Embedders can implement [`SshHandler`] to mock, proxy, log, or
1916    /// rate-limit SSH operations. The allowlist check happens before
1917    /// the handler is called.
1918    #[cfg(feature = "ssh")]
1919    pub fn ssh_handler(mut self, handler: Box<dyn builtins::ssh::SshHandler>) -> Self {
1920        self.ssh_handler = Some(handler);
1921        self
1922    }
1923
1924    /// Enable embedded Python (`python`/`python3` builtins) via Monty interpreter
1925    /// with default resource limits.
1926    ///
1927    /// Monty runs directly in the host process with resource limits enforced
1928    /// by Monty's runtime (memory, allocations, time, recursion).
1929    ///
1930    /// For security, execution is runtime-gated: set
1931    /// `BASHKIT_ALLOW_INPROCESS_PYTHON=1` via builder `.env(...)` before
1932    /// invoking `python`/`python3`.
1933    ///
1934    /// Requires the `python` feature flag. Python `pathlib.Path` operations are
1935    /// bridged to the virtual filesystem.
1936    ///
1937    /// # Example
1938    ///
1939    /// ```rust,ignore
1940    /// let bash = Bash::builder().python().build();
1941    /// ```
1942    #[cfg(feature = "python")]
1943    pub fn python(self) -> Self {
1944        self.python_with_limits(builtins::PythonLimits::default())
1945    }
1946
1947    /// Enable embedded SQLite (`sqlite`/`sqlite3` builtins) via Turso.
1948    ///
1949    /// Registers both names with the default [`SqliteLimits`]. The Turso
1950    /// engine is BETA upstream — for security, execution is runtime-gated:
1951    /// set `BASHKIT_ALLOW_INPROCESS_SQLITE=1` via builder `.env(...)` (or
1952    /// `export`) before invoking `sqlite`.
1953    ///
1954    /// Requires the `sqlite` feature flag. Database files are loaded from /
1955    /// flushed to the virtual filesystem at command boundaries.
1956    ///
1957    /// # Example
1958    ///
1959    /// ```rust,ignore
1960    /// let bash = Bash::builder()
1961    ///     .sqlite()
1962    ///     .env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1")
1963    ///     .build();
1964    /// ```
1965    #[cfg(feature = "sqlite")]
1966    pub fn sqlite(self) -> Self {
1967        self.sqlite_with_limits(builtins::SqliteLimits::default())
1968    }
1969
1970    /// Enable embedded SQLite with custom limits and backend selection.
1971    ///
1972    /// See [`BashBuilder::sqlite`] for details. Use [`SqliteLimits::backend`]
1973    /// to switch between the in-memory shim (Phase 1, default) and the
1974    /// VFS-backed adapter (Phase 2).
1975    ///
1976    /// # Example
1977    ///
1978    /// ```rust,ignore
1979    /// use bashkit::{SqliteBackend, SqliteLimits};
1980    ///
1981    /// let bash = Bash::builder()
1982    ///     .sqlite_with_limits(
1983    ///         SqliteLimits::default()
1984    ///             .backend(SqliteBackend::Vfs)
1985    ///             .max_db_bytes(8 * 1024 * 1024),
1986    ///     )
1987    ///     .build();
1988    /// ```
1989    #[cfg(feature = "sqlite")]
1990    pub fn sqlite_with_limits(self, limits: builtins::SqliteLimits) -> Self {
1991        self.builtin(
1992            "sqlite",
1993            Box::new(builtins::Sqlite::with_limits(limits.clone())),
1994        )
1995        .builtin("sqlite3", Box::new(builtins::Sqlite::with_limits(limits)))
1996    }
1997
1998    /// Enable embedded Python with custom resource limits.
1999    ///
2000    /// See [`BashBuilder::python`] for details.
2001    ///
2002    /// # Example
2003    ///
2004    /// ```rust,ignore
2005    /// use bashkit::PythonLimits;
2006    /// use std::time::Duration;
2007    ///
2008    /// let bash = Bash::builder()
2009    ///     .python_with_limits(PythonLimits::default().max_duration(Duration::from_secs(5)))
2010    ///     .build();
2011    /// ```
2012    #[cfg(feature = "python")]
2013    pub fn python_with_limits(self, limits: builtins::PythonLimits) -> Self {
2014        self.builtin(
2015            "python",
2016            Box::new(builtins::Python::with_limits(limits.clone())),
2017        )
2018        .builtin("python3", Box::new(builtins::Python::with_limits(limits)))
2019    }
2020
2021    /// Enable embedded Python with external function handlers.
2022    ///
2023    /// See [`PythonExternalFnHandler`] for handler details.
2024    #[cfg(feature = "python")]
2025    pub fn python_with_external_handler(
2026        self,
2027        limits: builtins::PythonLimits,
2028        external_fns: Vec<String>,
2029        handler: builtins::PythonExternalFnHandler,
2030    ) -> Self {
2031        self.builtin(
2032            "python",
2033            Box::new(
2034                builtins::Python::with_limits(limits.clone())
2035                    .with_external_handler(external_fns.clone(), handler.clone()),
2036            ),
2037        )
2038        .builtin(
2039            "python3",
2040            Box::new(
2041                builtins::Python::with_limits(limits).with_external_handler(external_fns, handler),
2042            ),
2043        )
2044    }
2045
2046    /// Enable embedded TypeScript/JavaScript execution via ZapCode with defaults.
2047    ///
2048    /// Registers `ts`, `typescript`, `node`, `deno`, and `bun` builtins.
2049    /// Requires the `typescript` feature.
2050    ///
2051    /// # Example
2052    ///
2053    /// ```rust,ignore
2054    /// let bash = Bash::builder().typescript().build();
2055    /// bash.exec("ts -c \"console.log('hello')\"").await?;
2056    /// ```
2057    #[cfg(feature = "typescript")]
2058    pub fn typescript(self) -> Self {
2059        self.typescript_with_config(builtins::TypeScriptConfig::default())
2060    }
2061
2062    /// Enable embedded TypeScript with custom resource limits.
2063    ///
2064    /// See [`BashBuilder::typescript`] for details.
2065    #[cfg(feature = "typescript")]
2066    pub fn typescript_with_limits(self, limits: builtins::TypeScriptLimits) -> Self {
2067        self.typescript_with_config(builtins::TypeScriptConfig::default().limits(limits))
2068    }
2069
2070    /// Enable embedded TypeScript with full configuration control.
2071    ///
2072    /// # Example
2073    ///
2074    /// ```rust,ignore
2075    /// use bashkit::{TypeScriptConfig, TypeScriptLimits};
2076    /// use std::time::Duration;
2077    ///
2078    /// // Only ts/typescript commands, no node/deno/bun aliases
2079    /// let bash = Bash::builder()
2080    ///     .typescript_with_config(TypeScriptConfig::default().compat_aliases(false))
2081    ///     .build();
2082    ///
2083    /// // Disable unsupported-mode hints
2084    /// let bash = Bash::builder()
2085    ///     .typescript_with_config(TypeScriptConfig::default().unsupported_mode_hint(false))
2086    ///     .build();
2087    ///
2088    /// // Custom limits + no compat aliases
2089    /// let bash = Bash::builder()
2090    ///     .typescript_with_config(
2091    ///         TypeScriptConfig::default()
2092    ///             .limits(TypeScriptLimits::default().max_duration(Duration::from_secs(5)))
2093    ///             .compat_aliases(false)
2094    ///     )
2095    ///     .build();
2096    /// ```
2097    #[cfg(feature = "typescript")]
2098    pub fn typescript_with_config(self, config: builtins::TypeScriptConfig) -> Self {
2099        self.extension(builtins::TypeScriptExtension::with_config(config))
2100    }
2101
2102    /// Enable embedded TypeScript with external function handlers.
2103    ///
2104    /// See [`TypeScriptExternalFnHandler`] for handler details.
2105    #[cfg(feature = "typescript")]
2106    pub fn typescript_with_external_handler(
2107        self,
2108        limits: builtins::TypeScriptLimits,
2109        external_fns: Vec<String>,
2110        handler: builtins::TypeScriptExternalFnHandler,
2111    ) -> Self {
2112        self.extension(builtins::TypeScriptExtension::with_external_handler(
2113            limits,
2114            external_fns,
2115            handler,
2116        ))
2117    }
2118
2119    /// Register a custom builtin command.
2120    ///
2121    /// Custom builtins extend bashkit with domain-specific commands that can be
2122    /// invoked from bash scripts. They have full access to the execution context
2123    /// including arguments, environment, shell variables, and the virtual filesystem.
2124    ///
2125    /// Custom builtins can override default builtins if registered with the same name.
2126    ///
2127    /// # Arguments
2128    ///
2129    /// * `name` - The command name (e.g., "psql", "kubectl")
2130    /// * `builtin` - A boxed implementation of the [`Builtin`] trait
2131    ///
2132    /// # Example
2133    ///
2134    /// ```rust
2135    /// use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
2136    ///
2137    /// struct Greet {
2138    ///     default_name: String,
2139    /// }
2140    ///
2141    /// #[async_trait]
2142    /// impl Builtin for Greet {
2143    ///     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
2144    ///         let name = ctx.args.first()
2145    ///             .map(|s| s.as_str())
2146    ///             .unwrap_or(&self.default_name);
2147    ///         Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
2148    ///     }
2149    /// }
2150    ///
2151    /// let bash = Bash::builder()
2152    ///     .builtin("greet", Box::new(Greet { default_name: "World".into() }))
2153    ///     .build();
2154    /// ```
2155    pub fn builtin(mut self, name: impl Into<String>, builtin: Box<dyn Builtin>) -> Self {
2156        self.custom_builtins.insert(name.into(), builtin);
2157        self
2158    }
2159
2160    /// Attach a host-owned mutable builtin registry.
2161    ///
2162    /// Unlike [`BashBuilder::builtin`], entries in a [`BuiltinRegistry`] can
2163    /// be inserted and removed after the `Bash` instance has been built. The
2164    /// registry is host-owned, so its contents survive `exec()` calls
2165    /// unchanged. This is intended for embedders (FFI bindings, REPLs) that
2166    /// want to register host callbacks at runtime without rebuilding the
2167    /// interpreter.
2168    ///
2169    /// The registry is consulted during command dispatch after shell
2170    /// functions and POSIX special builtins, but before baked-in builtins —
2171    /// so entries can override baked-in commands of the same name.
2172    ///
2173    /// The registry handle is `Clone`; clones share the same underlying
2174    /// storage. Keep a clone after calling this method to retain
2175    /// post-build mutation access.
2176    pub fn builtin_registry(mut self, registry: BuiltinRegistry) -> Self {
2177        self.host_builtins = Some(registry);
2178        self
2179    }
2180
2181    /// Register a capability extension.
2182    ///
2183    /// Extensions contribute a related set of builtins as one unit. Commands
2184    /// registered by an extension follow the same override rules as
2185    /// [`BashBuilder::builtin`]: later registrations replace earlier ones with
2186    /// the same name.
2187    ///
2188    /// # Example
2189    ///
2190    /// ```rust
2191    /// use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, Extension, async_trait};
2192    ///
2193    /// struct Hello;
2194    ///
2195    /// #[async_trait]
2196    /// impl Builtin for Hello {
2197    ///     async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
2198    ///         Ok(ExecResult::ok("hello\n".to_string()))
2199    ///     }
2200    /// }
2201    ///
2202    /// struct HelloExtension;
2203    ///
2204    /// impl Extension for HelloExtension {
2205    ///     fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
2206    ///         vec![("hello".to_string(), Box::new(Hello))]
2207    ///     }
2208    /// }
2209    ///
2210    /// let bash = Bash::builder().extension(HelloExtension).build();
2211    /// ```
2212    pub fn extension<E>(mut self, extension: E) -> Self
2213    where
2214        E: builtins::Extension,
2215    {
2216        for (name, builtin) in extension.builtins() {
2217            self.custom_builtins.insert(name, builtin);
2218        }
2219        self
2220    }
2221
2222    /// Register an `on_exit` interceptor hook.
2223    ///
2224    /// Fired when the `exit` builtin runs.  The hook can inspect or
2225    /// modify the [`ExitEvent`](hooks::ExitEvent), or cancel the exit.
2226    /// Multiple hooks run in registration order.
2227    ///
2228    /// # Example
2229    ///
2230    /// ```rust
2231    /// use bashkit::hooks::{HookAction, ExitEvent};
2232    /// use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
2233    ///
2234    /// let exited = Arc::new(AtomicBool::new(false));
2235    /// let flag = exited.clone();
2236    ///
2237    /// let bash = bashkit::Bash::builder()
2238    ///     .on_exit(Box::new(move |event: ExitEvent| {
2239    ///         flag.store(true, Ordering::Relaxed);
2240    ///         HookAction::Continue(event)
2241    ///     }))
2242    ///     .build();
2243    /// ```
2244    pub fn on_exit(mut self, hook: hooks::Interceptor<hooks::ExitEvent>) -> Self {
2245        self.hooks_on_exit.push(hook);
2246        self
2247    }
2248
2249    /// Register a `before_exec` interceptor hook.
2250    ///
2251    /// Fires before a script is executed. Can modify the script text
2252    /// or cancel execution entirely.
2253    pub fn before_exec(mut self, hook: hooks::Interceptor<hooks::ExecInput>) -> Self {
2254        self.hooks_before_exec.push(hook);
2255        self
2256    }
2257
2258    /// Register an `after_exec` interceptor hook.
2259    ///
2260    /// Fires after script execution completes. Can modify or inspect
2261    /// the output (stdout, stderr, exit code).
2262    pub fn after_exec(mut self, hook: hooks::Interceptor<hooks::ExecOutput>) -> Self {
2263        self.hooks_after_exec.push(hook);
2264        self
2265    }
2266
2267    /// Register a `before_tool` interceptor hook.
2268    ///
2269    /// Fires before a builtin command is executed. Can modify args or
2270    /// cancel the tool invocation.
2271    pub fn before_tool(mut self, hook: hooks::Interceptor<hooks::ToolEvent>) -> Self {
2272        self.hooks_before_tool.push(hook);
2273        self
2274    }
2275
2276    /// Register an `after_tool` interceptor hook.
2277    ///
2278    /// Fires after a builtin command completes.
2279    pub fn after_tool(mut self, hook: hooks::Interceptor<hooks::ToolResult>) -> Self {
2280        self.hooks_after_tool.push(hook);
2281        self
2282    }
2283
2284    /// Register an `on_error` interceptor hook.
2285    ///
2286    /// Fires when the interpreter encounters an error.
2287    pub fn on_error(mut self, hook: hooks::Interceptor<hooks::ErrorEvent>) -> Self {
2288        self.hooks_on_error.push(hook);
2289        self
2290    }
2291
2292    /// Register a `before_http` interceptor hook.
2293    ///
2294    /// Fires before each HTTP request (after allowlist validation).
2295    /// Can modify the URL/headers or cancel the request.
2296    ///
2297    /// # Example
2298    ///
2299    /// ```
2300    /// use bashkit::{Bash, hooks::{HookAction, HttpRequestEvent}};
2301    ///
2302    /// let bash = Bash::builder()
2303    ///     .before_http(Box::new(|req: HttpRequestEvent| {
2304    ///         if req.url.contains("blocked") {
2305    ///             HookAction::Cancel("blocked by policy".into())
2306    ///         } else {
2307    ///             HookAction::Continue(req)
2308    ///         }
2309    ///     }))
2310    ///     .build();
2311    /// ```
2312    #[cfg(feature = "http_client")]
2313    pub fn before_http(mut self, hook: hooks::Interceptor<hooks::HttpRequestEvent>) -> Self {
2314        self.hooks_before_http.push(hook);
2315        self
2316    }
2317
2318    /// Register an `after_http` interceptor hook.
2319    ///
2320    /// Fires after each HTTP response is received. Can inspect
2321    /// response status and headers.
2322    #[cfg(feature = "http_client")]
2323    pub fn after_http(mut self, hook: hooks::Interceptor<hooks::HttpResponseEvent>) -> Self {
2324        self.hooks_after_http.push(hook);
2325        self
2326    }
2327
2328    /// Inject credentials for outbound HTTP requests matching the given URL pattern.
2329    ///
2330    /// The pattern uses the same matching as [`NetworkAllowlist`]
2331    /// (scheme + host + port + path prefix). Injected headers **overwrite**
2332    /// any existing headers with the same name set by the script, preventing
2333    /// credential spoofing.
2334    ///
2335    /// The script never sees the real credential — it is injected transparently
2336    /// by a `before_http` hook after the allowlist check.
2337    ///
2338    /// # Example
2339    ///
2340    /// ```rust
2341    /// use bashkit::{Bash, Credential, NetworkAllowlist};
2342    ///
2343    /// let bash = Bash::builder()
2344    ///     .network(NetworkAllowlist::new()
2345    ///         .allow("https://api.github.com"))
2346    ///     .credential("https://api.github.com",
2347    ///         Credential::bearer("ghp_xxxx"))
2348    ///     .build();
2349    /// // Scripts can now: curl -s https://api.github.com/repos/foo/bar
2350    /// // Authorization: Bearer ghp_xxxx is added transparently.
2351    /// ```
2352    ///
2353    /// See [`credential_injection_guide`] for the full guide.
2354    #[cfg(feature = "http_client")]
2355    pub fn credential(mut self, pattern: &str, cred: credential::Credential) -> Self {
2356        self.credential_policy
2357            .get_or_insert_with(credential::CredentialPolicy::new)
2358            .add_injection(pattern, cred);
2359        self
2360    }
2361
2362    /// Inject credentials via a placeholder env var visible to scripts.
2363    ///
2364    /// Sets environment variable `env_name` to an opaque placeholder string.
2365    /// When a request to `pattern` contains the placeholder in any header
2366    /// value, it is replaced with the real credential on the wire.
2367    ///
2368    /// The placeholder is a random string (`bk_placeholder_<hex>`) that:
2369    /// - Cannot be reversed to the real credential
2370    /// - Is only replaced for requests matching the URL pattern
2371    /// - Passes most SDK non-empty validation checks
2372    ///
2373    /// # Example
2374    ///
2375    /// ```rust
2376    /// use bashkit::{Bash, Credential, NetworkAllowlist};
2377    ///
2378    /// let bash = Bash::builder()
2379    ///     .network(NetworkAllowlist::new()
2380    ///         .allow("https://api.openai.com"))
2381    ///     .credential_placeholder("OPENAI_API_KEY",
2382    ///         "https://api.openai.com",
2383    ///         Credential::bearer("sk-real-key"))
2384    ///     .build();
2385    /// // Scripts see $OPENAI_API_KEY as "bk_placeholder_..." and use it normally.
2386    /// // The placeholder is replaced with the real key in outbound headers.
2387    /// ```
2388    ///
2389    /// See [`credential_injection_guide`] for the full guide.
2390    #[cfg(feature = "http_client")]
2391    pub fn credential_placeholder(
2392        mut self,
2393        env_name: &str,
2394        pattern: &str,
2395        cred: credential::Credential,
2396    ) -> Self {
2397        let placeholder = self
2398            .credential_policy
2399            .get_or_insert_with(credential::CredentialPolicy::new)
2400            .add_placeholder(pattern, cred);
2401        self.env.insert(env_name.to_string(), placeholder);
2402        self
2403    }
2404
2405    /// Mount a text file in the virtual filesystem.
2406    ///
2407    /// This creates a regular file (mode `0o644`) with the specified content at
2408    /// the given path. Parent directories are created automatically.
2409    ///
2410    /// Mounted files are added via an [`OverlayFs`] layer on top of the base
2411    /// filesystem. This means:
2412    /// - The base filesystem remains unchanged
2413    /// - Mounted files take precedence over base filesystem files
2414    /// - Works with any filesystem implementation
2415    ///
2416    /// # Example
2417    ///
2418    /// ```rust
2419    /// use bashkit::Bash;
2420    ///
2421    /// # #[tokio::main]
2422    /// # async fn main() -> bashkit::Result<()> {
2423    /// let mut bash = Bash::builder()
2424    ///     .mount_text("/config/app.conf", "debug=true\nport=8080\n")
2425    ///     .mount_text("/data/users.json", r#"["alice", "bob"]"#)
2426    ///     .build();
2427    ///
2428    /// let result = bash.exec("cat /config/app.conf").await?;
2429    /// assert_eq!(result.stdout, "debug=true\nport=8080\n");
2430    /// # Ok(())
2431    /// # }
2432    /// ```
2433    pub fn mount_text(mut self, path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
2434        self.mounted_files.push(MountedFile {
2435            path: path.into(),
2436            content: content.into(),
2437            mode: 0o644,
2438        });
2439        self
2440    }
2441
2442    /// Mount a readonly text file in the virtual filesystem.
2443    ///
2444    /// This creates a readonly file (mode `0o444`) with the specified content.
2445    /// Parent directories are created automatically.
2446    ///
2447    /// Readonly files are useful for:
2448    /// - Configuration that shouldn't be modified by scripts
2449    /// - Reference data that should remain immutable
2450    /// - Simulating system files like `/etc/passwd`
2451    ///
2452    /// Mounted files are added via an [`OverlayFs`] layer on top of the base
2453    /// filesystem. This means:
2454    /// - The base filesystem remains unchanged
2455    /// - Mounted files take precedence over base filesystem files
2456    /// - Works with any filesystem implementation
2457    ///
2458    /// # Example
2459    ///
2460    /// ```rust
2461    /// use bashkit::Bash;
2462    ///
2463    /// # #[tokio::main]
2464    /// # async fn main() -> bashkit::Result<()> {
2465    /// let mut bash = Bash::builder()
2466    ///     .mount_readonly_text("/etc/version", "1.2.3")
2467    ///     .mount_readonly_text("/etc/app.conf", "production=true\n")
2468    ///     .build();
2469    ///
2470    /// // Can read the file
2471    /// let result = bash.exec("cat /etc/version").await?;
2472    /// assert_eq!(result.stdout, "1.2.3");
2473    ///
2474    /// // File has readonly permissions
2475    /// let stat = bash.fs().stat(std::path::Path::new("/etc/version")).await?;
2476    /// assert_eq!(stat.mode, 0o444);
2477    /// # Ok(())
2478    /// # }
2479    /// ```
2480    pub fn mount_readonly_text(
2481        mut self,
2482        path: impl Into<PathBuf>,
2483        content: impl Into<String>,
2484    ) -> Self {
2485        self.mounted_files.push(MountedFile {
2486            path: path.into(),
2487            content: content.into(),
2488            mode: 0o444,
2489        });
2490        self
2491    }
2492
2493    /// Mount a lazy file whose content is loaded on first read.
2494    ///
2495    /// The `loader` closure is called at most once when the file is first read.
2496    /// If the file is overwritten before being read, the loader is never called.
2497    /// `stat()` returns metadata using `size_hint` without triggering the load.
2498    ///
2499    /// # Example
2500    ///
2501    /// ```rust
2502    /// use bashkit::Bash;
2503    /// use std::sync::Arc;
2504    ///
2505    /// # #[tokio::main]
2506    /// # async fn main() -> bashkit::Result<()> {
2507    /// let mut bash = Bash::builder()
2508    ///     .mount_lazy("/data/large.csv", 1024, Arc::new(|| {
2509    ///         b"id,name\n1,Alice\n".to_vec()
2510    ///     }))
2511    ///     .build();
2512    ///
2513    /// let result = bash.exec("cat /data/large.csv").await?;
2514    /// assert_eq!(result.stdout, "id,name\n1,Alice\n");
2515    /// # Ok(())
2516    /// # }
2517    /// ```
2518    pub fn mount_lazy(
2519        mut self,
2520        path: impl Into<PathBuf>,
2521        size_hint: u64,
2522        loader: LazyLoader,
2523    ) -> Self {
2524        self.mounted_lazy_files.push(MountedLazyFile {
2525            path: path.into(),
2526            size_hint,
2527            mode: 0o644,
2528            loader,
2529        });
2530        self
2531    }
2532
2533    /// Mount a real host directory as a readonly overlay at the VFS root.
2534    ///
2535    /// Files from `host_path` become visible at the same paths inside the VFS.
2536    /// For example, if the host directory contains `src/main.rs`, it will be
2537    /// available as `/src/main.rs` inside the virtual bash session.
2538    ///
2539    /// The host directory is read-only: scripts cannot modify host files.
2540    ///
2541    /// Requires the `realfs` feature flag.
2542    ///
2543    /// # Example
2544    ///
2545    /// ```rust,ignore
2546    /// let bash = Bash::builder()
2547    ///     .mount_real_readonly("/path/to/project")
2548    ///     .build();
2549    /// ```
2550    #[cfg(feature = "realfs")]
2551    pub fn mount_real_readonly(mut self, host_path: impl Into<PathBuf>) -> Self {
2552        self.real_mounts.push(MountedRealDir {
2553            host_path: host_path.into(),
2554            vfs_mount: None,
2555            mode: fs::RealFsMode::ReadOnly,
2556        });
2557        self
2558    }
2559
2560    /// Mount a real host directory as a readonly filesystem at a specific VFS path.
2561    ///
2562    /// Files from `host_path` become visible under `vfs_mount` inside the VFS.
2563    /// For example, mounting `/home/user/data` at `/mnt/data` makes
2564    /// `/home/user/data/file.txt` available as `/mnt/data/file.txt`.
2565    ///
2566    /// The host directory is read-only: scripts cannot modify host files.
2567    ///
2568    /// Requires the `realfs` feature flag.
2569    ///
2570    /// # Example
2571    ///
2572    /// ```rust,ignore
2573    /// let bash = Bash::builder()
2574    ///     .mount_real_readonly_at("/path/to/data", "/mnt/data")
2575    ///     .build();
2576    /// ```
2577    #[cfg(feature = "realfs")]
2578    pub fn mount_real_readonly_at(
2579        mut self,
2580        host_path: impl Into<PathBuf>,
2581        vfs_mount: impl Into<PathBuf>,
2582    ) -> Self {
2583        self.real_mounts.push(MountedRealDir {
2584            host_path: host_path.into(),
2585            vfs_mount: Some(vfs_mount.into()),
2586            mode: fs::RealFsMode::ReadOnly,
2587        });
2588        self
2589    }
2590
2591    /// Mount a real host directory with read-write access at the VFS root.
2592    ///
2593    /// **WARNING**: This breaks the sandbox boundary. Scripts can modify files
2594    /// on the host filesystem. Only use when:
2595    /// - The script is fully trusted
2596    /// - The host directory is appropriately scoped
2597    ///
2598    /// Requires the `realfs` feature flag.
2599    ///
2600    /// # Example
2601    ///
2602    /// ```rust,ignore
2603    /// let bash = Bash::builder()
2604    ///     .mount_real_readwrite("/path/to/workspace")
2605    ///     .build();
2606    /// ```
2607    #[cfg(feature = "realfs")]
2608    pub fn mount_real_readwrite(mut self, host_path: impl Into<PathBuf>) -> Self {
2609        self.real_mounts.push(MountedRealDir {
2610            host_path: host_path.into(),
2611            vfs_mount: None,
2612            mode: fs::RealFsMode::ReadWrite,
2613        });
2614        self
2615    }
2616
2617    /// Mount a real host directory with read-write access at a specific VFS path.
2618    ///
2619    /// **WARNING**: This breaks the sandbox boundary. Scripts can modify files
2620    /// on the host filesystem.
2621    ///
2622    /// Requires the `realfs` feature flag.
2623    ///
2624    /// # Example
2625    ///
2626    /// ```rust,ignore
2627    /// let bash = Bash::builder()
2628    ///     .mount_real_readwrite_at("/path/to/workspace", "/mnt/workspace")
2629    ///     .build();
2630    /// ```
2631    #[cfg(feature = "realfs")]
2632    pub fn mount_real_readwrite_at(
2633        mut self,
2634        host_path: impl Into<PathBuf>,
2635        vfs_mount: impl Into<PathBuf>,
2636    ) -> Self {
2637        self.real_mounts.push(MountedRealDir {
2638            host_path: host_path.into(),
2639            vfs_mount: Some(vfs_mount.into()),
2640            mode: fs::RealFsMode::ReadWrite,
2641        });
2642        self
2643    }
2644
2645    /// Set an allowlist of host paths that may be mounted.
2646    ///
2647    /// When set, only host paths starting with an allowed prefix are accepted
2648    /// by `mount_real_*` methods. Paths outside the allowlist are rejected with
2649    /// a warning at build time.
2650    ///
2651    /// # Example
2652    ///
2653    /// ```rust,ignore
2654    /// let bash = Bash::builder()
2655    ///     .allowed_mount_paths(["/home/user/projects", "/tmp"])
2656    ///     .mount_real_readonly("/home/user/projects/data")  // OK
2657    ///     .mount_real_readonly("/etc/passwd")                // rejected
2658    ///     .build();
2659    /// ```
2660    #[cfg(feature = "realfs")]
2661    pub fn allowed_mount_paths(
2662        mut self,
2663        paths: impl IntoIterator<Item = impl Into<PathBuf>>,
2664    ) -> Self {
2665        self.mount_path_allowlist = Some(paths.into_iter().map(|p| p.into()).collect());
2666        self
2667    }
2668
2669    /// Make the final virtual filesystem read-only.
2670    ///
2671    /// This is stronger than mounting real directories read-only: writes to any
2672    /// VFS location fail, including `/tmp`, redirections, `cp`, `mv`, `rm`,
2673    /// `mkdir`, and `chmod`.
2674    pub fn readonly_filesystem(mut self, readonly: bool) -> Self {
2675        self.readonly_filesystem = readonly;
2676        self
2677    }
2678
2679    /// Build the Bash instance.
2680    ///
2681    /// If mounted files are specified, they are added via an [`OverlayFs`] layer
2682    /// on top of the base filesystem. This means:
2683    /// - The base filesystem remains unchanged
2684    /// - Mounted files take precedence over base filesystem files
2685    /// - Works with any filesystem implementation
2686    ///
2687    /// # Example
2688    ///
2689    /// ```rust
2690    /// use bashkit::{Bash, InMemoryFs};
2691    /// use std::sync::Arc;
2692    ///
2693    /// # #[tokio::main]
2694    /// # async fn main() -> bashkit::Result<()> {
2695    /// // Works with default InMemoryFs
2696    /// let mut bash = Bash::builder()
2697    ///     .mount_text("/config/app.conf", "debug=true\n")
2698    ///     .build();
2699    ///
2700    /// // Also works with custom filesystems
2701    /// let custom_fs = Arc::new(InMemoryFs::new());
2702    /// let mut bash = Bash::builder()
2703    ///     .fs(custom_fs)
2704    ///     .mount_text("/config/app.conf", "debug=true\n")
2705    ///     .mount_readonly_text("/etc/version", "1.0.0")
2706    ///     .build();
2707    ///
2708    /// let result = bash.exec("cat /config/app.conf").await?;
2709    /// assert_eq!(result.stdout, "debug=true\n");
2710    /// # Ok(())
2711    /// # }
2712    /// ```
2713    pub fn build(self) -> Bash {
2714        let base_fs: Arc<dyn FileSystem> = if self.shell_profile.is_logic_only() {
2715            Arc::new(fs::DisabledFs)
2716        } else if let Some(fs) = self.fs {
2717            fs
2718        } else {
2719            // No custom filesystem was supplied: provision the default
2720            // in-memory VFS with a home directory for the configured user so
2721            // that `$HOME` / `~` is a real, writable directory. A non-default
2722            // `username("eval")` would otherwise leave HOME=/home/eval pointing
2723            // at a nonexistent directory and writes to `~` fail with "parent
2724            // directory not found". See issue #2128.
2725            let username = self
2726                .username
2727                .as_deref()
2728                .unwrap_or(builtins::DEFAULT_USERNAME);
2729            Arc::new(inmem_fs_with_home(username))
2730        };
2731
2732        // Layer 1: Apply real filesystem mounts (if any)
2733        #[cfg(feature = "realfs")]
2734        let base_fs = Self::apply_real_mounts(
2735            &self.real_mounts,
2736            self.mount_path_allowlist.as_deref(),
2737            base_fs,
2738        );
2739
2740        // Layer 2: If there are mounted text/lazy files, wrap in an OverlayFs
2741        let has_mounts = !self.mounted_files.is_empty() || !self.mounted_lazy_files.is_empty();
2742        let base_fs: Arc<dyn FileSystem> = if has_mounts {
2743            let overlay = OverlayFs::with_limits(base_fs.clone(), base_fs.limits());
2744            for mf in &self.mounted_files {
2745                overlay.upper().add_file(&mf.path, &mf.content, mf.mode);
2746            }
2747            for lf in self.mounted_lazy_files {
2748                overlay
2749                    .upper()
2750                    .add_lazy_file(&lf.path, lf.size_hint, lf.mode, lf.loader);
2751            }
2752            Arc::new(overlay)
2753        } else {
2754            base_fs
2755        };
2756
2757        // Layer 3: Optionally deny all filesystem mutations after setup.
2758        let base_fs: Arc<dyn FileSystem> = if self.readonly_filesystem {
2759            Arc::new(ReadOnlyFs::new(base_fs))
2760        } else {
2761            base_fs
2762        };
2763
2764        // Layer 4: Wrap in MountableFs for post-build live mount/unmount
2765        let mountable = Arc::new(MountableFs::new(base_fs));
2766        let fs: Arc<dyn FileSystem> = Arc::clone(&mountable) as Arc<dyn FileSystem>;
2767
2768        let mut result = Self::build_with_fs(
2769            fs,
2770            mountable,
2771            self.readonly_filesystem,
2772            self.env,
2773            self.username,
2774            self.hostname,
2775            self.fixed_epoch,
2776            self.epoch_offset,
2777            self.cwd,
2778            self.shell_profile,
2779            self.limits,
2780            self.session_limits,
2781            self.memory_limits,
2782            self.trace_mode,
2783            self.trace_callback,
2784            self.custom_builtins,
2785            self.host_builtins,
2786            self.history_file,
2787            #[cfg(feature = "http_client")]
2788            self.network_allowlist,
2789            #[cfg(feature = "http_client")]
2790            self.http_transport,
2791            #[cfg(feature = "bot-auth")]
2792            self.bot_auth_config,
2793            #[cfg(feature = "logging")]
2794            self.log_config,
2795            #[cfg(feature = "git")]
2796            self.git_config,
2797            #[cfg(feature = "ssh")]
2798            self.ssh_config,
2799            #[cfg(feature = "ssh")]
2800            self.ssh_handler,
2801        );
2802
2803        // Set hooks after build — avoids adding another arg to build_with_fs.
2804        let hooks = hooks::Hooks {
2805            on_exit: self.hooks_on_exit,
2806            before_exec: self.hooks_before_exec,
2807            after_exec: self.hooks_after_exec,
2808            before_tool: self.hooks_before_tool,
2809            after_tool: self.hooks_after_tool,
2810            on_error: self.hooks_on_error,
2811        };
2812        if hooks.has_hooks() {
2813            result.interpreter.set_hooks(hooks);
2814        }
2815
2816        // Convert credential policy into a before_http hook.
2817        // Credential hook runs FIRST so subsequent hooks see injected headers.
2818        #[cfg(feature = "http_client")]
2819        let mut hooks_before_http = Vec::new();
2820        #[cfg(feature = "http_client")]
2821        if let Some(policy) = self.credential_policy
2822            && !policy.is_empty()
2823        {
2824            hooks_before_http.push(policy.into_hook());
2825        }
2826        #[cfg(feature = "http_client")]
2827        hooks_before_http.extend(self.hooks_before_http);
2828
2829        // Set HTTP hooks on the HttpClient (transport-level, not interpreter-level)
2830        #[cfg(feature = "http_client")]
2831        if (!hooks_before_http.is_empty() || !self.hooks_after_http.is_empty())
2832            && let Some(client) = result.interpreter.http_client_mut()
2833        {
2834            if !hooks_before_http.is_empty() {
2835                client.set_before_http(hooks_before_http);
2836            }
2837            if !self.hooks_after_http.is_empty() {
2838                client.set_after_http(self.hooks_after_http);
2839            }
2840        }
2841
2842        result
2843    }
2844
2845    /// THREAT[TM-FS-013]: Host prefixes refused as `RealFs` mount targets unless
2846    /// the embedder explicitly allowlists a narrower path under them. Mounting
2847    /// any of these (or a child of them) exposes broad system / kernel /
2848    /// secrets surface to sandboxed scripts via a single mount call.
2849    #[cfg(feature = "realfs")]
2850    const SENSITIVE_MOUNT_PATHS: &[&str] = &[
2851        // Kernel and pseudo-filesystems
2852        "/proc", "/sys", "/dev", // System configuration / secret stores
2853        "/etc", "/boot", // Privileged user directories (whole tree, not just secrets)
2854        "/root", // User home roots — refuse the whole tree; embedder must narrow.
2855        "/Users", "/home", // Runtime / sockets / pid dirs (host IPC surface)
2856        "/run", "/var/run", // macOS canonicalized roots that mirror the above
2857        "/private",
2858    ];
2859
2860    /// THREAT[TM-FS-013]: Path components that always indicate a secret-bearing
2861    /// directory regardless of where they live (typically inside a user home).
2862    /// Any mount whose canonicalized path contains one of these as a component
2863    /// is refused unless explicitly allowlisted.
2864    #[cfg(feature = "realfs")]
2865    const SENSITIVE_PATH_COMPONENTS: &[&str] =
2866        &[".ssh", ".aws", ".kube", ".docker", ".gnupg", ".gcloud"];
2867
2868    /// Returns `true` if `host_path` (already canonicalized) is a sensitive
2869    /// mount target — either the host root itself, a path under one of the
2870    /// `SENSITIVE_MOUNT_PATHS` prefixes, or a path containing a known secret
2871    /// directory component.
2872    #[cfg(feature = "realfs")]
2873    fn is_sensitive_mount_path(host_path: &Path) -> bool {
2874        // Refuse mounting the host root outright. `starts_with("/")` matches
2875        // everything so the prefix check below cannot express this.
2876        if host_path == Path::new("/") {
2877            return true;
2878        }
2879        if Self::SENSITIVE_MOUNT_PATHS
2880            .iter()
2881            .any(|s| host_path.starts_with(Path::new(s)))
2882        {
2883            return true;
2884        }
2885        host_path.components().any(|c| {
2886            let s = c.as_os_str();
2887            Self::SENSITIVE_PATH_COMPONENTS.iter().any(|sec| s == *sec)
2888        })
2889    }
2890
2891    #[cfg(feature = "realfs")]
2892    fn apply_real_mounts(
2893        real_mounts: &[MountedRealDir],
2894        mount_allowlist: Option<&[PathBuf]>,
2895        base_fs: Arc<dyn FileSystem>,
2896    ) -> Arc<dyn FileSystem> {
2897        if real_mounts.is_empty() {
2898            return base_fs;
2899        }
2900
2901        let mut current_fs = base_fs;
2902        let mut mount_points: Vec<(PathBuf, Arc<dyn FileSystem>)> = Vec::new();
2903        let canonical_allowlist: Option<Vec<PathBuf>> = mount_allowlist.map(|allowlist| {
2904            allowlist
2905                .iter()
2906                .filter_map(|allowed| match std::fs::canonicalize(allowed) {
2907                    Ok(path) => Some(path),
2908                    Err(e) => {
2909                        eprintln!(
2910                            "bashkit: warning: failed to canonicalize allowlist path {}: {}",
2911                            allowed.display(),
2912                            e
2913                        );
2914                        None
2915                    }
2916                })
2917                .collect()
2918        });
2919
2920        for m in real_mounts {
2921            // Warn on writable mounts
2922            if m.mode == fs::RealFsMode::ReadWrite {
2923                eprintln!(
2924                    "bashkit: warning: writable mount at {} — scripts can modify host files",
2925                    m.host_path.display()
2926                );
2927            }
2928
2929            let canonical_host = match std::fs::canonicalize(&m.host_path) {
2930                Ok(path) => path,
2931                Err(e) => {
2932                    eprintln!(
2933                        "bashkit: warning: failed to canonicalize mount path {}: {}",
2934                        m.host_path.display(),
2935                        e
2936                    );
2937                    continue;
2938                }
2939            };
2940
2941            // THREAT[TM-FS-013]: Sensitive paths are refused by default. They
2942            // can still be mounted by adding an explicit `allowed_mount_paths`
2943            // entry that covers them.
2944            let is_sensitive = Self::is_sensitive_mount_path(&canonical_host);
2945
2946            if let Some(allowlist) = &canonical_allowlist {
2947                if !allowlist
2948                    .iter()
2949                    .any(|allowed| canonical_host.starts_with(allowed))
2950                {
2951                    eprintln!(
2952                        "bashkit: warning: mount path {} not in allowlist, skipping",
2953                        m.host_path.display()
2954                    );
2955                    continue;
2956                }
2957            } else if is_sensitive {
2958                eprintln!(
2959                    "bashkit: warning: refusing to mount sensitive path {} (no allowlist set; \
2960                     pass an explicit `allowed_mount_paths` entry to override)",
2961                    m.host_path.display()
2962                );
2963                continue;
2964            }
2965
2966            let real_backend = match fs::RealFs::new(&canonical_host, m.mode) {
2967                Ok(b) => b,
2968                Err(e) => {
2969                    eprintln!(
2970                        "bashkit: warning: failed to mount {}: {}",
2971                        m.host_path.display(),
2972                        e
2973                    );
2974                    continue;
2975                }
2976            };
2977            let real_fs: Arc<dyn FileSystem> = Arc::new(PosixFs::new(real_backend));
2978
2979            match &m.vfs_mount {
2980                None => {
2981                    // Overlay at root: real fs becomes the lower layer,
2982                    // existing VFS content overlaid on top
2983                    current_fs = Arc::new(OverlayFs::new(real_fs));
2984                }
2985                Some(mount_point) => {
2986                    mount_points.push((mount_point.clone(), real_fs));
2987                }
2988            }
2989        }
2990
2991        // If there are specific mount points, wrap in MountableFs
2992        if !mount_points.is_empty() {
2993            let mountable = MountableFs::new(current_fs);
2994            for (path, fs) in mount_points {
2995                if let Err(e) = mountable.mount(&path, fs) {
2996                    eprintln!(
2997                        "bashkit: warning: failed to mount at {}: {}",
2998                        path.display(),
2999                        e
3000                    );
3001                }
3002            }
3003            Arc::new(mountable)
3004        } else {
3005            current_fs
3006        }
3007    }
3008
3009    /// Internal helper to build Bash with a configured filesystem.
3010    #[allow(clippy::too_many_arguments)]
3011    fn build_with_fs(
3012        fs: Arc<dyn FileSystem>,
3013        mountable: Arc<MountableFs>,
3014        readonly_filesystem: bool,
3015        env: HashMap<String, String>,
3016        username: Option<String>,
3017        hostname: Option<String>,
3018        fixed_epoch: Option<i64>,
3019        epoch_offset: Option<i64>,
3020        cwd: Option<PathBuf>,
3021        shell_profile: interpreter::ShellProfile,
3022        limits: ExecutionLimits,
3023        session_limits: SessionLimits,
3024        memory_limits: MemoryLimits,
3025        trace_mode: TraceMode,
3026        trace_callback: Option<TraceCallback>,
3027        custom_builtins: HashMap<String, Box<dyn Builtin>>,
3028        host_builtins: Option<BuiltinRegistry>,
3029        history_file: Option<PathBuf>,
3030        #[cfg(feature = "http_client")] network_allowlist: Option<NetworkAllowlist>,
3031        #[cfg(feature = "http_client")] http_transport: Option<Arc<dyn network::HttpTransport>>,
3032        #[cfg(feature = "bot-auth")] bot_auth_config: Option<network::BotAuthConfig>,
3033        #[cfg(feature = "logging")] log_config: Option<logging::LogConfig>,
3034        #[cfg(feature = "git")] git_config: Option<GitConfig>,
3035        #[cfg(feature = "ssh")] ssh_config: Option<SshConfig>,
3036        #[cfg(feature = "ssh")] ssh_handler: Option<Box<dyn builtins::ssh::SshHandler>>,
3037    ) -> Bash {
3038        #[cfg(feature = "logging")]
3039        let log_config = log_config.unwrap_or_default();
3040
3041        #[cfg(feature = "logging")]
3042        tracing::debug!(
3043            target: "bashkit::config",
3044            redact_sensitive = log_config.redact_sensitive,
3045            log_scripts = log_config.log_script_content,
3046            "Bash instance configured"
3047        );
3048
3049        let mut interpreter = Interpreter::with_config(
3050            Arc::clone(&fs),
3051            username.clone(),
3052            hostname,
3053            fixed_epoch,
3054            epoch_offset,
3055            custom_builtins,
3056            host_builtins,
3057            shell_profile,
3058        );
3059
3060        // Set environment variables (also override shell variable defaults)
3061        for (key, value) in &env {
3062            interpreter.set_env(key, value);
3063            // Shell variables like HOME, USER should also be set as variables
3064            // so they take precedence over the defaults
3065            interpreter.set_var(key, value);
3066        }
3067        #[cfg(feature = "python")]
3068        let python_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_PYTHON");
3069        #[cfg(feature = "sqlite")]
3070        let sqlite_inprocess_opt_in = env_opt_in_enabled(&env, "BASHKIT_ALLOW_INPROCESS_SQLITE");
3071        drop(env);
3072
3073        // If username is set, automatically set USER env var
3074        if let Some(ref username) = username {
3075            interpreter.set_env("USER", username);
3076            interpreter.set_var("USER", username);
3077        }
3078
3079        if let Some(cwd) = cwd {
3080            interpreter.set_cwd(cwd);
3081        }
3082
3083        // Configure HTTP client for network builtins
3084        #[cfg(feature = "http_client")]
3085        if let Some(allowlist) = network_allowlist {
3086            let mut client = network::HttpClient::new(allowlist);
3087            if let Some(transport) = http_transport {
3088                client.set_transport(transport);
3089            }
3090            #[cfg(feature = "bot-auth")]
3091            if let Some(bot_auth) = bot_auth_config {
3092                client.set_bot_auth(bot_auth);
3093            }
3094            interpreter.set_http_client(client);
3095        }
3096
3097        // Configure git client for git builtins
3098        #[cfg(feature = "git")]
3099        if let Some(config) = git_config {
3100            let client = builtins::git::GitClient::new(config);
3101            interpreter.set_git_client(client);
3102        }
3103
3104        // Configure SSH client for ssh/scp/sftp builtins
3105        #[cfg(feature = "ssh")]
3106        if let Some(config) = ssh_config {
3107            let mut client = builtins::ssh::SshClient::new(config);
3108            if let Some(handler) = ssh_handler {
3109                client.set_handler(handler);
3110            }
3111            interpreter.set_ssh_client(client);
3112        }
3113
3114        // Configure persistent history file
3115        if let Some(hf) = history_file {
3116            interpreter.set_history_file(hf);
3117        }
3118
3119        let parser_timeout = limits.parser_timeout;
3120        let max_input_bytes = limits.max_input_bytes;
3121        let max_ast_depth = limits.max_ast_depth;
3122        let max_parser_operations = limits.max_parser_operations;
3123        interpreter.set_limits(limits);
3124        interpreter.set_session_limits(session_limits);
3125        interpreter.set_memory_limits(memory_limits);
3126        let mut trace_collector = TraceCollector::new(trace_mode);
3127        if let Some(cb) = trace_callback {
3128            trace_collector.set_callback(cb);
3129        }
3130        interpreter.set_trace(trace_collector);
3131        Bash {
3132            fs,
3133            mountable,
3134            readonly_filesystem,
3135            interpreter,
3136            parser_timeout,
3137            max_input_bytes,
3138            max_ast_depth,
3139            max_parser_operations,
3140            #[cfg(feature = "logging")]
3141            log_config,
3142            #[cfg(feature = "python")]
3143            python_inprocess_opt_in,
3144            #[cfg(feature = "sqlite")]
3145            sqlite_inprocess_opt_in,
3146        }
3147    }
3148}
3149
3150// =============================================================================
3151// Documentation Modules
3152// =============================================================================
3153// These modules embed external markdown guides into rustdoc.
3154// Source files live in crates/bashkit/docs/ - edit there, not here.
3155// See specs/documentation.md for the documentation approach.
3156
3157/// Guide for transparent credential injection in outbound HTTP requests.
3158///
3159/// Two modes: **injection** (script unaware) and **placeholder** (opaque
3160/// env var replaced on the wire). Credentials are scoped per URL pattern
3161/// and never visible to sandboxed scripts.
3162///
3163/// **Related:** [`BashBuilder::credential`], [`BashBuilder::credential_placeholder`],
3164/// [`Credential`], [`NetworkAllowlist`], [`threat_model`]
3165#[cfg(feature = "http_client")]
3166#[doc = include_str!("../docs/credential-injection.md")]
3167pub mod credential_injection_guide {}
3168
3169/// Guide for creating custom builtins to extend Bashkit.
3170///
3171/// This guide covers:
3172/// - Implementing the [`Builtin`] trait
3173/// - Accessing execution context ([`BuiltinContext`])
3174/// - Working with arguments, environment, and filesystem
3175/// - Best practices and examples
3176///
3177/// **Related:** [`BashBuilder::builtin`], [`compatibility_scorecard`]
3178#[doc = include_str!("../docs/custom_builtins.md")]
3179pub mod custom_builtins_guide {}
3180
3181/// Public guide for clap-backed custom builtins.
3182///
3183/// This guide covers:
3184/// - Implementing [`ClapBuiltin`] with `#[derive(clap::Parser)]`
3185/// - Writing stdout/stderr through [`BashkitContext`]
3186/// - Help, version, and parse-error behavior
3187/// - Subcommands and pipeline stdin
3188///
3189/// **Related:** [`ClapBuiltin`], [`BashkitContext`], [`BashBuilder::builtin`], [`custom_builtins_guide`]
3190#[doc = include_str!("../docs/clap-builtins.md")]
3191pub mod clap_builtins_guide {}
3192
3193/// Bash compatibility scorecard.
3194///
3195/// Tracks feature parity with real bash:
3196/// - Implemented vs missing features
3197/// - Builtins, syntax, expansions
3198/// - POSIX compliance status
3199/// - Resource limits
3200///
3201/// **Related:** [`custom_builtins_guide`], [`threat_model`]
3202#[doc = include_str!("../docs/compatibility.md")]
3203pub mod compatibility_scorecard {}
3204
3205/// jq builtin: supported filters, flags, and variables.
3206///
3207/// **Topics covered:**
3208/// - Implemented command-line flags
3209/// - Variables (including `$ENV`)
3210/// - Notable filters and the bashkit compatibility shim
3211/// - Known gaps where bashkit's input model differs from upstream jq
3212///
3213/// **Related:** [`compatibility_scorecard`], [`threat_model`]
3214#[doc = include_str!("../docs/jq.md")]
3215pub mod jq_guide {}
3216
3217/// Security threat model guide.
3218///
3219/// This guide documents security threats addressed by Bashkit and their mitigations.
3220/// All threats use stable IDs for tracking and code references.
3221///
3222/// **Topics covered:**
3223/// - Denial of Service mitigations (TM-DOS-*)
3224/// - Sandbox escape prevention (TM-ESC-*)
3225/// - Information disclosure protection (TM-INF-*)
3226/// - Network security controls (TM-NET-*)
3227/// - Multi-tenant isolation (TM-ISO-*)
3228///
3229/// **Related:** [`ExecutionLimits`], [`FsLimits`], [`NetworkAllowlist`]
3230#[doc = include_str!("../docs/threat-model.md")]
3231pub mod threat_model {}
3232
3233/// Guide for embedded Python via the Monty interpreter.
3234///
3235/// **Experimental:** The Monty integration is experimental with known security
3236/// issues. See the guide below and [`threat_model`] for details.
3237///
3238/// This guide covers:
3239/// - Enabling Python with [`BashBuilder::python`]
3240/// - VFS bridging (`pathlib.Path` → virtual filesystem)
3241/// - Configuring resource limits with [`PythonLimits`]
3242/// - LLM tool integration via [`BashToolBuilder::python`]
3243/// - Known limitations (no `open()`, no HTTP, no classes)
3244///
3245/// **Related:** [`BashBuilder::python`], [`PythonLimits`], [`threat_model`]
3246#[cfg(feature = "python")]
3247#[doc = include_str!("../docs/python.md")]
3248pub mod python_guide {}
3249
3250/// Guide for the embedded SQLite builtin (Turso).
3251///
3252/// Topics covered:
3253/// - Quick start with `Bash::builder().sqlite()`
3254/// - Memory vs VFS backends
3255/// - `:memory:` databases
3256/// - Output modes (list, csv, tabs, line, column, box, json, markdown)
3257/// - Dot-commands (`.tables`, `.schema`, `.dump`, `.read`, …)
3258/// - Resource limits and security model
3259///
3260/// **Related:** [`BashBuilder::sqlite`], [`SqliteLimits`], [`SqliteBackend`], [`threat_model`]
3261#[cfg(feature = "sqlite")]
3262#[doc = include_str!("../docs/sqlite.md")]
3263pub mod sqlite_guide {}
3264
3265/// Guide for embedded TypeScript execution via the ZapCode interpreter.
3266///
3267/// This guide covers:
3268/// - Quick start with `Bash::builder().typescript()`
3269/// - Inline code, script files, pipelines
3270/// - VFS bridging via `readFile()`/`writeFile()` external functions
3271/// - Resource limits via `TypeScriptLimits`
3272/// - Configuration via `TypeScriptConfig` (compat aliases, unsupported-mode hints)
3273/// - LLM tool integration
3274///
3275/// **Related:** [`BashBuilder::typescript`], [`TypeScriptLimits`], [`TypeScriptConfig`], [`threat_model`]
3276#[cfg(feature = "typescript")]
3277#[doc = include_str!("../docs/typescript.md")]
3278pub mod typescript_guide {}
3279
3280/// Guide for SSH/SCP/SFTP remote operations.
3281///
3282/// **Related:** [`BashBuilder::ssh`], [`SshConfig`], [`SshAllowlist`], [`threat_model`]
3283#[cfg(feature = "ssh")]
3284#[doc = include_str!("../docs/ssh.md")]
3285pub mod ssh_guide {}
3286
3287/// Guide for live mount/unmount on a running Bash instance.
3288///
3289/// This guide covers:
3290/// - Attaching/detaching filesystems post-build
3291/// - State preservation across mount operations
3292/// - Hot-swapping mounted filesystems
3293/// - Layered filesystem architecture
3294///
3295/// **Related:** [`Bash::mount`], [`Bash::unmount`], [`MountableFs`], [`BashBuilder::mount_text`]
3296#[doc = include_str!("../docs/live_mounts.md")]
3297pub mod live_mounts_guide {}
3298
3299/// Guide to composing static filesystem namespaces.
3300#[doc = include_str!("../docs/namespace_filesystems.md")]
3301pub mod namespace_filesystems_guide {}
3302
3303/// Logging guide for Bashkit.
3304///
3305/// This guide covers configuring structured logging, log levels, security
3306/// considerations, and integration with tracing subscribers.
3307///
3308/// **Topics covered:**
3309/// - Enabling the `logging` feature
3310/// - Log levels and targets
3311/// - Security: sensitive data redaction (TM-LOG-*)
3312/// - Integration with tracing-subscriber
3313///
3314/// **Related:** [`LogConfig`], [`threat_model`]
3315#[cfg(feature = "logging")]
3316#[doc = include_str!("../docs/logging.md")]
3317pub mod logging_guide {}
3318
3319/// Interceptor hooks guide for Bashkit.
3320///
3321/// This guide covers the hook system for observing, modifying, and cancelling
3322/// operations at key points in the execution pipeline.
3323///
3324/// **Topics covered:**
3325/// - Execution hooks (`before_exec`, `after_exec`)
3326/// - Tool hooks (`before_tool`, `after_tool`)
3327/// - Lifecycle hooks (`on_exit`, `on_error`)
3328/// - HTTP hooks (`before_http`, `after_http`)
3329/// - Chaining multiple hooks
3330/// - Event payloads and thread safety
3331///
3332/// **Related:** [`BashBuilder`], [`hooks`], [`custom_builtins_guide`]
3333#[doc = include_str!("../docs/hooks.md")]
3334pub mod hooks_guide {}
3335
3336#[cfg(test)]
3337mod tests {
3338    use super::*;
3339    use std::sync::{Arc, Mutex};
3340
3341    #[tokio::test]
3342    async fn test_echo_hello() {
3343        let mut bash = Bash::new();
3344        let result = bash.exec("echo hello").await.unwrap();
3345        assert_eq!(result.stdout, "hello\n");
3346        assert_eq!(result.exit_code, 0);
3347    }
3348
3349    #[tokio::test]
3350    async fn test_echo_multiple_args() {
3351        let mut bash = Bash::new();
3352        let result = bash.exec("echo hello world").await.unwrap();
3353        assert_eq!(result.stdout, "hello world\n");
3354        assert_eq!(result.exit_code, 0);
3355    }
3356
3357    #[tokio::test]
3358    async fn test_variable_expansion() {
3359        let mut bash = Bash::builder().env("HOME", "/home/user").build();
3360        let result = bash.exec("echo $HOME").await.unwrap();
3361        assert_eq!(result.stdout, "/home/user\n");
3362        assert_eq!(result.exit_code, 0);
3363    }
3364
3365    #[tokio::test]
3366    async fn test_variable_brace_expansion() {
3367        let mut bash = Bash::builder().env("USER", "testuser").build();
3368        let result = bash.exec("echo ${USER}").await.unwrap();
3369        assert_eq!(result.stdout, "testuser\n");
3370    }
3371
3372    #[tokio::test]
3373    async fn test_undefined_variable_expands_to_empty() {
3374        let mut bash = Bash::new();
3375        let result = bash.exec("echo $UNDEFINED_VAR").await.unwrap();
3376        assert_eq!(result.stdout, "\n");
3377    }
3378
3379    #[tokio::test]
3380    async fn test_pipeline() {
3381        let mut bash = Bash::new();
3382        let result = bash.exec("echo hello | cat").await.unwrap();
3383        assert_eq!(result.stdout, "hello\n");
3384    }
3385
3386    #[tokio::test(start_paused = true)]
3387    async fn test_timed_out_bash_c_does_not_leak_stdin_to_next_exec() {
3388        let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
3389        let mut bash = Bash::builder().limits(limits).build();
3390
3391        let timed_out = bash.exec("printf secret | bash -c 'sleep 10'").await;
3392        assert!(matches!(
3393            timed_out,
3394            Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
3395        ));
3396
3397        let result = bash.exec("cat").await.unwrap();
3398        assert_eq!(result.stdout, "");
3399    }
3400
3401    #[tokio::test(start_paused = true)]
3402    async fn test_timed_out_fd3_capture_does_not_leak_to_next_exec() {
3403        let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
3404        let mut bash = Bash::builder().limits(limits).build();
3405
3406        let timed_out = bash.exec("{ sleep 10; } 3>&1 > /tmp/poison.txt").await;
3407        assert!(matches!(
3408            timed_out,
3409            Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
3410        ));
3411
3412        let hidden = bash.exec("echo SECRET_FROM_EXEC2 1>&3").await.unwrap();
3413        assert_eq!(hidden.stdout, "");
3414
3415        let routed = bash
3416            .exec("echo PUBLIC_FROM_EXEC3 2>&1 > /tmp/public.txt")
3417            .await
3418            .unwrap();
3419        assert_eq!(routed.stdout, "");
3420
3421        let file = bash.exec("cat /tmp/public.txt").await.unwrap();
3422        assert_eq!(file.stdout, "PUBLIC_FROM_EXEC3\n");
3423    }
3424
3425    #[tokio::test(start_paused = true)]
3426    async fn test_timed_out_debug_trap_does_not_suppress_next_exec_debug_trap() {
3427        let limits = ExecutionLimits::new().timeout(std::time::Duration::from_millis(1));
3428        let mut bash = Bash::builder().limits(limits).build();
3429
3430        let timed_out = bash
3431            .exec("trap 'sleep 10' DEBUG; echo should-not-run")
3432            .await;
3433        assert!(matches!(
3434            timed_out,
3435            Err(Error::ResourceLimit(LimitExceeded::Timeout(_)))
3436        ));
3437
3438        let result = bash
3439            .exec("count=0; trap '((count++))' DEBUG; echo body; trap - DEBUG; echo $count")
3440            .await
3441            .unwrap();
3442        assert_eq!(result.stdout, "body\n2\n");
3443    }
3444
3445    #[tokio::test]
3446    async fn test_pipeline_three_commands() {
3447        let mut bash = Bash::new();
3448        let result = bash.exec("echo hello | cat | cat").await.unwrap();
3449        assert_eq!(result.stdout, "hello\n");
3450    }
3451
3452    #[tokio::test]
3453    async fn test_redirect_output() {
3454        let mut bash = Bash::new();
3455        let result = bash.exec("echo hello > /tmp/test.txt").await.unwrap();
3456        assert_eq!(result.stdout, "");
3457        assert_eq!(result.exit_code, 0);
3458
3459        // Read the file back
3460        let result = bash.exec("cat /tmp/test.txt").await.unwrap();
3461        assert_eq!(result.stdout, "hello\n");
3462    }
3463
3464    #[tokio::test]
3465    async fn test_redirect_append() {
3466        let mut bash = Bash::new();
3467        bash.exec("echo hello > /tmp/append.txt").await.unwrap();
3468        bash.exec("echo world >> /tmp/append.txt").await.unwrap();
3469
3470        let result = bash.exec("cat /tmp/append.txt").await.unwrap();
3471        assert_eq!(result.stdout, "hello\nworld\n");
3472    }
3473
3474    #[tokio::test]
3475    async fn test_command_list_and() {
3476        let mut bash = Bash::new();
3477        let result = bash.exec("true && echo success").await.unwrap();
3478        assert_eq!(result.stdout, "success\n");
3479    }
3480
3481    #[tokio::test]
3482    async fn test_command_list_and_short_circuit() {
3483        let mut bash = Bash::new();
3484        let result = bash.exec("false && echo should_not_print").await.unwrap();
3485        assert_eq!(result.stdout, "");
3486        assert_eq!(result.exit_code, 1);
3487    }
3488
3489    #[tokio::test]
3490    async fn test_command_list_or() {
3491        let mut bash = Bash::new();
3492        let result = bash.exec("false || echo fallback").await.unwrap();
3493        assert_eq!(result.stdout, "fallback\n");
3494    }
3495
3496    #[tokio::test]
3497    async fn test_command_list_or_short_circuit() {
3498        let mut bash = Bash::new();
3499        let result = bash.exec("true || echo should_not_print").await.unwrap();
3500        assert_eq!(result.stdout, "");
3501        assert_eq!(result.exit_code, 0);
3502    }
3503
3504    /// Phase 1 target test: `echo $HOME | cat > /tmp/out && cat /tmp/out`
3505    #[tokio::test]
3506    async fn test_phase1_target() {
3507        let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
3508
3509        let result = bash
3510            .exec("echo $HOME | cat > /tmp/out && cat /tmp/out")
3511            .await
3512            .unwrap();
3513
3514        assert_eq!(result.stdout, "/home/testuser\n");
3515        assert_eq!(result.exit_code, 0);
3516    }
3517
3518    #[tokio::test]
3519    async fn test_redirect_input() {
3520        let mut bash = Bash::new();
3521        // Create a file first
3522        bash.exec("echo hello > /tmp/input.txt").await.unwrap();
3523
3524        // Read it using input redirection
3525        let result = bash.exec("cat < /tmp/input.txt").await.unwrap();
3526        assert_eq!(result.stdout, "hello\n");
3527    }
3528
3529    #[tokio::test]
3530    async fn test_here_string() {
3531        let mut bash = Bash::new();
3532        let result = bash.exec("cat <<< hello").await.unwrap();
3533        assert_eq!(result.stdout, "hello\n");
3534    }
3535
3536    #[tokio::test]
3537    async fn test_if_true() {
3538        let mut bash = Bash::new();
3539        let result = bash.exec("if true; then echo yes; fi").await.unwrap();
3540        assert_eq!(result.stdout, "yes\n");
3541    }
3542
3543    #[tokio::test]
3544    async fn test_if_false() {
3545        let mut bash = Bash::new();
3546        let result = bash.exec("if false; then echo yes; fi").await.unwrap();
3547        assert_eq!(result.stdout, "");
3548    }
3549
3550    #[tokio::test]
3551    async fn test_if_else() {
3552        let mut bash = Bash::new();
3553        let result = bash
3554            .exec("if false; then echo yes; else echo no; fi")
3555            .await
3556            .unwrap();
3557        assert_eq!(result.stdout, "no\n");
3558    }
3559
3560    #[tokio::test]
3561    async fn test_if_elif() {
3562        let mut bash = Bash::new();
3563        let result = bash
3564            .exec("if false; then echo one; elif true; then echo two; else echo three; fi")
3565            .await
3566            .unwrap();
3567        assert_eq!(result.stdout, "two\n");
3568    }
3569
3570    #[tokio::test]
3571    async fn test_for_loop() {
3572        let mut bash = Bash::new();
3573        let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
3574        assert_eq!(result.stdout, "a\nb\nc\n");
3575    }
3576
3577    #[tokio::test]
3578    async fn test_for_loop_positional_params() {
3579        let mut bash = Bash::new();
3580        // for x; do ... done iterates over positional parameters inside a function
3581        let result = bash
3582            .exec("f() { for x; do echo $x; done; }; f one two three")
3583            .await
3584            .unwrap();
3585        assert_eq!(result.stdout, "one\ntwo\nthree\n");
3586    }
3587
3588    #[tokio::test]
3589    async fn test_while_loop() {
3590        let mut bash = Bash::new();
3591        // While with false condition - executes 0 times
3592        let result = bash.exec("while false; do echo loop; done").await.unwrap();
3593        assert_eq!(result.stdout, "");
3594    }
3595
3596    #[tokio::test]
3597    async fn test_subshell() {
3598        let mut bash = Bash::new();
3599        let result = bash.exec("(echo hello)").await.unwrap();
3600        assert_eq!(result.stdout, "hello\n");
3601    }
3602
3603    #[tokio::test]
3604    async fn test_brace_group() {
3605        let mut bash = Bash::new();
3606        let result = bash.exec("{ echo hello; }").await.unwrap();
3607        assert_eq!(result.stdout, "hello\n");
3608    }
3609
3610    #[tokio::test]
3611    async fn test_function_keyword() {
3612        let mut bash = Bash::new();
3613        let result = bash
3614            .exec("function greet { echo hello; }; greet")
3615            .await
3616            .unwrap();
3617        assert_eq!(result.stdout, "hello\n");
3618    }
3619
3620    #[tokio::test]
3621    async fn test_function_posix() {
3622        let mut bash = Bash::new();
3623        let result = bash.exec("greet() { echo hello; }; greet").await.unwrap();
3624        assert_eq!(result.stdout, "hello\n");
3625    }
3626
3627    #[tokio::test]
3628    async fn test_function_args() {
3629        let mut bash = Bash::new();
3630        let result = bash
3631            .exec("greet() { echo $1 $2; }; greet world foo")
3632            .await
3633            .unwrap();
3634        assert_eq!(result.stdout, "world foo\n");
3635    }
3636
3637    #[tokio::test]
3638    async fn test_function_arg_count() {
3639        let mut bash = Bash::new();
3640        let result = bash
3641            .exec("count() { echo $#; }; count a b c")
3642            .await
3643            .unwrap();
3644        assert_eq!(result.stdout, "3\n");
3645    }
3646
3647    #[tokio::test]
3648    async fn test_case_literal() {
3649        let mut bash = Bash::new();
3650        let result = bash
3651            .exec("case foo in foo) echo matched ;; esac")
3652            .await
3653            .unwrap();
3654        assert_eq!(result.stdout, "matched\n");
3655    }
3656
3657    #[tokio::test]
3658    async fn test_case_wildcard() {
3659        let mut bash = Bash::new();
3660        let result = bash
3661            .exec("case bar in *) echo default ;; esac")
3662            .await
3663            .unwrap();
3664        assert_eq!(result.stdout, "default\n");
3665    }
3666
3667    #[tokio::test]
3668    async fn test_case_no_match() {
3669        let mut bash = Bash::new();
3670        let result = bash.exec("case foo in bar) echo no ;; esac").await.unwrap();
3671        assert_eq!(result.stdout, "");
3672    }
3673
3674    #[tokio::test]
3675    async fn test_case_multiple_patterns() {
3676        let mut bash = Bash::new();
3677        let result = bash
3678            .exec("case foo in bar|foo|baz) echo matched ;; esac")
3679            .await
3680            .unwrap();
3681        assert_eq!(result.stdout, "matched\n");
3682    }
3683
3684    #[tokio::test]
3685    async fn test_case_bracket_expr() {
3686        let mut bash = Bash::new();
3687        // Test [abc] bracket expression
3688        let result = bash
3689            .exec("case b in [abc]) echo matched ;; esac")
3690            .await
3691            .unwrap();
3692        assert_eq!(result.stdout, "matched\n");
3693    }
3694
3695    #[tokio::test]
3696    async fn test_case_bracket_range() {
3697        let mut bash = Bash::new();
3698        // Test [a-z] range expression
3699        let result = bash
3700            .exec("case m in [a-z]) echo letter ;; esac")
3701            .await
3702            .unwrap();
3703        assert_eq!(result.stdout, "letter\n");
3704    }
3705
3706    #[tokio::test]
3707    async fn test_case_bracket_wide_unicode_range() {
3708        let mut bash = Bash::new();
3709        let result = bash
3710            .exec("case z in [a-\u{10ffff}]) echo wide ;; esac")
3711            .await
3712            .unwrap();
3713        assert_eq!(result.stdout, "wide\n");
3714    }
3715
3716    #[tokio::test]
3717    async fn test_case_bracket_negation() {
3718        let mut bash = Bash::new();
3719        // Test [!abc] negation
3720        let result = bash
3721            .exec("case x in [!abc]) echo not_abc ;; esac")
3722            .await
3723            .unwrap();
3724        assert_eq!(result.stdout, "not_abc\n");
3725    }
3726
3727    #[tokio::test]
3728    async fn test_break_as_command() {
3729        let mut bash = Bash::new();
3730        // Just run break alone - should not error
3731        let result = bash.exec("break").await.unwrap();
3732        // break outside of loop returns success with no output
3733        assert_eq!(result.exit_code, 0);
3734    }
3735
3736    #[tokio::test]
3737    async fn test_for_one_item() {
3738        let mut bash = Bash::new();
3739        // Simple for loop with one item
3740        let result = bash.exec("for i in a; do echo $i; done").await.unwrap();
3741        assert_eq!(result.stdout, "a\n");
3742    }
3743
3744    #[tokio::test]
3745    async fn test_for_with_break() {
3746        let mut bash = Bash::new();
3747        // For loop with break
3748        let result = bash.exec("for i in a; do break; done").await.unwrap();
3749        assert_eq!(result.stdout, "");
3750        assert_eq!(result.exit_code, 0);
3751    }
3752
3753    #[tokio::test]
3754    async fn test_for_echo_break() {
3755        let mut bash = Bash::new();
3756        // For loop with echo then break - tests the semicolon command list in body
3757        let result = bash
3758            .exec("for i in a b c; do echo $i; break; done")
3759            .await
3760            .unwrap();
3761        assert_eq!(result.stdout, "a\n");
3762    }
3763
3764    #[tokio::test]
3765    async fn test_test_string_empty() {
3766        let mut bash = Bash::new();
3767        let result = bash.exec("test -z '' && echo yes").await.unwrap();
3768        assert_eq!(result.stdout, "yes\n");
3769    }
3770
3771    #[tokio::test]
3772    async fn test_test_string_not_empty() {
3773        let mut bash = Bash::new();
3774        let result = bash.exec("test -n 'hello' && echo yes").await.unwrap();
3775        assert_eq!(result.stdout, "yes\n");
3776    }
3777
3778    #[tokio::test]
3779    async fn test_test_string_equal() {
3780        let mut bash = Bash::new();
3781        let result = bash.exec("test foo = foo && echo yes").await.unwrap();
3782        assert_eq!(result.stdout, "yes\n");
3783    }
3784
3785    #[tokio::test]
3786    async fn test_test_string_not_equal() {
3787        let mut bash = Bash::new();
3788        let result = bash.exec("test foo != bar && echo yes").await.unwrap();
3789        assert_eq!(result.stdout, "yes\n");
3790    }
3791
3792    #[tokio::test]
3793    async fn test_test_numeric_equal() {
3794        let mut bash = Bash::new();
3795        let result = bash.exec("test 5 -eq 5 && echo yes").await.unwrap();
3796        assert_eq!(result.stdout, "yes\n");
3797    }
3798
3799    #[tokio::test]
3800    async fn test_test_numeric_less_than() {
3801        let mut bash = Bash::new();
3802        let result = bash.exec("test 3 -lt 5 && echo yes").await.unwrap();
3803        assert_eq!(result.stdout, "yes\n");
3804    }
3805
3806    #[tokio::test]
3807    async fn test_bracket_form() {
3808        let mut bash = Bash::new();
3809        let result = bash.exec("[ foo = foo ] && echo yes").await.unwrap();
3810        assert_eq!(result.stdout, "yes\n");
3811    }
3812
3813    #[tokio::test]
3814    async fn test_if_with_test() {
3815        let mut bash = Bash::new();
3816        let result = bash
3817            .exec("if [ 5 -gt 3 ]; then echo bigger; fi")
3818            .await
3819            .unwrap();
3820        assert_eq!(result.stdout, "bigger\n");
3821    }
3822
3823    #[tokio::test]
3824    async fn test_variable_assignment() {
3825        let mut bash = Bash::new();
3826        let result = bash.exec("FOO=bar; echo $FOO").await.unwrap();
3827        assert_eq!(result.stdout, "bar\n");
3828    }
3829
3830    #[tokio::test]
3831    async fn test_variable_assignment_inline() {
3832        let mut bash = Bash::new();
3833        // Assignment before command
3834        let result = bash.exec("MSG=hello; echo $MSG world").await.unwrap();
3835        assert_eq!(result.stdout, "hello world\n");
3836    }
3837
3838    #[tokio::test]
3839    async fn test_variable_assignment_only() {
3840        let mut bash = Bash::new();
3841        // Assignment without command should succeed silently
3842        let result = bash.exec("FOO=bar").await.unwrap();
3843        assert_eq!(result.stdout, "");
3844        assert_eq!(result.exit_code, 0);
3845
3846        // Verify the variable was set
3847        let result = bash.exec("echo $FOO").await.unwrap();
3848        assert_eq!(result.stdout, "bar\n");
3849    }
3850
3851    #[tokio::test]
3852    async fn test_multiple_assignments() {
3853        let mut bash = Bash::new();
3854        let result = bash.exec("A=1; B=2; C=3; echo $A $B $C").await.unwrap();
3855        assert_eq!(result.stdout, "1 2 3\n");
3856    }
3857
3858    #[tokio::test]
3859    async fn test_prefix_assignment_visible_in_env() {
3860        let mut bash = Bash::new();
3861        // VAR=value command should make VAR visible in the command's environment
3862        let result = bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
3863        assert_eq!(result.stdout, "hello\n");
3864    }
3865
3866    #[tokio::test]
3867    async fn test_prefix_assignment_temporary() {
3868        let mut bash = Bash::new();
3869        // Prefix assignment should NOT persist after the command
3870        bash.exec("MYVAR=hello printenv MYVAR").await.unwrap();
3871        let result = bash.exec("echo ${MYVAR:-unset}").await.unwrap();
3872        assert_eq!(result.stdout, "unset\n");
3873    }
3874
3875    #[tokio::test]
3876    async fn test_prefix_assignment_duplicate_name_temporary() {
3877        let mut bash = Bash::new();
3878        // Duplicate prefix assignments should still restore original env.
3879        let result = bash.exec("A=1 A=2 printenv A").await.unwrap();
3880        assert_eq!(result.stdout, "2\n");
3881        let result = bash.exec("echo ${A:-unset}").await.unwrap();
3882        assert_eq!(result.stdout, "unset\n");
3883    }
3884
3885    #[tokio::test]
3886    async fn test_prefix_assignment_does_not_clobber_existing_env() {
3887        let mut bash = Bash::new();
3888        // Set up existing env var
3889        let result = bash
3890            .exec("EXISTING=original; export EXISTING; EXISTING=temp printenv EXISTING")
3891            .await
3892            .unwrap();
3893        assert_eq!(result.stdout, "temp\n");
3894    }
3895
3896    #[tokio::test]
3897    async fn test_prefix_assignment_multiple_vars() {
3898        let mut bash = Bash::new();
3899        // Multiple prefix assignments on same command
3900        let result = bash.exec("A=one B=two printenv A").await.unwrap();
3901        assert_eq!(result.stdout, "one\n");
3902        assert_eq!(result.exit_code, 0);
3903    }
3904
3905    #[tokio::test]
3906    async fn test_prefix_assignment_empty_value() {
3907        let mut bash = Bash::new();
3908        // Empty value is still set in environment
3909        let result = bash.exec("MYVAR= printenv MYVAR").await.unwrap();
3910        assert_eq!(result.stdout, "\n");
3911        assert_eq!(result.exit_code, 0);
3912    }
3913
3914    #[tokio::test]
3915    async fn test_prefix_assignment_not_found_without_prefix() {
3916        let mut bash = Bash::new();
3917        // printenv for a var that was never set should fail
3918        let result = bash.exec("printenv NONEXISTENT").await.unwrap();
3919        assert_eq!(result.stdout, "");
3920        assert_eq!(result.exit_code, 1);
3921    }
3922
3923    #[tokio::test]
3924    async fn test_prefix_assignment_does_not_persist_in_variables() {
3925        let mut bash = Bash::new();
3926        // After prefix assignment with command, var should not be in shell scope
3927        bash.exec("TMPVAR=gone echo ok").await.unwrap();
3928        let result = bash.exec("echo \"${TMPVAR:-unset}\"").await.unwrap();
3929        assert_eq!(result.stdout, "unset\n");
3930    }
3931
3932    #[tokio::test]
3933    async fn test_assignment_only_persists() {
3934        let mut bash = Bash::new();
3935        // Assignment without a command should persist (not a prefix assignment)
3936        bash.exec("PERSIST=yes").await.unwrap();
3937        let result = bash.exec("echo $PERSIST").await.unwrap();
3938        assert_eq!(result.stdout, "yes\n");
3939    }
3940
3941    #[tokio::test]
3942    async fn test_printf_string() {
3943        let mut bash = Bash::new();
3944        let result = bash.exec("printf '%s' hello").await.unwrap();
3945        assert_eq!(result.stdout, "hello");
3946    }
3947
3948    #[tokio::test]
3949    async fn test_printf_newline() {
3950        let mut bash = Bash::new();
3951        let result = bash.exec("printf 'hello\\n'").await.unwrap();
3952        assert_eq!(result.stdout, "hello\n");
3953    }
3954
3955    #[tokio::test]
3956    async fn test_printf_multiple_args() {
3957        let mut bash = Bash::new();
3958        let result = bash.exec("printf '%s %s\\n' hello world").await.unwrap();
3959        assert_eq!(result.stdout, "hello world\n");
3960    }
3961
3962    #[tokio::test]
3963    async fn test_printf_integer() {
3964        let mut bash = Bash::new();
3965        let result = bash.exec("printf '%d' 42").await.unwrap();
3966        assert_eq!(result.stdout, "42");
3967    }
3968
3969    #[tokio::test]
3970    async fn test_export() {
3971        let mut bash = Bash::new();
3972        let result = bash.exec("export FOO=bar; echo $FOO").await.unwrap();
3973        assert_eq!(result.stdout, "bar\n");
3974    }
3975
3976    #[tokio::test]
3977    async fn test_read_basic() {
3978        let mut bash = Bash::new();
3979        let result = bash.exec("echo hello | read VAR; echo $VAR").await.unwrap();
3980        assert_eq!(result.stdout, "hello\n");
3981    }
3982
3983    #[tokio::test]
3984    async fn test_read_multiple_vars() {
3985        let mut bash = Bash::new();
3986        let result = bash
3987            .exec("echo 'a b c' | read X Y Z; echo $X $Y $Z")
3988            .await
3989            .unwrap();
3990        assert_eq!(result.stdout, "a b c\n");
3991    }
3992
3993    #[tokio::test]
3994    async fn test_read_respects_local_scope() {
3995        // Regression: `local k; read -r k <<< "val"` must set k in local scope
3996        let mut bash = Bash::new();
3997        let result = bash
3998            .exec(
3999                r#"
4000fn() { local k; read -r k <<< "test"; echo "$k"; }
4001fn
4002"#,
4003            )
4004            .await
4005            .unwrap();
4006        assert_eq!(result.stdout, "test\n");
4007    }
4008
4009    #[tokio::test]
4010    async fn test_local_ifs_array_join() {
4011        // Regression: local IFS=":" must affect "${arr[*]}" joining
4012        let mut bash = Bash::new();
4013        let result = bash
4014            .exec(
4015                r#"
4016fn() {
4017  local arr=(a b c)
4018  local IFS=":"
4019  echo "${arr[*]}"
4020}
4021fn
4022"#,
4023            )
4024            .await
4025            .unwrap();
4026        assert_eq!(result.stdout, "a:b:c\n");
4027    }
4028
4029    #[tokio::test]
4030    async fn test_glob_star() {
4031        let mut bash = Bash::new();
4032        // Create some files
4033        bash.exec("echo a > /tmp/file1.txt").await.unwrap();
4034        bash.exec("echo b > /tmp/file2.txt").await.unwrap();
4035        bash.exec("echo c > /tmp/other.log").await.unwrap();
4036
4037        // Glob for *.txt files
4038        let result = bash.exec("echo /tmp/*.txt").await.unwrap();
4039        assert_eq!(result.stdout, "/tmp/file1.txt /tmp/file2.txt\n");
4040    }
4041
4042    #[tokio::test]
4043    async fn test_glob_question_mark() {
4044        let mut bash = Bash::new();
4045        // Create some files
4046        bash.exec("echo a > /tmp/a1.txt").await.unwrap();
4047        bash.exec("echo b > /tmp/a2.txt").await.unwrap();
4048        bash.exec("echo c > /tmp/a10.txt").await.unwrap();
4049
4050        // Glob for a?.txt (single character)
4051        let result = bash.exec("echo /tmp/a?.txt").await.unwrap();
4052        assert_eq!(result.stdout, "/tmp/a1.txt /tmp/a2.txt\n");
4053    }
4054
4055    #[tokio::test]
4056    async fn test_glob_no_match() {
4057        let mut bash = Bash::new();
4058        // Glob that doesn't match anything should return the pattern
4059        let result = bash.exec("echo /nonexistent/*.xyz").await.unwrap();
4060        assert_eq!(result.stdout, "/nonexistent/*.xyz\n");
4061    }
4062
4063    #[tokio::test]
4064    async fn test_command_substitution() {
4065        let mut bash = Bash::new();
4066        let result = bash.exec("echo $(echo hello)").await.unwrap();
4067        assert_eq!(result.stdout, "hello\n");
4068    }
4069
4070    #[tokio::test]
4071    async fn test_command_substitution_in_string() {
4072        let mut bash = Bash::new();
4073        let result = bash.exec("echo \"result: $(echo 42)\"").await.unwrap();
4074        assert_eq!(result.stdout, "result: 42\n");
4075    }
4076
4077    #[tokio::test]
4078    async fn test_command_substitution_pipeline() {
4079        let mut bash = Bash::new();
4080        let result = bash.exec("echo $(echo hello | cat)").await.unwrap();
4081        assert_eq!(result.stdout, "hello\n");
4082    }
4083
4084    #[tokio::test]
4085    async fn test_command_substitution_variable() {
4086        let mut bash = Bash::new();
4087        let result = bash.exec("VAR=$(echo test); echo $VAR").await.unwrap();
4088        assert_eq!(result.stdout, "test\n");
4089    }
4090
4091    #[tokio::test]
4092    async fn test_arithmetic_simple() {
4093        let mut bash = Bash::new();
4094        let result = bash.exec("echo $((1 + 2))").await.unwrap();
4095        assert_eq!(result.stdout, "3\n");
4096    }
4097
4098    #[tokio::test]
4099    async fn test_arithmetic_multiply() {
4100        let mut bash = Bash::new();
4101        let result = bash.exec("echo $((3 * 4))").await.unwrap();
4102        assert_eq!(result.stdout, "12\n");
4103    }
4104
4105    #[tokio::test]
4106    async fn test_arithmetic_with_variable() {
4107        let mut bash = Bash::new();
4108        let result = bash.exec("X=5; echo $((X + 3))").await.unwrap();
4109        assert_eq!(result.stdout, "8\n");
4110    }
4111
4112    #[tokio::test]
4113    async fn test_arithmetic_complex() {
4114        let mut bash = Bash::new();
4115        let result = bash.exec("echo $((2 + 3 * 4))").await.unwrap();
4116        assert_eq!(result.stdout, "14\n");
4117    }
4118
4119    #[tokio::test]
4120    async fn test_heredoc_simple() {
4121        let mut bash = Bash::new();
4122        let result = bash.exec("cat <<EOF\nhello\nworld\nEOF").await.unwrap();
4123        assert_eq!(result.stdout, "hello\nworld\n");
4124    }
4125
4126    #[tokio::test]
4127    async fn test_heredoc_single_line() {
4128        let mut bash = Bash::new();
4129        let result = bash.exec("cat <<END\ntest\nEND").await.unwrap();
4130        assert_eq!(result.stdout, "test\n");
4131    }
4132
4133    #[tokio::test]
4134    async fn test_unset() {
4135        let mut bash = Bash::new();
4136        let result = bash
4137            .exec("FOO=bar; unset FOO; echo \"x${FOO}y\"")
4138            .await
4139            .unwrap();
4140        assert_eq!(result.stdout, "xy\n");
4141    }
4142
4143    #[tokio::test]
4144    async fn test_local_basic() {
4145        let mut bash = Bash::new();
4146        // Test that local command runs without error
4147        let result = bash.exec("local X=test; echo $X").await.unwrap();
4148        assert_eq!(result.stdout, "test\n");
4149    }
4150
4151    #[tokio::test]
4152    async fn test_set_option() {
4153        let mut bash = Bash::new();
4154        let result = bash.exec("set -e; echo ok").await.unwrap();
4155        assert_eq!(result.stdout, "ok\n");
4156    }
4157
4158    #[tokio::test]
4159    async fn test_param_default() {
4160        let mut bash = Bash::new();
4161        // ${var:-default} when unset
4162        let result = bash.exec("echo ${UNSET:-default}").await.unwrap();
4163        assert_eq!(result.stdout, "default\n");
4164
4165        // ${var:-default} when set
4166        let result = bash.exec("X=value; echo ${X:-default}").await.unwrap();
4167        assert_eq!(result.stdout, "value\n");
4168    }
4169
4170    #[tokio::test]
4171    async fn test_param_assign_default() {
4172        let mut bash = Bash::new();
4173        // ${var:=default} assigns when unset
4174        let result = bash.exec("echo ${NEW:=assigned}; echo $NEW").await.unwrap();
4175        assert_eq!(result.stdout, "assigned\nassigned\n");
4176    }
4177
4178    #[tokio::test]
4179    async fn test_param_length() {
4180        let mut bash = Bash::new();
4181        let result = bash.exec("X=hello; echo ${#X}").await.unwrap();
4182        assert_eq!(result.stdout, "5\n");
4183    }
4184
4185    #[tokio::test]
4186    async fn test_param_remove_prefix() {
4187        let mut bash = Bash::new();
4188        // ${var#pattern} - remove shortest prefix
4189        let result = bash.exec("X=hello.world.txt; echo ${X#*.}").await.unwrap();
4190        assert_eq!(result.stdout, "world.txt\n");
4191    }
4192
4193    #[tokio::test]
4194    async fn test_param_remove_prefix_mixed_pattern() {
4195        let mut bash = Bash::new();
4196        // ${var#./"$other"} - pattern mixing literal and quoted variable
4197        let result = bash
4198            .exec(r#"i="./tag_hello.tmp.html"; prefix_tags="tag_"; echo ${i#./"$prefix_tags"}"#)
4199            .await
4200            .unwrap();
4201        assert_eq!(result.stdout, "hello.tmp.html\n");
4202    }
4203
4204    #[tokio::test]
4205    async fn test_param_remove_suffix() {
4206        let mut bash = Bash::new();
4207        // ${var%pattern} - remove shortest suffix
4208        let result = bash.exec("X=file.tar.gz; echo ${X%.*}").await.unwrap();
4209        assert_eq!(result.stdout, "file.tar\n");
4210    }
4211
4212    #[tokio::test]
4213    async fn test_positional_param_prefix_replace() {
4214        let mut bash = Bash::new();
4215        // ${@/#/prefix} should prepend prefix to each positional parameter
4216        let result = bash
4217            .exec(r#"f() { set -- "${@/#/tag_}"; echo "$@"; }; f hello world"#)
4218            .await
4219            .unwrap();
4220        assert_eq!(result.stdout, "tag_hello tag_world\n");
4221    }
4222
4223    #[tokio::test]
4224    async fn test_positional_param_suffix_replace() {
4225        let mut bash = Bash::new();
4226        // ${@/%/suffix} should append suffix to each positional parameter
4227        let result = bash
4228            .exec(r#"f() { set -- "${@/%/.html}"; echo "$@"; }; f hello world"#)
4229            .await
4230            .unwrap();
4231        assert_eq!(result.stdout, "hello.html world.html\n");
4232    }
4233
4234    #[tokio::test]
4235    async fn test_positional_param_prefix_var_replace() {
4236        let mut bash = Bash::new();
4237        // ${@/#/$var} should prepend var value to each positional parameter
4238        let result = bash
4239            .exec(r#"f() { p="tag_"; set -- "${@/#/$p}"; echo "$@"; }; f hello world"#)
4240            .await
4241            .unwrap();
4242        assert_eq!(result.stdout, "tag_hello tag_world\n");
4243    }
4244
4245    #[tokio::test]
4246    async fn test_positional_param_prefix_strip() {
4247        let mut bash = Bash::new();
4248        // ${@#prefix} should strip prefix from each positional parameter
4249        let result = bash
4250            .exec(r#"f() { set -- "${@#tag_}"; echo "$@"; }; f tag_hello tag_world"#)
4251            .await
4252            .unwrap();
4253        assert_eq!(result.stdout, "hello world\n");
4254    }
4255
4256    #[tokio::test]
4257    async fn test_array_basic() {
4258        let mut bash = Bash::new();
4259        // Basic array declaration and access
4260        let result = bash.exec("arr=(a b c); echo ${arr[1]}").await.unwrap();
4261        assert_eq!(result.stdout, "b\n");
4262    }
4263
4264    #[tokio::test]
4265    async fn test_array_all_elements() {
4266        let mut bash = Bash::new();
4267        // ${arr[@]} - all elements
4268        let result = bash
4269            .exec("arr=(one two three); echo ${arr[@]}")
4270            .await
4271            .unwrap();
4272        assert_eq!(result.stdout, "one two three\n");
4273    }
4274
4275    #[tokio::test]
4276    async fn test_array_length() {
4277        let mut bash = Bash::new();
4278        // ${#arr[@]} - number of elements
4279        let result = bash.exec("arr=(a b c d e); echo ${#arr[@]}").await.unwrap();
4280        assert_eq!(result.stdout, "5\n");
4281    }
4282
4283    #[tokio::test]
4284    async fn test_array_indexed_assignment() {
4285        let mut bash = Bash::new();
4286        // arr[n]=value assignment
4287        let result = bash
4288            .exec("arr[0]=first; arr[1]=second; echo ${arr[0]} ${arr[1]}")
4289            .await
4290            .unwrap();
4291        assert_eq!(result.stdout, "first second\n");
4292    }
4293
4294    #[tokio::test]
4295    async fn test_array_single_quote_subscript_no_panic() {
4296        // Regression: single quote char as array index caused begin > end slice panic
4297        let mut bash = Bash::new();
4298        // Should not panic on malformed subscript with lone quote
4299        let _ = bash.exec("echo ${arr[\"]}").await;
4300    }
4301
4302    // Resource limit tests
4303
4304    #[tokio::test]
4305    async fn test_command_limit() {
4306        let limits = ExecutionLimits::new().max_commands(5);
4307        let mut bash = Bash::builder().limits(limits).build();
4308
4309        // Run 6 commands - should fail on the 6th
4310        let result = bash.exec("true; true; true; true; true; true").await;
4311        assert!(result.is_err());
4312        let err = result.unwrap_err();
4313        assert!(
4314            err.to_string().contains("maximum command count exceeded"),
4315            "Expected command limit error, got: {}",
4316            err
4317        );
4318    }
4319
4320    #[tokio::test]
4321    async fn test_command_limit_not_exceeded() {
4322        let limits = ExecutionLimits::new().max_commands(10);
4323        let mut bash = Bash::builder().limits(limits).build();
4324
4325        // Run 5 commands - should succeed
4326        let result = bash.exec("true; true; true; true; true").await.unwrap();
4327        assert_eq!(result.exit_code, 0);
4328    }
4329
4330    #[tokio::test]
4331    async fn test_loop_iteration_limit() {
4332        let limits = ExecutionLimits::new().max_loop_iterations(5);
4333        let mut bash = Bash::builder().limits(limits).build();
4334
4335        // Loop that tries to run 10 times
4336        let result = bash
4337            .exec("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done")
4338            .await;
4339        assert!(result.is_err());
4340        let err = result.unwrap_err();
4341        assert!(
4342            err.to_string().contains("maximum loop iterations exceeded"),
4343            "Expected loop limit error, got: {}",
4344            err
4345        );
4346    }
4347
4348    #[tokio::test]
4349    async fn test_loop_iteration_limit_not_exceeded() {
4350        let limits = ExecutionLimits::new().max_loop_iterations(10);
4351        let mut bash = Bash::builder().limits(limits).build();
4352
4353        // Loop that runs 5 times - should succeed
4354        let result = bash
4355            .exec("for i in 1 2 3 4 5; do echo $i; done")
4356            .await
4357            .unwrap();
4358        assert_eq!(result.stdout, "1\n2\n3\n4\n5\n");
4359    }
4360
4361    #[tokio::test]
4362    async fn test_function_depth_limit() {
4363        let limits = ExecutionLimits::new().max_function_depth(3);
4364        let mut bash = Bash::builder().limits(limits).build();
4365
4366        // Recursive function that would go 5 deep
4367        let result = bash
4368            .exec("f() { echo $1; if [ $1 -lt 5 ]; then f $(($1 + 1)); fi; }; f 1")
4369            .await;
4370        assert!(result.is_err());
4371        let err = result.unwrap_err();
4372        assert!(
4373            err.to_string().contains("maximum function depth exceeded"),
4374            "Expected function depth error, got: {}",
4375            err
4376        );
4377    }
4378
4379    #[tokio::test]
4380    async fn test_function_depth_limit_not_exceeded() {
4381        let limits = ExecutionLimits::new().max_function_depth(10);
4382        let mut bash = Bash::builder().limits(limits).build();
4383
4384        // Simple function call - should succeed
4385        let result = bash.exec("f() { echo hello; }; f").await.unwrap();
4386        assert_eq!(result.stdout, "hello\n");
4387    }
4388
4389    #[tokio::test]
4390    async fn test_while_loop_limit() {
4391        let limits = ExecutionLimits::new().max_loop_iterations(3);
4392        let mut bash = Bash::builder().limits(limits).build();
4393
4394        // While loop with counter
4395        let result = bash
4396            .exec("i=0; while [ $i -lt 10 ]; do echo $i; i=$((i + 1)); done")
4397            .await;
4398        assert!(result.is_err());
4399        let err = result.unwrap_err();
4400        assert!(
4401            err.to_string().contains("maximum loop iterations exceeded"),
4402            "Expected loop limit error, got: {}",
4403            err
4404        );
4405    }
4406
4407    #[tokio::test]
4408    async fn test_awk_respects_loop_iteration_limit() {
4409        let limits = ExecutionLimits::new().max_loop_iterations(5);
4410        let mut bash = Bash::builder().limits(limits).build();
4411        let result = bash
4412            .exec("awk 'BEGIN { i=0; while(1) { i++; if(i>999) break } print i }'")
4413            .await
4414            .unwrap();
4415        assert_eq!(result.stdout.trim(), "5");
4416    }
4417
4418    #[tokio::test]
4419    async fn test_awk_for_in_respects_loop_iteration_limit() {
4420        let limits = ExecutionLimits::new().max_loop_iterations(3);
4421        let mut bash = Bash::builder().limits(limits).build();
4422        let result = bash
4423            .exec("awk 'BEGIN { for(i=1;i<=10;i++) a[i]=i; c=0; for(k in a) c++; print c }'")
4424            .await
4425            .unwrap();
4426        assert_eq!(result.stdout.trim(), "3");
4427    }
4428
4429    #[tokio::test]
4430    async fn test_default_limits_allow_normal_scripts() {
4431        // Default limits should allow typical scripts to run
4432        let mut bash = Bash::new();
4433        // Avoid using "done" as a word after a for loop - it causes parsing ambiguity
4434        let result = bash
4435            .exec("for i in 1 2 3 4 5; do echo $i; done && echo finished")
4436            .await
4437            .unwrap();
4438        assert_eq!(result.stdout, "1\n2\n3\n4\n5\nfinished\n");
4439    }
4440
4441    #[tokio::test]
4442    async fn test_for_followed_by_echo_done() {
4443        let mut bash = Bash::new();
4444        let result = bash
4445            .exec("for i in 1; do echo $i; done; echo ok")
4446            .await
4447            .unwrap();
4448        assert_eq!(result.stdout, "1\nok\n");
4449    }
4450
4451    // Filesystem access tests
4452
4453    #[tokio::test]
4454    async fn test_fs_read_write_binary() {
4455        let bash = Bash::new();
4456        let fs = bash.fs();
4457        let path = std::path::Path::new("/tmp/binary.bin");
4458
4459        // Write binary data with null bytes and high bytes
4460        let binary_data: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE, 0x42, 0x00, 0x7F];
4461        fs.write_file(path, &binary_data).await.unwrap();
4462
4463        // Read it back
4464        let content = fs.read_file(path).await.unwrap();
4465        assert_eq!(content, binary_data);
4466    }
4467
4468    #[tokio::test]
4469    async fn test_fs_write_then_exec_cat() {
4470        let mut bash = Bash::new();
4471        let path = std::path::Path::new("/tmp/prepopulated.txt");
4472
4473        // Pre-populate a file before running bash
4474        bash.fs()
4475            .write_file(path, b"Hello from Rust!\n")
4476            .await
4477            .unwrap();
4478
4479        // Access it from bash
4480        let result = bash.exec("cat /tmp/prepopulated.txt").await.unwrap();
4481        assert_eq!(result.stdout, "Hello from Rust!\n");
4482    }
4483
4484    #[tokio::test]
4485    async fn test_fs_exec_then_read() {
4486        let mut bash = Bash::new();
4487        let path = std::path::Path::new("/tmp/from_bash.txt");
4488
4489        // Create file via bash
4490        bash.exec("echo 'Created by bash' > /tmp/from_bash.txt")
4491            .await
4492            .unwrap();
4493
4494        // Read it directly
4495        let content = bash.fs().read_file(path).await.unwrap();
4496        assert_eq!(content, b"Created by bash\n");
4497    }
4498
4499    #[tokio::test]
4500    async fn test_fs_exists_and_stat() {
4501        let bash = Bash::new();
4502        let fs = bash.fs();
4503        let path = std::path::Path::new("/tmp/testfile.txt");
4504
4505        // File doesn't exist yet
4506        assert!(!fs.exists(path).await.unwrap());
4507
4508        // Create it
4509        fs.write_file(path, b"content").await.unwrap();
4510
4511        // Now exists
4512        assert!(fs.exists(path).await.unwrap());
4513
4514        // Check metadata
4515        let stat = fs.stat(path).await.unwrap();
4516        assert!(stat.file_type.is_file());
4517        assert_eq!(stat.size, 7); // "content" = 7 bytes
4518    }
4519
4520    #[tokio::test]
4521    async fn test_fs_mkdir_and_read_dir() {
4522        let bash = Bash::new();
4523        let fs = bash.fs();
4524
4525        // Create nested directories
4526        fs.mkdir(std::path::Path::new("/data/nested/dir"), true)
4527            .await
4528            .unwrap();
4529
4530        // Create some files
4531        fs.write_file(std::path::Path::new("/data/file1.txt"), b"1")
4532            .await
4533            .unwrap();
4534        fs.write_file(std::path::Path::new("/data/file2.txt"), b"2")
4535            .await
4536            .unwrap();
4537
4538        // Read directory
4539        let entries = fs.read_dir(std::path::Path::new("/data")).await.unwrap();
4540        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
4541        assert!(names.contains(&"nested"));
4542        assert!(names.contains(&"file1.txt"));
4543        assert!(names.contains(&"file2.txt"));
4544    }
4545
4546    #[tokio::test]
4547    async fn test_fs_append() {
4548        let bash = Bash::new();
4549        let fs = bash.fs();
4550        let path = std::path::Path::new("/tmp/append.txt");
4551
4552        fs.write_file(path, b"line1\n").await.unwrap();
4553        fs.append_file(path, b"line2\n").await.unwrap();
4554        fs.append_file(path, b"line3\n").await.unwrap();
4555
4556        let content = fs.read_file(path).await.unwrap();
4557        assert_eq!(content, b"line1\nline2\nline3\n");
4558    }
4559
4560    #[tokio::test]
4561    async fn test_fs_copy_and_rename() {
4562        let bash = Bash::new();
4563        let fs = bash.fs();
4564
4565        fs.write_file(std::path::Path::new("/tmp/original.txt"), b"data")
4566            .await
4567            .unwrap();
4568
4569        // Copy
4570        fs.copy(
4571            std::path::Path::new("/tmp/original.txt"),
4572            std::path::Path::new("/tmp/copied.txt"),
4573        )
4574        .await
4575        .unwrap();
4576
4577        // Rename
4578        fs.rename(
4579            std::path::Path::new("/tmp/copied.txt"),
4580            std::path::Path::new("/tmp/renamed.txt"),
4581        )
4582        .await
4583        .unwrap();
4584
4585        // Verify
4586        let content = fs
4587            .read_file(std::path::Path::new("/tmp/renamed.txt"))
4588            .await
4589            .unwrap();
4590        assert_eq!(content, b"data");
4591        assert!(
4592            !fs.exists(std::path::Path::new("/tmp/copied.txt"))
4593                .await
4594                .unwrap()
4595        );
4596    }
4597
4598    // Bug fix tests
4599
4600    #[tokio::test]
4601    async fn test_echo_done_as_argument() {
4602        // BUG: "done" should be parsed as a regular argument when not in loop context
4603        let mut bash = Bash::new();
4604        let result = bash
4605            .exec("for i in 1; do echo $i; done; echo done")
4606            .await
4607            .unwrap();
4608        assert_eq!(result.stdout, "1\ndone\n");
4609    }
4610
4611    #[tokio::test]
4612    async fn test_simple_echo_done() {
4613        // Simple echo done without any loop
4614        let mut bash = Bash::new();
4615        let result = bash.exec("echo done").await.unwrap();
4616        assert_eq!(result.stdout, "done\n");
4617    }
4618
4619    #[tokio::test]
4620    async fn test_dev_null_redirect() {
4621        // BUG: Redirecting to /dev/null should discard output silently
4622        let mut bash = Bash::new();
4623        let result = bash.exec("echo hello > /dev/null; echo ok").await.unwrap();
4624        assert_eq!(result.stdout, "ok\n");
4625    }
4626
4627    #[tokio::test]
4628    async fn test_string_concatenation_in_loop() {
4629        // Test string concatenation in a loop
4630        let mut bash = Bash::new();
4631        // First test: basic for loop still works
4632        let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
4633        assert_eq!(result.stdout, "a\nb\nc\n");
4634
4635        // Test variable assignment followed by for loop
4636        let mut bash = Bash::new();
4637        let result = bash
4638            .exec("result=x; for i in a b c; do echo $i; done; echo $result")
4639            .await
4640            .unwrap();
4641        assert_eq!(result.stdout, "a\nb\nc\nx\n");
4642
4643        // Test string concatenation in a loop
4644        let mut bash = Bash::new();
4645        let result = bash
4646            .exec("result=start; for i in a b c; do result=${result}$i; done; echo $result")
4647            .await
4648            .unwrap();
4649        assert_eq!(result.stdout, "startabc\n");
4650    }
4651
4652    // Negative/edge case tests for reserved word handling
4653
4654    #[tokio::test]
4655    async fn test_done_still_terminates_loop() {
4656        // Ensure "done" still works as a loop terminator
4657        let mut bash = Bash::new();
4658        let result = bash.exec("for i in 1 2; do echo $i; done").await.unwrap();
4659        assert_eq!(result.stdout, "1\n2\n");
4660    }
4661
4662    #[tokio::test]
4663    async fn test_fi_still_terminates_if() {
4664        // Ensure "fi" still works as an if terminator
4665        let mut bash = Bash::new();
4666        let result = bash.exec("if true; then echo yes; fi").await.unwrap();
4667        assert_eq!(result.stdout, "yes\n");
4668    }
4669
4670    #[tokio::test]
4671    async fn test_echo_fi_as_argument() {
4672        // "fi" should be a valid argument outside of if context
4673        let mut bash = Bash::new();
4674        let result = bash.exec("echo fi").await.unwrap();
4675        assert_eq!(result.stdout, "fi\n");
4676    }
4677
4678    #[tokio::test]
4679    async fn test_echo_then_as_argument() {
4680        // "then" should be a valid argument outside of if context
4681        let mut bash = Bash::new();
4682        let result = bash.exec("echo then").await.unwrap();
4683        assert_eq!(result.stdout, "then\n");
4684    }
4685
4686    #[tokio::test]
4687    async fn test_reserved_words_in_quotes_are_arguments() {
4688        // Reserved words in quotes should always be arguments
4689        let mut bash = Bash::new();
4690        let result = bash.exec("echo 'done' 'fi' 'then'").await.unwrap();
4691        assert_eq!(result.stdout, "done fi then\n");
4692    }
4693
4694    #[tokio::test]
4695    async fn test_nested_loops_done_keyword() {
4696        // Nested loops should properly match done keywords
4697        let mut bash = Bash::new();
4698        let result = bash
4699            .exec("for i in 1; do for j in a; do echo $i$j; done; done")
4700            .await
4701            .unwrap();
4702        assert_eq!(result.stdout, "1a\n");
4703    }
4704
4705    // Negative/edge case tests for /dev/null
4706
4707    #[tokio::test]
4708    async fn test_dev_null_read_returns_empty() {
4709        // Reading from /dev/null should return empty
4710        let mut bash = Bash::new();
4711        let result = bash.exec("cat /dev/null").await.unwrap();
4712        assert_eq!(result.stdout, "");
4713    }
4714
4715    #[tokio::test]
4716    async fn test_dev_null_append() {
4717        // Appending to /dev/null should work silently
4718        let mut bash = Bash::new();
4719        let result = bash.exec("echo hello >> /dev/null; echo ok").await.unwrap();
4720        assert_eq!(result.stdout, "ok\n");
4721    }
4722
4723    #[tokio::test]
4724    async fn test_dev_null_in_pipeline() {
4725        // /dev/null in a pipeline should work
4726        let mut bash = Bash::new();
4727        let result = bash
4728            .exec("echo hello | cat > /dev/null; echo ok")
4729            .await
4730            .unwrap();
4731        assert_eq!(result.stdout, "ok\n");
4732    }
4733
4734    #[tokio::test]
4735    async fn test_dev_null_exists() {
4736        // /dev/null should exist and be readable
4737        let mut bash = Bash::new();
4738        let result = bash.exec("cat /dev/null; echo exit_$?").await.unwrap();
4739        assert_eq!(result.stdout, "exit_0\n");
4740    }
4741
4742    // Custom username/hostname tests
4743
4744    #[tokio::test]
4745    async fn test_custom_username_whoami() {
4746        let mut bash = Bash::builder().username("alice").build();
4747        let result = bash.exec("whoami").await.unwrap();
4748        assert_eq!(result.stdout, "alice\n");
4749    }
4750
4751    #[tokio::test]
4752    async fn test_custom_username_id() {
4753        let mut bash = Bash::builder().username("bob").build();
4754        let result = bash.exec("id").await.unwrap();
4755        assert!(result.stdout.contains("uid=1000(bob)"));
4756        assert!(result.stdout.contains("gid=1000(bob)"));
4757    }
4758
4759    #[tokio::test]
4760    async fn test_custom_username_sets_user_env() {
4761        let mut bash = Bash::builder().username("charlie").build();
4762        let result = bash.exec("echo $USER").await.unwrap();
4763        assert_eq!(result.stdout, "charlie\n");
4764    }
4765
4766    #[tokio::test]
4767    async fn test_custom_username_provisions_home_dir() {
4768        // Regression for #2128: a configured username must make $HOME a real,
4769        // writable directory. Previously HOME=/home/eval pointed at a
4770        // nonexistent directory and writes to ~ failed with
4771        // "parent directory not found".
4772        let mut bash = Bash::builder().username("eval").build();
4773        let result = bash
4774            .exec("echo hi > /home/eval/x.sh && cat /home/eval/x.sh")
4775            .await
4776            .unwrap();
4777        assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
4778        assert_eq!(result.stdout, "hi\n");
4779    }
4780
4781    #[tokio::test]
4782    async fn test_custom_username_home_tilde_write() {
4783        // `~` / `$HOME` must resolve to the provisioned, writable home dir.
4784        let mut bash = Bash::builder().username("agent").build();
4785        let result = bash
4786            .exec("echo $HOME; echo data > ~/file.txt && cat ~/file.txt")
4787            .await
4788            .unwrap();
4789        assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
4790        assert_eq!(result.stdout, "/home/agent\ndata\n");
4791    }
4792
4793    #[tokio::test]
4794    async fn test_default_username_provisions_home_dir() {
4795        // The default user's $HOME must also exist and be writable.
4796        let mut bash = Bash::new();
4797        let result = bash
4798            .exec("echo data > $HOME/f && cat $HOME/f")
4799            .await
4800            .unwrap();
4801        assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
4802        assert_eq!(result.stdout, "data\n");
4803    }
4804
4805    #[tokio::test]
4806    async fn test_default_ppid_is_sandboxed() {
4807        let mut bash = Bash::new();
4808        let result = bash.exec("echo $PPID").await.unwrap();
4809        assert_eq!(result.stdout, "0\n");
4810    }
4811
4812    #[tokio::test]
4813    async fn test_custom_hostname() {
4814        let mut bash = Bash::builder().hostname("my-server").build();
4815        let result = bash.exec("hostname").await.unwrap();
4816        assert_eq!(result.stdout, "my-server\n");
4817    }
4818
4819    #[tokio::test]
4820    async fn test_custom_hostname_uname() {
4821        let mut bash = Bash::builder().hostname("custom-host").build();
4822        let result = bash.exec("uname -n").await.unwrap();
4823        assert_eq!(result.stdout, "custom-host\n");
4824    }
4825
4826    #[tokio::test]
4827    async fn test_default_username_and_hostname() {
4828        // Default values should still work
4829        let mut bash = Bash::new();
4830        let result = bash.exec("whoami").await.unwrap();
4831        assert_eq!(result.stdout, "sandbox\n");
4832
4833        let result = bash.exec("hostname").await.unwrap();
4834        assert_eq!(result.stdout, "bashkit-sandbox\n");
4835    }
4836
4837    #[tokio::test]
4838    async fn test_custom_username_and_hostname_combined() {
4839        let mut bash = Bash::builder()
4840            .username("deploy")
4841            .hostname("prod-server-01")
4842            .build();
4843
4844        let result = bash.exec("whoami && hostname").await.unwrap();
4845        assert_eq!(result.stdout, "deploy\nprod-server-01\n");
4846
4847        let result = bash.exec("echo $USER").await.unwrap();
4848        assert_eq!(result.stdout, "deploy\n");
4849    }
4850
4851    // Custom builtins tests
4852
4853    mod custom_builtins {
4854        use super::*;
4855        use crate::builtins::{Builtin, Context};
4856        use crate::{ExecResult, ExecutionExtensions, Extension};
4857        use async_trait::async_trait;
4858
4859        /// A simple custom builtin that outputs a static string
4860        struct Hello;
4861
4862        #[async_trait]
4863        impl Builtin for Hello {
4864            async fn execute(&self, _ctx: Context<'_>) -> crate::Result<ExecResult> {
4865                Ok(ExecResult::ok("Hello from custom builtin!\n".to_string()))
4866            }
4867        }
4868
4869        #[tokio::test]
4870        async fn test_custom_builtin_basic() {
4871            let mut bash = Bash::builder().builtin("hello", Box::new(Hello)).build();
4872
4873            let result = bash.exec("hello").await.unwrap();
4874            assert_eq!(result.stdout, "Hello from custom builtin!\n");
4875            assert_eq!(result.exit_code, 0);
4876        }
4877
4878        struct ExecutionScoped;
4879
4880        #[async_trait]
4881        impl Builtin for ExecutionScoped {
4882            async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4883                let value = ctx
4884                    .execution_extension::<String>()
4885                    .cloned()
4886                    .unwrap_or_else(|| "missing".to_string());
4887                Ok(ExecResult::ok(format!("{value}\n")))
4888            }
4889        }
4890
4891        #[tokio::test]
4892        async fn test_custom_builtin_execution_extensions_are_per_call() {
4893            let mut bash = Bash::builder()
4894                .builtin("read-ext", Box::new(ExecutionScoped))
4895                .build();
4896
4897            let result = bash
4898                .exec_with_extensions(
4899                    "read-ext",
4900                    ExecutionExtensions::new().with("scoped".to_string()),
4901                )
4902                .await
4903                .unwrap();
4904            assert_eq!(result.stdout, "scoped\n");
4905
4906            let result = bash.exec("read-ext").await.unwrap();
4907            assert_eq!(result.stdout, "missing\n");
4908        }
4909
4910        /// A custom builtin that uses arguments
4911        struct Greet;
4912
4913        #[async_trait]
4914        impl Builtin for Greet {
4915            async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4916                let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World");
4917                Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
4918            }
4919        }
4920
4921        #[tokio::test]
4922        async fn test_custom_builtin_with_args() {
4923            let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
4924
4925            let result = bash.exec("greet").await.unwrap();
4926            assert_eq!(result.stdout, "Hello, World!\n");
4927
4928            let result = bash.exec("greet Alice").await.unwrap();
4929            assert_eq!(result.stdout, "Hello, Alice!\n");
4930
4931            let result = bash.exec("greet Bob Charlie").await.unwrap();
4932            assert_eq!(result.stdout, "Hello, Bob!\n");
4933        }
4934
4935        /// A custom builtin that reads from stdin
4936        struct Upper;
4937
4938        #[async_trait]
4939        impl Builtin for Upper {
4940            async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4941                let input = ctx.stdin.unwrap_or("");
4942                Ok(ExecResult::ok(input.to_uppercase()))
4943            }
4944        }
4945
4946        #[tokio::test]
4947        async fn test_custom_builtin_with_stdin() {
4948            let mut bash = Bash::builder().builtin("upper", Box::new(Upper)).build();
4949
4950            let result = bash.exec("echo hello | upper").await.unwrap();
4951            assert_eq!(result.stdout, "HELLO\n");
4952        }
4953
4954        /// A custom builtin that interacts with the filesystem
4955        struct WriteFile;
4956
4957        #[async_trait]
4958        impl Builtin for WriteFile {
4959            async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4960                if ctx.args.len() < 2 {
4961                    return Ok(ExecResult::err(
4962                        "Usage: writefile <path> <content>\n".to_string(),
4963                        1,
4964                    ));
4965                }
4966                let path = std::path::Path::new(&ctx.args[0]);
4967                let content = ctx.args[1..].join(" ");
4968                ctx.fs.write_file(path, content.as_bytes()).await?;
4969                Ok(ExecResult::ok(String::new()))
4970            }
4971        }
4972
4973        #[tokio::test]
4974        async fn test_custom_builtin_with_filesystem() {
4975            let mut bash = Bash::builder()
4976                .builtin("writefile", Box::new(WriteFile))
4977                .build();
4978
4979            bash.exec("writefile /tmp/test.txt custom content here")
4980                .await
4981                .unwrap();
4982
4983            let result = bash.exec("cat /tmp/test.txt").await.unwrap();
4984            assert_eq!(result.stdout, "custom content here");
4985        }
4986
4987        /// A custom builtin that overrides a default builtin
4988        struct CustomEcho;
4989
4990        #[async_trait]
4991        impl Builtin for CustomEcho {
4992            async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
4993                let msg = ctx.args.join(" ");
4994                Ok(ExecResult::ok(format!("[CUSTOM] {}\n", msg)))
4995            }
4996        }
4997
4998        #[tokio::test]
4999        async fn test_custom_builtin_override_default() {
5000            let mut bash = Bash::builder()
5001                .builtin("echo", Box::new(CustomEcho))
5002                .build();
5003
5004            let result = bash.exec("echo hello world").await.unwrap();
5005            assert_eq!(result.stdout, "[CUSTOM] hello world\n");
5006        }
5007
5008        /// Test multiple custom builtins
5009        #[tokio::test]
5010        async fn test_multiple_custom_builtins() {
5011            let mut bash = Bash::builder()
5012                .builtin("hello", Box::new(Hello))
5013                .builtin("greet", Box::new(Greet))
5014                .builtin("upper", Box::new(Upper))
5015                .build();
5016
5017            let result = bash.exec("hello").await.unwrap();
5018            assert_eq!(result.stdout, "Hello from custom builtin!\n");
5019
5020            let result = bash.exec("greet Test").await.unwrap();
5021            assert_eq!(result.stdout, "Hello, Test!\n");
5022
5023            let result = bash.exec("echo foo | upper").await.unwrap();
5024            assert_eq!(result.stdout, "FOO\n");
5025        }
5026
5027        struct GreetingExtension;
5028
5029        impl Extension for GreetingExtension {
5030            fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
5031                vec![
5032                    ("hello-ext".to_string(), Box::new(Hello)),
5033                    ("greet-ext".to_string(), Box::new(Greet)),
5034                ]
5035            }
5036        }
5037
5038        #[tokio::test]
5039        async fn test_extension_registers_multiple_builtins() {
5040            let mut bash = Bash::builder().extension(GreetingExtension).build();
5041
5042            let result = bash.exec("hello-ext").await.unwrap();
5043            assert_eq!(result.stdout, "Hello from custom builtin!\n");
5044
5045            let result = bash.exec("greet-ext Extension").await.unwrap();
5046            assert_eq!(result.stdout, "Hello, Extension!\n");
5047        }
5048
5049        /// A custom builtin with internal state
5050        struct Counter {
5051            prefix: String,
5052        }
5053
5054        #[async_trait]
5055        impl Builtin for Counter {
5056            async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5057                let count = ctx
5058                    .args
5059                    .first()
5060                    .and_then(|s| s.parse::<i32>().ok())
5061                    .unwrap_or(1);
5062                let mut output = String::new();
5063                for i in 1..=count {
5064                    output.push_str(&format!("{}{}\n", self.prefix, i));
5065                }
5066                Ok(ExecResult::ok(output))
5067            }
5068        }
5069
5070        #[tokio::test]
5071        async fn test_custom_builtin_with_state() {
5072            let mut bash = Bash::builder()
5073                .builtin(
5074                    "count",
5075                    Box::new(Counter {
5076                        prefix: "Item ".to_string(),
5077                    }),
5078                )
5079                .build();
5080
5081            let result = bash.exec("count 3").await.unwrap();
5082            assert_eq!(result.stdout, "Item 1\nItem 2\nItem 3\n");
5083        }
5084
5085        /// A custom builtin that returns an error
5086        struct Fail;
5087
5088        #[async_trait]
5089        impl Builtin for Fail {
5090            async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5091                let code = ctx
5092                    .args
5093                    .first()
5094                    .and_then(|s| s.parse::<i32>().ok())
5095                    .unwrap_or(1);
5096                Ok(ExecResult::err(
5097                    format!("Failed with code {}\n", code),
5098                    code,
5099                ))
5100            }
5101        }
5102
5103        #[tokio::test]
5104        async fn test_custom_builtin_error() {
5105            let mut bash = Bash::builder().builtin("fail", Box::new(Fail)).build();
5106
5107            let result = bash.exec("fail 42").await.unwrap();
5108            assert_eq!(result.exit_code, 42);
5109            assert_eq!(result.stderr, "Failed with code 42\n");
5110        }
5111
5112        #[tokio::test]
5113        async fn test_custom_builtin_in_script() {
5114            let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build();
5115
5116            let script = r#"
5117                for name in Alice Bob Charlie; do
5118                    greet $name
5119                done
5120            "#;
5121
5122            let result = bash.exec(script).await.unwrap();
5123            assert_eq!(
5124                result.stdout,
5125                "Hello, Alice!\nHello, Bob!\nHello, Charlie!\n"
5126            );
5127        }
5128
5129        #[tokio::test]
5130        async fn test_custom_builtin_with_conditionals() {
5131            let mut bash = Bash::builder()
5132                .builtin("fail", Box::new(Fail))
5133                .builtin("hello", Box::new(Hello))
5134                .build();
5135
5136            let result = bash.exec("fail 1 || hello").await.unwrap();
5137            assert_eq!(result.stdout, "Hello from custom builtin!\n");
5138            assert_eq!(result.exit_code, 0);
5139
5140            let result = bash.exec("hello && fail 5").await.unwrap();
5141            assert_eq!(result.exit_code, 5);
5142        }
5143
5144        /// A custom builtin that reads environment variables
5145        struct EnvReader;
5146
5147        #[async_trait]
5148        impl Builtin for EnvReader {
5149            async fn execute(&self, ctx: Context<'_>) -> crate::Result<ExecResult> {
5150                let var_name = ctx.args.first().map(|s| s.as_str()).unwrap_or("HOME");
5151                let value = ctx
5152                    .env
5153                    .get(var_name)
5154                    .map(|s| s.as_str())
5155                    .unwrap_or("(not set)");
5156                Ok(ExecResult::ok(format!("{}={}\n", var_name, value)))
5157            }
5158        }
5159
5160        #[tokio::test]
5161        async fn test_custom_builtin_reads_env() {
5162            let mut bash = Bash::builder()
5163                .env("MY_VAR", "my_value")
5164                .builtin("readenv", Box::new(EnvReader))
5165                .build();
5166
5167            let result = bash.exec("readenv MY_VAR").await.unwrap();
5168            assert_eq!(result.stdout, "MY_VAR=my_value\n");
5169
5170            let result = bash.exec("readenv UNKNOWN").await.unwrap();
5171            assert_eq!(result.stdout, "UNKNOWN=(not set)\n");
5172        }
5173    }
5174
5175    // Parser timeout tests
5176
5177    #[tokio::test]
5178    async fn test_parser_timeout_default() {
5179        // Default parser timeout should be 5 seconds
5180        let limits = ExecutionLimits::default();
5181        assert_eq!(limits.parser_timeout, std::time::Duration::from_secs(5));
5182    }
5183
5184    #[tokio::test]
5185    async fn test_parser_timeout_custom() {
5186        // Parser timeout can be customized
5187        let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_millis(100));
5188        assert_eq!(limits.parser_timeout, std::time::Duration::from_millis(100));
5189    }
5190
5191    #[tokio::test]
5192    async fn test_parser_timeout_normal_script() {
5193        // Normal scripts should complete well within timeout
5194        let limits = ExecutionLimits::new().parser_timeout(std::time::Duration::from_secs(1));
5195        let mut bash = Bash::builder().limits(limits).build();
5196        let result = bash.exec("echo hello").await.unwrap();
5197        assert_eq!(result.stdout, "hello\n");
5198    }
5199
5200    // Parser fuel tests
5201
5202    #[tokio::test]
5203    async fn test_parser_fuel_default() {
5204        // Default parser fuel should be 100,000
5205        let limits = ExecutionLimits::default();
5206        assert_eq!(limits.max_parser_operations, 100_000);
5207    }
5208
5209    #[tokio::test]
5210    async fn test_parser_fuel_custom() {
5211        // Parser fuel can be customized
5212        let limits = ExecutionLimits::new().max_parser_operations(1000);
5213        assert_eq!(limits.max_parser_operations, 1000);
5214    }
5215
5216    #[tokio::test]
5217    async fn test_parser_fuel_normal_script() {
5218        // Normal scripts should parse within fuel limit
5219        let limits = ExecutionLimits::new().max_parser_operations(1000);
5220        let mut bash = Bash::builder().limits(limits).build();
5221        let result = bash.exec("echo hello").await.unwrap();
5222        assert_eq!(result.stdout, "hello\n");
5223    }
5224
5225    // Input size limit tests
5226
5227    #[tokio::test]
5228    async fn test_input_size_limit_default() {
5229        // Default input size limit should be 10MB
5230        let limits = ExecutionLimits::default();
5231        assert_eq!(limits.max_input_bytes, 10_000_000);
5232    }
5233
5234    #[tokio::test]
5235    async fn test_input_size_limit_custom() {
5236        // Input size limit can be customized
5237        let limits = ExecutionLimits::new().max_input_bytes(1000);
5238        assert_eq!(limits.max_input_bytes, 1000);
5239    }
5240
5241    #[tokio::test]
5242    async fn test_input_size_limit_enforced() {
5243        // Scripts exceeding the limit should be rejected
5244        let limits = ExecutionLimits::new().max_input_bytes(10);
5245        let mut bash = Bash::builder().limits(limits).build();
5246
5247        // This script is longer than 10 bytes
5248        let result = bash.exec("echo hello world").await;
5249        assert!(result.is_err());
5250        let err = result.unwrap_err();
5251        assert!(
5252            err.to_string().contains("input too large"),
5253            "Expected input size error, got: {}",
5254            err
5255        );
5256    }
5257
5258    #[tokio::test]
5259    async fn test_input_size_limit_normal_script() {
5260        // Normal scripts should complete within limit
5261        let limits = ExecutionLimits::new().max_input_bytes(1000);
5262        let mut bash = Bash::builder().limits(limits).build();
5263        let result = bash.exec("echo hello").await.unwrap();
5264        assert_eq!(result.stdout, "hello\n");
5265    }
5266
5267    // AST depth limit tests
5268
5269    #[tokio::test]
5270    async fn test_ast_depth_limit_default() {
5271        // Default AST depth limit should be 100
5272        let limits = ExecutionLimits::default();
5273        assert_eq!(limits.max_ast_depth, 100);
5274    }
5275
5276    #[tokio::test]
5277    async fn test_ast_depth_limit_custom() {
5278        // AST depth limit can be customized
5279        let limits = ExecutionLimits::new().max_ast_depth(10);
5280        assert_eq!(limits.max_ast_depth, 10);
5281    }
5282
5283    #[tokio::test]
5284    async fn test_ast_depth_limit_normal_script() {
5285        // Normal scripts should parse within limit
5286        let limits = ExecutionLimits::new().max_ast_depth(10);
5287        let mut bash = Bash::builder().limits(limits).build();
5288        let result = bash.exec("if true; then echo ok; fi").await.unwrap();
5289        assert_eq!(result.stdout, "ok\n");
5290    }
5291
5292    #[tokio::test]
5293    async fn test_ast_depth_limit_enforced() {
5294        // Deeply nested scripts should be rejected
5295        let limits = ExecutionLimits::new().max_ast_depth(2);
5296        let mut bash = Bash::builder().limits(limits).build();
5297
5298        // This script has 3 levels of nesting (exceeds limit of 2)
5299        let result = bash
5300            .exec("if true; then if true; then if true; then echo nested; fi; fi; fi")
5301            .await;
5302        assert!(result.is_err());
5303        let err = result.unwrap_err();
5304        assert!(
5305            err.to_string().contains("AST nesting too deep"),
5306            "Expected AST depth error, got: {}",
5307            err
5308        );
5309    }
5310
5311    #[tokio::test]
5312    async fn test_parser_fuel_enforced() {
5313        // Scripts exceeding fuel limit should be rejected
5314        // With fuel of 3, parsing "echo a" should fail (needs multiple operations)
5315        let limits = ExecutionLimits::new().max_parser_operations(3);
5316        let mut bash = Bash::builder().limits(limits).build();
5317
5318        // Even a simple script needs more than 3 parsing operations
5319        let result = bash.exec("echo a; echo b; echo c").await;
5320        assert!(result.is_err());
5321        let err = result.unwrap_err();
5322        assert!(
5323            err.to_string().contains("parser fuel exhausted"),
5324            "Expected parser fuel error, got: {}",
5325            err
5326        );
5327    }
5328
5329    // set -e (errexit) tests
5330
5331    #[tokio::test]
5332    async fn test_set_e_basic() {
5333        // set -e should exit on non-zero return
5334        let mut bash = Bash::new();
5335        let result = bash
5336            .exec("set -e; true; false; echo should_not_reach")
5337            .await
5338            .unwrap();
5339        assert_eq!(result.stdout, "");
5340        assert_eq!(result.exit_code, 1);
5341    }
5342
5343    #[tokio::test]
5344    async fn test_set_e_after_failing_cmd() {
5345        // set -e exits immediately on failed command
5346        let mut bash = Bash::new();
5347        let result = bash
5348            .exec("set -e; echo before; false; echo after")
5349            .await
5350            .unwrap();
5351        assert_eq!(result.stdout, "before\n");
5352        assert_eq!(result.exit_code, 1);
5353    }
5354
5355    #[tokio::test]
5356    async fn test_set_e_disabled() {
5357        // set +e disables errexit
5358        let mut bash = Bash::new();
5359        let result = bash
5360            .exec("set -e; set +e; false; echo still_running")
5361            .await
5362            .unwrap();
5363        assert_eq!(result.stdout, "still_running\n");
5364    }
5365
5366    #[tokio::test]
5367    async fn test_set_e_in_pipeline_last() {
5368        // set -e only checks last command in pipeline
5369        let mut bash = Bash::new();
5370        let result = bash
5371            .exec("set -e; false | true; echo reached")
5372            .await
5373            .unwrap();
5374        assert_eq!(result.stdout, "reached\n");
5375    }
5376
5377    #[tokio::test]
5378    async fn test_set_e_in_if_condition() {
5379        // set -e should not trigger on if condition failure
5380        let mut bash = Bash::new();
5381        let result = bash
5382            .exec("set -e; if false; then echo yes; else echo no; fi; echo done")
5383            .await
5384            .unwrap();
5385        assert_eq!(result.stdout, "no\ndone\n");
5386    }
5387
5388    #[tokio::test]
5389    async fn test_set_e_in_while_condition() {
5390        // set -e should not trigger on while condition failure
5391        let mut bash = Bash::new();
5392        let result = bash
5393            .exec("set -e; x=0; while [ \"$x\" -lt 2 ]; do echo \"x=$x\"; x=$((x + 1)); done; echo done")
5394            .await
5395            .unwrap();
5396        assert_eq!(result.stdout, "x=0\nx=1\ndone\n");
5397    }
5398
5399    #[tokio::test]
5400    async fn test_set_e_in_brace_group() {
5401        // set -e should work inside brace groups
5402        let mut bash = Bash::new();
5403        let result = bash
5404            .exec("set -e; { echo start; false; echo unreached; }; echo after")
5405            .await
5406            .unwrap();
5407        assert_eq!(result.stdout, "start\n");
5408        assert_eq!(result.exit_code, 1);
5409    }
5410
5411    #[tokio::test]
5412    async fn test_set_e_and_chain() {
5413        // set -e should not trigger on && chain (false && ... is expected to not run second)
5414        let mut bash = Bash::new();
5415        let result = bash
5416            .exec("set -e; false && echo one; echo reached")
5417            .await
5418            .unwrap();
5419        assert_eq!(result.stdout, "reached\n");
5420    }
5421
5422    #[tokio::test]
5423    async fn test_set_e_or_chain() {
5424        // set -e should not trigger on || chain (true || false is expected to short circuit)
5425        let mut bash = Bash::new();
5426        let result = bash
5427            .exec("set -e; true || false; echo reached")
5428            .await
5429            .unwrap();
5430        assert_eq!(result.stdout, "reached\n");
5431    }
5432
5433    // Tilde expansion tests
5434
5435    #[tokio::test]
5436    async fn test_tilde_expansion_basic() {
5437        // ~ should expand to $HOME
5438        let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
5439        let result = bash.exec("echo ~").await.unwrap();
5440        assert_eq!(result.stdout, "/home/testuser\n");
5441    }
5442
5443    #[tokio::test]
5444    async fn test_tilde_expansion_with_path() {
5445        // ~/path should expand to $HOME/path
5446        let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
5447        let result = bash.exec("echo ~/documents/file.txt").await.unwrap();
5448        assert_eq!(result.stdout, "/home/testuser/documents/file.txt\n");
5449    }
5450
5451    #[tokio::test]
5452    async fn test_tilde_expansion_in_assignment() {
5453        // Tilde expansion should work in variable assignments
5454        let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
5455        let result = bash.exec("DIR=~/data; echo $DIR").await.unwrap();
5456        assert_eq!(result.stdout, "/home/testuser/data\n");
5457    }
5458
5459    #[tokio::test]
5460    async fn test_tilde_expansion_default_home() {
5461        // ~ should default to /home/sandbox (DEFAULT_USERNAME is "sandbox")
5462        let mut bash = Bash::new();
5463        let result = bash.exec("echo ~").await.unwrap();
5464        assert_eq!(result.stdout, "/home/sandbox\n");
5465    }
5466
5467    #[tokio::test]
5468    async fn test_tilde_not_at_start() {
5469        // ~ not at start of word should not expand
5470        let mut bash = Bash::builder().env("HOME", "/home/testuser").build();
5471        let result = bash.exec("echo foo~bar").await.unwrap();
5472        assert_eq!(result.stdout, "foo~bar\n");
5473    }
5474
5475    // Special variables tests
5476
5477    #[tokio::test]
5478    async fn test_special_var_dollar_dollar() {
5479        // $$ - current process ID
5480        let mut bash = Bash::new();
5481        let result = bash.exec("echo $$").await.unwrap();
5482        // Should be a numeric value
5483        let pid: u32 = result.stdout.trim().parse().expect("$$ should be a number");
5484        assert!(pid > 0, "$$ should be a positive number");
5485    }
5486
5487    #[tokio::test]
5488    async fn test_special_var_random() {
5489        // $RANDOM - random number between 0 and 32767
5490        let mut bash = Bash::new();
5491        let result = bash.exec("echo $RANDOM").await.unwrap();
5492        let random: u32 = result
5493            .stdout
5494            .trim()
5495            .parse()
5496            .expect("$RANDOM should be a number");
5497        assert!(random < 32768, "$RANDOM should be < 32768");
5498    }
5499
5500    #[tokio::test]
5501    async fn test_special_var_random_varies() {
5502        // $RANDOM should return different values on different calls
5503        let mut bash = Bash::new();
5504        let result1 = bash.exec("echo $RANDOM").await.unwrap();
5505        let result2 = bash.exec("echo $RANDOM").await.unwrap();
5506        // With high probability, they should be different
5507        // (small chance they're the same, so this test may rarely fail)
5508        // We'll just check they're both valid numbers
5509        let _: u32 = result1
5510            .stdout
5511            .trim()
5512            .parse()
5513            .expect("$RANDOM should be a number");
5514        let _: u32 = result2
5515            .stdout
5516            .trim()
5517            .parse()
5518            .expect("$RANDOM should be a number");
5519    }
5520
5521    #[tokio::test]
5522    async fn test_random_different_instances() {
5523        // Two separate Bash instances should produce different PRNG sequences
5524        // (with very high probability, since each is seeded from OS entropy)
5525        let mut bash1 = Bash::new();
5526        let mut bash2 = Bash::new();
5527        let r1 = bash1.exec("echo $RANDOM").await.unwrap();
5528        let r2 = bash2.exec("echo $RANDOM").await.unwrap();
5529        let v1: u32 = r1.stdout.trim().parse().expect("should be a number");
5530        let v2: u32 = r2.stdout.trim().parse().expect("should be a number");
5531        assert!(v1 < 32768);
5532        assert!(v2 < 32768);
5533        // Extremely unlikely to collide with independent OS-entropy seeds
5534        assert_ne!(v1, v2, "separate instances should produce different values");
5535    }
5536
5537    #[tokio::test]
5538    async fn test_random_reseed() {
5539        // RANDOM=N should reseed the PRNG, producing a deterministic sequence
5540        let mut bash1 = Bash::new();
5541        let mut bash2 = Bash::new();
5542        bash1.exec("RANDOM=42").await.unwrap();
5543        bash2.exec("RANDOM=42").await.unwrap();
5544        let r1 = bash1.exec("echo $RANDOM").await.unwrap();
5545        let r2 = bash2.exec("echo $RANDOM").await.unwrap();
5546        assert_eq!(
5547            r1.stdout, r2.stdout,
5548            "same seed should produce same first value"
5549        );
5550    }
5551
5552    #[tokio::test]
5553    async fn test_random_sequential_varies() {
5554        // Sequential $RANDOM calls within a single instance should differ
5555        let mut bash = Bash::new();
5556        let result = bash.exec("echo $RANDOM $RANDOM $RANDOM").await.unwrap();
5557        let values: Vec<u32> = result
5558            .stdout
5559            .split_whitespace()
5560            .map(|s| s.parse().expect("should be a number"))
5561            .collect();
5562        assert_eq!(values.len(), 3);
5563        // At least two of three should differ (LCG never produces same value twice in a row)
5564        assert!(
5565            values[0] != values[1] || values[1] != values[2],
5566            "sequential RANDOM calls should produce different values"
5567        );
5568    }
5569
5570    #[tokio::test]
5571    async fn test_special_var_lineno() {
5572        // $LINENO - current line number
5573        let mut bash = Bash::new();
5574        let result = bash.exec("echo $LINENO").await.unwrap();
5575        assert_eq!(result.stdout, "1\n");
5576    }
5577
5578    #[tokio::test]
5579    async fn test_lineno_multiline() {
5580        // $LINENO tracks line numbers across multiple lines
5581        let mut bash = Bash::new();
5582        let result = bash
5583            .exec(
5584                r#"echo "line $LINENO"
5585echo "line $LINENO"
5586echo "line $LINENO""#,
5587            )
5588            .await
5589            .unwrap();
5590        assert_eq!(result.stdout, "line 1\nline 2\nline 3\n");
5591    }
5592
5593    #[tokio::test]
5594    async fn test_lineno_in_loop() {
5595        // $LINENO inside a for loop
5596        let mut bash = Bash::new();
5597        let result = bash
5598            .exec(
5599                r#"for i in 1 2; do
5600  echo "loop $LINENO"
5601done"#,
5602            )
5603            .await
5604            .unwrap();
5605        // Loop body is on line 2
5606        assert_eq!(result.stdout, "loop 2\nloop 2\n");
5607    }
5608
5609    // File test operator tests
5610
5611    #[tokio::test]
5612    async fn test_file_test_r_readable() {
5613        // -r file: true if file exists (readable in virtual fs)
5614        let mut bash = Bash::new();
5615        bash.exec("echo hello > /tmp/readable.txt").await.unwrap();
5616        let result = bash
5617            .exec("test -r /tmp/readable.txt && echo yes")
5618            .await
5619            .unwrap();
5620        assert_eq!(result.stdout, "yes\n");
5621    }
5622
5623    #[tokio::test]
5624    async fn test_file_test_r_not_exists() {
5625        // -r file: false if file doesn't exist
5626        let mut bash = Bash::new();
5627        let result = bash
5628            .exec("test -r /tmp/nonexistent.txt && echo yes || echo no")
5629            .await
5630            .unwrap();
5631        assert_eq!(result.stdout, "no\n");
5632    }
5633
5634    #[tokio::test]
5635    async fn test_file_test_w_writable() {
5636        // -w file: true if file exists (writable in virtual fs)
5637        let mut bash = Bash::new();
5638        bash.exec("echo hello > /tmp/writable.txt").await.unwrap();
5639        let result = bash
5640            .exec("test -w /tmp/writable.txt && echo yes")
5641            .await
5642            .unwrap();
5643        assert_eq!(result.stdout, "yes\n");
5644    }
5645
5646    #[tokio::test]
5647    async fn test_file_test_x_executable() {
5648        // -x file: true if file exists and has execute permission
5649        let mut bash = Bash::new();
5650        bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
5651            .await
5652            .unwrap();
5653        bash.exec("chmod 755 /tmp/script.sh").await.unwrap();
5654        let result = bash
5655            .exec("test -x /tmp/script.sh && echo yes")
5656            .await
5657            .unwrap();
5658        assert_eq!(result.stdout, "yes\n");
5659    }
5660
5661    #[tokio::test]
5662    async fn test_file_test_x_not_executable() {
5663        // -x file: false if file has no execute permission
5664        let mut bash = Bash::new();
5665        bash.exec("echo 'data' > /tmp/noexec.txt").await.unwrap();
5666        bash.exec("chmod 644 /tmp/noexec.txt").await.unwrap();
5667        let result = bash
5668            .exec("test -x /tmp/noexec.txt && echo yes || echo no")
5669            .await
5670            .unwrap();
5671        assert_eq!(result.stdout, "no\n");
5672    }
5673
5674    #[tokio::test]
5675    async fn test_file_test_e_exists() {
5676        // -e file: true if file exists
5677        let mut bash = Bash::new();
5678        bash.exec("echo hello > /tmp/exists.txt").await.unwrap();
5679        let result = bash
5680            .exec("test -e /tmp/exists.txt && echo yes")
5681            .await
5682            .unwrap();
5683        assert_eq!(result.stdout, "yes\n");
5684    }
5685
5686    #[tokio::test]
5687    async fn test_file_test_f_regular() {
5688        // -f file: true if regular file
5689        let mut bash = Bash::new();
5690        bash.exec("echo hello > /tmp/regular.txt").await.unwrap();
5691        let result = bash
5692            .exec("test -f /tmp/regular.txt && echo yes")
5693            .await
5694            .unwrap();
5695        assert_eq!(result.stdout, "yes\n");
5696    }
5697
5698    #[tokio::test]
5699    async fn test_file_test_d_directory() {
5700        // -d file: true if directory
5701        let mut bash = Bash::new();
5702        bash.exec("mkdir -p /tmp/mydir").await.unwrap();
5703        let result = bash.exec("test -d /tmp/mydir && echo yes").await.unwrap();
5704        assert_eq!(result.stdout, "yes\n");
5705    }
5706
5707    #[tokio::test]
5708    async fn test_file_test_s_size() {
5709        // -s file: true if file has size > 0
5710        let mut bash = Bash::new();
5711        bash.exec("echo hello > /tmp/nonempty.txt").await.unwrap();
5712        let result = bash
5713            .exec("test -s /tmp/nonempty.txt && echo yes")
5714            .await
5715            .unwrap();
5716        assert_eq!(result.stdout, "yes\n");
5717    }
5718
5719    // ============================================================
5720    // Stderr Redirection Tests
5721    // ============================================================
5722
5723    #[tokio::test]
5724    async fn test_redirect_both_stdout_stderr() {
5725        // &> redirects both stdout and stderr to file
5726        let mut bash = Bash::new();
5727        // echo outputs to stdout, we use &> to redirect both to file
5728        let result = bash.exec("echo hello &> /tmp/out.txt").await.unwrap();
5729        // stdout should be empty (redirected to file)
5730        assert_eq!(result.stdout, "");
5731        // Verify file contents
5732        let check = bash.exec("cat /tmp/out.txt").await.unwrap();
5733        assert_eq!(check.stdout, "hello\n");
5734    }
5735
5736    #[tokio::test]
5737    async fn test_stderr_redirect_to_file() {
5738        // 2> redirects stderr to file
5739        // We need a command that outputs to stderr - let's use a command that fails
5740        // Or use a subshell with explicit stderr output
5741        let mut bash = Bash::new();
5742        // Create a test script that outputs to both stdout and stderr
5743        bash.exec("echo stdout; echo stderr 2> /tmp/err.txt")
5744            .await
5745            .unwrap();
5746        // Note: echo stderr doesn't actually output to stderr, it outputs to stdout
5747        // We need to test with actual stderr output
5748    }
5749
5750    #[tokio::test]
5751    async fn test_fd_redirect_parsing() {
5752        // Test that 2> is parsed correctly
5753        let mut bash = Bash::new();
5754        // Just test the parsing doesn't error
5755        let result = bash.exec("true 2> /tmp/err.txt").await.unwrap();
5756        assert_eq!(result.exit_code, 0);
5757    }
5758
5759    #[tokio::test]
5760    async fn test_fd_redirect_append_parsing() {
5761        // Test that 2>> is parsed correctly
5762        let mut bash = Bash::new();
5763        let result = bash.exec("true 2>> /tmp/err.txt").await.unwrap();
5764        assert_eq!(result.exit_code, 0);
5765    }
5766
5767    #[tokio::test]
5768    async fn test_fd_dup_parsing() {
5769        // Test that 2>&1 is parsed correctly
5770        let mut bash = Bash::new();
5771        let result = bash.exec("echo hello 2>&1").await.unwrap();
5772        assert_eq!(result.stdout, "hello\n");
5773        assert_eq!(result.exit_code, 0);
5774    }
5775
5776    #[tokio::test]
5777    async fn test_dup_output_redirect_stdout_to_stderr() {
5778        // >&2 redirects stdout to stderr
5779        let mut bash = Bash::new();
5780        let result = bash.exec("echo hello >&2").await.unwrap();
5781        // stdout should be moved to stderr
5782        assert_eq!(result.stdout, "");
5783        assert_eq!(result.stderr, "hello\n");
5784    }
5785
5786    #[tokio::test]
5787    async fn test_lexer_redirect_both() {
5788        // Test that &> is lexed as a single token, not & followed by >
5789        let mut bash = Bash::new();
5790        // Without proper lexing, this would be parsed as background + redirect
5791        let result = bash.exec("echo test &> /tmp/both.txt").await.unwrap();
5792        assert_eq!(result.stdout, "");
5793        let check = bash.exec("cat /tmp/both.txt").await.unwrap();
5794        assert_eq!(check.stdout, "test\n");
5795    }
5796
5797    #[tokio::test]
5798    async fn test_lexer_dup_output() {
5799        // Test that >& is lexed correctly
5800        let mut bash = Bash::new();
5801        let result = bash.exec("echo test >&2").await.unwrap();
5802        assert_eq!(result.stdout, "");
5803        assert_eq!(result.stderr, "test\n");
5804    }
5805
5806    #[tokio::test]
5807    async fn test_digit_before_redirect() {
5808        // Test that 2> works with digits
5809        let mut bash = Bash::new();
5810        // 2> should be recognized as stderr redirect
5811        let result = bash.exec("echo hello 2> /tmp/err.txt").await.unwrap();
5812        assert_eq!(result.exit_code, 0);
5813        // stdout should still have the output since echo doesn't write to stderr
5814        assert_eq!(result.stdout, "hello\n");
5815    }
5816
5817    // ============================================================
5818    // Arithmetic Logical Operator Tests
5819    // ============================================================
5820
5821    #[tokio::test]
5822    async fn test_arithmetic_logical_and_true() {
5823        // Both sides true
5824        let mut bash = Bash::new();
5825        let result = bash.exec("echo $((1 && 1))").await.unwrap();
5826        assert_eq!(result.stdout, "1\n");
5827    }
5828
5829    #[tokio::test]
5830    async fn test_arithmetic_logical_and_false_left() {
5831        // Left side false - short circuits
5832        let mut bash = Bash::new();
5833        let result = bash.exec("echo $((0 && 1))").await.unwrap();
5834        assert_eq!(result.stdout, "0\n");
5835    }
5836
5837    #[tokio::test]
5838    async fn test_arithmetic_logical_and_false_right() {
5839        // Right side false
5840        let mut bash = Bash::new();
5841        let result = bash.exec("echo $((1 && 0))").await.unwrap();
5842        assert_eq!(result.stdout, "0\n");
5843    }
5844
5845    #[tokio::test]
5846    async fn test_arithmetic_logical_or_false() {
5847        // Both sides false
5848        let mut bash = Bash::new();
5849        let result = bash.exec("echo $((0 || 0))").await.unwrap();
5850        assert_eq!(result.stdout, "0\n");
5851    }
5852
5853    #[tokio::test]
5854    async fn test_arithmetic_logical_or_true_left() {
5855        // Left side true - short circuits
5856        let mut bash = Bash::new();
5857        let result = bash.exec("echo $((1 || 0))").await.unwrap();
5858        assert_eq!(result.stdout, "1\n");
5859    }
5860
5861    #[tokio::test]
5862    async fn test_arithmetic_logical_or_true_right() {
5863        // Right side true
5864        let mut bash = Bash::new();
5865        let result = bash.exec("echo $((0 || 1))").await.unwrap();
5866        assert_eq!(result.stdout, "1\n");
5867    }
5868
5869    #[tokio::test]
5870    async fn test_arithmetic_logical_combined() {
5871        // Combined && and || with expressions
5872        let mut bash = Bash::new();
5873        // (5 > 3) && (2 < 4) => 1 && 1 => 1
5874        let result = bash.exec("echo $((5 > 3 && 2 < 4))").await.unwrap();
5875        assert_eq!(result.stdout, "1\n");
5876    }
5877
5878    #[tokio::test]
5879    async fn test_arithmetic_logical_with_comparison() {
5880        // || with comparison
5881        let mut bash = Bash::new();
5882        // (5 < 3) || (2 < 4) => 0 || 1 => 1
5883        let result = bash.exec("echo $((5 < 3 || 2 < 4))").await.unwrap();
5884        assert_eq!(result.stdout, "1\n");
5885    }
5886
5887    #[tokio::test]
5888    async fn test_arithmetic_multibyte_no_panic() {
5889        // Regression: multi-byte chars caused char-index/byte-index mismatch panic
5890        let mut bash = Bash::new();
5891        // Multi-byte char in comma expression - should not panic
5892        let result = bash.exec("echo $((0,1))").await.unwrap();
5893        assert_eq!(result.stdout, "1\n");
5894        // Ensure multi-byte input doesn't panic (treated as 0 / error)
5895        let _ = bash.exec("echo $((\u{00e9}+1))").await;
5896    }
5897
5898    // ============================================================
5899    // Brace Expansion Tests
5900    // ============================================================
5901
5902    #[tokio::test]
5903    async fn test_brace_expansion_list() {
5904        // {a,b,c} expands to a b c
5905        let mut bash = Bash::new();
5906        let result = bash.exec("echo {a,b,c}").await.unwrap();
5907        assert_eq!(result.stdout, "a b c\n");
5908    }
5909
5910    #[tokio::test]
5911    async fn test_brace_expansion_with_prefix() {
5912        // file{1,2,3}.txt expands to file1.txt file2.txt file3.txt
5913        let mut bash = Bash::new();
5914        let result = bash.exec("echo file{1,2,3}.txt").await.unwrap();
5915        assert_eq!(result.stdout, "file1.txt file2.txt file3.txt\n");
5916    }
5917
5918    #[tokio::test]
5919    async fn test_brace_expansion_numeric_range() {
5920        // {1..5} expands to 1 2 3 4 5
5921        let mut bash = Bash::new();
5922        let result = bash.exec("echo {1..5}").await.unwrap();
5923        assert_eq!(result.stdout, "1 2 3 4 5\n");
5924    }
5925
5926    #[tokio::test]
5927    async fn test_brace_expansion_char_range() {
5928        // {a..e} expands to a b c d e
5929        let mut bash = Bash::new();
5930        let result = bash.exec("echo {a..e}").await.unwrap();
5931        assert_eq!(result.stdout, "a b c d e\n");
5932    }
5933
5934    #[tokio::test]
5935    async fn test_brace_expansion_reverse_range() {
5936        // {5..1} expands to 5 4 3 2 1
5937        let mut bash = Bash::new();
5938        let result = bash.exec("echo {5..1}").await.unwrap();
5939        assert_eq!(result.stdout, "5 4 3 2 1\n");
5940    }
5941
5942    #[tokio::test]
5943    async fn test_brace_expansion_nested() {
5944        // Nested brace expansion: {a,b}{1,2}
5945        let mut bash = Bash::new();
5946        let result = bash.exec("echo {a,b}{1,2}").await.unwrap();
5947        assert_eq!(result.stdout, "a1 a2 b1 b2\n");
5948    }
5949
5950    #[tokio::test]
5951    async fn test_brace_expansion_with_suffix() {
5952        // Prefix and suffix: pre{x,y}suf
5953        let mut bash = Bash::new();
5954        let result = bash.exec("echo pre{x,y}suf").await.unwrap();
5955        assert_eq!(result.stdout, "prexsuf preysuf\n");
5956    }
5957
5958    #[tokio::test]
5959    async fn test_brace_expansion_empty_item() {
5960        // {,foo} expands to (empty) foo
5961        let mut bash = Bash::new();
5962        let result = bash.exec("echo x{,y}z").await.unwrap();
5963        assert_eq!(result.stdout, "xz xyz\n");
5964    }
5965
5966    // ============================================================
5967    // String Comparison Tests
5968    // ============================================================
5969
5970    #[tokio::test]
5971    async fn test_string_less_than() {
5972        let mut bash = Bash::new();
5973        let result = bash
5974            .exec("test apple '<' banana && echo yes")
5975            .await
5976            .unwrap();
5977        assert_eq!(result.stdout, "yes\n");
5978    }
5979
5980    #[tokio::test]
5981    async fn test_string_greater_than() {
5982        let mut bash = Bash::new();
5983        let result = bash
5984            .exec("test banana '>' apple && echo yes")
5985            .await
5986            .unwrap();
5987        assert_eq!(result.stdout, "yes\n");
5988    }
5989
5990    #[tokio::test]
5991    async fn test_string_less_than_false() {
5992        let mut bash = Bash::new();
5993        let result = bash
5994            .exec("test banana '<' apple && echo yes || echo no")
5995            .await
5996            .unwrap();
5997        assert_eq!(result.stdout, "no\n");
5998    }
5999
6000    // ============================================================
6001    // Array Indices Tests
6002    // ============================================================
6003
6004    #[tokio::test]
6005    async fn test_array_indices_basic() {
6006        // ${!arr[@]} returns the indices of the array
6007        let mut bash = Bash::new();
6008        let result = bash.exec("arr=(a b c); echo ${!arr[@]}").await.unwrap();
6009        assert_eq!(result.stdout, "0 1 2\n");
6010    }
6011
6012    #[tokio::test]
6013    async fn test_array_indices_sparse() {
6014        // ${!arr[@]} should show indices even for sparse arrays
6015        let mut bash = Bash::new();
6016        let result = bash
6017            .exec("arr[0]=a; arr[5]=b; arr[10]=c; echo ${!arr[@]}")
6018            .await
6019            .unwrap();
6020        assert_eq!(result.stdout, "0 5 10\n");
6021    }
6022
6023    #[tokio::test]
6024    async fn test_array_indices_star() {
6025        // ${!arr[*]} should also work
6026        let mut bash = Bash::new();
6027        let result = bash.exec("arr=(x y z); echo ${!arr[*]}").await.unwrap();
6028        assert_eq!(result.stdout, "0 1 2\n");
6029    }
6030
6031    #[tokio::test]
6032    async fn test_array_indices_empty() {
6033        // Empty array should return empty string
6034        let mut bash = Bash::new();
6035        let result = bash.exec("arr=(); echo \"${!arr[@]}\"").await.unwrap();
6036        assert_eq!(result.stdout, "\n");
6037    }
6038
6039    // ============================================================
6040    // Text file builder methods
6041    // ============================================================
6042
6043    #[tokio::test]
6044    async fn test_text_file_basic() {
6045        let mut bash = Bash::builder()
6046            .mount_text("/config/app.conf", "debug=true\nport=8080\n")
6047            .build();
6048
6049        let result = bash.exec("cat /config/app.conf").await.unwrap();
6050        assert_eq!(result.stdout, "debug=true\nport=8080\n");
6051    }
6052
6053    #[tokio::test]
6054    async fn test_text_file_multiple() {
6055        let mut bash = Bash::builder()
6056            .mount_text("/data/file1.txt", "content one")
6057            .mount_text("/data/file2.txt", "content two")
6058            .mount_text("/other/file3.txt", "content three")
6059            .build();
6060
6061        let result = bash.exec("cat /data/file1.txt").await.unwrap();
6062        assert_eq!(result.stdout, "content one");
6063
6064        let result = bash.exec("cat /data/file2.txt").await.unwrap();
6065        assert_eq!(result.stdout, "content two");
6066
6067        let result = bash.exec("cat /other/file3.txt").await.unwrap();
6068        assert_eq!(result.stdout, "content three");
6069    }
6070
6071    #[tokio::test]
6072    async fn test_text_file_nested_directory() {
6073        // Parent directories should be created automatically
6074        let mut bash = Bash::builder()
6075            .mount_text("/a/b/c/d/file.txt", "nested content")
6076            .build();
6077
6078        let result = bash.exec("cat /a/b/c/d/file.txt").await.unwrap();
6079        assert_eq!(result.stdout, "nested content");
6080    }
6081
6082    #[tokio::test]
6083    async fn test_text_file_mode() {
6084        let bash = Bash::builder()
6085            .mount_text("/tmp/writable.txt", "content")
6086            .build();
6087
6088        let stat = bash
6089            .fs()
6090            .stat(std::path::Path::new("/tmp/writable.txt"))
6091            .await
6092            .unwrap();
6093        assert_eq!(stat.mode, 0o644);
6094    }
6095
6096    #[tokio::test]
6097    async fn test_readonly_text_basic() {
6098        let mut bash = Bash::builder()
6099            .mount_readonly_text("/etc/version", "1.2.3")
6100            .build();
6101
6102        let result = bash.exec("cat /etc/version").await.unwrap();
6103        assert_eq!(result.stdout, "1.2.3");
6104    }
6105
6106    #[tokio::test]
6107    async fn test_readonly_text_mode() {
6108        let bash = Bash::builder()
6109            .mount_readonly_text("/etc/readonly.conf", "immutable")
6110            .build();
6111
6112        let stat = bash
6113            .fs()
6114            .stat(std::path::Path::new("/etc/readonly.conf"))
6115            .await
6116            .unwrap();
6117        assert_eq!(stat.mode, 0o444);
6118    }
6119
6120    #[tokio::test]
6121    async fn test_text_file_mixed_readonly_writable() {
6122        let bash = Bash::builder()
6123            .mount_text("/data/writable.txt", "can edit")
6124            .mount_readonly_text("/data/readonly.txt", "cannot edit")
6125            .build();
6126
6127        let writable_stat = bash
6128            .fs()
6129            .stat(std::path::Path::new("/data/writable.txt"))
6130            .await
6131            .unwrap();
6132        let readonly_stat = bash
6133            .fs()
6134            .stat(std::path::Path::new("/data/readonly.txt"))
6135            .await
6136            .unwrap();
6137
6138        assert_eq!(writable_stat.mode, 0o644);
6139        assert_eq!(readonly_stat.mode, 0o444);
6140    }
6141
6142    #[tokio::test]
6143    async fn test_text_file_with_env() {
6144        // text_file should work alongside other builder methods
6145        let mut bash = Bash::builder()
6146            .env("APP_NAME", "testapp")
6147            .mount_text("/config/app.conf", "name=${APP_NAME}")
6148            .build();
6149
6150        let result = bash.exec("echo $APP_NAME").await.unwrap();
6151        assert_eq!(result.stdout, "testapp\n");
6152
6153        let result = bash.exec("cat /config/app.conf").await.unwrap();
6154        assert_eq!(result.stdout, "name=${APP_NAME}");
6155    }
6156
6157    #[tokio::test]
6158    #[cfg(feature = "jq")]
6159    async fn test_text_file_json() {
6160        let mut bash = Bash::builder()
6161            .mount_text("/data/users.json", r#"["alice", "bob", "charlie"]"#)
6162            .build();
6163
6164        let result = bash.exec("cat /data/users.json | jq '.[0]'").await.unwrap();
6165        assert_eq!(result.stdout, "\"alice\"\n");
6166    }
6167
6168    #[tokio::test]
6169    async fn test_mount_with_custom_filesystem() {
6170        // Mount files work with custom filesystems via OverlayFs
6171        let custom_fs = std::sync::Arc::new(InMemoryFs::new());
6172
6173        // Pre-populate the base filesystem
6174        custom_fs
6175            .write_file(std::path::Path::new("/base.txt"), b"from base")
6176            .await
6177            .unwrap();
6178
6179        let mut bash = Bash::builder()
6180            .fs(custom_fs)
6181            .mount_text("/mounted.txt", "from mount")
6182            .mount_readonly_text("/readonly.txt", "immutable")
6183            .build();
6184
6185        // Can read base file
6186        let result = bash.exec("cat /base.txt").await.unwrap();
6187        assert_eq!(result.stdout, "from base");
6188
6189        // Can read mounted files
6190        let result = bash.exec("cat /mounted.txt").await.unwrap();
6191        assert_eq!(result.stdout, "from mount");
6192
6193        let result = bash.exec("cat /readonly.txt").await.unwrap();
6194        assert_eq!(result.stdout, "immutable");
6195
6196        // Mounted readonly file has correct permissions
6197        let stat = bash
6198            .fs()
6199            .stat(std::path::Path::new("/readonly.txt"))
6200            .await
6201            .unwrap();
6202        assert_eq!(stat.mode, 0o444);
6203    }
6204
6205    #[tokio::test]
6206    async fn test_mount_overwrites_base_file() {
6207        // Mounted files take precedence over base filesystem
6208        let custom_fs = std::sync::Arc::new(InMemoryFs::new());
6209        custom_fs
6210            .write_file(std::path::Path::new("/config.txt"), b"original")
6211            .await
6212            .unwrap();
6213
6214        let mut bash = Bash::builder()
6215            .fs(custom_fs)
6216            .mount_text("/config.txt", "overwritten")
6217            .build();
6218
6219        let result = bash.exec("cat /config.txt").await.unwrap();
6220        assert_eq!(result.stdout, "overwritten");
6221    }
6222
6223    #[tokio::test]
6224    async fn test_mount_preserves_custom_fs_limits() {
6225        let limited_fs =
6226            std::sync::Arc::new(InMemoryFs::with_limits(FsLimits::new().max_total_bytes(32)));
6227
6228        let bash = Bash::builder()
6229            .fs(limited_fs)
6230            .mount_text("/mounted.txt", "seed")
6231            .build();
6232
6233        let write_err = bash
6234            .fs()
6235            .write_file(
6236                std::path::Path::new("/too-big.txt"),
6237                b"this payload should exceed thirty-two bytes",
6238            )
6239            .await;
6240        assert!(write_err.is_err(), "custom fs limits should still apply");
6241    }
6242
6243    #[tokio::test]
6244    async fn test_mount_text_respects_filesystem_limits() {
6245        let limited_fs = std::sync::Arc::new(InMemoryFs::with_limits(
6246            FsLimits::new().max_total_bytes(5).max_file_size(5),
6247        ));
6248
6249        let bash = Bash::builder()
6250            .fs(limited_fs)
6251            .mount_text("/too-large.txt", "123456")
6252            .build();
6253
6254        let exists = bash
6255            .fs()
6256            .exists(std::path::Path::new("/too-large.txt"))
6257            .await
6258            .unwrap();
6259        assert!(!exists, "mount_text should not bypass configured FsLimits");
6260    }
6261
6262    // ============================================================
6263    // Parser Error Location Tests
6264    // ============================================================
6265
6266    #[tokio::test]
6267    async fn test_parse_error_includes_line_number() {
6268        // Parse errors should include line/column info
6269        let mut bash = Bash::new();
6270        let result = bash
6271            .exec(
6272                r#"echo ok
6273if true; then
6274echo missing fi"#,
6275            )
6276            .await;
6277        // Should fail to parse due to missing 'fi'
6278        assert!(result.is_err());
6279        let err = result.unwrap_err();
6280        let err_msg = format!("{}", err);
6281        // Error should mention line number
6282        assert!(
6283            err_msg.contains("line") || err_msg.contains("parse"),
6284            "Error should be a parse error: {}",
6285            err_msg
6286        );
6287    }
6288
6289    #[tokio::test]
6290    async fn test_parse_error_on_specific_line() {
6291        // Syntax error on line 3 should report line 3
6292        use crate::parser::Parser;
6293        let script = "echo line1\necho line2\nif true; then\n";
6294        let result = Parser::new(script).parse();
6295        assert!(result.is_err());
6296        let err = result.unwrap_err();
6297        let err_msg = format!("{}", err);
6298        // Error should mention the problem (either "expected" or "syntax error")
6299        assert!(
6300            err_msg.contains("expected") || err_msg.contains("syntax error"),
6301            "Error should be a parse error: {}",
6302            err_msg
6303        );
6304    }
6305
6306    // ==================== Root directory access tests ====================
6307
6308    #[tokio::test]
6309    async fn test_cd_to_root_and_ls() {
6310        // Test: cd / && ls should work
6311        let mut bash = Bash::new();
6312        let result = bash.exec("cd / && ls").await.unwrap();
6313        assert_eq!(
6314            result.exit_code, 0,
6315            "cd / && ls should succeed: {}",
6316            result.stderr
6317        );
6318        assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6319        assert!(result.stdout.contains("home"), "Root should contain home");
6320    }
6321
6322    #[tokio::test]
6323    async fn test_cd_to_root_and_pwd() {
6324        // Test: cd / && pwd should show /
6325        let mut bash = Bash::new();
6326        let result = bash.exec("cd / && pwd").await.unwrap();
6327        assert_eq!(result.exit_code, 0, "cd / && pwd should succeed");
6328        assert_eq!(result.stdout.trim(), "/");
6329    }
6330
6331    #[tokio::test]
6332    async fn test_cd_to_root_and_ls_dot() {
6333        // Test: cd / && ls . should list root contents
6334        let mut bash = Bash::new();
6335        let result = bash.exec("cd / && ls .").await.unwrap();
6336        assert_eq!(
6337            result.exit_code, 0,
6338            "cd / && ls . should succeed: {}",
6339            result.stderr
6340        );
6341        assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6342        assert!(result.stdout.contains("home"), "Root should contain home");
6343    }
6344
6345    #[tokio::test]
6346    async fn test_ls_root_directly() {
6347        // Test: ls / should work
6348        let mut bash = Bash::new();
6349        let result = bash.exec("ls /").await.unwrap();
6350        assert_eq!(
6351            result.exit_code, 0,
6352            "ls / should succeed: {}",
6353            result.stderr
6354        );
6355        assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6356        assert!(result.stdout.contains("home"), "Root should contain home");
6357        assert!(result.stdout.contains("dev"), "Root should contain dev");
6358    }
6359
6360    #[tokio::test]
6361    async fn test_ls_root_long_format() {
6362        // Test: ls -la / should work
6363        let mut bash = Bash::new();
6364        let result = bash.exec("ls -la /").await.unwrap();
6365        assert_eq!(
6366            result.exit_code, 0,
6367            "ls -la / should succeed: {}",
6368            result.stderr
6369        );
6370        assert!(result.stdout.contains("tmp"), "Root should contain tmp");
6371        assert!(
6372            result.stdout.contains("drw"),
6373            "Should show directory permissions"
6374        );
6375    }
6376
6377    // === Issue 1: Heredoc file writes ===
6378
6379    #[tokio::test]
6380    async fn test_heredoc_redirect_to_file() {
6381        // cat > file <<'EOF' is the #1 way LLMs create multi-line files
6382        let mut bash = Bash::new();
6383        let result = bash
6384            .exec("cat > /tmp/out.txt <<'EOF'\nhello\nworld\nEOF\ncat /tmp/out.txt")
6385            .await
6386            .unwrap();
6387        assert_eq!(result.stdout, "hello\nworld\n");
6388        assert_eq!(result.exit_code, 0);
6389    }
6390
6391    #[tokio::test]
6392    async fn test_heredoc_redirect_to_file_unquoted() {
6393        let mut bash = Bash::new();
6394        let result = bash
6395            .exec("cat > /tmp/out.txt <<EOF\nhello\nworld\nEOF\ncat /tmp/out.txt")
6396            .await
6397            .unwrap();
6398        assert_eq!(result.stdout, "hello\nworld\n");
6399        assert_eq!(result.exit_code, 0);
6400    }
6401
6402    // === Issue 2: Compound pipelines ===
6403
6404    #[tokio::test]
6405    async fn test_pipe_to_while_read() {
6406        // cmd | while read ...; do ... done is extremely common
6407        let mut bash = Bash::new();
6408        let result = bash
6409            .exec("echo -e 'a\\nb\\nc' | while read line; do echo \"got: $line\"; done")
6410            .await
6411            .unwrap();
6412        assert!(
6413            result.stdout.contains("got: a"),
6414            "stdout: {}",
6415            result.stdout
6416        );
6417        assert!(
6418            result.stdout.contains("got: b"),
6419            "stdout: {}",
6420            result.stdout
6421        );
6422        assert!(
6423            result.stdout.contains("got: c"),
6424            "stdout: {}",
6425            result.stdout
6426        );
6427    }
6428
6429    #[tokio::test]
6430    async fn test_pipe_to_while_read_count() {
6431        let mut bash = Bash::new();
6432        let result = bash
6433            .exec("printf 'x\\ny\\nz\\n' | while read line; do echo $line; done")
6434            .await
6435            .unwrap();
6436        assert_eq!(result.stdout, "x\ny\nz\n");
6437    }
6438
6439    // === Issue 3: Source loading functions ===
6440
6441    #[tokio::test]
6442    async fn test_source_loads_functions() {
6443        let mut bash = Bash::new();
6444        // Write a function library, then source it and call the function
6445        bash.exec("cat > /tmp/lib.sh <<'EOF'\ngreet() { echo \"hello $1\"; }\nEOF")
6446            .await
6447            .unwrap();
6448        let result = bash.exec("source /tmp/lib.sh; greet world").await.unwrap();
6449        assert_eq!(result.stdout, "hello world\n");
6450        assert_eq!(result.exit_code, 0);
6451    }
6452
6453    #[tokio::test]
6454    async fn test_source_loads_variables() {
6455        let mut bash = Bash::new();
6456        bash.exec("echo 'MY_VAR=loaded' > /tmp/vars.sh")
6457            .await
6458            .unwrap();
6459        let result = bash
6460            .exec("source /tmp/vars.sh; echo $MY_VAR")
6461            .await
6462            .unwrap();
6463        assert_eq!(result.stdout, "loaded\n");
6464    }
6465
6466    // === Issue 4: chmod +x symbolic mode ===
6467
6468    #[tokio::test]
6469    async fn test_chmod_symbolic_plus_x() {
6470        let mut bash = Bash::new();
6471        bash.exec("echo '#!/bin/bash' > /tmp/script.sh")
6472            .await
6473            .unwrap();
6474        let result = bash.exec("chmod +x /tmp/script.sh").await.unwrap();
6475        assert_eq!(
6476            result.exit_code, 0,
6477            "chmod +x should succeed: {}",
6478            result.stderr
6479        );
6480    }
6481
6482    #[tokio::test]
6483    async fn test_chmod_symbolic_u_plus_x() {
6484        let mut bash = Bash::new();
6485        bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
6486        let result = bash.exec("chmod u+x /tmp/file.txt").await.unwrap();
6487        assert_eq!(
6488            result.exit_code, 0,
6489            "chmod u+x should succeed: {}",
6490            result.stderr
6491        );
6492    }
6493
6494    #[tokio::test]
6495    async fn test_chmod_symbolic_a_plus_r() {
6496        let mut bash = Bash::new();
6497        bash.exec("echo 'test' > /tmp/file.txt").await.unwrap();
6498        let result = bash.exec("chmod a+r /tmp/file.txt").await.unwrap();
6499        assert_eq!(
6500            result.exit_code, 0,
6501            "chmod a+r should succeed: {}",
6502            result.stderr
6503        );
6504    }
6505
6506    // === Issue 5: Awk arrays ===
6507
6508    #[tokio::test]
6509    async fn test_awk_array_length() {
6510        // length(arr) should return element count
6511        let mut bash = Bash::new();
6512        let result = bash
6513            .exec(r#"echo "" | awk 'BEGIN{a[1]="x"; a[2]="y"; a[3]="z"} END{print length(a)}'"#)
6514            .await
6515            .unwrap();
6516        assert_eq!(result.stdout, "3\n");
6517    }
6518
6519    #[tokio::test]
6520    async fn test_awk_array_read_after_split() {
6521        // split() + reading elements back
6522        let mut bash = Bash::new();
6523        let result = bash
6524            .exec(r#"echo "a:b:c" | awk '{n=split($0,arr,":"); for(i=1;i<=n;i++) print arr[i]}'"#)
6525            .await
6526            .unwrap();
6527        assert_eq!(result.stdout, "a\nb\nc\n");
6528    }
6529
6530    #[tokio::test]
6531    async fn test_awk_array_word_count_pattern() {
6532        // Classic word frequency count - the most common awk array pattern
6533        let mut bash = Bash::new();
6534        let result = bash
6535            .exec(
6536                r#"printf "apple\nbanana\napple\ncherry\nbanana\napple" | awk '{count[$1]++} END{for(w in count) print w, count[w]}'"#,
6537            )
6538            .await
6539            .unwrap();
6540        assert!(
6541            result.stdout.contains("apple 3"),
6542            "stdout: {}",
6543            result.stdout
6544        );
6545        assert!(
6546            result.stdout.contains("banana 2"),
6547            "stdout: {}",
6548            result.stdout
6549        );
6550        assert!(
6551            result.stdout.contains("cherry 1"),
6552            "stdout: {}",
6553            result.stdout
6554        );
6555    }
6556
6557    // ---- Streaming output tests ----
6558
6559    #[tokio::test]
6560    async fn test_exec_streaming_for_loop() {
6561        let chunks = Arc::new(Mutex::new(Vec::new()));
6562        let chunks_cb = chunks.clone();
6563        let mut bash = Bash::new();
6564
6565        let result = bash
6566            .exec_streaming(
6567                "for i in 1 2 3; do echo $i; done",
6568                Box::new(move |stdout, _stderr| {
6569                    chunks_cb.lock().unwrap().push(stdout.to_string());
6570                }),
6571            )
6572            .await
6573            .unwrap();
6574
6575        assert_eq!(result.stdout, "1\n2\n3\n");
6576        assert_eq!(
6577            *chunks.lock().unwrap(),
6578            vec!["1\n", "2\n", "3\n"],
6579            "each loop iteration should stream separately"
6580        );
6581    }
6582
6583    #[tokio::test]
6584    async fn test_exec_streaming_while_loop() {
6585        let chunks = Arc::new(Mutex::new(Vec::new()));
6586        let chunks_cb = chunks.clone();
6587        let mut bash = Bash::new();
6588
6589        let result = bash
6590            .exec_streaming(
6591                "i=0; while [ $i -lt 3 ]; do i=$((i+1)); echo $i; done",
6592                Box::new(move |stdout, _stderr| {
6593                    chunks_cb.lock().unwrap().push(stdout.to_string());
6594                }),
6595            )
6596            .await
6597            .unwrap();
6598
6599        assert_eq!(result.stdout, "1\n2\n3\n");
6600        let chunks = chunks.lock().unwrap();
6601        // The while loop emits each iteration; surrounding list may add events too
6602        assert!(
6603            chunks.contains(&"1\n".to_string()),
6604            "should contain first iteration output"
6605        );
6606        assert!(
6607            chunks.contains(&"2\n".to_string()),
6608            "should contain second iteration output"
6609        );
6610        assert!(
6611            chunks.contains(&"3\n".to_string()),
6612            "should contain third iteration output"
6613        );
6614    }
6615
6616    #[tokio::test]
6617    async fn test_exec_streaming_no_callback_still_works() {
6618        // exec (non-streaming) should still work fine
6619        let mut bash = Bash::new();
6620        let result = bash.exec("for i in a b c; do echo $i; done").await.unwrap();
6621        assert_eq!(result.stdout, "a\nb\nc\n");
6622    }
6623
6624    #[tokio::test]
6625    async fn test_exec_streaming_cancel_clears_callback() {
6626        use std::time::Duration;
6627
6628        let chunks = Arc::new(Mutex::new(Vec::new()));
6629        let chunks_cb = chunks.clone();
6630        let mut bash = Bash::new();
6631
6632        let timed_out = tokio::time::timeout(
6633            Duration::from_millis(10),
6634            bash.exec_streaming(
6635                "sleep 1; echo should-not-run",
6636                Box::new(move |stdout, stderr| {
6637                    chunks_cb
6638                        .lock()
6639                        .unwrap()
6640                        .push((stdout.to_string(), stderr.to_string()));
6641                }),
6642            ),
6643        )
6644        .await;
6645
6646        assert!(timed_out.is_err(), "streaming execution should time out");
6647
6648        let result = bash.exec("echo later-run").await.unwrap();
6649
6650        assert_eq!(result.stdout, "later-run\n");
6651        assert_eq!(
6652            *chunks.lock().unwrap(),
6653            Vec::<(String, String)>::new(),
6654            "cancelled streaming callback must not receive later output"
6655        );
6656    }
6657
6658    #[tokio::test]
6659    async fn test_exec_streaming_nested_loops_no_duplicates() {
6660        let chunks = Arc::new(Mutex::new(Vec::new()));
6661        let chunks_cb = chunks.clone();
6662        let mut bash = Bash::new();
6663
6664        let result = bash
6665            .exec_streaming(
6666                "for i in 1 2; do for j in a b; do echo \"$i$j\"; done; done",
6667                Box::new(move |stdout, _stderr| {
6668                    chunks_cb.lock().unwrap().push(stdout.to_string());
6669                }),
6670            )
6671            .await
6672            .unwrap();
6673
6674        assert_eq!(result.stdout, "1a\n1b\n2a\n2b\n");
6675        let chunks = chunks.lock().unwrap();
6676        // Inner loop should emit each iteration; outer should not duplicate
6677        let total_chars: usize = chunks.iter().map(|c| c.len()).sum();
6678        assert_eq!(
6679            total_chars,
6680            result.stdout.len(),
6681            "total streamed bytes should match final output: chunks={:?}",
6682            *chunks
6683        );
6684    }
6685
6686    #[tokio::test]
6687    async fn test_exec_streaming_mixed_list_and_loop() {
6688        let chunks = Arc::new(Mutex::new(Vec::new()));
6689        let chunks_cb = chunks.clone();
6690        let mut bash = Bash::new();
6691
6692        let result = bash
6693            .exec_streaming(
6694                "echo start; for i in 1 2; do echo $i; done; echo end",
6695                Box::new(move |stdout, _stderr| {
6696                    chunks_cb.lock().unwrap().push(stdout.to_string());
6697                }),
6698            )
6699            .await
6700            .unwrap();
6701
6702        assert_eq!(result.stdout, "start\n1\n2\nend\n");
6703        let chunks = chunks.lock().unwrap();
6704        assert_eq!(
6705            *chunks,
6706            vec!["start\n", "1\n", "2\n", "end\n"],
6707            "mixed list+loop should produce exactly 4 events"
6708        );
6709    }
6710
6711    #[tokio::test]
6712    async fn test_exec_streaming_stderr() {
6713        let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
6714        let stderr_cb = stderr_chunks.clone();
6715        let mut bash = Bash::new();
6716
6717        let result = bash
6718            .exec_streaming(
6719                "echo ok; echo err >&2; echo ok2",
6720                Box::new(move |_stdout, stderr| {
6721                    if !stderr.is_empty() {
6722                        stderr_cb.lock().unwrap().push(stderr.to_string());
6723                    }
6724                }),
6725            )
6726            .await
6727            .unwrap();
6728
6729        assert_eq!(result.stdout, "ok\nok2\n");
6730        assert_eq!(result.stderr, "err\n");
6731        let stderr_chunks = stderr_chunks.lock().unwrap();
6732        assert!(
6733            stderr_chunks.contains(&"err\n".to_string()),
6734            "stderr should be streamed: {:?}",
6735            *stderr_chunks
6736        );
6737    }
6738
6739    // ---- Streamed vs non-streamed equivalence tests ----
6740    //
6741    // These run the same script through exec() and exec_streaming() and assert
6742    // that the final ExecResult is identical, plus concatenated chunks == stdout.
6743
6744    /// Helper: run script both ways, assert equivalence.
6745    async fn assert_streaming_equivalence(script: &str) {
6746        // Non-streaming
6747        let mut bash_plain = Bash::new();
6748        let plain = bash_plain.exec(script).await.unwrap();
6749
6750        // Streaming
6751        let stdout_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
6752        let stderr_chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
6753        let so = stdout_chunks.clone();
6754        let se = stderr_chunks.clone();
6755        let mut bash_stream = Bash::new();
6756        let streamed = bash_stream
6757            .exec_streaming(
6758                script,
6759                Box::new(move |stdout, stderr| {
6760                    if !stdout.is_empty() {
6761                        so.lock().unwrap().push(stdout.to_string());
6762                    }
6763                    if !stderr.is_empty() {
6764                        se.lock().unwrap().push(stderr.to_string());
6765                    }
6766                }),
6767            )
6768            .await
6769            .unwrap();
6770
6771        // Final results must match
6772        assert_eq!(
6773            plain.stdout, streamed.stdout,
6774            "stdout mismatch for: {script}"
6775        );
6776        assert_eq!(
6777            plain.stderr, streamed.stderr,
6778            "stderr mismatch for: {script}"
6779        );
6780        assert_eq!(
6781            plain.exit_code, streamed.exit_code,
6782            "exit_code mismatch for: {script}"
6783        );
6784
6785        // Concatenated chunks must equal full stdout/stderr
6786        let reassembled_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
6787        assert_eq!(
6788            reassembled_stdout, streamed.stdout,
6789            "reassembled stdout chunks != final stdout for: {script}"
6790        );
6791        let reassembled_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
6792        assert_eq!(
6793            reassembled_stderr, streamed.stderr,
6794            "reassembled stderr chunks != final stderr for: {script}"
6795        );
6796    }
6797
6798    #[tokio::test]
6799    async fn test_exec_streaming_respects_stdout_stderr_limits() {
6800        let stdout_chunks = Arc::new(Mutex::new(Vec::new()));
6801        let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
6802        let so = stdout_chunks.clone();
6803        let se = stderr_chunks.clone();
6804        let mut bash = Bash::builder()
6805            .limits(
6806                ExecutionLimits::new()
6807                    .max_stdout_bytes(10)
6808                    .max_stderr_bytes(8),
6809            )
6810            .build();
6811
6812        let result = bash
6813            .exec_streaming(
6814                "echo hello; echo world; echo err1 >&2; echo err2 >&2",
6815                Box::new(move |stdout, stderr| {
6816                    if !stdout.is_empty() {
6817                        so.lock().unwrap().push(stdout.to_string());
6818                    }
6819                    if !stderr.is_empty() {
6820                        se.lock().unwrap().push(stderr.to_string());
6821                    }
6822                }),
6823            )
6824            .await
6825            .unwrap();
6826
6827        assert_eq!(result.stdout, "hello\nworl");
6828        assert_eq!(result.stderr, "err1\nerr");
6829        assert!(result.stdout_truncated);
6830        assert!(result.stderr_truncated);
6831        let streamed_stdout: String = stdout_chunks.lock().unwrap().iter().cloned().collect();
6832        let streamed_stderr: String = stderr_chunks.lock().unwrap().iter().cloned().collect();
6833        assert_eq!(streamed_stdout, result.stdout);
6834        assert_eq!(streamed_stderr, result.stderr);
6835    }
6836
6837    #[tokio::test]
6838    async fn test_streaming_equivalence_for_loop() {
6839        assert_streaming_equivalence("for i in 1 2 3; do echo $i; done").await;
6840    }
6841
6842    #[tokio::test]
6843    async fn test_streaming_equivalence_while_loop() {
6844        assert_streaming_equivalence("i=0; while [ $i -lt 4 ]; do i=$((i+1)); echo $i; done").await;
6845    }
6846
6847    #[tokio::test]
6848    async fn test_streaming_equivalence_nested_loops() {
6849        assert_streaming_equivalence("for i in a b; do for j in 1 2; do echo \"$i$j\"; done; done")
6850            .await;
6851    }
6852
6853    #[tokio::test]
6854    async fn test_streaming_equivalence_mixed_list() {
6855        assert_streaming_equivalence("echo start; for i in x y; do echo $i; done; echo end").await;
6856    }
6857
6858    #[tokio::test]
6859    async fn test_streaming_equivalence_stderr() {
6860        assert_streaming_equivalence("echo out; echo err >&2; echo out2").await;
6861    }
6862
6863    #[tokio::test]
6864    async fn test_streaming_equivalence_pipeline() {
6865        assert_streaming_equivalence("echo -e 'a\\nb\\nc' | grep b").await;
6866    }
6867
6868    #[tokio::test]
6869    async fn test_streaming_equivalence_conditionals() {
6870        assert_streaming_equivalence("if true; then echo yes; else echo no; fi; echo done").await;
6871    }
6872
6873    #[tokio::test]
6874    async fn test_streaming_equivalence_subshell() {
6875        assert_streaming_equivalence("x=$(echo hello); echo $x").await;
6876    }
6877
6878    #[tokio::test]
6879    async fn test_streaming_equivalence_command_substitution_exit_trap() {
6880        assert_streaming_equivalence("secret=$(trap 'echo TOKEN' EXIT); trap - EXIT; echo ok")
6881            .await;
6882    }
6883
6884    #[tokio::test]
6885    async fn test_max_memory_caps_string_growth() {
6886        let mut bash = Bash::builder()
6887            .max_memory(1024)
6888            .limits(
6889                ExecutionLimits::new()
6890                    .max_commands(10_000)
6891                    .max_loop_iterations(10_000),
6892            )
6893            .build();
6894        let result = bash
6895            .exec(r#"x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}"#)
6896            .await
6897            .unwrap();
6898        let len: usize = result.stdout.trim().parse().unwrap();
6899        // 25 doublings of 10 bytes = 335 544 320 without limits; must be capped ≤ 1024
6900        assert!(len <= 1024, "string length {len} must be ≤ 1024");
6901    }
6902
6903    /// Issue #1116: 2>/dev/null must suppress stderr in streaming mode
6904    #[tokio::test]
6905    async fn test_stderr_redirect_devnull_streaming() {
6906        let stderr_chunks = Arc::new(Mutex::new(Vec::new()));
6907        let stderr_cb = stderr_chunks.clone();
6908        let mut bash = Bash::new();
6909
6910        // Compound command — the main bug: callback fired before redirect applied
6911        let result = bash
6912            .exec_streaming(
6913                "{ ls /nonexistent; } 2>/dev/null; echo exit:$?",
6914                Box::new(move |_stdout, stderr| {
6915                    if !stderr.is_empty() {
6916                        stderr_cb.lock().unwrap().push(stderr.to_string());
6917                    }
6918                }),
6919            )
6920            .await
6921            .unwrap();
6922
6923        assert_eq!(result.stderr, "", "final stderr should be empty");
6924        let stderr_chunks = stderr_chunks.lock().unwrap();
6925        assert!(
6926            stderr_chunks.is_empty(),
6927            "no stderr should be streamed when 2>/dev/null is used, got: {:?}",
6928            *stderr_chunks
6929        );
6930    }
6931
6932    #[tokio::test]
6933    async fn test_dot_slash_prefix_ls() {
6934        // Issue #1114: ./filename should resolve identically to filename
6935        let mut bash = Bash::new();
6936        bash.exec("mkdir -p /tmp/blogtest && cd /tmp/blogtest && echo hello > tag_hello.html")
6937            .await
6938            .unwrap();
6939
6940        // ls without ./ prefix should work
6941        let result = bash
6942            .exec("cd /tmp/blogtest && ls tag_hello.html")
6943            .await
6944            .unwrap();
6945        assert_eq!(
6946            result.exit_code, 0,
6947            "ls tag_hello.html should succeed: {}",
6948            result.stderr
6949        );
6950        assert!(result.stdout.contains("tag_hello.html"));
6951
6952        // ls with ./ prefix should also work
6953        let result = bash
6954            .exec("cd /tmp/blogtest && ls ./tag_hello.html")
6955            .await
6956            .unwrap();
6957        assert_eq!(
6958            result.exit_code, 0,
6959            "ls ./tag_hello.html should succeed: {}",
6960            result.stderr
6961        );
6962        assert!(result.stdout.contains("tag_hello.html"));
6963    }
6964
6965    #[tokio::test]
6966    async fn test_dot_slash_prefix_glob() {
6967        // Issue #1114: ./*.html should resolve identically to *.html
6968        let mut bash = Bash::new();
6969        bash.exec("mkdir -p /tmp/globtest && cd /tmp/globtest && echo hello > tag_hello.html")
6970            .await
6971            .unwrap();
6972
6973        // glob without ./ prefix
6974        let result = bash.exec("cd /tmp/globtest && echo *.html").await.unwrap();
6975        assert_eq!(
6976            result.exit_code, 0,
6977            "echo *.html should succeed: {}",
6978            result.stderr
6979        );
6980        assert!(result.stdout.contains("tag_hello.html"));
6981
6982        // glob with ./ prefix
6983        let result = bash
6984            .exec("cd /tmp/globtest && echo ./*.html")
6985            .await
6986            .unwrap();
6987        assert_eq!(
6988            result.exit_code, 0,
6989            "echo ./*.html should succeed: {}",
6990            result.stderr
6991        );
6992        assert!(result.stdout.contains("tag_hello.html"));
6993    }
6994
6995    #[tokio::test]
6996    async fn test_dot_slash_prefix_cat() {
6997        // Issue #1114: cat ./filename should work
6998        let mut bash = Bash::new();
6999        bash.exec("mkdir -p /tmp/cattest && cd /tmp/cattest && echo content123 > myfile.txt")
7000            .await
7001            .unwrap();
7002
7003        let result = bash
7004            .exec("cd /tmp/cattest && cat ./myfile.txt")
7005            .await
7006            .unwrap();
7007        assert_eq!(
7008            result.exit_code, 0,
7009            "cat ./myfile.txt should succeed: {}",
7010            result.stderr
7011        );
7012        assert!(result.stdout.contains("content123"));
7013    }
7014
7015    #[tokio::test]
7016    async fn test_dot_slash_prefix_redirect() {
7017        // Issue #1114: redirecting to ./filename should work
7018        let mut bash = Bash::new();
7019        bash.exec("mkdir -p /tmp/redirtest && cd /tmp/redirtest")
7020            .await
7021            .unwrap();
7022
7023        let result = bash
7024            .exec("cd /tmp/redirtest && echo hello > ./output.txt && cat ./output.txt")
7025            .await
7026            .unwrap();
7027        assert_eq!(
7028            result.exit_code, 0,
7029            "redirect to ./output.txt should succeed: {}",
7030            result.stderr
7031        );
7032        assert!(result.stdout.contains("hello"));
7033    }
7034
7035    #[tokio::test]
7036    async fn test_dot_slash_prefix_test_builtin() {
7037        // Issue #1114: test -f ./filename should work
7038        let mut bash = Bash::new();
7039        bash.exec("mkdir -p /tmp/testbuiltin && cd /tmp/testbuiltin && echo x > myfile.txt")
7040            .await
7041            .unwrap();
7042
7043        let result = bash
7044            .exec("cd /tmp/testbuiltin && test -f ./myfile.txt && echo yes")
7045            .await
7046            .unwrap();
7047        assert_eq!(
7048            result.exit_code, 0,
7049            "test -f ./myfile.txt should succeed: {}",
7050            result.stderr
7051        );
7052        assert!(result.stdout.contains("yes"));
7053    }
7054
7055    // === Hooks system tests ===
7056
7057    #[tokio::test]
7058    async fn test_before_exec_hook_modifies_script() {
7059        use std::sync::Arc;
7060        use std::sync::atomic::{AtomicBool, Ordering};
7061
7062        let called = Arc::new(AtomicBool::new(false));
7063        let called_clone = called.clone();
7064
7065        let mut bash = Bash::builder()
7066            .before_exec(Box::new(move |mut input| {
7067                called_clone.store(true, Ordering::Relaxed);
7068                // Rewrite the script
7069                input.script = "echo intercepted".to_string();
7070                hooks::HookAction::Continue(input)
7071            }))
7072            .build();
7073
7074        let result = bash.exec("echo original").await.unwrap();
7075        assert!(called.load(Ordering::Relaxed));
7076        assert_eq!(result.stdout.trim(), "intercepted");
7077    }
7078
7079    #[tokio::test]
7080    async fn test_before_exec_hook_cancels() {
7081        let mut bash = Bash::builder()
7082            .before_exec(Box::new(|_input| {
7083                hooks::HookAction::Cancel("blocked".to_string())
7084            }))
7085            .build();
7086
7087        let result = bash.exec("echo should-not-run").await.unwrap();
7088        assert_eq!(result.exit_code, 1);
7089        assert!(result.stdout.is_empty());
7090    }
7091
7092    #[tokio::test]
7093    async fn test_input_size_limit_rejects_before_before_exec_hook() {
7094        use std::sync::Arc;
7095        use std::sync::atomic::{AtomicBool, Ordering};
7096
7097        let called = Arc::new(AtomicBool::new(false));
7098        let called_clone = called.clone();
7099
7100        let limits = ExecutionLimits::new().max_input_bytes(8);
7101        let mut bash = Bash::builder()
7102            .limits(limits)
7103            .before_exec(Box::new(move |_input| {
7104                called_clone.store(true, Ordering::Relaxed);
7105                unreachable!("before_exec hook must not run for oversized input");
7106            }))
7107            .build();
7108
7109        let result = bash.exec("echo way-too-long").await;
7110        assert!(result.is_err());
7111        assert!(!called.load(Ordering::Relaxed));
7112    }
7113
7114    #[tokio::test]
7115    async fn test_after_exec_hook_observes_output() {
7116        use std::sync::{Arc, Mutex};
7117
7118        let captured = Arc::new(Mutex::new(String::new()));
7119        let captured_clone = captured.clone();
7120
7121        let mut bash = Bash::builder()
7122            .after_exec(Box::new(move |output| {
7123                *captured_clone.lock().unwrap() = output.stdout.clone();
7124                hooks::HookAction::Continue(output)
7125            }))
7126            .build();
7127
7128        bash.exec("echo hello-hooks").await.unwrap();
7129        assert_eq!(captured.lock().unwrap().trim(), "hello-hooks");
7130    }
7131
7132    #[tokio::test]
7133    async fn test_after_exec_hook_can_modify_output() {
7134        let mut bash = Bash::builder()
7135            .after_exec(Box::new(|mut output| {
7136                output.stdout = output.stdout.replace("SECRET", "[redacted]");
7137                output.stderr = "policy stderr\n".to_string();
7138                output.exit_code = 7;
7139                hooks::HookAction::Continue(output)
7140            }))
7141            .build();
7142
7143        let result = bash.exec("echo SECRET").await.unwrap();
7144        assert_eq!(result.stdout, "[redacted]\n");
7145        assert_eq!(result.stderr, "policy stderr\n");
7146        assert_eq!(result.exit_code, 7);
7147    }
7148
7149    #[tokio::test]
7150    async fn test_after_exec_hook_can_cancel_result() {
7151        let mut bash = Bash::builder()
7152            .after_exec(Box::new(|_output| {
7153                hooks::HookAction::Cancel("blocked".to_string())
7154            }))
7155            .build();
7156
7157        let result = bash.exec("echo SECRET").await.unwrap();
7158        assert_eq!(result.stdout, "");
7159        assert_eq!(result.stderr, "cancelled by after_exec hook");
7160        assert_eq!(result.exit_code, 1);
7161    }
7162
7163    #[tokio::test]
7164    async fn test_before_tool_hook_can_cancel_special_builtin() {
7165        let mut bash = Bash::builder()
7166            .before_tool(Box::new(|event| {
7167                if event.name == "source" {
7168                    hooks::HookAction::Cancel("source blocked".to_string())
7169                } else {
7170                    hooks::HookAction::Continue(event)
7171                }
7172            }))
7173            .build();
7174
7175        let result = bash.exec("source missing.sh").await.unwrap();
7176        assert_eq!(result.exit_code, 1);
7177        assert!(result.stderr.contains("cancelled by before_tool hook"));
7178    }
7179
7180    #[tokio::test]
7181    async fn test_after_tool_hook_can_modify_builtin_result() {
7182        let mut bash = Bash::builder()
7183            .after_tool(Box::new(|mut result| {
7184                if result.name == "echo" {
7185                    result.stdout = result.stdout.replace("SECRET", "[redacted]");
7186                    result.exit_code = 9;
7187                }
7188                hooks::HookAction::Continue(result)
7189            }))
7190            .build();
7191
7192        let result = bash.exec("echo SECRET").await.unwrap();
7193        assert_eq!(result.stdout, "[redacted]\n");
7194        assert_eq!(result.exit_code, 9);
7195    }
7196
7197    #[tokio::test]
7198    async fn test_after_tool_hook_can_cancel_builtin_result() {
7199        let mut bash = Bash::builder()
7200            .after_tool(Box::new(|result| {
7201                if result.name == "echo" {
7202                    hooks::HookAction::Cancel("blocked".to_string())
7203                } else {
7204                    hooks::HookAction::Continue(result)
7205                }
7206            }))
7207            .build();
7208
7209        let result = bash.exec("echo SECRET").await.unwrap();
7210        assert_eq!(result.stdout, "");
7211        assert!(result.stderr.contains("cancelled by after_tool hook"));
7212        assert_eq!(result.exit_code, 1);
7213    }
7214
7215    #[tokio::test]
7216    async fn test_multiple_hooks_chain() {
7217        let mut bash = Bash::builder()
7218            .before_exec(Box::new(|mut input| {
7219                input.script = input.script.replace("world", "hooks");
7220                hooks::HookAction::Continue(input)
7221            }))
7222            .before_exec(Box::new(|mut input| {
7223                input.script = input.script.replace("hello", "greetings");
7224                hooks::HookAction::Continue(input)
7225            }))
7226            .build();
7227
7228        let result = bash.exec("echo hello world").await.unwrap();
7229        assert_eq!(result.stdout.trim(), "greetings hooks");
7230    }
7231
7232    #[tokio::test]
7233    async fn test_on_exit_hook_not_fired_for_path_script_exit() {
7234        use std::path::Path;
7235        use std::sync::Arc;
7236        use std::sync::atomic::{AtomicU32, Ordering};
7237
7238        let count = Arc::new(AtomicU32::new(0));
7239        let count_clone = count.clone();
7240
7241        let mut bash = Bash::builder()
7242            .on_exit(Box::new(move |event| {
7243                count_clone.fetch_add(1, Ordering::Relaxed);
7244                hooks::HookAction::Continue(event)
7245            }))
7246            .build();
7247
7248        let fs = bash.fs();
7249        fs.mkdir(Path::new("/bin"), false).await.unwrap();
7250        fs.write_file(Path::new("/bin/child-exit"), b"#!/usr/bin/env bash\nexit 7")
7251            .await
7252            .unwrap();
7253        fs.chmod(Path::new("/bin/child-exit"), 0o755).await.unwrap();
7254
7255        let result = bash
7256            .exec("PATH=/bin:$PATH\nchild-exit\necho after:$?")
7257            .await
7258            .unwrap();
7259
7260        assert_eq!(result.stdout.trim(), "after:7");
7261        assert_eq!(count.load(Ordering::Relaxed), 0);
7262    }
7263
7264    #[tokio::test]
7265    async fn test_on_exit_hook_not_fired_for_direct_script_exit() {
7266        use std::path::Path;
7267        use std::sync::Arc;
7268        use std::sync::atomic::{AtomicU32, Ordering};
7269
7270        let count = Arc::new(AtomicU32::new(0));
7271        let count_clone = count.clone();
7272
7273        let mut bash = Bash::builder()
7274            .on_exit(Box::new(move |event| {
7275                count_clone.fetch_add(1, Ordering::Relaxed);
7276                hooks::HookAction::Continue(event)
7277            }))
7278            .build();
7279
7280        let fs = bash.fs();
7281        fs.write_file(
7282            Path::new("/tmp/child-exit.sh"),
7283            b"#!/usr/bin/env bash\nexit 8",
7284        )
7285        .await
7286        .unwrap();
7287        fs.chmod(Path::new("/tmp/child-exit.sh"), 0o755)
7288            .await
7289            .unwrap();
7290
7291        let result = bash
7292            .exec("/tmp/child-exit.sh\necho after:$?")
7293            .await
7294            .unwrap();
7295
7296        assert_eq!(result.stdout.trim(), "after:8");
7297        assert_eq!(count.load(Ordering::Relaxed), 0);
7298    }
7299
7300    #[tokio::test]
7301    async fn test_on_exit_hook_not_fired_for_nested_bash_exit() {
7302        use std::sync::Arc;
7303        use std::sync::atomic::{AtomicU32, Ordering};
7304
7305        let count = Arc::new(AtomicU32::new(0));
7306        let count_clone = count.clone();
7307
7308        let mut bash = Bash::builder()
7309            .on_exit(Box::new(move |event| {
7310                count_clone.fetch_add(1, Ordering::Relaxed);
7311                hooks::HookAction::Continue(event)
7312            }))
7313            .build();
7314
7315        let result = bash.exec("bash -c 'exit 9'\necho after:$?").await.unwrap();
7316
7317        assert_eq!(result.stdout.trim(), "after:9");
7318        assert_eq!(count.load(Ordering::Relaxed), 0);
7319    }
7320
7321    #[tokio::test]
7322    async fn test_path_script_exit_runs_child_exit_trap() {
7323        use std::path::Path;
7324
7325        let mut bash = Bash::new();
7326        let fs = bash.fs();
7327        fs.write_file(
7328            Path::new("/tmp/child-trap.sh"),
7329            b"#!/usr/bin/env bash\ntrap 'echo child-trap' EXIT\nexit 4",
7330        )
7331        .await
7332        .unwrap();
7333        fs.chmod(Path::new("/tmp/child-trap.sh"), 0o755)
7334            .await
7335            .unwrap();
7336
7337        let result = bash
7338            .exec("/tmp/child-trap.sh\necho after:$?")
7339            .await
7340            .unwrap();
7341
7342        assert_eq!(result.stdout.trim(), "child-trap\nafter:4");
7343    }
7344
7345    #[tokio::test]
7346    async fn test_on_exit_hook_still_fires_for_source_exit() {
7347        use std::path::Path;
7348        use std::sync::Arc;
7349        use std::sync::atomic::{AtomicU32, Ordering};
7350
7351        let count = Arc::new(AtomicU32::new(0));
7352        let count_clone = count.clone();
7353
7354        let mut bash = Bash::builder()
7355            .on_exit(Box::new(move |event| {
7356                count_clone.fetch_add(1, Ordering::Relaxed);
7357                hooks::HookAction::Continue(event)
7358            }))
7359            .build();
7360
7361        let fs = bash.fs();
7362        fs.write_file(Path::new("/tmp/source-exit.sh"), b"exit 5")
7363            .await
7364            .unwrap();
7365
7366        let result = bash.exec("source /tmp/source-exit.sh").await.unwrap();
7367
7368        assert_eq!(result.exit_code, 5);
7369        assert_eq!(count.load(Ordering::Relaxed), 1);
7370    }
7371
7372    #[tokio::test]
7373    async fn test_on_exit_hook_cancel_prevents_exit() {
7374        let mut bash = Bash::builder()
7375            .on_exit(Box::new(|_event| {
7376                hooks::HookAction::Cancel("blocked by policy".to_string())
7377            }))
7378            .build();
7379
7380        let result = bash.exec("echo before\nexit 5\necho after").await.unwrap();
7381        assert_eq!(result.stdout.trim(), "before\nafter");
7382        assert_eq!(result.exit_code, 0);
7383    }
7384
7385    #[tokio::test]
7386    async fn test_on_exit_hook_can_modify_exit_code() {
7387        let mut bash = Bash::builder()
7388            .on_exit(Box::new(|mut event| {
7389                event.code = 17;
7390                hooks::HookAction::Continue(event)
7391            }))
7392            .build();
7393
7394        let result = bash.exec("exit 5").await.unwrap();
7395        assert_eq!(result.exit_code, 17);
7396    }
7397
7398    #[tokio::test]
7399    async fn test_bash_versinfo_reports_bash_compatible_major() {
7400        let mut bash = Bash::new();
7401
7402        let result = bash
7403            .exec(r#"[[ ${BASH_VERSINFO[0]} -ge 4 ]] && echo bash4plus"#)
7404            .await
7405            .unwrap();
7406
7407        assert_eq!(result.stdout.trim(), "bash4plus");
7408    }
7409
7410    #[tokio::test]
7411    async fn test_bash_version_surface_matches_bash_compatible_tuple() {
7412        let mut bash = Bash::new();
7413
7414        let result = bash
7415            .exec(
7416                r#"printf '%s\n' "$BASH_VERSION" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}" "${BASH_VERSINFO[2]}" "${BASH_VERSINFO[3]}" "${BASH_VERSINFO[4]}" "${BASH_VERSINFO[5]}""#,
7417            )
7418            .await
7419            .unwrap();
7420
7421        assert_eq!(
7422            result.stdout,
7423            "5.2.15(1)-release\n5\n2\n15\n1\nrelease\nvirtual\n"
7424        );
7425    }
7426
7427    #[tokio::test]
7428    async fn test_path_script_retains_bash_versinfo_array() {
7429        use std::path::Path;
7430
7431        let mut bash = Bash::new();
7432        let fs = bash.fs();
7433        fs.write_file(
7434            Path::new("/tmp/bash-version-check.sh"),
7435            b"#!/usr/bin/env bash\nprintf '%s\\n' \"${BASH_VERSINFO[0]}\"",
7436        )
7437        .await
7438        .unwrap();
7439        fs.chmod(Path::new("/tmp/bash-version-check.sh"), 0o755)
7440            .await
7441            .unwrap();
7442
7443        let result = bash.exec("/tmp/bash-version-check.sh").await.unwrap();
7444
7445        assert_eq!(result.stdout.trim(), "5");
7446    }
7447
7448    #[tokio::test]
7449    async fn test_path_script_bash_versinfo_satisfies_bash4_guard() {
7450        use std::path::Path;
7451
7452        let mut bash = Bash::new();
7453        let fs = bash.fs();
7454        fs.write_file(
7455            Path::new("/tmp/bash-version-guard.sh"),
7456            b"#!/usr/bin/env bash\nif (( BASH_VERSINFO[0] < 4 )); then echo too-old; else echo ok; fi",
7457        )
7458        .await
7459        .unwrap();
7460        fs.chmod(Path::new("/tmp/bash-version-guard.sh"), 0o755)
7461            .await
7462            .unwrap();
7463
7464        let result = bash.exec("/tmp/bash-version-guard.sh").await.unwrap();
7465
7466        assert_eq!(result.stdout.trim(), "ok");
7467    }
7468
7469    #[tokio::test]
7470    async fn test_before_tool_hook_modifies_args() {
7471        use std::sync::Arc;
7472        use std::sync::atomic::{AtomicBool, Ordering};
7473
7474        let called = Arc::new(AtomicBool::new(false));
7475        let called_clone = called.clone();
7476
7477        let mut bash = Bash::builder()
7478            .before_tool(Box::new(move |mut event| {
7479                called_clone.store(true, Ordering::Relaxed);
7480                // Rewrite args: replace first arg with "intercepted"
7481                if !event.args.is_empty() {
7482                    event.args = vec!["intercepted".to_string()];
7483                }
7484                hooks::HookAction::Continue(event)
7485            }))
7486            .build();
7487
7488        let result = bash.exec("echo original").await.unwrap();
7489        assert!(called.load(Ordering::Relaxed));
7490        assert_eq!(result.stdout.trim(), "intercepted");
7491    }
7492
7493    #[tokio::test]
7494    async fn test_before_tool_hook_cancels() {
7495        let mut bash = Bash::builder()
7496            .before_tool(Box::new(|event| {
7497                if event.name == "echo" {
7498                    hooks::HookAction::Cancel("echo blocked".to_string())
7499                } else {
7500                    hooks::HookAction::Continue(event)
7501                }
7502            }))
7503            .build();
7504
7505        let result = bash.exec("echo should-not-run").await.unwrap();
7506        assert_eq!(result.exit_code, 1);
7507        assert!(result.stderr.contains("cancelled by before_tool hook"));
7508    }
7509
7510    #[tokio::test]
7511    async fn test_after_tool_hook_observes_result() {
7512        use std::sync::{Arc, Mutex};
7513
7514        let captured = Arc::new(Mutex::new(Vec::new()));
7515        let captured_clone = captured.clone();
7516
7517        let mut bash = Bash::builder()
7518            .after_tool(Box::new(move |result| {
7519                captured_clone.lock().unwrap().push((
7520                    result.name.clone(),
7521                    result.stdout.clone(),
7522                    result.exit_code,
7523                ));
7524                hooks::HookAction::Continue(result)
7525            }))
7526            .build();
7527
7528        bash.exec("echo hello-tool").await.unwrap();
7529        let results = captured.lock().unwrap();
7530        assert!(!results.is_empty());
7531        assert_eq!(results[0].0, "echo");
7532        assert!(results[0].1.contains("hello-tool"));
7533        assert_eq!(results[0].2, 0);
7534    }
7535
7536    #[tokio::test]
7537    async fn test_before_tool_hook_fires_for_special_and_registered_builtins() {
7538        // Special builtins now route through execute_special_builtin_with_hooks
7539        // so before_tool fires for both declare and echo.
7540        use std::sync::Arc;
7541        use std::sync::atomic::{AtomicU32, Ordering};
7542
7543        let count = Arc::new(AtomicU32::new(0));
7544        let count_clone = count.clone();
7545
7546        let mut bash = Bash::builder()
7547            .before_tool(Box::new(move |event| {
7548                count_clone.fetch_add(1, Ordering::Relaxed);
7549                hooks::HookAction::Continue(event)
7550            }))
7551            .build();
7552
7553        // declare is a special builtin — now triggers before_tool
7554        bash.exec("declare x=1").await.unwrap();
7555        assert_eq!(count.load(Ordering::Relaxed), 1);
7556
7557        // echo is a registered builtin — also triggers before_tool
7558        bash.exec("echo hi").await.unwrap();
7559        assert_eq!(count.load(Ordering::Relaxed), 2);
7560    }
7561
7562    #[cfg(feature = "http_client")]
7563    #[tokio::test]
7564    async fn test_before_http_hook_cancels_request() {
7565        use crate::NetworkAllowlist;
7566
7567        let mut bash = Bash::builder()
7568            .network(NetworkAllowlist::allow_all())
7569            .before_http(Box::new(|req| {
7570                if req.url.contains("blocked.example.com") {
7571                    hooks::HookAction::Cancel("blocked by policy".to_string())
7572                } else {
7573                    hooks::HookAction::Continue(req)
7574                }
7575            }))
7576            .build();
7577
7578        // The before_http hook should cancel this request
7579        let result = bash
7580            .exec("curl -s https://blocked.example.com/data")
7581            .await
7582            .unwrap();
7583        assert_ne!(result.exit_code, 0);
7584        assert!(result.stderr.contains("cancelled by before_http hook"));
7585    }
7586
7587    #[cfg(feature = "http_client")]
7588    #[tokio::test]
7589    async fn test_after_http_hook_observes_response() {
7590        use std::sync::{Arc, Mutex};
7591
7592        use crate::NetworkAllowlist;
7593
7594        let captured = Arc::new(Mutex::new(Vec::new()));
7595        let captured_clone = captured.clone();
7596
7597        let mut bash = Bash::builder()
7598            .network(NetworkAllowlist::allow_all())
7599            .after_http(Box::new(move |event| {
7600                captured_clone
7601                    .lock()
7602                    .unwrap()
7603                    .push((event.url.clone(), event.status));
7604                hooks::HookAction::Continue(event)
7605            }))
7606            .build();
7607
7608        // Even though the request will fail (no real server), the hook
7609        // infrastructure is wired correctly if it doesn't panic.
7610        // A successful test is that the builder accepts the hook and builds.
7611        let _result = bash.exec("curl -s https://httpbin.org/get").await;
7612        // We can't assert on captured content since there's no real HTTP
7613        // server, but the hook is wired and the build succeeded.
7614    }
7615}