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