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