Skip to main content

bashkit/interpreter/
mod.rs

1//! Interpreter for executing bash scripts
2//!
3//! # Fail Points (enabled with `failpoints` feature)
4//!
5//! - `interp::execute_command` - Inject failures in command execution
6//! - `interp::expand_variable` - Inject failures in variable expansion
7//! - `interp::execute_function` - Inject failures in function calls
8
9// Interpreter uses chars().last().unwrap() and chars().next().unwrap() after
10// validating string contents. This is safe because we check for non-empty strings.
11#![allow(clippy::unwrap_used)]
12
13mod jobs;
14mod state;
15
16#[allow(unused_imports)]
17pub use jobs::{JobTable, SharedJobTable};
18pub use state::{ControlFlow, ExecResult};
19// Re-export snapshot type for public API
20
21use std::collections::{HashMap, HashSet};
22use std::panic::AssertUnwindSafe;
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::sync::Arc;
26
27/// Monotonic counter for unique process substitution file paths
28static PROC_SUB_COUNTER: AtomicU64 = AtomicU64::new(0);
29
30use futures::FutureExt;
31
32use crate::builtins::{self, Builtin};
33#[cfg(feature = "failpoints")]
34use crate::error::Error;
35use crate::error::Result;
36use crate::fs::FileSystem;
37use crate::limits::{ExecutionCounters, ExecutionLimits};
38
39/// Callback for streaming output chunks as they are produced.
40///
41/// Arguments: `(stdout_chunk, stderr_chunk)`. Called after each loop iteration
42/// and each top-level command completes. Only non-empty chunks trigger a call.
43///
44/// Requires `Send + Sync` because the interpreter holds this across `.await` points.
45/// Closures capturing `Arc<Mutex<_>>` satisfy both bounds automatically.
46pub type OutputCallback = Box<dyn FnMut(&str, &str) + Send + Sync>;
47use crate::parser::{
48    ArithmeticForCommand, AssignmentValue, CaseCommand, Command, CommandList, CompoundCommand,
49    ForCommand, FunctionDef, IfCommand, ListOperator, ParameterOp, Parser, Pipeline, Redirect,
50    RedirectKind, Script, SelectCommand, SimpleCommand, Span, TimeCommand, UntilCommand,
51    WhileCommand, Word, WordPart,
52};
53
54#[cfg(feature = "failpoints")]
55use fail::fail_point;
56
57/// The canonical /dev/null path.
58/// This is handled at the interpreter level to prevent custom filesystems from bypassing it.
59const DEV_NULL: &str = "/dev/null";
60
61/// Check if a name is a shell keyword (for `command -v`/`command -V`).
62fn is_keyword(name: &str) -> bool {
63    matches!(
64        name,
65        "if" | "then"
66            | "else"
67            | "elif"
68            | "fi"
69            | "for"
70            | "while"
71            | "until"
72            | "do"
73            | "done"
74            | "case"
75            | "esac"
76            | "in"
77            | "function"
78            | "select"
79            | "time"
80            | "{"
81            | "}"
82            | "[["
83            | "]]"
84            | "!"
85    )
86}
87
88/// Levenshtein edit distance between two strings.
89fn levenshtein(a: &str, b: &str) -> usize {
90    let a: Vec<char> = a.chars().collect();
91    let b: Vec<char> = b.chars().collect();
92    let n = b.len();
93    let mut prev = (0..=n).collect::<Vec<_>>();
94    let mut curr = vec![0; n + 1];
95    for (i, ca) in a.iter().enumerate() {
96        curr[0] = i + 1;
97        for (j, cb) in b.iter().enumerate() {
98            let cost = if ca == cb { 0 } else { 1 };
99            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
100        }
101        std::mem::swap(&mut prev, &mut curr);
102    }
103    prev[n]
104}
105
106/// Hint for common commands that are unavailable in the sandbox.
107fn unavailable_command_hint(name: &str) -> Option<&'static str> {
108    match name {
109        "pip" | "pip3" | "pip2" => Some("Package managers are not available in the sandbox."),
110        "apt" | "apt-get" | "yum" | "dnf" | "pacman" | "brew" | "apk" => {
111            Some("Package managers are not available in the sandbox.")
112        }
113        "npm" | "yarn" | "pnpm" | "bun" => {
114            Some("Package managers are not available in the sandbox.")
115        }
116        "sudo" | "su" | "doas" => Some("All commands run without privilege restrictions."),
117        "ssh" | "scp" | "sftp" | "rsync" => Some("Network access is limited to curl/wget."),
118        "docker" | "podman" | "kubectl" | "systemctl" | "service" => {
119            Some("Container and service management is not available in the sandbox.")
120        }
121        "make" | "cmake" | "gcc" | "g++" | "clang" | "rustc" | "cargo" | "go" | "javac"
122        | "node" => Some("Compilers and build tools are not available in the sandbox."),
123        "vi" | "vim" | "nano" | "emacs" => {
124            Some("Interactive editors are not available. Use echo/printf/cat to write files.")
125        }
126        "man" | "info" => Some("Manual pages are not available in the sandbox."),
127        _ => None,
128    }
129}
130
131/// Build a "command not found" error with optional suggestions.
132fn command_not_found_message(name: &str, known_commands: &[&str]) -> String {
133    let mut msg = format!("bash: {}: command not found", name);
134
135    // Check for unavailable command hints first
136    if let Some(hint) = unavailable_command_hint(name) {
137        msg.push_str(&format!(". {}", hint));
138        return msg;
139    }
140
141    // Find close matches via Levenshtein distance
142    let max_dist = if name.len() <= 3 { 1 } else { 2 };
143    let mut suggestions: Vec<(&str, usize)> = known_commands
144        .iter()
145        .filter_map(|cmd| {
146            let d = levenshtein(name, cmd);
147            if d > 0 && d <= max_dist {
148                Some((*cmd, d))
149            } else {
150                None
151            }
152        })
153        .collect();
154    suggestions.sort_by_key(|(_, d)| *d);
155    suggestions.truncate(3);
156
157    if !suggestions.is_empty() {
158        let names: Vec<&str> = suggestions.iter().map(|(s, _)| *s).collect();
159        msg.push_str(&format!(". Did you mean: {}?", names.join(", ")));
160    }
161
162    msg
163}
164
165/// Check if a path refers to /dev/null after normalization.
166/// Handles attempts to bypass via paths like `/dev/../dev/null`.
167fn is_dev_null(path: &Path) -> bool {
168    // Normalize the path to handle .. and . components
169    let mut normalized = PathBuf::new();
170    for component in path.components() {
171        match component {
172            std::path::Component::RootDir => normalized.push("/"),
173            std::path::Component::Normal(name) => normalized.push(name),
174            std::path::Component::ParentDir => {
175                normalized.pop();
176            }
177            std::path::Component::CurDir => {}
178            std::path::Component::Prefix(_) => {}
179        }
180    }
181    if normalized.as_os_str().is_empty() {
182        normalized.push("/");
183    }
184    normalized == Path::new(DEV_NULL)
185}
186
187/// THREAT[TM-INJ-009,TM-INJ-016]: Check if a variable name is an internal marker.
188/// Used by builtins and interpreter to block user assignment to internal prefixes.
189pub(crate) fn is_internal_variable(name: &str) -> bool {
190    name.starts_with("_NAMEREF_")
191        || name.starts_with("_READONLY_")
192        || name.starts_with("_UPPER_")
193        || name.starts_with("_LOWER_")
194        || name.starts_with("_ARRAY_READ_")
195        || name == "_EVAL_CMD"
196        || name == "_SHIFT_COUNT"
197        || name == "_SET_POSITIONAL"
198}
199
200/// A frame in the call stack for local variable scoping
201#[derive(Debug, Clone)]
202struct CallFrame {
203    /// Function name
204    name: String,
205    /// Local variables in this scope
206    locals: HashMap<String, String>,
207    /// Positional parameters ($1, $2, etc.)
208    positional: Vec<String>,
209}
210
211/// Shell options that can be set via `set -o` or `set -x`
212#[derive(Debug, Clone, Default)]
213pub struct ShellOptions {
214    /// Exit immediately if a command exits with non-zero status (set -e)
215    pub errexit: bool,
216    /// Print commands before execution (set -x)
217    pub xtrace: bool,
218    /// Return rightmost non-zero exit code from pipeline (set -o pipefail)
219    pub pipefail: bool,
220}
221
222/// A snapshot of shell state (variables, env, cwd, options).
223///
224/// Captures the serializable portions of the interpreter state.
225/// Combined with [`VfsSnapshot`](crate::VfsSnapshot) this provides
226/// full session snapshot/restore.
227#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
228pub struct ShellState {
229    /// Environment variables
230    pub env: HashMap<String, String>,
231    /// Shell variables
232    pub variables: HashMap<String, String>,
233    /// Indexed arrays
234    pub arrays: HashMap<String, HashMap<usize, String>>,
235    /// Associative arrays
236    pub assoc_arrays: HashMap<String, HashMap<String, String>>,
237    /// Current working directory
238    pub cwd: PathBuf,
239    /// Last exit code
240    pub last_exit_code: i32,
241    /// Shell aliases
242    pub aliases: HashMap<String, String>,
243    /// Trap handlers
244    pub traps: HashMap<String, String>,
245    /// Shell options
246    pub errexit: bool,
247    /// Shell options
248    pub xtrace: bool,
249    /// Shell options
250    pub pipefail: bool,
251}
252
253/// Interpreter state.
254pub struct Interpreter {
255    fs: Arc<dyn FileSystem>,
256    env: HashMap<String, String>,
257    variables: HashMap<String, String>,
258    /// Arrays - stored as name -> index -> value
259    arrays: HashMap<String, HashMap<usize, String>>,
260    /// Associative arrays (declare -A) - stored as name -> key -> value
261    assoc_arrays: HashMap<String, HashMap<String, String>>,
262    cwd: PathBuf,
263    last_exit_code: i32,
264    /// Built-in commands (default + custom)
265    builtins: HashMap<String, Box<dyn Builtin>>,
266    /// Defined functions
267    functions: HashMap<String, FunctionDef>,
268    /// Call stack for local variable scoping
269    call_stack: Vec<CallFrame>,
270    /// Resource limits
271    limits: ExecutionLimits,
272    /// Execution counters for resource tracking
273    counters: ExecutionCounters,
274    /// Job table for background execution
275    #[allow(dead_code)]
276    jobs: JobTable,
277    /// Shell options (set -e, set -x, etc.)
278    options: ShellOptions,
279    /// Current line number for $LINENO
280    current_line: usize,
281    /// HTTP client for network builtins (curl, wget)
282    #[cfg(feature = "http_client")]
283    http_client: Option<crate::network::HttpClient>,
284    /// Git client for git builtins
285    #[cfg(feature = "git")]
286    git_client: Option<crate::git::GitClient>,
287    /// Stdin inherited from pipeline for compound commands (while read, etc.)
288    /// Each read operation consumes one line, advancing through the data.
289    pipeline_stdin: Option<String>,
290    /// Optional callback for streaming output chunks during execution.
291    /// When set, output is emitted incrementally via this callback in addition
292    /// to being accumulated in the returned ExecResult.
293    output_callback: Option<OutputCallback>,
294    /// Monotonic counter incremented each time output is emitted via callback.
295    /// Used to detect whether sub-calls already emitted output, preventing duplicates.
296    output_emit_count: u64,
297    /// Pending nounset (set -u) error message, consumed by execute_command.
298    nounset_error: Option<String>,
299    /// Trap handlers: signal/event name -> command string
300    traps: HashMap<String, String>,
301    /// PIPESTATUS: exit codes of the last pipeline's commands
302    pipestatus: Vec<i32>,
303    /// Shell aliases: name -> expansion value
304    aliases: HashMap<String, String>,
305    /// Aliases currently being expanded (prevents infinite recursion).
306    /// When alias `foo` expands to `foo bar`, the inner `foo` is not re-expanded.
307    expanding_aliases: HashSet<String>,
308}
309
310impl Interpreter {
311    const MAX_GLOB_DEPTH: usize = 50;
312
313    /// Create a new interpreter with the given filesystem.
314    pub fn new(fs: Arc<dyn FileSystem>) -> Self {
315        Self::with_config(fs, None, None, None, HashMap::new())
316    }
317
318    /// Create a new interpreter with custom username, hostname, and builtins.
319    ///
320    /// # Arguments
321    ///
322    /// * `fs` - The virtual filesystem to use
323    /// * `username` - Optional custom username for virtual identity
324    /// * `hostname` - Optional custom hostname for virtual identity
325    /// * `custom_builtins` - Custom builtins to register (override defaults if same name)
326    pub fn with_config(
327        fs: Arc<dyn FileSystem>,
328        username: Option<String>,
329        hostname: Option<String>,
330        fixed_epoch: Option<i64>,
331        custom_builtins: HashMap<String, Box<dyn Builtin>>,
332    ) -> Self {
333        let mut builtins: HashMap<String, Box<dyn Builtin>> = HashMap::new();
334
335        // Register default builtins
336        builtins.insert("echo".to_string(), Box::new(builtins::Echo));
337        builtins.insert("true".to_string(), Box::new(builtins::True));
338        builtins.insert("false".to_string(), Box::new(builtins::False));
339        builtins.insert("exit".to_string(), Box::new(builtins::Exit));
340        builtins.insert("cd".to_string(), Box::new(builtins::Cd));
341        builtins.insert("pwd".to_string(), Box::new(builtins::Pwd));
342        builtins.insert("cat".to_string(), Box::new(builtins::Cat));
343        builtins.insert("break".to_string(), Box::new(builtins::Break));
344        builtins.insert("continue".to_string(), Box::new(builtins::Continue));
345        builtins.insert("return".to_string(), Box::new(builtins::Return));
346        builtins.insert("test".to_string(), Box::new(builtins::Test));
347        builtins.insert("[".to_string(), Box::new(builtins::Bracket));
348        builtins.insert("printf".to_string(), Box::new(builtins::Printf));
349        builtins.insert("export".to_string(), Box::new(builtins::Export));
350        builtins.insert("read".to_string(), Box::new(builtins::Read));
351        builtins.insert("set".to_string(), Box::new(builtins::Set));
352        builtins.insert("unset".to_string(), Box::new(builtins::Unset));
353        builtins.insert("shift".to_string(), Box::new(builtins::Shift));
354        builtins.insert("local".to_string(), Box::new(builtins::Local));
355        // POSIX special built-ins
356        builtins.insert(":".to_string(), Box::new(builtins::Colon));
357        builtins.insert("readonly".to_string(), Box::new(builtins::Readonly));
358        builtins.insert("times".to_string(), Box::new(builtins::Times));
359        builtins.insert("eval".to_string(), Box::new(builtins::Eval));
360        builtins.insert(
361            "source".to_string(),
362            Box::new(builtins::Source::new(fs.clone())),
363        );
364        builtins.insert(".".to_string(), Box::new(builtins::Source::new(fs.clone())));
365        builtins.insert("jq".to_string(), Box::new(builtins::Jq));
366        builtins.insert("grep".to_string(), Box::new(builtins::Grep));
367        builtins.insert("sed".to_string(), Box::new(builtins::Sed));
368        builtins.insert("awk".to_string(), Box::new(builtins::Awk));
369        builtins.insert("sleep".to_string(), Box::new(builtins::Sleep));
370        builtins.insert("head".to_string(), Box::new(builtins::Head));
371        builtins.insert("tail".to_string(), Box::new(builtins::Tail));
372        builtins.insert("basename".to_string(), Box::new(builtins::Basename));
373        builtins.insert("dirname".to_string(), Box::new(builtins::Dirname));
374        builtins.insert("realpath".to_string(), Box::new(builtins::Realpath));
375        builtins.insert("mkdir".to_string(), Box::new(builtins::Mkdir));
376        builtins.insert("mktemp".to_string(), Box::new(builtins::Mktemp));
377        builtins.insert("rm".to_string(), Box::new(builtins::Rm));
378        builtins.insert("cp".to_string(), Box::new(builtins::Cp));
379        builtins.insert("mv".to_string(), Box::new(builtins::Mv));
380        builtins.insert("touch".to_string(), Box::new(builtins::Touch));
381        builtins.insert("chmod".to_string(), Box::new(builtins::Chmod));
382        builtins.insert("ln".to_string(), Box::new(builtins::Ln));
383        builtins.insert("chown".to_string(), Box::new(builtins::Chown));
384        builtins.insert("kill".to_string(), Box::new(builtins::Kill));
385        builtins.insert("wc".to_string(), Box::new(builtins::Wc));
386        builtins.insert("nl".to_string(), Box::new(builtins::Nl));
387        builtins.insert("paste".to_string(), Box::new(builtins::Paste));
388        builtins.insert("column".to_string(), Box::new(builtins::Column));
389        builtins.insert("comm".to_string(), Box::new(builtins::Comm));
390        builtins.insert("diff".to_string(), Box::new(builtins::Diff));
391        builtins.insert("strings".to_string(), Box::new(builtins::Strings));
392        builtins.insert("od".to_string(), Box::new(builtins::Od));
393        builtins.insert("xxd".to_string(), Box::new(builtins::Xxd));
394        builtins.insert("hexdump".to_string(), Box::new(builtins::Hexdump));
395        builtins.insert("base64".to_string(), Box::new(builtins::Base64));
396        builtins.insert("md5sum".to_string(), Box::new(builtins::Md5sum));
397        builtins.insert("sha1sum".to_string(), Box::new(builtins::Sha1sum));
398        builtins.insert("sha256sum".to_string(), Box::new(builtins::Sha256sum));
399        builtins.insert("seq".to_string(), Box::new(builtins::Seq));
400        builtins.insert("tac".to_string(), Box::new(builtins::Tac));
401        builtins.insert("rev".to_string(), Box::new(builtins::Rev));
402        builtins.insert("yes".to_string(), Box::new(builtins::Yes));
403        builtins.insert("expr".to_string(), Box::new(builtins::Expr));
404        builtins.insert("bc".to_string(), Box::new(builtins::Bc));
405        builtins.insert("pushd".to_string(), Box::new(builtins::Pushd));
406        builtins.insert("popd".to_string(), Box::new(builtins::Popd));
407        builtins.insert("dirs".to_string(), Box::new(builtins::Dirs));
408        builtins.insert("sort".to_string(), Box::new(builtins::Sort));
409        builtins.insert("uniq".to_string(), Box::new(builtins::Uniq));
410        builtins.insert("cut".to_string(), Box::new(builtins::Cut));
411        builtins.insert("tr".to_string(), Box::new(builtins::Tr));
412        // THREAT[TM-INF-018]: Use fixed epoch if configured, else real clock
413        builtins.insert(
414            "date".to_string(),
415            Box::new(if let Some(epoch) = fixed_epoch {
416                use chrono::DateTime;
417                builtins::Date::with_fixed_epoch(
418                    DateTime::from_timestamp(epoch, 0).unwrap_or_default(),
419                )
420            } else {
421                builtins::Date::new()
422            }),
423        );
424        builtins.insert("wait".to_string(), Box::new(builtins::Wait));
425        builtins.insert("curl".to_string(), Box::new(builtins::Curl));
426        builtins.insert("wget".to_string(), Box::new(builtins::Wget));
427        // Git builtin (requires git feature and configuration at runtime)
428        #[cfg(feature = "git")]
429        builtins.insert("git".to_string(), Box::new(builtins::Git));
430        // Python builtins: opt-in via BashBuilder::python() / BashToolBuilder::python()
431        // The `python` feature flag enables compilation; registration is explicit.
432        builtins.insert("timeout".to_string(), Box::new(builtins::Timeout));
433        // System info builtins (configurable virtual values)
434        let hostname_val = hostname.unwrap_or_else(|| builtins::DEFAULT_HOSTNAME.to_string());
435        let username_val = username.unwrap_or_else(|| builtins::DEFAULT_USERNAME.to_string());
436        builtins.insert(
437            "hostname".to_string(),
438            Box::new(builtins::Hostname::with_hostname(&hostname_val)),
439        );
440        builtins.insert(
441            "uname".to_string(),
442            Box::new(builtins::Uname::with_hostname(&hostname_val)),
443        );
444        builtins.insert(
445            "whoami".to_string(),
446            Box::new(builtins::Whoami::with_username(&username_val)),
447        );
448        builtins.insert(
449            "id".to_string(),
450            Box::new(builtins::Id::with_username(&username_val)),
451        );
452        // Directory listing and search
453        builtins.insert("ls".to_string(), Box::new(builtins::Ls));
454        builtins.insert("find".to_string(), Box::new(builtins::Find));
455        builtins.insert("rmdir".to_string(), Box::new(builtins::Rmdir));
456        // File inspection
457        builtins.insert("less".to_string(), Box::new(builtins::Less));
458        builtins.insert("file".to_string(), Box::new(builtins::File));
459        builtins.insert("stat".to_string(), Box::new(builtins::Stat));
460        // Archive operations
461        builtins.insert("tar".to_string(), Box::new(builtins::Tar));
462        builtins.insert("gzip".to_string(), Box::new(builtins::Gzip));
463        builtins.insert("gunzip".to_string(), Box::new(builtins::Gunzip));
464        // Disk usage
465        builtins.insert("du".to_string(), Box::new(builtins::Du));
466        builtins.insert("df".to_string(), Box::new(builtins::Df));
467        // Environment builtins
468        builtins.insert("env".to_string(), Box::new(builtins::Env));
469        builtins.insert("printenv".to_string(), Box::new(builtins::Printenv));
470        builtins.insert("history".to_string(), Box::new(builtins::History));
471        // Pipeline control
472        builtins.insert("xargs".to_string(), Box::new(builtins::Xargs));
473        builtins.insert("tee".to_string(), Box::new(builtins::Tee));
474        builtins.insert("watch".to_string(), Box::new(builtins::Watch));
475        builtins.insert("shopt".to_string(), Box::new(builtins::Shopt));
476
477        // Merge custom builtins (override defaults if same name)
478        for (name, builtin) in custom_builtins {
479            builtins.insert(name, builtin);
480        }
481
482        // Initialize default shell variables
483        let mut variables = HashMap::new();
484        variables.insert("HOME".to_string(), format!("/home/{}", &username_val));
485        variables.insert("USER".to_string(), username_val.clone());
486        variables.insert("UID".to_string(), "1000".to_string());
487        variables.insert("EUID".to_string(), "1000".to_string());
488        variables.insert("HOSTNAME".to_string(), hostname_val.clone());
489
490        // BASH_VERSINFO array: (major minor patch build status machine)
491        let version = env!("CARGO_PKG_VERSION");
492        let parts: Vec<&str> = version.split('.').collect();
493        let mut bash_versinfo = HashMap::new();
494        bash_versinfo.insert(0, parts.first().unwrap_or(&"0").to_string());
495        bash_versinfo.insert(1, parts.get(1).unwrap_or(&"0").to_string());
496        bash_versinfo.insert(2, parts.get(2).unwrap_or(&"0").to_string());
497        bash_versinfo.insert(3, "0".to_string());
498        bash_versinfo.insert(4, "release".to_string());
499        bash_versinfo.insert(5, "virtual".to_string());
500
501        let mut arrays = HashMap::new();
502        arrays.insert("BASH_VERSINFO".to_string(), bash_versinfo);
503
504        Self {
505            fs,
506            env: HashMap::new(),
507            variables,
508            arrays,
509            assoc_arrays: HashMap::new(),
510            cwd: PathBuf::from("/home/user"),
511            last_exit_code: 0,
512            builtins,
513            functions: HashMap::new(),
514            call_stack: Vec::new(),
515            limits: ExecutionLimits::default(),
516            counters: ExecutionCounters::new(),
517            jobs: JobTable::new(),
518            options: ShellOptions::default(),
519            current_line: 1,
520            #[cfg(feature = "http_client")]
521            http_client: None,
522            #[cfg(feature = "git")]
523            git_client: None,
524            pipeline_stdin: None,
525            output_callback: None,
526            output_emit_count: 0,
527            nounset_error: None,
528            traps: HashMap::new(),
529            pipestatus: Vec::new(),
530            aliases: HashMap::new(),
531            expanding_aliases: HashSet::new(),
532        }
533    }
534
535    /// Get mutable access to shell options (for builtins like `set`)
536    #[allow(dead_code)]
537    pub fn options_mut(&mut self) -> &mut ShellOptions {
538        &mut self.options
539    }
540
541    /// Get shell options
542    #[allow(dead_code)]
543    pub fn options(&self) -> &ShellOptions {
544        &self.options
545    }
546
547    /// Check if errexit (set -e) is enabled
548    /// This checks both the options struct and the SHOPT_e variable
549    /// (the `set` builtin stores options in SHOPT_e)
550    fn is_errexit_enabled(&self) -> bool {
551        self.options.errexit
552            || self
553                .variables
554                .get("SHOPT_e")
555                .map(|v| v == "1")
556                .unwrap_or(false)
557    }
558
559    /// Check if xtrace (set -x) is enabled
560    fn is_xtrace_enabled(&self) -> bool {
561        self.options.xtrace
562            || self
563                .variables
564                .get("SHOPT_x")
565                .map(|v| v == "1")
566                .unwrap_or(false)
567    }
568
569    /// Set execution limits.
570    pub fn set_limits(&mut self, limits: ExecutionLimits) {
571        self.limits = limits;
572    }
573
574    /// Set an environment variable.
575    pub fn set_env(&mut self, key: &str, value: &str) {
576        self.env.insert(key.to_string(), value.to_string());
577    }
578
579    /// Set a shell variable (public API for builder).
580    pub fn set_var(&mut self, key: &str, value: &str) {
581        self.variables.insert(key.to_string(), value.to_string());
582    }
583
584    /// Set the current working directory.
585    pub fn set_cwd(&mut self, cwd: PathBuf) {
586        self.cwd = cwd;
587    }
588
589    /// Capture the current shell state (variables, env, cwd, options).
590    pub fn shell_state(&self) -> ShellState {
591        ShellState {
592            env: self.env.clone(),
593            variables: self.variables.clone(),
594            arrays: self.arrays.clone(),
595            assoc_arrays: self.assoc_arrays.clone(),
596            cwd: self.cwd.clone(),
597            last_exit_code: self.last_exit_code,
598            aliases: self.aliases.clone(),
599            traps: self.traps.clone(),
600            errexit: self.options.errexit,
601            xtrace: self.options.xtrace,
602            pipefail: self.options.pipefail,
603        }
604    }
605
606    /// Restore shell state from a snapshot.
607    pub fn restore_shell_state(&mut self, state: &ShellState) {
608        self.env = state.env.clone();
609        self.variables = state.variables.clone();
610        self.arrays = state.arrays.clone();
611        self.assoc_arrays = state.assoc_arrays.clone();
612        self.cwd = state.cwd.clone();
613        self.last_exit_code = state.last_exit_code;
614        self.aliases = state.aliases.clone();
615        self.traps = state.traps.clone();
616        self.options.errexit = state.errexit;
617        self.options.xtrace = state.xtrace;
618        self.options.pipefail = state.pipefail;
619    }
620
621    /// Set an output callback for streaming output during execution.
622    ///
623    /// When set, the interpreter calls this callback with `(stdout_chunk, stderr_chunk)`
624    /// after each loop iteration, command list element, and top-level command.
625    /// Output is still accumulated in the returned `ExecResult` for the final result.
626    pub fn set_output_callback(&mut self, callback: OutputCallback) {
627        self.output_callback = Some(callback);
628        self.output_emit_count = 0;
629    }
630
631    /// Clear the output callback.
632    pub fn clear_output_callback(&mut self) {
633        self.output_callback = None;
634        self.output_emit_count = 0;
635    }
636
637    /// Emit output via the callback if set, and if sub-calls didn't already emit.
638    /// Returns `true` if output was emitted.
639    ///
640    /// `emit_count_before` is the value of `output_emit_count` before the sub-call
641    /// that produced this output. If the count advanced, sub-calls already emitted
642    /// and we skip to avoid duplicates.
643    fn maybe_emit_output(&mut self, stdout: &str, stderr: &str, emit_count_before: u64) -> bool {
644        if self.output_callback.is_none() {
645            return false;
646        }
647        // Sub-calls already emitted — skip to avoid duplicates
648        if self.output_emit_count != emit_count_before {
649            return false;
650        }
651        if stdout.is_empty() && stderr.is_empty() {
652            return false;
653        }
654        if let Some(ref mut cb) = self.output_callback {
655            cb(stdout, stderr);
656            self.output_emit_count += 1;
657        }
658        true
659    }
660
661    /// Set the HTTP client for network builtins (curl, wget).
662    ///
663    /// This is only available when the `http_client` feature is enabled.
664    #[cfg(feature = "http_client")]
665    pub fn set_http_client(&mut self, client: crate::network::HttpClient) {
666        self.http_client = Some(client);
667    }
668
669    /// Set the git client for git builtins.
670    ///
671    /// This is only available when the `git` feature is enabled.
672    #[cfg(feature = "git")]
673    pub fn set_git_client(&mut self, client: crate::git::GitClient) {
674        self.git_client = Some(client);
675    }
676
677    /// Execute a script.
678    pub async fn execute(&mut self, script: &Script) -> Result<ExecResult> {
679        // Reset per-execution counters so each exec() gets a fresh budget.
680        // Without this, hitting the limit in one exec() permanently poisons the session.
681        self.counters.reset_for_execution();
682
683        let mut stdout = String::new();
684        let mut stderr = String::new();
685        let mut exit_code = 0;
686
687        for command in &script.commands {
688            let emit_before = self.output_emit_count;
689            let result = self.execute_command(command).await?;
690            self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
691            stdout.push_str(&result.stdout);
692            stderr.push_str(&result.stderr);
693            exit_code = result.exit_code;
694            self.last_exit_code = exit_code;
695
696            // Stop on control flow (e.g. nounset error uses Return to abort)
697            if result.control_flow != ControlFlow::None {
698                break;
699            }
700
701            // Run ERR trap on non-zero exit (unless in conditional chain)
702            if exit_code != 0 {
703                let suppressed = matches!(command, Command::List(_))
704                    || matches!(command, Command::Pipeline(p) if p.negated);
705                if !suppressed {
706                    self.run_err_trap(&mut stdout, &mut stderr).await;
707                }
708            }
709
710            // errexit (set -e): stop on non-zero exit for top-level simple commands.
711            // List commands handle errexit internally (with && / || chain awareness).
712            // Negated pipelines (! cmd) explicitly handle the exit code.
713            if self.is_errexit_enabled() && exit_code != 0 {
714                let suppressed = matches!(command, Command::List(_))
715                    || matches!(command, Command::Pipeline(p) if p.negated);
716                if !suppressed {
717                    break;
718                }
719            }
720        }
721
722        // Run EXIT trap if registered
723        if let Some(trap_cmd) = self.traps.get("EXIT").cloned() {
724            // THREAT[TM-DOS-030]: Propagate interpreter parser limits
725            if let Ok(trap_script) = Parser::with_limits(
726                &trap_cmd,
727                self.limits.max_ast_depth,
728                self.limits.max_parser_operations,
729            )
730            .parse()
731            {
732                let emit_before = self.output_emit_count;
733                if let Ok(trap_result) = self.execute_command_sequence(&trap_script.commands).await
734                {
735                    self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before);
736                    stdout.push_str(&trap_result.stdout);
737                    stderr.push_str(&trap_result.stderr);
738                }
739            }
740        }
741
742        Ok(ExecResult {
743            stdout,
744            stderr,
745            exit_code,
746            control_flow: ControlFlow::None,
747        })
748    }
749
750    /// Get the source line number from a command's span
751    fn command_line(command: &Command) -> usize {
752        match command {
753            Command::Simple(c) => c.span.line(),
754            Command::Pipeline(c) => c.span.line(),
755            Command::List(c) => c.span.line(),
756            Command::Compound(c, _) => match c {
757                CompoundCommand::If(cmd) => cmd.span.line(),
758                CompoundCommand::For(cmd) => cmd.span.line(),
759                CompoundCommand::ArithmeticFor(cmd) => cmd.span.line(),
760                CompoundCommand::While(cmd) => cmd.span.line(),
761                CompoundCommand::Until(cmd) => cmd.span.line(),
762                CompoundCommand::Case(cmd) => cmd.span.line(),
763                CompoundCommand::Select(cmd) => cmd.span.line(),
764                CompoundCommand::Time(cmd) => cmd.span.line(),
765                CompoundCommand::Subshell(_) | CompoundCommand::BraceGroup(_) => 1,
766                CompoundCommand::Arithmetic(_) | CompoundCommand::Conditional(_) => 1,
767            },
768            Command::Function(c) => c.span.line(),
769        }
770    }
771
772    fn execute_command<'a>(
773        &'a mut self,
774        command: &'a Command,
775    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ExecResult>> + Send + 'a>> {
776        Box::pin(async move {
777            // Update current line for $LINENO
778            self.current_line = Self::command_line(command);
779
780            // Fail point: inject failures during command execution
781            #[cfg(feature = "failpoints")]
782            fail_point!("interp::execute_command", |action| {
783                match action.as_deref() {
784                    Some("panic") => {
785                        // Test panic recovery
786                        panic!("injected panic in execute_command");
787                    }
788                    Some("error") => {
789                        return Err(Error::Execution("injected execution error".to_string()));
790                    }
791                    Some("exit_nonzero") => {
792                        // Return non-zero exit code without error
793                        return Ok(ExecResult {
794                            stdout: String::new(),
795                            stderr: "injected failure".to_string(),
796                            exit_code: 127,
797                            control_flow: ControlFlow::None,
798                        });
799                    }
800                    _ => {}
801                }
802                Ok(ExecResult::ok(String::new()))
803            });
804
805            // Check command count limit
806            self.counters.tick_command(&self.limits)?;
807
808            match command {
809                Command::Simple(simple) => self.execute_simple_command(simple, None).await,
810                Command::Pipeline(pipeline) => self.execute_pipeline(pipeline).await,
811                Command::List(list) => self.execute_list(list).await,
812                Command::Compound(compound, redirects) => {
813                    // Process input redirections before executing compound
814                    let stdin = self.process_input_redirections(None, redirects).await?;
815                    let prev_pipeline_stdin = if stdin.is_some() {
816                        let prev = self.pipeline_stdin.take();
817                        self.pipeline_stdin = stdin;
818                        Some(prev)
819                    } else {
820                        None
821                    };
822                    let result = self.execute_compound(compound).await?;
823                    if let Some(prev) = prev_pipeline_stdin {
824                        self.pipeline_stdin = prev;
825                    }
826                    if redirects.is_empty() {
827                        Ok(result)
828                    } else {
829                        self.apply_redirections(result, redirects).await
830                    }
831                }
832                Command::Function(func_def) => {
833                    // Store the function definition
834                    self.functions
835                        .insert(func_def.name.clone(), func_def.clone());
836                    Ok(ExecResult::ok(String::new()))
837                }
838            }
839        })
840    }
841
842    /// Execute a compound command (if, for, while, etc.)
843    async fn execute_compound(&mut self, compound: &CompoundCommand) -> Result<ExecResult> {
844        match compound {
845            CompoundCommand::If(if_cmd) => self.execute_if(if_cmd).await,
846            CompoundCommand::For(for_cmd) => self.execute_for(for_cmd).await,
847            CompoundCommand::ArithmeticFor(arith_for) => {
848                self.execute_arithmetic_for(arith_for).await
849            }
850            CompoundCommand::While(while_cmd) => self.execute_while(while_cmd).await,
851            CompoundCommand::Until(until_cmd) => self.execute_until(until_cmd).await,
852            CompoundCommand::Subshell(commands) => {
853                // Subshells run in fully isolated scope: variables, arrays,
854                // functions, cwd, traps, positional params, and options are
855                // all snapshot/restored so mutations don't leak to the parent.
856                let saved_vars = self.variables.clone();
857                let saved_arrays = self.arrays.clone();
858                let saved_assoc = self.assoc_arrays.clone();
859                let saved_functions = self.functions.clone();
860                let saved_cwd = self.cwd.clone();
861                let saved_traps = self.traps.clone();
862                let saved_call_stack = self.call_stack.clone();
863                let saved_exit = self.last_exit_code;
864                let saved_options = self.options.clone();
865                let saved_aliases = self.aliases.clone();
866
867                let mut result = self.execute_command_sequence(commands).await;
868
869                // Fire EXIT trap set inside the subshell before restoring parent state
870                if let Some(trap_cmd) = self.traps.get("EXIT").cloned() {
871                    // Only fire if the subshell set its own EXIT trap (different from parent)
872                    let parent_had_same = saved_traps.get("EXIT") == Some(&trap_cmd);
873                    if !parent_had_same {
874                        // THREAT[TM-DOS-030]: Propagate interpreter parser limits
875                        if let Ok(trap_script) = Parser::with_limits(
876                            &trap_cmd,
877                            self.limits.max_ast_depth,
878                            self.limits.max_parser_operations,
879                        )
880                        .parse()
881                        {
882                            let emit_before = self.output_emit_count;
883                            if let Ok(ref mut res) = result {
884                                if let Ok(trap_result) =
885                                    self.execute_command_sequence(&trap_script.commands).await
886                                {
887                                    self.maybe_emit_output(
888                                        &trap_result.stdout,
889                                        &trap_result.stderr,
890                                        emit_before,
891                                    );
892                                    res.stdout.push_str(&trap_result.stdout);
893                                    res.stderr.push_str(&trap_result.stderr);
894                                }
895                            }
896                        }
897                    }
898                }
899
900                self.variables = saved_vars;
901                self.arrays = saved_arrays;
902                self.assoc_arrays = saved_assoc;
903                self.functions = saved_functions;
904                self.cwd = saved_cwd;
905                self.traps = saved_traps;
906                self.call_stack = saved_call_stack;
907                self.last_exit_code = saved_exit;
908                self.options = saved_options;
909                self.aliases = saved_aliases;
910                result
911            }
912            CompoundCommand::BraceGroup(commands) => self.execute_command_sequence(commands).await,
913            CompoundCommand::Case(case_cmd) => self.execute_case(case_cmd).await,
914            CompoundCommand::Select(select_cmd) => self.execute_select(select_cmd).await,
915            CompoundCommand::Arithmetic(expr) => self.execute_arithmetic_command(expr).await,
916            CompoundCommand::Time(time_cmd) => self.execute_time(time_cmd).await,
917            CompoundCommand::Conditional(words) => self.execute_conditional(words).await,
918        }
919    }
920
921    /// Execute an if statement
922    async fn execute_if(&mut self, if_cmd: &IfCommand) -> Result<ExecResult> {
923        // Execute condition (no errexit checking - conditions are expected to fail)
924        let condition_result = self.execute_condition_sequence(&if_cmd.condition).await?;
925
926        if condition_result.exit_code == 0 {
927            // Condition succeeded, execute then branch
928            return self.execute_command_sequence(&if_cmd.then_branch).await;
929        }
930
931        // Check elif branches
932        for (elif_condition, elif_body) in &if_cmd.elif_branches {
933            let elif_result = self.execute_condition_sequence(elif_condition).await?;
934            if elif_result.exit_code == 0 {
935                return self.execute_command_sequence(elif_body).await;
936            }
937        }
938
939        // Execute else branch if present
940        if let Some(else_branch) = &if_cmd.else_branch {
941            return self.execute_command_sequence(else_branch).await;
942        }
943
944        // No branch executed, return success
945        Ok(ExecResult::ok(String::new()))
946    }
947
948    /// Execute a for loop
949    async fn execute_for(&mut self, for_cmd: &ForCommand) -> Result<ExecResult> {
950        // Validate for-loop variable name (bash rejects invalid names at runtime, exit 1)
951        if !Self::is_valid_var_name(&for_cmd.variable) {
952            return Ok(ExecResult::err(
953                format!("bash: `{}': not a valid identifier\n", for_cmd.variable),
954                1,
955            ));
956        }
957
958        let mut stdout = String::new();
959        let mut stderr = String::new();
960        let mut exit_code = 0;
961
962        // Get iteration values: expand fields, then apply brace/glob expansion
963        let values: Vec<String> = if let Some(words) = &for_cmd.words {
964            let mut vals = Vec::new();
965            for w in words {
966                let fields = self.expand_word_to_fields(w).await?;
967
968                // Quoted words skip brace/glob expansion
969                if w.quoted {
970                    vals.extend(fields);
971                    continue;
972                }
973
974                for expanded in fields {
975                    let brace_expanded = self.expand_braces(&expanded);
976                    for item in brace_expanded {
977                        match self.expand_glob_item(&item).await {
978                            Ok(items) => vals.extend(items),
979                            Err(pat) => {
980                                self.last_exit_code = 1;
981                                return Ok(ExecResult::err(
982                                    format!("-bash: no match: {}\n", pat),
983                                    1,
984                                ));
985                            }
986                        }
987                    }
988                }
989            }
990            vals
991        } else {
992            // No words specified - iterate over positional parameters ($@)
993            self.call_stack
994                .last()
995                .map(|frame| frame.positional.clone())
996                .unwrap_or_default()
997        };
998
999        // Reset loop counter for this loop
1000        self.counters.reset_loop();
1001
1002        for value in values {
1003            // Check loop iteration limit
1004            self.counters.tick_loop(&self.limits)?;
1005
1006            // Set loop variable (respects nameref)
1007            self.set_variable(for_cmd.variable.clone(), value.clone());
1008
1009            // Execute body
1010            let emit_before = self.output_emit_count;
1011            let result = self.execute_command_sequence(&for_cmd.body).await?;
1012            self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
1013            stdout.push_str(&result.stdout);
1014            stderr.push_str(&result.stderr);
1015            exit_code = result.exit_code;
1016
1017            // Check for break/continue
1018            match result.control_flow {
1019                ControlFlow::Break(n) => {
1020                    if n <= 1 {
1021                        break;
1022                    } else {
1023                        // Propagate break with decremented count
1024                        return Ok(ExecResult {
1025                            stdout,
1026                            stderr,
1027                            exit_code,
1028                            control_flow: ControlFlow::Break(n - 1),
1029                        });
1030                    }
1031                }
1032                ControlFlow::Continue(n) => {
1033                    if n <= 1 {
1034                        continue;
1035                    } else {
1036                        // Propagate continue with decremented count
1037                        return Ok(ExecResult {
1038                            stdout,
1039                            stderr,
1040                            exit_code,
1041                            control_flow: ControlFlow::Continue(n - 1),
1042                        });
1043                    }
1044                }
1045                ControlFlow::Return(code) => {
1046                    // Propagate return
1047                    return Ok(ExecResult {
1048                        stdout,
1049                        stderr,
1050                        exit_code: code,
1051                        control_flow: ControlFlow::Return(code),
1052                    });
1053                }
1054                ControlFlow::None => {
1055                    // Check if errexit caused early return from body
1056                    if self.is_errexit_enabled() && exit_code != 0 {
1057                        return Ok(ExecResult {
1058                            stdout,
1059                            stderr,
1060                            exit_code,
1061                            control_flow: ControlFlow::None,
1062                        });
1063                    }
1064                }
1065            }
1066        }
1067
1068        Ok(ExecResult {
1069            stdout,
1070            stderr,
1071            exit_code,
1072            control_flow: ControlFlow::None,
1073        })
1074    }
1075
1076    /// Execute a select loop: select var in list; do body; done
1077    ///
1078    /// Reads lines from pipeline_stdin. Each line is treated as the user's
1079    /// menu selection. If the line is a valid number, the variable is set to
1080    /// the corresponding item; otherwise it is set to empty. REPLY is always
1081    /// set to the raw input. EOF ends the loop.
1082    async fn execute_select(&mut self, select_cmd: &SelectCommand) -> Result<ExecResult> {
1083        let mut stdout = String::new();
1084        let mut stderr = String::new();
1085        let mut exit_code = 0;
1086
1087        // Expand word list
1088        let mut values = Vec::new();
1089        for w in &select_cmd.words {
1090            let fields = self.expand_word_to_fields(w).await?;
1091            if w.quoted {
1092                values.extend(fields);
1093            } else {
1094                for expanded in fields {
1095                    let brace_expanded = self.expand_braces(&expanded);
1096                    for item in brace_expanded {
1097                        match self.expand_glob_item(&item).await {
1098                            Ok(items) => values.extend(items),
1099                            Err(pat) => {
1100                                self.last_exit_code = 1;
1101                                return Ok(ExecResult::err(
1102                                    format!("-bash: no match: {}\n", pat),
1103                                    1,
1104                                ));
1105                            }
1106                        }
1107                    }
1108                }
1109            }
1110        }
1111
1112        if values.is_empty() {
1113            return Ok(ExecResult {
1114                stdout,
1115                stderr,
1116                exit_code,
1117                control_flow: ControlFlow::None,
1118            });
1119        }
1120
1121        // Build menu string
1122        let menu: String = values
1123            .iter()
1124            .enumerate()
1125            .map(|(i, v)| format!("{}) {}", i + 1, v))
1126            .collect::<Vec<_>>()
1127            .join("\n");
1128
1129        let ps3 = self
1130            .variables
1131            .get("PS3")
1132            .cloned()
1133            .unwrap_or_else(|| "#? ".to_string());
1134
1135        // Reset loop counter
1136        self.counters.reset_loop();
1137
1138        loop {
1139            self.counters.tick_loop(&self.limits)?;
1140
1141            // Output menu to stderr
1142            stderr.push_str(&menu);
1143            stderr.push('\n');
1144            stderr.push_str(&ps3);
1145
1146            // Read a line from pipeline_stdin
1147            let line = if let Some(ref ps) = self.pipeline_stdin {
1148                if ps.is_empty() {
1149                    // EOF: bash prints newline and exits with code 1
1150                    stdout.push('\n');
1151                    exit_code = 1;
1152                    break;
1153                }
1154                let data = ps.clone();
1155                if let Some(newline_pos) = data.find('\n') {
1156                    let line = data[..newline_pos].to_string();
1157                    self.pipeline_stdin = Some(data[newline_pos + 1..].to_string());
1158                    line
1159                } else {
1160                    self.pipeline_stdin = Some(String::new());
1161                    data
1162                }
1163            } else {
1164                // No stdin: bash prints newline and exits with code 1
1165                stdout.push('\n');
1166                exit_code = 1;
1167                break;
1168            };
1169
1170            // Set REPLY to raw input
1171            self.variables.insert("REPLY".to_string(), line.clone());
1172
1173            // Parse selection number
1174            let selected = line
1175                .trim()
1176                .parse::<usize>()
1177                .ok()
1178                .and_then(|n| {
1179                    if n >= 1 && n <= values.len() {
1180                        Some(values[n - 1].clone())
1181                    } else {
1182                        None
1183                    }
1184                })
1185                .unwrap_or_default();
1186
1187            self.variables.insert(select_cmd.variable.clone(), selected);
1188
1189            // Execute body
1190            let emit_before = self.output_emit_count;
1191            let result = self.execute_command_sequence(&select_cmd.body).await?;
1192            self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
1193            stdout.push_str(&result.stdout);
1194            stderr.push_str(&result.stderr);
1195            exit_code = result.exit_code;
1196
1197            // Check for break/continue
1198            match result.control_flow {
1199                ControlFlow::Break(n) => {
1200                    if n <= 1 {
1201                        break;
1202                    } else {
1203                        return Ok(ExecResult {
1204                            stdout,
1205                            stderr,
1206                            exit_code,
1207                            control_flow: ControlFlow::Break(n - 1),
1208                        });
1209                    }
1210                }
1211                ControlFlow::Continue(n) => {
1212                    if n <= 1 {
1213                        continue;
1214                    } else {
1215                        return Ok(ExecResult {
1216                            stdout,
1217                            stderr,
1218                            exit_code,
1219                            control_flow: ControlFlow::Continue(n - 1),
1220                        });
1221                    }
1222                }
1223                ControlFlow::Return(code) => {
1224                    return Ok(ExecResult {
1225                        stdout,
1226                        stderr,
1227                        exit_code: code,
1228                        control_flow: ControlFlow::Return(code),
1229                    });
1230                }
1231                ControlFlow::None => {}
1232            }
1233        }
1234
1235        Ok(ExecResult {
1236            stdout,
1237            stderr,
1238            exit_code,
1239            control_flow: ControlFlow::None,
1240        })
1241    }
1242
1243    /// Execute a C-style arithmetic for loop: for ((init; cond; step))
1244    async fn execute_arithmetic_for(
1245        &mut self,
1246        arith_for: &ArithmeticForCommand,
1247    ) -> Result<ExecResult> {
1248        let mut stdout = String::new();
1249        let mut stderr = String::new();
1250        let mut exit_code = 0;
1251
1252        // Execute initialization
1253        if !arith_for.init.is_empty() {
1254            self.execute_arithmetic_with_side_effects(&arith_for.init);
1255        }
1256
1257        // Reset loop counter for this loop
1258        self.counters.reset_loop();
1259
1260        loop {
1261            // Check loop iteration limit
1262            self.counters.tick_loop(&self.limits)?;
1263
1264            // Check condition (if empty, always true)
1265            if !arith_for.condition.is_empty() {
1266                let cond_result = self.evaluate_arithmetic(&arith_for.condition);
1267                if cond_result == 0 {
1268                    break;
1269                }
1270            }
1271
1272            // Execute body
1273            let emit_before = self.output_emit_count;
1274            let result = self.execute_command_sequence(&arith_for.body).await?;
1275            self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
1276            stdout.push_str(&result.stdout);
1277            stderr.push_str(&result.stderr);
1278            exit_code = result.exit_code;
1279
1280            // Check for break/continue
1281            match result.control_flow {
1282                ControlFlow::Break(n) => {
1283                    if n <= 1 {
1284                        break;
1285                    } else {
1286                        return Ok(ExecResult {
1287                            stdout,
1288                            stderr,
1289                            exit_code,
1290                            control_flow: ControlFlow::Break(n - 1),
1291                        });
1292                    }
1293                }
1294                ControlFlow::Continue(n) => {
1295                    if n > 1 {
1296                        return Ok(ExecResult {
1297                            stdout,
1298                            stderr,
1299                            exit_code,
1300                            control_flow: ControlFlow::Continue(n - 1),
1301                        });
1302                    }
1303                    // n <= 1: continue to next iteration (after step)
1304                }
1305                ControlFlow::Return(code) => {
1306                    return Ok(ExecResult {
1307                        stdout,
1308                        stderr,
1309                        exit_code: code,
1310                        control_flow: ControlFlow::Return(code),
1311                    });
1312                }
1313                ControlFlow::None => {
1314                    // Check if errexit caused early return from body
1315                    if self.is_errexit_enabled() && exit_code != 0 {
1316                        return Ok(ExecResult {
1317                            stdout,
1318                            stderr,
1319                            exit_code,
1320                            control_flow: ControlFlow::None,
1321                        });
1322                    }
1323                }
1324            }
1325
1326            // Execute step
1327            if !arith_for.step.is_empty() {
1328                self.execute_arithmetic_with_side_effects(&arith_for.step);
1329            }
1330        }
1331
1332        Ok(ExecResult {
1333            stdout,
1334            stderr,
1335            exit_code,
1336            control_flow: ControlFlow::None,
1337        })
1338    }
1339
1340    /// Execute an arithmetic command ((expression))
1341    /// Returns exit code 0 if result is non-zero, 1 if result is zero
1342    /// Execute a [[ conditional expression ]]
1343    async fn execute_conditional(&mut self, words: &[Word]) -> Result<ExecResult> {
1344        // Expand all words
1345        let mut expanded = Vec::new();
1346        for word in words {
1347            expanded.push(self.expand_word(word).await?);
1348        }
1349
1350        let result = self.evaluate_conditional(&expanded).await;
1351        let exit_code = if result { 0 } else { 1 };
1352        self.last_exit_code = exit_code;
1353
1354        Ok(ExecResult {
1355            stdout: String::new(),
1356            stderr: String::new(),
1357            exit_code,
1358            control_flow: ControlFlow::None,
1359        })
1360    }
1361
1362    /// Evaluate a [[ ]] conditional expression from expanded words.
1363    fn evaluate_conditional<'a>(
1364        &'a mut self,
1365        args: &'a [String],
1366    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>> {
1367        Box::pin(async move {
1368            if args.is_empty() {
1369                return false;
1370            }
1371
1372            // Handle negation
1373            if args[0] == "!" {
1374                return !self.evaluate_conditional(&args[1..]).await;
1375            }
1376
1377            // Handle parentheses
1378            if args.first().map(|s| s.as_str()) == Some("(")
1379                && args.last().map(|s| s.as_str()) == Some(")")
1380            {
1381                return self.evaluate_conditional(&args[1..args.len() - 1]).await;
1382            }
1383
1384            // Look for logical operators (lowest precedence, right to left)
1385            for i in (0..args.len()).rev() {
1386                if args[i] == "&&" && i > 0 {
1387                    return self.evaluate_conditional(&args[..i]).await
1388                        && self.evaluate_conditional(&args[i + 1..]).await;
1389                }
1390            }
1391            for i in (0..args.len()).rev() {
1392                if args[i] == "||" && i > 0 {
1393                    return self.evaluate_conditional(&args[..i]).await
1394                        || self.evaluate_conditional(&args[i + 1..]).await;
1395                }
1396            }
1397
1398            match args.len() {
1399                1 => !args[0].is_empty(),
1400                2 => {
1401                    // Unary operators
1402                    let resolve = |p: &str| -> std::path::PathBuf {
1403                        let path = std::path::Path::new(p);
1404                        if path.is_absolute() {
1405                            path.to_path_buf()
1406                        } else {
1407                            self.cwd.join(path)
1408                        }
1409                    };
1410                    match args[0].as_str() {
1411                        "-z" => args[1].is_empty(),
1412                        "-n" => !args[1].is_empty(),
1413                        "-e" | "-a" => self.fs.exists(&resolve(&args[1])).await.unwrap_or(false),
1414                        "-f" => self
1415                            .fs
1416                            .stat(&resolve(&args[1]))
1417                            .await
1418                            .map(|m| m.file_type.is_file())
1419                            .unwrap_or(false),
1420                        "-d" => self
1421                            .fs
1422                            .stat(&resolve(&args[1]))
1423                            .await
1424                            .map(|m| m.file_type.is_dir())
1425                            .unwrap_or(false),
1426                        "-r" | "-w" | "-x" => {
1427                            self.fs.exists(&resolve(&args[1])).await.unwrap_or(false)
1428                        }
1429                        "-s" => self
1430                            .fs
1431                            .stat(&resolve(&args[1]))
1432                            .await
1433                            .map(|m| m.size > 0)
1434                            .unwrap_or(false),
1435                        _ => !args[0].is_empty(),
1436                    }
1437                }
1438                3 => {
1439                    // Binary operators
1440                    match args[1].as_str() {
1441                        "=" | "==" => self.pattern_matches(&args[0], &args[2]),
1442                        "!=" => !self.pattern_matches(&args[0], &args[2]),
1443                        "<" => args[0] < args[2],
1444                        ">" => args[0] > args[2],
1445                        "-eq" => {
1446                            args[0].parse::<i64>().unwrap_or(0)
1447                                == args[2].parse::<i64>().unwrap_or(0)
1448                        }
1449                        "-ne" => {
1450                            args[0].parse::<i64>().unwrap_or(0)
1451                                != args[2].parse::<i64>().unwrap_or(0)
1452                        }
1453                        "-lt" => {
1454                            args[0].parse::<i64>().unwrap_or(0)
1455                                < args[2].parse::<i64>().unwrap_or(0)
1456                        }
1457                        "-le" => {
1458                            args[0].parse::<i64>().unwrap_or(0)
1459                                <= args[2].parse::<i64>().unwrap_or(0)
1460                        }
1461                        "-gt" => {
1462                            args[0].parse::<i64>().unwrap_or(0)
1463                                > args[2].parse::<i64>().unwrap_or(0)
1464                        }
1465                        "-ge" => {
1466                            args[0].parse::<i64>().unwrap_or(0)
1467                                >= args[2].parse::<i64>().unwrap_or(0)
1468                        }
1469                        "=~" => self.regex_match(&args[0], &args[2]),
1470                        "-nt" => {
1471                            let lm = self.fs.stat(std::path::Path::new(&args[0])).await;
1472                            let rm = self.fs.stat(std::path::Path::new(&args[2])).await;
1473                            match (lm, rm) {
1474                                (Ok(l), Ok(r)) => l.modified > r.modified,
1475                                (Ok(_), Err(_)) => true,
1476                                _ => false,
1477                            }
1478                        }
1479                        "-ot" => {
1480                            let lm = self.fs.stat(std::path::Path::new(&args[0])).await;
1481                            let rm = self.fs.stat(std::path::Path::new(&args[2])).await;
1482                            match (lm, rm) {
1483                                (Ok(l), Ok(r)) => l.modified < r.modified,
1484                                (Err(_), Ok(_)) => true,
1485                                _ => false,
1486                            }
1487                        }
1488                        "-ef" => {
1489                            let lp = crate::builtins::resolve_path(
1490                                &std::path::PathBuf::from("/"),
1491                                &args[0],
1492                            );
1493                            let rp = crate::builtins::resolve_path(
1494                                &std::path::PathBuf::from("/"),
1495                                &args[2],
1496                            );
1497                            lp == rp
1498                        }
1499                        _ => false,
1500                    }
1501                }
1502                _ => false,
1503            }
1504        })
1505    }
1506
1507    /// Perform regex match and set BASH_REMATCH array.
1508    fn regex_match(&mut self, string: &str, pattern: &str) -> bool {
1509        match regex::Regex::new(pattern) {
1510            Ok(re) => {
1511                if let Some(captures) = re.captures(string) {
1512                    // Set BASH_REMATCH array
1513                    let mut rematch = HashMap::new();
1514                    for (i, m) in captures.iter().enumerate() {
1515                        rematch.insert(i, m.map(|m| m.as_str().to_string()).unwrap_or_default());
1516                    }
1517                    self.arrays.insert("BASH_REMATCH".to_string(), rematch);
1518                    true
1519                } else {
1520                    self.arrays.remove("BASH_REMATCH");
1521                    false
1522                }
1523            }
1524            Err(_) => {
1525                self.arrays.remove("BASH_REMATCH");
1526                false
1527            }
1528        }
1529    }
1530
1531    async fn execute_arithmetic_command(&mut self, expr: &str) -> Result<ExecResult> {
1532        let result = self.execute_arithmetic_with_side_effects(expr);
1533        let exit_code = if result != 0 { 0 } else { 1 };
1534
1535        Ok(ExecResult {
1536            stdout: String::new(),
1537            stderr: String::new(),
1538            exit_code,
1539            control_flow: ControlFlow::None,
1540        })
1541    }
1542
1543    /// Execute arithmetic expression with side effects (assignments, ++, --)
1544    fn execute_arithmetic_with_side_effects(&mut self, expr: &str) -> i64 {
1545        let expr = expr.trim();
1546
1547        // Handle comma-separated expressions
1548        if expr.contains(',') {
1549            let parts: Vec<&str> = expr.split(',').collect();
1550            let mut result = 0;
1551            for part in parts {
1552                result = self.execute_arithmetic_with_side_effects(part.trim());
1553            }
1554            return result;
1555        }
1556
1557        // Handle assignment: var = expr or var op= expr
1558        if let Some(eq_pos) = expr.find('=') {
1559            // Check it's not ==, !=, <=, >=
1560            // eq_pos is a byte offset from find(), so use byte-safe slicing
1561            let before_eq = &expr[..eq_pos];
1562            let before = before_eq.chars().last();
1563            let after = expr[eq_pos + 1..].chars().next();
1564
1565            if after != Some('=') && !matches!(before, Some('!' | '<' | '>' | '=')) {
1566                // This is an assignment
1567                let lhs = expr[..eq_pos].trim();
1568                let rhs = expr[eq_pos + 1..].trim();
1569
1570                // Check for compound assignment (+=, -=, *=, /=, %=)
1571                let (var_name, op, effective_rhs) = if lhs.ends_with('+')
1572                    || lhs.ends_with('-')
1573                    || lhs.ends_with('*')
1574                    || lhs.ends_with('/')
1575                    || lhs.ends_with('%')
1576                {
1577                    let op = lhs.chars().last().unwrap();
1578                    let name = lhs[..lhs.len() - 1].trim();
1579                    (name, Some(op), rhs)
1580                } else {
1581                    (lhs, None, rhs)
1582                };
1583
1584                let rhs_value = self.execute_arithmetic_with_side_effects(effective_rhs);
1585                let final_value = if let Some(op) = op {
1586                    let current = self.evaluate_arithmetic(var_name);
1587                    // THREAT[TM-DOS-043]: wrapping to prevent overflow panic
1588                    match op {
1589                        '+' => current.wrapping_add(rhs_value),
1590                        '-' => current.wrapping_sub(rhs_value),
1591                        '*' => current.wrapping_mul(rhs_value),
1592                        '/' => {
1593                            if rhs_value != 0 && !(current == i64::MIN && rhs_value == -1) {
1594                                current / rhs_value
1595                            } else {
1596                                0
1597                            }
1598                        }
1599                        '%' => {
1600                            if rhs_value != 0 && !(current == i64::MIN && rhs_value == -1) {
1601                                current % rhs_value
1602                            } else {
1603                                0
1604                            }
1605                        }
1606                        _ => rhs_value,
1607                    }
1608                } else {
1609                    rhs_value
1610                };
1611
1612                self.set_variable(var_name.to_string(), final_value.to_string());
1613                return final_value;
1614            }
1615        }
1616
1617        // Handle pre-increment/decrement: ++var or --var
1618        if let Some(stripped) = expr.strip_prefix("++") {
1619            let var_name = stripped.trim();
1620            let current = self.evaluate_arithmetic(var_name);
1621            let new_value = current + 1;
1622            self.set_variable(var_name.to_string(), new_value.to_string());
1623            return new_value;
1624        }
1625        if let Some(stripped) = expr.strip_prefix("--") {
1626            let var_name = stripped.trim();
1627            let current = self.evaluate_arithmetic(var_name);
1628            let new_value = current - 1;
1629            self.set_variable(var_name.to_string(), new_value.to_string());
1630            return new_value;
1631        }
1632
1633        // Handle post-increment/decrement: var++ or var--
1634        if let Some(stripped) = expr.strip_suffix("++") {
1635            let var_name = stripped.trim();
1636            let current = self.evaluate_arithmetic(var_name);
1637            let new_value = current + 1;
1638            self.set_variable(var_name.to_string(), new_value.to_string());
1639            return current; // Return old value for post-increment
1640        }
1641        if let Some(stripped) = expr.strip_suffix("--") {
1642            let var_name = stripped.trim();
1643            let current = self.evaluate_arithmetic(var_name);
1644            let new_value = current - 1;
1645            self.set_variable(var_name.to_string(), new_value.to_string());
1646            return current; // Return old value for post-decrement
1647        }
1648
1649        // No side effects, just evaluate
1650        self.evaluate_arithmetic(expr)
1651    }
1652
1653    /// Execute a while loop
1654    async fn execute_while(&mut self, while_cmd: &WhileCommand) -> Result<ExecResult> {
1655        let mut stdout = String::new();
1656        let mut stderr = String::new();
1657        let mut exit_code = 0;
1658
1659        // Reset loop counter for this loop
1660        self.counters.reset_loop();
1661
1662        loop {
1663            // Check loop iteration limit
1664            self.counters.tick_loop(&self.limits)?;
1665
1666            // Check condition (no errexit - conditions are expected to fail)
1667            let emit_before_cond = self.output_emit_count;
1668            let condition_result = self
1669                .execute_condition_sequence(&while_cmd.condition)
1670                .await?;
1671            // Condition commands produce visible output (e.g., `while cat <<EOF; do ... done`)
1672            self.maybe_emit_output(
1673                &condition_result.stdout,
1674                &condition_result.stderr,
1675                emit_before_cond,
1676            );
1677            stdout.push_str(&condition_result.stdout);
1678            stderr.push_str(&condition_result.stderr);
1679            if condition_result.exit_code != 0 {
1680                break;
1681            }
1682
1683            // Execute body
1684            let emit_before = self.output_emit_count;
1685            let result = self.execute_command_sequence(&while_cmd.body).await?;
1686            self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
1687            stdout.push_str(&result.stdout);
1688            stderr.push_str(&result.stderr);
1689            exit_code = result.exit_code;
1690
1691            // Check for break/continue
1692            match result.control_flow {
1693                ControlFlow::Break(n) => {
1694                    if n <= 1 {
1695                        break;
1696                    } else {
1697                        return Ok(ExecResult {
1698                            stdout,
1699                            stderr,
1700                            exit_code,
1701                            control_flow: ControlFlow::Break(n - 1),
1702                        });
1703                    }
1704                }
1705                ControlFlow::Continue(n) => {
1706                    if n <= 1 {
1707                        continue;
1708                    } else {
1709                        return Ok(ExecResult {
1710                            stdout,
1711                            stderr,
1712                            exit_code,
1713                            control_flow: ControlFlow::Continue(n - 1),
1714                        });
1715                    }
1716                }
1717                ControlFlow::Return(code) => {
1718                    return Ok(ExecResult {
1719                        stdout,
1720                        stderr,
1721                        exit_code: code,
1722                        control_flow: ControlFlow::Return(code),
1723                    });
1724                }
1725                ControlFlow::None => {
1726                    // Check if errexit caused early return from body
1727                    if self.is_errexit_enabled() && exit_code != 0 {
1728                        return Ok(ExecResult {
1729                            stdout,
1730                            stderr,
1731                            exit_code,
1732                            control_flow: ControlFlow::None,
1733                        });
1734                    }
1735                }
1736            }
1737        }
1738
1739        Ok(ExecResult {
1740            stdout,
1741            stderr,
1742            exit_code,
1743            control_flow: ControlFlow::None,
1744        })
1745    }
1746
1747    /// Execute an until loop
1748    async fn execute_until(&mut self, until_cmd: &UntilCommand) -> Result<ExecResult> {
1749        let mut stdout = String::new();
1750        let mut stderr = String::new();
1751        let mut exit_code = 0;
1752
1753        // Reset loop counter for this loop
1754        self.counters.reset_loop();
1755
1756        loop {
1757            // Check loop iteration limit
1758            self.counters.tick_loop(&self.limits)?;
1759
1760            // Check condition (no errexit - conditions are expected to fail)
1761            let emit_before_cond = self.output_emit_count;
1762            let condition_result = self
1763                .execute_condition_sequence(&until_cmd.condition)
1764                .await?;
1765            // Condition commands produce visible output
1766            self.maybe_emit_output(
1767                &condition_result.stdout,
1768                &condition_result.stderr,
1769                emit_before_cond,
1770            );
1771            stdout.push_str(&condition_result.stdout);
1772            stderr.push_str(&condition_result.stderr);
1773            if condition_result.exit_code == 0 {
1774                break;
1775            }
1776
1777            // Execute body
1778            let emit_before = self.output_emit_count;
1779            let result = self.execute_command_sequence(&until_cmd.body).await?;
1780            self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
1781            stdout.push_str(&result.stdout);
1782            stderr.push_str(&result.stderr);
1783            exit_code = result.exit_code;
1784
1785            // Check for break/continue
1786            match result.control_flow {
1787                ControlFlow::Break(n) => {
1788                    if n <= 1 {
1789                        break;
1790                    } else {
1791                        return Ok(ExecResult {
1792                            stdout,
1793                            stderr,
1794                            exit_code,
1795                            control_flow: ControlFlow::Break(n - 1),
1796                        });
1797                    }
1798                }
1799                ControlFlow::Continue(n) => {
1800                    if n <= 1 {
1801                        continue;
1802                    } else {
1803                        return Ok(ExecResult {
1804                            stdout,
1805                            stderr,
1806                            exit_code,
1807                            control_flow: ControlFlow::Continue(n - 1),
1808                        });
1809                    }
1810                }
1811                ControlFlow::Return(code) => {
1812                    return Ok(ExecResult {
1813                        stdout,
1814                        stderr,
1815                        exit_code: code,
1816                        control_flow: ControlFlow::Return(code),
1817                    });
1818                }
1819                ControlFlow::None => {
1820                    // Check if errexit caused early return from body
1821                    if self.is_errexit_enabled() && exit_code != 0 {
1822                        return Ok(ExecResult {
1823                            stdout,
1824                            stderr,
1825                            exit_code,
1826                            control_flow: ControlFlow::None,
1827                        });
1828                    }
1829                }
1830            }
1831        }
1832
1833        Ok(ExecResult {
1834            stdout,
1835            stderr,
1836            exit_code,
1837            control_flow: ControlFlow::None,
1838        })
1839    }
1840
1841    /// Execute a case statement
1842    async fn execute_case(&mut self, case_cmd: &CaseCommand) -> Result<ExecResult> {
1843        use crate::parser::CaseTerminator;
1844        let word_value = self.expand_word(&case_cmd.word).await?;
1845
1846        let mut stdout = String::new();
1847        let mut stderr = String::new();
1848        let mut exit_code = 0;
1849        let mut fallthrough = false;
1850
1851        for case_item in &case_cmd.cases {
1852            let matched = if fallthrough {
1853                true
1854            } else {
1855                let mut m = false;
1856                for pattern in &case_item.patterns {
1857                    let pattern_str = self.expand_word(pattern).await?;
1858                    if self.pattern_matches(&word_value, &pattern_str) {
1859                        m = true;
1860                        break;
1861                    }
1862                }
1863                m
1864            };
1865
1866            if matched {
1867                let r = self.execute_command_sequence(&case_item.commands).await?;
1868                stdout.push_str(&r.stdout);
1869                stderr.push_str(&r.stderr);
1870                exit_code = r.exit_code;
1871                match case_item.terminator {
1872                    CaseTerminator::Break => {
1873                        return Ok(ExecResult {
1874                            stdout,
1875                            stderr,
1876                            exit_code,
1877                            control_flow: r.control_flow,
1878                        });
1879                    }
1880                    CaseTerminator::FallThrough => {
1881                        fallthrough = true;
1882                    }
1883                    CaseTerminator::Continue => {
1884                        fallthrough = false;
1885                    }
1886                }
1887            }
1888        }
1889
1890        Ok(ExecResult {
1891            stdout,
1892            stderr,
1893            exit_code,
1894            control_flow: ControlFlow::None,
1895        })
1896    }
1897
1898    /// Execute a time command - measure wall-clock execution time
1899    ///
1900    /// Note: Bashkit only measures wall-clock (real) time.
1901    /// User and system CPU time are always reported as 0.
1902    /// This is a documented incompatibility with bash.
1903    async fn execute_time(&mut self, time_cmd: &TimeCommand) -> Result<ExecResult> {
1904        use std::time::Instant;
1905
1906        let start = Instant::now();
1907
1908        // Execute the wrapped command if present
1909        let mut result = if let Some(cmd) = &time_cmd.command {
1910            self.execute_command(cmd).await?
1911        } else {
1912            // time with no command - just output timing for nothing
1913            ExecResult::ok(String::new())
1914        };
1915
1916        let elapsed = start.elapsed();
1917
1918        // Calculate time components
1919        let total_secs = elapsed.as_secs_f64();
1920        let minutes = (total_secs / 60.0).floor() as u64;
1921        let seconds = total_secs % 60.0;
1922
1923        // Format timing output (goes to stderr, per bash behavior)
1924        let timing = if time_cmd.posix_format {
1925            // POSIX format: simple, machine-readable
1926            format!("real {:.2}\nuser 0.00\nsys 0.00\n", total_secs)
1927        } else {
1928            // Default bash format
1929            format!(
1930                "\nreal\t{}m{:.3}s\nuser\t0m0.000s\nsys\t0m0.000s\n",
1931                minutes, seconds
1932            )
1933        };
1934
1935        // Append timing to stderr (preserve command's stderr)
1936        result.stderr.push_str(&timing);
1937
1938        Ok(result)
1939    }
1940
1941    /// Execute a timeout command - run command with time limit
1942    ///
1943    /// Usage: timeout [OPTIONS] DURATION COMMAND [ARGS...]
1944    ///
1945    /// Options:
1946    ///   --preserve-status  Exit with command's status even on timeout
1947    ///   -k DURATION        Kill signal timeout (ignored - always terminates)
1948    ///   -s SIGNAL          Signal to send (ignored)
1949    ///
1950    /// Exit codes:
1951    ///   124 - Command timed out
1952    ///   125 - Timeout itself failed (bad arguments)
1953    ///   Otherwise, exit status of command
1954    async fn execute_timeout(
1955        &mut self,
1956        args: &[String],
1957        stdin: Option<String>,
1958        redirects: &[Redirect],
1959    ) -> Result<ExecResult> {
1960        use std::time::Duration;
1961        use tokio::time::timeout;
1962
1963        const MAX_TIMEOUT_SECONDS: u64 = 300; // 5 minutes max for safety
1964
1965        if args.is_empty() {
1966            return Ok(ExecResult::err(
1967                "timeout: missing operand\nUsage: timeout DURATION COMMAND [ARGS...]\n".to_string(),
1968                125,
1969            ));
1970        }
1971
1972        // Parse options and find duration/command
1973        let mut preserve_status = false;
1974        let mut arg_idx = 0;
1975
1976        while arg_idx < args.len() {
1977            let arg = &args[arg_idx];
1978            match arg.as_str() {
1979                "--preserve-status" => {
1980                    preserve_status = true;
1981                    arg_idx += 1;
1982                }
1983                "-k" | "-s" => {
1984                    // These options take a value, skip it
1985                    arg_idx += 2;
1986                }
1987                s if s.starts_with('-')
1988                    && !s.chars().nth(1).is_some_and(|c| c.is_ascii_digit()) =>
1989                {
1990                    // Unknown option, skip
1991                    arg_idx += 1;
1992                }
1993                _ => break, // Found duration
1994            }
1995        }
1996
1997        if arg_idx >= args.len() {
1998            return Ok(ExecResult::err(
1999                "timeout: missing operand\nUsage: timeout DURATION COMMAND [ARGS...]\n".to_string(),
2000                125,
2001            ));
2002        }
2003
2004        // Parse duration
2005        let duration_str = &args[arg_idx];
2006        let max_duration = Duration::from_secs(MAX_TIMEOUT_SECONDS);
2007        let duration = match Self::parse_timeout_duration(duration_str) {
2008            Some(d) => {
2009                // Cap at max while preserving subsecond precision
2010                if d > max_duration {
2011                    max_duration
2012                } else {
2013                    d
2014                }
2015            }
2016            None => {
2017                return Ok(ExecResult::err(
2018                    format!("timeout: invalid time interval '{}'\n", duration_str),
2019                    125,
2020                ));
2021            }
2022        };
2023
2024        arg_idx += 1;
2025
2026        if arg_idx >= args.len() {
2027            return Ok(ExecResult::err(
2028                "timeout: missing command\nUsage: timeout DURATION COMMAND [ARGS...]\n".to_string(),
2029                125,
2030            ));
2031        }
2032
2033        // Build the inner command
2034        let cmd_name = &args[arg_idx];
2035        let cmd_args: Vec<String> = args[arg_idx + 1..].to_vec();
2036
2037        // If we have stdin from a pipeline, pass it to the inner command via here-string
2038        let inner_redirects = if let Some(ref stdin_data) = stdin {
2039            vec![Redirect {
2040                fd: None,
2041                kind: RedirectKind::HereString,
2042                target: Word::literal(stdin_data.trim_end_matches('\n').to_string()),
2043            }]
2044        } else {
2045            Vec::new()
2046        };
2047
2048        // Create a SimpleCommand for the inner command
2049        let inner_cmd = Command::Simple(SimpleCommand {
2050            name: Word::literal(cmd_name.clone()),
2051            args: cmd_args.iter().map(|s| Word::literal(s.clone())).collect(),
2052            redirects: inner_redirects,
2053            assignments: Vec::new(),
2054            span: Span::new(),
2055        });
2056
2057        // Execute with timeout using execute_command (which handles recursion via Box::pin)
2058        let exec_future = self.execute_command(&inner_cmd);
2059        let result = match timeout(duration, exec_future).await {
2060            Ok(Ok(result)) => result,
2061            Ok(Err(e)) => return Err(e),
2062            Err(_) => {
2063                // Timeout expired
2064                if preserve_status {
2065                    // Return the timeout exit code but preserve-status means...
2066                    // actually in bash --preserve-status makes timeout return
2067                    // the command's exit status, but if it times out, there's no status
2068                    // so it still returns 124
2069                    ExecResult::err(String::new(), 124)
2070                } else {
2071                    ExecResult::err(String::new(), 124)
2072                }
2073            }
2074        };
2075
2076        // Apply output redirections
2077        self.apply_redirections(result, redirects).await
2078    }
2079
2080    /// Parse a timeout duration string like "30", "30s", "5m", "1h"
2081    fn parse_timeout_duration(s: &str) -> Option<std::time::Duration> {
2082        use std::time::Duration;
2083
2084        let s = s.trim();
2085        if s.is_empty() {
2086            return None;
2087        }
2088
2089        // Check for suffix
2090        let (num_str, multiplier) = if let Some(stripped) = s.strip_suffix('s') {
2091            (stripped, 1u64)
2092        } else if let Some(stripped) = s.strip_suffix('m') {
2093            (stripped, 60u64)
2094        } else if let Some(stripped) = s.strip_suffix('h') {
2095            (stripped, 3600u64)
2096        } else if let Some(stripped) = s.strip_suffix('d') {
2097            (stripped, 86400u64)
2098        } else {
2099            (s, 1u64) // Default to seconds
2100        };
2101
2102        // Parse the number (support decimals)
2103        let seconds: f64 = num_str.parse().ok()?;
2104        if seconds < 0.0 {
2105            return None;
2106        }
2107
2108        let total_secs_f64 = seconds * multiplier as f64;
2109        Some(Duration::from_secs_f64(total_secs_f64))
2110    }
2111
2112    /// Execute `xargs` - build and execute command lines from stdin.
2113    ///
2114    /// Parses xargs options, splits stdin into arguments, and executes the
2115    /// target command via the interpreter for each batch.
2116    async fn execute_xargs(
2117        &mut self,
2118        args: &[String],
2119        stdin: Option<String>,
2120        redirects: &[Redirect],
2121    ) -> Result<ExecResult> {
2122        let mut replace_str: Option<String> = None;
2123        let mut max_args: Option<usize> = None;
2124        let mut delimiter: Option<char> = None;
2125        let mut command: Vec<String> = Vec::new();
2126
2127        // Parse xargs options
2128        let mut i = 0;
2129        while i < args.len() {
2130            let arg = &args[i];
2131            match arg.as_str() {
2132                "-I" => {
2133                    i += 1;
2134                    if i >= args.len() {
2135                        return Ok(ExecResult::err(
2136                            "xargs: option requires an argument -- 'I'\n".to_string(),
2137                            1,
2138                        ));
2139                    }
2140                    replace_str = Some(args[i].clone());
2141                    max_args = Some(1); // -I implies -n 1
2142                }
2143                "-n" => {
2144                    i += 1;
2145                    if i >= args.len() {
2146                        return Ok(ExecResult::err(
2147                            "xargs: option requires an argument -- 'n'\n".to_string(),
2148                            1,
2149                        ));
2150                    }
2151                    match args[i].parse::<usize>() {
2152                        Ok(n) if n > 0 => max_args = Some(n),
2153                        _ => {
2154                            return Ok(ExecResult::err(
2155                                format!("xargs: invalid number: '{}'\n", args[i]),
2156                                1,
2157                            ));
2158                        }
2159                    }
2160                }
2161                "-d" => {
2162                    i += 1;
2163                    if i >= args.len() {
2164                        return Ok(ExecResult::err(
2165                            "xargs: option requires an argument -- 'd'\n".to_string(),
2166                            1,
2167                        ));
2168                    }
2169                    delimiter = args[i].chars().next();
2170                }
2171                "-0" => {
2172                    delimiter = Some('\0');
2173                }
2174                s if s.starts_with('-') && s != "-" => {
2175                    return Ok(ExecResult::err(
2176                        format!("xargs: invalid option -- '{}'\n", &s[1..]),
2177                        1,
2178                    ));
2179                }
2180                _ => {
2181                    // Rest is the command
2182                    command.extend(args[i..].iter().cloned());
2183                    break;
2184                }
2185            }
2186            i += 1;
2187        }
2188
2189        // Default command is echo
2190        if command.is_empty() {
2191            command.push("echo".to_string());
2192        }
2193
2194        // Read input
2195        let input = stdin.as_deref().unwrap_or("");
2196        if input.is_empty() {
2197            let result = ExecResult::ok(String::new());
2198            return self.apply_redirections(result, redirects).await;
2199        }
2200
2201        // Split input by delimiter
2202        let items: Vec<&str> = if let Some(delim) = delimiter {
2203            input.split(delim).filter(|s| !s.is_empty()).collect()
2204        } else {
2205            input.split_whitespace().collect()
2206        };
2207
2208        if items.is_empty() {
2209            let result = ExecResult::ok(String::new());
2210            return self.apply_redirections(result, redirects).await;
2211        }
2212
2213        let mut combined_stdout = String::new();
2214        let mut combined_stderr = String::new();
2215        let mut last_exit_code = 0;
2216
2217        // Group items based on max_args
2218        let chunk_size = max_args.unwrap_or(items.len());
2219        let chunks: Vec<Vec<&str>> = items.chunks(chunk_size).map(|c| c.to_vec()).collect();
2220
2221        for chunk in chunks {
2222            let cmd_args: Vec<String> = if let Some(ref repl) = replace_str {
2223                // With -I, substitute REPLACE string in all command args
2224                let item = chunk.first().unwrap_or(&"");
2225                command.iter().map(|arg| arg.replace(repl, item)).collect()
2226            } else {
2227                // Append chunk items as arguments after the command
2228                let mut full = command.clone();
2229                full.extend(chunk.iter().map(|s| s.to_string()));
2230                full
2231            };
2232
2233            // Build a SimpleCommand and execute it through the interpreter
2234            let cmd_name = cmd_args[0].clone();
2235            let cmd_rest: Vec<Word> = cmd_args[1..]
2236                .iter()
2237                .map(|s| Word::literal(s.clone()))
2238                .collect();
2239
2240            let inner_cmd = Command::Simple(SimpleCommand {
2241                name: Word::literal(cmd_name),
2242                args: cmd_rest,
2243                redirects: Vec::new(),
2244                assignments: Vec::new(),
2245                span: Span::new(),
2246            });
2247
2248            let result = self.execute_command(&inner_cmd).await?;
2249            combined_stdout.push_str(&result.stdout);
2250            combined_stderr.push_str(&result.stderr);
2251            last_exit_code = result.exit_code;
2252        }
2253
2254        let mut result = ExecResult {
2255            stdout: combined_stdout,
2256            stderr: combined_stderr,
2257            exit_code: last_exit_code,
2258            control_flow: ControlFlow::None,
2259        };
2260
2261        result = self.apply_redirections(result, redirects).await?;
2262        Ok(result)
2263    }
2264
2265    /// Execute `find` with `-exec` support.
2266    ///
2267    /// Intercepts find when -exec is present so commands can be executed
2268    /// through the interpreter. Supports:
2269    /// - `find PATH -exec cmd {} \;`  (per-file execution)
2270    /// - `find PATH -exec cmd {} +`   (batch execution)
2271    /// - All standard find options: -name, -type, -maxdepth
2272    async fn execute_find(
2273        &mut self,
2274        args: &[String],
2275        redirects: &[Redirect],
2276    ) -> Result<ExecResult> {
2277        let mut search_paths: Vec<String> = Vec::new();
2278        let mut name_pattern: Option<String> = None;
2279        let mut type_filter: Option<char> = None;
2280        let mut max_depth: Option<usize> = None;
2281        let mut exec_args: Vec<String> = Vec::new();
2282        let mut exec_batch = false;
2283
2284        // Parse arguments
2285        let mut i = 0;
2286        while i < args.len() {
2287            let arg = &args[i];
2288            match arg.as_str() {
2289                "-name" => {
2290                    i += 1;
2291                    if i >= args.len() {
2292                        return Ok(ExecResult::err(
2293                            "find: missing argument to '-name'\n".to_string(),
2294                            1,
2295                        ));
2296                    }
2297                    name_pattern = Some(args[i].clone());
2298                }
2299                "-type" => {
2300                    i += 1;
2301                    if i >= args.len() {
2302                        return Ok(ExecResult::err(
2303                            "find: missing argument to '-type'\n".to_string(),
2304                            1,
2305                        ));
2306                    }
2307                    match args[i].as_str() {
2308                        "f" | "d" | "l" => type_filter = Some(args[i].chars().next().unwrap()),
2309                        t => {
2310                            return Ok(ExecResult::err(format!("find: unknown type '{}'\n", t), 1));
2311                        }
2312                    }
2313                }
2314                "-maxdepth" => {
2315                    i += 1;
2316                    if i >= args.len() {
2317                        return Ok(ExecResult::err(
2318                            "find: missing argument to '-maxdepth'\n".to_string(),
2319                            1,
2320                        ));
2321                    }
2322                    match args[i].parse::<usize>() {
2323                        Ok(n) => max_depth = Some(n),
2324                        Err(_) => {
2325                            return Ok(ExecResult::err(
2326                                format!("find: invalid maxdepth value '{}'\n", args[i]),
2327                                1,
2328                            ));
2329                        }
2330                    }
2331                }
2332                "-print" | "-print0" => {}
2333                "-exec" | "-execdir" => {
2334                    i += 1;
2335                    while i < args.len() {
2336                        let a = &args[i];
2337                        if a == ";" || a == "\\;" {
2338                            break;
2339                        }
2340                        if a == "+" {
2341                            exec_batch = true;
2342                            break;
2343                        }
2344                        exec_args.push(a.clone());
2345                        i += 1;
2346                    }
2347                }
2348                "-not" | "!" => {}
2349                s if s.starts_with('-') => {
2350                    return Ok(ExecResult::err(
2351                        format!("find: unknown predicate '{}'\n", s),
2352                        1,
2353                    ));
2354                }
2355                _ => {
2356                    search_paths.push(arg.clone());
2357                }
2358            }
2359            i += 1;
2360        }
2361
2362        if search_paths.is_empty() {
2363            search_paths.push(".".to_string());
2364        }
2365
2366        // Collect matching paths via recursive walk
2367        let mut matched_paths: Vec<String> = Vec::new();
2368        for path_str in &search_paths {
2369            let path = self.resolve_path(path_str);
2370            if !self.fs.exists(&path).await.unwrap_or(false) {
2371                return Ok(ExecResult::err(
2372                    format!("find: '{}': No such file or directory\n", path_str),
2373                    1,
2374                ));
2375            }
2376            self.find_collect(
2377                &path,
2378                path_str,
2379                &name_pattern,
2380                type_filter,
2381                max_depth,
2382                0,
2383                &mut matched_paths,
2384            )
2385            .await?;
2386        }
2387
2388        // Execute commands for matched paths
2389        if exec_args.is_empty() {
2390            // No exec command parsed, just print
2391            let output =
2392                matched_paths.join("\n") + if matched_paths.is_empty() { "" } else { "\n" };
2393            let result = ExecResult::ok(output);
2394            return self.apply_redirections(result, redirects).await;
2395        }
2396
2397        let mut combined_stdout = String::new();
2398        let mut combined_stderr = String::new();
2399        let mut last_exit_code = 0;
2400
2401        if exec_batch {
2402            // Batch mode: -exec cmd {} +
2403            // Replace {} with all paths at once
2404            let cmd_args: Vec<String> = exec_args
2405                .iter()
2406                .flat_map(|arg| {
2407                    if arg == "{}" {
2408                        matched_paths.clone()
2409                    } else {
2410                        vec![arg.clone()]
2411                    }
2412                })
2413                .collect();
2414
2415            if !cmd_args.is_empty() {
2416                let cmd_name = cmd_args[0].clone();
2417                let cmd_rest: Vec<Word> = cmd_args[1..]
2418                    .iter()
2419                    .map(|s| Word::literal(s.clone()))
2420                    .collect();
2421
2422                let inner_cmd = Command::Simple(SimpleCommand {
2423                    name: Word::literal(cmd_name),
2424                    args: cmd_rest,
2425                    redirects: Vec::new(),
2426                    assignments: Vec::new(),
2427                    span: Span::new(),
2428                });
2429
2430                let result = self.execute_command(&inner_cmd).await?;
2431                combined_stdout.push_str(&result.stdout);
2432                combined_stderr.push_str(&result.stderr);
2433                last_exit_code = result.exit_code;
2434            }
2435        } else {
2436            // Per-file mode: -exec cmd {} \;
2437            for found_path in &matched_paths {
2438                let cmd_args: Vec<String> = exec_args
2439                    .iter()
2440                    .map(|arg| arg.replace("{}", found_path))
2441                    .collect();
2442
2443                if cmd_args.is_empty() {
2444                    continue;
2445                }
2446
2447                let cmd_name = cmd_args[0].clone();
2448                let cmd_rest: Vec<Word> = cmd_args[1..]
2449                    .iter()
2450                    .map(|s| Word::literal(s.clone()))
2451                    .collect();
2452
2453                let inner_cmd = Command::Simple(SimpleCommand {
2454                    name: Word::literal(cmd_name),
2455                    args: cmd_rest,
2456                    redirects: Vec::new(),
2457                    assignments: Vec::new(),
2458                    span: Span::new(),
2459                });
2460
2461                let result = self.execute_command(&inner_cmd).await?;
2462                combined_stdout.push_str(&result.stdout);
2463                combined_stderr.push_str(&result.stderr);
2464                last_exit_code = result.exit_code;
2465            }
2466        }
2467
2468        let mut result = ExecResult {
2469            stdout: combined_stdout,
2470            stderr: combined_stderr,
2471            exit_code: last_exit_code,
2472            control_flow: ControlFlow::None,
2473        };
2474
2475        result = self.apply_redirections(result, redirects).await?;
2476        Ok(result)
2477    }
2478
2479    /// Recursively collect paths matching find criteria.
2480    ///
2481    /// Helper for `execute_find`. Walks the filesystem tree and collects
2482    /// display paths of entries matching name/type/depth filters.
2483    #[allow(clippy::too_many_arguments)]
2484    fn find_collect<'a>(
2485        &'a self,
2486        path: &'a Path,
2487        display_path: &'a str,
2488        name_pattern: &'a Option<String>,
2489        type_filter: Option<char>,
2490        max_depth: Option<usize>,
2491        current_depth: usize,
2492        results: &'a mut Vec<String>,
2493    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
2494        Box::pin(async move {
2495            use crate::builtins::glob_match;
2496
2497            let metadata = self.fs.stat(path).await?;
2498            let entry_name = Path::new(display_path)
2499                .file_name()
2500                .map(|s| s.to_string_lossy().to_string())
2501                .unwrap_or_else(|| display_path.to_string());
2502
2503            let type_matches = match type_filter {
2504                Some('f') => metadata.file_type.is_file(),
2505                Some('d') => metadata.file_type.is_dir(),
2506                Some('l') => metadata.file_type.is_symlink(),
2507                _ => true,
2508            };
2509
2510            let name_matches = match name_pattern {
2511                Some(pattern) => glob_match(&entry_name, pattern),
2512                None => true,
2513            };
2514
2515            if type_matches && name_matches {
2516                results.push(display_path.to_string());
2517            }
2518
2519            if metadata.file_type.is_dir() {
2520                if let Some(max) = max_depth {
2521                    if current_depth >= max {
2522                        return Ok(());
2523                    }
2524                }
2525
2526                let entries = self.fs.read_dir(path).await?;
2527                let mut sorted_entries = entries;
2528                sorted_entries.sort_by(|a, b| a.name.cmp(&b.name));
2529
2530                for entry in sorted_entries {
2531                    let child_path = path.join(&entry.name);
2532                    let child_display = if display_path == "." {
2533                        format!("./{}", entry.name)
2534                    } else {
2535                        format!("{}/{}", display_path, entry.name)
2536                    };
2537
2538                    self.find_collect(
2539                        &child_path,
2540                        &child_display,
2541                        name_pattern,
2542                        type_filter,
2543                        max_depth,
2544                        current_depth + 1,
2545                        results,
2546                    )
2547                    .await?;
2548                }
2549            }
2550
2551            Ok(())
2552        })
2553    }
2554
2555    /// Execute `bash` or `sh` command - interpret scripts using this interpreter.
2556    ///
2557    /// Supports:
2558    /// - `bash -c "command"` - execute a command string
2559    /// - `bash -n script.sh` - syntax check only (noexec)
2560    /// - `bash script.sh [args...]` - execute a script file
2561    /// - `echo 'echo hello' | bash` - execute script from stdin
2562    /// - `bash --version` / `bash --help`
2563    ///
2564    /// SECURITY: This re-invokes the virtual interpreter, NOT external bash.
2565    /// See threat model TM-ESC-015 for security analysis.
2566    async fn execute_shell(
2567        &mut self,
2568        shell_name: &str,
2569        args: &[String],
2570        stdin: Option<String>,
2571        redirects: &[Redirect],
2572    ) -> Result<ExecResult> {
2573        // Parse options
2574        let mut command_string: Option<String> = None;
2575        let mut script_file: Option<String> = None;
2576        let mut script_args: Vec<String> = Vec::new();
2577        let mut noexec = false; // -n flag: syntax check only
2578                                // Shell options to set before executing the script
2579        let mut shell_opts: Vec<(&str, &str)> = Vec::new();
2580        let mut idx = 0;
2581
2582        while idx < args.len() {
2583            let arg = &args[idx];
2584            match arg.as_str() {
2585                "--version" => {
2586                    // Return virtual interpreter version info (not real bash)
2587                    return Ok(ExecResult::ok(format!(
2588                        "Bashkit {} (virtual {} interpreter)\n",
2589                        env!("CARGO_PKG_VERSION"),
2590                        shell_name
2591                    )));
2592                }
2593                "--help" => {
2594                    return Ok(ExecResult::ok(format!(
2595                        "Usage: {} [option] ... [file [argument] ...]\n\
2596                         Virtual shell interpreter (not GNU bash)\n\n\
2597                         Options:\n\
2598                         \t-c string\tExecute commands from string\n\
2599                         \t-n\t\tCheck syntax without executing (noexec)\n\
2600                         \t-e\t\tExit on error (errexit)\n\
2601                         \t-x\t\tPrint commands before execution (xtrace)\n\
2602                         \t-u\t\tError on unset variables (nounset)\n\
2603                         \t-o option\tSet option by name\n\
2604                         \t--version\tShow version\n\
2605                         \t--help\t\tShow this help\n",
2606                        shell_name
2607                    )));
2608                }
2609                "-c" => {
2610                    // Next argument is the command string
2611                    idx += 1;
2612                    if idx >= args.len() {
2613                        return Ok(ExecResult::err(
2614                            format!("{}: -c: option requires an argument\n", shell_name),
2615                            2,
2616                        ));
2617                    }
2618                    command_string = Some(args[idx].clone());
2619                    idx += 1;
2620                    // Remaining args become positional parameters (starting at $0)
2621                    script_args = args[idx..].to_vec();
2622                    break;
2623                }
2624                "-n" => {
2625                    noexec = true;
2626                    idx += 1;
2627                }
2628                "-e" => {
2629                    shell_opts.push(("SHOPT_e", "1"));
2630                    idx += 1;
2631                }
2632                "-x" => {
2633                    shell_opts.push(("SHOPT_x", "1"));
2634                    idx += 1;
2635                }
2636                "-u" => {
2637                    shell_opts.push(("SHOPT_u", "1"));
2638                    idx += 1;
2639                }
2640                "-v" => {
2641                    shell_opts.push(("SHOPT_v", "1"));
2642                    idx += 1;
2643                }
2644                "-f" => {
2645                    shell_opts.push(("SHOPT_f", "1"));
2646                    idx += 1;
2647                }
2648                "-o" => {
2649                    idx += 1;
2650                    if idx >= args.len() {
2651                        return Ok(ExecResult::err(
2652                            format!("{}: -o: option requires an argument\n", shell_name),
2653                            2,
2654                        ));
2655                    }
2656                    let opt = &args[idx];
2657                    match opt.as_str() {
2658                        "errexit" => shell_opts.push(("SHOPT_e", "1")),
2659                        "nounset" => shell_opts.push(("SHOPT_u", "1")),
2660                        "xtrace" => shell_opts.push(("SHOPT_x", "1")),
2661                        "verbose" => shell_opts.push(("SHOPT_v", "1")),
2662                        "pipefail" => shell_opts.push(("SHOPT_pipefail", "1")),
2663                        "noglob" => shell_opts.push(("SHOPT_f", "1")),
2664                        "noclobber" => shell_opts.push(("SHOPT_C", "1")),
2665                        _ => {
2666                            return Ok(ExecResult::err(
2667                                format!("{}: set: {}: invalid option name\n", shell_name, opt),
2668                                2,
2669                            ));
2670                        }
2671                    }
2672                    idx += 1;
2673                }
2674                // Accept but don't act on these:
2675                // -i (interactive): not applicable in virtual mode
2676                // -s (stdin): read from stdin (implicit behavior)
2677                "-i" | "-s" => {
2678                    idx += 1;
2679                }
2680                "--" => {
2681                    idx += 1;
2682                    // Remaining args after -- are file and arguments
2683                    if idx < args.len() {
2684                        script_file = Some(args[idx].clone());
2685                        idx += 1;
2686                        script_args = args[idx..].to_vec();
2687                    }
2688                    break;
2689                }
2690                s if s.starts_with("--") => {
2691                    // Unknown long option - skip
2692                    idx += 1;
2693                }
2694                s if s.starts_with('-') && s.len() > 1 => {
2695                    // Combined short options like -ne, -ev, -euxo
2696                    let chars: Vec<char> = s.chars().skip(1).collect();
2697                    let mut ci = 0;
2698                    while ci < chars.len() {
2699                        match chars[ci] {
2700                            'n' => noexec = true,
2701                            'e' => shell_opts.push(("SHOPT_e", "1")),
2702                            'x' => shell_opts.push(("SHOPT_x", "1")),
2703                            'u' => shell_opts.push(("SHOPT_u", "1")),
2704                            'v' => shell_opts.push(("SHOPT_v", "1")),
2705                            'f' => shell_opts.push(("SHOPT_f", "1")),
2706                            'o' => {
2707                                // -o in combined form: next arg is option name
2708                                idx += 1;
2709                                if idx < args.len() {
2710                                    match args[idx].as_str() {
2711                                        "errexit" => shell_opts.push(("SHOPT_e", "1")),
2712                                        "nounset" => shell_opts.push(("SHOPT_u", "1")),
2713                                        "xtrace" => shell_opts.push(("SHOPT_x", "1")),
2714                                        "verbose" => shell_opts.push(("SHOPT_v", "1")),
2715                                        "pipefail" => shell_opts.push(("SHOPT_pipefail", "1")),
2716                                        "noglob" => shell_opts.push(("SHOPT_f", "1")),
2717                                        "noclobber" => shell_opts.push(("SHOPT_C", "1")),
2718                                        _ => {}
2719                                    }
2720                                }
2721                            }
2722                            _ => {} // Ignore unknown
2723                        }
2724                        ci += 1;
2725                    }
2726                    idx += 1;
2727                }
2728                _ => {
2729                    // First non-option is the script file
2730                    script_file = Some(arg.clone());
2731                    idx += 1;
2732                    // Remaining args become positional parameters
2733                    script_args = args[idx..].to_vec();
2734                    break;
2735                }
2736            }
2737        }
2738
2739        // Determine what to execute
2740        let is_command_mode = command_string.is_some();
2741        let script_content = if let Some(cmd) = command_string {
2742            // bash -c "command"
2743            cmd
2744        } else if let Some(ref file) = script_file {
2745            // bash script.sh
2746            let path = self.resolve_path(file);
2747            match self.fs.read_file(&path).await {
2748                Ok(content) => String::from_utf8_lossy(&content).to_string(),
2749                Err(_) => {
2750                    return Ok(ExecResult::err(
2751                        format!("{}: {}: No such file or directory\n", shell_name, file),
2752                        127,
2753                    ));
2754                }
2755            }
2756        } else if let Some(ref stdin_content) = stdin {
2757            // Read script from stdin (pipe)
2758            stdin_content.clone()
2759        } else {
2760            // No command, file, or stdin - nothing to do
2761            return Ok(ExecResult::ok(String::new()));
2762        };
2763
2764        // THREAT[TM-DOS-021]: Propagate interpreter's parser limits to child shell
2765        let parser = Parser::with_limits(
2766            &script_content,
2767            self.limits.max_ast_depth,
2768            self.limits.max_parser_operations,
2769        );
2770        let script = match parser.parse() {
2771            Ok(s) => s,
2772            Err(e) => {
2773                return Ok(ExecResult::err(
2774                    format!("{}: syntax error: {}\n", shell_name, e),
2775                    2,
2776                ));
2777            }
2778        };
2779
2780        // -n (noexec): syntax check only, don't execute
2781        if noexec {
2782            return Ok(ExecResult::ok(String::new()));
2783        }
2784
2785        // Determine $0 and positional parameters
2786        // For bash -c "cmd" arg0 arg1: $0=arg0, $1=arg1
2787        // For bash script.sh arg1: $0=script.sh, $1=arg1
2788        let (name_arg, positional_args) = if is_command_mode {
2789            // For -c, first arg is $0, rest are $1, $2, etc.
2790            if script_args.is_empty() {
2791                (shell_name.to_string(), Vec::new())
2792            } else {
2793                let name = script_args[0].clone();
2794                let positional = script_args[1..].to_vec();
2795                (name, positional)
2796            }
2797        } else if let Some(ref file) = script_file {
2798            // For script file, filename is $0, args are $1, $2, etc.
2799            (file.clone(), script_args)
2800        } else {
2801            // Stdin mode
2802            (shell_name.to_string(), Vec::new())
2803        };
2804
2805        // Push a call frame for this script
2806        self.call_stack.push(CallFrame {
2807            name: name_arg,
2808            locals: HashMap::new(),
2809            positional: positional_args,
2810        });
2811
2812        // Save and apply shell options (-e, -x, -u, -o pipefail, etc.)
2813        // Also save/restore OPTIND so getopts state doesn't leak between scripts
2814        let mut saved_opts: Vec<(String, Option<String>)> = Vec::new();
2815        for (var, val) in &shell_opts {
2816            let prev = self.variables.get(*var).cloned();
2817            saved_opts.push((var.to_string(), prev));
2818            self.variables.insert(var.to_string(), val.to_string());
2819        }
2820        let saved_optind = self.variables.get("OPTIND").cloned();
2821        let saved_optchar = self.variables.get("_OPTCHAR_IDX").cloned();
2822        self.variables.insert("OPTIND".to_string(), "1".to_string());
2823        self.variables.remove("_OPTCHAR_IDX");
2824
2825        // Execute the script
2826        let result = self.execute(&script).await;
2827
2828        // Restore OPTIND and internal getopts state
2829        if let Some(val) = saved_optind {
2830            self.variables.insert("OPTIND".to_string(), val);
2831        } else {
2832            self.variables.remove("OPTIND");
2833        }
2834        if let Some(val) = saved_optchar {
2835            self.variables.insert("_OPTCHAR_IDX".to_string(), val);
2836        } else {
2837            self.variables.remove("_OPTCHAR_IDX");
2838        }
2839
2840        // Restore shell options
2841        for (var, prev) in saved_opts {
2842            if let Some(val) = prev {
2843                self.variables.insert(var, val);
2844            } else {
2845                self.variables.remove(&var);
2846            }
2847        }
2848
2849        // Pop the call frame
2850        self.call_stack.pop();
2851
2852        // Apply redirections and return
2853        match result {
2854            Ok(exec_result) => self.apply_redirections(exec_result, redirects).await,
2855            Err(e) => Err(e),
2856        }
2857    }
2858
2859    /// Check if pattern contains extglob operators
2860    fn contains_extglob(&self, s: &str) -> bool {
2861        if !self.is_extglob() {
2862            return false;
2863        }
2864        let bytes = s.as_bytes();
2865        for i in 0..bytes.len().saturating_sub(1) {
2866            if matches!(bytes[i], b'@' | b'?' | b'*' | b'+' | b'!') && bytes[i + 1] == b'(' {
2867                return true;
2868            }
2869        }
2870        false
2871    }
2872
2873    /// Check if a value matches a shell pattern
2874    fn pattern_matches(&self, value: &str, pattern: &str) -> bool {
2875        // Handle special case of * (match anything)
2876        if pattern == "*" {
2877            return true;
2878        }
2879
2880        // Glob pattern matching with *, ?, [], and extglob support
2881        if pattern.contains('*')
2882            || pattern.contains('?')
2883            || pattern.contains('[')
2884            || self.contains_extglob(pattern)
2885        {
2886            self.glob_match(value, pattern)
2887        } else {
2888            // Literal match
2889            value == pattern
2890        }
2891    }
2892
2893    /// Simple glob pattern matching with support for *, ?, and [...]
2894    fn glob_match(&self, value: &str, pattern: &str) -> bool {
2895        self.glob_match_impl(value, pattern, false, 0)
2896    }
2897
2898    /// Parse an extglob pattern-list from pattern string starting after '('.
2899    /// Returns (alternatives, rest_of_pattern) or None if malformed.
2900    fn parse_extglob_pattern_list(pattern: &str) -> Option<(Vec<String>, String)> {
2901        let mut depth = 1;
2902        let mut end = 0;
2903        let chars: Vec<char> = pattern.chars().collect();
2904        while end < chars.len() {
2905            match chars[end] {
2906                '(' => depth += 1,
2907                ')' => {
2908                    depth -= 1;
2909                    if depth == 0 {
2910                        let inner: String = chars[..end].iter().collect();
2911                        let rest: String = chars[end + 1..].iter().collect();
2912                        // Split on | at depth 0
2913                        let mut alts = Vec::new();
2914                        let mut current = String::new();
2915                        let mut d = 0;
2916                        for c in inner.chars() {
2917                            match c {
2918                                '(' => {
2919                                    d += 1;
2920                                    current.push(c);
2921                                }
2922                                ')' => {
2923                                    d -= 1;
2924                                    current.push(c);
2925                                }
2926                                '|' if d == 0 => {
2927                                    alts.push(current.clone());
2928                                    current.clear();
2929                                }
2930                                _ => current.push(c),
2931                            }
2932                        }
2933                        alts.push(current);
2934                        return Some((alts, rest));
2935                    }
2936                }
2937                '\\' => {
2938                    end += 1; // skip escaped char
2939                }
2940                _ => {}
2941            }
2942            end += 1;
2943        }
2944        None // unclosed paren
2945    }
2946
2947    /// Glob match with optional case-insensitive mode
2948    fn glob_match_impl(&self, value: &str, pattern: &str, nocase: bool, depth: usize) -> bool {
2949        // THREAT[TM-DOS-031]: Bail on excessive recursion depth
2950        if depth >= Self::MAX_GLOB_DEPTH {
2951            return false;
2952        }
2953
2954        let extglob = self.is_extglob();
2955
2956        // Check for extglob at the start of pattern
2957        if extglob && pattern.len() >= 2 {
2958            let bytes = pattern.as_bytes();
2959            if matches!(bytes[0], b'@' | b'?' | b'*' | b'+' | b'!') && bytes[1] == b'(' {
2960                let op = bytes[0];
2961                if let Some((alts, rest)) = Self::parse_extglob_pattern_list(&pattern[2..]) {
2962                    return self.match_extglob(op, &alts, &rest, value, nocase, depth + 1);
2963                }
2964            }
2965        }
2966
2967        let mut value_chars = value.chars().peekable();
2968        let mut pattern_chars = pattern.chars().peekable();
2969
2970        loop {
2971            match (pattern_chars.peek().copied(), value_chars.peek().copied()) {
2972                (None, None) => return true,
2973                (None, Some(_)) => return false,
2974                (Some('*'), _) => {
2975                    // Check for extglob *(...)
2976                    let mut pc_clone = pattern_chars.clone();
2977                    pc_clone.next();
2978                    if extglob && pc_clone.peek() == Some(&'(') {
2979                        // Extglob *(pattern-list) — collect remaining pattern
2980                        let remaining_pattern: String = pattern_chars.collect();
2981                        let remaining_value: String = value_chars.collect();
2982                        return self.glob_match_impl(
2983                            &remaining_value,
2984                            &remaining_pattern,
2985                            nocase,
2986                            depth + 1,
2987                        );
2988                    }
2989                    pattern_chars.next();
2990                    // * matches zero or more characters
2991                    if pattern_chars.peek().is_none() {
2992                        return true; // * at end matches everything
2993                    }
2994                    // Try matching from each position
2995                    while value_chars.peek().is_some() {
2996                        let remaining_value: String = value_chars.clone().collect();
2997                        let remaining_pattern: String = pattern_chars.clone().collect();
2998                        if self.glob_match_impl(
2999                            &remaining_value,
3000                            &remaining_pattern,
3001                            nocase,
3002                            depth + 1,
3003                        ) {
3004                            return true;
3005                        }
3006                        value_chars.next();
3007                    }
3008                    // Also try with empty match
3009                    let remaining_pattern: String = pattern_chars.collect();
3010                    return self.glob_match_impl("", &remaining_pattern, nocase, depth + 1);
3011                }
3012                (Some('?'), _) => {
3013                    // Check for extglob ?(...)
3014                    let mut pc_clone = pattern_chars.clone();
3015                    pc_clone.next();
3016                    if extglob && pc_clone.peek() == Some(&'(') {
3017                        let remaining_pattern: String = pattern_chars.collect();
3018                        let remaining_value: String = value_chars.collect();
3019                        return self.glob_match_impl(
3020                            &remaining_value,
3021                            &remaining_pattern,
3022                            nocase,
3023                            depth + 1,
3024                        );
3025                    }
3026                    if value_chars.peek().is_some() {
3027                        pattern_chars.next();
3028                        value_chars.next();
3029                    } else {
3030                        return false;
3031                    }
3032                }
3033                (Some('['), Some(v)) => {
3034                    pattern_chars.next(); // consume '['
3035                    let match_char = if nocase { v.to_ascii_lowercase() } else { v };
3036                    if let Some(matched) =
3037                        self.match_bracket_expr(&mut pattern_chars, match_char, nocase)
3038                    {
3039                        if matched {
3040                            value_chars.next();
3041                        } else {
3042                            return false;
3043                        }
3044                    } else {
3045                        // Invalid bracket expression, treat '[' as literal
3046                        return false;
3047                    }
3048                }
3049                (Some('['), None) => return false,
3050                (Some(p), Some(v)) => {
3051                    // Check for extglob operators: @(, +(, !(
3052                    if extglob && matches!(p, '@' | '+' | '!') {
3053                        let mut pc_clone = pattern_chars.clone();
3054                        pc_clone.next();
3055                        if pc_clone.peek() == Some(&'(') {
3056                            let remaining_pattern: String = pattern_chars.collect();
3057                            let remaining_value: String = value_chars.collect();
3058                            return self.glob_match_impl(
3059                                &remaining_value,
3060                                &remaining_pattern,
3061                                nocase,
3062                                depth + 1,
3063                            );
3064                        }
3065                    }
3066                    let matches = if nocase {
3067                        p.eq_ignore_ascii_case(&v)
3068                    } else {
3069                        p == v
3070                    };
3071                    if matches {
3072                        pattern_chars.next();
3073                        value_chars.next();
3074                    } else {
3075                        return false;
3076                    }
3077                }
3078                (Some(_), None) => return false,
3079            }
3080        }
3081    }
3082
3083    /// Match an extglob pattern against a value.
3084    /// op: b'@', b'?', b'*', b'+', b'!'
3085    /// alts: the | separated alternatives
3086    /// rest: pattern after the closing )
3087    fn match_extglob(
3088        &self,
3089        op: u8,
3090        alts: &[String],
3091        rest: &str,
3092        value: &str,
3093        nocase: bool,
3094        depth: usize,
3095    ) -> bool {
3096        // THREAT[TM-DOS-031]: Bail on excessive recursion depth
3097        if depth >= Self::MAX_GLOB_DEPTH {
3098            return false;
3099        }
3100
3101        match op {
3102            b'@' => {
3103                // @(a|b) — exactly one of the alternatives
3104                for alt in alts {
3105                    let full = format!("{}{}", alt, rest);
3106                    if self.glob_match_impl(value, &full, nocase, depth + 1) {
3107                        return true;
3108                    }
3109                }
3110                false
3111            }
3112            b'?' => {
3113                // ?(a|b) — zero or one of the alternatives
3114                // Try zero: skip the extglob entirely
3115                if self.glob_match_impl(value, rest, nocase, depth + 1) {
3116                    return true;
3117                }
3118                // Try one
3119                for alt in alts {
3120                    let full = format!("{}{}", alt, rest);
3121                    if self.glob_match_impl(value, &full, nocase, depth + 1) {
3122                        return true;
3123                    }
3124                }
3125                false
3126            }
3127            b'+' => {
3128                // +(a|b) — one or more of the alternatives
3129                for alt in alts {
3130                    let full = format!("{}{}", alt, rest);
3131                    if self.glob_match_impl(value, &full, nocase, depth + 1) {
3132                        return true;
3133                    }
3134                    // Try alt followed by more +(a|b)rest
3135                    // We need to try consuming `alt` prefix then matching +(...)rest again
3136                    for split in 1..=value.len() {
3137                        let prefix = &value[..split];
3138                        let suffix = &value[split..];
3139                        if self.glob_match_impl(prefix, alt, nocase, depth + 1) {
3140                            // Rebuild the extglob for the suffix
3141                            let inner = alts.join("|");
3142                            let re_pattern = format!("+({}){}", inner, rest);
3143                            if self.glob_match_impl(suffix, &re_pattern, nocase, depth + 1) {
3144                                return true;
3145                            }
3146                        }
3147                    }
3148                }
3149                false
3150            }
3151            b'*' => {
3152                // *(a|b) — zero or more of the alternatives
3153                // Try zero
3154                if self.glob_match_impl(value, rest, nocase, depth + 1) {
3155                    return true;
3156                }
3157                // Try one or more (same as +(...))
3158                for alt in alts {
3159                    let full = format!("{}{}", alt, rest);
3160                    if self.glob_match_impl(value, &full, nocase, depth + 1) {
3161                        return true;
3162                    }
3163                    for split in 1..=value.len() {
3164                        let prefix = &value[..split];
3165                        let suffix = &value[split..];
3166                        if self.glob_match_impl(prefix, alt, nocase, depth + 1) {
3167                            let inner = alts.join("|");
3168                            let re_pattern = format!("*({}){}", inner, rest);
3169                            if self.glob_match_impl(suffix, &re_pattern, nocase, depth + 1) {
3170                                return true;
3171                            }
3172                        }
3173                    }
3174                }
3175                false
3176            }
3177            b'!' => {
3178                // !(a|b) — match anything except one of the alternatives
3179                // Try every possible split point: prefix must NOT match any alt, rest matches
3180                // Actually: !(pat) matches anything that doesn't match @(pat)
3181                let inner = alts.join("|");
3182                let positive = format!("@({}){}", inner, rest);
3183                !self.glob_match_impl(value, &positive, nocase, depth + 1)
3184                    && self.glob_match_impl(value, rest, nocase, depth + 1)
3185                    || {
3186                        // !(pat) can also consume characters — try each split
3187                        for split in 1..=value.len() {
3188                            let prefix = &value[..split];
3189                            let suffix = &value[split..];
3190                            // prefix must not match any alt
3191                            let prefix_matches_any = alts
3192                                .iter()
3193                                .any(|a| self.glob_match_impl(prefix, a, nocase, depth + 1));
3194                            if !prefix_matches_any
3195                                && self.glob_match_impl(suffix, rest, nocase, depth + 1)
3196                            {
3197                                return true;
3198                            }
3199                        }
3200                        false
3201                    }
3202            }
3203            _ => false,
3204        }
3205    }
3206
3207    /// Match a bracket expression [abc], [a-z], [!abc], [^abc]
3208    /// Returns Some(true) if matched, Some(false) if not matched, None if invalid
3209    fn match_bracket_expr(
3210        &self,
3211        pattern_chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
3212        value_char: char,
3213        nocase: bool,
3214    ) -> Option<bool> {
3215        let mut chars_in_class = Vec::new();
3216        let mut negate = false;
3217
3218        // Check for negation
3219        if matches!(pattern_chars.peek(), Some('!') | Some('^')) {
3220            negate = true;
3221            pattern_chars.next();
3222        }
3223
3224        // Collect all characters in the bracket expression
3225        loop {
3226            match pattern_chars.next() {
3227                Some(']') if !chars_in_class.is_empty() => break,
3228                Some(']') if chars_in_class.is_empty() => {
3229                    // ] as first char is literal
3230                    chars_in_class.push(']');
3231                }
3232                Some('-') if !chars_in_class.is_empty() => {
3233                    // Could be a range
3234                    if let Some(&next) = pattern_chars.peek() {
3235                        if next == ']' {
3236                            // - at end is literal
3237                            chars_in_class.push('-');
3238                        } else {
3239                            // Range: prev-next
3240                            pattern_chars.next();
3241                            if let Some(&prev) = chars_in_class.last() {
3242                                for c in prev..=next {
3243                                    chars_in_class.push(c);
3244                                }
3245                            }
3246                        }
3247                    } else {
3248                        return None; // Unclosed bracket
3249                    }
3250                }
3251                Some(c) => chars_in_class.push(c),
3252                None => return None, // Unclosed bracket
3253            }
3254        }
3255
3256        let matched = if nocase {
3257            let lc = value_char.to_ascii_lowercase();
3258            chars_in_class.iter().any(|&c| c.to_ascii_lowercase() == lc)
3259        } else {
3260            chars_in_class.contains(&value_char)
3261        };
3262        Some(if negate { !matched } else { matched })
3263    }
3264
3265    /// Execute a sequence of commands (with errexit checking)
3266    async fn execute_command_sequence(&mut self, commands: &[Command]) -> Result<ExecResult> {
3267        self.execute_command_sequence_impl(commands, true).await
3268    }
3269
3270    /// Execute a sequence of commands used as a condition (no errexit checking)
3271    /// Used for if/while/until conditions where failure is expected
3272    async fn execute_condition_sequence(&mut self, commands: &[Command]) -> Result<ExecResult> {
3273        self.execute_command_sequence_impl(commands, false).await
3274    }
3275
3276    /// Execute a sequence of commands with optional errexit checking
3277    async fn execute_command_sequence_impl(
3278        &mut self,
3279        commands: &[Command],
3280        check_errexit: bool,
3281    ) -> Result<ExecResult> {
3282        let mut stdout = String::new();
3283        let mut stderr = String::new();
3284        let mut exit_code = 0;
3285
3286        for command in commands {
3287            let emit_before = self.output_emit_count;
3288            let result = self.execute_command(command).await?;
3289            self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
3290            stdout.push_str(&result.stdout);
3291            stderr.push_str(&result.stderr);
3292            exit_code = result.exit_code;
3293            self.last_exit_code = exit_code;
3294
3295            // Propagate control flow
3296            if result.control_flow != ControlFlow::None {
3297                return Ok(ExecResult {
3298                    stdout,
3299                    stderr,
3300                    exit_code,
3301                    control_flow: result.control_flow,
3302                });
3303            }
3304
3305            // Check for errexit (set -e) if enabled
3306            if check_errexit && self.is_errexit_enabled() && exit_code != 0 {
3307                return Ok(ExecResult {
3308                    stdout,
3309                    stderr,
3310                    exit_code,
3311                    control_flow: ControlFlow::None,
3312                });
3313            }
3314        }
3315
3316        Ok(ExecResult {
3317            stdout,
3318            stderr,
3319            exit_code,
3320            control_flow: ControlFlow::None,
3321        })
3322    }
3323
3324    /// Execute a pipeline (cmd1 | cmd2 | cmd3)
3325    async fn execute_pipeline(&mut self, pipeline: &Pipeline) -> Result<ExecResult> {
3326        let mut stdin_data: Option<String> = None;
3327        let mut last_result = ExecResult::ok(String::new());
3328        let mut pipe_statuses = Vec::new();
3329
3330        for (i, command) in pipeline.commands.iter().enumerate() {
3331            let is_last = i == pipeline.commands.len() - 1;
3332
3333            let result = match command {
3334                Command::Simple(simple) => {
3335                    self.execute_simple_command(simple, stdin_data.take())
3336                        .await?
3337                }
3338                _ => {
3339                    // Compound commands, lists, etc. in pipeline:
3340                    // set pipeline_stdin so inner commands (read, cat, etc.) can consume it
3341                    let prev_pipeline_stdin = self.pipeline_stdin.take();
3342                    self.pipeline_stdin = stdin_data.take();
3343                    let result = self.execute_command(command).await?;
3344                    self.pipeline_stdin = prev_pipeline_stdin;
3345                    result
3346                }
3347            };
3348
3349            pipe_statuses.push(result.exit_code);
3350
3351            if is_last {
3352                last_result = result;
3353            } else {
3354                stdin_data = Some(result.stdout);
3355            }
3356        }
3357
3358        // Store PIPESTATUS array
3359        self.pipestatus = pipe_statuses.clone();
3360        let mut ps_arr = HashMap::new();
3361        for (i, code) in pipe_statuses.iter().enumerate() {
3362            ps_arr.insert(i, code.to_string());
3363        }
3364        self.arrays.insert("PIPESTATUS".to_string(), ps_arr);
3365
3366        // pipefail: return rightmost non-zero exit code from pipeline
3367        if self.is_pipefail() {
3368            if let Some(&nonzero) = pipe_statuses.iter().rev().find(|&&c| c != 0) {
3369                last_result.exit_code = nonzero;
3370            }
3371        }
3372
3373        // Handle negation
3374        if pipeline.negated {
3375            last_result.exit_code = if last_result.exit_code == 0 { 1 } else { 0 };
3376        }
3377
3378        Ok(last_result)
3379    }
3380
3381    /// Execute a command list (cmd1 && cmd2 || cmd3)
3382    async fn execute_list(&mut self, list: &CommandList) -> Result<ExecResult> {
3383        let mut stdout = String::new();
3384        let mut stderr = String::new();
3385        let mut exit_code;
3386        let emit_before = self.output_emit_count;
3387        let result = self.execute_command(&list.first).await?;
3388        self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
3389        stdout.push_str(&result.stdout);
3390        stderr.push_str(&result.stderr);
3391        exit_code = result.exit_code;
3392        self.last_exit_code = exit_code;
3393        let mut control_flow = result.control_flow;
3394
3395        // If first command signaled control flow, return immediately
3396        if control_flow != ControlFlow::None {
3397            return Ok(ExecResult {
3398                stdout,
3399                stderr,
3400                exit_code,
3401                control_flow,
3402            });
3403        }
3404
3405        // Check if first command in a semicolon-separated list failed => ERR trap
3406        // Only fire if the first rest operator is semicolon (not &&/||)
3407        let first_op_is_semicolon = list
3408            .rest
3409            .first()
3410            .is_some_and(|(op, _)| matches!(op, ListOperator::Semicolon));
3411        if exit_code != 0 && first_op_is_semicolon {
3412            self.run_err_trap(&mut stdout, &mut stderr).await;
3413        }
3414
3415        // Track if the list contains any && or || operators
3416        // If so, failures within the list are "handled" by those operators
3417        let has_conditional_operators = list
3418            .rest
3419            .iter()
3420            .any(|(op, _)| matches!(op, ListOperator::And | ListOperator::Or));
3421
3422        // Track if we just exited a conditional chain (for errexit check)
3423        let mut just_exited_conditional_chain = false;
3424
3425        for (i, (op, cmd)) in list.rest.iter().enumerate() {
3426            // Check if next operator (if any) is && or ||
3427            let next_op = list.rest.get(i + 1).map(|(op, _)| op);
3428            let current_is_conditional = matches!(op, ListOperator::And | ListOperator::Or);
3429            let next_is_conditional =
3430                matches!(next_op, Some(ListOperator::And) | Some(ListOperator::Or));
3431
3432            // Check errexit before executing if:
3433            // - We just exited a conditional chain (and current op is semicolon)
3434            // - OR: current op is semicolon and previous wasn't in a conditional chain
3435            // - Exit code is non-zero
3436            // But NOT if we're about to enter/continue a conditional chain
3437            let should_check_errexit = matches!(op, ListOperator::Semicolon)
3438                && !just_exited_conditional_chain
3439                && self.is_errexit_enabled()
3440                && exit_code != 0;
3441
3442            if should_check_errexit {
3443                return Ok(ExecResult {
3444                    stdout,
3445                    stderr,
3446                    exit_code,
3447                    control_flow: ControlFlow::None,
3448                });
3449            }
3450
3451            // Reset the flag
3452            just_exited_conditional_chain = false;
3453
3454            // Mark that we're exiting a conditional chain if:
3455            // - Current is conditional (&&/||) and next is not conditional (;/end)
3456            if current_is_conditional && !next_is_conditional {
3457                just_exited_conditional_chain = true;
3458            }
3459
3460            let should_execute = match op {
3461                ListOperator::And => exit_code == 0,
3462                ListOperator::Or => exit_code != 0,
3463                ListOperator::Semicolon => true,
3464                ListOperator::Background => {
3465                    // Background (&) runs command synchronously in virtual mode.
3466                    // True process backgrounding requires OS process spawning which
3467                    // is excluded from the sandboxed virtual environment by design.
3468                    true
3469                }
3470            };
3471
3472            if should_execute {
3473                let emit_before = self.output_emit_count;
3474                let result = self.execute_command(cmd).await?;
3475                self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
3476                stdout.push_str(&result.stdout);
3477                stderr.push_str(&result.stderr);
3478                exit_code = result.exit_code;
3479                self.last_exit_code = exit_code;
3480                control_flow = result.control_flow;
3481
3482                // If command signaled control flow, return immediately
3483                if control_flow != ControlFlow::None {
3484                    return Ok(ExecResult {
3485                        stdout,
3486                        stderr,
3487                        exit_code,
3488                        control_flow,
3489                    });
3490                }
3491
3492                // ERR trap: fire on non-zero exit after semicolon commands (not &&/||)
3493                if exit_code != 0 && !current_is_conditional {
3494                    self.run_err_trap(&mut stdout, &mut stderr).await;
3495                }
3496            }
3497        }
3498
3499        // Final errexit check for the last command
3500        // Don't check if:
3501        // - The list had conditional operators (failures are "handled" by && / ||)
3502        // - OR we're in/just exited a conditional chain
3503        let should_final_errexit_check =
3504            !has_conditional_operators && self.is_errexit_enabled() && exit_code != 0;
3505
3506        if should_final_errexit_check {
3507            return Ok(ExecResult {
3508                stdout,
3509                stderr,
3510                exit_code,
3511                control_flow: ControlFlow::None,
3512            });
3513        }
3514
3515        Ok(ExecResult {
3516            stdout,
3517            stderr,
3518            exit_code,
3519            control_flow: ControlFlow::None,
3520        })
3521    }
3522
3523    async fn execute_simple_command(
3524        &mut self,
3525        command: &SimpleCommand,
3526        stdin: Option<String>,
3527    ) -> Result<ExecResult> {
3528        // Save old variable values before applying prefix assignments.
3529        // If there's a command, these assignments are temporary (bash behavior:
3530        // `VAR=value cmd` sets VAR only for cmd's duration).
3531        let var_saves: Vec<(String, Option<String>)> = command
3532            .assignments
3533            .iter()
3534            .map(|a| (a.name.clone(), self.variables.get(&a.name).cloned()))
3535            .collect();
3536
3537        // Process variable assignments first
3538        for assignment in &command.assignments {
3539            match &assignment.value {
3540                AssignmentValue::Scalar(word) => {
3541                    let value = self.expand_word(word).await?;
3542                    if let Some(index_str) = &assignment.index {
3543                        // Resolve nameref for array name
3544                        let resolved_name = self.resolve_nameref(&assignment.name).to_string();
3545                        if self.assoc_arrays.contains_key(&resolved_name) {
3546                            // Associative array: use string key
3547                            let key = self.expand_variable_or_literal(index_str);
3548                            let arr = self.assoc_arrays.entry(resolved_name).or_default();
3549                            if assignment.append {
3550                                let existing = arr.get(&key).cloned().unwrap_or_default();
3551                                arr.insert(key, existing + &value);
3552                            } else {
3553                                arr.insert(key, value);
3554                            }
3555                        } else {
3556                            // Indexed array: use numeric index (supports negative)
3557                            let raw_idx = self.evaluate_arithmetic(index_str);
3558                            let index = if raw_idx < 0 {
3559                                let len = self
3560                                    .arrays
3561                                    .get(&resolved_name)
3562                                    .and_then(|a| a.keys().max().map(|m| m + 1))
3563                                    .unwrap_or(0) as i64;
3564                                (len + raw_idx).max(0) as usize
3565                            } else {
3566                                raw_idx as usize
3567                            };
3568                            let arr = self.arrays.entry(resolved_name).or_default();
3569                            if assignment.append {
3570                                let existing = arr.get(&index).cloned().unwrap_or_default();
3571                                arr.insert(index, existing + &value);
3572                            } else {
3573                                arr.insert(index, value);
3574                            }
3575                        }
3576                    } else if assignment.append {
3577                        // VAR+=value - append to variable
3578                        let existing = self.expand_variable(&assignment.name);
3579                        self.set_variable(assignment.name.clone(), existing + &value);
3580                    } else {
3581                        self.set_variable(assignment.name.clone(), value);
3582                    }
3583                }
3584                AssignmentValue::Array(words) => {
3585                    // arr=(a b c) - set whole array
3586                    // arr+=(d e f) - append to array
3587                    // Handle word splitting for command substitution like arr=($(echo a b c))
3588
3589                    // First, expand all words (need to do this before borrowing arrays)
3590                    let mut expanded_values = Vec::new();
3591                    for word in words.iter() {
3592                        let has_command_subst = word
3593                            .parts
3594                            .iter()
3595                            .any(|p| matches!(p, WordPart::CommandSubstitution(_)));
3596                        let value = self.expand_word(word).await?;
3597                        expanded_values.push((value, has_command_subst));
3598                    }
3599
3600                    // Now handle the array assignment
3601                    let arr = self.arrays.entry(assignment.name.clone()).or_default();
3602
3603                    // Find starting index (max existing index + 1 for append, 0 for replace)
3604                    let mut idx = if assignment.append {
3605                        arr.keys().max().map(|k| k + 1).unwrap_or(0)
3606                    } else {
3607                        arr.clear();
3608                        0
3609                    };
3610
3611                    for (value, has_command_subst) in expanded_values {
3612                        if has_command_subst && !value.is_empty() {
3613                            // Word-split command substitution results
3614                            for part in value.split_whitespace() {
3615                                arr.insert(idx, part.to_string());
3616                                idx += 1;
3617                            }
3618                        } else if !value.is_empty() || !has_command_subst {
3619                            arr.insert(idx, value);
3620                            idx += 1;
3621                        }
3622                    }
3623                }
3624            }
3625        }
3626
3627        let name = self.expand_word(&command.name).await?;
3628
3629        // Check for nounset error from variable expansion
3630        if let Some(err_msg) = self.nounset_error.take() {
3631            // Restore variable saves since we're aborting
3632            for (name, old) in var_saves.into_iter().rev() {
3633                match old {
3634                    Some(v) => {
3635                        self.variables.insert(name, v);
3636                    }
3637                    None => {
3638                        self.variables.remove(&name);
3639                    }
3640                }
3641            }
3642            self.last_exit_code = 1;
3643            return Ok(ExecResult {
3644                stdout: String::new(),
3645                stderr: err_msg,
3646                exit_code: 1,
3647                control_flow: ControlFlow::Return(1),
3648            });
3649        }
3650
3651        // Alias expansion: only for plain literal unquoted command names.
3652        // Words from variable expansion ($cmd), command substitution, etc. are not
3653        // alias-expanded (bash behavior). Also skip if currently expanding this alias
3654        // to prevent infinite recursion (e.g., `alias echo='echo foo'`).
3655        let is_plain_literal = !command.name.quoted
3656            && command
3657                .name
3658                .parts
3659                .iter()
3660                .all(|p| matches!(p, WordPart::Literal(_)));
3661        if is_plain_literal
3662            && self.is_expand_aliases_enabled()
3663            && !self.expanding_aliases.contains(&name)
3664        {
3665            if let Some(expansion) = self.aliases.get(&name).cloned() {
3666                // Restore variable saves before re-executing (alias expansion
3667                // replays the full command including assignments)
3668                for (vname, old) in var_saves.into_iter().rev() {
3669                    match old {
3670                        Some(v) => {
3671                            self.variables.insert(vname, v);
3672                        }
3673                        None => {
3674                            self.variables.remove(&vname);
3675                        }
3676                    }
3677                }
3678
3679                // Build expanded command: alias value + original args.
3680                // If alias value ends with space, also expand the first arg
3681                // as an alias (bash trailing-space alias chaining).
3682                let mut expanded_cmd = expansion.clone();
3683                let trailing_space = expanded_cmd.ends_with(' ');
3684                let mut args_iter = command.args.iter();
3685                if trailing_space {
3686                    if let Some(first_arg) = args_iter.next() {
3687                        let arg_str = format!("{}", first_arg);
3688                        if let Some(arg_expansion) = self.aliases.get(&arg_str).cloned() {
3689                            expanded_cmd.push_str(&arg_expansion);
3690                        } else {
3691                            expanded_cmd.push_str(&arg_str);
3692                        }
3693                    }
3694                }
3695                for word in args_iter {
3696                    expanded_cmd.push(' ');
3697                    expanded_cmd.push_str(&format!("{}", word));
3698                }
3699                // Append original redirections as text
3700                for redir in &command.redirects {
3701                    expanded_cmd.push(' ');
3702                    expanded_cmd.push_str(&Self::format_redirect(redir));
3703                }
3704
3705                // Mark this alias as being expanded to prevent recursion
3706                self.expanding_aliases.insert(name.clone());
3707
3708                // Forward pipeline stdin so aliases work in pipelines
3709                let prev_pipeline_stdin = self.pipeline_stdin.take();
3710                if stdin.is_some() {
3711                    self.pipeline_stdin = stdin;
3712                }
3713
3714                // THREAT[TM-DOS-030]: Propagate interpreter parser limits
3715                let parser = Parser::with_limits(
3716                    &expanded_cmd,
3717                    self.limits.max_ast_depth,
3718                    self.limits.max_parser_operations,
3719                );
3720                let result = match parser.parse() {
3721                    Ok(s) => self.execute(&s).await,
3722                    Err(e) => Ok(ExecResult::err(
3723                        format!("bash: alias expansion: parse error: {}\n", e),
3724                        1,
3725                    )),
3726                };
3727
3728                self.pipeline_stdin = prev_pipeline_stdin;
3729                self.expanding_aliases.remove(&name);
3730                return result;
3731            }
3732        }
3733
3734        // If name is empty after expansion, behavior depends on context:
3735        // - Quoted empty string ('', "", "$empty") -> "command not found" (exit 127)
3736        // - Unquoted expansion that vanished ($empty, $(true)) -> no-op, preserve $?
3737        // - Assignment-only (VAR=val) -> no-op, preserve $?
3738        if name.is_empty() {
3739            if command.name.quoted && command.assignments.is_empty() {
3740                // Bash: '' as a command is "command not found"
3741                self.last_exit_code = 127;
3742                return Ok(ExecResult::err(
3743                    "bash: : command not found\n".to_string(),
3744                    127,
3745                ));
3746            }
3747            return Ok(ExecResult {
3748                stdout: String::new(),
3749                stderr: String::new(),
3750                exit_code: self.last_exit_code,
3751                control_flow: crate::interpreter::ControlFlow::None,
3752            });
3753        }
3754
3755        // Has a command: prefix assignments are temporary (bash behavior).
3756        // Inject scalar prefix assignments into self.env so builtins/functions
3757        // can see them via ctx.env (e.g., `MYVAR=hello printenv MYVAR`).
3758        let mut env_saves: Vec<(String, Option<String>)> = Vec::new();
3759        for assignment in &command.assignments {
3760            if assignment.index.is_none() {
3761                if let Some(value) = self.variables.get(&assignment.name).cloned() {
3762                    let old = self.env.insert(assignment.name.clone(), value);
3763                    env_saves.push((assignment.name.clone(), old));
3764                }
3765            }
3766        }
3767
3768        // Emit xtrace (set -x): build trace line for stderr
3769        let xtrace_line = if self.is_xtrace_enabled() {
3770            let ps4 = self
3771                .variables
3772                .get("PS4")
3773                .cloned()
3774                .unwrap_or_else(|| "+ ".to_string());
3775            let mut trace = ps4;
3776            trace.push_str(&name);
3777            for word in &command.args {
3778                let expanded = self.expand_word(word).await.unwrap_or_default();
3779                trace.push(' ');
3780                if expanded.contains(' ') || expanded.contains('\t') || expanded.is_empty() {
3781                    trace.push('\'');
3782                    trace.push_str(&expanded.replace('\'', "'\\''"));
3783                    trace.push('\'');
3784                } else {
3785                    trace.push_str(&expanded);
3786                }
3787            }
3788            trace.push('\n');
3789            Some(trace)
3790        } else {
3791            None
3792        };
3793
3794        // Dispatch to the appropriate handler
3795        let result = self.execute_dispatched_command(&name, command, stdin).await;
3796
3797        // Restore env (prefix assignments are command-scoped)
3798        for (name, old) in env_saves {
3799            match old {
3800                Some(v) => {
3801                    self.env.insert(name, v);
3802                }
3803                None => {
3804                    self.env.remove(&name);
3805                }
3806            }
3807        }
3808
3809        // Restore variables (prefix assignments don't persist when there's a command)
3810        for (name, old) in var_saves {
3811            match old {
3812                Some(v) => {
3813                    self.variables.insert(name, v);
3814                }
3815                None => {
3816                    self.variables.remove(&name);
3817                }
3818            }
3819        }
3820
3821        // Prepend xtrace to stderr (like real bash, xtrace goes to the
3822        // shell's stderr, unaffected by per-command redirections like 2>&1).
3823        if let Some(trace) = xtrace_line {
3824            result.map(|mut r| {
3825                r.stderr = trace + &r.stderr;
3826                r
3827            })
3828        } else {
3829            result
3830        }
3831    }
3832
3833    /// Execute a command after name resolution and prefix assignment setup.
3834    ///
3835    /// Handles argument expansion, stdin processing, and dispatch to
3836    /// functions, special builtins, regular builtins, or command-not-found.
3837    async fn execute_dispatched_command(
3838        &mut self,
3839        name: &str,
3840        command: &SimpleCommand,
3841        stdin: Option<String>,
3842    ) -> Result<ExecResult> {
3843        // Expand arguments with brace and glob expansion
3844        let mut args: Vec<String> = Vec::new();
3845        for word in &command.args {
3846            // Use field expansion so "${arr[@]}" produces multiple args
3847            let fields = self.expand_word_to_fields(word).await?;
3848
3849            // Skip brace and glob expansion for quoted words
3850            if word.quoted {
3851                args.extend(fields);
3852                continue;
3853            }
3854
3855            // For each field, apply brace and glob expansion
3856            for expanded in fields {
3857                // Step 1: Brace expansion (produces multiple strings)
3858                let brace_expanded = self.expand_braces(&expanded);
3859
3860                // Step 2: For each brace-expanded item, do glob expansion
3861                for item in brace_expanded {
3862                    match self.expand_glob_item(&item).await {
3863                        Ok(items) => args.extend(items),
3864                        Err(pat) => {
3865                            self.last_exit_code = 1;
3866                            return Ok(ExecResult::err(format!("-bash: no match: {}\n", pat), 1));
3867                        }
3868                    }
3869                }
3870            }
3871        }
3872
3873        // Check for nounset error from argument expansion
3874        if let Some(err_msg) = self.nounset_error.take() {
3875            self.last_exit_code = 1;
3876            return Ok(ExecResult {
3877                stdout: String::new(),
3878                stderr: err_msg,
3879                exit_code: 1,
3880                control_flow: ControlFlow::Return(1),
3881            });
3882        }
3883
3884        // Handle input redirections first
3885        let stdin = self
3886            .process_input_redirections(stdin, &command.redirects)
3887            .await?;
3888
3889        // If no explicit stdin, inherit from pipeline_stdin (for compound cmds in pipes).
3890        // For `read`, consume one line; for other commands, provide all remaining data.
3891        let stdin = if stdin.is_some() {
3892            stdin
3893        } else if let Some(ref ps) = self.pipeline_stdin {
3894            if !ps.is_empty() {
3895                if name == "read" {
3896                    // Consume one line from pipeline stdin
3897                    let data = ps.clone();
3898                    if let Some(newline_pos) = data.find('\n') {
3899                        let line = data[..=newline_pos].to_string();
3900                        self.pipeline_stdin = Some(data[newline_pos + 1..].to_string());
3901                        Some(line)
3902                    } else {
3903                        // Last line without trailing newline
3904                        self.pipeline_stdin = Some(String::new());
3905                        Some(data)
3906                    }
3907                } else {
3908                    Some(ps.clone())
3909                }
3910            } else {
3911                None
3912            }
3913        } else {
3914            None
3915        };
3916
3917        // Check for functions first
3918        if let Some(func_def) = self.functions.get(name).cloned() {
3919            return self
3920                .execute_function_call(name, &func_def, args, stdin, &command.redirects)
3921                .await;
3922        }
3923
3924        // Handle `local` specially - must set in call frame locals
3925        if name == "local" {
3926            return self.execute_local_builtin(&args, &command.redirects).await;
3927        }
3928
3929        // Handle `timeout` specially - needs interpreter-level command execution
3930        if name == "timeout" {
3931            return self.execute_timeout(&args, stdin, &command.redirects).await;
3932        }
3933
3934        // Handle `xargs` specially - needs interpreter-level command execution
3935        if name == "xargs" {
3936            return self.execute_xargs(&args, stdin, &command.redirects).await;
3937        }
3938
3939        // Handle `find -exec` specially - needs interpreter-level command execution
3940        if name == "find" && args.iter().any(|a| a == "-exec" || a == "-execdir") {
3941            return self.execute_find(&args, &command.redirects).await;
3942        }
3943
3944        // Handle `bash` and `sh` specially - execute scripts using the interpreter
3945        if name == "bash" || name == "sh" {
3946            return self
3947                .execute_shell(name, &args, stdin, &command.redirects)
3948                .await;
3949        }
3950
3951        // Handle source/eval at interpreter level - they need to execute
3952        // parsed scripts in the current shell context (functions, variables, etc.)
3953        if name == "source" || name == "." {
3954            return self.execute_source(&args, &command.redirects).await;
3955        }
3956
3957        if name == "eval" {
3958            return self.execute_eval(&args, stdin, &command.redirects).await;
3959        }
3960
3961        // Handle `command` builtin - needs interpreter-level access to builtins/functions
3962        if name == "command" {
3963            return self
3964                .execute_command_builtin(&args, stdin, &command.redirects)
3965                .await;
3966        }
3967
3968        // Handle `type`/`which`/`hash` builtins - need interpreter-level access
3969        if name == "type" {
3970            return self.execute_type_builtin(&args, &command.redirects).await;
3971        }
3972        if name == "which" {
3973            return self.execute_which_builtin(&args, &command.redirects).await;
3974        }
3975        if name == "hash" {
3976            // hash is a no-op in sandboxed env (no real PATH search cache)
3977            let mut result = ExecResult::ok(String::new());
3978            result = self.apply_redirections(result, &command.redirects).await?;
3979            return Ok(result);
3980        }
3981
3982        // Handle `trap` - register signal/event handlers
3983        if name == "trap" {
3984            return self.execute_trap_builtin(&args, &command.redirects).await;
3985        }
3986
3987        // Handle `declare`/`typeset` - needs interpreter-level access to arrays
3988        if name == "declare" || name == "typeset" {
3989            return self
3990                .execute_declare_builtin(&args, &command.redirects)
3991                .await;
3992        }
3993
3994        // Handle `let` - evaluate arithmetic expressions with assignment
3995        if name == "let" {
3996            return self.execute_let_builtin(&args, &command.redirects).await;
3997        }
3998
3999        // Handle `unset` with array element syntax and nameref support
4000        if name == "unset" {
4001            return self.execute_unset_builtin(&args, &command.redirects).await;
4002        }
4003
4004        // Handle `getopts` builtin - needs to read/write shell variables (OPTIND, OPTARG)
4005        if name == "getopts" {
4006            return self.execute_getopts(&args, &command.redirects).await;
4007        }
4008
4009        // Handle `caller` - needs direct access to call stack
4010        if name == "caller" {
4011            return self.execute_caller_builtin(&args, &command.redirects).await;
4012        }
4013
4014        // Handle `mapfile`/`readarray` - needs direct access to arrays
4015        if name == "mapfile" || name == "readarray" {
4016            return self.execute_mapfile(&args, stdin.as_deref()).await;
4017        }
4018
4019        // Handle `alias` builtin - needs direct access to self.aliases
4020        if name == "alias" {
4021            return self.execute_alias_builtin(&args, &command.redirects).await;
4022        }
4023
4024        // Handle `unalias` builtin - needs direct access to self.aliases
4025        if name == "unalias" {
4026            return self
4027                .execute_unalias_builtin(&args, &command.redirects)
4028                .await;
4029        }
4030
4031        // Check for builtins
4032        if let Some(builtin) = self.builtins.get(name) {
4033            let ctx = builtins::Context {
4034                args: &args,
4035                env: &self.env,
4036                variables: &mut self.variables,
4037                cwd: &mut self.cwd,
4038                fs: Arc::clone(&self.fs),
4039                stdin: stdin.as_deref(),
4040                #[cfg(feature = "http_client")]
4041                http_client: self.http_client.as_ref(),
4042                #[cfg(feature = "git")]
4043                git_client: self.git_client.as_ref(),
4044            };
4045
4046            // Execute builtin with panic catching for security
4047            // THREAT[TM-INT-001]: Builtins may panic on unexpected input
4048            // SECURITY: All builtins (built-in and custom) may panic - we catch this to:
4049            // 1. Prevent interpreter crash
4050            // 2. Avoid leaking panic message (may contain sensitive info)
4051            // 3. Return sanitized error to user
4052            let result = AssertUnwindSafe(builtin.execute(ctx)).catch_unwind().await;
4053
4054            let result = match result {
4055                Ok(Ok(exec_result)) => exec_result,
4056                Ok(Err(e)) => return Err(e),
4057                Err(_panic) => {
4058                    // Panic caught! Return sanitized error message.
4059                    // SECURITY: Do NOT include panic message - it may contain:
4060                    // - Stack traces with internal paths
4061                    // - Memory addresses
4062                    // - Secret values from variables
4063                    ExecResult::err(format!("bash: {}: builtin failed unexpectedly\n", name), 1)
4064                }
4065            };
4066
4067            // Post-process: read -a populates array from marker variable
4068            let markers: Vec<(String, String)> = self
4069                .variables
4070                .iter()
4071                .filter(|(k, _)| k.starts_with("_ARRAY_READ_"))
4072                .map(|(k, v)| (k.clone(), v.clone()))
4073                .collect();
4074            for (marker, value) in markers {
4075                let arr_name = marker.strip_prefix("_ARRAY_READ_").unwrap();
4076                let mut arr = HashMap::new();
4077                for (i, word) in value.split('\x1F').enumerate() {
4078                    if !word.is_empty() {
4079                        arr.insert(i, word.to_string());
4080                    }
4081                }
4082                self.arrays.insert(arr_name.to_string(), arr);
4083                self.variables.remove(&marker);
4084            }
4085
4086            // Post-process: shift builtin updates positional parameters
4087            if let Some(shift_str) = self.variables.remove("_SHIFT_COUNT") {
4088                let n: usize = shift_str.parse().unwrap_or(1);
4089                if let Some(frame) = self.call_stack.last_mut() {
4090                    if n <= frame.positional.len() {
4091                        frame.positional.drain(..n);
4092                    } else {
4093                        frame.positional.clear();
4094                    }
4095                }
4096            }
4097
4098            // Post-process: `set --` replaces positional parameters
4099            // Encoded as count\x1Farg1\x1Farg2... to preserve empty args.
4100            if let Some(encoded) = self.variables.remove("_SET_POSITIONAL") {
4101                let parts: Vec<&str> = encoded.splitn(2, '\x1F').collect();
4102                let count: usize = parts[0].parse().unwrap_or(0);
4103                let new_positional: Vec<String> = if count == 0 {
4104                    Vec::new()
4105                } else if parts.len() > 1 {
4106                    parts[1].split('\x1F').map(|s| s.to_string()).collect()
4107                } else {
4108                    Vec::new()
4109                };
4110                if let Some(frame) = self.call_stack.last_mut() {
4111                    frame.positional = new_positional;
4112                } else {
4113                    self.call_stack.push(CallFrame {
4114                        name: String::new(),
4115                        locals: HashMap::new(),
4116                        positional: new_positional,
4117                    });
4118                }
4119            }
4120
4121            // Handle output redirections
4122            return self.apply_redirections(result, &command.redirects).await;
4123        }
4124
4125        // Check if command is a path to an executable script in the VFS
4126        if name.contains('/') {
4127            let result = self
4128                .try_execute_script_by_path(name, &args, &command.redirects)
4129                .await?;
4130            return Ok(result);
4131        }
4132
4133        // No slash in name: search $PATH for executable script
4134        if let Some(result) = self
4135            .try_execute_script_via_path_search(name, &args, &command.redirects)
4136            .await?
4137        {
4138            return Ok(result);
4139        }
4140
4141        // Command not found - build error with suggestions for LLM self-correction
4142        let known: Vec<&str> = self
4143            .builtins
4144            .keys()
4145            .map(|s| s.as_str())
4146            .chain(self.functions.keys().map(|s| s.as_str()))
4147            .chain(self.aliases.keys().map(|s| s.as_str()))
4148            .collect();
4149        let msg = command_not_found_message(name, &known);
4150        Ok(ExecResult::err(msg, 127))
4151    }
4152
4153    /// Execute a script file by resolved path.
4154    ///
4155    /// Bash behavior for path-based commands (name contains `/`):
4156    /// 1. Resolve path (absolute or relative to cwd)
4157    /// 2. stat() — if not found: "No such file or directory" (exit 127)
4158    /// 3. If directory: "Is a directory" (exit 126)
4159    /// 4. If not executable (mode & 0o111 == 0): "Permission denied" (exit 126)
4160    /// 5. Read file, strip shebang, parse, execute in call frame
4161    async fn try_execute_script_by_path(
4162        &mut self,
4163        name: &str,
4164        args: &[String],
4165        redirects: &[Redirect],
4166    ) -> Result<ExecResult> {
4167        let path = self.resolve_path(name);
4168
4169        // stat the file
4170        let meta = match self.fs.stat(&path).await {
4171            Ok(m) => m,
4172            Err(_) => {
4173                return Ok(ExecResult::err(
4174                    format!("bash: {}: No such file or directory", name),
4175                    127,
4176                ));
4177            }
4178        };
4179
4180        // Directory check
4181        if meta.file_type.is_dir() {
4182            return Ok(ExecResult::err(
4183                format!("bash: {}: Is a directory", name),
4184                126,
4185            ));
4186        }
4187
4188        // Execute permission check
4189        if meta.mode & 0o111 == 0 {
4190            return Ok(ExecResult::err(
4191                format!("bash: {}: Permission denied", name),
4192                126,
4193            ));
4194        }
4195
4196        // Read file content
4197        let content = match self.fs.read_file(&path).await {
4198            Ok(c) => String::from_utf8_lossy(&c).to_string(),
4199            Err(_) => {
4200                return Ok(ExecResult::err(
4201                    format!("bash: {}: No such file or directory", name),
4202                    127,
4203                ));
4204            }
4205        };
4206
4207        self.execute_script_content(name, &content, args, redirects)
4208            .await
4209    }
4210
4211    /// Search $PATH for an executable script and run it.
4212    ///
4213    /// Returns `Ok(None)` if no matching file found (caller emits "command not found").
4214    async fn try_execute_script_via_path_search(
4215        &mut self,
4216        name: &str,
4217        args: &[String],
4218        redirects: &[Redirect],
4219    ) -> Result<Option<ExecResult>> {
4220        let path_var = self
4221            .variables
4222            .get("PATH")
4223            .or_else(|| self.env.get("PATH"))
4224            .cloned()
4225            .unwrap_or_default();
4226
4227        for dir in path_var.split(':') {
4228            if dir.is_empty() {
4229                continue;
4230            }
4231            let candidate = PathBuf::from(dir).join(name);
4232            if let Ok(meta) = self.fs.stat(&candidate).await {
4233                if meta.file_type.is_dir() {
4234                    continue;
4235                }
4236                if meta.mode & 0o111 == 0 {
4237                    continue;
4238                }
4239                if let Ok(content) = self.fs.read_file(&candidate).await {
4240                    let script_text = String::from_utf8_lossy(&content).to_string();
4241                    let result = self
4242                        .execute_script_content(name, &script_text, args, redirects)
4243                        .await?;
4244                    return Ok(Some(result));
4245                }
4246            }
4247        }
4248
4249        Ok(None)
4250    }
4251
4252    /// Parse and execute script content in a new call frame.
4253    ///
4254    /// Shared by path-based and $PATH-based script execution.
4255    /// Sets up $0 = script name, $1..N = args, strips shebang.
4256    async fn execute_script_content(
4257        &mut self,
4258        name: &str,
4259        content: &str,
4260        args: &[String],
4261        redirects: &[Redirect],
4262    ) -> Result<ExecResult> {
4263        // Strip shebang line if present
4264        let script_text = if content.starts_with("#!") {
4265            content
4266                .find('\n')
4267                .map(|pos| &content[pos + 1..])
4268                .unwrap_or("")
4269        } else {
4270            content
4271        };
4272
4273        let parser = Parser::with_limits(
4274            script_text,
4275            self.limits.max_ast_depth,
4276            self.limits.max_parser_operations,
4277        );
4278        let script = match parser.parse() {
4279            Ok(s) => s,
4280            Err(e) => {
4281                return Ok(ExecResult::err(format!("bash: {}: {}\n", name, e), 2));
4282            }
4283        };
4284
4285        // Push call frame: $0 = script name, $1..N = args
4286        self.call_stack.push(CallFrame {
4287            name: name.to_string(),
4288            locals: HashMap::new(),
4289            positional: args.to_vec(),
4290        });
4291
4292        let result = self.execute(&script).await;
4293
4294        // Pop call frame
4295        self.call_stack.pop();
4296
4297        match result {
4298            Ok(mut exec_result) => {
4299                // Handle return - convert Return control flow to exit code
4300                if let ControlFlow::Return(code) = exec_result.control_flow {
4301                    exec_result.exit_code = code;
4302                    exec_result.control_flow = ControlFlow::None;
4303                }
4304                self.apply_redirections(exec_result, redirects).await
4305            }
4306            Err(e) => Err(e),
4307        }
4308    }
4309
4310    /// Execute `source` / `.` - read and execute commands from a file in current shell.
4311    ///
4312    /// Bash behavior:
4313    /// - If filename contains a slash, use it directly (absolute or relative to cwd)
4314    /// - If filename has no slash, search $PATH directories
4315    /// - Extra arguments become positional parameters ($1, $2, ...) during sourcing
4316    /// - Original positional parameters are restored after sourcing completes
4317    async fn execute_source(
4318        &mut self,
4319        args: &[String],
4320        redirects: &[Redirect],
4321    ) -> Result<ExecResult> {
4322        let filename = match args.first() {
4323            Some(f) => f,
4324            None => {
4325                return Ok(ExecResult::err("source: filename argument required", 1));
4326            }
4327        };
4328
4329        // Resolve the file path:
4330        // - If filename contains '/', resolve relative to cwd
4331        // - Otherwise, search $PATH directories (bash behavior)
4332        let content = if filename.contains('/') {
4333            let path = self.resolve_path(filename);
4334            match self.fs.read_file(&path).await {
4335                Ok(c) => String::from_utf8_lossy(&c).to_string(),
4336                Err(_) => {
4337                    return Ok(ExecResult::err(
4338                        format!("source: {}: No such file or directory", filename),
4339                        1,
4340                    ));
4341                }
4342            }
4343        } else {
4344            // Search PATH for the file
4345            let mut found = None;
4346            let path_var = self
4347                .variables
4348                .get("PATH")
4349                .or_else(|| self.env.get("PATH"))
4350                .cloned()
4351                .unwrap_or_default();
4352            for dir in path_var.split(':') {
4353                if dir.is_empty() {
4354                    continue;
4355                }
4356                let candidate = PathBuf::from(dir).join(filename);
4357                if let Ok(c) = self.fs.read_file(&candidate).await {
4358                    found = Some(String::from_utf8_lossy(&c).to_string());
4359                    break;
4360                }
4361            }
4362            // Also try cwd as fallback (bash sources from cwd too)
4363            if found.is_none() {
4364                let path = self.resolve_path(filename);
4365                if let Ok(c) = self.fs.read_file(&path).await {
4366                    found = Some(String::from_utf8_lossy(&c).to_string());
4367                }
4368            }
4369            match found {
4370                Some(c) => c,
4371                None => {
4372                    return Ok(ExecResult::err(
4373                        format!("source: {}: No such file or directory", filename),
4374                        1,
4375                    ));
4376                }
4377            }
4378        };
4379
4380        // THREAT[TM-DOS-030]: Propagate interpreter parser limits
4381        let parser = Parser::with_limits(
4382            &content,
4383            self.limits.max_ast_depth,
4384            self.limits.max_parser_operations,
4385        );
4386        let script = match parser.parse() {
4387            Ok(s) => s,
4388            Err(e) => {
4389                return Ok(ExecResult::err(
4390                    format!("source: {}: parse error: {}", filename, e),
4391                    1,
4392                ));
4393            }
4394        };
4395
4396        // Set positional parameters if extra arguments provided.
4397        // Save and restore the caller's positional params.
4398        let source_args: Vec<String> = args[1..].to_vec();
4399        let has_source_args = !source_args.is_empty();
4400
4401        let saved_positional = if has_source_args {
4402            let saved = self.call_stack.last().map(|frame| frame.positional.clone());
4403            // Push a temporary call frame for positional params
4404            if self.call_stack.is_empty() {
4405                self.call_stack.push(CallFrame {
4406                    name: filename.clone(),
4407                    locals: HashMap::new(),
4408                    positional: source_args,
4409                });
4410            } else if let Some(frame) = self.call_stack.last_mut() {
4411                frame.positional = source_args;
4412            }
4413            saved
4414        } else {
4415            None
4416        };
4417
4418        // Execute the script commands in the current shell context
4419        let mut result = self.execute(&script).await?;
4420
4421        // Restore positional parameters
4422        if has_source_args {
4423            if let Some(saved) = saved_positional {
4424                if let Some(frame) = self.call_stack.last_mut() {
4425                    frame.positional = saved;
4426                }
4427            } else {
4428                // We pushed a frame; pop it
4429                self.call_stack.pop();
4430            }
4431        }
4432
4433        // Apply redirections
4434        result = self.apply_redirections(result, redirects).await?;
4435        Ok(result)
4436    }
4437
4438    /// Execute `eval` - parse and execute concatenated arguments
4439    async fn execute_eval(
4440        &mut self,
4441        args: &[String],
4442        stdin: Option<String>,
4443        redirects: &[Redirect],
4444    ) -> Result<ExecResult> {
4445        if args.is_empty() {
4446            return Ok(ExecResult::ok(String::new()));
4447        }
4448
4449        let cmd = args.join(" ");
4450        // THREAT[TM-DOS-030]: Propagate interpreter parser limits
4451        let parser = Parser::with_limits(
4452            &cmd,
4453            self.limits.max_ast_depth,
4454            self.limits.max_parser_operations,
4455        );
4456        let script = match parser.parse() {
4457            Ok(s) => s,
4458            Err(e) => {
4459                return Ok(ExecResult::err(format!("eval: parse error: {}", e), 1));
4460            }
4461        };
4462
4463        // Set up pipeline stdin if provided
4464        let prev_pipeline_stdin = self.pipeline_stdin.take();
4465        if stdin.is_some() {
4466            self.pipeline_stdin = stdin;
4467        }
4468
4469        let mut result = self.execute(&script).await?;
4470
4471        self.pipeline_stdin = prev_pipeline_stdin;
4472
4473        result = self.apply_redirections(result, redirects).await?;
4474        Ok(result)
4475    }
4476
4477    /// Check if expand_aliases is enabled via shopt.
4478    fn is_expand_aliases_enabled(&self) -> bool {
4479        self.variables
4480            .get("SHOPT_expand_aliases")
4481            .map(|v| v == "1")
4482            .unwrap_or(false)
4483    }
4484
4485    /// Format a Redirect back to its textual representation for alias expansion.
4486    fn format_redirect(redir: &Redirect) -> String {
4487        let fd_prefix = redir.fd.map(|fd| fd.to_string()).unwrap_or_default();
4488        let op = match redir.kind {
4489            RedirectKind::Output => ">",
4490            RedirectKind::Append => ">>",
4491            RedirectKind::Input => "<",
4492            RedirectKind::HereDoc => "<<",
4493            RedirectKind::HereDocStrip => "<<-",
4494            RedirectKind::HereString => "<<<",
4495            RedirectKind::DupOutput => ">&",
4496            RedirectKind::DupInput => "<&",
4497            RedirectKind::OutputBoth => "&>",
4498        };
4499        format!("{}{}{}", fd_prefix, op, redir.target)
4500    }
4501
4502    /// Execute a shell function call with call frame management.
4503    async fn execute_function_call(
4504        &mut self,
4505        name: &str,
4506        func_def: &FunctionDef,
4507        args: Vec<String>,
4508        stdin: Option<String>,
4509        redirects: &[Redirect],
4510    ) -> Result<ExecResult> {
4511        // Check function depth limit
4512        self.counters.push_function(&self.limits)?;
4513
4514        // Push call frame with positional parameters
4515        self.call_stack.push(CallFrame {
4516            name: name.to_string(),
4517            locals: HashMap::new(),
4518            positional: args,
4519        });
4520
4521        // Set FUNCNAME array from call stack (index 0 = current, 1 = caller, ...)
4522        let funcname_arr: HashMap<usize, String> = self
4523            .call_stack
4524            .iter()
4525            .rev()
4526            .enumerate()
4527            .map(|(i, f)| (i, f.name.clone()))
4528            .collect();
4529        let prev_funcname = self.arrays.insert("FUNCNAME".to_string(), funcname_arr);
4530
4531        // Forward pipeline stdin to function body
4532        let prev_pipeline_stdin = self.pipeline_stdin.take();
4533        self.pipeline_stdin = stdin;
4534
4535        // Execute function body
4536        let mut result = self.execute_command(&func_def.body).await?;
4537
4538        // Restore previous pipeline stdin
4539        self.pipeline_stdin = prev_pipeline_stdin;
4540
4541        // Pop call frame and function counter
4542        self.call_stack.pop();
4543        self.counters.pop_function();
4544
4545        // Restore previous FUNCNAME (or set from remaining stack)
4546        if self.call_stack.is_empty() {
4547            self.arrays.remove("FUNCNAME");
4548        } else if let Some(prev) = prev_funcname {
4549            self.arrays.insert("FUNCNAME".to_string(), prev);
4550        }
4551
4552        // Handle return - convert Return control flow to exit code
4553        if let ControlFlow::Return(code) = result.control_flow {
4554            result.exit_code = code;
4555            result.control_flow = ControlFlow::None;
4556        }
4557
4558        self.apply_redirections(result, redirects).await
4559    }
4560
4561    /// Execute the `local` builtin — set variables in function call frame.
4562    async fn execute_local_builtin(
4563        &mut self,
4564        args: &[String],
4565        redirects: &[Redirect],
4566    ) -> Result<ExecResult> {
4567        // Parse flags: -n for nameref
4568        let mut is_nameref = false;
4569        let mut var_args: Vec<&String> = Vec::new();
4570        for arg in args {
4571            if arg.starts_with('-') && !arg.contains('=') {
4572                for c in arg[1..].chars() {
4573                    if c == 'n' {
4574                        is_nameref = true;
4575                    }
4576                }
4577            } else {
4578                var_args.push(arg);
4579            }
4580        }
4581
4582        if let Some(frame) = self.call_stack.last_mut() {
4583            // In a function - set in locals
4584            for arg in &var_args {
4585                if let Some(eq_pos) = arg.find('=') {
4586                    let var_name = &arg[..eq_pos];
4587                    let value = &arg[eq_pos + 1..];
4588                    if !Self::is_valid_var_name(var_name) {
4589                        let result = ExecResult::err(
4590                            format!("local: `{}': not a valid identifier\n", arg),
4591                            1,
4592                        );
4593                        return self.apply_redirections(result, redirects).await;
4594                    }
4595                    // THREAT[TM-INJ-014]: Block internal variable prefix injection via local
4596                    if is_internal_variable(var_name) {
4597                        continue;
4598                    }
4599                    if is_nameref {
4600                        frame.locals.insert(var_name.to_string(), String::new());
4601                    } else {
4602                        frame.locals.insert(var_name.to_string(), value.to_string());
4603                    }
4604                } else if !is_internal_variable(arg) {
4605                    frame.locals.insert(arg.to_string(), String::new());
4606                }
4607            }
4608            // Set nameref markers (after frame borrow is released)
4609            if is_nameref {
4610                for arg in &var_args {
4611                    if let Some(eq_pos) = arg.find('=') {
4612                        let var_name = &arg[..eq_pos];
4613                        let value = &arg[eq_pos + 1..];
4614                        if !is_internal_variable(var_name) {
4615                            self.variables
4616                                .insert(format!("_NAMEREF_{}", var_name), value.to_string());
4617                        }
4618                    }
4619                }
4620            }
4621        } else {
4622            // Not in a function - set in global variables (bash behavior)
4623            for arg in &var_args {
4624                if let Some(eq_pos) = arg.find('=') {
4625                    let var_name = &arg[..eq_pos];
4626                    let value = &arg[eq_pos + 1..];
4627                    // THREAT[TM-INJ-014]: Block internal variable prefix injection via local
4628                    if is_internal_variable(var_name) {
4629                        continue;
4630                    }
4631                    if is_nameref {
4632                        self.variables
4633                            .insert(format!("_NAMEREF_{}", var_name), value.to_string());
4634                    } else {
4635                        self.variables
4636                            .insert(var_name.to_string(), value.to_string());
4637                    }
4638                } else if !is_internal_variable(arg) {
4639                    self.variables.insert(arg.to_string(), String::new());
4640                }
4641            }
4642        }
4643        Ok(ExecResult::ok(String::new()))
4644    }
4645
4646    /// Execute the `trap` builtin — register/list signal handlers.
4647    async fn execute_trap_builtin(
4648        &mut self,
4649        args: &[String],
4650        redirects: &[Redirect],
4651    ) -> Result<ExecResult> {
4652        if args.is_empty() {
4653            // List all traps
4654            let mut output = String::new();
4655            let mut sorted: Vec<_> = self.traps.iter().collect();
4656            sorted.sort_by_key(|(sig, _)| (*sig).clone());
4657            for (sig, cmd) in sorted {
4658                output.push_str(&format!("trap -- '{}' {}\n", cmd, sig));
4659            }
4660            let result = ExecResult::ok(output);
4661            return self.apply_redirections(result, redirects).await;
4662        }
4663        // Handle -p flag (print traps)
4664        if args[0] == "-p" {
4665            let mut output = String::new();
4666            if args.len() == 1 {
4667                let mut sorted: Vec<_> = self.traps.iter().collect();
4668                sorted.sort_by_key(|(sig, _)| (*sig).clone());
4669                for (sig, cmd) in sorted {
4670                    output.push_str(&format!("trap -- '{}' {}\n", cmd, sig));
4671                }
4672            } else {
4673                for sig in &args[1..] {
4674                    let sig_upper = sig.to_uppercase();
4675                    if let Some(cmd) = self.traps.get(&sig_upper) {
4676                        output.push_str(&format!("trap -- '{}' {}\n", cmd, sig_upper));
4677                    }
4678                }
4679            }
4680            let result = ExecResult::ok(output);
4681            return self.apply_redirections(result, redirects).await;
4682        }
4683        if args.len() == 1 {
4684            let sig = args[0].to_uppercase();
4685            self.traps.remove(&sig);
4686        } else {
4687            let cmd = args[0].clone();
4688            for sig in &args[1..] {
4689                let sig_upper = sig.to_uppercase();
4690                if cmd == "-" {
4691                    self.traps.remove(&sig_upper);
4692                } else {
4693                    self.traps.insert(sig_upper, cmd.clone());
4694                }
4695            }
4696        }
4697        let result = ExecResult::ok(String::new());
4698        self.apply_redirections(result, redirects).await
4699    }
4700
4701    /// Execute the `let` builtin — evaluate arithmetic expressions.
4702    async fn execute_let_builtin(
4703        &mut self,
4704        args: &[String],
4705        redirects: &[Redirect],
4706    ) -> Result<ExecResult> {
4707        let mut last_val = 0i64;
4708        for arg in args {
4709            last_val = self.evaluate_arithmetic_with_assign(arg);
4710        }
4711        let exit_code = if last_val == 0 { 1 } else { 0 };
4712        let result = ExecResult {
4713            stdout: String::new(),
4714            stderr: String::new(),
4715            exit_code,
4716            control_flow: ControlFlow::None,
4717        };
4718        self.apply_redirections(result, redirects).await
4719    }
4720
4721    /// Execute the `unset` builtin — remove variables, array elements, and namerefs.
4722    async fn execute_unset_builtin(
4723        &mut self,
4724        args: &[String],
4725        redirects: &[Redirect],
4726    ) -> Result<ExecResult> {
4727        let mut unset_nameref = false;
4728        let mut var_args: Vec<&String> = Vec::new();
4729        for arg in args {
4730            if arg == "-n" {
4731                unset_nameref = true;
4732            } else if arg == "-v" || arg == "-f" {
4733                // -v (variable, default) and -f (function) flags - skip
4734            } else {
4735                var_args.push(arg);
4736            }
4737        }
4738
4739        for arg in &var_args {
4740            if let Some(bracket) = arg.find('[') {
4741                if arg.ends_with(']') {
4742                    let arr_name = &arg[..bracket];
4743                    let key = &arg[bracket + 1..arg.len() - 1];
4744                    let expanded_key = self.expand_variable_or_literal(key);
4745                    let resolved_name = self.resolve_nameref(arr_name).to_string();
4746                    if let Some(arr) = self.assoc_arrays.get_mut(&resolved_name) {
4747                        arr.remove(&expanded_key);
4748                    } else if let Some(arr) = self.arrays.get_mut(&resolved_name) {
4749                        if let Ok(idx) = key.parse::<usize>() {
4750                            arr.remove(&idx);
4751                        }
4752                    }
4753                    continue;
4754                }
4755            }
4756            if unset_nameref {
4757                self.variables.remove(&format!("_NAMEREF_{}", arg));
4758            } else {
4759                let resolved = self.resolve_nameref(arg).to_string();
4760                self.variables.remove(&resolved);
4761                self.arrays.remove(&resolved);
4762                self.assoc_arrays.remove(&resolved);
4763                for frame in self.call_stack.iter_mut().rev() {
4764                    frame.locals.remove(&resolved);
4765                }
4766            }
4767        }
4768        let result = ExecResult::ok(String::new());
4769        self.apply_redirections(result, redirects).await
4770    }
4771
4772    /// Execute the `caller` builtin — show call stack frame info.
4773    async fn execute_caller_builtin(
4774        &mut self,
4775        args: &[String],
4776        redirects: &[Redirect],
4777    ) -> Result<ExecResult> {
4778        let frame_num: usize = args.first().and_then(|s| s.parse().ok()).unwrap_or(0);
4779        if self.call_stack.is_empty() {
4780            let result = ExecResult::err(String::new(), 1);
4781            return self.apply_redirections(result, redirects).await;
4782        }
4783        let source = "main";
4784        let line = 1;
4785        let output = if frame_num == 0 && self.call_stack.len() == 1 {
4786            format!("{} main {}\n", line, source)
4787        } else if frame_num + 1 < self.call_stack.len() {
4788            let idx = self.call_stack.len() - 2 - frame_num;
4789            let frame = &self.call_stack[idx];
4790            format!("{} {} {}\n", line, frame.name, source)
4791        } else if frame_num + 1 == self.call_stack.len() {
4792            format!("{} main {}\n", line, source)
4793        } else {
4794            let result = ExecResult::err(String::new(), 1);
4795            return self.apply_redirections(result, redirects).await;
4796        };
4797        let result = ExecResult::ok(output);
4798        self.apply_redirections(result, redirects).await
4799    }
4800
4801    /// Execute the `alias` builtin. Needs direct access to self.aliases.
4802    ///
4803    /// Usage:
4804    /// - `alias` - list all aliases
4805    /// - `alias name` - show alias for name (error if not defined)
4806    /// - `alias name=value` - define alias
4807    /// - `alias name=value name2=value2` - define multiple aliases
4808    async fn execute_alias_builtin(
4809        &mut self,
4810        args: &[String],
4811        redirects: &[Redirect],
4812    ) -> Result<ExecResult> {
4813        if args.is_empty() {
4814            // List all aliases
4815            let mut output = String::new();
4816            let mut sorted: Vec<_> = self.aliases.iter().collect();
4817            sorted.sort_by_key(|(k, _)| (*k).clone());
4818            for (name, value) in sorted {
4819                output.push_str(&format!("alias {}='{}'\n", name, value));
4820            }
4821            let result = ExecResult::ok(output);
4822            return self.apply_redirections(result, redirects).await;
4823        }
4824
4825        let mut output = String::new();
4826        let mut exit_code = 0;
4827        let mut stderr = String::new();
4828
4829        for arg in args {
4830            if let Some(eq_pos) = arg.find('=') {
4831                // alias name=value
4832                let name = &arg[..eq_pos];
4833                let value = &arg[eq_pos + 1..];
4834                self.aliases.insert(name.to_string(), value.to_string());
4835            } else {
4836                // alias name - show the alias
4837                if let Some(value) = self.aliases.get(arg.as_str()) {
4838                    output.push_str(&format!("alias {}='{}'\n", arg, value));
4839                } else {
4840                    stderr.push_str(&format!("bash: alias: {}: not found\n", arg));
4841                    exit_code = 1;
4842                }
4843            }
4844        }
4845
4846        let result = ExecResult {
4847            stdout: output,
4848            stderr,
4849            exit_code,
4850            control_flow: ControlFlow::None,
4851        };
4852        self.apply_redirections(result, redirects).await
4853    }
4854
4855    /// Execute the `unalias` builtin. Needs direct access to self.aliases.
4856    ///
4857    /// Usage:
4858    /// - `unalias name` - remove alias
4859    /// - `unalias -a` - remove all aliases
4860    async fn execute_unalias_builtin(
4861        &mut self,
4862        args: &[String],
4863        redirects: &[Redirect],
4864    ) -> Result<ExecResult> {
4865        if args.is_empty() {
4866            let result = ExecResult::err(
4867                "bash: unalias: usage: unalias [-a] name [name ...]\n".to_string(),
4868                2,
4869            );
4870            return self.apply_redirections(result, redirects).await;
4871        }
4872
4873        let mut exit_code = 0;
4874        let mut stderr = String::new();
4875
4876        for arg in args {
4877            if arg == "-a" {
4878                self.aliases.clear();
4879            } else if self.aliases.remove(arg.as_str()).is_none() {
4880                stderr.push_str(&format!("bash: unalias: {}: not found\n", arg));
4881                exit_code = 1;
4882            }
4883        }
4884
4885        let result = ExecResult {
4886            stdout: String::new(),
4887            stderr,
4888            exit_code,
4889            control_flow: ControlFlow::None,
4890        };
4891        self.apply_redirections(result, redirects).await
4892    }
4893
4894    /// Execute the `getopts` builtin (POSIX option parsing).
4895    ///
4896    /// Execute mapfile/readarray builtin — reads lines into an indexed array.
4897    /// Handled inline because it needs direct access to self.arrays.
4898    async fn execute_mapfile(
4899        &mut self,
4900        args: &[String],
4901        stdin_data: Option<&str>,
4902    ) -> Result<ExecResult> {
4903        let mut trim_trailing = false; // -t: strip trailing newlines
4904        let mut array_name = "MAPFILE".to_string();
4905        let mut positional = Vec::new();
4906
4907        for arg in args {
4908            match arg.as_str() {
4909                "-t" => trim_trailing = true,
4910                a if a.starts_with('-') => {} // skip unknown flags
4911                _ => positional.push(arg.clone()),
4912            }
4913        }
4914
4915        if let Some(name) = positional.first() {
4916            array_name = name.clone();
4917        }
4918
4919        let input = stdin_data.unwrap_or("");
4920
4921        // Clear existing array
4922        self.arrays.remove(&array_name);
4923
4924        // Split into lines and populate array
4925        if !input.is_empty() {
4926            let mut arr = HashMap::new();
4927            for (idx, line) in input.lines().enumerate() {
4928                let value = if trim_trailing {
4929                    line.to_string()
4930                } else {
4931                    format!("{}\n", line)
4932                };
4933                arr.insert(idx, value);
4934            }
4935            if !arr.is_empty() {
4936                self.arrays.insert(array_name, arr);
4937            }
4938        }
4939
4940        Ok(ExecResult::ok(String::new()))
4941    }
4942
4943    /// Usage: `getopts optstring name [args...]`
4944    ///
4945    /// Parses options from positional params (or `args`).
4946    /// Uses/updates `OPTIND` variable for tracking position.
4947    /// Sets `name` variable to the found option letter.
4948    /// Sets `OPTARG` for options that take arguments (marked with `:` in optstring).
4949    /// Returns 0 while options remain, 1 when done.
4950    async fn execute_getopts(
4951        &mut self,
4952        args: &[String],
4953        redirects: &[Redirect],
4954    ) -> Result<ExecResult> {
4955        if args.len() < 2 {
4956            let result = ExecResult::err("getopts: usage: getopts optstring name [arg ...]\n", 2);
4957            return Ok(result);
4958        }
4959
4960        let optstring = &args[0];
4961        let varname = &args[1];
4962
4963        // Get the arguments to parse (remaining args, or positional params)
4964        let parse_args: Vec<String> = if args.len() > 2 {
4965            args[2..].to_vec()
4966        } else {
4967            // Use positional parameters $1, $2, ...
4968            self.call_stack
4969                .last()
4970                .map(|frame| frame.positional.clone())
4971                .unwrap_or_default()
4972        };
4973
4974        // Get current OPTIND (1-based index into args)
4975        let optind: usize = self
4976            .variables
4977            .get("OPTIND")
4978            .and_then(|v| v.parse().ok())
4979            .unwrap_or(1);
4980
4981        // Check if we're past the end
4982        if optind < 1 || optind > parse_args.len() {
4983            self.variables.insert(varname.clone(), "?".to_string());
4984            return Ok(ExecResult {
4985                stdout: String::new(),
4986                stderr: String::new(),
4987                exit_code: 1,
4988                control_flow: crate::interpreter::ControlFlow::None,
4989            });
4990        }
4991
4992        let current_arg = &parse_args[optind - 1];
4993
4994        // Check if this is an option (starts with -)
4995        if !current_arg.starts_with('-') || current_arg == "-" || current_arg == "--" {
4996            self.variables.insert(varname.clone(), "?".to_string());
4997            if current_arg == "--" {
4998                self.variables
4999                    .insert("OPTIND".to_string(), (optind + 1).to_string());
5000            }
5001            return Ok(ExecResult {
5002                stdout: String::new(),
5003                stderr: String::new(),
5004                exit_code: 1,
5005                control_flow: crate::interpreter::ControlFlow::None,
5006            });
5007        }
5008
5009        // Parse the option character(s) from current arg
5010        // Handle multi-char option groups like -abc
5011        let opt_chars: Vec<char> = current_arg[1..].chars().collect();
5012
5013        // Track position within the current argument for multi-char options
5014        let char_idx: usize = self
5015            .variables
5016            .get("_OPTCHAR_IDX")
5017            .and_then(|v| v.parse().ok())
5018            .unwrap_or(0);
5019
5020        if char_idx >= opt_chars.len() {
5021            // Should not happen, but advance
5022            self.variables
5023                .insert("OPTIND".to_string(), (optind + 1).to_string());
5024            self.variables.remove("_OPTCHAR_IDX");
5025            self.variables.insert(varname.clone(), "?".to_string());
5026            return Ok(ExecResult {
5027                stdout: String::new(),
5028                stderr: String::new(),
5029                exit_code: 1,
5030                control_flow: crate::interpreter::ControlFlow::None,
5031            });
5032        }
5033
5034        let opt_char = opt_chars[char_idx];
5035        let silent = optstring.starts_with(':');
5036        let spec = if silent { &optstring[1..] } else { optstring };
5037
5038        // Check if this option is in the optstring
5039        if let Some(pos) = spec.find(opt_char) {
5040            let needs_arg = spec.get(pos + 1..pos + 2) == Some(":");
5041            self.variables.insert(varname.clone(), opt_char.to_string());
5042
5043            if needs_arg {
5044                // Option needs an argument
5045                if char_idx + 1 < opt_chars.len() {
5046                    // Rest of current arg is the argument
5047                    let arg_val: String = opt_chars[char_idx + 1..].iter().collect();
5048                    self.variables.insert("OPTARG".to_string(), arg_val);
5049                    self.variables
5050                        .insert("OPTIND".to_string(), (optind + 1).to_string());
5051                    self.variables.remove("_OPTCHAR_IDX");
5052                } else if optind < parse_args.len() {
5053                    // Next arg is the argument
5054                    self.variables
5055                        .insert("OPTARG".to_string(), parse_args[optind].clone());
5056                    self.variables
5057                        .insert("OPTIND".to_string(), (optind + 2).to_string());
5058                    self.variables.remove("_OPTCHAR_IDX");
5059                } else {
5060                    // Missing argument
5061                    self.variables.remove("OPTARG");
5062                    self.variables
5063                        .insert("OPTIND".to_string(), (optind + 1).to_string());
5064                    self.variables.remove("_OPTCHAR_IDX");
5065                    if silent {
5066                        self.variables.insert(varname.clone(), ":".to_string());
5067                        self.variables
5068                            .insert("OPTARG".to_string(), opt_char.to_string());
5069                    } else {
5070                        self.variables.insert(varname.clone(), "?".to_string());
5071                        let mut result = ExecResult::ok(String::new());
5072                        result.stderr = format!(
5073                            "bash: getopts: option requires an argument -- '{}'\n",
5074                            opt_char
5075                        );
5076                        result = self.apply_redirections(result, redirects).await?;
5077                        return Ok(result);
5078                    }
5079                }
5080            } else {
5081                // No argument needed
5082                self.variables.remove("OPTARG");
5083                if char_idx + 1 < opt_chars.len() {
5084                    // More chars in this arg
5085                    self.variables
5086                        .insert("_OPTCHAR_IDX".to_string(), (char_idx + 1).to_string());
5087                } else {
5088                    // Move to next arg
5089                    self.variables
5090                        .insert("OPTIND".to_string(), (optind + 1).to_string());
5091                    self.variables.remove("_OPTCHAR_IDX");
5092                }
5093            }
5094        } else {
5095            // Unknown option
5096            self.variables.remove("OPTARG");
5097            if char_idx + 1 < opt_chars.len() {
5098                self.variables
5099                    .insert("_OPTCHAR_IDX".to_string(), (char_idx + 1).to_string());
5100            } else {
5101                self.variables
5102                    .insert("OPTIND".to_string(), (optind + 1).to_string());
5103                self.variables.remove("_OPTCHAR_IDX");
5104            }
5105
5106            if silent {
5107                self.variables.insert(varname.clone(), "?".to_string());
5108                self.variables
5109                    .insert("OPTARG".to_string(), opt_char.to_string());
5110            } else {
5111                self.variables.insert(varname.clone(), "?".to_string());
5112                let mut result = ExecResult::ok(String::new());
5113                result.stderr = format!("bash: getopts: illegal option -- '{}'\n", opt_char);
5114                result = self.apply_redirections(result, redirects).await?;
5115                return Ok(result);
5116            }
5117        }
5118
5119        let mut result = ExecResult::ok(String::new());
5120        result = self.apply_redirections(result, redirects).await?;
5121        Ok(result)
5122    }
5123
5124    /// Execute the `command` builtin.
5125    ///
5126    /// - `command -v name` — print command path/name if found (exit 0) or nothing (exit 1)
5127    /// - `command -V name` — verbose: describe what `name` is
5128    /// - `command name args...` — run `name` bypassing shell functions
5129    async fn execute_command_builtin(
5130        &mut self,
5131        args: &[String],
5132        _stdin: Option<String>,
5133        redirects: &[Redirect],
5134    ) -> Result<ExecResult> {
5135        if args.is_empty() {
5136            return Ok(ExecResult::ok(String::new()));
5137        }
5138
5139        let mut mode = ' '; // default: run the command
5140        let mut cmd_args_start = 0;
5141
5142        // Parse flags
5143        let mut i = 0;
5144        while i < args.len() {
5145            let arg = &args[i];
5146            if arg == "-v" {
5147                mode = 'v';
5148                i += 1;
5149            } else if arg == "-V" {
5150                mode = 'V';
5151                i += 1;
5152            } else if arg == "-p" {
5153                // -p: use default PATH (ignore in sandboxed env)
5154                i += 1;
5155            } else {
5156                cmd_args_start = i;
5157                break;
5158            }
5159        }
5160
5161        if cmd_args_start >= args.len() {
5162            return Ok(ExecResult::ok(String::new()));
5163        }
5164
5165        let cmd_name = &args[cmd_args_start];
5166
5167        match mode {
5168            'v' => {
5169                // command -v: print name if it's a known command
5170                let found = self.builtins.contains_key(cmd_name.as_str())
5171                    || self.functions.contains_key(cmd_name.as_str())
5172                    || is_keyword(cmd_name);
5173                let mut result = if found {
5174                    ExecResult::ok(format!("{}\n", cmd_name))
5175                } else {
5176                    ExecResult {
5177                        stdout: String::new(),
5178                        stderr: String::new(),
5179                        exit_code: 1,
5180                        control_flow: crate::interpreter::ControlFlow::None,
5181                    }
5182                };
5183                result = self.apply_redirections(result, redirects).await?;
5184                Ok(result)
5185            }
5186            'V' => {
5187                // command -V: verbose description
5188                let description = if self.functions.contains_key(cmd_name.as_str()) {
5189                    format!("{} is a function\n", cmd_name)
5190                } else if self.builtins.contains_key(cmd_name.as_str()) {
5191                    format!("{} is a shell builtin\n", cmd_name)
5192                } else if is_keyword(cmd_name) {
5193                    format!("{} is a shell keyword\n", cmd_name)
5194                } else {
5195                    return Ok(ExecResult::err(
5196                        format!("bash: command: {}: not found\n", cmd_name),
5197                        1,
5198                    ));
5199                };
5200                let mut result = ExecResult::ok(description);
5201                result = self.apply_redirections(result, redirects).await?;
5202                Ok(result)
5203            }
5204            _ => {
5205                // command name args...: run bypassing functions (use builtin only)
5206                // Build a synthetic simple command and execute it, skipping function lookup
5207                let remaining = &args[cmd_args_start..];
5208                if let Some(builtin) = self.builtins.get(remaining[0].as_str()) {
5209                    let builtin_args = &remaining[1..];
5210                    let ctx = builtins::Context {
5211                        args: builtin_args,
5212                        env: &self.env,
5213                        variables: &mut self.variables,
5214                        cwd: &mut self.cwd,
5215                        fs: Arc::clone(&self.fs),
5216                        stdin: _stdin.as_deref(),
5217                        #[cfg(feature = "http_client")]
5218                        http_client: self.http_client.as_ref(),
5219                        #[cfg(feature = "git")]
5220                        git_client: self.git_client.as_ref(),
5221                    };
5222                    let mut result = builtin.execute(ctx).await?;
5223                    result = self.apply_redirections(result, redirects).await?;
5224                    Ok(result)
5225                } else {
5226                    Ok(ExecResult::err(
5227                        format!("bash: {}: command not found\n", remaining[0]),
5228                        127,
5229                    ))
5230                }
5231            }
5232        }
5233    }
5234
5235    /// Execute `type` builtin — describe command type.
5236    ///
5237    /// - `type name` — "name is a shell builtin" / "name is a function" / etc.
5238    /// - `type -t name` — print just the type word: builtin, function, keyword, file, alias
5239    /// - `type -p name` — print path if it would be found on PATH
5240    /// - `type -a name` — show all matches (functions, builtins, keywords)
5241    async fn execute_type_builtin(
5242        &mut self,
5243        args: &[String],
5244        redirects: &[Redirect],
5245    ) -> Result<ExecResult> {
5246        if args.is_empty() {
5247            return Ok(ExecResult::err(
5248                "bash: type: usage: type [-afptP] name [name ...]\n".to_string(),
5249                1,
5250            ));
5251        }
5252
5253        let mut type_only = false; // -t
5254        let mut path_only = false; // -p
5255        let mut show_all = false; // -a
5256        let mut names: Vec<&str> = Vec::new();
5257
5258        for arg in args {
5259            if arg.starts_with('-') && arg.len() > 1 {
5260                for c in arg[1..].chars() {
5261                    match c {
5262                        't' => type_only = true,
5263                        'p' => path_only = true,
5264                        'a' => show_all = true,
5265                        'f' => {} // -f: suppress function lookup (ignored for now)
5266                        'P' => path_only = true,
5267                        _ => {
5268                            return Ok(ExecResult::err(
5269                                format!(
5270                                    "bash: type: -{}: invalid option\ntype: usage: type [-afptP] name [name ...]\n",
5271                                    c
5272                                ),
5273                                1,
5274                            ));
5275                        }
5276                    }
5277                }
5278            } else {
5279                names.push(arg);
5280            }
5281        }
5282
5283        let mut output = String::new();
5284        let mut all_found = true;
5285
5286        for name in &names {
5287            let is_func = self.functions.contains_key(*name);
5288            let is_builtin = self.builtins.contains_key(*name);
5289            let is_kw = is_keyword(name);
5290
5291            if type_only {
5292                if is_func {
5293                    output.push_str("function\n");
5294                } else if is_kw {
5295                    output.push_str("keyword\n");
5296                } else if is_builtin {
5297                    output.push_str("builtin\n");
5298                } else {
5299                    // not found — print nothing, set exit code
5300                    all_found = false;
5301                }
5302            } else if path_only {
5303                // -p only reports external files; builtins/functions have no path
5304                if !is_func && !is_builtin && !is_kw {
5305                    all_found = false;
5306                }
5307                // In sandboxed env there are no external files, so nothing to print
5308            } else {
5309                // default verbose output
5310                let mut found_any = false;
5311                if is_func {
5312                    output.push_str(&format!("{} is a function\n", name));
5313                    found_any = true;
5314                    if !show_all {
5315                        continue;
5316                    }
5317                }
5318                if is_kw {
5319                    output.push_str(&format!("{} is a shell keyword\n", name));
5320                    found_any = true;
5321                    if !show_all {
5322                        continue;
5323                    }
5324                }
5325                if is_builtin {
5326                    output.push_str(&format!("{} is a shell builtin\n", name));
5327                    found_any = true;
5328                    if !show_all {
5329                        continue;
5330                    }
5331                }
5332                if !found_any {
5333                    output.push_str(&format!("bash: type: {}: not found\n", name));
5334                    all_found = false;
5335                }
5336            }
5337        }
5338
5339        let exit_code = if all_found { 0 } else { 1 };
5340        let mut result = ExecResult {
5341            stdout: output,
5342            stderr: String::new(),
5343            exit_code,
5344            control_flow: ControlFlow::None,
5345        };
5346        result = self.apply_redirections(result, redirects).await?;
5347        Ok(result)
5348    }
5349
5350    /// Execute `which` builtin — locate a command.
5351    ///
5352    /// In bashkit's sandboxed environment, builtins are the equivalent of
5353    /// executables on PATH. Reports the name if found.
5354    async fn execute_which_builtin(
5355        &mut self,
5356        args: &[String],
5357        redirects: &[Redirect],
5358    ) -> Result<ExecResult> {
5359        let names: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
5360
5361        if names.is_empty() {
5362            return Ok(ExecResult::ok(String::new()));
5363        }
5364
5365        let mut output = String::new();
5366        let mut all_found = true;
5367
5368        for name in &names {
5369            if self.builtins.contains_key(*name)
5370                || self.functions.contains_key(*name)
5371                || is_keyword(name)
5372            {
5373                output.push_str(&format!("{}\n", name));
5374            } else {
5375                all_found = false;
5376            }
5377        }
5378
5379        let exit_code = if all_found { 0 } else { 1 };
5380        let mut result = ExecResult {
5381            stdout: output,
5382            stderr: String::new(),
5383            exit_code,
5384            control_flow: ControlFlow::None,
5385        };
5386        result = self.apply_redirections(result, redirects).await?;
5387        Ok(result)
5388    }
5389
5390    /// Execute `declare`/`typeset` builtin — declare variables with attributes.
5391    ///
5392    /// - `declare var=value` — set variable
5393    /// - `declare -i var=value` — integer attribute (stored as-is)
5394    /// - `declare -r var=value` — readonly
5395    /// - `declare -x var=value` — export
5396    /// - `declare -a arr` — indexed array
5397    /// - `declare -p [var]` — print variable declarations
5398    async fn execute_declare_builtin(
5399        &mut self,
5400        args: &[String],
5401        redirects: &[Redirect],
5402    ) -> Result<ExecResult> {
5403        if args.is_empty() {
5404            // declare with no args: print all variables, filtering internal markers (TM-INF-017)
5405            let mut output = String::new();
5406            let mut entries: Vec<_> = self.variables.iter().collect();
5407            entries.sort_by_key(|(k, _)| (*k).clone());
5408            for (name, value) in entries {
5409                if is_internal_variable(name) {
5410                    continue;
5411                }
5412                output.push_str(&format!("declare -- {}=\"{}\"\n", name, value));
5413            }
5414            let mut result = ExecResult::ok(output);
5415            result = self.apply_redirections(result, redirects).await?;
5416            return Ok(result);
5417        }
5418
5419        let mut print_mode = false;
5420        let mut is_readonly = false;
5421        let mut is_export = false;
5422        let mut is_array = false;
5423        let mut is_assoc = false;
5424        let mut is_integer = false;
5425        let mut is_nameref = false;
5426        let mut remove_nameref = false;
5427        let mut is_lowercase = false;
5428        let mut is_uppercase = false;
5429        let mut names: Vec<&str> = Vec::new();
5430
5431        for arg in args {
5432            if arg.starts_with('-') && !arg.contains('=') {
5433                for c in arg[1..].chars() {
5434                    match c {
5435                        'p' => print_mode = true,
5436                        'r' => is_readonly = true,
5437                        'x' => is_export = true,
5438                        'a' => is_array = true,
5439                        'i' => is_integer = true,
5440                        'A' => is_assoc = true,
5441                        'n' => is_nameref = true,
5442                        'l' => is_lowercase = true,
5443                        'u' => is_uppercase = true,
5444                        'g' | 't' | 'f' | 'F' => {} // ignored
5445                        _ => {}
5446                    }
5447                }
5448            } else if arg.starts_with('+') && !arg.contains('=') {
5449                // +n removes nameref attribute
5450                for c in arg[1..].chars() {
5451                    if c == 'n' {
5452                        remove_nameref = true;
5453                    }
5454                }
5455            } else {
5456                names.push(arg);
5457            }
5458        }
5459
5460        if print_mode {
5461            let mut output = String::new();
5462            if names.is_empty() {
5463                // Print all variables, filtering internal markers (TM-INF-017)
5464                let mut entries: Vec<_> = self.variables.iter().collect();
5465                entries.sort_by_key(|(k, _)| (*k).clone());
5466                for (name, value) in entries {
5467                    if is_internal_variable(name) {
5468                        continue;
5469                    }
5470                    output.push_str(&format!("declare -- {}=\"{}\"\n", name, value));
5471                }
5472            } else {
5473                for name in &names {
5474                    // Strip =value if present
5475                    let var_name = name.split('=').next().unwrap_or(name);
5476                    if let Some(value) = self.variables.get(var_name) {
5477                        let mut attrs = String::from("--");
5478                        if self
5479                            .variables
5480                            .contains_key(&format!("_READONLY_{}", var_name))
5481                        {
5482                            attrs = String::from("-r");
5483                        }
5484                        output.push_str(&format!("declare {} {}=\"{}\"\n", attrs, var_name, value));
5485                    } else if let Some(arr) = self.assoc_arrays.get(var_name) {
5486                        let mut items: Vec<_> = arr.iter().collect();
5487                        items.sort_by_key(|(k, _)| (*k).clone());
5488                        let inner: String = items
5489                            .iter()
5490                            .map(|(k, v)| format!("[{}]=\"{}\"", k, v))
5491                            .collect::<Vec<_>>()
5492                            .join(" ");
5493                        output.push_str(&format!("declare -A {}=({})\n", var_name, inner));
5494                    } else if let Some(arr) = self.arrays.get(var_name) {
5495                        let mut items: Vec<_> = arr.iter().collect();
5496                        items.sort_by_key(|(k, _)| *k);
5497                        let inner: String = items
5498                            .iter()
5499                            .map(|(k, v)| format!("[{}]=\"{}\"", k, v))
5500                            .collect::<Vec<_>>()
5501                            .join(" ");
5502                        output.push_str(&format!("declare -a {}=({})\n", var_name, inner));
5503                    } else {
5504                        return Ok(ExecResult::err(
5505                            format!("bash: declare: {}: not found\n", var_name),
5506                            1,
5507                        ));
5508                    }
5509                }
5510            }
5511            let mut result = ExecResult::ok(output);
5512            result = self.apply_redirections(result, redirects).await?;
5513            return Ok(result);
5514        }
5515
5516        // Reconstruct compound assignments: declare -A m=([a]="1" [b]="2")
5517        // Args may be split across names: ["m=([a]=1", "[b]=2)"]
5518        let mut merged_names: Vec<String> = Vec::new();
5519        let mut pending: Option<String> = None;
5520        for name in &names {
5521            if let Some(ref mut p) = pending {
5522                p.push(' ');
5523                p.push_str(name);
5524                if name.ends_with(')') {
5525                    merged_names.push(p.clone());
5526                    pending = None;
5527                }
5528            } else if let Some(eq_pos) = name.find("=(") {
5529                if name.ends_with(')') {
5530                    merged_names.push(name.to_string());
5531                } else {
5532                    pending = Some(name.to_string());
5533                    let _ = eq_pos; // used above in find
5534                }
5535            } else {
5536                merged_names.push(name.to_string());
5537            }
5538        }
5539        if let Some(p) = pending {
5540            merged_names.push(p);
5541        }
5542
5543        // Set variables
5544        for name in &merged_names {
5545            if let Some(eq_pos) = name.find('=') {
5546                let var_name = &name[..eq_pos];
5547                let value = &name[eq_pos + 1..];
5548
5549                // THREAT[TM-INJ-012]: Block internal variable prefix injection via declare
5550                if is_internal_variable(var_name) {
5551                    continue;
5552                }
5553
5554                // Handle compound array assignment: declare -A m=([k]="v" ...)
5555                if (is_assoc || is_array) && value.starts_with('(') && value.ends_with(')') {
5556                    let inner = &value[1..value.len() - 1];
5557                    if is_assoc {
5558                        let arr = self.assoc_arrays.entry(var_name.to_string()).or_default();
5559                        arr.clear();
5560                        // Parse [key]="value" pairs
5561                        let mut rest = inner.trim();
5562                        while let Some(bracket_start) = rest.find('[') {
5563                            if let Some(bracket_end) = rest[bracket_start..].find(']') {
5564                                let key = &rest[bracket_start + 1..bracket_start + bracket_end];
5565                                let after = &rest[bracket_start + bracket_end + 1..];
5566                                if let Some(eq_rest) = after.strip_prefix('=') {
5567                                    let eq_rest = eq_rest.trim_start();
5568                                    let (val, remainder) = if let Some(stripped) =
5569                                        eq_rest.strip_prefix('"')
5570                                    {
5571                                        // Quoted value
5572                                        if let Some(end_q) = stripped.find('"') {
5573                                            (&stripped[..end_q], stripped[end_q + 1..].trim_start())
5574                                        } else {
5575                                            (stripped.trim_end_matches('"'), "")
5576                                        }
5577                                    } else {
5578                                        // Unquoted value — up to next space or end
5579                                        match eq_rest.find(char::is_whitespace) {
5580                                            Some(sp) => {
5581                                                (&eq_rest[..sp], eq_rest[sp..].trim_start())
5582                                            }
5583                                            None => (eq_rest, ""),
5584                                        }
5585                                    };
5586                                    arr.insert(key.to_string(), val.to_string());
5587                                    rest = remainder;
5588                                } else {
5589                                    break;
5590                                }
5591                            } else {
5592                                break;
5593                            }
5594                        }
5595                    } else {
5596                        // Indexed array: declare -a arr=(a b c)
5597                        let arr = self.arrays.entry(var_name.to_string()).or_default();
5598                        arr.clear();
5599                        for (idx, val) in inner.split_whitespace().enumerate() {
5600                            arr.insert(idx, val.trim_matches('"').to_string());
5601                        }
5602                    }
5603                } else if is_nameref {
5604                    // declare -n ref=target: create nameref
5605                    self.variables
5606                        .insert(format!("_NAMEREF_{}", var_name), value.to_string());
5607                } else if is_integer {
5608                    // Evaluate as arithmetic expression
5609                    let int_val = self.evaluate_arithmetic_with_assign(value);
5610                    self.variables
5611                        .insert(var_name.to_string(), int_val.to_string());
5612                } else {
5613                    // Apply case conversion attributes
5614                    let final_value = if is_lowercase {
5615                        value.to_lowercase()
5616                    } else if is_uppercase {
5617                        value.to_uppercase()
5618                    } else {
5619                        value.to_string()
5620                    };
5621                    self.variables.insert(var_name.to_string(), final_value);
5622                }
5623
5624                // Set case conversion attribute markers
5625                if is_lowercase {
5626                    self.variables
5627                        .insert(format!("_LOWER_{}", var_name), "1".to_string());
5628                    self.variables.remove(&format!("_UPPER_{}", var_name));
5629                }
5630                if is_uppercase {
5631                    self.variables
5632                        .insert(format!("_UPPER_{}", var_name), "1".to_string());
5633                    self.variables.remove(&format!("_LOWER_{}", var_name));
5634                }
5635                if is_readonly {
5636                    self.variables
5637                        .insert(format!("_READONLY_{}", var_name), "1".to_string());
5638                }
5639                if is_export {
5640                    self.env.insert(
5641                        var_name.to_string(),
5642                        self.variables.get(var_name).cloned().unwrap_or_default(),
5643                    );
5644                }
5645            } else {
5646                // Declare without value
5647                if remove_nameref {
5648                    // typeset +n ref: remove nameref attribute
5649                    self.variables.remove(&format!("_NAMEREF_{}", name));
5650                } else if is_nameref {
5651                    // typeset -n ref (without =value): use existing variable value as target
5652                    if let Some(existing) = self.variables.get(name.as_str()).cloned() {
5653                        if !existing.is_empty() {
5654                            self.variables
5655                                .insert(format!("_NAMEREF_{}", name), existing);
5656                        }
5657                    }
5658                } else if is_assoc {
5659                    // Initialize empty associative array
5660                    self.assoc_arrays.entry(name.to_string()).or_default();
5661                } else if is_array {
5662                    // Initialize empty indexed array
5663                    self.arrays.entry(name.to_string()).or_default();
5664                } else if !self.variables.contains_key(name.as_str()) {
5665                    self.variables.insert(name.to_string(), String::new());
5666                }
5667                // Set case conversion attribute markers
5668                if is_lowercase {
5669                    self.variables
5670                        .insert(format!("_LOWER_{}", name), "1".to_string());
5671                    self.variables.remove(&format!("_UPPER_{}", name));
5672                }
5673                if is_uppercase {
5674                    self.variables
5675                        .insert(format!("_UPPER_{}", name), "1".to_string());
5676                    self.variables.remove(&format!("_LOWER_{}", name));
5677                }
5678                if is_readonly {
5679                    self.variables
5680                        .insert(format!("_READONLY_{}", name), "1".to_string());
5681                }
5682                if is_export {
5683                    self.env.insert(
5684                        name.to_string(),
5685                        self.variables
5686                            .get(name.as_str())
5687                            .cloned()
5688                            .unwrap_or_default(),
5689                    );
5690                }
5691            }
5692        }
5693
5694        let mut result = ExecResult::ok(String::new());
5695        result = self.apply_redirections(result, redirects).await?;
5696        Ok(result)
5697    }
5698
5699    /// Process input redirections (< file, <<< string)
5700    async fn process_input_redirections(
5701        &mut self,
5702        existing_stdin: Option<String>,
5703        redirects: &[Redirect],
5704    ) -> Result<Option<String>> {
5705        let mut stdin = existing_stdin;
5706
5707        for redirect in redirects {
5708            match redirect.kind {
5709                RedirectKind::Input => {
5710                    let target_path = self.expand_word(&redirect.target).await?;
5711                    let path = self.resolve_path(&target_path);
5712                    // Handle /dev/null at interpreter level - cannot be bypassed
5713                    if is_dev_null(&path) {
5714                        stdin = Some(String::new()); // EOF
5715                    } else {
5716                        let content = self.fs.read_file(&path).await?;
5717                        stdin = Some(String::from_utf8_lossy(&content).to_string());
5718                    }
5719                }
5720                RedirectKind::HereString => {
5721                    // <<< string - use the target as stdin content
5722                    let content = self.expand_word(&redirect.target).await?;
5723                    stdin = Some(format!("{}\n", content));
5724                }
5725                RedirectKind::HereDoc | RedirectKind::HereDocStrip => {
5726                    // << EOF / <<- EOF - use the heredoc content as stdin
5727                    let content = self.expand_word(&redirect.target).await?;
5728                    stdin = Some(content);
5729                }
5730                _ => {
5731                    // Output redirections handled separately
5732                }
5733            }
5734        }
5735
5736        Ok(stdin)
5737    }
5738
5739    /// Apply output redirections to command output
5740    async fn apply_redirections(
5741        &mut self,
5742        mut result: ExecResult,
5743        redirects: &[Redirect],
5744    ) -> Result<ExecResult> {
5745        for redirect in redirects {
5746            match redirect.kind {
5747                RedirectKind::Output => {
5748                    let target_path = self.expand_word(&redirect.target).await?;
5749                    let path = self.resolve_path(&target_path);
5750                    // Handle /dev/null at interpreter level - cannot be bypassed
5751                    if is_dev_null(&path) {
5752                        // Discard output without calling filesystem
5753                        match redirect.fd {
5754                            Some(2) => result.stderr = String::new(),
5755                            _ => result.stdout = String::new(),
5756                        }
5757                    } else {
5758                        // Check which fd we're redirecting
5759                        match redirect.fd {
5760                            Some(2) => {
5761                                // 2> - redirect stderr to file
5762                                if let Err(e) =
5763                                    self.fs.write_file(&path, result.stderr.as_bytes()).await
5764                                {
5765                                    // Redirect failed - set exit code and report error
5766                                    result.stderr = format!("bash: {}: {}\n", target_path, e);
5767                                    result.exit_code = 1;
5768                                    return Ok(result);
5769                                }
5770                                result.stderr = String::new();
5771                            }
5772                            _ => {
5773                                // Default (stdout) - write stdout to file
5774                                if let Err(e) =
5775                                    self.fs.write_file(&path, result.stdout.as_bytes()).await
5776                                {
5777                                    // Redirect failed - output is lost, set exit code and report error
5778                                    result.stdout = String::new();
5779                                    result.stderr = format!("bash: {}: {}\n", target_path, e);
5780                                    result.exit_code = 1;
5781                                    return Ok(result);
5782                                }
5783                                result.stdout = String::new();
5784                            }
5785                        }
5786                    }
5787                }
5788                RedirectKind::Append => {
5789                    let target_path = self.expand_word(&redirect.target).await?;
5790                    let path = self.resolve_path(&target_path);
5791                    // Handle /dev/null at interpreter level - cannot be bypassed
5792                    if is_dev_null(&path) {
5793                        // Discard output without calling filesystem
5794                        match redirect.fd {
5795                            Some(2) => result.stderr = String::new(),
5796                            _ => result.stdout = String::new(),
5797                        }
5798                    } else {
5799                        // Check which fd we're appending
5800                        match redirect.fd {
5801                            Some(2) => {
5802                                // 2>> - append stderr to file
5803                                if let Err(e) =
5804                                    self.fs.append_file(&path, result.stderr.as_bytes()).await
5805                                {
5806                                    result.stderr = format!("bash: {}: {}\n", target_path, e);
5807                                    result.exit_code = 1;
5808                                    return Ok(result);
5809                                }
5810                                result.stderr = String::new();
5811                            }
5812                            _ => {
5813                                // Default (stdout) - append stdout to file
5814                                if let Err(e) =
5815                                    self.fs.append_file(&path, result.stdout.as_bytes()).await
5816                                {
5817                                    // Redirect failed - output is lost
5818                                    result.stdout = String::new();
5819                                    result.stderr = format!("bash: {}: {}\n", target_path, e);
5820                                    result.exit_code = 1;
5821                                    return Ok(result);
5822                                }
5823                                result.stdout = String::new();
5824                            }
5825                        }
5826                    }
5827                }
5828                RedirectKind::OutputBoth => {
5829                    // &> - redirect both stdout and stderr to file
5830                    let target_path = self.expand_word(&redirect.target).await?;
5831                    let path = self.resolve_path(&target_path);
5832                    // Handle /dev/null at interpreter level - cannot be bypassed
5833                    if is_dev_null(&path) {
5834                        // Discard both outputs without calling filesystem
5835                        result.stdout = String::new();
5836                        result.stderr = String::new();
5837                    } else {
5838                        // Write both stdout and stderr to file
5839                        let combined = format!("{}{}", result.stdout, result.stderr);
5840                        if let Err(e) = self.fs.write_file(&path, combined.as_bytes()).await {
5841                            result.stderr = format!("bash: {}: {}\n", target_path, e);
5842                            result.exit_code = 1;
5843                            return Ok(result);
5844                        }
5845                        result.stdout = String::new();
5846                        result.stderr = String::new();
5847                    }
5848                }
5849                RedirectKind::DupOutput => {
5850                    // Handle fd duplication (e.g., 2>&1, >&2)
5851                    let target = self.expand_word(&redirect.target).await?;
5852                    let target_fd: i32 = target.parse().unwrap_or(1);
5853                    let src_fd = redirect.fd.unwrap_or(1);
5854
5855                    match (src_fd, target_fd) {
5856                        (2, 1) => {
5857                            // 2>&1 - redirect stderr to stdout
5858                            result.stdout.push_str(&result.stderr);
5859                            result.stderr = String::new();
5860                        }
5861                        (1, 2) => {
5862                            // >&2 or 1>&2 - redirect stdout to stderr
5863                            result.stderr.push_str(&result.stdout);
5864                            result.stdout = String::new();
5865                        }
5866                        _ => {
5867                            // Other fd duplications not yet supported
5868                        }
5869                    }
5870                }
5871                RedirectKind::Input
5872                | RedirectKind::HereString
5873                | RedirectKind::HereDoc
5874                | RedirectKind::HereDocStrip => {
5875                    // Input redirections handled in process_input_redirections
5876                }
5877                RedirectKind::DupInput => {
5878                    // Input fd duplication not yet supported
5879                }
5880            }
5881        }
5882
5883        Ok(result)
5884    }
5885
5886    /// Resolve a path relative to cwd
5887    fn resolve_path(&self, path: &str) -> PathBuf {
5888        let p = Path::new(path);
5889        if p.is_absolute() {
5890            p.to_path_buf()
5891        } else {
5892            self.cwd.join(p)
5893        }
5894    }
5895
5896    async fn expand_word(&mut self, word: &Word) -> Result<String> {
5897        let mut result = String::new();
5898        let mut is_first_part = true;
5899
5900        for part in &word.parts {
5901            match part {
5902                WordPart::Literal(s) => {
5903                    // Tilde expansion: ~ at start of word expands to $HOME
5904                    if is_first_part && s.starts_with('~') {
5905                        let home = self
5906                            .env
5907                            .get("HOME")
5908                            .or_else(|| self.variables.get("HOME"))
5909                            .cloned()
5910                            .unwrap_or_else(|| "/home/user".to_string());
5911
5912                        if s == "~" {
5913                            // Just ~
5914                            result.push_str(&home);
5915                        } else if s.starts_with("~/") {
5916                            // ~/path
5917                            result.push_str(&home);
5918                            result.push_str(&s[1..]); // Include the /
5919                        } else {
5920                            // ~user - not implemented, keep as-is
5921                            result.push_str(s);
5922                        }
5923                    } else {
5924                        result.push_str(s);
5925                    }
5926                }
5927                WordPart::Variable(name) => {
5928                    // set -u (nounset): error on unset variables
5929                    if self.is_nounset() && !self.is_variable_set(name) {
5930                        self.nounset_error = Some(format!("bash: {}: unbound variable\n", name));
5931                    }
5932                    // "$*" in word context joins with IFS first char
5933                    if name == "*" && word.quoted {
5934                        let positional = self
5935                            .call_stack
5936                            .last()
5937                            .map(|f| f.positional.clone())
5938                            .unwrap_or_default();
5939                        let sep = match self.variables.get("IFS") {
5940                            Some(ifs) => ifs
5941                                .chars()
5942                                .next()
5943                                .map(|c| c.to_string())
5944                                .unwrap_or_default(),
5945                            None => " ".to_string(),
5946                        };
5947                        result.push_str(&positional.join(&sep));
5948                    } else {
5949                        result.push_str(&self.expand_variable(name));
5950                    }
5951                }
5952                WordPart::CommandSubstitution(commands) => {
5953                    // Execute the commands and capture stdout
5954                    let mut stdout = String::new();
5955                    for cmd in commands {
5956                        let cmd_result = self.execute_command(cmd).await?;
5957                        stdout.push_str(&cmd_result.stdout);
5958                        // Propagate exit code from last command in substitution
5959                        self.last_exit_code = cmd_result.exit_code;
5960                    }
5961                    // Remove trailing newline (bash behavior)
5962                    let trimmed = stdout.trim_end_matches('\n');
5963                    result.push_str(trimmed);
5964                }
5965                WordPart::ArithmeticExpansion(expr) => {
5966                    // Handle assignment: VAR = expr (must be checked before
5967                    // variable expansion so the LHS name is preserved)
5968                    let value = self.evaluate_arithmetic_with_assign(expr);
5969                    result.push_str(&value.to_string());
5970                }
5971                WordPart::Length(name) => {
5972                    // ${#var} - length of variable value
5973                    // Also handles ${#arr[n]} - length of array element
5974                    let value = if let Some(bracket_pos) = name.find('[') {
5975                        // Array element length: ${#arr[n]}
5976                        let arr_name = &name[..bracket_pos];
5977                        let index_end = name.find(']').unwrap_or(name.len());
5978                        let index_str = &name[bracket_pos + 1..index_end];
5979                        let idx: usize =
5980                            self.evaluate_arithmetic(index_str).try_into().unwrap_or(0);
5981                        if let Some(arr) = self.arrays.get(arr_name) {
5982                            arr.get(&idx).cloned().unwrap_or_default()
5983                        } else {
5984                            String::new()
5985                        }
5986                    } else {
5987                        self.expand_variable(name)
5988                    };
5989                    result.push_str(&value.chars().count().to_string());
5990                }
5991                WordPart::ParameterExpansion {
5992                    name,
5993                    operator,
5994                    operand,
5995                    colon_variant,
5996                } => {
5997                    // Reject bad substitution: operator on empty/invalid name
5998                    // e.g. ${%} parses as RemoveSuffix with empty name
5999                    if name.is_empty()
6000                        && !matches!(
6001                            operator,
6002                            ParameterOp::UseDefault
6003                                | ParameterOp::AssignDefault
6004                                | ParameterOp::UseReplacement
6005                                | ParameterOp::Error
6006                        )
6007                    {
6008                        self.nounset_error = Some("bash: ${}: bad substitution\n".to_string());
6009                        continue;
6010                    }
6011
6012                    // Under set -u, operators like :-, :=, :+, :? suppress nounset errors
6013                    // because the script is explicitly handling unset variables.
6014                    let suppress_nounset = matches!(
6015                        operator,
6016                        ParameterOp::UseDefault
6017                            | ParameterOp::AssignDefault
6018                            | ParameterOp::UseReplacement
6019                            | ParameterOp::Error
6020                    );
6021
6022                    // Resolve name (handles arr[@], @, *, and regular vars)
6023                    let (is_set, value) = self.resolve_param_expansion_name(name);
6024
6025                    if self.is_nounset() && !suppress_nounset && !is_set {
6026                        self.nounset_error = Some(format!("bash: {}: unbound variable\n", name));
6027                    }
6028                    let expanded = self.apply_parameter_op(
6029                        &value,
6030                        name,
6031                        operator,
6032                        operand,
6033                        *colon_variant,
6034                        is_set,
6035                    );
6036                    result.push_str(&expanded);
6037                }
6038                WordPart::ArrayAccess { name, index } => {
6039                    // Resolve nameref: array name may be a nameref to the real array
6040                    let resolved_name = self.resolve_nameref(name);
6041                    // Check if resolved_name itself contains an array index (e.g., "a[2]")
6042                    let (arr_name, extra_index) = if let Some(bracket) = resolved_name.find('[') {
6043                        let idx_part = &resolved_name[bracket + 1..resolved_name.len() - 1];
6044                        (&resolved_name[..bracket], Some(idx_part.to_string()))
6045                    } else {
6046                        (resolved_name, None)
6047                    };
6048                    if index == "@" || index == "*" {
6049                        // ${arr[@]} or ${arr[*]} - expand to all elements
6050                        if let Some(arr) = self.assoc_arrays.get(arr_name) {
6051                            let mut keys: Vec<_> = arr.keys().collect();
6052                            keys.sort();
6053                            let values: Vec<String> =
6054                                keys.iter().filter_map(|k| arr.get(*k).cloned()).collect();
6055                            result.push_str(&values.join(" "));
6056                        } else if let Some(arr) = self.arrays.get(arr_name) {
6057                            let mut indices: Vec<_> = arr.keys().collect();
6058                            indices.sort();
6059                            let values: Vec<_> =
6060                                indices.iter().filter_map(|i| arr.get(i)).collect();
6061                            result.push_str(
6062                                &values.into_iter().cloned().collect::<Vec<_>>().join(" "),
6063                            );
6064                        }
6065                    } else if let Some(extra_idx) = extra_index {
6066                        // Nameref resolved to "a[2]" form - use the embedded index
6067                        if let Some(arr) = self.assoc_arrays.get(arr_name) {
6068                            if let Some(value) = arr.get(&extra_idx) {
6069                                result.push_str(value);
6070                            }
6071                        } else {
6072                            let idx: usize =
6073                                self.evaluate_arithmetic(&extra_idx).try_into().unwrap_or(0);
6074                            if let Some(arr) = self.arrays.get(arr_name) {
6075                                if let Some(value) = arr.get(&idx) {
6076                                    result.push_str(value);
6077                                }
6078                            }
6079                        }
6080                    } else if let Some(arr) = self.assoc_arrays.get(arr_name) {
6081                        // ${assoc[key]} - get by string key
6082                        let key = self.expand_variable_or_literal(index);
6083                        if let Some(value) = arr.get(&key) {
6084                            result.push_str(value);
6085                        }
6086                    } else {
6087                        // ${arr[n]} - get specific element (supports negative indexing)
6088                        let raw_idx = self.evaluate_arithmetic(index);
6089                        let idx = if raw_idx < 0 {
6090                            // Negative index: count from end
6091                            let len = self
6092                                .arrays
6093                                .get(arr_name)
6094                                .map(|a| a.keys().max().map(|m| m + 1).unwrap_or(0))
6095                                .unwrap_or(0) as i64;
6096                            (len + raw_idx).max(0) as usize
6097                        } else {
6098                            raw_idx as usize
6099                        };
6100                        if let Some(arr) = self.arrays.get(arr_name) {
6101                            if let Some(value) = arr.get(&idx) {
6102                                result.push_str(value);
6103                            }
6104                        }
6105                    }
6106                }
6107                WordPart::ArrayIndices(name) => {
6108                    // ${!arr[@]} or ${!arr[*]} - expand to array indices/keys
6109                    if let Some(arr) = self.assoc_arrays.get(name) {
6110                        let mut keys: Vec<_> = arr.keys().cloned().collect();
6111                        keys.sort();
6112                        result.push_str(&keys.join(" "));
6113                    } else if let Some(arr) = self.arrays.get(name) {
6114                        let mut indices: Vec<_> = arr.keys().collect();
6115                        indices.sort();
6116                        let index_strs: Vec<String> =
6117                            indices.iter().map(|i| i.to_string()).collect();
6118                        result.push_str(&index_strs.join(" "));
6119                    }
6120                }
6121                WordPart::Substring {
6122                    name,
6123                    offset,
6124                    length,
6125                } => {
6126                    // ${var:offset} or ${var:offset:length} - character-based indexing
6127                    let value = self.expand_variable(name);
6128                    let char_count = value.chars().count();
6129                    let offset_val: isize = self.evaluate_arithmetic(offset) as isize;
6130                    let start = if offset_val < 0 {
6131                        (char_count as isize + offset_val).max(0) as usize
6132                    } else {
6133                        (offset_val as usize).min(char_count)
6134                    };
6135                    let substr: String = if let Some(len_expr) = length {
6136                        let len_val = self.evaluate_arithmetic(len_expr) as usize;
6137                        value.chars().skip(start).take(len_val).collect()
6138                    } else {
6139                        value.chars().skip(start).collect()
6140                    };
6141                    result.push_str(&substr);
6142                }
6143                WordPart::ArraySlice {
6144                    name,
6145                    offset,
6146                    length,
6147                } => {
6148                    // ${arr[@]:offset:length}
6149                    if let Some(arr) = self.arrays.get(name) {
6150                        let mut indices: Vec<_> = arr.keys().cloned().collect();
6151                        indices.sort();
6152                        let values: Vec<_> =
6153                            indices.iter().filter_map(|i| arr.get(i).cloned()).collect();
6154
6155                        let offset_val: isize = self.evaluate_arithmetic(offset) as isize;
6156                        let start = if offset_val < 0 {
6157                            (values.len() as isize + offset_val).max(0) as usize
6158                        } else {
6159                            (offset_val as usize).min(values.len())
6160                        };
6161
6162                        let sliced = if let Some(len_expr) = length {
6163                            let len_val = self.evaluate_arithmetic(len_expr) as usize;
6164                            let end = (start + len_val).min(values.len());
6165                            &values[start..end]
6166                        } else {
6167                            &values[start..]
6168                        };
6169                        result.push_str(&sliced.join(" "));
6170                    }
6171                }
6172                WordPart::IndirectExpansion(name) => {
6173                    // ${!var} - for namerefs, returns the nameref target name (inverted)
6174                    // For non-namerefs, does normal indirect expansion
6175                    let nameref_key = format!("_NAMEREF_{}", name);
6176                    if let Some(target) = self.variables.get(&nameref_key).cloned() {
6177                        // var is a nameref: ${!ref} returns the target variable name
6178                        result.push_str(&target);
6179                    } else {
6180                        // Normal indirect expansion
6181                        let var_name = self.expand_variable(name);
6182                        let value = self.expand_variable(&var_name);
6183                        result.push_str(&value);
6184                    }
6185                }
6186                WordPart::PrefixMatch(prefix) => {
6187                    // ${!prefix*} - names of variables with given prefix
6188                    let mut names: Vec<String> = self
6189                        .variables
6190                        .keys()
6191                        .filter(|k| k.starts_with(prefix.as_str()))
6192                        // THREAT[TM-INJ-009]: Hide internal marker variables
6193                        .filter(|k| !Self::is_internal_variable(k))
6194                        .cloned()
6195                        .collect();
6196                    // Also check env
6197                    for k in self.env.keys() {
6198                        if k.starts_with(prefix.as_str())
6199                            && !names.contains(k)
6200                            // THREAT[TM-INJ-009]: Hide internal marker variables
6201                            && !Self::is_internal_variable(k)
6202                        {
6203                            names.push(k.clone());
6204                        }
6205                    }
6206                    names.sort();
6207                    result.push_str(&names.join(" "));
6208                }
6209                WordPart::ArrayLength(name) => {
6210                    // ${#arr[@]} - number of elements
6211                    if let Some(arr) = self.assoc_arrays.get(name) {
6212                        result.push_str(&arr.len().to_string());
6213                    } else if let Some(arr) = self.arrays.get(name) {
6214                        result.push_str(&arr.len().to_string());
6215                    } else {
6216                        result.push('0');
6217                    }
6218                }
6219                WordPart::ProcessSubstitution { commands, is_input } => {
6220                    // Execute the commands and capture output
6221                    let mut stdout = String::new();
6222                    for cmd in commands {
6223                        let cmd_result = self.execute_command(cmd).await?;
6224                        stdout.push_str(&cmd_result.stdout);
6225                    }
6226
6227                    // Create a virtual file with the output
6228                    let path_str = format!(
6229                        "/dev/fd/proc_sub_{}",
6230                        PROC_SUB_COUNTER.fetch_add(1, Ordering::Relaxed)
6231                    );
6232                    let path = Path::new(&path_str);
6233
6234                    // Write to virtual filesystem
6235                    if self.fs.write_file(path, stdout.as_bytes()).await.is_err() {
6236                        // If we can't write, just inline the content
6237                        // This is a fallback for simpler behavior
6238                        if *is_input {
6239                            result.push_str(&stdout);
6240                        }
6241                    } else {
6242                        result.push_str(&path_str);
6243                    }
6244                }
6245                WordPart::Transformation { name, operator } => {
6246                    let value = self.expand_variable(name);
6247                    let transformed = match operator {
6248                        'Q' => {
6249                            // Quote for reuse as input
6250                            format!("'{}'", value.replace('\'', "'\\''"))
6251                        }
6252                        'E' => {
6253                            // Expand backslash escape sequences
6254                            value
6255                                .replace("\\n", "\n")
6256                                .replace("\\t", "\t")
6257                                .replace("\\\\", "\\")
6258                        }
6259                        'P' => {
6260                            // Prompt string expansion (simplified)
6261                            value.clone()
6262                        }
6263                        'A' => {
6264                            // Assignment statement form
6265                            format!("{}='{}'", name, value.replace('\'', "'\\''"))
6266                        }
6267                        'K' => {
6268                            // Display as key-value pairs (for assoc arrays, same as value for scalars)
6269                            value.clone()
6270                        }
6271                        'a' => {
6272                            // Attribute flags for the variable
6273                            let mut attrs = String::new();
6274                            if self.variables.contains_key(&format!("_READONLY_{}", name)) {
6275                                attrs.push('r');
6276                            }
6277                            if self.env.contains_key(name.as_str()) {
6278                                attrs.push('x');
6279                            }
6280                            attrs
6281                        }
6282                        'u' | 'U' => {
6283                            // Uppercase (u = first char, U = all)
6284                            if *operator == 'U' {
6285                                value.to_uppercase()
6286                            } else {
6287                                let mut chars = value.chars();
6288                                match chars.next() {
6289                                    Some(first) => {
6290                                        first.to_uppercase().collect::<String>() + chars.as_str()
6291                                    }
6292                                    None => String::new(),
6293                                }
6294                            }
6295                        }
6296                        'L' => {
6297                            // Lowercase all
6298                            value.to_lowercase()
6299                        }
6300                        _ => value.clone(),
6301                    };
6302                    result.push_str(&transformed);
6303                }
6304            }
6305            is_first_part = false;
6306        }
6307
6308        Ok(result)
6309    }
6310
6311    /// Expand a word to multiple fields (for array iteration and command args)
6312    /// Returns Vec<String> where array expansions like "${arr[@]}" produce multiple fields.
6313    /// "${arr[*]}" in quoted context joins elements into a single field (bash behavior).
6314    async fn expand_word_to_fields(&mut self, word: &Word) -> Result<Vec<String>> {
6315        // Check if the word contains only an array expansion or $@/$*
6316        if word.parts.len() == 1 {
6317            // Handle $@ and $* as special parameters
6318            if let WordPart::Variable(name) = &word.parts[0] {
6319                if name == "@" {
6320                    let positional = self
6321                        .call_stack
6322                        .last()
6323                        .map(|f| f.positional.clone())
6324                        .unwrap_or_default();
6325                    if word.quoted {
6326                        // "$@" preserves individual positional params
6327                        return Ok(positional);
6328                    }
6329                    // $@ unquoted: each param is subject to further IFS splitting
6330                    let mut fields = Vec::new();
6331                    for p in &positional {
6332                        fields.extend(self.ifs_split(p));
6333                    }
6334                    return Ok(fields);
6335                }
6336                if name == "*" {
6337                    let positional = self
6338                        .call_stack
6339                        .last()
6340                        .map(|f| f.positional.clone())
6341                        .unwrap_or_default();
6342                    if word.quoted {
6343                        // "$*" joins with first char of IFS.
6344                        // IFS unset → space; IFS="" → no separator.
6345                        let sep = match self.variables.get("IFS") {
6346                            Some(ifs) => ifs
6347                                .chars()
6348                                .next()
6349                                .map(|c| c.to_string())
6350                                .unwrap_or_default(),
6351                            None => " ".to_string(),
6352                        };
6353                        return Ok(vec![positional.join(&sep)]);
6354                    }
6355                    // $* unquoted: each param is subject to IFS splitting
6356                    let mut fields = Vec::new();
6357                    for p in &positional {
6358                        fields.extend(self.ifs_split(p));
6359                    }
6360                    return Ok(fields);
6361                }
6362            }
6363            if let WordPart::ArrayAccess { name, index } = &word.parts[0] {
6364                if index == "@" || index == "*" {
6365                    // Check assoc arrays first
6366                    if let Some(arr) = self.assoc_arrays.get(name) {
6367                        let mut keys: Vec<_> = arr.keys().cloned().collect();
6368                        keys.sort();
6369                        let values: Vec<String> =
6370                            keys.iter().filter_map(|k| arr.get(k).cloned()).collect();
6371                        if word.quoted && index == "*" {
6372                            return Ok(vec![values.join(" ")]);
6373                        }
6374                        return Ok(values);
6375                    }
6376                    if let Some(arr) = self.arrays.get(name) {
6377                        let mut indices: Vec<_> = arr.keys().collect();
6378                        indices.sort();
6379                        let values: Vec<String> =
6380                            indices.iter().filter_map(|i| arr.get(i).cloned()).collect();
6381                        // "${arr[*]}" joins into single field; "${arr[@]}" keeps separate
6382                        if word.quoted && index == "*" {
6383                            return Ok(vec![values.join(" ")]);
6384                        }
6385                        return Ok(values);
6386                    }
6387                    return Ok(Vec::new());
6388                }
6389            }
6390            // "${!arr[@]}" - array keys/indices as separate fields
6391            if let WordPart::ArrayIndices(name) = &word.parts[0] {
6392                if let Some(arr) = self.assoc_arrays.get(name) {
6393                    let mut keys: Vec<_> = arr.keys().cloned().collect();
6394                    keys.sort();
6395                    return Ok(keys);
6396                }
6397                if let Some(arr) = self.arrays.get(name) {
6398                    let mut indices: Vec<_> = arr.keys().collect();
6399                    indices.sort();
6400                    return Ok(indices.iter().map(|i| i.to_string()).collect());
6401                }
6402                return Ok(Vec::new());
6403            }
6404        }
6405
6406        // For other words, expand to a single field then apply IFS word splitting
6407        // when the word is unquoted and contains an expansion.
6408        // Per POSIX, unquoted variable/command/arithmetic expansion results undergo
6409        // field splitting on IFS.
6410        let expanded = self.expand_word(word).await?;
6411
6412        // IFS splitting applies to unquoted expansions only.
6413        // Skip splitting for assignment-like words (e.g., result="$1") where
6414        // the lexer stripped quotes from a mixed-quoted word (produces Token::Word
6415        // with quoted: false even though the expansion was inside double quotes).
6416        let is_assignment_word =
6417            matches!(word.parts.first(), Some(WordPart::Literal(s)) if s.contains('='));
6418        let has_expansion = !word.quoted
6419            && !is_assignment_word
6420            && word.parts.iter().any(|p| {
6421                matches!(
6422                    p,
6423                    WordPart::Variable(_)
6424                        | WordPart::CommandSubstitution(_)
6425                        | WordPart::ArithmeticExpansion(_)
6426                        | WordPart::ParameterExpansion { .. }
6427                        | WordPart::ArrayAccess { .. }
6428                )
6429            });
6430
6431        if has_expansion {
6432            Ok(self.ifs_split(&expanded))
6433        } else {
6434            Ok(vec![expanded])
6435        }
6436    }
6437
6438    /// Resolve name for parameter expansion, handling array subscripts and special params.
6439    /// Returns (is_set, expanded_value).
6440    fn resolve_param_expansion_name(&self, name: &str) -> (bool, String) {
6441        // Check for array subscript pattern: name[@] or name[*]
6442        if let Some(arr_name) = name
6443            .strip_suffix("[@]")
6444            .or_else(|| name.strip_suffix("[*]"))
6445        {
6446            if let Some(arr) = self.assoc_arrays.get(arr_name) {
6447                let is_set = !arr.is_empty();
6448                let mut keys: Vec<_> = arr.keys().collect();
6449                keys.sort();
6450                let values: Vec<String> =
6451                    keys.iter().filter_map(|k| arr.get(*k).cloned()).collect();
6452                return (is_set, values.join(" "));
6453            }
6454            if let Some(arr) = self.arrays.get(arr_name) {
6455                let is_set = !arr.is_empty();
6456                let mut indices: Vec<_> = arr.keys().collect();
6457                indices.sort();
6458                let values: Vec<_> = indices.iter().filter_map(|i| arr.get(i)).collect();
6459                return (
6460                    is_set,
6461                    values.into_iter().cloned().collect::<Vec<_>>().join(" "),
6462                );
6463            }
6464            return (false, String::new());
6465        }
6466
6467        // Special parameters @ and *
6468        if name == "@" || name == "*" {
6469            if let Some(frame) = self.call_stack.last() {
6470                let is_set = !frame.positional.is_empty();
6471                return (is_set, frame.positional.join(" "));
6472            }
6473            return (false, String::new());
6474        }
6475
6476        // Regular variable
6477        let is_set = self.is_variable_set(name);
6478        let value = self.expand_variable(name);
6479        (is_set, value)
6480    }
6481
6482    /// Split a string on IFS characters according to POSIX rules.
6483    ///
6484    /// - IFS whitespace (space, tab, newline) collapses; leading/trailing stripped.
6485    /// - IFS non-whitespace chars are significant delimiters. Two adjacent produce
6486    ///   an empty field between them.
6487    /// - `<ws><nws><ws>` = single delimiter (ws absorbed into the nws delimiter).
6488    /// - Empty IFS → no splitting. Unset IFS → default " \t\n".
6489    fn ifs_split(&self, s: &str) -> Vec<String> {
6490        let ifs = self
6491            .variables
6492            .get("IFS")
6493            .cloned()
6494            .unwrap_or_else(|| " \t\n".to_string());
6495
6496        if ifs.is_empty() {
6497            return vec![s.to_string()];
6498        }
6499
6500        let is_ifs = |c: char| ifs.contains(c);
6501        let is_ifs_ws = |c: char| ifs.contains(c) && " \t\n".contains(c);
6502        let is_ifs_nws = |c: char| ifs.contains(c) && !" \t\n".contains(c);
6503        let all_whitespace_ifs = ifs.chars().all(|c| " \t\n".contains(c));
6504
6505        if all_whitespace_ifs {
6506            // IFS is only whitespace: split on runs, elide empties
6507            return s
6508                .split(|c: char| is_ifs(c))
6509                .filter(|f| !f.is_empty())
6510                .map(|f| f.to_string())
6511                .collect();
6512        }
6513
6514        // Mixed or pure non-whitespace IFS.
6515        let mut fields: Vec<String> = Vec::new();
6516        let mut current = String::new();
6517        let chars: Vec<char> = s.chars().collect();
6518        let mut i = 0;
6519
6520        // Skip leading IFS whitespace
6521        while i < chars.len() && is_ifs_ws(chars[i]) {
6522            i += 1;
6523        }
6524        // Leading non-whitespace IFS produces an empty first field
6525        if i < chars.len() && is_ifs_nws(chars[i]) {
6526            fields.push(String::new());
6527            i += 1;
6528            while i < chars.len() && is_ifs_ws(chars[i]) {
6529                i += 1;
6530            }
6531        }
6532
6533        while i < chars.len() {
6534            let c = chars[i];
6535            if is_ifs_nws(c) {
6536                // Non-whitespace IFS delimiter: finalize current field
6537                fields.push(std::mem::take(&mut current));
6538                i += 1;
6539                // Consume trailing IFS whitespace
6540                while i < chars.len() && is_ifs_ws(chars[i]) {
6541                    i += 1;
6542                }
6543            } else if is_ifs_ws(c) {
6544                // IFS whitespace: skip it, then check for non-ws delimiter
6545                while i < chars.len() && is_ifs_ws(chars[i]) {
6546                    i += 1;
6547                }
6548                if i < chars.len() && is_ifs_nws(chars[i]) {
6549                    // <ws><nws> = single delimiter. Push current field.
6550                    fields.push(std::mem::take(&mut current));
6551                    i += 1; // consume the nws char
6552                    while i < chars.len() && is_ifs_ws(chars[i]) {
6553                        i += 1;
6554                    }
6555                } else if i < chars.len() {
6556                    // ws alone as delimiter (no nws follows)
6557                    fields.push(std::mem::take(&mut current));
6558                }
6559                // trailing ws at end → ignore (don't push empty field)
6560            } else {
6561                current.push(c);
6562                i += 1;
6563            }
6564        }
6565
6566        if !current.is_empty() {
6567            fields.push(current);
6568        }
6569
6570        fields
6571    }
6572
6573    /// Expand an operand string from a parameter expansion (sync, lazy).
6574    /// Only called when the operand is actually needed, providing lazy evaluation.
6575    fn expand_operand(&mut self, operand: &str) -> String {
6576        if operand.is_empty() {
6577            return String::new();
6578        }
6579        // THREAT[TM-DOS-050]: Propagate caller-configured limits to word parsing
6580        let word = Parser::parse_word_string_with_limits(
6581            operand,
6582            self.limits.max_ast_depth,
6583            self.limits.max_parser_operations,
6584        );
6585        let mut result = String::new();
6586        for part in &word.parts {
6587            match part {
6588                WordPart::Literal(s) => result.push_str(s),
6589                WordPart::Variable(name) => {
6590                    result.push_str(&self.expand_variable(name));
6591                }
6592                WordPart::ArithmeticExpansion(expr) => {
6593                    let val = self.evaluate_arithmetic_with_assign(expr);
6594                    result.push_str(&val.to_string());
6595                }
6596                WordPart::ParameterExpansion {
6597                    name,
6598                    operator,
6599                    operand: inner_operand,
6600                    colon_variant,
6601                } => {
6602                    let (is_set, value) = self.resolve_param_expansion_name(name);
6603                    let expanded = self.apply_parameter_op(
6604                        &value,
6605                        name,
6606                        operator,
6607                        inner_operand,
6608                        *colon_variant,
6609                        is_set,
6610                    );
6611                    result.push_str(&expanded);
6612                }
6613                WordPart::Length(name) => {
6614                    let value = self.expand_variable(name);
6615                    result.push_str(&value.len().to_string());
6616                }
6617                // TODO: handle CommandSubstitution etc. in sync operand expansion
6618                _ => {}
6619            }
6620        }
6621        result
6622    }
6623
6624    /// Apply parameter expansion operator.
6625    /// `colon_variant`: true = check unset-or-empty, false = check unset-only.
6626    /// `is_set`: whether the variable is defined (distinct from being empty).
6627    fn apply_parameter_op(
6628        &mut self,
6629        value: &str,
6630        name: &str,
6631        operator: &ParameterOp,
6632        operand: &str,
6633        colon_variant: bool,
6634        is_set: bool,
6635    ) -> String {
6636        // colon (:-) => trigger when unset OR empty
6637        // no-colon (-) => trigger only when unset
6638        let use_default = if colon_variant {
6639            !is_set || value.is_empty()
6640        } else {
6641            !is_set
6642        };
6643        let use_replacement = if colon_variant {
6644            is_set && !value.is_empty()
6645        } else {
6646            is_set
6647        };
6648
6649        match operator {
6650            ParameterOp::UseDefault => {
6651                if use_default {
6652                    self.expand_operand(operand)
6653                } else {
6654                    value.to_string()
6655                }
6656            }
6657            ParameterOp::AssignDefault => {
6658                if use_default {
6659                    let expanded = self.expand_operand(operand);
6660                    self.set_variable(name.to_string(), expanded.clone());
6661                    expanded
6662                } else {
6663                    value.to_string()
6664                }
6665            }
6666            ParameterOp::UseReplacement => {
6667                if use_replacement {
6668                    self.expand_operand(operand)
6669                } else {
6670                    String::new()
6671                }
6672            }
6673            ParameterOp::Error => {
6674                if use_default {
6675                    let expanded = self.expand_operand(operand);
6676                    let msg = if expanded.is_empty() {
6677                        format!("bash: {}: parameter null or not set\n", name)
6678                    } else {
6679                        format!("bash: {}: {}\n", name, expanded)
6680                    };
6681                    self.nounset_error = Some(msg);
6682                    String::new()
6683                } else {
6684                    value.to_string()
6685                }
6686            }
6687            ParameterOp::RemovePrefixShort => {
6688                // ${var#pattern} - remove shortest prefix match
6689                self.remove_pattern(value, operand, true, false)
6690            }
6691            ParameterOp::RemovePrefixLong => {
6692                // ${var##pattern} - remove longest prefix match
6693                self.remove_pattern(value, operand, true, true)
6694            }
6695            ParameterOp::RemoveSuffixShort => {
6696                // ${var%pattern} - remove shortest suffix match
6697                self.remove_pattern(value, operand, false, false)
6698            }
6699            ParameterOp::RemoveSuffixLong => {
6700                // ${var%%pattern} - remove longest suffix match
6701                self.remove_pattern(value, operand, false, true)
6702            }
6703            ParameterOp::ReplaceFirst {
6704                pattern,
6705                replacement,
6706            } => {
6707                // ${var/pattern/replacement} - replace first occurrence
6708                self.replace_pattern(value, pattern, replacement, false)
6709            }
6710            ParameterOp::ReplaceAll {
6711                pattern,
6712                replacement,
6713            } => {
6714                // ${var//pattern/replacement} - replace all occurrences
6715                self.replace_pattern(value, pattern, replacement, true)
6716            }
6717            ParameterOp::UpperFirst => {
6718                // ${var^} - uppercase first character
6719                let mut chars = value.chars();
6720                match chars.next() {
6721                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6722                    None => String::new(),
6723                }
6724            }
6725            ParameterOp::UpperAll => {
6726                // ${var^^} - uppercase all characters
6727                value.to_uppercase()
6728            }
6729            ParameterOp::LowerFirst => {
6730                // ${var,} - lowercase first character
6731                let mut chars = value.chars();
6732                match chars.next() {
6733                    Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
6734                    None => String::new(),
6735                }
6736            }
6737            ParameterOp::LowerAll => {
6738                // ${var,,} - lowercase all characters
6739                value.to_lowercase()
6740            }
6741        }
6742    }
6743
6744    /// Replace pattern in value
6745    fn replace_pattern(
6746        &self,
6747        value: &str,
6748        pattern: &str,
6749        replacement: &str,
6750        global: bool,
6751    ) -> String {
6752        if pattern.is_empty() {
6753            return value.to_string();
6754        }
6755
6756        // Handle # prefix anchor (match at start only)
6757        if let Some(rest) = pattern.strip_prefix('#') {
6758            if rest.is_empty() {
6759                return value.to_string();
6760            }
6761            if let Some(stripped) = value.strip_prefix(rest) {
6762                return format!("{}{}", replacement, stripped);
6763            }
6764            // Try glob match at prefix
6765            if rest.contains('*') {
6766                let matched = self.remove_pattern(value, rest, true, false);
6767                if matched != value {
6768                    let prefix_len = value.len() - matched.len();
6769                    return format!("{}{}", replacement, &value[prefix_len..]);
6770                }
6771            }
6772            return value.to_string();
6773        }
6774
6775        // Handle % suffix anchor (match at end only)
6776        if let Some(rest) = pattern.strip_prefix('%') {
6777            if rest.is_empty() {
6778                return value.to_string();
6779            }
6780            if let Some(stripped) = value.strip_suffix(rest) {
6781                return format!("{}{}", stripped, replacement);
6782            }
6783            // Try glob match at suffix
6784            if rest.contains('*') {
6785                let matched = self.remove_pattern(value, rest, false, false);
6786                if matched != value {
6787                    return format!("{}{}", matched, replacement);
6788                }
6789            }
6790            return value.to_string();
6791        }
6792
6793        // Handle glob pattern with *
6794        if pattern.contains('*') {
6795            // Convert glob to regex-like behavior
6796            // For simplicity, we'll handle basic cases: prefix*, *suffix, *middle*
6797            if pattern == "*" {
6798                // Replace everything
6799                return replacement.to_string();
6800            }
6801
6802            if let Some(star_pos) = pattern.find('*') {
6803                let prefix = &pattern[..star_pos];
6804                let suffix = &pattern[star_pos + 1..];
6805
6806                if prefix.is_empty() && !suffix.is_empty() {
6807                    // *suffix - match anything ending with suffix
6808                    if let Some(pos) = value.find(suffix) {
6809                        let after = &value[pos + suffix.len()..];
6810                        if global {
6811                            return replacement.to_string()
6812                                + &self.replace_pattern(after, pattern, replacement, true);
6813                        } else {
6814                            return replacement.to_string() + after;
6815                        }
6816                    }
6817                } else if !prefix.is_empty() && suffix.is_empty() {
6818                    // prefix* - match prefix and anything after
6819                    if value.starts_with(prefix) {
6820                        return replacement.to_string();
6821                    }
6822                }
6823            }
6824            // If we can't match the glob pattern, return as-is
6825            return value.to_string();
6826        }
6827
6828        // Simple string replacement
6829        if global {
6830            value.replace(pattern, replacement)
6831        } else {
6832            value.replacen(pattern, replacement, 1)
6833        }
6834    }
6835
6836    /// Remove prefix/suffix pattern from value
6837    fn remove_pattern(&self, value: &str, pattern: &str, prefix: bool, longest: bool) -> String {
6838        // Simple pattern matching with * glob
6839        if pattern.is_empty() {
6840            return value.to_string();
6841        }
6842
6843        if prefix {
6844            // Remove from beginning
6845            if pattern == "*" {
6846                if longest {
6847                    return String::new();
6848                } else if !value.is_empty() {
6849                    return value.chars().skip(1).collect();
6850                } else {
6851                    return value.to_string();
6852                }
6853            }
6854
6855            // Check if pattern contains *
6856            if let Some(star_pos) = pattern.find('*') {
6857                let prefix_part = &pattern[..star_pos];
6858                let suffix_part = &pattern[star_pos + 1..];
6859
6860                if prefix_part.is_empty() {
6861                    // Pattern is "*suffix" - find suffix and remove everything before it
6862                    if longest {
6863                        // Find last occurrence of suffix
6864                        if let Some(pos) = value.rfind(suffix_part) {
6865                            return value[pos + suffix_part.len()..].to_string();
6866                        }
6867                    } else {
6868                        // Find first occurrence of suffix
6869                        if let Some(pos) = value.find(suffix_part) {
6870                            return value[pos + suffix_part.len()..].to_string();
6871                        }
6872                    }
6873                } else if suffix_part.is_empty() {
6874                    // Pattern is "prefix*" - match prefix and any chars after
6875                    if let Some(rest) = value.strip_prefix(prefix_part) {
6876                        if longest {
6877                            return String::new();
6878                        } else {
6879                            return rest.to_string();
6880                        }
6881                    }
6882                } else {
6883                    // Pattern is "prefix*suffix" - more complex matching
6884                    if let Some(rest) = value.strip_prefix(prefix_part) {
6885                        if longest {
6886                            if let Some(pos) = rest.rfind(suffix_part) {
6887                                return rest[pos + suffix_part.len()..].to_string();
6888                            }
6889                        } else if let Some(pos) = rest.find(suffix_part) {
6890                            return rest[pos + suffix_part.len()..].to_string();
6891                        }
6892                    }
6893                }
6894            } else if let Some(rest) = value.strip_prefix(pattern) {
6895                return rest.to_string();
6896            }
6897        } else {
6898            // Remove from end (suffix)
6899            if pattern == "*" {
6900                if longest {
6901                    return String::new();
6902                } else if !value.is_empty() {
6903                    let mut s = value.to_string();
6904                    s.pop();
6905                    return s;
6906                } else {
6907                    return value.to_string();
6908                }
6909            }
6910
6911            // Check if pattern contains *
6912            if let Some(star_pos) = pattern.find('*') {
6913                let prefix_part = &pattern[..star_pos];
6914                let suffix_part = &pattern[star_pos + 1..];
6915
6916                if suffix_part.is_empty() {
6917                    // Pattern is "prefix*" - find prefix and remove from there to end
6918                    if longest {
6919                        // Find first occurrence of prefix
6920                        if let Some(pos) = value.find(prefix_part) {
6921                            return value[..pos].to_string();
6922                        }
6923                    } else {
6924                        // Find last occurrence of prefix
6925                        if let Some(pos) = value.rfind(prefix_part) {
6926                            return value[..pos].to_string();
6927                        }
6928                    }
6929                } else if prefix_part.is_empty() {
6930                    // Pattern is "*suffix" - match any chars before suffix
6931                    if let Some(before) = value.strip_suffix(suffix_part) {
6932                        if longest {
6933                            return String::new();
6934                        } else {
6935                            return before.to_string();
6936                        }
6937                    }
6938                } else {
6939                    // Pattern is "prefix*suffix" - more complex matching
6940                    if let Some(before_suffix) = value.strip_suffix(suffix_part) {
6941                        if longest {
6942                            if let Some(pos) = before_suffix.find(prefix_part) {
6943                                return value[..pos].to_string();
6944                            }
6945                        } else if let Some(pos) = before_suffix.rfind(prefix_part) {
6946                            return value[..pos].to_string();
6947                        }
6948                    }
6949                }
6950            } else if let Some(before) = value.strip_suffix(pattern) {
6951                return before.to_string();
6952            }
6953        }
6954
6955        value.to_string()
6956    }
6957
6958    /// Maximum recursion depth for arithmetic expression evaluation.
6959    /// THREAT[TM-DOS-026]: Prevents stack overflow via deeply nested arithmetic like
6960    /// $(((((((...)))))))
6961    const MAX_ARITHMETIC_DEPTH: usize = 50;
6962
6963    /// Evaluate arithmetic with assignment support (e.g. `X = X + 1`).
6964    /// Assignment must be handled before variable expansion so the LHS
6965    /// variable name is preserved.
6966    /// Check if a string is a valid shell variable name
6967    fn is_valid_var_name(s: &str) -> bool {
6968        !s.is_empty()
6969            && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
6970            && !s.chars().next().unwrap_or('0').is_ascii_digit()
6971    }
6972
6973    fn evaluate_arithmetic_with_assign(&mut self, expr: &str) -> i64 {
6974        let expr = expr.trim();
6975
6976        // Handle comma operator (lowest precedence): evaluate all, return last
6977        // But not inside parentheses
6978        {
6979            let mut depth = 0i32;
6980            let chars: Vec<char> = expr.chars().collect();
6981            for i in (0..chars.len()).rev() {
6982                match chars[i] {
6983                    '(' => depth += 1,
6984                    ')' => depth -= 1,
6985                    ',' if depth == 0 => {
6986                        let left = &expr[..i];
6987                        let right = &expr[i + 1..];
6988                        self.evaluate_arithmetic_with_assign(left);
6989                        return self.evaluate_arithmetic_with_assign(right);
6990                    }
6991                    _ => {}
6992                }
6993            }
6994        }
6995
6996        // Handle pre-increment/pre-decrement: ++var, --var
6997        if let Some(var_name) = expr.strip_prefix("++") {
6998            let var_name = var_name.trim();
6999            if Self::is_valid_var_name(var_name) {
7000                let val = self.expand_variable(var_name).parse::<i64>().unwrap_or(0) + 1;
7001                self.set_variable(var_name.to_string(), val.to_string());
7002                return val;
7003            }
7004        }
7005        if let Some(var_name) = expr.strip_prefix("--") {
7006            let var_name = var_name.trim();
7007            if Self::is_valid_var_name(var_name) {
7008                let val = self.expand_variable(var_name).parse::<i64>().unwrap_or(0) - 1;
7009                self.set_variable(var_name.to_string(), val.to_string());
7010                return val;
7011            }
7012        }
7013
7014        // Handle post-increment/post-decrement: var++, var--
7015        if let Some(var_name) = expr.strip_suffix("++") {
7016            let var_name = var_name.trim();
7017            if Self::is_valid_var_name(var_name) {
7018                let old_val = self.expand_variable(var_name).parse::<i64>().unwrap_or(0);
7019                self.set_variable(var_name.to_string(), (old_val + 1).to_string());
7020                return old_val;
7021            }
7022        }
7023        if let Some(var_name) = expr.strip_suffix("--") {
7024            let var_name = var_name.trim();
7025            if Self::is_valid_var_name(var_name) {
7026                let old_val = self.expand_variable(var_name).parse::<i64>().unwrap_or(0);
7027                self.set_variable(var_name.to_string(), (old_val - 1).to_string());
7028                return old_val;
7029            }
7030        }
7031
7032        // Check for compound assignments: +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=
7033        // and simple assignment: VAR = expr (but not == comparison)
7034        if let Some(eq_pos) = expr.find('=') {
7035            let before = &expr[..eq_pos];
7036            let after_char = expr.as_bytes().get(eq_pos + 1);
7037            // Not == or !=
7038            if !before.ends_with('!') && after_char != Some(&b'=') {
7039                // Detect compound operator: check multi-char ops first
7040                let (var_name, op) = if let Some(s) = before.strip_suffix("<<") {
7041                    (s.trim(), "<<")
7042                } else if let Some(s) = before.strip_suffix(">>") {
7043                    (s.trim(), ">>")
7044                } else if let Some(s) = before.strip_suffix('+') {
7045                    (s.trim(), "+")
7046                } else if let Some(s) = before.strip_suffix('-') {
7047                    (s.trim(), "-")
7048                } else if let Some(s) = before.strip_suffix('*') {
7049                    (s.trim(), "*")
7050                } else if let Some(s) = before.strip_suffix('/') {
7051                    (s.trim(), "/")
7052                } else if let Some(s) = before.strip_suffix('%') {
7053                    (s.trim(), "%")
7054                } else if let Some(s) = before.strip_suffix('&') {
7055                    (s.trim(), "&")
7056                } else if let Some(s) = before.strip_suffix('|') {
7057                    (s.trim(), "|")
7058                } else if let Some(s) = before.strip_suffix('^') {
7059                    (s.trim(), "^")
7060                } else if !before.ends_with('<') && !before.ends_with('>') {
7061                    (before.trim(), "")
7062                } else {
7063                    ("", "")
7064                };
7065
7066                if Self::is_valid_var_name(var_name) {
7067                    let rhs = &expr[eq_pos + 1..];
7068                    let rhs_val = self.evaluate_arithmetic(rhs);
7069                    let value = if op.is_empty() {
7070                        rhs_val
7071                    } else {
7072                        let lhs_val = self.expand_variable(var_name).parse::<i64>().unwrap_or(0);
7073                        // THREAT[TM-DOS-043]: wrapping to prevent overflow panic
7074                        match op {
7075                            "+" => lhs_val.wrapping_add(rhs_val),
7076                            "-" => lhs_val.wrapping_sub(rhs_val),
7077                            "*" => lhs_val.wrapping_mul(rhs_val),
7078                            "/" => {
7079                                if rhs_val != 0 && !(lhs_val == i64::MIN && rhs_val == -1) {
7080                                    lhs_val / rhs_val
7081                                } else {
7082                                    0
7083                                }
7084                            }
7085                            "%" => {
7086                                if rhs_val != 0 && !(lhs_val == i64::MIN && rhs_val == -1) {
7087                                    lhs_val % rhs_val
7088                                } else {
7089                                    0
7090                                }
7091                            }
7092                            "&" => lhs_val & rhs_val,
7093                            "|" => lhs_val | rhs_val,
7094                            "^" => lhs_val ^ rhs_val,
7095                            "<<" => lhs_val.wrapping_shl((rhs_val & 63) as u32),
7096                            ">>" => lhs_val.wrapping_shr((rhs_val & 63) as u32),
7097                            _ => rhs_val,
7098                        }
7099                    };
7100                    self.set_variable(var_name.to_string(), value.to_string());
7101                    return value;
7102                }
7103            }
7104        }
7105
7106        self.evaluate_arithmetic(expr)
7107    }
7108
7109    /// Evaluate a simple arithmetic expression
7110    fn evaluate_arithmetic(&self, expr: &str) -> i64 {
7111        // Simple arithmetic evaluation - handles basic operations
7112        let expr = expr.trim();
7113
7114        // First expand any variables in the expression
7115        let expanded = self.expand_arithmetic_vars(expr);
7116
7117        // Parse and evaluate with depth tracking (TM-DOS-026)
7118        self.parse_arithmetic_impl(&expanded, 0)
7119    }
7120
7121    /// Recursively resolve a variable value in arithmetic context.
7122    /// In bash arithmetic, bare variable names are recursively evaluated:
7123    /// if b=a and a=3, then $((b)) evaluates b -> "a" -> 3.
7124    /// If x='1 + 2', then $((x)) evaluates x -> "1 + 2" -> 3 (as sub-expression).
7125    /// THREAT[TM-DOS-026]: `depth` prevents infinite recursion.
7126    fn resolve_arith_var(&self, value: &str, depth: usize) -> String {
7127        if depth >= Self::MAX_ARITHMETIC_DEPTH {
7128            return "0".to_string();
7129        }
7130        let trimmed = value.trim();
7131        if trimmed.is_empty() {
7132            return "0".to_string();
7133        }
7134        // If value is a simple integer, return it directly
7135        if trimmed.parse::<i64>().is_ok() {
7136            return trimmed.to_string();
7137        }
7138        // If value looks like a variable name, recursively dereference
7139        if Self::is_valid_var_name(trimmed) {
7140            let inner = self.expand_variable(trimmed);
7141            return self.resolve_arith_var(&inner, depth + 1);
7142        }
7143        // Value contains an expression (e.g. "1 + 2") — expand vars in it
7144        // and wrap in parens to preserve grouping
7145        let expanded = self.expand_arithmetic_vars_depth(trimmed, depth + 1);
7146        format!("({})", expanded)
7147    }
7148
7149    /// Expand variables in arithmetic expression (no $ needed in $((...)))
7150    fn expand_arithmetic_vars(&self, expr: &str) -> String {
7151        self.expand_arithmetic_vars_depth(expr, 0)
7152    }
7153
7154    /// Inner implementation with depth tracking for recursive expansion.
7155    /// THREAT[TM-DOS-026]: `depth` prevents stack overflow via recursive variable values.
7156    fn expand_arithmetic_vars_depth(&self, expr: &str, depth: usize) -> String {
7157        if depth >= Self::MAX_ARITHMETIC_DEPTH {
7158            return "0".to_string();
7159        }
7160
7161        // Strip double quotes — "$x" in arithmetic is the same as $x
7162        let expr = expr.replace('"', "");
7163
7164        let mut result = String::new();
7165        let mut chars = expr.chars().peekable();
7166        // Track whether we're in a numeric literal context (after # or 0x)
7167        let mut in_numeric_literal = false;
7168
7169        while let Some(ch) = chars.next() {
7170            if ch == '$' {
7171                in_numeric_literal = false;
7172                // Handle $var syntax (common in arithmetic)
7173                let mut name = String::new();
7174                while let Some(&c) = chars.peek() {
7175                    if c.is_ascii_alphanumeric() || c == '_' {
7176                        name.push(chars.next().unwrap());
7177                    } else {
7178                        break;
7179                    }
7180                }
7181                if !name.is_empty() {
7182                    // $var is direct text substitution — no recursive arithmetic eval.
7183                    // Only bare names (without $) get recursive resolution.
7184                    let value = self.expand_variable(&name);
7185                    if value.is_empty() {
7186                        result.push('0');
7187                    } else {
7188                        result.push_str(&value);
7189                    }
7190                } else {
7191                    result.push(ch);
7192                }
7193            } else if ch == '#' {
7194                // base#value syntax: digits before # are base, chars after are literal digits
7195                result.push(ch);
7196                in_numeric_literal = true;
7197            } else if in_numeric_literal && (ch.is_ascii_alphanumeric() || ch == '_') {
7198                // Part of a base#value literal — don't expand as variable
7199                result.push(ch);
7200            } else if ch.is_ascii_digit() {
7201                result.push(ch);
7202                // Check for 0x/0X hex prefix
7203                if ch == '0' {
7204                    if let Some(&next) = chars.peek() {
7205                        if next == 'x' || next == 'X' {
7206                            result.push(chars.next().unwrap());
7207                            in_numeric_literal = true;
7208                        }
7209                    }
7210                }
7211            } else if ch.is_ascii_alphabetic() || ch == '_' {
7212                in_numeric_literal = false;
7213                // Could be a variable name
7214                let mut name = String::new();
7215                name.push(ch);
7216                while let Some(&c) = chars.peek() {
7217                    if c.is_ascii_alphanumeric() || c == '_' {
7218                        name.push(chars.next().unwrap());
7219                    } else {
7220                        break;
7221                    }
7222                }
7223                // Check for array access: name[expr]
7224                if chars.peek() == Some(&'[') {
7225                    chars.next(); // consume '['
7226                    let mut index_expr = String::new();
7227                    let mut bracket_depth = 1;
7228                    while let Some(&c) = chars.peek() {
7229                        chars.next();
7230                        if c == '[' {
7231                            bracket_depth += 1;
7232                            index_expr.push(c);
7233                        } else if c == ']' {
7234                            bracket_depth -= 1;
7235                            if bracket_depth == 0 {
7236                                break;
7237                            }
7238                            index_expr.push(c);
7239                        } else {
7240                            index_expr.push(c);
7241                        }
7242                    }
7243                    // Evaluate the index expression as arithmetic
7244                    let idx = self.evaluate_arithmetic(&index_expr);
7245                    // Look up array element
7246                    if let Some(arr) = self.arrays.get(&name) {
7247                        let idx_usize: usize = idx.try_into().unwrap_or(0);
7248                        let value = arr.get(&idx_usize).cloned().unwrap_or_default();
7249                        result.push_str(&self.resolve_arith_var(&value, depth));
7250                    } else {
7251                        // Not an array — treat as scalar (index 0 returns the var value)
7252                        let value = self.expand_variable(&name);
7253                        if idx == 0 {
7254                            result.push_str(&self.resolve_arith_var(&value, depth));
7255                        } else {
7256                            result.push('0');
7257                        }
7258                    }
7259                } else {
7260                    // Expand the variable with recursive arithmetic resolution
7261                    let value = self.expand_variable(&name);
7262                    result.push_str(&self.resolve_arith_var(&value, depth));
7263                }
7264            } else {
7265                in_numeric_literal = false;
7266                result.push(ch);
7267            }
7268        }
7269
7270        result
7271    }
7272
7273    /// Parse and evaluate a simple arithmetic expression with depth tracking.
7274    /// THREAT[TM-DOS-026]: `arith_depth` prevents stack overflow from deeply nested expressions.
7275    fn parse_arithmetic_impl(&self, expr: &str, arith_depth: usize) -> i64 {
7276        let expr = expr.trim();
7277
7278        if expr.is_empty() {
7279            return 0;
7280        }
7281
7282        // Non-ASCII chars can't be valid arithmetic; bail to avoid byte/char index mismatch
7283        if !expr.is_ascii() {
7284            return 0;
7285        }
7286
7287        // THREAT[TM-DOS-026]: Bail out if arithmetic nesting is too deep
7288        if arith_depth >= Self::MAX_ARITHMETIC_DEPTH {
7289            return 0;
7290        }
7291
7292        // Handle parentheses
7293        if expr.starts_with('(') && expr.ends_with(')') {
7294            // Check if parentheses are balanced
7295            let mut depth = 0;
7296            let mut balanced = true;
7297            for (i, ch) in expr.chars().enumerate() {
7298                match ch {
7299                    '(' => depth += 1,
7300                    ')' => {
7301                        depth -= 1;
7302                        if depth == 0 && i < expr.len() - 1 {
7303                            balanced = false;
7304                            break;
7305                        }
7306                    }
7307                    _ => {}
7308                }
7309            }
7310            if balanced && depth == 0 {
7311                return self.parse_arithmetic_impl(&expr[1..expr.len() - 1], arith_depth + 1);
7312            }
7313        }
7314
7315        let chars: Vec<char> = expr.chars().collect();
7316
7317        // Ternary operator (lowest precedence)
7318        let mut depth = 0;
7319        for i in 0..chars.len() {
7320            match chars[i] {
7321                '(' => depth += 1,
7322                ')' => depth -= 1,
7323                '?' if depth == 0 => {
7324                    // Find matching :
7325                    let mut colon_depth = 0;
7326                    for j in (i + 1)..chars.len() {
7327                        match chars[j] {
7328                            '(' => colon_depth += 1,
7329                            ')' => colon_depth -= 1,
7330                            '?' => colon_depth += 1,
7331                            ':' if colon_depth == 0 => {
7332                                let cond = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7333                                let then_val =
7334                                    self.parse_arithmetic_impl(&expr[i + 1..j], arith_depth + 1);
7335                                let else_val =
7336                                    self.parse_arithmetic_impl(&expr[j + 1..], arith_depth + 1);
7337                                return if cond != 0 { then_val } else { else_val };
7338                            }
7339                            ':' => colon_depth -= 1,
7340                            _ => {}
7341                        }
7342                    }
7343                }
7344                _ => {}
7345            }
7346        }
7347
7348        // Logical OR (||)
7349        depth = 0;
7350        for i in (0..chars.len()).rev() {
7351            match chars[i] {
7352                '(' => depth += 1,
7353                ')' => depth -= 1,
7354                '|' if depth == 0 && i > 0 && chars[i - 1] == '|' => {
7355                    let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1);
7356                    // Short-circuit: if left is true, don't evaluate right
7357                    if left != 0 {
7358                        return 1;
7359                    }
7360                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7361                    return if right != 0 { 1 } else { 0 };
7362                }
7363                _ => {}
7364            }
7365        }
7366
7367        // Logical AND (&&)
7368        depth = 0;
7369        for i in (0..chars.len()).rev() {
7370            match chars[i] {
7371                '(' => depth += 1,
7372                ')' => depth -= 1,
7373                '&' if depth == 0 && i > 0 && chars[i - 1] == '&' => {
7374                    let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1);
7375                    // Short-circuit: if left is false, don't evaluate right
7376                    if left == 0 {
7377                        return 0;
7378                    }
7379                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7380                    return if right != 0 { 1 } else { 0 };
7381                }
7382                _ => {}
7383            }
7384        }
7385
7386        // Bitwise OR (|) - but not ||
7387        depth = 0;
7388        for i in (0..chars.len()).rev() {
7389            match chars[i] {
7390                '(' => depth += 1,
7391                ')' => depth -= 1,
7392                '|' if depth == 0
7393                    && (i == 0 || chars[i - 1] != '|')
7394                    && (i + 1 >= chars.len() || chars[i + 1] != '|') =>
7395                {
7396                    let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7397                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7398                    return left | right;
7399                }
7400                _ => {}
7401            }
7402        }
7403
7404        // Bitwise XOR (^)
7405        depth = 0;
7406        for i in (0..chars.len()).rev() {
7407            match chars[i] {
7408                '(' => depth += 1,
7409                ')' => depth -= 1,
7410                '^' if depth == 0 => {
7411                    let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7412                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7413                    return left ^ right;
7414                }
7415                _ => {}
7416            }
7417        }
7418
7419        // Bitwise AND (&) - but not &&
7420        depth = 0;
7421        for i in (0..chars.len()).rev() {
7422            match chars[i] {
7423                '(' => depth += 1,
7424                ')' => depth -= 1,
7425                '&' if depth == 0
7426                    && (i == 0 || chars[i - 1] != '&')
7427                    && (i + 1 >= chars.len() || chars[i + 1] != '&') =>
7428                {
7429                    let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7430                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7431                    return left & right;
7432                }
7433                _ => {}
7434            }
7435        }
7436
7437        // Equality operators (==, !=)
7438        depth = 0;
7439        for i in (0..chars.len()).rev() {
7440            match chars[i] {
7441                '(' => depth += 1,
7442                ')' => depth -= 1,
7443                '=' if depth == 0 && i > 0 && chars[i - 1] == '=' => {
7444                    let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1);
7445                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7446                    return if left == right { 1 } else { 0 };
7447                }
7448                '=' if depth == 0 && i > 0 && chars[i - 1] == '!' => {
7449                    let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1);
7450                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7451                    return if left != right { 1 } else { 0 };
7452                }
7453                _ => {}
7454            }
7455        }
7456
7457        // Relational operators (<, >, <=, >=)
7458        depth = 0;
7459        for i in (0..chars.len()).rev() {
7460            match chars[i] {
7461                '(' => depth += 1,
7462                ')' => depth -= 1,
7463                '=' if depth == 0 && i > 0 && chars[i - 1] == '<' => {
7464                    let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1);
7465                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7466                    return if left <= right { 1 } else { 0 };
7467                }
7468                '=' if depth == 0 && i > 0 && chars[i - 1] == '>' => {
7469                    let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1);
7470                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7471                    return if left >= right { 1 } else { 0 };
7472                }
7473                '<' if depth == 0
7474                    && (i + 1 >= chars.len() || (chars[i + 1] != '=' && chars[i + 1] != '<'))
7475                    && (i == 0 || chars[i - 1] != '<') =>
7476                {
7477                    let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7478                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7479                    return if left < right { 1 } else { 0 };
7480                }
7481                '>' if depth == 0
7482                    && (i + 1 >= chars.len() || (chars[i + 1] != '=' && chars[i + 1] != '>'))
7483                    && (i == 0 || chars[i - 1] != '>') =>
7484                {
7485                    let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7486                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7487                    return if left > right { 1 } else { 0 };
7488                }
7489                _ => {}
7490            }
7491        }
7492
7493        // Bitwise shift (<< >>) - but not <<= or heredoc contexts
7494        depth = 0;
7495        for i in (0..chars.len()).rev() {
7496            match chars[i] {
7497                '(' => depth += 1,
7498                ')' => depth -= 1,
7499                '<' if depth == 0
7500                    && i > 0
7501                    && chars[i - 1] == '<'
7502                    && (i < 2 || chars[i - 2] != '<')
7503                    && (i + 1 >= chars.len() || chars[i + 1] != '=') =>
7504                {
7505                    let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1);
7506                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7507                    // THREAT[TM-DOS-029]: clamp shift to 0..=63 to prevent panic
7508                    let shift = right.clamp(0, 63) as u32;
7509                    return left.wrapping_shl(shift);
7510                }
7511                '>' if depth == 0
7512                    && i > 0
7513                    && chars[i - 1] == '>'
7514                    && (i < 2 || chars[i - 2] != '>')
7515                    && (i + 1 >= chars.len() || chars[i + 1] != '=') =>
7516                {
7517                    let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1);
7518                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7519                    // THREAT[TM-DOS-029]: clamp shift to 0..=63 to prevent panic
7520                    let shift = right.clamp(0, 63) as u32;
7521                    return left.wrapping_shr(shift);
7522                }
7523                _ => {}
7524            }
7525        }
7526
7527        // Addition/Subtraction
7528        depth = 0;
7529        for i in (0..chars.len()).rev() {
7530            match chars[i] {
7531                '(' => depth += 1,
7532                ')' => depth -= 1,
7533                '+' | '-' if depth == 0 && i > 0 => {
7534                    // Skip ++/-- (handled elsewhere as increment/decrement)
7535                    if chars[i] == '+' && i + 1 < chars.len() && chars[i + 1] == '+' {
7536                        continue;
7537                    }
7538                    if chars[i] == '+' && i > 0 && chars[i - 1] == '+' {
7539                        continue;
7540                    }
7541                    if chars[i] == '-' && i + 1 < chars.len() && chars[i + 1] == '-' {
7542                        continue;
7543                    }
7544                    if chars[i] == '-' && i > 0 && chars[i - 1] == '-' {
7545                        continue;
7546                    }
7547                    let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7548                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7549                    // THREAT[TM-DOS-029]: wrapping to prevent overflow panic
7550                    return if chars[i] == '+' {
7551                        left.wrapping_add(right)
7552                    } else {
7553                        left.wrapping_sub(right)
7554                    };
7555                }
7556                _ => {}
7557            }
7558        }
7559
7560        // Multiplication/Division/Modulo (higher precedence, skip ** which is power)
7561        depth = 0;
7562        for i in (0..chars.len()).rev() {
7563            match chars[i] {
7564                '(' => depth += 1,
7565                ')' => depth -= 1,
7566                '*' if depth == 0 => {
7567                    // Skip ** (power operator handled below)
7568                    if i + 1 < chars.len() && chars[i + 1] == '*' {
7569                        continue;
7570                    }
7571                    if i > 0 && chars[i - 1] == '*' {
7572                        continue;
7573                    }
7574                    let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7575                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7576                    // THREAT[TM-DOS-029]: wrapping to prevent overflow panic
7577                    return left.wrapping_mul(right);
7578                }
7579                '/' | '%' if depth == 0 => {
7580                    let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7581                    let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1);
7582                    // THREAT[TM-DOS-029]: wrapping to prevent i64::MIN / -1 panic
7583                    return match chars[i] {
7584                        '/' => {
7585                            if right != 0 {
7586                                left.wrapping_div(right)
7587                            } else {
7588                                0
7589                            }
7590                        }
7591                        '%' => {
7592                            if right != 0 {
7593                                left.wrapping_rem(right)
7594                            } else {
7595                                0
7596                            }
7597                        }
7598                        _ => 0,
7599                    };
7600                }
7601                _ => {}
7602            }
7603        }
7604
7605        // Exponentiation ** (right-associative, higher precedence than */%)
7606        depth = 0;
7607        for i in 0..chars.len() {
7608            match chars[i] {
7609                '(' => depth += 1,
7610                ')' => depth -= 1,
7611                '*' if depth == 0 && i + 1 < chars.len() && chars[i + 1] == '*' => {
7612                    let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1);
7613                    // Right-associative: parse from i+2 onward (may contain more **)
7614                    let right = self.parse_arithmetic_impl(&expr[i + 2..], arith_depth + 1);
7615                    // THREAT[TM-DOS-029]: clamp exponent to 0..=63 to prevent panic/hang
7616                    let exp = right.clamp(0, 63) as u32;
7617                    return left.wrapping_pow(exp);
7618                }
7619                _ => {}
7620            }
7621        }
7622
7623        // Unary negation and bitwise NOT
7624        if let Some(rest) = expr.strip_prefix('-') {
7625            let rest = rest.trim();
7626            if !rest.is_empty() {
7627                // THREAT[TM-DOS-029]: wrapping to prevent i64::MIN negation panic
7628                return self
7629                    .parse_arithmetic_impl(rest, arith_depth + 1)
7630                    .wrapping_neg();
7631            }
7632        }
7633        if let Some(rest) = expr.strip_prefix('~') {
7634            let rest = rest.trim();
7635            if !rest.is_empty() {
7636                return !self.parse_arithmetic_impl(rest, arith_depth + 1);
7637            }
7638        }
7639        if let Some(rest) = expr.strip_prefix('!') {
7640            let rest = rest.trim();
7641            if !rest.is_empty() {
7642                let val = self.parse_arithmetic_impl(rest, arith_depth + 1);
7643                return if val == 0 { 1 } else { 0 };
7644            }
7645        }
7646
7647        // Base conversion: base#value (e.g., 16#ff = 255, 2#1010 = 10)
7648        if let Some(hash_pos) = expr.find('#') {
7649            let base_str = &expr[..hash_pos];
7650            let value_str = &expr[hash_pos + 1..];
7651            if let Ok(base) = base_str.parse::<u32>() {
7652                if (2..=36).contains(&base) {
7653                    return i64::from_str_radix(value_str, base).unwrap_or(0);
7654                } else if (37..=64).contains(&base) {
7655                    // Bash bases 37-64 use: 0-9, a-z, A-Z, @, _
7656                    return Self::parse_base_n(value_str, base);
7657                }
7658            }
7659        }
7660
7661        // Hex (0x...), octal (0...) literals
7662        if expr.starts_with("0x") || expr.starts_with("0X") {
7663            return i64::from_str_radix(&expr[2..], 16).unwrap_or(0);
7664        }
7665        if expr.starts_with('0') && expr.len() > 1 && expr.chars().all(|c| c.is_ascii_digit()) {
7666            return i64::from_str_radix(&expr[1..], 8).unwrap_or(0);
7667        }
7668
7669        // Parse as number
7670        expr.trim().parse().unwrap_or(0)
7671    }
7672
7673    /// Parse a number in base 37-64 using bash's extended charset: 0-9, a-z, A-Z, @, _
7674    fn parse_base_n(value_str: &str, base: u32) -> i64 {
7675        let mut result: i64 = 0;
7676        for ch in value_str.chars() {
7677            let digit = match ch {
7678                '0'..='9' => ch as u32 - '0' as u32,
7679                'a'..='z' => 10 + ch as u32 - 'a' as u32,
7680                'A'..='Z' => 36 + ch as u32 - 'A' as u32,
7681                '@' => 62,
7682                '_' => 63,
7683                _ => return 0,
7684            };
7685            if digit >= base {
7686                return 0;
7687            }
7688            result = result.wrapping_mul(base as i64).wrapping_add(digit as i64);
7689        }
7690        result
7691    }
7692
7693    /// Expand a variable by name, checking local scope, positional params, shell vars, then env
7694    /// Expand a string as a variable reference, or return as literal.
7695    /// Used for associative array keys which may be variable refs or literals.
7696    fn expand_variable_or_literal(&self, s: &str) -> String {
7697        // Handle $var and ${var} references in assoc array keys
7698        let trimmed = s.trim();
7699        if let Some(var_name) = trimmed.strip_prefix('$') {
7700            let var_name = var_name.trim_start_matches('{').trim_end_matches('}');
7701            return self.expand_variable(var_name);
7702        }
7703        if let Some(val) = self.variables.get(s) {
7704            return val.clone();
7705        }
7706        s.to_string()
7707    }
7708
7709    /// THREAT[TM-INJ-009]: Check if a variable name is an internal marker.
7710    fn is_internal_variable(name: &str) -> bool {
7711        is_internal_variable(name)
7712    }
7713
7714    /// Set a variable, respecting dynamic scoping.
7715    /// If the variable is declared `local` in any active call frame, update that frame.
7716    /// Otherwise, set in global variables.
7717    fn set_variable(&mut self, name: String, value: String) {
7718        // THREAT[TM-INJ-009]: Block user assignment to internal marker variables
7719        if Self::is_internal_variable(&name) {
7720            return;
7721        }
7722        // Resolve nameref: if `name` is a nameref, assign to the target instead
7723        let resolved = self.resolve_nameref(&name).to_string();
7724        // Apply case conversion attributes (declare -l / declare -u)
7725        let value = if self
7726            .variables
7727            .get(&format!("_LOWER_{}", resolved))
7728            .map(|v| v == "1")
7729            .unwrap_or(false)
7730        {
7731            value.to_lowercase()
7732        } else if self
7733            .variables
7734            .get(&format!("_UPPER_{}", resolved))
7735            .map(|v| v == "1")
7736            .unwrap_or(false)
7737        {
7738            value.to_uppercase()
7739        } else {
7740            value
7741        };
7742        for frame in self.call_stack.iter_mut().rev() {
7743            if let std::collections::hash_map::Entry::Occupied(mut e) =
7744                frame.locals.entry(resolved.clone())
7745            {
7746                e.insert(value);
7747                return;
7748            }
7749        }
7750        self.variables.insert(resolved, value);
7751    }
7752
7753    /// Resolve nameref chains: if `name` has a `_NAMEREF_<name>` marker,
7754    /// follow the chain (up to 10 levels to prevent infinite loops).
7755    fn resolve_nameref<'a>(&'a self, name: &'a str) -> &'a str {
7756        let mut current = name;
7757        let mut visited = std::collections::HashSet::new();
7758        visited.insert(name);
7759        for _ in 0..10 {
7760            let key = format!("_NAMEREF_{}", current);
7761            if let Some(target) = self.variables.get(&key) {
7762                // THREAT[TM-INJ-011]: Detect cyclic namerefs and stop.
7763                if !visited.insert(target.as_str()) {
7764                    // Cycle detected — return original name (Bash emits a warning)
7765                    return name;
7766                }
7767                current = target.as_str();
7768            } else {
7769                break;
7770            }
7771        }
7772        current
7773    }
7774
7775    fn expand_variable(&self, name: &str) -> String {
7776        // Resolve nameref before expansion
7777        let name = self.resolve_nameref(name);
7778
7779        // If resolved name is an array element ref like "a[2]", expand as array access
7780        if let Some(bracket) = name.find('[') {
7781            if name.ends_with(']') {
7782                let arr_name = &name[..bracket];
7783                let idx_str = &name[bracket + 1..name.len() - 1];
7784                if let Some(arr) = self.assoc_arrays.get(arr_name) {
7785                    return arr.get(idx_str).cloned().unwrap_or_default();
7786                } else if let Some(arr) = self.arrays.get(arr_name) {
7787                    let idx: usize = self.evaluate_arithmetic(idx_str).try_into().unwrap_or(0);
7788                    return arr.get(&idx).cloned().unwrap_or_default();
7789                }
7790                return String::new();
7791            }
7792        }
7793
7794        // Check for special parameters (POSIX required)
7795        match name {
7796            "?" => return self.last_exit_code.to_string(),
7797            "#" => {
7798                // Number of positional parameters
7799                if let Some(frame) = self.call_stack.last() {
7800                    return frame.positional.len().to_string();
7801                }
7802                return "0".to_string();
7803            }
7804            "@" => {
7805                // All positional parameters (space-separated as string)
7806                if let Some(frame) = self.call_stack.last() {
7807                    return frame.positional.join(" ");
7808                }
7809                return String::new();
7810            }
7811            "*" => {
7812                // All positional parameters joined by IFS first char
7813                if let Some(frame) = self.call_stack.last() {
7814                    let sep = match self.variables.get("IFS") {
7815                        Some(ifs) => ifs
7816                            .chars()
7817                            .next()
7818                            .map(|c| c.to_string())
7819                            .unwrap_or_default(),
7820                        None => " ".to_string(),
7821                    };
7822                    return frame.positional.join(&sep);
7823                }
7824                return String::new();
7825            }
7826            "$" => {
7827                // THREAT[TM-INF-014]: Return sandboxed PID, not real host PID.
7828                return "1".to_string();
7829            }
7830            "!" => {
7831                // $! - PID of most recent background command
7832                // In Bashkit's virtual environment, background jobs run synchronously
7833                // Return empty string or last job ID placeholder
7834                if let Some(last_bg_pid) = self.variables.get("_LAST_BG_PID") {
7835                    return last_bg_pid.clone();
7836                }
7837                return String::new();
7838            }
7839            "-" => {
7840                // $- - Current option flags as a string
7841                // Build from SHOPT_* variables
7842                let mut flags = String::new();
7843                for opt in ['e', 'x', 'u', 'f', 'n', 'v', 'a', 'b', 'h', 'm'] {
7844                    let opt_name = format!("SHOPT_{}", opt);
7845                    if self
7846                        .variables
7847                        .get(&opt_name)
7848                        .map(|v| v == "1")
7849                        .unwrap_or(false)
7850                    {
7851                        flags.push(opt);
7852                    }
7853                }
7854                // Also check options struct
7855                if self.options.errexit && !flags.contains('e') {
7856                    flags.push('e');
7857                }
7858                if self.options.xtrace && !flags.contains('x') {
7859                    flags.push('x');
7860                }
7861                return flags;
7862            }
7863            "RANDOM" => {
7864                // $RANDOM - random number between 0 and 32767
7865                use std::collections::hash_map::RandomState;
7866                use std::hash::{BuildHasher, Hasher};
7867                let random = RandomState::new().build_hasher().finish() as u32;
7868                return (random % 32768).to_string();
7869            }
7870            "LINENO" => {
7871                // $LINENO - current line number from command span
7872                return self.current_line.to_string();
7873            }
7874            "PWD" => {
7875                return self.cwd.to_string_lossy().to_string();
7876            }
7877            "OLDPWD" => {
7878                if let Some(v) = self.variables.get("OLDPWD") {
7879                    return v.clone();
7880                }
7881                return self.cwd.to_string_lossy().to_string();
7882            }
7883            "HOSTNAME" => {
7884                if let Some(v) = self.variables.get("HOSTNAME") {
7885                    return v.clone();
7886                }
7887                return "localhost".to_string();
7888            }
7889            "BASH_VERSION" => {
7890                return format!("{}-bashkit", env!("CARGO_PKG_VERSION"));
7891            }
7892            "SECONDS" => {
7893                // Seconds since shell started - always 0 in stateless model
7894                if let Some(v) = self.variables.get("SECONDS") {
7895                    return v.clone();
7896                }
7897                return "0".to_string();
7898            }
7899            _ => {}
7900        }
7901
7902        // Check for numeric positional parameter ($1, $2, etc.)
7903        if let Ok(n) = name.parse::<usize>() {
7904            if n == 0 {
7905                // $0 is the script/function name
7906                if let Some(frame) = self.call_stack.last() {
7907                    return frame.name.clone();
7908                }
7909                return "bash".to_string();
7910            }
7911            // $1, $2, etc. (1-indexed)
7912            if let Some(frame) = self.call_stack.last() {
7913                if n > 0 && n <= frame.positional.len() {
7914                    return frame.positional[n - 1].clone();
7915                }
7916            }
7917            return String::new();
7918        }
7919
7920        // Check local variables in call stack (top to bottom)
7921        for frame in self.call_stack.iter().rev() {
7922            if let Some(value) = frame.locals.get(name) {
7923                return value.clone();
7924            }
7925        }
7926
7927        // Check shell variables
7928        if let Some(value) = self.variables.get(name) {
7929            return value.clone();
7930        }
7931
7932        // Check environment
7933        if let Some(value) = self.env.get(name) {
7934            return value.clone();
7935        }
7936
7937        // Not found - expand to empty string (bash behavior)
7938        String::new()
7939    }
7940
7941    /// Check if a variable is set (for `set -u` / nounset).
7942    fn is_variable_set(&self, name: &str) -> bool {
7943        // Special variables are always "set"
7944        if matches!(
7945            name,
7946            "?" | "#"
7947                | "@"
7948                | "*"
7949                | "$"
7950                | "!"
7951                | "-"
7952                | "RANDOM"
7953                | "LINENO"
7954                | "PWD"
7955                | "OLDPWD"
7956                | "HOSTNAME"
7957                | "BASH_VERSION"
7958                | "SECONDS"
7959        ) {
7960            return true;
7961        }
7962        // Positional params $0..$N
7963        if let Ok(n) = name.parse::<usize>() {
7964            if n == 0 {
7965                return true;
7966            }
7967            return self
7968                .call_stack
7969                .last()
7970                .map(|f| n <= f.positional.len())
7971                .unwrap_or(false);
7972        }
7973        // Local variables
7974        for frame in self.call_stack.iter().rev() {
7975            if frame.locals.contains_key(name) {
7976                return true;
7977            }
7978        }
7979        // Shell variables
7980        if self.variables.contains_key(name) {
7981            return true;
7982        }
7983        // Environment
7984        self.env.contains_key(name)
7985    }
7986
7987    /// Check if nounset (`set -u`) is active.
7988    fn is_nounset(&self) -> bool {
7989        self.variables
7990            .get("SHOPT_u")
7991            .map(|v| v == "1")
7992            .unwrap_or(false)
7993    }
7994
7995    /// Check if pipefail (`set -o pipefail`) is active.
7996    fn is_pipefail(&self) -> bool {
7997        self.options.pipefail
7998            || self
7999                .variables
8000                .get("SHOPT_pipefail")
8001                .map(|v| v == "1")
8002                .unwrap_or(false)
8003    }
8004
8005    /// Run ERR trap if registered. Appends trap output to stdout/stderr.
8006    async fn run_err_trap(&mut self, stdout: &mut String, stderr: &mut String) {
8007        if let Some(trap_cmd) = self.traps.get("ERR").cloned() {
8008            // THREAT[TM-DOS-030]: Propagate interpreter parser limits
8009            if let Ok(trap_script) = Parser::with_limits(
8010                &trap_cmd,
8011                self.limits.max_ast_depth,
8012                self.limits.max_parser_operations,
8013            )
8014            .parse()
8015            {
8016                let emit_before = self.output_emit_count;
8017                if let Ok(trap_result) = self.execute_command_sequence(&trap_script.commands).await
8018                {
8019                    self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before);
8020                    stdout.push_str(&trap_result.stdout);
8021                    stderr.push_str(&trap_result.stderr);
8022                }
8023            }
8024        }
8025    }
8026
8027    /// Set a local variable in the current call frame
8028    #[allow(dead_code)]
8029    fn set_local(&mut self, name: &str, value: &str) {
8030        if let Some(frame) = self.call_stack.last_mut() {
8031            frame.locals.insert(name.to_string(), value.to_string());
8032        }
8033    }
8034
8035    /// Check if a string contains glob characters
8036    /// Expand brace patterns like {a,b,c} or {1..5}
8037    /// Returns a Vec of expanded strings, or a single-element Vec if no braces
8038    /// THREAT[TM-DOS-042]: Cap total expansion count to prevent combinatorial OOM.
8039    fn expand_braces(&self, s: &str) -> Vec<String> {
8040        const MAX_BRACE_EXPANSION_TOTAL: usize = 100_000;
8041        let mut count = 0;
8042        self.expand_braces_capped(s, &mut count, MAX_BRACE_EXPANSION_TOTAL)
8043    }
8044
8045    fn expand_braces_capped(&self, s: &str, count: &mut usize, max: usize) -> Vec<String> {
8046        if *count >= max {
8047            return vec![s.to_string()];
8048        }
8049
8050        // Find the first brace that has a matching close brace
8051        let mut depth = 0;
8052        let mut brace_start = None;
8053        let mut brace_end = None;
8054        let chars: Vec<char> = s.chars().collect();
8055
8056        for (i, &ch) in chars.iter().enumerate() {
8057            match ch {
8058                '{' => {
8059                    if depth == 0 {
8060                        brace_start = Some(i);
8061                    }
8062                    depth += 1;
8063                }
8064                '}' => {
8065                    depth -= 1;
8066                    if depth == 0 && brace_start.is_some() {
8067                        brace_end = Some(i);
8068                        break;
8069                    }
8070                }
8071                _ => {}
8072            }
8073        }
8074
8075        // No valid brace pattern found
8076        let (start, end) = match (brace_start, brace_end) {
8077            (Some(s), Some(e)) => (s, e),
8078            _ => return vec![s.to_string()],
8079        };
8080
8081        let prefix: String = chars[..start].iter().collect();
8082        let suffix: String = chars[end + 1..].iter().collect();
8083        let brace_content: String = chars[start + 1..end].iter().collect();
8084
8085        // Brace content with leading/trailing space is not expanded
8086        if brace_content.starts_with(' ') || brace_content.ends_with(' ') {
8087            return vec![s.to_string()];
8088        }
8089
8090        // Check for range expansion like {1..5} or {a..z}
8091        if let Some(range_result) = self.try_expand_range(&brace_content) {
8092            let mut results = Vec::new();
8093            for item in range_result {
8094                if *count >= max {
8095                    break;
8096                }
8097                let expanded = format!("{}{}{}", prefix, item, suffix);
8098                let sub = self.expand_braces_capped(&expanded, count, max);
8099                *count += sub.len();
8100                results.extend(sub);
8101            }
8102            return results;
8103        }
8104
8105        // List expansion like {a,b,c}
8106        // Need to split by comma, but respect nested braces
8107        let items = self.split_brace_items(&brace_content);
8108        if items.len() <= 1 && !brace_content.contains(',') {
8109            // Not a valid brace expansion (e.g., just {foo})
8110            return vec![s.to_string()];
8111        }
8112
8113        let mut results = Vec::new();
8114        for item in items {
8115            if *count >= max {
8116                break;
8117            }
8118            let expanded = format!("{}{}{}", prefix, item, suffix);
8119            let sub = self.expand_braces_capped(&expanded, count, max);
8120            *count += sub.len();
8121            results.extend(sub);
8122        }
8123
8124        results
8125    }
8126
8127    /// Try to expand a range like 1..5 or a..z
8128    /// THREAT[TM-DOS-041]: Cap range size to prevent OOM from {1..999999999}
8129    fn try_expand_range(&self, content: &str) -> Option<Vec<String>> {
8130        /// Maximum number of elements in a brace range expansion
8131        const MAX_BRACE_RANGE: u64 = 10_000;
8132
8133        // Check for .. separator
8134        let parts: Vec<&str> = content.split("..").collect();
8135        if parts.len() != 2 {
8136            return None;
8137        }
8138
8139        let start = parts[0];
8140        let end = parts[1];
8141
8142        // Try numeric range
8143        if let (Ok(start_num), Ok(end_num)) = (start.parse::<i64>(), end.parse::<i64>()) {
8144            let range_size = (end_num as i128 - start_num as i128).unsigned_abs() + 1;
8145            if range_size > MAX_BRACE_RANGE as u128 {
8146                return None; // Treat as literal — too large
8147            }
8148            let mut results = Vec::new();
8149            if start_num <= end_num {
8150                for i in start_num..=end_num {
8151                    results.push(i.to_string());
8152                }
8153            } else {
8154                for i in (end_num..=start_num).rev() {
8155                    results.push(i.to_string());
8156                }
8157            }
8158            return Some(results);
8159        }
8160
8161        // Try character range (single chars only)
8162        if start.len() == 1 && end.len() == 1 {
8163            let start_char = start.chars().next().unwrap();
8164            let end_char = end.chars().next().unwrap();
8165
8166            if start_char.is_ascii_alphabetic() && end_char.is_ascii_alphabetic() {
8167                let mut results = Vec::new();
8168                let start_byte = start_char as u8;
8169                let end_byte = end_char as u8;
8170
8171                if start_byte <= end_byte {
8172                    for b in start_byte..=end_byte {
8173                        results.push((b as char).to_string());
8174                    }
8175                } else {
8176                    for b in (end_byte..=start_byte).rev() {
8177                        results.push((b as char).to_string());
8178                    }
8179                }
8180                return Some(results);
8181            }
8182        }
8183
8184        None
8185    }
8186
8187    /// Split brace content by commas, respecting nested braces
8188    fn split_brace_items(&self, content: &str) -> Vec<String> {
8189        let mut items = Vec::new();
8190        let mut current = String::new();
8191        let mut depth = 0;
8192
8193        for ch in content.chars() {
8194            match ch {
8195                '{' => {
8196                    depth += 1;
8197                    current.push(ch);
8198                }
8199                '}' => {
8200                    depth -= 1;
8201                    current.push(ch);
8202                }
8203                ',' if depth == 0 => {
8204                    items.push(current);
8205                    current = String::new();
8206                }
8207                _ => {
8208                    current.push(ch);
8209                }
8210            }
8211        }
8212        items.push(current);
8213
8214        items
8215    }
8216
8217    fn contains_glob_chars(&self, s: &str) -> bool {
8218        s.contains('*') || s.contains('?') || s.contains('[')
8219    }
8220
8221    /// Check if dotglob shopt is enabled
8222    fn is_dotglob(&self) -> bool {
8223        self.variables
8224            .get("SHOPT_dotglob")
8225            .map(|v| v == "1")
8226            .unwrap_or(false)
8227    }
8228
8229    /// Check if nocaseglob shopt is enabled
8230    fn is_nocaseglob(&self) -> bool {
8231        self.variables
8232            .get("SHOPT_nocaseglob")
8233            .map(|v| v == "1")
8234            .unwrap_or(false)
8235    }
8236
8237    /// Check if noglob (set -f) is enabled
8238    fn is_noglob(&self) -> bool {
8239        self.variables
8240            .get("SHOPT_f")
8241            .map(|v| v == "1")
8242            .unwrap_or(false)
8243    }
8244
8245    /// Check if failglob shopt is enabled
8246    fn is_failglob(&self) -> bool {
8247        self.variables
8248            .get("SHOPT_failglob")
8249            .map(|v| v == "1")
8250            .unwrap_or(false)
8251    }
8252
8253    /// Check if globstar shopt is enabled
8254    fn is_globstar(&self) -> bool {
8255        self.variables
8256            .get("SHOPT_globstar")
8257            .map(|v| v == "1")
8258            .unwrap_or(false)
8259    }
8260
8261    /// Check if extglob shopt is enabled
8262    fn is_extglob(&self) -> bool {
8263        self.variables
8264            .get("SHOPT_extglob")
8265            .map(|v| v == "1")
8266            .unwrap_or(false)
8267    }
8268
8269    /// Expand glob for a single item, applying noglob/failglob/nullglob.
8270    /// Returns Err(pattern) if failglob triggers, Ok(items) otherwise.
8271    async fn expand_glob_item(&self, item: &str) -> std::result::Result<Vec<String>, String> {
8272        if !self.contains_glob_chars(item) || self.is_noglob() {
8273            return Ok(vec![item.to_string()]);
8274        }
8275        let glob_matches = self.expand_glob(item).await.unwrap_or_default();
8276        if glob_matches.is_empty() {
8277            if self.is_failglob() {
8278                return Err(item.to_string());
8279            }
8280            let nullglob = self
8281                .variables
8282                .get("SHOPT_nullglob")
8283                .map(|v| v == "1")
8284                .unwrap_or(false);
8285            if nullglob {
8286                Ok(vec![])
8287            } else {
8288                Ok(vec![item.to_string()])
8289            }
8290        } else {
8291            Ok(glob_matches)
8292        }
8293    }
8294
8295    /// Expand a glob pattern against the filesystem
8296    async fn expand_glob(&self, pattern: &str) -> Result<Vec<String>> {
8297        // Check for ** (recursive glob) — only when globstar is enabled
8298        if pattern.contains("**") && self.is_globstar() {
8299            return self.expand_glob_recursive(pattern).await;
8300        }
8301
8302        let mut matches = Vec::new();
8303        let dotglob = self.is_dotglob();
8304        let nocase = self.is_nocaseglob();
8305
8306        // Split pattern into directory and filename parts
8307        let path = Path::new(pattern);
8308        let (dir, file_pattern) = if path.is_absolute() {
8309            let parent = path.parent().unwrap_or(Path::new("/"));
8310            let name = path.file_name().map(|s| s.to_string_lossy().to_string());
8311            (parent.to_path_buf(), name)
8312        } else {
8313            // Relative path - use cwd
8314            let parent = path.parent();
8315            let name = path.file_name().map(|s| s.to_string_lossy().to_string());
8316            if let Some(p) = parent {
8317                if p.as_os_str().is_empty() {
8318                    (self.cwd.clone(), name)
8319                } else {
8320                    (self.cwd.join(p), name)
8321                }
8322            } else {
8323                (self.cwd.clone(), name)
8324            }
8325        };
8326
8327        let file_pattern = match file_pattern {
8328            Some(p) => p,
8329            None => return Ok(matches),
8330        };
8331
8332        // Check if the directory exists
8333        if !self.fs.exists(&dir).await.unwrap_or(false) {
8334            return Ok(matches);
8335        }
8336
8337        // Read directory entries
8338        let entries = match self.fs.read_dir(&dir).await {
8339            Ok(e) => e,
8340            Err(_) => return Ok(matches),
8341        };
8342
8343        // Check if pattern explicitly starts with dot
8344        let pattern_starts_with_dot = file_pattern.starts_with('.');
8345
8346        // Match each entry against the pattern
8347        for entry in entries {
8348            // Skip dotfiles unless dotglob is set or pattern explicitly starts with '.'
8349            if entry.name.starts_with('.') && !dotglob && !pattern_starts_with_dot {
8350                continue;
8351            }
8352
8353            if self.glob_match_impl(&entry.name, &file_pattern, nocase, 0) {
8354                // Construct the full path
8355                let full_path = if path.is_absolute() {
8356                    dir.join(&entry.name).to_string_lossy().to_string()
8357                } else {
8358                    // For relative patterns, return relative path
8359                    if let Some(parent) = path.parent() {
8360                        if parent.as_os_str().is_empty() {
8361                            entry.name.clone()
8362                        } else {
8363                            format!("{}/{}", parent.to_string_lossy(), entry.name)
8364                        }
8365                    } else {
8366                        entry.name.clone()
8367                    }
8368                };
8369                matches.push(full_path);
8370            }
8371        }
8372
8373        // Sort matches alphabetically (bash behavior)
8374        matches.sort();
8375        Ok(matches)
8376    }
8377
8378    /// Expand a glob pattern containing ** (recursive directory matching).
8379    async fn expand_glob_recursive(&self, pattern: &str) -> Result<Vec<String>> {
8380        let is_absolute = pattern.starts_with('/');
8381        let components: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
8382        let dotglob = self.is_dotglob();
8383        let nocase = self.is_nocaseglob();
8384
8385        // Find the ** component
8386        let star_star_idx = match components.iter().position(|&c| c == "**") {
8387            Some(i) => i,
8388            None => return Ok(Vec::new()),
8389        };
8390
8391        // Build the base directory from components before **
8392        let base_dir = if is_absolute {
8393            let mut p = PathBuf::from("/");
8394            for c in &components[..star_star_idx] {
8395                p.push(c);
8396            }
8397            p
8398        } else {
8399            let mut p = self.cwd.clone();
8400            for c in &components[..star_star_idx] {
8401                p.push(c);
8402            }
8403            p
8404        };
8405
8406        // Pattern components after **
8407        let after_pattern: Vec<&str> = components[star_star_idx + 1..].to_vec();
8408
8409        // Collect all directories recursively (including the base)
8410        let mut all_dirs = vec![base_dir.clone()];
8411        // THREAT[TM-DOS-049]: Cap recursion depth using filesystem path depth limit
8412        let max_depth = self.fs.limits().max_path_depth;
8413        self.collect_dirs_recursive(&base_dir, &mut all_dirs, max_depth)
8414            .await;
8415
8416        let mut matches = Vec::new();
8417
8418        for dir in &all_dirs {
8419            if after_pattern.is_empty() {
8420                // ** alone matches all files recursively
8421                if let Ok(entries) = self.fs.read_dir(dir).await {
8422                    for entry in entries {
8423                        if entry.name.starts_with('.') && !dotglob {
8424                            continue;
8425                        }
8426                        if !entry.metadata.file_type.is_dir() {
8427                            matches.push(dir.join(&entry.name).to_string_lossy().to_string());
8428                        }
8429                    }
8430                }
8431            } else if after_pattern.len() == 1 {
8432                // Single pattern after **: match files in this directory
8433                let pat = after_pattern[0];
8434                let pattern_starts_with_dot = pat.starts_with('.');
8435                if let Ok(entries) = self.fs.read_dir(dir).await {
8436                    for entry in entries {
8437                        if entry.name.starts_with('.') && !dotglob && !pattern_starts_with_dot {
8438                            continue;
8439                        }
8440                        if self.glob_match_impl(&entry.name, pat, nocase, 0) {
8441                            matches.push(dir.join(&entry.name).to_string_lossy().to_string());
8442                        }
8443                    }
8444                }
8445            }
8446        }
8447
8448        matches.sort();
8449        Ok(matches)
8450    }
8451
8452    /// Recursively collect all subdirectories starting from dir.
8453    /// THREAT[TM-DOS-049]: `max_depth` caps recursion to prevent stack exhaustion.
8454    fn collect_dirs_recursive<'a>(
8455        &'a self,
8456        dir: &'a Path,
8457        result: &'a mut Vec<PathBuf>,
8458        max_depth: usize,
8459    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
8460        Box::pin(async move {
8461            if max_depth == 0 {
8462                return;
8463            }
8464            if let Ok(entries) = self.fs.read_dir(dir).await {
8465                for entry in entries {
8466                    if entry.metadata.file_type.is_dir() {
8467                        let subdir = dir.join(&entry.name);
8468                        result.push(subdir.clone());
8469                        self.collect_dirs_recursive(&subdir, result, max_depth - 1)
8470                            .await;
8471                    }
8472                }
8473            }
8474        })
8475    }
8476}
8477
8478#[cfg(test)]
8479mod tests {
8480    use super::*;
8481    use crate::fs::InMemoryFs;
8482    use crate::parser::Parser;
8483
8484    /// Test timeout with paused time for deterministic behavior
8485    #[tokio::test(start_paused = true)]
8486    async fn test_timeout_expires_deterministically() {
8487        let fs: Arc<dyn FileSystem> = Arc::new(InMemoryFs::new());
8488        let mut interp = Interpreter::new(Arc::clone(&fs));
8489
8490        // timeout 0.001 sleep 10 - should timeout (1ms << 10s)
8491        let parser = Parser::new("timeout 0.001 sleep 10; echo $?");
8492        let ast = parser.parse().unwrap();
8493        let result = interp.execute(&ast).await.unwrap();
8494        assert_eq!(
8495            result.stdout.trim(),
8496            "124",
8497            "Expected exit code 124 for timeout"
8498        );
8499    }
8500
8501    /// Test zero timeout
8502    #[tokio::test(start_paused = true)]
8503    async fn test_timeout_zero_deterministically() {
8504        let fs: Arc<dyn FileSystem> = Arc::new(InMemoryFs::new());
8505        let mut interp = Interpreter::new(Arc::clone(&fs));
8506
8507        // timeout 0 sleep 1 - should timeout immediately
8508        let parser = Parser::new("timeout 0 sleep 1; echo $?");
8509        let ast = parser.parse().unwrap();
8510        let result = interp.execute(&ast).await.unwrap();
8511        assert_eq!(
8512            result.stdout.trim(),
8513            "124",
8514            "Expected exit code 124 for zero timeout"
8515        );
8516    }
8517
8518    /// Test that parse_timeout_duration preserves subsecond precision
8519    #[test]
8520    fn test_parse_timeout_duration_subsecond() {
8521        use std::time::Duration;
8522
8523        // Should preserve subsecond precision
8524        let d = Interpreter::parse_timeout_duration("0.001").unwrap();
8525        assert_eq!(d, Duration::from_secs_f64(0.001));
8526
8527        let d = Interpreter::parse_timeout_duration("0.5").unwrap();
8528        assert_eq!(d, Duration::from_millis(500));
8529
8530        let d = Interpreter::parse_timeout_duration("1.5s").unwrap();
8531        assert_eq!(d, Duration::from_millis(1500));
8532
8533        // Zero should work
8534        let d = Interpreter::parse_timeout_duration("0").unwrap();
8535        assert_eq!(d, Duration::ZERO);
8536    }
8537
8538    // POSIX special builtins tests
8539
8540    /// Helper to run a script and return result
8541    async fn run_script(script: &str) -> ExecResult {
8542        let fs: Arc<dyn FileSystem> = Arc::new(InMemoryFs::new());
8543        let mut interp = Interpreter::new(Arc::clone(&fs));
8544        let parser = Parser::new(script);
8545        let ast = parser.parse().unwrap();
8546        interp.execute(&ast).await.unwrap()
8547    }
8548
8549    #[tokio::test]
8550    async fn test_colon_null_utility() {
8551        // POSIX : (colon) - null utility, should return success
8552        let result = run_script(":").await;
8553        assert_eq!(result.exit_code, 0);
8554        assert_eq!(result.stdout, "");
8555    }
8556
8557    #[tokio::test]
8558    async fn test_colon_with_args() {
8559        // Colon should ignore arguments and still succeed
8560        let result = run_script(": arg1 arg2 arg3").await;
8561        assert_eq!(result.exit_code, 0);
8562        assert_eq!(result.stdout, "");
8563    }
8564
8565    #[tokio::test]
8566    async fn test_colon_in_while_loop() {
8567        // Common use case: while : (infinite loop, but we limit iterations)
8568        let result = run_script(
8569            "x=0; while :; do x=$((x+1)); if [ $x -ge 3 ]; then break; fi; done; echo $x",
8570        )
8571        .await;
8572        assert_eq!(result.stdout.trim(), "3");
8573    }
8574
8575    #[tokio::test]
8576    async fn test_times_builtin() {
8577        // POSIX times - returns process times (zeros in virtual mode)
8578        let result = run_script("times").await;
8579        assert_eq!(result.exit_code, 0);
8580        assert!(result.stdout.contains("0m0.000s"));
8581    }
8582
8583    #[tokio::test]
8584    async fn test_readonly_basic() {
8585        // POSIX readonly - mark variable as read-only
8586        let result = run_script("readonly X=value; echo $X").await;
8587        assert_eq!(result.stdout.trim(), "value");
8588    }
8589
8590    #[tokio::test]
8591    async fn test_special_param_dash() {
8592        // $- should return current option flags
8593        let result = run_script("set -e; echo \"$-\"").await;
8594        assert!(result.stdout.contains('e'));
8595    }
8596
8597    #[tokio::test]
8598    async fn test_special_param_bang() {
8599        // $! - last background PID (empty in virtual mode with no bg jobs)
8600        let result = run_script("echo \"$!\"").await;
8601        // Should be empty or a placeholder
8602        assert_eq!(result.exit_code, 0);
8603    }
8604
8605    // =========================================================================
8606    // Additional POSIX positive tests
8607    // =========================================================================
8608
8609    #[tokio::test]
8610    async fn test_colon_variable_side_effect() {
8611        // Common pattern: use : with parameter expansion for defaults
8612        let result = run_script(": ${X:=default}; echo $X").await;
8613        assert_eq!(result.stdout.trim(), "default");
8614        assert_eq!(result.exit_code, 0);
8615    }
8616
8617    #[tokio::test]
8618    async fn test_colon_in_if_then() {
8619        // Use : as no-op in then branch
8620        let result = run_script("if true; then :; fi; echo done").await;
8621        assert_eq!(result.stdout.trim(), "done");
8622        assert_eq!(result.exit_code, 0);
8623    }
8624
8625    #[tokio::test]
8626    async fn test_readonly_set_and_read() {
8627        // Set readonly variable and verify it's accessible
8628        let result = run_script("readonly FOO=bar; readonly BAR=baz; echo $FOO $BAR").await;
8629        assert_eq!(result.stdout.trim(), "bar baz");
8630    }
8631
8632    #[tokio::test]
8633    async fn test_readonly_mark_existing() {
8634        // Mark an existing variable as readonly
8635        let result = run_script("X=hello; readonly X; echo $X").await;
8636        assert_eq!(result.stdout.trim(), "hello");
8637    }
8638
8639    #[tokio::test]
8640    async fn test_times_two_lines() {
8641        // times should output exactly two lines
8642        let result = run_script("times").await;
8643        let lines: Vec<&str> = result.stdout.lines().collect();
8644        assert_eq!(lines.len(), 2);
8645    }
8646
8647    #[tokio::test]
8648    async fn test_eval_simple_command() {
8649        // eval should execute the constructed command
8650        let result = run_script("cmd='echo hello'; eval $cmd").await;
8651        // Note: eval stores command for interpreter, actual execution depends on interpreter support
8652        assert_eq!(result.exit_code, 0);
8653    }
8654
8655    #[tokio::test]
8656    async fn test_special_param_dash_multiple_options() {
8657        // Set multiple options and verify $- contains them
8658        let result = run_script("set -e; set -x; echo \"$-\"").await;
8659        assert!(result.stdout.contains('e'));
8660        // Note: x is stored but we verify at least e is present
8661    }
8662
8663    #[tokio::test]
8664    async fn test_special_param_dash_no_options() {
8665        // With no options set, $- should be empty or minimal
8666        let result = run_script("echo \"flags:$-:end\"").await;
8667        assert!(result.stdout.contains("flags:"));
8668        assert!(result.stdout.contains(":end"));
8669        assert_eq!(result.exit_code, 0);
8670    }
8671
8672    // =========================================================================
8673    // POSIX negative tests (error cases / edge cases)
8674    // =========================================================================
8675
8676    #[tokio::test]
8677    async fn test_colon_does_not_produce_output() {
8678        // Colon should never produce any output
8679        let result = run_script(": 'this should not appear'").await;
8680        assert_eq!(result.stdout, "");
8681        assert_eq!(result.stderr, "");
8682    }
8683
8684    #[tokio::test]
8685    async fn test_eval_empty_args() {
8686        // eval with no arguments should succeed silently
8687        let result = run_script("eval; echo $?").await;
8688        assert!(result.stdout.contains('0'));
8689        assert_eq!(result.exit_code, 0);
8690    }
8691
8692    #[tokio::test]
8693    async fn test_readonly_empty_value() {
8694        // readonly with empty value
8695        let result = run_script("readonly EMPTY=; echo \"[$EMPTY]\"").await;
8696        assert_eq!(result.stdout.trim(), "[]");
8697    }
8698
8699    #[tokio::test]
8700    async fn test_times_no_args_accepted() {
8701        // times should ignore any arguments
8702        let result = run_script("times ignored args here").await;
8703        assert_eq!(result.exit_code, 0);
8704        assert!(result.stdout.contains("0m0.000s"));
8705    }
8706
8707    #[tokio::test]
8708    async fn test_special_param_bang_empty_without_bg() {
8709        // $! should be empty when no background jobs have run
8710        let result = run_script("x=\"$!\"; [ -z \"$x\" ] && echo empty || echo not_empty").await;
8711        assert_eq!(result.stdout.trim(), "empty");
8712    }
8713
8714    #[tokio::test]
8715    async fn test_colon_exit_code_zero() {
8716        // Verify colon always returns 0 even after failed command
8717        let result = run_script("false; :; echo $?").await;
8718        assert_eq!(result.stdout.trim(), "0");
8719    }
8720
8721    #[tokio::test]
8722    async fn test_readonly_without_value_preserves_existing() {
8723        // readonly on existing var preserves value
8724        let result = run_script("VAR=existing; readonly VAR; echo $VAR").await;
8725        assert_eq!(result.stdout.trim(), "existing");
8726    }
8727
8728    // bash/sh command tests
8729
8730    #[tokio::test]
8731    async fn test_bash_c_simple_command() {
8732        // bash -c "command" should execute the command
8733        let result = run_script("bash -c 'echo hello'").await;
8734        assert_eq!(result.exit_code, 0);
8735        assert_eq!(result.stdout.trim(), "hello");
8736    }
8737
8738    #[tokio::test]
8739    async fn test_sh_c_simple_command() {
8740        // sh -c "command" should also work
8741        let result = run_script("sh -c 'echo world'").await;
8742        assert_eq!(result.exit_code, 0);
8743        assert_eq!(result.stdout.trim(), "world");
8744    }
8745
8746    #[tokio::test]
8747    async fn test_bash_c_multiple_commands() {
8748        // bash -c with multiple commands separated by semicolon
8749        let result = run_script("bash -c 'echo one; echo two'").await;
8750        assert_eq!(result.exit_code, 0);
8751        assert_eq!(result.stdout, "one\ntwo\n");
8752    }
8753
8754    #[tokio::test]
8755    async fn test_bash_c_with_positional_args() {
8756        // bash -c "cmd" arg0 arg1 - positional parameters
8757        let result = run_script("bash -c 'echo $0 $1' zero one").await;
8758        assert_eq!(result.exit_code, 0);
8759        assert_eq!(result.stdout.trim(), "zero one");
8760    }
8761
8762    #[tokio::test]
8763    async fn test_bash_script_file() {
8764        // bash script.sh - execute a script file
8765        let fs = Arc::new(InMemoryFs::new());
8766        fs.write_file(std::path::Path::new("/tmp/test.sh"), b"echo 'from script'")
8767            .await
8768            .unwrap();
8769
8770        let mut interpreter = Interpreter::new(fs.clone());
8771        let parser = Parser::new("bash /tmp/test.sh");
8772        let script = parser.parse().unwrap();
8773        let result = interpreter.execute(&script).await.unwrap();
8774
8775        assert_eq!(result.exit_code, 0);
8776        assert_eq!(result.stdout.trim(), "from script");
8777    }
8778
8779    #[tokio::test]
8780    async fn test_bash_script_file_with_args() {
8781        // bash script.sh arg1 arg2 - script with arguments
8782        let fs = Arc::new(InMemoryFs::new());
8783        fs.write_file(std::path::Path::new("/tmp/args.sh"), b"echo $1 $2")
8784            .await
8785            .unwrap();
8786
8787        let mut interpreter = Interpreter::new(fs.clone());
8788        let parser = Parser::new("bash /tmp/args.sh first second");
8789        let script = parser.parse().unwrap();
8790        let result = interpreter.execute(&script).await.unwrap();
8791
8792        assert_eq!(result.exit_code, 0);
8793        assert_eq!(result.stdout.trim(), "first second");
8794    }
8795
8796    #[tokio::test]
8797    async fn test_bash_piped_script() {
8798        // echo "script" | bash - execute from stdin
8799        let result = run_script("echo 'echo piped' | bash").await;
8800        assert_eq!(result.exit_code, 0);
8801        assert_eq!(result.stdout.trim(), "piped");
8802    }
8803
8804    #[tokio::test]
8805    async fn test_bash_nonexistent_file() {
8806        // bash missing.sh - should error with exit code 127
8807        let result = run_script("bash /nonexistent/missing.sh").await;
8808        assert_eq!(result.exit_code, 127);
8809        assert!(result.stderr.contains("No such file"));
8810    }
8811
8812    #[tokio::test]
8813    async fn test_bash_c_missing_argument() {
8814        // bash -c without command string - should error
8815        let result = run_script("bash -c").await;
8816        assert_eq!(result.exit_code, 2);
8817        assert!(result.stderr.contains("option requires an argument"));
8818    }
8819
8820    #[tokio::test]
8821    async fn test_bash_c_syntax_error() {
8822        // bash -c with invalid syntax
8823        let result = run_script("bash -c 'if then'").await;
8824        assert_eq!(result.exit_code, 2);
8825        assert!(result.stderr.contains("syntax error"));
8826    }
8827
8828    #[tokio::test]
8829    async fn test_bash_preserves_variables() {
8830        // Variables set in bash -c should affect the parent
8831        // (since we share the interpreter state)
8832        let result = run_script("bash -c 'X=inner'; echo $X").await;
8833        assert_eq!(result.exit_code, 0);
8834        assert_eq!(result.stdout.trim(), "inner");
8835    }
8836
8837    #[tokio::test]
8838    async fn test_bash_c_exit_code_propagates() {
8839        // Exit code from bash -c should propagate
8840        let result = run_script("bash -c 'exit 42'; echo $?").await;
8841        assert_eq!(result.stdout.trim(), "42");
8842    }
8843
8844    #[tokio::test]
8845    async fn test_bash_nested() {
8846        // Nested bash -c calls
8847        let result = run_script("bash -c \"bash -c 'echo nested'\"").await;
8848        assert_eq!(result.exit_code, 0);
8849        assert_eq!(result.stdout.trim(), "nested");
8850    }
8851
8852    #[tokio::test]
8853    async fn test_sh_script_file() {
8854        // sh script.sh - same as bash script.sh
8855        let fs = Arc::new(InMemoryFs::new());
8856        fs.write_file(std::path::Path::new("/tmp/sh_test.sh"), b"echo 'sh works'")
8857            .await
8858            .unwrap();
8859
8860        let mut interpreter = Interpreter::new(fs.clone());
8861        let parser = Parser::new("sh /tmp/sh_test.sh");
8862        let script = parser.parse().unwrap();
8863        let result = interpreter.execute(&script).await.unwrap();
8864
8865        assert_eq!(result.exit_code, 0);
8866        assert_eq!(result.stdout.trim(), "sh works");
8867    }
8868
8869    #[tokio::test]
8870    async fn test_bash_with_option_e() {
8871        // bash -e -c "command" - -e is accepted but doesn't change behavior in virtual mode
8872        let result = run_script("bash -e -c 'echo works'").await;
8873        assert_eq!(result.exit_code, 0);
8874        assert_eq!(result.stdout.trim(), "works");
8875    }
8876
8877    #[tokio::test]
8878    async fn test_bash_empty_input() {
8879        // bash with no arguments or stdin does nothing
8880        let result = run_script("bash; echo done").await;
8881        assert_eq!(result.exit_code, 0);
8882        assert_eq!(result.stdout.trim(), "done");
8883    }
8884
8885    // Additional bash/sh tests for noexec, version, help
8886
8887    #[tokio::test]
8888    async fn test_bash_n_syntax_check_success() {
8889        // bash -n parses but doesn't execute
8890        let result = run_script("bash -n -c 'echo should not print'").await;
8891        assert_eq!(result.exit_code, 0);
8892        assert_eq!(result.stdout, ""); // Nothing printed - didn't execute
8893    }
8894
8895    #[tokio::test]
8896    async fn test_bash_n_syntax_error_detected() {
8897        // bash -n catches syntax errors
8898        let result = run_script("bash -n -c 'if then'").await;
8899        assert_eq!(result.exit_code, 2);
8900        assert!(result.stderr.contains("syntax error"));
8901    }
8902
8903    #[tokio::test]
8904    async fn test_bash_n_combined_flags() {
8905        // -n can be combined with other flags like -ne
8906        let result = run_script("bash -ne -c 'echo test'; echo done").await;
8907        assert_eq!(result.exit_code, 0);
8908        assert_eq!(result.stdout.trim(), "done"); // Only "done" - bash -n didn't execute
8909    }
8910
8911    #[tokio::test]
8912    async fn test_bash_version() {
8913        // --version shows Bashkit version
8914        let result = run_script("bash --version").await;
8915        assert_eq!(result.exit_code, 0);
8916        assert!(result.stdout.contains("Bashkit"));
8917        assert!(result.stdout.contains("virtual"));
8918    }
8919
8920    #[tokio::test]
8921    async fn test_sh_version() {
8922        // sh --version also works
8923        let result = run_script("sh --version").await;
8924        assert_eq!(result.exit_code, 0);
8925        assert!(result.stdout.contains("virtual sh"));
8926    }
8927
8928    #[tokio::test]
8929    async fn test_bash_help() {
8930        // --help shows usage
8931        let result = run_script("bash --help").await;
8932        assert_eq!(result.exit_code, 0);
8933        assert!(result.stdout.contains("Usage:"));
8934        assert!(result.stdout.contains("-c string"));
8935        assert!(result.stdout.contains("-n"));
8936    }
8937
8938    #[tokio::test]
8939    async fn test_bash_double_dash() {
8940        // -- ends option processing
8941        let result = run_script("bash -- --help").await;
8942        // Should try to run file named "--help", which doesn't exist
8943        assert_eq!(result.exit_code, 127);
8944    }
8945
8946    // Negative test cases
8947
8948    #[tokio::test]
8949    async fn test_bash_invalid_syntax_in_file() {
8950        // Syntax error in script file - unclosed if
8951        let fs = Arc::new(InMemoryFs::new());
8952        fs.write_file(std::path::Path::new("/tmp/bad.sh"), b"if true; then echo x")
8953            .await
8954            .unwrap();
8955
8956        let mut interpreter = Interpreter::new(fs.clone());
8957        let parser = Parser::new("bash /tmp/bad.sh");
8958        let script = parser.parse().unwrap();
8959        let result = interpreter.execute(&script).await.unwrap();
8960
8961        assert_eq!(result.exit_code, 2);
8962        assert!(result.stderr.contains("syntax error"));
8963    }
8964
8965    #[tokio::test]
8966    async fn test_bash_permission_in_sandbox() {
8967        // Filesystem operations work through bash -c
8968        let result = run_script("bash -c 'echo test > /tmp/out.txt && cat /tmp/out.txt'").await;
8969        assert_eq!(result.exit_code, 0);
8970        assert_eq!(result.stdout.trim(), "test");
8971    }
8972
8973    #[tokio::test]
8974    async fn test_bash_all_positional() {
8975        // $@ and $* work correctly
8976        let result = run_script("bash -c 'echo $@' _ a b c").await;
8977        assert_eq!(result.exit_code, 0);
8978        assert_eq!(result.stdout.trim(), "a b c");
8979    }
8980
8981    #[tokio::test]
8982    async fn test_bash_arg_count() {
8983        // $# counts positional params
8984        let result = run_script("bash -c 'echo $#' _ 1 2 3 4").await;
8985        assert_eq!(result.exit_code, 0);
8986        assert_eq!(result.stdout.trim(), "4");
8987    }
8988
8989    // Security-focused tests
8990
8991    #[tokio::test]
8992    async fn test_bash_no_real_bash_escape() {
8993        // Verify bash -c doesn't escape sandbox
8994        // Try to run a command that would work in real bash but not here
8995        let result = run_script("bash -c 'which bash 2>/dev/null || echo not found'").await;
8996        // 'which' is not a builtin, so this should fail
8997        assert!(result.stdout.contains("not found") || result.exit_code == 127);
8998    }
8999
9000    #[tokio::test]
9001    async fn test_bash_nested_limits_respected() {
9002        // Deep nesting should eventually hit limits
9003        // This tests that bash -c doesn't bypass command limits
9004        let result = run_script("bash -c 'for i in 1 2 3; do echo $i; done'").await;
9005        assert_eq!(result.exit_code, 0);
9006        // Loop executed successfully within limits
9007    }
9008
9009    #[tokio::test]
9010    async fn test_bash_c_injection_safe() {
9011        // Variable expansion doesn't allow injection
9012        let result = run_script("INJECT='; rm -rf /'; bash -c 'echo safe'").await;
9013        assert_eq!(result.exit_code, 0);
9014        assert_eq!(result.stdout.trim(), "safe");
9015    }
9016
9017    #[tokio::test]
9018    async fn test_bash_version_no_host_info() {
9019        // Version output doesn't leak host information
9020        let result = run_script("bash --version").await;
9021        assert!(!result.stdout.contains("/usr"));
9022        assert!(!result.stdout.contains("GNU"));
9023        // Should only contain virtual version info
9024    }
9025
9026    // Additional positive tests
9027
9028    #[tokio::test]
9029    async fn test_bash_c_with_quotes() {
9030        // Handles quoted strings correctly
9031        let result = run_script(r#"bash -c 'echo "hello world"'"#).await;
9032        assert_eq!(result.exit_code, 0);
9033        assert_eq!(result.stdout.trim(), "hello world");
9034    }
9035
9036    #[tokio::test]
9037    async fn test_bash_c_with_variables() {
9038        // Variables expand correctly in bash -c
9039        let result = run_script("X=test; bash -c 'echo $X'").await;
9040        assert_eq!(result.exit_code, 0);
9041        assert_eq!(result.stdout.trim(), "test");
9042    }
9043
9044    #[tokio::test]
9045    async fn test_bash_c_pipe_in_command() {
9046        // Pipes work inside bash -c
9047        let result = run_script("bash -c 'echo hello | cat'").await;
9048        assert_eq!(result.exit_code, 0);
9049        assert_eq!(result.stdout.trim(), "hello");
9050    }
9051
9052    #[tokio::test]
9053    async fn test_bash_c_subshell() {
9054        // Command substitution works in bash -c
9055        let result = run_script("bash -c 'echo $(echo inner)'").await;
9056        assert_eq!(result.exit_code, 0);
9057        assert_eq!(result.stdout.trim(), "inner");
9058    }
9059
9060    #[tokio::test]
9061    async fn test_bash_c_conditional() {
9062        // Conditionals work in bash -c
9063        let result = run_script("bash -c 'if true; then echo yes; fi'").await;
9064        assert_eq!(result.exit_code, 0);
9065        assert_eq!(result.stdout.trim(), "yes");
9066    }
9067
9068    #[tokio::test]
9069    async fn test_bash_script_with_shebang() {
9070        // Script with shebang is handled (shebang line ignored)
9071        let fs = Arc::new(InMemoryFs::new());
9072        fs.write_file(
9073            std::path::Path::new("/tmp/shebang.sh"),
9074            b"#!/bin/bash\necho works",
9075        )
9076        .await
9077        .unwrap();
9078
9079        let mut interpreter = Interpreter::new(fs.clone());
9080        let parser = Parser::new("bash /tmp/shebang.sh");
9081        let script = parser.parse().unwrap();
9082        let result = interpreter.execute(&script).await.unwrap();
9083
9084        assert_eq!(result.exit_code, 0);
9085        assert_eq!(result.stdout.trim(), "works");
9086    }
9087
9088    #[tokio::test]
9089    async fn test_bash_n_with_valid_multiline() {
9090        // -n validates multiline scripts
9091        let result = run_script("bash -n -c 'echo one\necho two\necho three'").await;
9092        assert_eq!(result.exit_code, 0);
9093    }
9094
9095    #[tokio::test]
9096    async fn test_sh_behaves_like_bash() {
9097        // sh and bash produce same results
9098        let bash_result = run_script("bash -c 'echo $((1+2))'").await;
9099        let sh_result = run_script("sh -c 'echo $((1+2))'").await;
9100        assert_eq!(bash_result.stdout, sh_result.stdout);
9101        assert_eq!(bash_result.exit_code, sh_result.exit_code);
9102    }
9103
9104    // Additional negative tests
9105
9106    #[tokio::test]
9107    async fn test_bash_n_unclosed_if() {
9108        // -n catches unclosed control structures
9109        let result = run_script("bash -n -c 'if true; then echo x'").await;
9110        assert_eq!(result.exit_code, 2);
9111        assert!(result.stderr.contains("syntax error"));
9112    }
9113
9114    #[tokio::test]
9115    async fn test_bash_n_unclosed_while() {
9116        // -n catches unclosed while
9117        let result = run_script("bash -n -c 'while true; do echo x'").await;
9118        assert_eq!(result.exit_code, 2);
9119    }
9120
9121    #[tokio::test]
9122    async fn test_bash_empty_c_string() {
9123        // Empty -c string is valid (does nothing)
9124        let result = run_script("bash -c ''").await;
9125        assert_eq!(result.exit_code, 0);
9126        assert_eq!(result.stdout, "");
9127    }
9128
9129    #[tokio::test]
9130    async fn test_bash_whitespace_only_c_string() {
9131        // Whitespace-only -c string is valid
9132        let result = run_script("bash -c '   '").await;
9133        assert_eq!(result.exit_code, 0);
9134    }
9135
9136    #[tokio::test]
9137    async fn test_bash_directory_not_file() {
9138        // Trying to execute a directory fails
9139        let result = run_script("bash /tmp").await;
9140        // Should fail - /tmp is a directory
9141        assert_ne!(result.exit_code, 0);
9142    }
9143
9144    #[tokio::test]
9145    async fn test_bash_c_exit_propagates() {
9146        // Exit code from bash -c is captured in $?
9147        let result = run_script("bash -c 'exit 42'; echo \"code: $?\"").await;
9148        assert_eq!(result.exit_code, 0);
9149        assert!(result.stdout.contains("code: 42"));
9150    }
9151
9152    #[tokio::test]
9153    async fn test_bash_multiple_scripts_sequential() {
9154        // Multiple bash calls work sequentially
9155        let result = run_script("bash -c 'echo 1'; bash -c 'echo 2'; bash -c 'echo 3'").await;
9156        assert_eq!(result.exit_code, 0);
9157        assert_eq!(result.stdout, "1\n2\n3\n");
9158    }
9159
9160    // Security edge cases
9161
9162    #[tokio::test]
9163    async fn test_bash_c_path_traversal_blocked() {
9164        // Path traversal in bash -c doesn't escape sandbox
9165        let result =
9166            run_script("bash -c 'cat /../../etc/passwd 2>/dev/null || echo blocked'").await;
9167        assert!(result.stdout.contains("blocked") || result.exit_code != 0);
9168    }
9169
9170    #[tokio::test]
9171    async fn test_bash_nested_deeply() {
9172        // Deeply nested bash calls work within limits
9173        let result = run_script("bash -c \"bash -c 'bash -c \\\"echo deep\\\"'\"").await;
9174        assert_eq!(result.exit_code, 0);
9175        assert_eq!(result.stdout.trim(), "deep");
9176    }
9177
9178    #[tokio::test]
9179    async fn test_bash_c_special_chars() {
9180        // Special characters in commands handled safely
9181        let result = run_script("bash -c 'echo \"$HOME\"'").await;
9182        // Should use virtual home directory, not real system path
9183        assert!(!result.stdout.contains("/root"));
9184        assert!(result.stdout.contains("/home/sandbox"));
9185    }
9186
9187    #[tokio::test]
9188    async fn test_bash_c_dollar_substitution() {
9189        // $() substitution works in bash -c
9190        let result = run_script("bash -c 'echo $(echo subst)'").await;
9191        assert_eq!(result.exit_code, 0);
9192        assert_eq!(result.stdout.trim(), "subst");
9193    }
9194
9195    #[tokio::test]
9196    async fn test_bash_help_contains_expected_options() {
9197        // Help output contains documented options
9198        let result = run_script("bash --help").await;
9199        assert!(result.stdout.contains("-c"));
9200        assert!(result.stdout.contains("-n"));
9201        assert!(result.stdout.contains("--version"));
9202    }
9203
9204    #[tokio::test]
9205    async fn test_bash_c_array_operations() {
9206        // Array operations work in bash -c
9207        let result = run_script("bash -c 'arr=(a b c); echo ${arr[1]}'").await;
9208        assert_eq!(result.exit_code, 0);
9209        assert_eq!(result.stdout.trim(), "b");
9210    }
9211
9212    #[tokio::test]
9213    async fn test_bash_positional_special_vars() {
9214        // Special positional vars work
9215        let result = run_script("bash -c 'echo \"args: $#, first: $1, all: $*\"' prog a b c").await;
9216        assert_eq!(result.exit_code, 0);
9217        assert!(result.stdout.contains("args: 3"));
9218        assert!(result.stdout.contains("first: a"));
9219        assert!(result.stdout.contains("all: a b c"));
9220    }
9221
9222    #[tokio::test]
9223    async fn test_xtrace_basic() {
9224        // set -x sends trace to stderr
9225        let result = run_script("set -x; echo hello").await;
9226        assert_eq!(result.exit_code, 0);
9227        assert_eq!(result.stdout, "hello\n");
9228        assert!(
9229            result.stderr.contains("+ echo hello"),
9230            "stderr should contain xtrace: {:?}",
9231            result.stderr
9232        );
9233    }
9234
9235    #[tokio::test]
9236    async fn test_xtrace_multiple_commands() {
9237        let result = run_script("set -x; echo one; echo two").await;
9238        assert_eq!(result.stdout, "one\ntwo\n");
9239        assert!(result.stderr.contains("+ echo one"));
9240        assert!(result.stderr.contains("+ echo two"));
9241    }
9242
9243    #[tokio::test]
9244    async fn test_xtrace_expanded_variables() {
9245        // Trace shows expanded values, not variable names
9246        let result = run_script("x=hello; set -x; echo $x").await;
9247        assert_eq!(result.stdout, "hello\n");
9248        assert!(
9249            result.stderr.contains("+ echo hello"),
9250            "xtrace should show expanded value: {:?}",
9251            result.stderr
9252        );
9253    }
9254
9255    #[tokio::test]
9256    async fn test_xtrace_disable() {
9257        // set +x disables tracing; set +x itself is traced
9258        let result = run_script("set -x; echo traced; set +x; echo not_traced").await;
9259        assert_eq!(result.stdout, "traced\nnot_traced\n");
9260        assert!(result.stderr.contains("+ echo traced"));
9261        assert!(
9262            result.stderr.contains("+ set +x"),
9263            "set +x should be traced: {:?}",
9264            result.stderr
9265        );
9266        assert!(
9267            !result.stderr.contains("+ echo not_traced"),
9268            "echo after set +x should NOT be traced: {:?}",
9269            result.stderr
9270        );
9271    }
9272
9273    #[tokio::test]
9274    async fn test_xtrace_no_trace_without_flag() {
9275        let result = run_script("echo hello").await;
9276        assert_eq!(result.stdout, "hello\n");
9277        assert!(
9278            result.stderr.is_empty(),
9279            "no xtrace without set -x: {:?}",
9280            result.stderr
9281        );
9282    }
9283
9284    #[tokio::test]
9285    async fn test_xtrace_not_captured_by_redirect() {
9286        // 2>&1 should NOT capture xtrace (matches real bash behavior)
9287        let result = run_script("set -x; echo hello 2>&1").await;
9288        assert_eq!(result.stdout, "hello\n");
9289        assert!(
9290            result.stderr.contains("+ echo hello"),
9291            "xtrace should stay in stderr even with 2>&1: {:?}",
9292            result.stderr
9293        );
9294    }
9295
9296    // ==================== xargs execution tests ====================
9297
9298    #[tokio::test]
9299    async fn test_xargs_executes_command() {
9300        // xargs should execute the command, not echo it
9301        let fs = Arc::new(InMemoryFs::new());
9302        fs.mkdir(std::path::Path::new("/workspace"), true)
9303            .await
9304            .unwrap();
9305        fs.write_file(std::path::Path::new("/workspace/file.txt"), b"hello world")
9306            .await
9307            .unwrap();
9308
9309        let mut interp = Interpreter::new(fs.clone());
9310        let parser = Parser::new("echo /workspace/file.txt | xargs cat");
9311        let ast = parser.parse().unwrap();
9312        let result = interp.execute(&ast).await.unwrap();
9313
9314        assert_eq!(result.exit_code, 0);
9315        assert_eq!(
9316            result.stdout.trim(),
9317            "hello world",
9318            "xargs should execute cat, not echo it. Got: {:?}",
9319            result.stdout
9320        );
9321    }
9322
9323    #[tokio::test]
9324    async fn test_xargs_default_echo() {
9325        // With no command, xargs defaults to echo
9326        let result = run_script("echo 'a b c' | xargs").await;
9327        assert_eq!(result.exit_code, 0);
9328        assert_eq!(result.stdout.trim(), "a b c");
9329    }
9330
9331    #[tokio::test]
9332    async fn test_xargs_splits_newlines() {
9333        // xargs should split input on whitespace/newlines into separate args
9334        let fs = Arc::new(InMemoryFs::new());
9335        fs.mkdir(std::path::Path::new("/workspace"), true)
9336            .await
9337            .unwrap();
9338        fs.write_file(std::path::Path::new("/workspace/a.txt"), b"AAA")
9339            .await
9340            .unwrap();
9341        fs.write_file(std::path::Path::new("/workspace/b.txt"), b"BBB")
9342            .await
9343            .unwrap();
9344
9345        let mut interp = Interpreter::new(fs.clone());
9346        let script = "printf '/workspace/a.txt\\n/workspace/b.txt' | xargs cat";
9347        let parser = Parser::new(script);
9348        let ast = parser.parse().unwrap();
9349        let result = interp.execute(&ast).await.unwrap();
9350
9351        assert_eq!(result.exit_code, 0);
9352        assert!(
9353            result.stdout.contains("AAA"),
9354            "should contain contents of a.txt"
9355        );
9356        assert!(
9357            result.stdout.contains("BBB"),
9358            "should contain contents of b.txt"
9359        );
9360    }
9361
9362    #[tokio::test]
9363    async fn test_xargs_n1_executes_per_item() {
9364        // xargs -n 1 should execute once per argument
9365        let result = run_script("echo 'a b c' | xargs -n 1 echo item:").await;
9366        assert_eq!(result.exit_code, 0);
9367        let lines: Vec<&str> = result.stdout.trim().lines().collect();
9368        assert_eq!(lines.len(), 3);
9369        assert_eq!(lines[0], "item: a");
9370        assert_eq!(lines[1], "item: b");
9371        assert_eq!(lines[2], "item: c");
9372    }
9373
9374    #[tokio::test]
9375    async fn test_xargs_replace_str() {
9376        // xargs -I {} should substitute {} with each input line
9377        let fs = Arc::new(InMemoryFs::new());
9378        fs.mkdir(std::path::Path::new("/workspace"), true)
9379            .await
9380            .unwrap();
9381        fs.write_file(std::path::Path::new("/workspace/hello.txt"), b"Hello!")
9382            .await
9383            .unwrap();
9384
9385        let mut interp = Interpreter::new(fs.clone());
9386        let script = "echo /workspace/hello.txt | xargs -I {} cat {}";
9387        let parser = Parser::new(script);
9388        let ast = parser.parse().unwrap();
9389        let result = interp.execute(&ast).await.unwrap();
9390
9391        assert_eq!(result.exit_code, 0);
9392        assert_eq!(result.stdout.trim(), "Hello!");
9393    }
9394
9395    // ==================== find -exec tests ====================
9396
9397    #[tokio::test]
9398    async fn test_find_exec_per_file() {
9399        // find -exec cmd {} \; should execute cmd for each matched file
9400        let fs = Arc::new(InMemoryFs::new());
9401        fs.mkdir(std::path::Path::new("/project"), true)
9402            .await
9403            .unwrap();
9404        fs.write_file(std::path::Path::new("/project/a.txt"), b"content-a")
9405            .await
9406            .unwrap();
9407        fs.write_file(std::path::Path::new("/project/b.txt"), b"content-b")
9408            .await
9409            .unwrap();
9410
9411        let mut interp = Interpreter::new(fs.clone());
9412        interp.set_cwd(std::path::PathBuf::from("/"));
9413
9414        let script = r#"find /project -name "*.txt" -exec echo {} \;"#;
9415        let parser = Parser::new(script);
9416        let ast = parser.parse().unwrap();
9417        let result = interp.execute(&ast).await.unwrap();
9418
9419        assert_eq!(result.exit_code, 0);
9420        let lines: Vec<&str> = result.stdout.trim().lines().collect();
9421        assert_eq!(lines.len(), 2);
9422        assert!(result.stdout.contains("/project/a.txt"));
9423        assert!(result.stdout.contains("/project/b.txt"));
9424    }
9425
9426    #[tokio::test]
9427    async fn test_find_exec_batch_mode() {
9428        // find -exec cmd {} + should execute cmd once with all matched paths
9429        let fs = Arc::new(InMemoryFs::new());
9430        fs.mkdir(std::path::Path::new("/project"), true)
9431            .await
9432            .unwrap();
9433        fs.write_file(std::path::Path::new("/project/a.txt"), b"aaa")
9434            .await
9435            .unwrap();
9436        fs.write_file(std::path::Path::new("/project/b.txt"), b"bbb")
9437            .await
9438            .unwrap();
9439
9440        let mut interp = Interpreter::new(fs.clone());
9441        interp.set_cwd(std::path::PathBuf::from("/"));
9442
9443        let script = r#"find /project -name "*.txt" -exec echo {} +"#;
9444        let parser = Parser::new(script);
9445        let ast = parser.parse().unwrap();
9446        let result = interp.execute(&ast).await.unwrap();
9447
9448        assert_eq!(result.exit_code, 0);
9449        // Should be a single line with both paths
9450        let lines: Vec<&str> = result.stdout.trim().lines().collect();
9451        assert_eq!(lines.len(), 1);
9452        assert!(result.stdout.contains("/project/a.txt"));
9453        assert!(result.stdout.contains("/project/b.txt"));
9454    }
9455
9456    #[tokio::test]
9457    async fn test_find_exec_cat_reads_files() {
9458        // find -exec cat {} \; should actually read file contents
9459        let fs = Arc::new(InMemoryFs::new());
9460        fs.mkdir(std::path::Path::new("/data"), true).await.unwrap();
9461        fs.write_file(std::path::Path::new("/data/hello.txt"), b"Hello World")
9462            .await
9463            .unwrap();
9464
9465        let mut interp = Interpreter::new(fs.clone());
9466        interp.set_cwd(std::path::PathBuf::from("/"));
9467
9468        let script = r#"find /data -name "hello.txt" -exec cat {} \;"#;
9469        let parser = Parser::new(script);
9470        let ast = parser.parse().unwrap();
9471        let result = interp.execute(&ast).await.unwrap();
9472
9473        assert_eq!(result.exit_code, 0);
9474        assert_eq!(result.stdout, "Hello World");
9475    }
9476
9477    #[tokio::test]
9478    async fn test_find_exec_with_type_filter() {
9479        // find -type f -exec should only process files
9480        let fs = Arc::new(InMemoryFs::new());
9481        fs.mkdir(std::path::Path::new("/root/subdir"), true)
9482            .await
9483            .unwrap();
9484        fs.write_file(std::path::Path::new("/root/file.txt"), b"data")
9485            .await
9486            .unwrap();
9487
9488        let mut interp = Interpreter::new(fs.clone());
9489        interp.set_cwd(std::path::PathBuf::from("/"));
9490
9491        let script = r#"find /root -type f -exec echo found {} \;"#;
9492        let parser = Parser::new(script);
9493        let ast = parser.parse().unwrap();
9494        let result = interp.execute(&ast).await.unwrap();
9495
9496        assert_eq!(result.exit_code, 0);
9497        assert!(result.stdout.contains("found /root/file.txt"));
9498        assert!(!result.stdout.contains("found /root/subdir"));
9499    }
9500
9501    #[tokio::test]
9502    async fn test_find_exec_nonexistent_path() {
9503        let fs = Arc::new(InMemoryFs::new());
9504        let mut interp = Interpreter::new(fs.clone());
9505        interp.set_cwd(std::path::PathBuf::from("/"));
9506
9507        let script = r#"find /nonexistent -exec echo {} \;"#;
9508        let parser = Parser::new(script);
9509        let ast = parser.parse().unwrap();
9510        let result = interp.execute(&ast).await.unwrap();
9511
9512        assert_eq!(result.exit_code, 1);
9513        assert!(result.stderr.contains("No such file or directory"));
9514    }
9515
9516    #[tokio::test]
9517    async fn test_find_exec_no_matches() {
9518        let fs = Arc::new(InMemoryFs::new());
9519        fs.mkdir(std::path::Path::new("/empty"), true)
9520            .await
9521            .unwrap();
9522
9523        let mut interp = Interpreter::new(fs.clone());
9524        interp.set_cwd(std::path::PathBuf::from("/"));
9525
9526        let script = r#"find /empty -name "*.xyz" -exec echo {} \;"#;
9527        let parser = Parser::new(script);
9528        let ast = parser.parse().unwrap();
9529        let result = interp.execute(&ast).await.unwrap();
9530
9531        assert_eq!(result.exit_code, 0);
9532        assert_eq!(result.stdout, "");
9533    }
9534
9535    #[tokio::test]
9536    async fn test_find_exec_multiple_placeholder() {
9537        // {} can appear multiple times in the command template
9538        let fs = Arc::new(InMemoryFs::new());
9539        fs.mkdir(std::path::Path::new("/src"), true).await.unwrap();
9540        fs.write_file(std::path::Path::new("/src/test.txt"), b"hi")
9541            .await
9542            .unwrap();
9543
9544        let mut interp = Interpreter::new(fs.clone());
9545        interp.set_cwd(std::path::PathBuf::from("/"));
9546
9547        let script = r#"find /src -name "test.txt" -exec echo {} {} \;"#;
9548        let parser = Parser::new(script);
9549        let ast = parser.parse().unwrap();
9550        let result = interp.execute(&ast).await.unwrap();
9551
9552        assert_eq!(result.exit_code, 0);
9553        assert_eq!(result.stdout.trim(), "/src/test.txt /src/test.txt");
9554    }
9555
9556    #[tokio::test]
9557    async fn test_star_join_with_ifs() {
9558        // "$*" joins with IFS first char; empty IFS = no separator
9559        let result = run_script("set -- x y z\nIFS=:\necho \"$*\"").await;
9560        assert_eq!(result.stdout, "x:y:z\n");
9561        let result = run_script("set -- x y z\nIFS=\necho \"$*\"").await;
9562        assert_eq!(result.stdout, "xyz\n");
9563        // echo ["$*"] — brackets are literal, quotes are stripped
9564        let result = run_script("set -- x y z\necho [\"$*\"]").await;
9565        assert_eq!(result.stdout, "[x y z]\n");
9566        // "$*" in assignment
9567        let result = run_script("IFS=:\nset -- x 'y z'\ns=\"$*\"\necho \"star=$s\"").await;
9568        assert_eq!(result.stdout, "star=x:y z\n");
9569        // set a b c (without --)
9570        let result = run_script("set a b c\necho $#\necho $1 $2 $3").await;
9571        assert_eq!(result.stdout, "3\na b c\n");
9572    }
9573
9574    #[tokio::test]
9575    async fn test_arithmetic_exponent_negative_no_panic() {
9576        let result = run_script("echo $(( 2 ** -1 ))").await;
9577        assert_eq!(result.exit_code, 0);
9578    }
9579
9580    #[tokio::test]
9581    async fn test_arithmetic_exponent_large_no_panic() {
9582        let result = run_script("echo $(( 2 ** 100 ))").await;
9583        assert_eq!(result.exit_code, 0);
9584    }
9585
9586    #[tokio::test]
9587    async fn test_arithmetic_shift_large_no_panic() {
9588        let result = run_script("echo $(( 1 << 64 ))").await;
9589        assert_eq!(result.exit_code, 0);
9590    }
9591
9592    #[tokio::test]
9593    async fn test_arithmetic_shift_negative_no_panic() {
9594        let result = run_script("echo $(( 1 << -1 ))").await;
9595        assert_eq!(result.exit_code, 0);
9596    }
9597
9598    #[tokio::test]
9599    async fn test_arithmetic_div_min_neg1_no_panic() {
9600        let result = run_script("echo $(( -9223372036854775808 / -1 ))").await;
9601        assert_eq!(result.exit_code, 0);
9602    }
9603
9604    #[tokio::test]
9605    async fn test_arithmetic_mod_min_neg1_no_panic() {
9606        let result = run_script("echo $(( -9223372036854775808 % -1 ))").await;
9607        assert_eq!(result.exit_code, 0);
9608    }
9609
9610    #[tokio::test]
9611    async fn test_arithmetic_overflow_add_no_panic() {
9612        let result = run_script("echo $(( 9223372036854775807 + 1 ))").await;
9613        assert_eq!(result.exit_code, 0);
9614    }
9615
9616    #[tokio::test]
9617    async fn test_arithmetic_overflow_mul_no_panic() {
9618        let result = run_script("echo $(( 9223372036854775807 * 2 ))").await;
9619        assert_eq!(result.exit_code, 0);
9620    }
9621
9622    /// Regression test for fuzz crash: base > 36 in arithmetic
9623    /// (crash-802347e7f64e6cb69da447b343e4f16081ffe48d)
9624    #[tokio::test]
9625    async fn test_arithmetic_base_gt_36_no_panic() {
9626        let result = run_script("echo $(( 64#A ))").await;
9627        assert_eq!(result.exit_code, 0);
9628        // 64#A = 36 (A is position 36 in the extended charset)
9629        assert_eq!(result.stdout.trim(), "36");
9630    }
9631
9632    #[tokio::test]
9633    async fn test_arithmetic_base_gt_36_special_chars() {
9634        // @ = 62, _ = 63 in bash base-64 encoding
9635        let result = run_script("echo $(( 64#@ ))").await;
9636        assert_eq!(result.stdout.trim(), "62");
9637        let result = run_script("echo $(( 64#_ ))").await;
9638        assert_eq!(result.stdout.trim(), "63");
9639    }
9640
9641    #[tokio::test]
9642    async fn test_arithmetic_base_gt_36_invalid_digit() {
9643        // Invalid char for base — should return 0
9644        let result = run_script("echo $(( 37#! ))").await;
9645        assert_eq!(result.exit_code, 0);
9646    }
9647
9648    #[tokio::test]
9649    async fn test_eval_respects_parser_limits() {
9650        let fs: Arc<dyn FileSystem> = Arc::new(InMemoryFs::new());
9651        let mut interp = Interpreter::new(Arc::clone(&fs));
9652        interp.limits.max_ast_depth = 5;
9653        let parser = Parser::new("eval 'echo hello'");
9654        let ast = parser.parse().unwrap();
9655        let result = interp.execute(&ast).await.unwrap();
9656        assert_eq!(result.exit_code, 0);
9657    }
9658
9659    #[tokio::test]
9660    async fn test_source_respects_parser_limits() {
9661        let fs: Arc<dyn FileSystem> = Arc::new(InMemoryFs::new());
9662        fs.write_file(std::path::Path::new("/tmp/test.sh"), b"echo sourced")
9663            .await
9664            .unwrap();
9665        let mut interp = Interpreter::new(Arc::clone(&fs));
9666        interp.limits.max_ast_depth = 5;
9667        let parser = Parser::new("source /tmp/test.sh");
9668        let ast = parser.parse().unwrap();
9669        let result = interp.execute(&ast).await.unwrap();
9670        assert_eq!(result.exit_code, 0);
9671        assert_eq!(result.stdout.trim(), "sourced");
9672    }
9673
9674    #[tokio::test]
9675    async fn test_internal_var_prefix_not_exposed() {
9676        // ${!_NAMEREF*} must not expose internal markers
9677        let result = run_script("echo \"${!_NAMEREF*}\"").await;
9678        assert_eq!(result.stdout.trim(), "");
9679    }
9680
9681    #[tokio::test]
9682    async fn test_internal_var_readonly_not_exposed() {
9683        let result = run_script("echo \"${!_READONLY*}\"").await;
9684        assert_eq!(result.stdout.trim(), "");
9685    }
9686
9687    #[tokio::test]
9688    async fn test_internal_var_assignment_blocked() {
9689        // Direct assignment to _NAMEREF_ prefix should be silently ignored
9690        let result = run_script("_NAMEREF_x=PATH; echo ${!x}").await;
9691        assert!(!result.stdout.contains("/usr"));
9692    }
9693
9694    #[tokio::test]
9695    async fn test_internal_var_readonly_injection_blocked() {
9696        // Should not be able to fake readonly
9697        let result = run_script("_READONLY_myvar=1; myvar=hello; echo $myvar").await;
9698        assert_eq!(result.stdout.trim(), "hello");
9699    }
9700
9701    #[tokio::test]
9702    async fn test_extglob_no_hang() {
9703        use std::time::{Duration, Instant};
9704        let start = Instant::now();
9705        let result = run_script(
9706            r#"shopt -s extglob; [[ "aaaaaaaaaaaa" == +(a|aa) ]] && echo yes || echo no"#,
9707        )
9708        .await;
9709        let elapsed = start.elapsed();
9710        assert!(
9711            elapsed < Duration::from_secs(5),
9712            "extglob took too long: {:?}",
9713            elapsed
9714        );
9715        assert_eq!(result.exit_code, 0);
9716    }
9717
9718    // Issue #425: $$ should not leak real host PID
9719    #[tokio::test]
9720    async fn test_dollar_dollar_no_host_pid_leak() {
9721        let mut bash = crate::Bash::new();
9722        let result = bash.exec("echo $$").await.unwrap();
9723        let pid: u32 = result.stdout.trim().parse().unwrap();
9724        // Should be sandboxed value (1), not real PID
9725        assert_eq!(pid, 1, "$$ should return sandboxed PID, not real host PID");
9726    }
9727
9728    // Issue #426: cyclic nameref should not resolve to wrong variable
9729    #[tokio::test]
9730    async fn test_cyclic_nameref_detected() {
9731        let mut bash = crate::Bash::new();
9732        // Create cycle: a -> b -> a
9733        let result = bash
9734            .exec("declare -n a=b; declare -n b=a; a=hello; echo $a")
9735            .await
9736            .unwrap();
9737        // With the bug, this would silently resolve to an arbitrary variable.
9738        // With the fix, the cycle is detected and 'a' resolves to itself.
9739        assert_eq!(result.exit_code, 0);
9740    }
9741
9742    // Issue #437: arithmetic expansion byte/char index mismatch
9743    #[tokio::test]
9744    async fn test_arithmetic_compound_assign_ascii() {
9745        let mut bash = crate::Bash::new();
9746        let result = bash.exec("x=10; (( x += 5 )); echo $x").await.unwrap();
9747        assert_eq!(result.stdout.trim(), "15");
9748    }
9749
9750    #[tokio::test]
9751    async fn test_getopts_while_loop() {
9752        // Issue #397: getopts in while loop should iterate over all options
9753        let mut bash = crate::Bash::new();
9754        let result = bash
9755            .exec(
9756                r#"
9757set -- -f json -v
9758while getopts "f:vh" opt; do
9759  case "$opt" in
9760    f) FORMAT="$OPTARG" ;;
9761    v) VERBOSE=1 ;;
9762  esac
9763done
9764echo "FORMAT=$FORMAT VERBOSE=$VERBOSE"
9765"#,
9766            )
9767            .await
9768            .unwrap();
9769        assert_eq!(result.exit_code, 0);
9770        assert_eq!(result.stdout.trim(), "FORMAT=json VERBOSE=1");
9771    }
9772
9773    #[tokio::test]
9774    async fn test_getopts_script_with_args() {
9775        // Issue #397: getopts via bash -c with script args
9776        let mut bash = crate::Bash::new();
9777        // Write a script that uses getopts, then invoke it with arguments
9778        let result = bash
9779            .exec(
9780                r#"
9781cat > /tmp/test_getopts.sh << 'SCRIPT'
9782while getopts "f:vh" opt; do
9783  case "$opt" in
9784    f) FORMAT="$OPTARG" ;;
9785    v) VERBOSE=1 ;;
9786  esac
9787done
9788echo "FORMAT=$FORMAT VERBOSE=$VERBOSE"
9789SCRIPT
9790bash /tmp/test_getopts.sh -f json -v
9791"#,
9792            )
9793            .await
9794            .unwrap();
9795        assert_eq!(result.stdout.trim(), "FORMAT=json VERBOSE=1");
9796    }
9797
9798    #[tokio::test]
9799    async fn test_getopts_bash_c_with_args() {
9800        // Issue #397: getopts via bash -c 'script' -- args
9801        let mut bash = crate::Bash::new();
9802        let result = bash
9803            .exec(
9804                r#"bash -c '
9805FORMAT="csv"
9806VERBOSE=0
9807while getopts "f:vh" opt; do
9808    case "$opt" in
9809        f) FORMAT="$OPTARG" ;;
9810        v) VERBOSE=1 ;;
9811    esac
9812done
9813echo "FORMAT=$FORMAT VERBOSE=$VERBOSE"
9814' -- -f json -v"#,
9815            )
9816            .await
9817            .unwrap();
9818        assert_eq!(result.stdout.trim(), "FORMAT=json VERBOSE=1");
9819    }
9820
9821    #[tokio::test]
9822    async fn test_getopts_optind_reset_between_scripts() {
9823        // Issue #397: OPTIND persists across bash script invocations, causing
9824        // getopts to skip all options on the second run
9825        let mut bash = crate::Bash::new();
9826        let result = bash
9827            .exec(
9828                r#"
9829cat > /tmp/opts.sh << 'SCRIPT'
9830FORMAT="csv"
9831VERBOSE=0
9832while getopts "f:vh" opt; do
9833    case "$opt" in
9834        f) FORMAT="$OPTARG" ;;
9835        v) VERBOSE=1 ;;
9836    esac
9837done
9838echo "FORMAT=$FORMAT VERBOSE=$VERBOSE"
9839SCRIPT
9840bash /tmp/opts.sh -f json -v
9841bash /tmp/opts.sh -f xml -v
9842"#,
9843            )
9844            .await
9845            .unwrap();
9846        let lines: Vec<&str> = result.stdout.trim().lines().collect();
9847        assert_eq!(lines.len(), 2, "expected 2 lines: {}", result.stdout);
9848        assert_eq!(lines[0], "FORMAT=json VERBOSE=1");
9849        assert_eq!(lines[1], "FORMAT=xml VERBOSE=1");
9850    }
9851
9852    #[tokio::test]
9853    async fn test_wc_l_in_pipe() {
9854        let mut bash = crate::Bash::new();
9855        let result = bash.exec(r#"echo -e "a\nb\nc" | wc -l"#).await.unwrap();
9856        assert_eq!(result.exit_code, 0);
9857        assert_eq!(result.stdout.trim(), "3");
9858    }
9859
9860    #[tokio::test]
9861    async fn test_wc_l_in_pipe_subst() {
9862        let mut bash = crate::Bash::new();
9863        let result = bash
9864            .exec(
9865                r#"
9866cat > /tmp/data.csv << 'EOF'
9867name,score
9868alice,95
9869bob,87
9870carol,92
9871EOF
9872COUNT=$(tail -n +2 /tmp/data.csv | wc -l)
9873echo "count=$COUNT"
9874"#,
9875            )
9876            .await
9877            .unwrap();
9878        assert_eq!(result.exit_code, 0);
9879        assert_eq!(result.stdout.trim(), "count=3");
9880    }
9881
9882    #[tokio::test]
9883    async fn test_wc_l_counts_newlines() {
9884        let mut bash = crate::Bash::new();
9885        let result = bash.exec(r#"printf "a\nb\nc" | wc -l"#).await.unwrap();
9886        assert_eq!(result.stdout.trim(), "2");
9887    }
9888
9889    #[tokio::test]
9890    async fn test_regex_match_from_variable() {
9891        let mut bash = crate::Bash::new();
9892        let result = bash
9893            .exec(r#"re="200"; line="hello 200 world"; [[ $line =~ $re ]] && echo "match" || echo "no""#)
9894            .await
9895            .unwrap();
9896        assert_eq!(result.stdout.trim(), "match");
9897    }
9898
9899    #[tokio::test]
9900    async fn test_regex_match_literal() {
9901        let mut bash = crate::Bash::new();
9902        let result = bash
9903            .exec(r#"line="hello 200 world"; [[ $line =~ 200 ]] && echo "match" || echo "no""#)
9904            .await
9905            .unwrap();
9906        assert_eq!(result.stdout.trim(), "match");
9907    }
9908
9909    #[tokio::test]
9910    async fn test_assoc_array_in_double_quotes() {
9911        let mut bash = crate::Bash::new();
9912        let result = bash
9913            .exec(r#"declare -A arr; arr["foo"]="bar"; echo "value: ${arr["foo"]}""#)
9914            .await
9915            .unwrap();
9916        assert_eq!(result.stdout.trim(), "value: bar");
9917    }
9918
9919    #[tokio::test]
9920    async fn test_assoc_array_keys_in_quotes() {
9921        let mut bash = crate::Bash::new();
9922        let result = bash
9923            .exec(r#"declare -A arr; arr["a"]=1; arr["b"]=2; echo "keys: ${!arr[@]}""#)
9924            .await
9925            .unwrap();
9926        let output = result.stdout.trim();
9927        assert!(output.starts_with("keys: "), "got: {}", output);
9928        assert!(output.contains("a"), "got: {}", output);
9929        assert!(output.contains("b"), "got: {}", output);
9930    }
9931
9932    #[tokio::test]
9933    async fn test_glob_with_quoted_prefix() {
9934        let mut bash = crate::Bash::new();
9935        bash.fs()
9936            .mkdir(std::path::Path::new("/testdir"), true)
9937            .await
9938            .unwrap();
9939        bash.fs()
9940            .write_file(std::path::Path::new("/testdir/a.txt"), b"a")
9941            .await
9942            .unwrap();
9943        bash.fs()
9944            .write_file(std::path::Path::new("/testdir/b.txt"), b"b")
9945            .await
9946            .unwrap();
9947        let result = bash
9948            .exec(r#"DIR="/testdir"; for f in "$DIR"/*; do echo "$f"; done"#)
9949            .await
9950            .unwrap();
9951        let mut lines: Vec<&str> = result.stdout.trim().lines().collect();
9952        lines.sort();
9953        assert_eq!(lines, vec!["/testdir/a.txt", "/testdir/b.txt"]);
9954    }
9955}