diff --git a/crates/bashkit-js/__test__/security.spec.ts b/crates/bashkit-js/__test__/security.spec.ts index afe0e53b7..0074a4249 100644 --- a/crates/bashkit-js/__test__/security.spec.ts +++ b/crates/bashkit-js/__test__/security.spec.ts @@ -959,21 +959,20 @@ test("BB: arithmetic overflow (TM-DOS-029)", (t) => { test("BB: division by zero does not crash (TM-DOS-029)", (t) => { const bash = new Bash(); const r = bash.executeSync("echo $((1 / 0)) 2>&1"); - // Reports like bash. Key security property: no crash or panic. - t.is(typeof r.exitCode, "number", "division by zero must not crash"); - t.not(r.exitCode, 0, "division by zero fails"); - t.is(r.stdout, "", "no value is printed"); - t.true(r.stderr.includes("division by 0"), "the error is reported"); + // Like bash: an expansion error aborts the line with exit 1. + // Key security property: interpreter must not crash or panic. + t.is(r.exitCode, 1, "division by zero must fail, not crash"); + t.is(r.stdout, "", "the aborted echo prints nothing"); + t.true(r.stderr.includes("division by 0")); }); test("BB: modulo by zero does not crash (TM-DOS-029)", (t) => { const bash = new Bash(); const r = bash.executeSync("echo $((1 % 0)) 2>&1"); - // Same as div-by-zero: reports and fails. - t.is(typeof r.exitCode, "number", "modulo by zero must not crash"); - t.not(r.exitCode, 0, "modulo by zero fails"); - t.is(r.stdout, "", "no value is printed"); - t.true(r.stderr.includes("division by 0"), "the error is reported"); + // Same as div-by-zero: the line aborts with exit 1, like bash. + t.is(r.exitCode, 1, "modulo by zero must fail, not crash"); + t.is(r.stdout, "", "the aborted echo prints nothing"); + t.true(r.stderr.includes("division by 0")); }); test("BB: negative exponent", (t) => { diff --git a/crates/bashkit-python/tests/_security_core.py b/crates/bashkit-python/tests/_security_core.py index 9a7fc0a58..0b4b44439 100644 --- a/crates/bashkit-python/tests/_security_core.py +++ b/crates/bashkit-python/tests/_security_core.py @@ -253,10 +253,10 @@ def test_tm_dos_029_arithmetic_overflow_does_not_crash(): def test_tm_dos_029_division_by_zero_does_not_crash(): - """TM-DOS-029: division by zero must report like bash, not panic.""" + """TM-DOS-029: division by zero must return an error value, not panic.""" r = Bash().execute_sync("echo $((1 / 0)) 2>&1") - assert isinstance(r.exit_code, int) - assert r.exit_code != 0 + # Like bash: the expansion error aborts the line with exit 1. + assert r.exit_code == 1 assert r.stdout == "" assert "division by 0" in r.stderr _assert_sanitized_error(r.stderr) @@ -265,8 +265,8 @@ def test_tm_dos_029_division_by_zero_does_not_crash(): def test_tm_dos_029_modulo_by_zero_does_not_crash(): """TM-DOS-029: modulo by zero must not unwind through the binding.""" r = Bash().execute_sync("echo $((1 % 0)) 2>&1") - assert isinstance(r.exit_code, int) - assert r.exit_code != 0 + # Like bash: the expansion error aborts the line with exit 1. + assert r.exit_code == 1 assert r.stdout == "" assert "division by 0" in r.stderr _assert_sanitized_error(r.stderr) @@ -275,8 +275,10 @@ def test_tm_dos_029_modulo_by_zero_does_not_crash(): def test_tm_dos_029_negative_exponent_does_not_crash(): """TM-DOS-029: unsupported arithmetic should stay bounded.""" r = Bash().execute_sync("echo $((2 ** -1)) 2>&1") - assert isinstance(r.exit_code, int) - assert r.stdout.strip() == "0" + # Like bash: a negative exponent is an error that aborts the line. + assert r.exit_code == 1 + assert r.stdout == "" + assert "exponent less than 0" in r.stderr _assert_sanitized_error(r.stderr) diff --git a/crates/bashkit/benches/results/criterion-parallel-vm-linux-x86_64-1791401002.md b/crates/bashkit/benches/results/criterion-parallel-vm-linux-x86_64-1791401002.md new file mode 100644 index 000000000..c78e31911 --- /dev/null +++ b/crates/bashkit/benches/results/criterion-parallel-vm-linux-x86_64-1791401002.md @@ -0,0 +1,84 @@ +# Criterion Parallel Execution Benchmark + +## System Information + +- **Moniker**: `vm-linux-x86_64` +- **Hostname**: vm +- **OS**: linux +- **Architecture**: x86_64 +- **CPUs**: 4 +- **Timestamp**: 1791401002 + +## Workload Comparison (50 sessions) + +| Benchmark | Time | +|-----------|------| +| workload_types/light_sequential | 5.9398 ms | +| workload_types/light_parallel | 4.4518 ms | +| workload_types/medium_sequential | 21.632 ms | +| workload_types/medium_parallel | 17.490 ms | +| workload_types/heavy_sequential | 65.949 ms | +| workload_types/heavy_parallel | 49.142 ms | + +## Parallel Scaling (medium workload) + +| Benchmark | Time | +|-----------|------| +| parallel_scaling/medium_seq/10 | 4.3466 ms | +| parallel_scaling/medium_par/10 | 2.8989 ms | +| parallel_scaling/shared_fs/10 | 2.1865 ms | +| parallel_scaling/medium_seq/50 | 22.751 ms | +| parallel_scaling/medium_par/50 | 14.542 ms | +| parallel_scaling/shared_fs/50 | 9.2122 ms | +| parallel_scaling/medium_seq/100 | 48.931 ms | +| parallel_scaling/medium_par/100 | 28.244 ms | +| parallel_scaling/shared_fs/100 | 18.105 ms | +| parallel_scaling/medium_seq/200 | 101.58 ms | +| parallel_scaling/medium_par/200 | 61.706 ms | +| parallel_scaling/shared_fs/200 | 34.450 ms | +| parallel_scaling/medium_seq/500 | 221.65 ms | +| parallel_scaling/medium_par/500 | 103.14 ms | +| parallel_scaling/shared_fs/500 | 99.824 ms | +| parallel_scaling/medium_seq/1000 | 416.20 ms | +| parallel_scaling/medium_par/1000 | 286.09 ms | +| parallel_scaling/shared_fs/1000 | 211.56 ms | + +## Single Operations + +| Benchmark | Time | +|-----------|------| +| single_bash_new | 68.289 µs | +| single_echo | 83.043 µs | +| single_file_write_read | 128.29 µs | +| single_grep | 115.58 µs | +| single_awk | 121.48 µs | +| single_sed | 114.34 µs | +| single_light_script | 110.54 µs | +| single_medium_script | 470.18 µs | +| single_heavy_script | 1.3484 ms | + +## Speedup Summary + +| Workload | Sequential | Parallel | Speedup | +|----------|-----------|----------|---------| +| light | 5.940 ms | 4.452 ms | **1.33x** | +| medium | 21.632 ms | 17.490 ms | **1.24x** | +| heavy | 65.949 ms | 49.142 ms | **1.34x** | + +| Sessions | Sequential | Parallel | Shared FS | Par Speedup | +|----------|-----------|----------|-----------|-------------| +| 10 | 4.347 ms | 2.899 ms | 2.187 ms | **1.50x** | +| 50 | 22.751 ms | 14.542 ms | 9.212 ms | **1.56x** | +| 100 | 48.931 ms | 28.244 ms | 18.105 ms | **1.73x** | +| 200 | 101.580 ms | 61.706 ms | 34.450 ms | **1.65x** | +| 500 | 221.650 ms | 103.140 ms | 99.824 ms | **2.15x** | +| 1000 | 416.200 ms | 286.090 ms | 211.560 ms | **1.45x** | + +## Notes + +Branch `claude/project-thread-eafupr-bb-vars` (declaration engine, shallow-binding +locals, `env` as `Arc`) after merging main 8d47605. Host load average was ~8 on +4 CPUs during the run, so single runs swing about 25%. An interleaved A/B run of +the main and branch bench binaries (two rounds each) put every `workload_types` +average within 3% of main (light_seq 5.67 vs 5.46 ms, medium_par 15.3 vs 15.8 ms, +heavy_seq 60.7 vs 61.7 ms, heavy_par 42.1 vs 41.7 ms): no regression beyond noise. diff --git a/crates/bashkit/src/analysis.rs b/crates/bashkit/src/analysis.rs index dbc0cdf3c..4d3058546 100644 --- a/crates/bashkit/src/analysis.rs +++ b/crates/bashkit/src/analysis.rs @@ -576,6 +576,11 @@ impl Walker { self.out.has_command_substitution = true; self.out.has_dynamic_commands = true; } + WordPart::CompoundAssignment { elements, .. } => { + for element in elements { + self.walk_word_parts(element, ctx); + } + } _ => {} } } @@ -706,6 +711,15 @@ mod tests { assert_eq!(analysis.commands[1].assignments, ["out"]); } + #[test] + fn substitution_in_declared_compound_array_is_walked() { + // `declare -a x=(...)` keeps its elements in a CompoundAssignment + // part; a substitution inside must still reach the permission gate. + let analysis = a("declare -a x=(a $(rm -rf /data) [3]=`curl http://x`)"); + assert!(analysis.has_command_substitution); + assert_eq!(analysis.command_names(), ["rm", "curl", "declare"]); + } + #[test] fn dynamic_command_name_is_null_and_flagged() { let analysis = a("$cmd --force"); diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index b63c26597..379913773 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -266,6 +266,7 @@ pub use trap::Trap; pub use tree::Tree; pub use truncate::Truncate; pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset}; +pub(crate) use vars::{SET_O_OPTIONS, dollar_dash}; pub use verify::Verify; #[cfg(feature = "terminal")] pub use vi::Vi; diff --git a/crates/bashkit/src/builtins/navigation.rs b/crates/bashkit/src/builtins/navigation.rs index f3bca5d73..b83f2c806 100644 --- a/crates/bashkit/src/builtins/navigation.rs +++ b/crates/bashkit/src/builtins/navigation.rs @@ -13,13 +13,16 @@ pub struct Cd; #[async_trait] impl Builtin for Cd { async fn execute(&self, ctx: Context<'_>) -> Result { - let target = ctx + let Some(target) = ctx .args .first() .map(|s| s.as_str()) .or_else(|| ctx.variables.get("HOME").map(|s| s.as_str())) .or_else(|| ctx.env.get("HOME").map(|s| s.as_str())) - .unwrap_or("/home/user"); + else { + // Bare `cd` with HOME unset fails, as in bash. + return Ok(ExecResult::err("bash: cd: HOME not set\n", 1)); + }; let new_path = if target.starts_with('/') { PathBuf::from(target) diff --git a/crates/bashkit/src/builtins/read.rs b/crates/bashkit/src/builtins/read.rs index 5deb7fa9d..9f2d4766a 100644 --- a/crates/bashkit/src/builtins/read.rs +++ b/crates/bashkit/src/builtins/read.rs @@ -4,12 +4,18 @@ //! waits for a line typed on the terminal: `-p` prints the prompt there, //! `-s` turns echo off, `-n N` returns after N characters, `-t SECS` gives //! up with status 142. Outside a terminal no stdin is EOF (status 1). +//! +//! Options are read only before the first name (bash's getopt), so in +//! `read a -r` the `-r` is a name. Names are checked as they are assigned: +//! the ones before an invalid name get their fields, the rest are left +//! alone, and read fails with status 1. `-N n` reads exactly n characters +//! with no delimiter and no field splitting. use async_trait::async_trait; use super::{Builtin, BuiltinSideEffect, Context}; use crate::error::Result; -use crate::interpreter::{ExecResult, is_internal_variable}; +use crate::interpreter::{ExecResult, is_internal_variable, is_valid_var_name}; /// read builtin - read a line of input into variables pub struct Read; @@ -26,13 +32,17 @@ impl Builtin for Read { let mut array_mode = false; // -a: read into array let mut delimiter = None::; // -d: custom delimiter let mut nchars = None::; // -n: read N chars + let mut exact_nchars = false; // -N: exactly N chars, no splitting let mut prompt = None::; // -p prompt let mut silent = false; // -s: no echo (terminal input only) let mut timeout = None::; // -t SECS (terminal input only) let mut var_args = Vec::new(); let mut args_iter = ctx.args.iter(); + let mut names_started = false; while let Some(arg) = args_iter.next() { - if arg.starts_with('-') && arg.len() > 1 { + if !names_started && arg == "--" { + names_started = true; + } else if !names_started && arg.starts_with('-') && arg.len() > 1 { let mut chars = arg[1..].chars(); while let Some(flag) = chars.next() { match flag { @@ -49,7 +59,8 @@ impl Builtin for Read { delimiter = delim_str.chars().next(); break; } - 'n' => { + 'n' | 'N' => { + exact_nchars = flag == 'N'; let rest: String = chars.collect(); let n_str = if rest.is_empty() { args_iter.next().map(|s| s.as_str()).unwrap_or("0") @@ -92,9 +103,16 @@ impl Builtin for Read { } } } else { + names_started = true; var_args.push(arg.as_str()); } } + if array_mode + && let Some(name) = var_args.first() + && !valid_read_name(name, true) + { + return Ok(ExecResult::err(invalid_name(name), 1)); + } #[cfg(feature = "terminal")] if input.is_none() && let Some(tty) = ctx.execution_extension::() @@ -150,6 +168,10 @@ impl Builtin for Read { }; let mut result = ExecResult::err("", 1); for var_name in &var_names { + if !valid_read_name(var_name, false) { + result.stderr = invalid_name(var_name).into(); + break; + } if is_internal_variable(var_name) { continue; } @@ -165,8 +187,13 @@ impl Builtin for Read { // One record up to the delimiter (newline by default) or N chars. // Data that ends before the delimiter is still assigned, but the // status is 1, as in bash. - let (line, terminated) = - read_record(&input, delimiter.unwrap_or('\n'), nchars, raw_mode, ifs); + // -N ignores the delimiter (consumed_len uses NUL the same way). + let delim = if exact_nchars { + '\0' + } else { + delimiter.unwrap_or('\n') + }; + let (line, terminated) = read_record(&input, delim, nchars, raw_mode, ifs); let status = if terminated { 0 } else { 1 }; struct ReadField<'a> { @@ -174,7 +201,18 @@ impl Builtin for Read { start: usize, } - let words: Vec> = if ifs.is_empty() { + let words: Vec> = if exact_nchars { + // -N: the characters read are one field, delimiters and IFS + // included. + if line.is_empty() { + Vec::new() + } else { + vec![ReadField { + text: &line, + start: 0, + }] + } + } else if ifs.is_empty() { // Empty IFS means no word splitting vec![ReadField { text: &line, @@ -325,6 +363,11 @@ impl Builtin for Read { let mut result = ExecResult::ok(String::new()); result.exit_code = status; for (i, var_name) in var_names.iter().enumerate() { + if !valid_read_name(var_name, false) { + result.stderr = invalid_name(var_name).into(); + result.exit_code = 1; + break; + } // THREAT[TM-INJ-009]: Block internal variable prefix injection via read if is_internal_variable(var_name) { continue; @@ -362,6 +405,21 @@ impl Builtin for Read { } } +/// A name `read` can assign: an identifier, or `name[subscript]` unless +/// reading into an array (`-a`). +fn valid_read_name(name: &str, array: bool) -> bool { + match name.find('[') { + Some(b) if !array => { + is_valid_var_name(&name[..b]) && name.ends_with(']') && name.len() > b + 2 + } + _ => is_valid_var_name(name), + } +} + +fn invalid_name(name: &str) -> String { + format!("bash: read: `{name}': not a valid identifier\n") +} + /// Bytes of `input` one `read` with these `args` consumes from a shared /// stdin (pipe feeding a loop): the record plus its delimiter, following /// `-r`, `-d`, `-n` and `\` continuation like [`read_record`]. diff --git a/crates/bashkit/src/builtins/vars.rs b/crates/bashkit/src/builtins/vars.rs index f3ca41bbf..b2df1efb1 100644 --- a/crates/bashkit/src/builtins/vars.rs +++ b/crates/bashkit/src/builtins/vars.rs @@ -67,75 +67,126 @@ impl Builtin for Unset { /// - `set --` - set positional parameters pub struct Set; -/// Map long option names to their SHOPT_* variable names -fn option_name_to_var(name: &str) -> Option<&'static str> { - match name { - "allexport" => Some("SHOPT_a"), - "errexit" => Some("SHOPT_e"), - "nounset" => Some("SHOPT_u"), - "xtrace" => Some("SHOPT_x"), - "verbose" => Some("SHOPT_v"), - "pipefail" => Some("SHOPT_pipefail"), - "noclobber" => Some("SHOPT_C"), - "noglob" => Some("SHOPT_f"), - "noexec" => Some("SHOPT_n"), - _ => None, +/// Every `set -o` option in bash's listing order: (name, letter, variable, +/// on by default). Options with no effect in the sandbox (`hashall`, +/// `keyword`, `monitor`, ...) are still recorded so `$-`, `set -o` and +/// `shopt -o` report them like bash. +pub(crate) const SET_O_OPTIONS: &[(&str, Option, &str, bool)] = &[ + ("allexport", Some('a'), "SHOPT_a", false), + ("braceexpand", Some('B'), "SHOPT_B", true), + ("emacs", None, "SHOPT_emacs", false), + ("errexit", Some('e'), "SHOPT_e", false), + ("errtrace", Some('E'), "SHOPT_E", false), + ("functrace", Some('T'), "SHOPT_T", false), + ("hashall", Some('h'), "SHOPT_h", true), + ("histexpand", Some('H'), "SHOPT_H", false), + ("history", None, "SHOPT_history", false), + ("ignoreeof", None, "SHOPT_ignoreeof", false), + ( + "interactive-comments", + None, + "SHOPT_interactive_comments", + true, + ), + ("keyword", Some('k'), "SHOPT_k", false), + ("monitor", Some('m'), "SHOPT_m", false), + ("noclobber", Some('C'), "SHOPT_C", false), + ("noexec", Some('n'), "SHOPT_n", false), + ("noglob", Some('f'), "SHOPT_f", false), + ("nolog", None, "SHOPT_nolog", false), + ("notify", Some('b'), "SHOPT_b", false), + ("nounset", Some('u'), "SHOPT_u", false), + ("onecmd", Some('t'), "SHOPT_t", false), + ("physical", Some('P'), "SHOPT_P", false), + ("pipefail", None, "SHOPT_pipefail", false), + ("posix", None, "SHOPT_posix", false), + ("privileged", Some('p'), "SHOPT_p", false), + ("verbose", Some('v'), "SHOPT_v", false), + ("vi", None, "SHOPT_vi", false), + ("xtrace", Some('x'), "SHOPT_x", false), +]; + +/// Letters in the order bash prints them in `$-`. +const DOLLAR_DASH_ORDER: &str = "abefhikmnptuvxBCEHPT"; + +fn set_option_by_name( + name: &str, +) -> Option<&'static (&'static str, Option, &'static str, bool)> { + SET_O_OPTIONS.iter().find(|o| o.0 == name) +} + +fn set_option_by_letter( + c: char, +) -> Option<&'static (&'static str, Option, &'static str, bool)> { + SET_O_OPTIONS.iter().find(|o| o.1 == Some(c)) +} + +fn set_option_on( + variables: &std::collections::HashMap, + var: &str, + default: bool, +) -> bool { + match variables.get(var).map(String::as_str) { + Some("1") => true, + Some("0") => false, + _ => default, } } -/// Map accepted short `set` option flags to their transient SHOPT_* variables. -fn short_option_to_var(opt: char) -> Option<&'static str> { - match opt { - 'a' => Some("SHOPT_a"), - 'b' => Some("SHOPT_b"), - 'e' => Some("SHOPT_e"), - 'f' => Some("SHOPT_f"), - 'h' => Some("SHOPT_h"), - 'm' => Some("SHOPT_m"), - 'n' => Some("SHOPT_n"), - 'u' => Some("SHOPT_u"), - 'v' => Some("SHOPT_v"), - 'x' => Some("SHOPT_x"), - 'C' => Some("SHOPT_C"), - _ => None, +/// The value of `$-`: set option letters in bash order, then `c` (every +/// bashkit script runs like `bash -c`). +pub(crate) fn dollar_dash(variables: &std::collections::HashMap) -> String { + let mut out = String::new(); + for c in DOLLAR_DASH_ORDER.chars() { + if let Some((_, _, var, default)) = set_option_by_letter(c) + && set_option_on(variables, var, *default) + { + out.push(c); + } } + out.push('c'); + out } -/// All known `set -o` options with their variable names, in display order. -const SET_O_OPTIONS: &[(&str, &str)] = &[ - ("allexport", "SHOPT_a"), - ("errexit", "SHOPT_e"), - ("noglob", "SHOPT_f"), - ("noclobber", "SHOPT_C"), - ("noexec", "SHOPT_n"), - ("nounset", "SHOPT_u"), - ("pipefail", "SHOPT_pipefail"), - ("verbose", "SHOPT_v"), - ("xtrace", "SHOPT_x"), -]; +/// One `set -o` listing line (human-readable). +fn format_dash_o_line(variables: &std::collections::HashMap, name: &str) -> String { + let (_, _, var, default) = set_option_by_name(name).expect("known option"); + let state = if set_option_on(variables, var, *default) { + "on" + } else { + "off" + }; + format!("{:<15}\t{}\n", name, state) +} -/// Format option display for `set -o` (human-readable). -fn format_set_dash_o(variables: &std::collections::HashMap) -> String { - let mut output = String::new(); - for (name, var) in SET_O_OPTIONS { - let enabled = variables.get(*var).map(|v| v == "1").unwrap_or(false); - let state = if enabled { "on" } else { "off" }; - output.push_str(&format!("{:<15}\t{}\n", name, state)); - } - output +/// One `set +o` listing line (re-executable). +fn format_plus_o_line(variables: &std::collections::HashMap, name: &str) -> String { + let (_, _, var, default) = set_option_by_name(name).expect("known option"); + let flag = if set_option_on(variables, var, *default) { + "-o" + } else { + "+o" + }; + format!("set {} {}\n", flag, name) } -/// Format option display for `set +o` (re-executable). -fn format_set_plus_o(variables: &std::collections::HashMap) -> String { - let mut output = String::new(); - for (name, var) in SET_O_OPTIONS { - let enabled = variables.get(*var).map(|v| v == "1").unwrap_or(false); - let flag = if enabled { "-o" } else { "+o" }; - output.push_str(&format!("set {} {}\n", flag, name)); - } - output +/// Format option display for `set -o` / `set +o`. +fn format_set_o(variables: &std::collections::HashMap, dash: bool) -> String { + SET_O_OPTIONS + .iter() + .map(|(name, ..)| { + if dash { + format_dash_o_line(variables, name) + } else { + format_plus_o_line(variables, name) + } + }) + .collect() } +const SET_USAGE: &str = + "set: usage: set [-abefhkmnptuvxBCEHPT] [-o option-name] [--] [-] [arg ...]\n"; + impl Set { /// Create a SetPositional side effect. fn positional_effect(positional: &[&str]) -> BuiltinSideEffect { @@ -157,84 +208,77 @@ impl Builtin for Set { return Ok(ExecResult::ok(output)); } - let mut side_effects = Vec::new(); + // Parse everything first: an invalid option changes nothing (bash). + let mut changes: Vec<(&'static str, bool)> = Vec::new(); + let mut listing: Option = None; + let mut positional: Option<&[String]> = None; let mut i = 0; while i < ctx.args.len() { let arg = &ctx.args[i]; if arg == "--" { // Everything after `--` becomes positional parameters. - let positional: Vec<&str> = ctx.args[i + 1..].iter().map(|s| s.as_str()).collect(); - side_effects.push(Self::positional_effect(&positional)); + positional = Some(&ctx.args[i + 1..]); break; - } else if (arg.starts_with('-') || arg.starts_with('+')) - && arg.len() > 1 - && (arg.as_bytes()[1] == b'o' && arg.len() == 2) - { - // -o / +o: either display options or set/unset a named option - let enable = arg.starts_with('-'); + } + if arg == "-" { + // `set -` turns off -x and -v and ends the options. + changes.push(("SHOPT_x", false)); + changes.push(("SHOPT_v", false)); if i + 1 < ctx.args.len() { - // -o option_name / +o option_name - i += 1; - if let Some(var) = option_name_to_var(&ctx.args[i]) { - ctx.variables - .insert(var.to_string(), if enable { "1" } else { "0" }.to_string()); - } - } else { - // Bare -o or +o: display options - let output = if enable { - format_set_dash_o(ctx.variables) - } else { - format_set_plus_o(ctx.variables) - }; - return Ok(ExecResult::ok(output)); - } - } else if arg.starts_with('-') || arg.starts_with('+') { - let enable = arg.starts_with('-'); - let mut need_o_arg = false; - for opt in arg.chars().skip(1) { - if opt == 'o' { - // -o within a combined flag (e.g. -euo): next arg is option name - need_o_arg = true; - } else if short_option_to_var(opt).is_none() { - return Ok(ExecResult::err( - format!( - "bash: set: {}{}: invalid option\n", - if enable { '-' } else { '+' }, - opt - ), - 2, - )); - } - } - for opt in arg.chars().skip(1) { - if opt == 'o' { - continue; - } - if let Some(opt_name) = short_option_to_var(opt) { - ctx.variables.insert( - opt_name.to_string(), - if enable { "1" } else { "0" }.to_string(), - ); - } - } - if need_o_arg && i + 1 < ctx.args.len() { - i += 1; - if let Some(var) = option_name_to_var(&ctx.args[i]) { - ctx.variables - .insert(var.to_string(), if enable { "1" } else { "0" }.to_string()); - } + positional = Some(&ctx.args[i + 1..]); } - } else { + break; + } + if !(arg.starts_with('-') || arg.starts_with('+')) || arg.len() < 2 { // Non-flag arg: this and everything after become positional params - let positional: Vec<&str> = ctx.args[i..].iter().map(|s| s.as_str()).collect(); - side_effects.push(Self::positional_effect(&positional)); + positional = Some(&ctx.args[i..]); break; } + let enable = arg.starts_with('-'); + let sign = if enable { '-' } else { '+' }; + for opt in arg.chars().skip(1) { + if opt == 'o' { + // `-o name`; a cluster's `o` takes the next argument, and + // `-o` with nothing after it lists the options. + if i + 1 < ctx.args.len() { + i += 1; + let name = &ctx.args[i]; + match set_option_by_name(name) { + Some((_, _, var, _)) => changes.push((var, enable)), + None => { + return Ok(ExecResult::err( + format!("bash: set: {name}: invalid option name\n"), + 2, + )); + } + } + } else { + listing = Some(enable); + } + } else if let Some((_, _, var, _)) = set_option_by_letter(opt) { + changes.push((var, enable)); + } else { + return Ok(ExecResult::err( + format!("bash: set: {sign}{opt}: invalid option\n{SET_USAGE}"), + 2, + )); + } + } i += 1; } - let mut result = ExecResult::ok(String::new()); - result.side_effects = side_effects; + for (var, on) in changes { + ctx.variables + .insert(var.to_string(), if on { "1" } else { "0" }.to_string()); + } + let mut result = match listing { + Some(dash) => ExecResult::ok(format_set_o(ctx.variables, dash)), + None => ExecResult::ok(String::new()), + }; + if let Some(args) = positional { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + result.side_effects.push(Self::positional_effect(&args)); + } Ok(result) } } @@ -274,62 +318,6 @@ impl Builtin for Shift { } } -impl Shopt { - /// `shopt -o`: the same operations on `set -o` option names. - fn set_o_options( - variables: &mut std::collections::HashMap, - mode: Option, - opts: &[String], - ) -> ExecResult { - let mut names: Vec<(&str, &'static str)> = Vec::new(); - if opts.is_empty() { - names.extend(SET_O_OPTIONS.iter().copied()); - } - for opt in opts { - match option_name_to_var(opt) { - Some(var) => names.push((opt.as_str(), var)), - None => { - return ExecResult::err( - format!("bash: shopt: {opt}: invalid option name\n"), - 1, - ); - } - } - } - let on = |vars: &std::collections::HashMap, var: &str| { - vars.get(var).is_some_and(|v| v == "1") - }; - let mut output = String::new(); - let mut all_on = true; - for (name, var) in names { - match mode { - Some('s') => { - variables.insert(var.to_string(), "1".to_string()); - } - Some('u') => { - variables.remove(var); - } - Some('q') => all_on &= on(variables, var), - Some('p') => { - let flag = if on(variables, var) { "-o" } else { "+o" }; - output.push_str(&format!("set {flag} {name}\n")); - } - _ => { - let enabled = on(variables, var); - all_on &= enabled; - let state = if enabled { "on" } else { "off" }; - output.push_str(&format!("{name:<15}\t{state}\n")); - } - } - } - let mut result = ExecResult::ok(output); - if matches!(mode, Some('q')) || (mode.is_none() && !opts.is_empty()) { - result.exit_code = i32::from(!all_on); - } - result - } -} - /// local builtin - declare local variables in functions pub struct Local; @@ -531,6 +519,54 @@ const SHOPT_OPTIONS: &[&str] = &[ "xpg_echo", ]; +/// `shopt -o`: the same verbs applied to `set -o` options. +fn shopt_set_o( + variables: &mut std::collections::HashMap, + mode: Option, + opts: &[String], +) -> ExecResult { + let names: Vec<&str> = if opts.is_empty() { + SET_O_OPTIONS.iter().map(|(name, ..)| *name).collect() + } else { + opts.iter().map(String::as_str).collect() + }; + // An unknown name fails the whole command before anything changes. + if let Some(bad) = names.iter().find(|n| set_option_by_name(n).is_none()) { + return ExecResult::err(format!("bash: shopt: {bad}: invalid option name\n"), 1); + } + let mut out = String::new(); + let mut status = 0; + for name in names { + let Some((_, _, var, default)) = set_option_by_name(name) else { + continue; + }; + match mode { + Some('s') => { + variables.insert(var.to_string(), "1".to_string()); + } + Some('u') => { + variables.insert(var.to_string(), "0".to_string()); + } + Some('q') => { + if !set_option_on(variables, var, *default) { + status = 1; + } + } + Some('p') => out.push_str(&format_plus_o_line(variables, name)), + _ => { + // Naming options reports their state in the status too. + if !opts.is_empty() && !set_option_on(variables, var, *default) { + status = 1; + } + out.push_str(&format_dash_o_line(variables, name)); + } + } + } + let mut result = ExecResult::ok(out); + result.exit_code = status; + result +} + /// shopt builtin - set/unset bash-specific shell options. /// /// Usage: @@ -559,8 +595,8 @@ impl Builtin for Shopt { } let mut mode: Option = None; // 's'=set, 'u'=unset, 'q'=query, 'p'=print - let mut set_o = false; // -o: act on `set -o` options let mut opts: Vec = Vec::new(); + let mut set_o = false; // -o: operate on `set -o` options for arg in ctx.args { if arg.starts_with('-') && opts.is_empty() { @@ -584,7 +620,7 @@ impl Builtin for Shopt { } if set_o { - return Ok(Self::set_o_options(ctx.variables, mode, &opts)); + return Ok(shopt_set_o(ctx.variables, mode, &opts)); } match mode { diff --git a/crates/bashkit/src/error.rs b/crates/bashkit/src/error.rs index 8a06e44a3..b1a773738 100644 --- a/crates/bashkit/src/error.rs +++ b/crates/bashkit/src/error.rs @@ -81,6 +81,13 @@ pub enum Error { #[error("snapshot capability mismatch: {0}")] SnapshotCapabilityMismatch(String), + /// Bash abandons the current command line (its `DISCARD` jump), e.g. an + /// arithmetic error inside `$((...))`. Converted to + /// `ControlFlow::Abort` at the command boundary; + /// the message is the diagnostic written to stderr. + #[error("{0}")] + LineAbort(String), + /// Internal error for unexpected failures. /// /// THREAT[TM-INT-002]: Unexpected internal failures should not crash the interpreter. diff --git a/crates/bashkit/src/interpreter/arithmetic.rs b/crates/bashkit/src/interpreter/arithmetic.rs index d031f09f2..f2e390942 100644 --- a/crates/bashkit/src/interpreter/arithmetic.rs +++ b/crates/bashkit/src/interpreter/arithmetic.rs @@ -1,500 +1,910 @@ //! Arithmetic expansion and evaluation (`$(( ))`, `(( ))`, `let`). //! -//! Split out of interpreter/mod.rs. Recursive-descent evaluator plus the -//! variable/brace/param expansion helpers that feed it. Uses the parent's -//! `ArithmeticExpansionState` and `MAX_ARITHMETIC_EXPANSION_*` consts. +//! Important decisions: +//! - Two phases, as in bash: `$`-expansion is textual (`$x`, `${..}`, `$1`), +//! then a tokenizer + precedence-climbing parser (bash `expr.c` grammar) +//! evaluates the result. Bare identifiers are looked up while parsing and +//! their values evaluated recursively as expressions. +//! - Evaluation never mutates the interpreter: assignments land in an overlay +//! (so `x=5, x+1` sees 5) and are returned as an ordered write list. `&mut` +//! callers apply the writes; read-only callers drop them. +//! - Errors (division by 0, syntax errors, bad subscripts, bad numbers) are +//! real errors. `$((..))` turns them into a line abort (bash DISCARD), +//! `((..))`/`let` into status 1. Read-only call sites record the error in +//! `arith_error` and the command boundary aborts the line. +//! - Integers wrap at 64 bits; shift counts are masked to 0..63 (x86 / bash). +//! - THREAT[TM-DOS-026]: recursion depth (`MAX_ARITHMETIC_DEPTH`) and fuel +//! (`MAX_ARITHMETIC_EXPANSION_FUEL`) bound nested and self-referential input. use super::*; -impl Interpreter { - /// Evaluate arithmetic with assignment support (e.g. `X = X + 1`). - /// Assignment must be handled before variable expansion so the LHS - /// variable name is preserved. - pub(super) fn evaluate_arithmetic_with_assign(&mut self, expr: &str) -> i64 { - let expr = expr.trim(); - self.note_arith_expr(expr); +/// One assignment performed while evaluating: `(name, key, value)`. +/// `key` is `None` for scalars, the decimal index for indexed arrays, and the +/// key for associative arrays. +pub(super) type ArithWrite = (String, Option, String); + +#[derive(Debug, Clone, PartialEq)] +enum Tok { + Num(String), + Ident(String, Option), + Op(&'static str), + LParen, + RParen, + Eof, +} - // Handle comma operator (lowest precedence): evaluate all, return last - // But not inside parentheses - { - let mut depth = 0i32; - let chars: Vec = expr.chars().collect(); - let byte_offsets: Vec = expr.char_indices().map(|(b, _)| b).collect(); - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - ',' if depth == 0 => { - let left = &expr[..byte_offsets[i]]; - let right = &expr[byte_offsets[i] + 1..]; - self.evaluate_arithmetic_with_assign(left); - return self.evaluate_arithmetic_with_assign(right); +const ARITH_OPS: &[&str] = &[ + "<<=", ">>=", "**", "++", "--", "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "+=", "-=", + "*=", "/=", "%=", "&=", "^=", "|=", "+", "-", "*", "/", "%", "<", ">", "!", "~", "&", "^", "|", + "?", ":", "=", ",", +]; + +fn tokenize(src: &str) -> std::result::Result, (String, usize)> { + let bytes = src.as_bytes(); + let mut toks = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if c.is_ascii_whitespace() { + i += 1; + continue; + } + let start = i; + if c.is_ascii_digit() { + while i < bytes.len() + && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'#' | b'@' | b'_')) + { + i += 1; + } + toks.push((Tok::Num(src[start..i].to_string()), start)); + continue; + } + if c.is_ascii_alphabetic() || c == b'_' { + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { + i += 1; + } + let name = src[start..i].to_string(); + let mut j = i; + while j < bytes.len() && bytes[j].is_ascii_whitespace() { + j += 1; + } + let mut sub = None; + if j < bytes.len() && bytes[j] == b'[' { + let mut depth = 0usize; + let mut k = j; + let mut end = None; + while k < bytes.len() { + match bytes[k] { + b'[' => depth += 1, + b']' => { + depth -= 1; + if depth == 0 { + end = Some(k); + break; + } + } + _ => {} } - _ => {} + k += 1; } + let Some(end) = end else { + return Err(("bad array subscript".to_string(), start)); + }; + sub = Some(src[j + 1..end].to_string()); + i = end + 1; } + toks.push((Tok::Ident(name, sub), start)); + continue; } - - // Handle pre-increment/pre-decrement: ++var, --var - if let Some(var_name) = expr.strip_prefix("++") { - let var_name = var_name.trim(); - if is_arith_lvalue(var_name) { - // THREAT[TM-DOS-043]: Bash integer side effects wrap at i64 bounds. - let val = self.arith_lvalue_value(var_name).wrapping_add(1); - self.set_arith_lvalue(var_name, val.to_string()); - return val; - } + if c == b'(' { + toks.push((Tok::LParen, start)); + i += 1; + continue; + } + if c == b')' { + toks.push((Tok::RParen, start)); + i += 1; + continue; } - if let Some(var_name) = expr.strip_prefix("--") { - let var_name = var_name.trim(); - if is_arith_lvalue(var_name) { - let val = self.arith_lvalue_value(var_name).wrapping_sub(1); - self.set_arith_lvalue(var_name, val.to_string()); - return val; + let rest = &src[i..]; + match ARITH_OPS.iter().find(|op| rest.starts_with(**op)) { + Some(op) => { + toks.push((Tok::Op(op), start)); + i += op.len(); + } + None => { + return Err(( + "syntax error: invalid arithmetic operator".to_string(), + start, + )); } } + } + toks.push((Tok::Eof, src.len())); + Ok(toks) +} - // Handle post-increment/post-decrement: var++, var-- - if let Some(var_name) = expr.strip_suffix("++") { - let var_name = var_name.trim(); - if is_arith_lvalue(var_name) { - let old_val = self.arith_lvalue_value(var_name); - self.set_arith_lvalue(var_name, old_val.wrapping_add(1).to_string()); - return old_val; - } +/// Parse an integer constant the way bash does: `0x` hex, leading-0 octal, +/// `base#digits` (2..64), decimal otherwise. Overflow wraps. +pub(super) fn parse_arith_number(s: &str) -> std::result::Result { + let (base, digits): (u32, &str) = + if let Some(rest) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + (16, rest) + } else if let Some(hash) = s.find('#') { + let base = s[..hash] + .parse::() + .ok() + .filter(|b| (2..=64).contains(b)) + .ok_or_else(|| "invalid arithmetic base".to_string())?; + (base, &s[hash + 1..]) + } else if s.len() > 1 && s.starts_with('0') { + (8, &s[1..]) + } else { + (10, s) + }; + if digits.is_empty() && base != 8 { + return Err("invalid integer constant".to_string()); + } + let mut val: i64 = 0; + for ch in digits.chars() { + let d = match ch { + '0'..='9' => ch as u32 - '0' as u32, + 'a'..='z' => ch as u32 - 'a' as u32 + 10, + 'A'..='Z' if base > 36 => ch as u32 - 'A' as u32 + 36, + 'A'..='Z' => ch as u32 - 'A' as u32 + 10, + '@' => 62, + '_' => 63, + _ => return Err("invalid number".to_string()), + }; + if d >= base { + return Err("value too great for base".to_string()); } - if let Some(var_name) = expr.strip_suffix("--") { - let var_name = var_name.trim(); - if is_arith_lvalue(var_name) { - let old_val = self.arith_lvalue_value(var_name); - self.set_arith_lvalue(var_name, old_val.wrapping_sub(1).to_string()); - return old_val; - } + val = val.wrapping_mul(base as i64).wrapping_add(d as i64); + } + Ok(val) +} + +fn wrapping_ipow(mut base: i64, mut exp: i64) -> i64 { + let mut acc: i64 = 1; + while exp > 0 { + if exp & 1 == 1 { + acc = acc.wrapping_mul(base); } + base = base.wrapping_mul(base); + exp >>= 1; + } + acc +} - // Check for compound assignments: +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>= - // and simple assignment: VAR = expr (but not == comparison) - if let Some(eq_pos) = expr.find('=') { - let before = &expr[..eq_pos]; - let after_char = expr.as_bytes().get(eq_pos + 1); - // Not == or != - if !before.ends_with('!') && after_char != Some(&b'=') { - // Detect compound operator: check multi-char ops first - let (var_name, op) = if let Some(s) = before.strip_suffix("<<") { - (s.trim(), "<<") - } else if let Some(s) = before.strip_suffix(">>") { - (s.trim(), ">>") - } else if let Some(s) = before.strip_suffix('+') { - (s.trim(), "+") - } else if let Some(s) = before.strip_suffix('-') { - (s.trim(), "-") - } else if let Some(s) = before.strip_suffix('*') { - (s.trim(), "*") - } else if let Some(s) = before.strip_suffix('/') { - (s.trim(), "/") - } else if let Some(s) = before.strip_suffix('%') { - (s.trim(), "%") - } else if let Some(s) = before.strip_suffix('&') { - (s.trim(), "&") - } else if let Some(s) = before.strip_suffix('|') { - (s.trim(), "|") - } else if let Some(s) = before.strip_suffix('^') { - (s.trim(), "^") - } else if !before.ends_with('<') && !before.ends_with('>') { - (before.trim(), "") - } else { - ("", "") - }; +/// A resolved assignment target. +struct LValue { + name: String, + key: Option, +} - if is_arith_lvalue(var_name) { - let rhs = &expr[eq_pos + 1..]; - let rhs_val = self.evaluate_arithmetic(rhs); - let value = if op.is_empty() { - rhs_val - } else { - let lhs_val = self.arith_lvalue_value(var_name); - // THREAT[TM-DOS-043]: wrapping to prevent overflow panic - match op { - "+" => lhs_val.wrapping_add(rhs_val), - "-" => lhs_val.wrapping_sub(rhs_val), - "*" => lhs_val.wrapping_mul(rhs_val), - "/" => { - if rhs_val != 0 && !(lhs_val == i64::MIN && rhs_val == -1) { - lhs_val / rhs_val - } else { - 0 - } - } - "%" => { - if rhs_val != 0 && !(lhs_val == i64::MIN && rhs_val == -1) { - lhs_val % rhs_val - } else { - 0 - } - } - "&" => lhs_val & rhs_val, - "|" => lhs_val | rhs_val, - "^" => lhs_val ^ rhs_val, - "<<" => lhs_val.wrapping_shl((rhs_val & 63) as u32), - ">>" => lhs_val.wrapping_shr((rhs_val & 63) as u32), - _ => rhs_val, - } - }; - self.set_arith_lvalue(var_name, value.to_string()); - return value; - } - } +type ArithResult = std::result::Result; + +/// Expression evaluator state shared across recursive variable evaluation. +pub(super) struct ArithEval<'a> { + interp: &'a Interpreter, + overlay: HashMap<(String, Option), String>, + pub(super) writes: Vec, + noeval: u32, + depth: usize, + fuel: usize, +} + +struct ArithParser<'s> { + src: &'s str, + toks: Vec<(Tok, usize)>, + pos: usize, +} + +impl ArithParser<'_> { + fn peek(&self) -> &Tok { + &self.toks[self.pos].0 + } + fn peek_at(&self, n: usize) -> &Tok { + let i = (self.pos + n).min(self.toks.len() - 1); + &self.toks[i].0 + } + fn next(&mut self) -> Tok { + let t = self.toks[self.pos].0.clone(); + if self.pos + 1 < self.toks.len() { + self.pos += 1; } + t + } + fn is_op(&self, op: &str) -> bool { + matches!(self.peek(), Tok::Op(o) if *o == op) + } + fn rest(&self) -> &str { + &self.src[self.toks[self.pos].1..] + } + fn err(&self, msg: &str) -> String { + let rest = self.rest().trim(); + if rest.is_empty() { + msg.to_string() + } else { + format!("{msg} (error token is \"{rest}\")") + } + } +} - self.evaluate_arithmetic(expr) +impl<'a> ArithEval<'a> { + pub(super) fn new(interp: &'a Interpreter) -> Self { + Self { + interp, + overlay: HashMap::new(), + writes: Vec::new(), + noeval: 0, + depth: 0, + fuel: Interpreter::MAX_ARITHMETIC_EXPANSION_FUEL, + } } - /// Current integer value of an arithmetic lvalue (`name` or `name[sub]`). - pub(super) fn arith_lvalue_value(&self, lvalue: &str) -> i64 { - let Some(bracket) = lvalue.find('[') else { - return self.evaluate_arithmetic(lvalue); - }; - let resolved = self.resolve_nameref(&lvalue[..bracket]); - let value = match self.scoped.assoc_arrays.get(resolved) { - Some(arr) => { - let key = self.arith_assoc_key(&lvalue[bracket + 1..lvalue.len() - 1]); - arr.get(&key).cloned().unwrap_or_default() - } - None => self.expand_name_or_array_element(&unquote_arith_subscript(lvalue)), - }; - let value = value.trim(); - value - .parse::() - .unwrap_or_else(|_| self.evaluate_arithmetic(value)) + fn enter(&mut self) -> ArithResult<()> { + self.depth += 1; + if self.depth >= Interpreter::MAX_ARITHMETIC_DEPTH { + return Err("expression recursion level exceeded".to_string()); + } + Ok(()) } - /// Store an arithmetic result into `name` or `name[sub]`. Array elements - /// go through the same budgeted writer as `${a[i]:=v}`. - pub(super) fn set_arith_lvalue(&mut self, lvalue: &str, value: String) { - if let Some(bracket) = lvalue.find('[') { - let resolved = self.resolve_nameref(&lvalue[..bracket]).to_string(); - if self.scoped.assoc_arrays.contains_key(&resolved) { - let key = self.arith_assoc_key(&lvalue[bracket + 1..lvalue.len() - 1]); - self.set_assoc_element_checked(resolved, key, value); - } else { - let target = unquote_arith_subscript(lvalue); - self.set_parameter_expansion_target(&target, value); - } - } else { - self.set_variable(lvalue.to_string(), value); + fn leave(&mut self) { + self.depth -= 1; + } + + /// Evaluate a full expression (already `$`-expanded). Empty -> 0. + pub(super) fn eval_str(&mut self, src: &str) -> ArithResult { + if src.len() > Interpreter::MAX_ARITHMETIC_EXPANSION_BYTES { + return Err("expression too long".to_string()); + } + if self.fuel < src.len().max(1) { + return Err("expression recursion level exceeded".to_string()); + } + self.fuel -= src.len().max(1); + if src.trim().is_empty() { + return Ok(0); + } + let toks = tokenize(src).map_err(|(msg, at)| { + let rest = src[at..].trim(); + format!("{msg} (error token is \"{rest}\")") + })?; + let mut p = ArithParser { src, toks, pos: 0 }; + self.enter()?; + let r = self.comma(&mut p); + self.leave(); + let v = r?; + if !matches!(p.peek(), Tok::Eof) { + return Err(p.err("syntax error in expression")); } + Ok(v) } - /// Associative key inside `((...))`: quotes dropped, `$name`/`${name}` - /// expanded once (`c[k$i]` -> `k0`), everything else literal. - fn arith_assoc_key(&self, raw: &str) -> String { - let raw = strip_subscript_quotes(raw); - if !raw.contains('$') { - return raw.to_string(); + fn comma(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.assign(p)?; + while p.is_op(",") { + p.next(); + v = self.assign(p)?; } - let mut out = String::new(); - let mut rest = raw; - while let Some(pos) = rest.find('$') { - out.push_str(&rest[..pos]); - let after = &rest[pos + 1..]; - let (name, consumed) = if let Some(inner) = after.strip_prefix('{') { - match inner.find('}') { - Some(end) => (&inner[..end], end + 2), - None => ("", 0), - } + Ok(v) + } + + fn assign(&mut self, p: &mut ArithParser) -> ArithResult { + if let Tok::Ident(name, sub) = p.peek().clone() + && let Tok::Op(op) = p.peek_at(1).clone() + && matches!( + op, + "=" | "+=" | "-=" | "*=" | "/=" | "%=" | "<<=" | ">>=" | "&=" | "^=" | "|=" + ) + { + p.next(); + p.next(); + let lv = self.lvalue(&name, sub.as_deref())?; + let rhs_at = p.toks[p.pos].1; + let rhs = self.assign(p)?; + let value = if op == "=" { + rhs } else { - let end = after - .find(|c: char| !c.is_ascii_alphanumeric() && c != '_') - .unwrap_or(after.len()); - (&after[..end], end) + let cur = self.read_lvalue_int(&lv)?; + self.binop(&op[..op.len() - 1], cur, rhs, rhs_at, p)? }; - if name.is_empty() { - out.push('$'); - rest = after; - } else { - out.push_str(&self.expand_variable(name)); - rest = &after[consumed..]; - } + self.store(&lv, value); + return Ok(value); } - out.push_str(rest); - out + let v = self.cond(p)?; + if let Tok::Op(op) = p.peek() + && matches!( + *op, + "=" | "+=" | "-=" | "*=" | "/=" | "%=" | "<<=" | ">>=" | "&=" | "^=" | "|=" + ) + { + return Err(p.err("attempted assignment to non-variable")); + } + Ok(v) } - /// Evaluate a simple arithmetic expression - pub(super) fn evaluate_arithmetic(&self, expr: &str) -> i64 { - self.evaluate_arithmetic_depth(expr, 0) + fn cond(&mut self, p: &mut ArithParser) -> ArithResult { + let c = self.lor(p)?; + if !p.is_op("?") { + return Ok(c); + } + p.next(); + if c == 0 { + self.noeval += 1; + } + let t = self.comma(p); + if c == 0 { + self.noeval -= 1; + } + let t = t?; + if !p.is_op(":") { + return Err(p.err("`:' expected for conditional expression")); + } + p.next(); + if c != 0 { + self.noeval += 1; + } + let f = self.cond(p); + if c != 0 { + self.noeval -= 1; + } + let f = f?; + Ok(if c != 0 { t } else { f }) } - /// Evaluate arithmetic while carrying recursion depth from caller contexts. - /// THREAT[TM-DOS-026]: Preserves the recursion guard across nested array index eval. - pub(super) fn evaluate_arithmetic_depth(&self, expr: &str, depth: usize) -> i64 { - self.note_arith_expr(expr.trim()); - let mut state = ArithmeticExpansionState::new(Self::MAX_ARITHMETIC_EXPANSION_FUEL); - self.evaluate_arithmetic_depth_state(expr, depth, &mut state) + fn lor(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.land(p)?; + while p.is_op("||") { + p.next(); + let skip = v != 0; + if skip { + self.noeval += 1; + } + let r = self.land(p); + if skip { + self.noeval -= 1; + } + let r = r?; + v = i64::from(v != 0 || r != 0); + } + Ok(v) } - pub(super) fn evaluate_arithmetic_depth_state( - &self, - expr: &str, - depth: usize, - state: &mut ArithmeticExpansionState, - ) -> i64 { - if depth >= Self::MAX_ARITHMETIC_DEPTH || !state.spend(expr.len().max(1)) { - return 0; + fn land(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.bor(p)?; + while p.is_op("&&") { + p.next(); + let skip = v == 0; + if skip { + self.noeval += 1; + } + let r = self.bor(p); + if skip { + self.noeval -= 1; + } + let r = r?; + v = i64::from(v != 0 && r != 0); } - // Simple arithmetic evaluation - handles basic operations - let expr = expr.trim(); + Ok(v) + } - // First expand any variables in the expression - let expanded = self.expand_arithmetic_vars_depth_state(expr, depth + 1, state); - if expanded.len() > Self::MAX_ARITHMETIC_EXPANSION_BYTES { - return 0; + fn bor(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.bxor(p)?; + while p.is_op("|") { + p.next(); + v |= self.bxor(p)?; } - - // Parse and evaluate with depth tracking (TM-DOS-026) - self.parse_arithmetic_impl(&expanded, depth + 1) + Ok(v) } - /// Recursively resolve a variable value in arithmetic context. - /// In bash arithmetic, bare variable names are recursively evaluated: - /// if b=a and a=3, then $((b)) evaluates b -> "a" -> 3. - /// If x='1 + 2', then $((x)) evaluates x -> "1 + 2" -> 3 (as sub-expression). - /// THREAT[TM-DOS-026]: `depth` prevents infinite recursion. - pub(super) fn resolve_arith_var( - &self, - value: &str, - depth: usize, - state: &mut ArithmeticExpansionState, - ) -> String { - if depth >= Self::MAX_ARITHMETIC_DEPTH || !state.spend(value.len().max(1)) { - return "0".to_string(); + fn bxor(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.band(p)?; + while p.is_op("^") { + p.next(); + v ^= self.band(p)?; } - let trimmed = value.trim(); - if trimmed.is_empty() { - return "0".to_string(); + Ok(v) + } + + fn band(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.equality(p)?; + while p.is_op("&") { + p.next(); + v &= self.equality(p)?; } - // If value is a simple integer, return it directly - if trimmed.parse::().is_ok() { - return trimmed.to_string(); + Ok(v) + } + + fn equality(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.relational(p)?; + loop { + let op = match p.peek() { + Tok::Op(o @ ("==" | "!=")) => *o, + _ => return Ok(v), + }; + p.next(); + let r = self.relational(p)?; + v = i64::from(if op == "==" { v == r } else { v != r }); } - // If value looks like a variable name, recursively dereference - if is_valid_var_name(trimmed) { - let inner = self.expand_variable(trimmed); - return self.resolve_arith_named_var(trimmed, &inner, depth + 1, state); + } + + fn relational(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.shift(p)?; + loop { + let op = match p.peek() { + Tok::Op(o @ ("<" | ">" | "<=" | ">=")) => *o, + _ => return Ok(v), + }; + p.next(); + let r = self.shift(p)?; + v = i64::from(match op { + "<" => v < r, + ">" => v > r, + "<=" => v <= r, + _ => v >= r, + }); } - // Value contains an expression (e.g. "1 + 2") — expand vars in it - // and wrap in parens to preserve grouping - let expanded = self.expand_arithmetic_vars_depth_state(trimmed, depth + 1, state); - if expanded.len() > Self::MAX_ARITHMETIC_EXPANSION_BYTES { - return "0".to_string(); + } + + fn shift(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.additive(p)?; + loop { + let op = match p.peek() { + Tok::Op(o @ ("<<" | ">>")) => *o, + _ => return Ok(v), + }; + p.next(); + let rhs_at = p.toks[p.pos].1; + let r = self.additive(p)?; + v = self.binop(op, v, r, rhs_at, p)?; } - format!("({})", expanded) } - pub(super) fn resolve_arith_named_var( - &self, - name: &str, - value: &str, - depth: usize, - state: &mut ArithmeticExpansionState, - ) -> String { - if !state.enter_var(name) { - return "0".to_string(); + fn additive(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.multiplicative(p)?; + loop { + let op = match p.peek() { + Tok::Op(o @ ("+" | "-")) => *o, + // `5--1` / `5++1`: binary operator followed by a unary one. + Tok::Op("--") => { + p.toks[p.pos].0 = Tok::Op("-"); + let at = p.toks[p.pos].1 + 1; + p.toks.insert(p.pos + 1, (Tok::Op("-"), at)); + "-" + } + Tok::Op("++") => { + p.toks[p.pos].0 = Tok::Op("+"); + let at = p.toks[p.pos].1 + 1; + p.toks.insert(p.pos + 1, (Tok::Op("+"), at)); + "+" + } + _ => return Ok(v), + }; + p.next(); + let r = self.multiplicative(p)?; + v = if op == "+" { + v.wrapping_add(r) + } else { + v.wrapping_sub(r) + }; } - let resolved = self.resolve_arith_var(value, depth, state); - state.exit_var(); - resolved } - /// Expand variables in arithmetic expression (no $ needed in $((...))). - /// THREAT[TM-DOS-026]: `depth` prevents stack overflow via recursive variable values. - pub(super) fn expand_arithmetic_vars_depth_state( - &self, - expr: &str, - depth: usize, - state: &mut ArithmeticExpansionState, - ) -> String { - if depth >= Self::MAX_ARITHMETIC_DEPTH || !state.spend(expr.len().max(1)) { - return "0".to_string(); + fn multiplicative(&mut self, p: &mut ArithParser) -> ArithResult { + let mut v = self.power(p)?; + loop { + let op = match p.peek() { + Tok::Op(o @ ("*" | "/" | "%")) => *o, + _ => return Ok(v), + }; + p.next(); + let rhs_at = p.toks[p.pos].1; + let r = self.power(p)?; + v = self.binop(op, v, r, rhs_at, p)?; } + } - // Strip double quotes — "$x" in arithmetic is the same as $x - let expr = expr.replace('"', ""); + fn power(&mut self, p: &mut ArithParser) -> ArithResult { + let base = self.unary(p)?; + if p.is_op("**") { + p.next(); + let rhs_at = p.toks[p.pos].1; + self.enter()?; + let exp = self.power(p); + self.leave(); + return self.binop("**", base, exp?, rhs_at, p); + } + Ok(base) + } - let mut result = String::new(); - let mut chars = expr.chars().peekable(); - // Track whether we're in a numeric literal context (after # or 0x) - let mut in_numeric_literal = false; + fn unary(&mut self, p: &mut ArithParser) -> ArithResult { + let op = match p.peek() { + Tok::Op(o @ ("!" | "~" | "-" | "+")) => Some(*o), + Tok::Op(o @ ("++" | "--")) => { + if let Tok::Ident(name, sub) = p.peek_at(1).clone() { + let inc = *o == "++"; + p.next(); + p.next(); + let lv = self.lvalue(&name, sub.as_deref())?; + let cur = self.read_lvalue_int(&lv)?; + let v = if inc { + cur.wrapping_add(1) + } else { + cur.wrapping_sub(1) + }; + self.store(&lv, v); + return Ok(v); + } + // `++5` / `--5`: two unary signs. + let single = if *o == "++" { "+" } else { "-" }; + p.toks[p.pos].0 = Tok::Op(single); + let at = p.toks[p.pos].1 + 1; + p.toks.insert(p.pos + 1, (Tok::Op(single), at)); + Some(single) + } + _ => None, + }; + let Some(op) = op else { + return self.primary(p); + }; + p.next(); + self.enter()?; + let v = self.unary(p); + self.leave(); + let v = v?; + Ok(match op { + "!" => i64::from(v == 0), + "~" => !v, + "-" => v.wrapping_neg(), + _ => v, + }) + } - while let Some(ch) = chars.next() { - if ch == '$' { - in_numeric_literal = false; - if chars.peek() == Some(&'{') { - // Handle ${...} syntax inside arithmetic - chars.next(); // consume '{' - let mut brace_content = String::new(); - let mut brace_depth = 1i32; - while let Some(&c) = chars.peek() { - chars.next(); - if c == '{' { - brace_depth += 1; - brace_content.push(c); - } else if c == '}' { - brace_depth -= 1; - if brace_depth == 0 { - break; - } - brace_content.push(c); + fn primary(&mut self, p: &mut ArithParser) -> ArithResult { + match p.peek().clone() { + Tok::LParen => { + p.next(); + self.enter()?; + let v = self.comma(p); + self.leave(); + let v = v?; + if !matches!(p.peek(), Tok::RParen) { + return Err(p.err("missing `)'")); + } + p.next(); + Ok(v) + } + Tok::Num(s) => { + let v = + parse_arith_number(&s).map_err(|m| format!("{m} (error token is \"{s}\")"))?; + p.next(); + Ok(v) + } + Tok::Ident(name, sub) => { + p.next(); + // `a[]` reads as 0 (bash reports "bad array subscript" but + // keeps evaluating). + if sub.as_deref().is_some_and(|s| s.trim().is_empty()) + && !matches!(p.peek(), Tok::Op("++" | "--")) + { + return Ok(0); + } + let lv = self.lvalue(&name, sub.as_deref())?; + let cur = self.read_lvalue_int(&lv)?; + match p.peek() { + Tok::Op(o @ ("++" | "--")) => { + let inc = *o == "++"; + p.next(); + let v = if inc { + cur.wrapping_add(1) } else { - brace_content.push(c); - } - } - let expanded = - self.expand_brace_expr_in_arithmetic(&brace_content, depth + 1, state); - if expanded.is_empty() { - result.push('0'); - } else { - result.push_str(&expanded); + cur.wrapping_sub(1) + }; + self.store(&lv, v); + Ok(cur) } - } else if let Some(&c) = chars.peek() - && matches!(c, '#' | '?' | '$' | '!' | '@' | '*' | '-') - { - // Handle special variables: $#, $?, $$, $!, $@, $*, $- - chars.next(); - let value = self.expand_variable(&c.to_string()); - if value.is_empty() { - result.push('0'); - } else { - result.push_str(&value); + _ => Ok(cur), + } + } + _ => Err(p.err("syntax error: operand expected")), + } + } + + /// `rhs_at` is where the right operand starts: bash names it as the error + /// token of a division by 0. + fn binop( + &mut self, + op: &str, + l: i64, + r: i64, + rhs_at: usize, + p: &ArithParser, + ) -> ArithResult { + Ok(match op { + "+" => l.wrapping_add(r), + "-" => l.wrapping_sub(r), + "*" => l.wrapping_mul(r), + "/" | "%" => { + if r == 0 { + if self.noeval > 0 { + return Ok(0); } + let token = p.src.get(rhs_at..).unwrap_or("").trim(); + return Err(format!("division by 0 (error token is \"{token}\")")); + } + if op == "/" { + l.wrapping_div(r) } else { - // Handle $var syntax (common in arithmetic) - let mut name = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_alphanumeric() || c == '_' { - name.push(chars.next().unwrap()); - } else { - break; - } + l.wrapping_rem(r) + } + } + "**" => { + if r < 0 { + if self.noeval > 0 { + return Ok(0); } - if !name.is_empty() { - // $var is direct text substitution — no recursive arithmetic eval. - // Only bare names (without $) get recursive resolution. - let value = self.expand_variable(&name); - if value.is_empty() { - result.push('0'); - } else { - result.push_str(&value); - } + return Err("exponent less than 0".to_string()); + } + wrapping_ipow(l, r) + } + "<<" => l.wrapping_shl((r & 63) as u32), + ">>" => l.wrapping_shr((r & 63) as u32), + "&" => l & r, + "|" => l | r, + "^" => l ^ r, + _ => r, + }) + } + + /// Resolve `name` / `name[sub]` to a concrete storage slot. + fn lvalue(&mut self, name: &str, sub: Option<&str>) -> ArithResult { + let resolved = self.interp.resolve_nameref(name).to_string(); + let (name, sub): (String, Option) = match sub { + Some(s) => (resolved, Some(s.to_string())), + None => match parse_embedded_array_ref(&resolved) { + Some((n, s)) => (n.to_string(), Some(s.to_string())), + None => (resolved, None), + }, + }; + let Some(sub) = sub else { + return Ok(LValue { name, key: None }); + }; + if self.interp.is_assoc_array(&name) { + let key = strip_subscript_quotes(&sub).to_string(); + return Ok(LValue { + name, + key: Some(key), + }); + } + if sub.trim().is_empty() { + return Err(format!("{name}[]: bad array subscript")); + } + let mut idx = self.eval_str(&sub)?; + if idx < 0 { + let len = self.interp.indexed_len_for_subscript(&name); + idx += len; + if idx < 0 && self.noeval == 0 { + return Err(format!("{name}[{sub}]: bad array subscript")); + } + } + Ok(LValue { + name, + key: Some(idx.max(0).to_string()), + }) + } + + fn read_lvalue_str(&self, lv: &LValue) -> String { + if let Some(v) = self.overlay.get(&(lv.name.clone(), lv.key.clone())) { + return v.clone(); + } + let interp = self.interp; + match &lv.key { + None => interp.expand_variable(&lv.name), + Some(key) => { + if let Some(arr) = interp.scoped.assoc_arrays.get(&lv.name) { + return arr.get(key).cloned().unwrap_or_default(); + } + let idx: usize = key.parse().unwrap_or(0); + if let Some(arr) = interp.scoped.arrays.get(&lv.name) { + return arr.get(&idx).cloned().unwrap_or_default(); + } + if idx == 0 { + interp.expand_variable(&lv.name) + } else { + String::new() + } + } + } + } + + fn read_lvalue_int(&mut self, lv: &LValue) -> ArithResult { + let s = self.read_lvalue_str(lv); + let t = s.trim(); + if t.is_empty() { + return Ok(0); + } + if let Ok(v) = t.parse::() { + return Ok(v); + } + if self.noeval > 0 { + return Ok(0); + } + self.enter()?; + let r = self.eval_str(t); + self.leave(); + r + } + + fn store(&mut self, lv: &LValue, value: i64) { + if self.noeval > 0 { + return; + } + let v = value.to_string(); + self.overlay + .insert((lv.name.clone(), lv.key.clone()), v.clone()); + self.writes.push((lv.name.clone(), lv.key.clone(), v)); + } +} + +impl Interpreter { + /// True when `name` is (or is declared as) an associative array. + pub(super) fn is_assoc_array(&self, name: &str) -> bool { + self.scoped.assoc_arrays.contains_key(name) + } + + /// Element count used to resolve negative subscripts: max index + 1 for + /// arrays, 1 for a set scalar (element 0), 0 otherwise. + pub(super) fn indexed_len_for_subscript(&self, name: &str) -> i64 { + if let Some(arr) = self.scoped.arrays.get(name) { + return arr.keys().max().map_or(0, |m| *m as i64 + 1); + } + i64::from(self.lookup_regular_variable(name).is_some()) + } + + /// Evaluate `expr`, returning the value or an error message, plus the + /// assignments made (in order, including those before an error). + pub(super) fn arith_eval(&self, expr: &str) -> (ArithResult, Vec) { + let expanded = self.expand_arith_dollars(expr); + let mut ev = ArithEval::new(self); + let r = ev.eval_str(&expanded); + (r, ev.writes) + } + + /// Apply writes produced by [`arith_eval`](Self::arith_eval). + pub(super) fn apply_arith_writes(&mut self, writes: Vec) { + for (name, key, value) in writes { + match key { + None => self.set_variable(name, value), + Some(key) => { + if self.scoped.assoc_arrays.contains_key(&name) { + self.set_assoc_element_checked(name, key, value); } else { - result.push(ch); + self.set_parameter_expansion_target(&format!("{name}[{key}]"), value); } } - } else if ch == '#' { - // base#value syntax: digits before # are base, chars after are literal digits - result.push(ch); - in_numeric_literal = true; - } else if in_numeric_literal && (ch.is_ascii_alphanumeric() || ch == '_') { - // Part of a base#value literal — don't expand as variable - result.push(ch); - } else if ch.is_ascii_digit() { + } + } + } + + /// Evaluate with side effects; errors are returned to the caller. + pub(super) fn try_evaluate_arithmetic_with_assign(&mut self, expr: &str) -> ArithResult { + let (r, writes) = self.arith_eval(expr); + self.apply_arith_writes(writes); + r.map_err(|m| format!("{}: {m}", expr.trim())) + } + + /// Evaluate with side effects; an error is recorded for the command + /// boundary (line abort) and the value reads as 0. + pub(super) fn evaluate_arithmetic_with_assign(&mut self, expr: &str) -> i64 { + match self.try_evaluate_arithmetic_with_assign(expr) { + Ok(v) => v, + Err(msg) => { + self.record_arith_error(msg); + 0 + } + } + } + + /// Evaluate without side effects; an error is recorded for the command + /// boundary (line abort) and the value reads as 0. + pub(super) fn evaluate_arithmetic(&self, expr: &str) -> i64 { + match self.arith_eval(expr).0 { + Ok(v) => v, + Err(msg) => { + self.record_arith_error(format!("{}: {msg}", expr.trim())); + 0 + } + } + } + + pub(super) fn record_arith_error(&self, msg: String) { + if let Ok(mut slot) = self.arith_error.lock() + && slot.is_none() + { + *slot = Some(msg); + } + } + + /// bash's arithmetic diagnostic: `bash: line N: : `. + /// `prefix` names the reporting builtin (`((: `, `let: `), empty for an + /// expansion. + pub(super) fn arith_diag(&self, prefix: &str, msg: &str) -> String { + format!("bash: line {}: {prefix}{msg}\n", self.current_line) + } + + pub(super) fn take_arith_error(&self) -> Option { + self.arith_error.lock().ok().and_then(|mut s| s.take()) + } + + /// Textual `$` expansion inside an arithmetic expression (`$x`, `${..}`, + /// `$1`, `$#`, ...). Bare identifiers are left for the evaluator. + /// Double quotes are dropped (`"$x"` is `$x`). + pub(super) fn expand_arith_dollars(&self, expr: &str) -> String { + if !expr.contains('$') && !expr.contains('"') { + return expr.to_string(); + } + let expr = expr.replace('"', ""); + let mut result = String::new(); + let mut chars = expr.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '$' { result.push(ch); - // Check for 0x/0X hex prefix - if ch == '0' - && let Some(&next) = chars.peek() - && (next == 'x' || next == 'X') - { - result.push(chars.next().unwrap()); - in_numeric_literal = true; + continue; + } + if chars.peek() == Some(&'{') { + chars.next(); + let mut brace_content = String::new(); + let mut brace_depth = 1i32; + for c in chars.by_ref() { + if c == '{' { + brace_depth += 1; + } else if c == '}' { + brace_depth -= 1; + if brace_depth == 0 { + break; + } + } + brace_content.push(c); } - } else if ch.is_ascii_alphabetic() || ch == '_' { - in_numeric_literal = false; - // Could be a variable name + result.push_str(&self.expand_brace_expr_in_arithmetic(&brace_content)); + } else if let Some(&c) = chars.peek() + && matches!(c, '#' | '?' | '$' | '!' | '@' | '*' | '-' | '0'..='9') + { + chars.next(); + result.push_str(&self.expand_variable(&c.to_string())); + } else { let mut name = String::new(); - name.push(ch); while let Some(&c) = chars.peek() { if c.is_ascii_alphanumeric() || c == '_' { - name.push(chars.next().unwrap()); + name.push(c); + chars.next(); } else { break; } } - - if chars.peek() == Some(&'[') { - // Check for array access: name[expr] - chars.next(); // consume '[' - let mut index_expr = String::new(); - let mut bracket_depth = 1; - while let Some(&c) = chars.peek() { - chars.next(); - if c == '[' { - bracket_depth += 1; - index_expr.push(c); - } else if c == ']' { - bracket_depth -= 1; - if bracket_depth == 0 { - break; - } - index_expr.push(c); - } else { - index_expr.push(c); - } - } - // Evaluate the index expression as arithmetic - let idx = self.evaluate_arithmetic_depth_state(&index_expr, depth + 1, state); - // Look up array element - if let Some(arr) = self.scoped.arrays.get(&name) { - let idx_usize: usize = idx.try_into().unwrap_or(0); - let value = arr.get(&idx_usize).cloned().unwrap_or_default(); - result.push_str(&self.resolve_arith_var(&value, depth, state)); - } else { - // Not an array — treat as scalar (index 0 returns the var value) - let value = self.expand_variable(&name); - if idx == 0 { - result.push_str(&self.resolve_arith_var(&value, depth, state)); - } else { - result.push('0'); - } - } + if name.is_empty() { + result.push('$'); } else { - // Expand the variable with recursive arithmetic resolution - let value = self.expand_variable(&name); - result.push_str(&self.resolve_arith_named_var(&name, &value, depth, state)); + result.push_str(&self.expand_variable(&name)); } - } else { - in_numeric_literal = false; - result.push(ch); - } - if result.len() > Self::MAX_ARITHMETIC_EXPANSION_BYTES { - return "0".to_string(); } } - result } /// Expand a `${...}` expression encountered inside arithmetic context. /// Handles: `${#arr[@]}`, `${#arr[*]}`, `${#var}`, `${arr[idx]}`, `${var}`. - pub(super) fn expand_brace_expr_in_arithmetic( - &self, - inner: &str, - depth: usize, - state: &mut ArithmeticExpansionState, - ) -> String { + pub(super) fn expand_brace_expr_in_arithmetic(&self, inner: &str) -> String { // ${#arr[@]} or ${#arr[*]} — array length if let Some(rest) = inner.strip_prefix('#') { if let Some(bracket) = rest.find('[') { - // Require a closing ']' — anything else (e.g. `${#arr[` with - // an unterminated index, or `${#arr[禧` whose final byte sits - // inside a multi-byte UTF-8 char) is malformed. Without this - // guard `end = rest.len() - 1` could land mid-codepoint and - // panic the slice below. if !rest.ends_with(']') { return "0".to_string(); } let end = rest.len() - 1; if bracket + 1 > end { - // Malformed — treat as string length of empty var return "0".to_string(); } let arr_name = &rest[..bracket]; @@ -508,42 +918,23 @@ impl Interpreter { } return "0".to_string(); } - // ${#arr[n]} — length of element - let idx_val = self.evaluate_arithmetic_depth_state(idx, depth + 1, state); - let idx_usize: usize = idx_val.try_into().unwrap_or(0); - if let Some(arr) = self.scoped.arrays.get(arr_name) { - return arr - .get(&idx_usize) - .map(|v| v.len().to_string()) - .unwrap_or_else(|| "0".to_string()); - } - return "0".to_string(); + return self + .expand_name_or_array_element(&rest[..=end]) + .chars() + .count() + .to_string(); } - // ${#var} — string length let val = self.expand_variable(rest); - return val.len().to_string(); + return val.chars().count().to_string(); } - // ${arr[idx]} — array access if let Some(bracket) = inner.find('[') && inner.ends_with(']') + && is_valid_var_name(&inner[..bracket]) { - let arr_name = &inner[..bracket]; - let idx_str = &inner[bracket + 1..inner.len() - 1]; - if let Some(arr) = self.scoped.assoc_arrays.get(arr_name) { - let key = self.expand_variable_or_literal(idx_str); - return arr.get(&key).cloned().unwrap_or_default(); - } - if let Some(arr) = self.scoped.arrays.get(arr_name) { - let idx_val = self.evaluate_arithmetic_depth_state(idx_str, depth + 1, state); - let idx_usize: usize = idx_val.try_into().unwrap_or(0); - return arr.get(&idx_usize).cloned().unwrap_or_default(); - } - return String::new(); + return self.expand_name_or_array_element(inner); } - // Check for parameter expansion operators (%, %%, #, ##, :-, etc.) - // If present, handle expansion with the operator applied. let has_operator = inner.contains("%%") || inner.contains('%') || (inner.contains('#') && !inner.starts_with('#')) @@ -552,7 +943,6 @@ impl Interpreter { return self.expand_param_op_in_arithmetic(inner); } - // ${var} — plain variable self.expand_variable(inner) } @@ -593,7 +983,6 @@ impl Interpreter { _ => {} } } - // Fallback self.expand_name_or_array_element(inner) } @@ -616,553 +1005,13 @@ impl Interpreter { let idx = self.resolve_indexed_array_subscript(resolved, idx_str); return arr.get(&idx).cloned().unwrap_or_default(); } + if self.resolve_indexed_array_subscript(resolved, idx_str) == 0 { + return self.expand_variable(resolved); + } return String::new(); } self.expand_variable(name) } - - /// Parse and evaluate a simple arithmetic expression with depth tracking. - /// THREAT[TM-DOS-026]: `arith_depth` prevents stack overflow from deeply nested expressions. - /// Parse an arithmetic atom: unary operators, parenthesized expressions, and literals. - pub(super) fn parse_arith_atom(&self, expr: &str, arith_depth: usize) -> i64 { - // THREAT[TM-DOS-029]: The positive magnitude of i64::MIN is not - // representable on its own, so unary parsing cannot construct it. - if expr.trim().parse::() == Ok(i64::MIN) { - return i64::MIN; - } - - // Unary negation and bitwise NOT - if let Some(rest) = expr.strip_prefix('-') { - let rest = rest.trim(); - if !rest.is_empty() { - // THREAT[TM-DOS-029]: wrapping to prevent i64::MIN negation panic - return self - .parse_arithmetic_impl(rest, arith_depth + 1) - .wrapping_neg(); - } - } - if let Some(rest) = expr.strip_prefix('~') { - let rest = rest.trim(); - if !rest.is_empty() { - return !self.parse_arithmetic_impl(rest, arith_depth + 1); - } - } - if let Some(rest) = expr.strip_prefix('!') { - let rest = rest.trim(); - if !rest.is_empty() { - let val = self.parse_arithmetic_impl(rest, arith_depth + 1); - return if val == 0 { 1 } else { 0 }; - } - } - - // Base conversion: base#value (e.g., 16#ff = 255, 2#1010 = 10) - if let Some(hash_pos) = expr.find('#') { - let base_str = &expr[..hash_pos]; - let value_str = &expr[hash_pos + 1..]; - if let Ok(base) = base_str.parse::() { - if (2..=36).contains(&base) { - return self.radix_or_error(expr, value_str, base); - } else if (37..=64).contains(&base) { - return Self::parse_base_n(value_str, base); - } - } - } - - // Hex (0x...), octal (0...) literals - if expr.starts_with("0x") || expr.starts_with("0X") { - return self.radix_or_error(expr, &expr[2..], 16); - } - if expr.starts_with('0') && expr.len() > 1 && expr.chars().all(|c| c.is_ascii_digit()) { - return self.radix_or_error(expr, &expr[1..], 8); - } - - // Parse as number or variable - expr.trim().parse().unwrap_or(0) - } - - /// Try to parse a binary operator at the current precedence level. - /// Scans `chars`/`bo` for operators, splitting and recursing. - /// Returns `Some(value)` if an operator was found, `None` to try next level. - /// Whether the `+`/`-` at `i` is a sign rather than a binary operator: - /// nothing but another operator precedes it (`3 - -1`, `2 * -x`). A - /// postfix `x++ - 1` still leaves the `-` binary. - fn arith_sign_is_unary(chars: &[char], i: usize) -> bool { - let operand_end = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | ')' | ']'); - let mut j = i; - while j > 0 && chars[j - 1].is_ascii_whitespace() { - j -= 1; - } - if j == 0 { - return true; - } - let p = chars[j - 1]; - if operand_end(p) { - return false; - } - if matches!(p, '+' | '-') && j >= 2 && chars[j - 2] == p { - let mut k = j - 2; - while k > 0 && chars[k - 1].is_ascii_whitespace() { - k -= 1; - } - if k > 0 && operand_end(chars[k - 1]) { - return false; - } - } - true - } - - pub(super) fn try_parse_arith_addmul( - &self, - expr: &str, - chars: &[char], - bo: &[usize], - arith_depth: usize, - ) -> Option { - let mut depth: i32 = 0; - - // Addition/Subtraction - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '+' | '-' if depth == 0 && i > 0 => { - if chars[i] == '+' && i + 1 < chars.len() && chars[i + 1] == '+' { - continue; - } - if chars[i] == '+' && i > 0 && chars[i - 1] == '+' { - continue; - } - if chars[i] == '-' && i + 1 < chars.len() && chars[i + 1] == '-' { - continue; - } - if chars[i] == '-' && i > 0 && chars[i - 1] == '-' { - continue; - } - // `3 - -1`: a sign after another operator is unary. - if Self::arith_sign_is_unary(chars, i) { - continue; - } - let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(if chars[i] == '+' { - left.wrapping_add(right) - } else { - left.wrapping_sub(right) - }); - } - _ => {} - } - } - - // Multiplication/Division/Modulo - depth = 0; - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '*' if depth == 0 => { - if i + 1 < chars.len() && chars[i + 1] == '*' { - continue; - } - if i > 0 && chars[i - 1] == '*' { - continue; - } - let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(left.wrapping_mul(right)); - } - '/' | '%' if depth == 0 => { - let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let rhs_text = &expr[bo[i] + 1..]; - let right = self.parse_arithmetic_impl(rhs_text, arith_depth + 1); - if right == 0 { - // An empty right side means this split was not the real - // operator (the scan also splits inside signed - // operands); only a written zero is an error. - if !rhs_text.trim().is_empty() { - self.set_arith_error("division by 0", rhs_text.trim()); - } - return Some(0); - } - return Some(match chars[i] { - '/' => left.wrapping_div(right), - _ => left.wrapping_rem(right), - }); - } - _ => {} - } - } - - // Exponentiation ** (right-associative) - depth = 0; - for i in 0..chars.len() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '*' if depth == 0 && i + 1 < chars.len() && chars[i + 1] == '*' => { - let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 2..], arith_depth + 1); - // Negative exponent: bash errors; this evaluator's - // error value is 0 (TM-DOS-029), not `x ** 0`. - if right < 0 { - return Some(0); - } - let exp = right.clamp(0, 63) as u32; - return Some(left.wrapping_pow(exp)); - } - _ => {} - } - } - - None - } - - /// Try to parse comparison and logical/bitwise operators. - pub(super) fn try_parse_arith_comparison( - &self, - expr: &str, - chars: &[char], - bo: &[usize], - arith_depth: usize, - ) -> Option { - let mut depth: i32 = 0; - - // Ternary operator (lowest precedence) - for i in 0..chars.len() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '?' if depth == 0 => { - let mut colon_depth = 0; - for j in (i + 1)..chars.len() { - match chars[j] { - '(' => colon_depth += 1, - ')' => colon_depth -= 1, - '?' => colon_depth += 1, - ':' if colon_depth == 0 => { - let cond = - self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let then_val = self.parse_arithmetic_impl( - &expr[bo[i] + 1..bo[j]], - arith_depth + 1, - ); - let else_val = - self.parse_arithmetic_impl(&expr[bo[j] + 1..], arith_depth + 1); - return Some(if cond != 0 { then_val } else { else_val }); - } - ':' => colon_depth -= 1, - _ => {} - } - } - } - _ => {} - } - } - - // Logical OR (||) - depth = 0; - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '|' if depth == 0 && i > 0 && chars[i - 1] == '|' => { - let left = self.parse_arithmetic_impl(&expr[..bo[i - 1]], arith_depth + 1); - if left != 0 { - return Some(1); - } - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(if right != 0 { 1 } else { 0 }); - } - _ => {} - } - } - - // Logical AND (&&) - depth = 0; - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '&' if depth == 0 && i > 0 && chars[i - 1] == '&' => { - let left = self.parse_arithmetic_impl(&expr[..bo[i - 1]], arith_depth + 1); - if left == 0 { - return Some(0); - } - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(if right != 0 { 1 } else { 0 }); - } - _ => {} - } - } - - // Bitwise OR (|) - but not || - depth = 0; - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '|' if depth == 0 - && (i == 0 || chars[i - 1] != '|') - && (i + 1 >= chars.len() || chars[i + 1] != '|') => - { - let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(left | right); - } - _ => {} - } - } - - // Bitwise XOR (^) - depth = 0; - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '^' if depth == 0 => { - let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(left ^ right); - } - _ => {} - } - } - - // Bitwise AND (&) - but not && - depth = 0; - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '&' if depth == 0 - && (i == 0 || chars[i - 1] != '&') - && (i + 1 >= chars.len() || chars[i + 1] != '&') => - { - let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(left & right); - } - _ => {} - } - } - - // Equality operators (==, !=) - depth = 0; - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '=' if depth == 0 && i > 0 && chars[i - 1] == '=' => { - let left = self.parse_arithmetic_impl(&expr[..bo[i - 1]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(if left == right { 1 } else { 0 }); - } - '=' if depth == 0 && i > 0 && chars[i - 1] == '!' => { - let left = self.parse_arithmetic_impl(&expr[..bo[i - 1]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(if left != right { 1 } else { 0 }); - } - _ => {} - } - } - - // Relational operators (<, >, <=, >=) - depth = 0; - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '=' if depth == 0 && i > 0 && chars[i - 1] == '<' => { - let left = self.parse_arithmetic_impl(&expr[..bo[i - 1]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(if left <= right { 1 } else { 0 }); - } - '=' if depth == 0 && i > 0 && chars[i - 1] == '>' => { - let left = self.parse_arithmetic_impl(&expr[..bo[i - 1]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(if left >= right { 1 } else { 0 }); - } - '<' if depth == 0 - && (i + 1 >= chars.len() || (chars[i + 1] != '=' && chars[i + 1] != '<')) - && (i == 0 || chars[i - 1] != '<') => - { - let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(if left < right { 1 } else { 0 }); - } - '>' if depth == 0 - && (i + 1 >= chars.len() || (chars[i + 1] != '=' && chars[i + 1] != '>')) - && (i == 0 || chars[i - 1] != '>') => - { - let left = self.parse_arithmetic_impl(&expr[..bo[i]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(if left > right { 1 } else { 0 }); - } - _ => {} - } - } - - // Bitwise shift (<< >>) - depth = 0; - for i in (0..chars.len()).rev() { - match chars[i] { - '(' => depth += 1, - ')' => depth -= 1, - '<' if depth == 0 - && i > 0 - && chars[i - 1] == '<' - && (i < 2 || chars[i - 2] != '<') - && (i + 1 >= chars.len() || chars[i + 1] != '=') => - { - let left = self.parse_arithmetic_impl(&expr[..bo[i - 1]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - let shift = right.clamp(0, 63) as u32; - return Some(left.wrapping_shl(shift)); - } - '>' if depth == 0 - && i > 0 - && chars[i - 1] == '>' - && (i < 2 || chars[i - 2] != '>') - && (i + 1 >= chars.len() || chars[i + 1] != '=') => - { - let left = self.parse_arithmetic_impl(&expr[..bo[i - 1]], arith_depth + 1); - let right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - let shift = right.clamp(0, 63) as u32; - return Some(left.wrapping_shr(shift)); - } - _ => {} - } - } - - None - } - - pub(super) fn parse_arithmetic_impl(&self, expr: &str, arith_depth: usize) -> i64 { - let expr = expr.trim(); - - if expr.is_empty() { - return 0; - } - - if !expr.is_ascii() { - return 0; - } - - // THREAT[TM-DOS-026]: Bail out if arithmetic nesting is too deep - if arith_depth >= Self::MAX_ARITHMETIC_DEPTH { - return 0; - } - - // Handle parentheses - if expr.starts_with('(') && expr.ends_with(')') { - let mut depth = 0; - let mut balanced = true; - for (i, ch) in expr.chars().enumerate() { - match ch { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 && i < expr.len() - 1 { - balanced = false; - break; - } - } - _ => {} - } - } - if balanced && depth == 0 { - return self.parse_arithmetic_impl(&expr[1..expr.len() - 1], arith_depth + 1); - } - } - - let chars: Vec = expr.chars().collect(); - let bo: Vec = expr.char_indices().map(|(b, _)| b).collect(); - - // Try comparison/logical/bitwise operators (lowest precedence first) - if let Some(val) = self.try_parse_arith_comparison(expr, &chars, &bo, arith_depth) { - return val; - } - - // Try additive/multiplicative/power operators - if let Some(val) = self.try_parse_arith_addmul(expr, &chars, &bo, arith_depth) { - return val; - } - - // Atom: unary operators and literals - self.parse_arith_atom(expr, arith_depth) - } - - /// Parse `digits` in `base`, or record bash's "value too great for base" - /// error (the error token is the whole literal, as bash prints it). - fn radix_or_error(&self, literal: &str, digits: &str, base: u32) -> i64 { - match i64::from_str_radix(digits, base) { - Ok(v) => v, - Err(_) => { - self.set_arith_error("value too great for base", literal.trim()); - 0 - } - } - } - - /// Remember the expression text for a later error message, while no error - /// is pending (the expression of the first error is the one bash prints). - fn note_arith_expr(&self, expr: &str) { - if let Ok(mut e) = self.arith_error.lock() - && e.failure.is_none() - { - e.expr = expr.to_string(); - e.line = self.current_line; - } - } - - /// Record an arithmetic error in bash's wording. The first error wins, as - /// bash stops evaluating at the first one. - pub(super) fn set_arith_error(&self, what: &str, token: &str) { - let Ok(mut e) = self.arith_error.lock() else { - return; - }; - if e.failure.is_some() { - return; - } - if e.expr.is_empty() { - e.expr = token.to_string(); - e.line = self.current_line; - } - e.failure = Some((what.to_string(), token.to_string())); - } - - /// Parse a number in base 37-64 using bash's extended charset: 0-9, a-z, A-Z, @, _ - pub(super) fn parse_base_n(value_str: &str, base: u32) -> i64 { - let mut result: i64 = 0; - for ch in value_str.chars() { - let digit = match ch { - '0'..='9' => ch as u32 - '0' as u32, - 'a'..='z' => 10 + ch as u32 - 'a' as u32, - 'A'..='Z' => 36 + ch as u32 - 'A' as u32, - '@' => 62, - '_' => 63, - _ => return 0, - }; - if digit >= base { - return 0; - } - result = result.wrapping_mul(base as i64).wrapping_add(digit as i64); - } - result - } -} - -/// `name` or `name[subscript]` — a target `((...))`/`let` may assign to. -pub(super) fn is_arith_lvalue(s: &str) -> bool { - match s.find('[') { - Some(bracket) if s.ends_with(']') => { - let sub = &s[bracket + 1..s.len() - 1]; - is_valid_var_name(&s[..bracket]) - && !sub.trim().is_empty() - && sub.matches('[').count() == sub.matches(']').count() - } - _ => is_valid_var_name(s), - } } /// Drop one layer of matching quotes around a subscript: `c["x y"]` -> key `x y`. @@ -1177,38 +1026,29 @@ fn strip_subscript_quotes(sub: &str) -> &str { } } -/// Rebuild `name[sub]` with the subscript unquoted for the array writer. -fn unquote_arith_subscript(lvalue: &str) -> String { - match lvalue.find('[') { - Some(bracket) => format!( - "{}[{}]", - &lvalue[..bracket], - strip_subscript_quotes(&lvalue[bracket + 1..lvalue.len() - 1]) - ), - None => lvalue.to_string(), +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_follow_bash_bases() { + assert_eq!(parse_arith_number("0x1f"), Ok(31)); + assert_eq!(parse_arith_number("017"), Ok(15)); + assert_eq!(parse_arith_number("2#101"), Ok(5)); + assert_eq!(parse_arith_number("64#@"), Ok(62)); + assert_eq!(parse_arith_number("36#Z"), Ok(35)); + assert!(parse_arith_number("08").is_err()); + assert!(parse_arith_number("65#1").is_err()); + assert_eq!( + parse_arith_number("99999999999999999999"), + Ok(7766279631452241919) + ); } -} -/// Byte length of the leading `name` or `name[...]` in `s` (0 when none). -pub(super) fn arith_lvalue_end(s: &str) -> usize { - let name_end = s - .find(|c: char| !c.is_ascii_alphanumeric() && c != '_') - .unwrap_or(s.len()); - if name_end == 0 || !s[name_end..].starts_with('[') { - return name_end; - } - let mut depth = 0usize; - for (i, c) in s[name_end..].char_indices() { - match c { - '[' => depth += 1, - ']' => { - depth -= 1; - if depth == 0 { - return name_end + i + 1; - } - } - _ => {} - } + #[test] + fn tokenizer_rejects_unknown_operator() { + assert!(tokenize("1 $ 2").is_err()); + assert!(tokenize("a[1 ").is_err()); + assert_eq!(tokenize("a[b[1]]+1").unwrap().len(), 4); } - name_end } diff --git a/crates/bashkit/src/interpreter/declare.rs b/crates/bashkit/src/interpreter/declare.rs new file mode 100644 index 000000000..39b62e4b7 --- /dev/null +++ b/crates/bashkit/src/interpreter/declare.rs @@ -0,0 +1,1577 @@ +//! Declaration builtins: `declare`/`typeset`, `local`, `export`, `readonly`. +//! +//! Important decisions: +//! - One engine serves all four builtins (bash shares `declare_internal`): +//! each builtin only restricts its option letters and fixes an attribute +//! (`export` adds `-x`, `readonly` adds `-r`, `local` makes the name local). +//! - Compound values arrive unexpanded. The parser turns `name=(...)` in an +//! argument into a `WordPart::CompoundAssignment`; argument expansion +//! replaces it with a placeholder (`COMPOUND_MARK`) and parks the element +//! words in `pending_compound_args`, which the engine takes. A quoted +//! `'(...)'` value is a plain string unless `-a`/`-A` asks for an array, +//! as in bash. +//! - Listing (`declare -p`, `export -p`, plain `declare`) formats values the +//! way bash does: `"..."` with `\" \\ \$ \``` escaped, or `$'...'` when the +//! value holds control characters; associative arrays in bash's hash order. + +use super::*; + +/// Marks the placeholder that stands for a compound argument (`a=(...)`). +/// A private-use character, so ordinary text never contains it. +pub(super) const COMPOUND_MARK: char = '\u{F8FF}'; + +/// Which builtin is running the declaration engine. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum DeclKind { + Declare, + Local, + Export, + Readonly, +} + +impl DeclKind { + fn allowed_options(self) -> &'static str { + match self { + DeclKind::Declare | DeclKind::Local => "aAfFgiIlnprtux", + DeclKind::Export => "fnp", + DeclKind::Readonly => "aAfp", + } + } + + fn usage(self, cmd: &str) -> String { + match self { + DeclKind::Declare => format!( + "{cmd}: usage: {cmd} [-aAfFgiIlnrtux] [name[=value] ...] or {cmd} -p [-aAfFilnrtux] [name ...]\n" + ), + DeclKind::Local => "local: usage: local [option] name[=value] ...\n".to_string(), + DeclKind::Export => { + "export: usage: export [-fn] [name[=value] ...] or export -p\n".to_string() + } + DeclKind::Readonly => { + "readonly: usage: readonly [-aAf] [name[=value] ...] or readonly -p\n".to_string() + } + } + } +} + +/// Attribute letters given with `-` (set) or `+` (clear). +#[derive(Default, Clone, Copy)] +struct AttrFlags { + indexed: bool, + assoc: bool, + integer: bool, + nameref: bool, + readonly: bool, + export: bool, + lower: bool, + upper: bool, +} + +impl AttrFlags { + fn any(&self) -> bool { + self.indexed + || self.assoc + || self.integer + || self.nameref + || self.readonly + || self.export + || self.lower + || self.upper + } +} + +#[derive(Default)] +struct DeclOpts { + on: AttrFlags, + off: AttrFlags, + print: bool, + functions: bool, + function_names: bool, + global: bool, +} + +/// The value side of one `name=value` operand. +enum DeclValue { + Str(String), + Compound(Vec), +} + +/// Kind of the live binding of a (nameref-resolved) name. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum VarKind { + Scalar, + Indexed, + Assoc, + Unset, +} + +/// Is `s` usable as a nameref target: a name or `name[subscript]`. +pub(super) fn valid_nameref_target(s: &str) -> bool { + match s.find('[') { + Some(b) => is_valid_var_name(&s[..b]) && s.ends_with(']') && s.len() > b + 2, + None => is_valid_var_name(s), + } +} + +/// Position of the `=` that ends an assignment operand's name, skipping +/// `=` inside a `[subscript]`. +fn operand_eq_pos(arg: &str) -> Option { + let mut depth = 0usize; + for (i, c) in arg.char_indices() { + match c { + '[' => depth += 1, + ']' => depth = depth.saturating_sub(1), + '=' if depth == 0 => return Some(i), + _ => {} + } + } + None +} + +/// Does `s` need `$'...'` quoting (it holds control characters)? +pub(super) fn ansic_should_quote(s: &str) -> bool { + s.chars().any(|c| c.is_control()) +} + +/// Quote `s` as `$'...'` the way bash's `ansic_quote` does. +pub(super) fn ansic_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 3); + out.push_str("$'"); + for c in s.chars() { + match c { + '\x07' => out.push_str("\\a"), + '\x08' => out.push_str("\\b"), + '\x1b' => out.push_str("\\E"), + '\x0c' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\x0b' => out.push_str("\\v"), + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + c if c.is_control() => { + let mut buf = [0u8; 4]; + for b in c.encode_utf8(&mut buf).bytes() { + out.push_str(&format!("\\{b:03o}")); + } + } + c => out.push(c), + } + } + out.push('\''); + out +} + +/// Double-quote `s`, escaping `"`, `\`, `$` and `` ` `` (bash `sh_double_quote`). +pub(super) fn double_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + if matches!(c, '"' | '\\' | '$' | '`') { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + +/// Quote a value for `declare -p`. +pub(super) fn declare_quote(s: &str) -> String { + if ansic_should_quote(s) { + ansic_quote(s) + } else { + double_quote(s) + } +} + +/// Bash `sh_contains_shell_metas`. +fn contains_shell_metas(s: &str) -> bool { + let chars: Vec = s.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + match c { + ' ' | '\t' | '\n' | '\'' | '"' | '\\' | '|' | '&' | ';' | '(' | ')' | '<' | '>' + | '!' | '{' | '}' | '*' | '[' | '?' | ']' | '^' | '$' | '`' => return true, + '~' if i == 0 || matches!(chars[i - 1], '=' | ':') => return true, + '#' if i == 0 => return true, + _ => {} + } + } + false +} + +/// Quote a value for plain `declare`/`set` listing. +fn set_quote(s: &str) -> String { + if ansic_should_quote(s) { + ansic_quote(s) + } else if contains_shell_metas(s) { + format!("'{}'", s.replace('\'', "'\\''")) + } else { + s.to_string() + } +} + +/// FNV-1 32-bit, the hash bash uses for associative arrays. +fn bash_hash(s: &str) -> u32 { + let mut h: u32 = 2_166_136_261; + for b in s.bytes() { + h = h.wrapping_mul(16_777_619); + h ^= u32::from(b); + } + h +} + +/// Keys of an associative array in the order bash iterates them: by hash +/// bucket (1024 buckets). Keys sharing a bucket are ordered by key, since +/// insertion order is not tracked. +pub(super) fn assoc_keys_bash_order(arr: &HashMap) -> Vec<&String> { + let mut keys: Vec<&String> = arr.keys().collect(); + keys.sort_by(|a, b| { + (bash_hash(a) & 1023) + .cmp(&(bash_hash(b) & 1023)) + .then_with(|| b.cmp(a)) + }); + keys +} + +/// Format an indexed array body: `([0]="a" [1]="b")`. +fn format_indexed_body(arr: &HashMap) -> String { + let mut idx: Vec<&usize> = arr.keys().collect(); + idx.sort(); + let items: Vec = idx + .iter() + .map(|i| format!("[{}]={}", i, declare_quote(&arr[*i]))) + .collect(); + format!("({})", items.join(" ")) +} + +/// Format an associative array body: `([k]="v" )` (bash keeps a trailing space). +fn format_assoc_body(arr: &HashMap) -> String { + if arr.is_empty() { + return "()".to_string(); + } + let mut out = String::from("("); + for k in assoc_keys_bash_order(arr) { + let key = if contains_shell_metas(k) || k.is_empty() { + double_quote(k) + } else { + k.clone() + }; + out.push_str(&format!("[{}]={} ", key, declare_quote(&arr[k]))); + } + out.push(')'); + out +} + +/// Split a `[key]=value` compound element into key and value words. +/// Returns `None` for an ordinary element. +pub(super) fn split_keyed_word(word: &Word) -> Option<(Word, Word, bool)> { + let quoted_at = |i: usize| word.part_quoted.get(i).copied().unwrap_or(word.quoted); + match word.parts.first() { + Some(WordPart::Literal(s)) if s.starts_with('[') && !quoted_at(0) => {} + _ => return None, + } + let mut depth = 0usize; + for (pi, part) in word.parts.iter().enumerate() { + let WordPart::Literal(s) = part else { + continue; + }; + if quoted_at(pi) { + continue; + } + let start = if pi == 0 { 1 } else { 0 }; + if pi == 0 { + depth = 1; + } + for (ci, c) in s.char_indices().skip(start) { + match c { + '[' => depth += 1, + ']' => { + depth -= 1; + if depth == 0 { + let rest = &s[ci + 1..]; + let (append, value_head) = if let Some(v) = rest.strip_prefix("+=") { + (true, v) + } else if let Some(v) = rest.strip_prefix('=') { + (false, v) + } else { + return None; + }; + let key_head = if pi == 0 { &s[1..ci] } else { &s[..ci] }; + let mut key_parts = Vec::new(); + let mut key_quoted = Vec::new(); + for (i, p) in word.parts.iter().enumerate().take(pi) { + if i == 0 { + if let WordPart::Literal(l) = p { + key_parts.push(WordPart::Literal(l[1..].to_string())); + } + } else { + key_parts.push(p.clone()); + } + key_quoted.push(quoted_at(i)); + } + if pi == 0 || !key_head.is_empty() { + key_parts.push(WordPart::Literal(key_head.to_string())); + key_quoted.push(false); + } + let mut value_parts = Vec::new(); + let mut value_quoted = Vec::new(); + if !value_head.is_empty() { + value_parts.push(WordPart::Literal(value_head.to_string())); + value_quoted.push(false); + } + for (i, p) in word.parts.iter().enumerate().skip(pi + 1) { + value_parts.push(p.clone()); + value_quoted.push(quoted_at(i)); + } + let make = |parts: Vec, quoted: Vec| Word { + quoted: !quoted.is_empty() && quoted.iter().all(|q| *q), + parts, + has_unquoted_glob: false, + part_quoted: quoted, + raw: None, + }; + return Some(( + make(key_parts, key_quoted), + make(value_parts, value_quoted), + append, + )); + } + } + _ => {} + } + } + } + None +} + +impl Interpreter { + /// Kind of the live binding of `name` (no nameref resolution). + pub(super) fn var_kind(&self, name: &str) -> VarKind { + if self.scoped.assoc_arrays.contains_key(name) { + VarKind::Assoc + } else if self.scoped.arrays.contains_key(name) { + VarKind::Indexed + } else if self.scoped.variables.contains_key(name) || self.env.contains_key(name) { + VarKind::Scalar + } else { + VarKind::Unset + } + } + + /// Is `name` local to any active function frame? + pub(super) fn is_local_anywhere(&self, name: &str) -> bool { + self.call_stack + .iter() + .any(|f| f.saved_vars.contains_key(name)) + } + + /// Does `name` exist as far as `declare -p` is concerned (set, or + /// declared with attributes but no value)? + fn declared(&self, name: &str) -> bool { + self.var_kind(name) != VarKind::Unset + || self.scoped.var_attrs.contains_key(name) + || self.scoped.namerefs.contains_key(name) + || self.is_local_anywhere(name) + } + + /// Attribute letters of `name` in bash's order (`aAinrxlu`), or `-`. + fn attr_letters(&self, name: &str) -> String { + let attrs = self.var_attrs_get(name); + let mut s = String::new(); + match self.var_kind(name) { + VarKind::Indexed => s.push('a'), + VarKind::Assoc => s.push('A'), + _ => {} + } + if attrs.contains(VarAttrs::INTEGER) { + s.push('i'); + } + if self.scoped.namerefs.contains_key(name) { + s.push('n'); + } + if attrs.contains(VarAttrs::READONLY) { + s.push('r'); + } + if attrs.contains(VarAttrs::EXPORT) || self.env.contains_key(name) { + s.push('x'); + } + if attrs.contains(VarAttrs::LOWER) { + s.push('l'); + } + if attrs.contains(VarAttrs::UPPER) { + s.push('u'); + } + if s.is_empty() { + s.push('-'); + } + s + } + + /// One `declare -p` line for `name`, or `None` when it does not exist. + pub(super) fn format_declare_line(&self, name: &str) -> Option { + if !self.declared(name) { + return None; + } + let flags = self.attr_letters(name); + let novalue = self.var_attrs_get(name).contains(VarAttrs::NOVALUE); + let body = if let Some(target) = self.scoped.namerefs.get(name) { + (!target.is_empty()).then(|| double_quote(target)) + } else { + match self.var_kind(name) { + VarKind::Indexed => { + let arr = &self.scoped.arrays[name]; + (!(arr.is_empty() && novalue)).then(|| format_indexed_body(arr)) + } + VarKind::Assoc => { + let arr = &self.scoped.assoc_arrays[name]; + (!(arr.is_empty() && novalue)).then(|| format_assoc_body(arr)) + } + VarKind::Scalar => self + .scoped + .variables + .get(name) + .or_else(|| self.env.get(name)) + .map(|v| declare_quote(v)), + VarKind::Unset => None, + } + }; + Some(match body { + Some(b) => format!("declare -{flags} {name}={b}\n"), + None => format!("declare -{flags} {name}\n"), + }) + } + + /// All variable names visible to listings, sorted, hidden ones dropped. + fn listable_names(&self) -> Vec { + let mut names: std::collections::BTreeSet = std::collections::BTreeSet::new(); + names.extend(self.scoped.variables.keys().cloned()); + names.extend(self.env.keys().cloned()); + names.extend(self.scoped.arrays.keys().cloned()); + names.extend(self.scoped.assoc_arrays.keys().cloned()); + names.extend(self.scoped.var_attrs.keys().cloned()); + names.extend(self.scoped.namerefs.keys().cloned()); + names + .into_iter() + // THREAT[TM-INF-017]: never list internal marker variables. + .filter(|n| !is_hidden_variable(n) && !is_internal_variable(n)) + .collect() + } + + /// Does `name` carry every attribute set in `want`? + fn has_attrs(&self, name: &str, want: &AttrFlags) -> bool { + let attrs = self.var_attrs_get(name); + let kind = self.var_kind(name); + (!want.indexed || kind == VarKind::Indexed) + && (!want.assoc || kind == VarKind::Assoc) + && (!want.integer || attrs.contains(VarAttrs::INTEGER)) + && (!want.nameref || self.scoped.namerefs.contains_key(name)) + && (!want.readonly || attrs.contains(VarAttrs::READONLY)) + && (!want.export || attrs.contains(VarAttrs::EXPORT) || self.env.contains_key(name)) + && (!want.lower || attrs.contains(VarAttrs::LOWER)) + && (!want.upper || attrs.contains(VarAttrs::UPPER)) + } + + /// Plain `declare` (and `set`) listing: `name=value`, set-style quoting. + pub(super) fn format_set_listing(&self) -> String { + let mut out = String::new(); + for name in self.listable_names() { + if self.scoped.namerefs.contains_key(&name) { + if let Some(t) = self.scoped.namerefs.get(&name) + && !t.is_empty() + { + out.push_str(&format!("{name}={}\n", set_quote(t))); + } + continue; + } + match self.var_kind(&name) { + VarKind::Indexed => { + out.push_str(&format!( + "{name}={}\n", + format_indexed_body(&self.scoped.arrays[&name]) + )); + } + VarKind::Assoc => { + out.push_str(&format!( + "{name}={}\n", + format_assoc_body(&self.scoped.assoc_arrays[&name]) + )); + } + VarKind::Scalar => { + let v = self + .scoped + .variables + .get(&name) + .or_else(|| self.env.get(&name)) + .cloned() + .unwrap_or_default(); + out.push_str(&format!("{name}={}\n", set_quote(&v))); + } + VarKind::Unset => {} + } + } + out + } + + /// Resolve a nameref chain to its final target. `Err` on a cycle. + pub(super) fn resolve_nameref_strict(&self, name: &str) -> std::result::Result { + if self.scoped.namerefs.is_empty() { + return Ok(name.to_string()); + } + let mut current = name.to_string(); + let mut seen = std::collections::HashSet::new(); + seen.insert(current.clone()); + loop { + // `r[1]` where r is a nameref to `a` means `a[1]`. + let (base, sub) = match current.find('[') { + Some(b) => (current[..b].to_string(), Some(current[b..].to_string())), + None => (current.clone(), None), + }; + match self.scoped.namerefs.get(&base) { + Some(target) if !target.is_empty() => { + let next = match (&sub, target.find('[')) { + (Some(s), None) => format!("{target}{s}"), + _ => target.clone(), + }; + if !seen.insert(next.clone()) { + return Err(()); + } + current = next; + } + _ => return Ok(current), + } + } + } + + /// Entry point for `declare`/`typeset`/`local`/`export`/`readonly`. + pub(super) async fn execute_declaration_builtin( + &mut self, + cmd: &str, + kind: DeclKind, + args: &[String], + redirects: &[Redirect], + ) -> Result { + let compounds = std::mem::take(&mut self.pending_compound_args); + let result = self.run_declaration(cmd, kind, args, compounds).await?; + self.apply_redirections(result, redirects).await + } + + async fn run_declaration( + &mut self, + cmd: &str, + kind: DeclKind, + args: &[String], + compounds: Vec>, + ) -> Result { + // Options: only before the first operand. + let mut opts = DeclOpts::default(); + let mut i = 0; + while i < args.len() { + let a = &args[i]; + if a == "--" { + i += 1; + break; + } + let (set, rest) = if let Some(r) = a.strip_prefix('-') { + (true, r) + } else if let Some(r) = a.strip_prefix('+') { + (false, r) + } else { + break; + }; + if rest.is_empty() || rest.contains('=') { + break; + } + for c in rest.chars() { + if !kind.allowed_options().contains(c) { + let sign = if set { '-' } else { '+' }; + return Ok(ExecResult::err( + format!( + "bash: {cmd}: {sign}{c}: invalid option\n{}", + kind.usage(cmd) + ), + 2, + )); + } + let flags = if set { &mut opts.on } else { &mut opts.off }; + match c { + 'a' => flags.indexed = true, + 'A' => flags.assoc = true, + 'i' => flags.integer = true, + 'n' if kind == DeclKind::Export => opts.off.export = true, + 'n' => flags.nameref = true, + 'r' => flags.readonly = true, + 'x' => flags.export = true, + 'l' => flags.lower = true, + 'u' => flags.upper = true, + 'p' => opts.print = true, + 'f' => opts.functions = true, + 'F' => opts.function_names = true, + 'g' => opts.global = true, + _ => {} // t, I: accepted, no effect here + } + } + i += 1; + } + let names = &args[i..]; + + if kind == DeclKind::Local && self.local_frame_index().is_none() { + // Bash quirk: outside a function, `local` fails, but its + // `name=(...)` operands were already assigned as globals. + for arg in names { + if let Some(eq) = operand_eq_pos(arg) + && let Some(words) = arg[eq + 1..] + .strip_prefix(COMPOUND_MARK) + .and_then(|r| r.strip_suffix(COMPOUND_MARK)) + .and_then(|n| n.parse::().ok()) + .and_then(|n| compounds.get(n)) + { + let (lhs, append) = match arg[..eq].strip_suffix('+') { + Some(l) => (l, true), + None => (&arg[..eq], false), + }; + if is_valid_var_name(lhs) + && !is_internal_variable(lhs) + && !self.is_var_readonly(lhs) + { + let assoc = self.var_kind(lhs) == VarKind::Assoc; + let words = words.clone(); + self.assign_array_words(lhs, &words, append, assoc).await?; + } + } + } + return Ok(ExecResult::err( + "bash: local: can only be used in a function\n".to_string(), + 1, + )); + } + + if opts.functions || opts.function_names { + return Ok(self.declaration_functions(cmd, kind, &opts, names)); + } + + if names.is_empty() || opts.print { + return Ok(self.declaration_listing(cmd, kind, &opts, names)); + } + + let mut stderr = String::new(); + let mut status = 0; + for arg in names { + if let Some(msg) = self + .declare_operand(cmd, kind, &opts, arg, &compounds) + .await? + { + stderr.push_str(&msg); + status = 1; + } + } + Ok(ExecResult { + stderr: stderr.into(), + exit_code: status, + ..Default::default() + }) + } + + fn declaration_functions( + &self, + cmd: &str, + kind: DeclKind, + opts: &DeclOpts, + names: &[String], + ) -> ExecResult { + // `-F` lists names (`declare -f NAME` when listing all, the bare + // name for an operand); `-f` prints bodies as bash's print_cmd does. + let show = |name: &str, listing_all: bool| match self.scoped.functions.get(name) { + Some(_) if opts.function_names && listing_all => format!("declare -f {name}\n"), + Some(_) if opts.function_names => format!("{name}\n"), + Some(f) => format!("{}\n", crate::parser::function_string(name, &f.body)), + None => String::new(), + }; + let mut out = String::new(); + let mut err = String::new(); + let mut status = 0; + if names.is_empty() { + if matches!(kind, DeclKind::Declare | DeclKind::Local) { + let mut fnames: Vec<&String> = self.scoped.functions.keys().collect(); + fnames.sort(); + for f in fnames { + out.push_str(&show(f, true)); + } + } + } else { + for name in names { + if !self.scoped.functions.contains_key(name.as_str()) { + status = 1; + if matches!(kind, DeclKind::Export | DeclKind::Readonly) { + err.push_str(&format!("bash: {cmd}: {name}: not a function\n")); + } + } else if matches!(kind, DeclKind::Declare | DeclKind::Local) { + out.push_str(&show(name, false)); + } + } + } + ExecResult { + stdout: out.into(), + stderr: err.into(), + exit_code: status, + ..Default::default() + } + } + + fn declaration_listing( + &self, + cmd: &str, + kind: DeclKind, + opts: &DeclOpts, + names: &[String], + ) -> ExecResult { + let mut out = String::new(); + if !names.is_empty() { + let mut err = String::new(); + let mut status = 0; + for name in names { + match self.format_declare_line(name) { + Some(line) => out.push_str(&line), + None => { + err.push_str(&format!("bash: {cmd}: {name}: not found\n")); + status = 1; + } + } + } + return ExecResult { + stdout: out.into(), + stderr: err.into(), + exit_code: status, + ..Default::default() + }; + } + let mut want = opts.on; + match kind { + DeclKind::Local => { + if let Some(idx) = self.local_frame_index() { + let mut locals: Vec<&String> = self.call_stack[idx].saved_vars.keys().collect(); + locals.sort(); + for name in locals { + if let Some(line) = self.format_declare_line(name) { + out.push_str(&line); + } + } + } + return ExecResult::ok(out); + } + DeclKind::Export => { + if opts.off.export { + return ExecResult::ok(String::new()); + } + want.export = true; + } + DeclKind::Readonly => want.readonly = true, + DeclKind::Declare => { + if !opts.print && !want.any() { + return ExecResult::ok(self.format_set_listing()); + } + } + } + for name in self.listable_names() { + if self.has_attrs(&name, &want) + && let Some(line) = self.format_declare_line(&name) + { + out.push_str(&line); + } + } + ExecResult::ok(out) + } + + /// Process one operand. `Ok(Some(msg))` is a failed operand (status 1). + async fn declare_operand( + &mut self, + cmd: &str, + kind: DeclKind, + opts: &DeclOpts, + arg: &str, + compounds: &[Vec], + ) -> Result> { + // Split NAME[=VALUE] / NAME+=VALUE. + let (lhs, value, append) = match operand_eq_pos(arg) { + Some(eq) => { + let lhs = &arg[..eq]; + let (lhs, append) = match lhs.strip_suffix('+') { + Some(l) => (l, true), + None => (lhs, false), + }; + let raw = &arg[eq + 1..]; + let value = match raw + .strip_prefix(COMPOUND_MARK) + .and_then(|r| r.strip_suffix(COMPOUND_MARK)) + .and_then(|n| n.parse::().ok()) + .and_then(|n| compounds.get(n)) + { + Some(words) => DeclValue::Compound(words.clone()), + None => DeclValue::Str(raw.to_string()), + }; + (lhs, Some(value), append) + } + None => (arg, None, false), + }; + let display_arg = || -> String { + match operand_eq_pos(arg) { + Some(eq) if arg[eq + 1..].starts_with(COMPOUND_MARK) => { + format!("{}(...)", &arg[..=eq]) + } + _ => arg.to_string(), + } + }; + + // Validate the name (`name` or `name[subscript]`). + let (base, subscript) = match lhs.find('[') { + Some(b) + if lhs.ends_with(']') && matches!(kind, DeclKind::Declare | DeclKind::Local) => + { + (&lhs[..b], Some(lhs[b + 1..lhs.len() - 1].to_string())) + } + _ => (lhs, None), + }; + if !is_valid_var_name(base) { + return Ok(Some(format!( + "bash: {cmd}: `{}': not a valid identifier\n", + display_arg() + ))); + } + // THREAT[TM-INJ-012/014/015]: internal marker names are never declared. + if is_internal_variable(base) { + return Ok(None); + } + + let in_function = self.local_frame_index().is_some(); + let make_local = !opts.global + && match kind { + DeclKind::Local => true, + DeclKind::Declare => in_function, + _ => false, + }; + + // -n: declare a name reference. + if opts.on.nameref && kind != DeclKind::Export { + return Ok(self.declare_nameref(cmd, base, value, make_local, opts)); + } + // +n: turn a nameref back into a plain variable holding the target. + if opts.off.nameref + && let Some(target) = self.scoped.namerefs.get(base).cloned() + { + self.remove_nameref(base); + self.insert_variable_checked(base.to_string(), target); + return Ok(None); + } + + // Act on the nameref target unless the name is being made local. + let target = if make_local && !self.is_local_in_current_frame(base) { + lhs.to_string() + } else { + match self.resolve_nameref_strict(lhs) { + Ok(t) => t, + Err(()) => { + return Ok(Some(format!( + "bash: {cmd}: warning: {base}: circular name reference\n" + ))); + } + } + }; + let (name, subscript) = match target.find('[') { + Some(b) if target.ends_with(']') => ( + target[..b].to_string(), + Some(target[b + 1..target.len() - 1].to_string()), + ), + _ => (target.clone(), subscript), + }; + let name = name.as_str(); + + // `declare -g x=v` under a local `x`: the global binding is the one + // the outermost local saved. + if opts.global + && subscript.is_none() + && let Some(DeclValue::Str(v)) = &value + && let Some(idx) = self + .call_stack + .iter() + .position(|f| f.saved_vars.contains_key(name)) + { + let saved = self.call_stack[idx] + .saved_vars + .get(name) + .cloned() + .unwrap_or_default(); + if saved.attrs.is_some_and(|a| a.contains(VarAttrs::READONLY)) { + return Ok(Some(format!("bash: {cmd}: {name}: readonly variable\n"))); + } + let new_value = if append { + saved.value.clone().unwrap_or_default() + v + } else { + v.clone() + }; + // THREAT[TM-DOS-060]: the saved value stays charged; budget the + // size change like any other variable write. + let old_len = saved.value.as_ref().map_or(0, String::len); + let is_new = saved.value.is_none(); + let old_key_len = if is_new { 0 } else { name.len() }; + if let Err(error) = self.memory_budget.check_variable_insert( + name.len(), + new_value.len(), + is_new, + old_key_len, + old_len, + &self.memory_limits, + ) { + self.memory_limit_error.get_or_insert(error); + return Ok(None); + } + self.memory_budget.record_variable_insert( + name.len(), + new_value.len(), + is_new, + old_key_len, + old_len, + ); + if let Some(slot) = self.call_stack[idx].saved_vars.get_mut(name) { + slot.value = Some(new_value); + } + return Ok(None); + } + + if make_local && !self.is_local_in_current_frame(name) { + if self.is_var_readonly(name) { + return Ok(Some(format!("bash: {cmd}: {name}: readonly variable\n"))); + } + self.make_local(name); + } + + // THREAT[TM-INJ-019/020/021]: refuse to change readonly variables and + // surface the error so callers cannot mistake a silent skip for success. + if self.is_var_readonly(name) + && (value.is_some() || opts.off.readonly || opts.off.export && kind == DeclKind::Export) + { + return Ok(Some(format!("bash: {cmd}: {name}: readonly variable\n"))); + } + + let is_compound = matches!(value, Some(DeclValue::Compound(_))); + let (mut want_indexed, mut want_assoc) = (opts.on.indexed, opts.on.assoc); + if kind == DeclKind::Readonly && !is_compound { + // `readonly -a/-A` only shapes a compound value. + want_indexed = false; + want_assoc = false; + } + if subscript.is_some() && !want_assoc && self.var_kind(name) != VarKind::Assoc { + want_indexed = true; + } + let kind_now = self.var_kind(name); + let conversion = if want_assoc && kind_now == VarKind::Indexed { + Some("indexed to associative") + } else if want_indexed && kind_now == VarKind::Assoc { + Some("associative to indexed") + } else { + None + }; + if let Some(what) = conversion { + let msg = format!("bash: {cmd}: {name}: cannot convert {what} array\n"); + if is_compound { + // With an array value bash abandons the whole command line. + return Err(crate::error::Error::LineAbort(msg)); + } + return Ok(Some(msg)); + } + + // Shape: create or convert to the requested array type. + if want_assoc && kind_now != VarKind::Assoc { + let mut arr = HashMap::new(); + if let Some(v) = self.remove_scalar_value(name) { + arr.insert("0".to_string(), v); + } + let empty = arr.is_empty(); + self.insert_assoc_array_checked(name.to_string(), arr); + if empty && value.is_none() { + self.add_var_attr(name, VarAttrs::NOVALUE); + } + } else if want_indexed && !matches!(kind_now, VarKind::Indexed | VarKind::Assoc) { + let mut arr = HashMap::new(); + if let Some(v) = self.remove_scalar_value(name) { + arr.insert(0, v); + } + let empty = arr.is_empty(); + self.insert_array_checked(name.to_string(), arr); + if empty && value.is_none() { + self.add_var_attr(name, VarAttrs::NOVALUE); + } + } else if kind_now == VarKind::Unset + && value.is_none() + && !self.scoped.var_attrs.contains_key(name) + && !self.is_local_in_current_frame(name) + { + self.add_var_attr(name, VarAttrs::NOVALUE); + } + + // Attributes that shape the assigned value go on first. + self.apply_declared_attrs(name, kind, opts); + + match value { + None => {} + Some(DeclValue::Compound(words)) => { + if subscript.is_some() { + return Ok(Some(format!( + "bash: {cmd}: {name}: cannot assign list to array member\n" + ))); + } + let assoc = self.var_kind(name) == VarKind::Assoc; + self.assign_array_words(name, &words, append, assoc).await?; + } + Some(DeclValue::Str(v)) => { + if let Some(sub) = subscript { + self.assign_element(name, &sub, v, append).await?; + } else if (opts.on.indexed || opts.on.assoc) + && v.starts_with('(') + && v.ends_with(')') + && kind != DeclKind::Readonly + { + // `declare -a x='(a b)'` re-parses the string as a list. + let words = self.parse_compound_text(name, &v)?; + let assoc = self.var_kind(name) == VarKind::Assoc; + self.assign_array_words(name, &words, append, assoc).await?; + } else if append { + self.append_scalar(name, v); + } else { + self.set_variable(name.to_string(), v); + } + } + } + + // Readonly last, so the value above could still be written. + if opts.on.readonly || kind == DeclKind::Readonly { + self.add_var_attr(name, VarAttrs::READONLY); + } + if self.var_attrs_get(name).contains(VarAttrs::EXPORT) + && let Some(v) = self.scoped.variables.get(name).cloned() + { + self.insert_env_checked(name.to_string(), v); + } + Ok(None) + } + + /// Apply `-i -l -u -x` / `+i +l +u +x` to `name`. + fn apply_declared_attrs(&mut self, name: &str, kind: DeclKind, opts: &DeclOpts) { + if opts.on.integer { + self.add_var_attr(name, VarAttrs::INTEGER); + } + if opts.off.integer { + self.remove_var_attr(name, VarAttrs::INTEGER); + } + // -l and -u replace each other; given together neither applies. + if opts.on.lower && opts.on.upper { + self.remove_var_attr(name, VarAttrs::LOWER); + self.remove_var_attr(name, VarAttrs::UPPER); + } else if opts.on.lower { + self.add_var_attr(name, VarAttrs::LOWER); + self.remove_var_attr(name, VarAttrs::UPPER); + } else if opts.on.upper { + self.add_var_attr(name, VarAttrs::UPPER); + self.remove_var_attr(name, VarAttrs::LOWER); + } + if opts.off.lower { + self.remove_var_attr(name, VarAttrs::LOWER); + } + if opts.off.upper { + self.remove_var_attr(name, VarAttrs::UPPER); + } + if (opts.on.export || kind == DeclKind::Export) && !opts.off.export { + self.add_var_attr(name, VarAttrs::EXPORT); + } + if opts.off.export { + // Keep the value as a shell variable, drop it from the environment. + if !self.scoped.variables.contains_key(name) + && let Some(v) = self.env.get(name).cloned() + { + self.insert_variable_checked(name.to_string(), v); + } + self.remove_var_attr(name, VarAttrs::EXPORT); + self.env_mut().remove(name); + } + } + + /// `declare -n name[=target]`. + fn declare_nameref( + &mut self, + cmd: &str, + name: &str, + value: Option, + make_local: bool, + opts: &DeclOpts, + ) -> Option { + let target = match value { + Some(DeclValue::Str(t)) => Some(t), + Some(DeclValue::Compound(_)) => { + return Some(format!( + "bash: {cmd}: {name}: reference variable cannot be an array\n" + )); + } + None => None, + }; + if let Some(t) = &target { + if !valid_nameref_target(t) { + return Some(format!( + "bash: {cmd}: `{t}': invalid variable name for name reference\n" + )); + } + if t == name && !make_local { + return Some(format!( + "bash: {cmd}: {name}: nameref variable self references not allowed\n" + )); + } + } + if make_local && !self.is_local_in_current_frame(name) { + if self.is_var_readonly(name) { + return Some(format!("bash: {cmd}: {name}: readonly variable\n")); + } + self.make_local(name); + } + if target.is_some() && self.is_var_readonly(name) { + return Some(format!("bash: {cmd}: {name}: readonly variable\n")); + } + let target = match target { + Some(t) => t, + None => match self.scoped.namerefs.get(name) { + Some(existing) => existing.clone(), + None => match self.scoped.variables.get(name).cloned() { + // An existing value becomes the target when it is a name. + Some(v) if valid_nameref_target(&v) && v != name => { + self.remove_scalar_value(name); + v + } + Some(v) => { + return Some(format!( + "bash: {cmd}: `{v}': invalid variable name for name reference\n" + )); + } + None => String::new(), + }, + }, + }; + self.remove_scalar_value(name); + self.remove_var_attr(name, VarAttrs::NOVALUE); + self.set_nameref(name, target); + if opts.on.readonly { + self.add_var_attr(name, VarAttrs::READONLY); + } + None + } + + /// `name+=value` on a scalar (or element 0 of an array). + pub(super) fn append_scalar(&mut self, name: &str, value: String) { + let existing = self.expand_variable(name); + if self.var_attrs_get(name).contains(VarAttrs::INTEGER) { + let base = if existing.is_empty() { + "0".to_string() + } else { + existing + }; + self.set_variable(name.to_string(), format!("{base}+({value})")); + } else { + self.set_variable(name.to_string(), existing + &value); + } + } + + /// Apply the integer / case attributes of array `name` to an element + /// value; `old` is the element being appended to. + fn transform_element(&mut self, attrs: VarAttrs, old: Option<&str>, value: String) -> String { + if attrs.contains(VarAttrs::INTEGER) { + let expr = match old { + Some(o) if !o.is_empty() => format!("{o}+({value})"), + _ => value, + }; + return self.evaluate_arithmetic_with_assign(&expr).to_string(); + } + let value = match old { + Some(o) => format!("{o}{value}"), + None => value, + }; + if attrs.contains(VarAttrs::LOWER) { + value.to_lowercase() + } else if attrs.contains(VarAttrs::UPPER) { + value.to_uppercase() + } else { + value + } + } + + /// `name[sub]=value` / `name[sub]+=value` with the array's attributes. + pub(super) async fn assign_element( + &mut self, + name: &str, + sub: &str, + value: String, + append: bool, + ) -> Result<()> { + let attrs = self.var_attrs_get(name); + if self.var_kind(name) == VarKind::Assoc { + let key = self.expand_assoc_key(sub).await?; + let old = if append { + self.scoped + .assoc_arrays + .get(name) + .and_then(|a| a.get(&key)) + .cloned() + } else { + None + }; + let v = self.transform_element(attrs, old.as_deref(), value); + self.set_assoc_element_checked(name.to_string(), key, v); + } else { + self.promote_scalar_to_indexed(name); + let idx = self.resolve_indexed_array_subscript(name, sub); + let old = if append { + Some( + self.scoped + .arrays + .get(name) + .and_then(|a| a.get(&idx)) + .cloned() + .unwrap_or_default(), + ) + } else { + None + }; + let v = self.transform_element(attrs, old.as_deref(), value); + self.set_indexed_element_checked(name, idx, v); + } + self.remove_var_attr(name, VarAttrs::NOVALUE); + Ok(()) + } + + /// Budgeted write of one indexed element. + pub(super) fn set_indexed_element_checked(&mut self, name: &str, idx: usize, value: String) { + let old_len = self + .scoped + .arrays + .get(name) + .and_then(|a| a.get(&idx)) + .map(String::len); + let is_new = old_len.is_none(); + if !self.admit_array_write(usize::from(is_new), value.len(), old_len.unwrap_or(0)) { + return; + } + self.arrays_mut() + .entry(name.to_string()) + .or_default() + .insert(idx, value); + } + + /// A scalar written through a subscript becomes element 0 of an array. + pub(super) fn promote_scalar_to_indexed(&mut self, name: &str) { + if self.scoped.arrays.contains_key(name) || self.scoped.assoc_arrays.contains_key(name) { + return; + } + let Some(v) = self.remove_scalar_value(name) else { + return; + }; + let mut arr = HashMap::new(); + arr.insert(0, v); + self.insert_array_checked(name.to_string(), arr); + } + + /// Parse `(...)` text into element words (`declare -a x='(a b)'`). + fn parse_compound_text(&self, name: &str, text: &str) -> Result> { + let src = format!("{name}={text}"); + let parser = Parser::with_limits( + &src, + self.limits.max_ast_depth, + self.limits.max_parser_operations, + ) + .with_execution_budget(self.execution_budget.clone()); + let script = parser.parse()?; + for cmd in &script.commands { + if let Command::Simple(simple) = cmd + && let Some(a) = simple.assignments.first() + && let AssignmentValue::Array(words) = &a.value + { + return Ok(words.clone()); + } + } + Ok(Vec::new()) + } + + /// Expand compound-array element words and store them in `name` + /// (`a=(x [3]=y)`, `m=([k]=v)`, `m=(k1 v1 k2 v2)`). Shared by + /// assignment statements and the declaration builtins. + pub(super) async fn assign_array_words( + &mut self, + name: &str, + words: &[Word], + append: bool, + assoc: bool, + ) -> Result<()> { + let attrs = self.var_attrs_get(name); + if assoc { + let mut map: HashMap = if append { + self.scoped + .assoc_arrays + .get(name) + .cloned() + .unwrap_or_default() + } else { + HashMap::new() + }; + let old_entries = self.scoped.assoc_arrays.get(name).map_or(0, |a| a.len()); + let max_entries = old_entries.saturating_add( + self.memory_limits + .max_array_entries + .saturating_sub(self.memory_budget.array_entries), + ); + let mut pending_key: Option = None; + for word in words { + if map.len() >= max_entries { + break; + } + if let Some((kw, vw, kappend)) = split_keyed_word(word) { + let key = self.expand_word(&kw).await?; + let val = self.expand_word(&vw).await?; + let old = if kappend { + Some(map.get(&key).cloned().unwrap_or_default()) + } else { + None + }; + let v = self.transform_element(attrs, old.as_deref(), val); + map.insert(key, v); + continue; + } + let remaining = max_entries.saturating_sub(map.len()).saturating_mul(2); + for field in self.expand_element_fields(word, remaining).await? { + match pending_key.take() { + Some(k) => { + let v = self.transform_element(attrs, None, field); + map.insert(k, v); + } + None => pending_key = Some(field), + } + } + } + if let Some(k) = pending_key { + map.insert(k, String::new()); + } + self.remove_scalar_value(name); + self.arrays_mut().remove(name); + self.insert_assoc_array_checked(name.to_string(), map); + } else { + let mut map: HashMap = if append { + self.promote_scalar_to_indexed(name); + self.scoped.arrays.get(name).cloned().unwrap_or_default() + } else { + HashMap::new() + }; + let old_entries = self.scoped.arrays.get(name).map_or(0, |a| a.len()); + let max_entries = old_entries.saturating_add( + self.memory_limits + .max_array_entries + .saturating_sub(self.memory_budget.array_entries), + ); + let mut next = map.keys().max().map_or(0, |k| k + 1); + 'words: for word in words { + // Bash brace-expands indexed elements before spotting + // `[i]=`, so `([2]=v{1,2})` stores the plain words + // `[2]=v1 [2]=v2`; assoc elements never brace-expand. + let braces = self.brace_expand_word(word).is_some(); + if let Some((kw, vw, kappend)) = split_keyed_word(word).filter(|_| !braces) { + let key_text = self.expand_word(&kw).await?; + let raw = match self.try_evaluate_arithmetic_with_assign(&key_text) { + Ok(v) => v, + Err(msg) => { + return Err(crate::error::Error::LineAbort(self.arith_diag("", &msg))); + } + }; + let idx = if raw < 0 { + let len = map.keys().max().map_or(0, |m| m + 1) as i64; + let i = len + raw; + if i < 0 { + return Err(crate::error::Error::LineAbort(format!( + "bash: {name}[{key_text}]: bad array subscript\n" + ))); + } + i as usize + } else { + raw as usize + }; + let val = self.expand_word(&vw).await?; + let old = if kappend { + Some(map.get(&idx).cloned().unwrap_or_default()) + } else { + None + }; + let v = self.transform_element(attrs, old.as_deref(), val); + if map.len() >= max_entries && !map.contains_key(&idx) { + break; + } + map.insert(idx, v); + next = idx + 1; + continue; + } + let remaining = max_entries.saturating_sub(map.len()); + if remaining == 0 { + break; + } + for field in self.expand_element_fields(word, remaining).await? { + if map.len() >= max_entries { + break 'words; + } + let v = self.transform_element(attrs, None, field); + map.insert(next, v); + next += 1; + } + } + self.remove_scalar_value(name); + self.assoc_arrays_mut().remove(name); + self.insert_array_checked(name.to_string(), map); + } + self.remove_var_attr(name, VarAttrs::NOVALUE); + Ok(()) + } + + /// Expand one unkeyed compound element into fields: word splitting for + /// unquoted expansions (bounded by `limit`), `"${a[@]}"` splats, brace + /// expansion and globbing for unquoted literals. + async fn expand_element_fields(&mut self, word: &Word, limit: usize) -> Result> { + // Brace expansion runs first on the unexpanded word, as for command + // arguments: `a=("x"{1,2})` gives `x1 x2`, `a=('{a,b}')` stays literal. + let Some(braced) = self.brace_expand_word(word) else { + return self.expand_element_word(word, limit).await; + }; + let mut fields = Vec::new(); + for w in &braced { + let left = limit.saturating_sub(fields.len()); + if left == 0 { + break; + } + fields.extend(self.expand_element_word(w, left).await?); + } + fields.truncate(limit); + Ok(fields) + } + + /// One brace-expanded compound element: splitting, splats, then globbing + /// for words with unquoted literal text. + async fn expand_element_word(&mut self, word: &Word, limit: usize) -> Result> { + let is_unquoted_expansion = !word.quoted + && word.parts.iter().any(|p| { + matches!( + p, + WordPart::Variable(_) + | WordPart::CommandSubstitution(_) + | WordPart::ArithmeticExpansion(_) + | WordPart::ParameterExpansion { .. } + | WordPart::ArrayAccess { .. } + ) + }); + let is_splat = word.quoted + && word.parts.len() == 1 + && matches!( + &word.parts[0], + WordPart::ArrayAccess { index, .. } if index == "@" + ) + || word.quoted + && word.parts.len() == 1 + && matches!(&word.parts[0], WordPart::Variable(n) if n == "@"); + if is_unquoted_expansion { + // Split fields are pathname-expanded like command words: + // `p='*.txt'; a=($p)` holds the matching files. + let expanded = self.expand_word(word).await?; + let mut fields = Vec::new(); + for field in self.ifs_split_limited(&expanded, limit)? { + match self.expand_glob_item(&field, false).await { + Ok(items) => fields.extend(items), + Err(_) => fields.push(field), + } + if fields.len() >= limit { + break; + } + } + fields.truncate(limit); + return Ok(fields); + } + if is_splat { + return self.expand_word_to_fields(word).await; + } + let value = self.expand_word(word).await?; + let literal_only = word.parts.iter().all(|p| matches!(p, WordPart::Literal(_))); + let globbable = if word.quoted { + word.has_unquoted_glob + } else { + literal_only + }; + if !globbable { + return Ok(vec![value]); + } + let mut fields = match self + .expand_glob_item(&value, word.quoted && word.has_unquoted_glob) + .await + { + Ok(items) => items, + Err(_) => vec![value], + }; + fields.truncate(limit); + Ok(fields) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quoting_matches_bash() { + assert_eq!(declare_quote("a\"b$c`d\\e"), "\"a\\\"b\\$c\\`d\\\\e\""); + assert_eq!(declare_quote("x\ny"), "$'x\\ny'"); + assert_eq!(ansic_quote("a\x01\x7f'"), "$'a\\001\\177\\''"); + assert_eq!(set_quote("abc"), "abc"); + assert_eq!(set_quote("a b"), "'a b'"); + assert_eq!(set_quote("it's"), "'it'\\''s'"); + assert_eq!(set_quote("a\tb c"), "$'a\\tb c'"); + } + + #[test] + fn assoc_order_follows_bash_hash_buckets() { + let m: HashMap = ["k", "j", "a", "b", "zz", "x1"] + .iter() + .map(|k| (k.to_string(), String::new())) + .collect(); + let keys: Vec<&str> = assoc_keys_bash_order(&m) + .into_iter() + .map(String::as_str) + .collect(); + assert_eq!(keys, ["x1", "k", "j", "b", "a", "zz"]); + } + + #[test] + fn keyed_elements_split_into_key_and_value() { + let script = Parser::new(r#"a=([k]="hello world" [i+1]+=x plain)"#) + .parse() + .unwrap(); + let Command::Simple(cmd) = &script.commands[0] else { + panic!("expected a simple command"); + }; + let AssignmentValue::Array(words) = &cmd.assignments[0].value else { + panic!("expected an array assignment"); + }; + assert_eq!(words.len(), 3); + let (k, v, append) = split_keyed_word(&words[0]).expect("keyed"); + assert_eq!( + (k.to_string(), v.to_string(), append), + ("k".into(), "hello world".into(), false) + ); + let (k, _, append) = split_keyed_word(&words[1]).expect("keyed"); + assert_eq!((k.to_string(), append), ("i+1".into(), true)); + assert!(split_keyed_word(&words[2]).is_none()); + } + + #[test] + fn nameref_targets() { + assert!(valid_nameref_target("a")); + assert!(valid_nameref_target("a[1]")); + assert!(!valid_nameref_target("1x")); + assert!(!valid_nameref_target("@")); + assert!(!valid_nameref_target("a b")); + } +} diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index c27500e3f..479009aff 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -17,6 +17,65 @@ enum CaseChange { } impl Interpreter { + /// Elements of `name` as `${name[@]}` sees them, as `(key, value)`: + /// an associative array in bash's hash order, an indexed array by + /// index, and a set scalar as its element 0 (`x=5; echo ${x[@]} + /// ${!x[@]}` prints `5 0`). `None` when the name has no value. + pub(super) fn array_view(&self, name: &str) -> Option> { + let name = self.resolve_nameref(name); + if let Some(arr) = self.scoped.assoc_arrays.get(name) { + return Some( + super::declare::assoc_keys_bash_order(arr) + .into_iter() + .map(|k| (k.clone(), arr[k].clone())) + .collect(), + ); + } + if let Some(arr) = self.scoped.arrays.get(name) { + let mut items: Vec<_> = arr.iter().collect(); + items.sort_unstable_by_key(|(i, _)| **i); + return Some( + items + .into_iter() + .map(|(i, v)| (i.to_string(), v.clone())) + .collect(), + ); + } + self.scalar_value(name).map(|v| vec![("0".to_string(), v)]) + } + + /// Values of `${name[@]}` (see [`Self::array_view`]). + pub(super) fn array_values(&self, name: &str) -> Vec { + self.array_view(name) + .map(|items| items.into_iter().map(|(_, v)| v).collect()) + .unwrap_or_default() + } + + /// Keys of `${!name[@]}` (see [`Self::array_view`]). + pub(super) fn array_keys(&self, name: &str) -> Vec { + self.array_view(name) + .map(|items| items.into_iter().map(|(k, _)| k).collect()) + .unwrap_or_default() + } + + /// The value of a plain (non-array) variable, if set. + fn scalar_value(&self, name: &str) -> Option { + self.scoped + .variables + .get(name) + .or_else(|| self.env.get(name)) + .cloned() + } + + /// `${x[i]}` on a scalar `x`: element 0 (or -1) is the scalar itself. + fn scalar_element(&self, name: &str, index: &str) -> Option { + if self.scoped.arrays.contains_key(name) || self.scoped.assoc_arrays.contains_key(name) { + return None; + } + let value = self.scalar_value(name)?; + matches!(self.evaluate_arithmetic(index), 0 | -1).then_some(value) + } + /// Expand an array access expression (`${arr[index]}`). pub(super) fn expand_array_access_part(&self, name: &str, index: &str) -> String { let resolved_name = self.resolve_nameref(name); @@ -31,18 +90,7 @@ impl Interpreter { } else { " ".to_string() }; - if let Some(arr) = self.scoped.assoc_arrays.get(arr_name) { - let mut keys: Vec<_> = arr.keys().collect(); - keys.sort(); - let values: Vec = - keys.iter().filter_map(|k| arr.get(*k).cloned()).collect(); - result.push_str(&values.join(&sep)); - } else if let Some(arr) = self.scoped.arrays.get(arr_name) { - let mut indices: Vec<_> = arr.keys().collect(); - indices.sort(); - let values: Vec<_> = indices.iter().filter_map(|i| arr.get(i)).collect(); - result.push_str(&values.into_iter().cloned().collect::>().join(&sep)); - } + result.push_str(&self.array_values(arr_name).join(&sep)); } else if let Some(extra_idx) = extra_index { if let Some(arr) = self.scoped.assoc_arrays.get(arr_name) { if let Some(value) = arr.get(&extra_idx) { @@ -61,13 +109,13 @@ impl Interpreter { if let Some(value) = arr.get(&key) { result.push_str(value); } - } else { + } else if let Some(arr) = self.scoped.arrays.get(arr_name) { let idx = self.resolve_indexed_array_subscript(arr_name, index); - if let Some(arr) = self.scoped.arrays.get(arr_name) - && let Some(value) = arr.get(&idx) - { + if let Some(value) = arr.get(&idx) { result.push_str(value); } + } else if let Some(value) = self.scalar_element(arr_name, index) { + result.push_str(&value); } result } @@ -228,6 +276,19 @@ impl Interpreter { for part in &word.parts { match part { + WordPart::CompoundAssignment { .. } => { + // Only declaration builtins take `name=(...)`; elsewhere + // the text stands for itself. + let text = Word { + parts: vec![part.clone()], + quoted: false, + has_unquoted_glob: false, + part_quoted: Vec::new(), + raw: None, + } + .to_string(); + result.push_str(&text); + } WordPart::Literal(s) => { // Tilde expansion: ~ at start of word expands to $HOME // A fully quoted word (`"~"`, `'~'`) keeps its tilde. @@ -319,7 +380,9 @@ impl Interpreter { } else { expr.to_string() }; - let value = self.evaluate_arithmetic_with_assign(&expanded_expr); + let value = self + .try_evaluate_arithmetic_with_assign(&expanded_expr) + .map_err(|msg| crate::error::Error::LineAbort(self.arith_diag("", &msg)))?; Self::append_expansion_for_word(&mut result, word, &value.to_string()); } WordPart::Length(name) => { @@ -333,13 +396,7 @@ impl Interpreter { .unwrap_or(name.len()); let start = (bracket_pos + 1).min(index_end); let index_str = &name[start..index_end]; - let idx: usize = - self.evaluate_arithmetic(index_str).try_into().unwrap_or(0); - if let Some(arr) = self.scoped.arrays.get(arr_name) { - arr.get(&idx).cloned().unwrap_or_default() - } else { - String::new() - } + self.expand_array_access_part(arr_name, index_str) } else { self.expand_variable(name) }; @@ -413,18 +470,8 @@ impl Interpreter { ); } WordPart::ArrayIndices(name) => { - let resolved = self.resolve_nameref(name); - if let Some(arr) = self.scoped.assoc_arrays.get(resolved) { - let mut keys: Vec<_> = arr.keys().cloned().collect(); - keys.sort(); - Self::append_expansion_for_word(&mut result, word, &keys.join(" ")); - } else if let Some(arr) = self.scoped.arrays.get(resolved) { - let mut indices: Vec<_> = arr.keys().collect(); - indices.sort(); - let index_strs: Vec = - indices.iter().map(|i| i.to_string()).collect(); - Self::append_expansion_for_word(&mut result, word, &index_strs.join(" ")); - } + let keys = self.array_keys(name); + Self::append_expansion_for_word(&mut result, word, &keys.join(" ")); } WordPart::Substring { name, @@ -465,11 +512,8 @@ impl Interpreter { offset, length, } => { - if let Some(arr) = self.scoped.arrays.get(name) { - let mut indices: Vec<_> = arr.keys().cloned().collect(); - indices.sort(); - let values: Vec<_> = - indices.iter().filter_map(|i| arr.get(i).cloned()).collect(); + if let Some(items) = self.array_view(name) { + let values: Vec = items.into_iter().map(|(_, v)| v).collect(); let offset_val: isize = self.evaluate_arithmetic(offset) as isize; let start = if offset_val < 0 { @@ -498,11 +542,10 @@ impl Interpreter { let is_nameref = nameref_target.is_some(); if is_nameref && operator.is_none() { - // Nameref without operator: ${!ref} returns the - // name the nameref points to (original behavior). - if let Some(ref target) = nameref_target { - Self::append_expansion_for_word(&mut result, word, target); - } + // Nameref without operator: ${!ref} is the name + // at the end of the nameref chain. + let target = self.resolve_nameref(name).to_string(); + Self::append_expansion_for_word(&mut result, word, &target); } else { // Resolve the indirect target variable name let resolved_name = if let Some(target) = nameref_target { @@ -561,13 +604,14 @@ impl Interpreter { } WordPart::ArrayLength(name) => { let resolved = self.resolve_nameref(name); - if let Some(arr) = self.scoped.assoc_arrays.get(resolved) { - result.push_str(&arr.len().to_string()); + let len = if let Some(arr) = self.scoped.assoc_arrays.get(resolved) { + arr.len() } else if let Some(arr) = self.scoped.arrays.get(resolved) { - result.push_str(&arr.len().to_string()); + arr.len() } else { - result.push('0'); - } + usize::from(self.scalar_value(resolved).is_some()) + }; + result.push_str(&len.to_string()); } WordPart::ProcessSubstitution { commands, is_input } => { let expanded = self @@ -651,31 +695,16 @@ impl Interpreter { if let WordPart::ArrayAccess { name, index } = &word.parts[0] && (index == "@" || index == "*") { - // Check assoc arrays first - if let Some(arr) = self.scoped.assoc_arrays.get(name) { - let mut keys: Vec<_> = arr.keys().cloned().collect(); - keys.sort(); - let values: Vec = - keys.iter().filter_map(|k| arr.get(k).cloned()).collect(); - if word.quoted && index == "*" { - let sep = self.get_ifs_separator(); - return Ok(vec![values.join(&sep)]); - } - return Ok(values); - } - if let Some(arr) = self.scoped.arrays.get(name) { - let mut indices: Vec<_> = arr.keys().collect(); - indices.sort(); - let values: Vec = - indices.iter().filter_map(|i| arr.get(i).cloned()).collect(); - // "${arr[*]}" joins into single field with IFS; "${arr[@]}" keeps separate - if word.quoted && index == "*" { - let sep = self.get_ifs_separator(); - return Ok(vec![values.join(&sep)]); - } - return Ok(values); + let Some(items) = self.array_view(name) else { + return Ok(Vec::new()); + }; + let values: Vec = items.into_iter().map(|(_, v)| v).collect(); + // "${arr[*]}" joins into single field with IFS; "${arr[@]}" keeps separate + if word.quoted && index == "*" { + let sep = self.get_ifs_separator(); + return Ok(vec![values.join(&sep)]); } - return Ok(Vec::new()); + return Ok(values); } // "${a[@]/x/y}", "${@^}", "${a[@]@Q}": one field per element. if let Some((elems, star)) = self.elementwise_fields(&word.parts[0]) { @@ -693,18 +722,7 @@ impl Interpreter { } // "${!arr[@]}" - array keys/indices as separate fields if let WordPart::ArrayIndices(name) = &word.parts[0] { - let resolved = self.resolve_nameref(name); - if let Some(arr) = self.scoped.assoc_arrays.get(resolved) { - let mut keys: Vec<_> = arr.keys().cloned().collect(); - keys.sort(); - return Ok(keys); - } - if let Some(arr) = self.scoped.arrays.get(resolved) { - let mut indices: Vec<_> = arr.keys().collect(); - indices.sort(); - return Ok(indices.iter().map(|i| i.to_string()).collect()); - } - return Ok(Vec::new()); + return Ok(self.array_keys(name)); } } @@ -851,25 +869,8 @@ impl Interpreter { } else { " ".to_string() }; - if let Some(arr) = self.scoped.assoc_arrays.get(resolved_arr_name) { - let is_set = !arr.is_empty(); - let mut keys: Vec<_> = arr.keys().collect(); - keys.sort(); - let values: Vec = - keys.iter().filter_map(|k| arr.get(*k).cloned()).collect(); - return (is_set, values.join(&sep)); - } - if let Some(arr) = self.scoped.arrays.get(resolved_arr_name) { - let is_set = !arr.is_empty(); - let mut indices: Vec<_> = arr.keys().collect(); - indices.sort(); - let values: Vec<_> = indices.iter().filter_map(|i| arr.get(i)).collect(); - return ( - is_set, - values.into_iter().cloned().collect::>().join(&sep), - ); - } - return (false, String::new()); + let values = self.array_values(resolved_arr_name); + return (!values.is_empty(), values.join(&sep)); } // Check for array element subscript: name[key] @@ -894,7 +895,10 @@ impl Interpreter { None => (false, String::new()), }; } - return (false, String::new()); + return match self.scalar_element(resolved_arr_name, key) { + Some(v) => (true, v), + None => (false, String::new()), + }; } // Special parameters @ and * @@ -930,18 +934,7 @@ impl Interpreter { .strip_suffix("[@]") .or_else(|| name.strip_suffix("[*]")) { - let resolved = self.resolve_nameref(arr_name); - if let Some(arr) = self.scoped.assoc_arrays.get(resolved) { - let mut keys: Vec<_> = arr.keys().collect(); - keys.sort(); - return Some(keys.iter().filter_map(|k| arr.get(*k).cloned()).collect()); - } - if let Some(arr) = self.scoped.arrays.get(resolved) { - let mut indices: Vec<_> = arr.keys().collect(); - indices.sort(); - return Some(indices.iter().filter_map(|i| arr.get(i).cloned()).collect()); - } - return Some(Vec::new()); + return Some(self.array_values(arr_name)); } None } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 76f827df7..64166785e 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -11,8 +11,8 @@ #![allow(clippy::unwrap_used)] mod arithmetic; -use arithmetic::{arith_lvalue_end, is_arith_lvalue}; mod brace_expansion; +mod declare; mod expansion; mod glob; mod jobs; @@ -434,8 +434,8 @@ const ENV_SHELL_ONLY_BUILTINS: &[&str] = &[ const MAX_CHILD_SHELL_DEPTH: usize = 8; const SPECIAL_BUILTIN_NAMES: &[&str] = &[ - ".", "bash", "builtin", "command", "declare", "eval", "exec", "getopts", "let", "local", "sh", - "source", "typeset", "unset", + ".", "bash", "builtin", "command", "declare", "eval", "exec", "export", "getopts", "let", + "local", "readonly", "sh", "source", "typeset", "unset", ]; /// Interpreter-dispatched names that real bash reports as shell builtins @@ -934,56 +934,20 @@ pub(crate) fn is_valid_var_name(name: &str) -> bool { chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } -/// Flags shared between `declare` and `local` builtins. -#[derive(Default)] -struct DeclareFlags { - nameref: bool, - array: bool, - assoc: bool, - integer: bool, -} - -impl DeclareFlags { - /// Parse common declare/local flags from a flag argument like "-naAi". - fn parse_flag_chars(&mut self, flag_arg: &str) { - for c in flag_arg[1..].chars() { - match c { - 'n' => self.nameref = true, - 'a' => self.array = true, - 'A' => self.assoc = true, - 'i' => self.integer = true, - _ => {} - } - } - } -} +// Important decision: locals use shallow binding, like bash. `local x` +// moves the caller-visible scalar binding (value, attributes, nameref, env +// entry) into the frame's `saved_vars` and the live maps then hold the local; +// popping the frame (or `unset` from a callee) puts the saved binding back. +// Lookups and builtins therefore only ever see the live maps, so `read`, +// `printf -v` and `declare -p` work on locals with no frame walk. -/// Reconstruct compound assignments that were split across arguments. -/// -/// Shell compound assignments like `arr=(1 2 3)` get split into -/// `["arr=(1", "2", "3)"]` by the parser. This merges them back. -fn merge_compound_assignments>(args: &[S]) -> Vec { - let mut merged = Vec::new(); - let mut pending: Option = None; - for arg in args { - let s = arg.as_ref(); - if let Some(ref mut p) = pending { - p.push(' '); - p.push_str(s); - if s.ends_with(')') { - merged.push(p.clone()); - pending = None; - } - } else if s.contains("=(") && !s.ends_with(')') { - pending = Some(s.to_string()); - } else { - merged.push(s.to_string()); - } - } - if let Some(p) = pending { - merged.push(p); - } - merged +/// A caller binding saved by a `local` declaration (shallow binding). +#[derive(Debug, Clone, Default)] +struct SavedVar { + value: Option, + attrs: Option, + nameref: Option, + env: Option, } /// A frame in the call stack for local variable scoping @@ -991,8 +955,11 @@ fn merge_compound_assignments>(args: &[S]) -> Vec { struct CallFrame { /// Function name name: String, - /// Local variables in this scope - locals: HashMap, + /// Caller bindings shadowed by `local` in this frame, keyed by name. + /// The keys are this frame's local variables. + saved_vars: HashMap, + /// True for a shell function frame (where `local` is allowed). + is_function: bool, /// Indexed arrays shadowed by local declarations in this scope. local_arrays: HashMap>>, /// Associative arrays shadowed by local declarations in this scope. @@ -1230,6 +1197,8 @@ bitflags::bitflags! { const LOWER = 0b0000_0100; const UPPER = 0b0000_1000; const EXPORT = 0b0001_0000; + /// Declared (`declare x`, `declare -a x`) but never assigned. + const NOVALUE = 0b0010_0000; } } @@ -1335,6 +1304,7 @@ struct PipelineStageScope { struct SubshellSnapshot { scoped: ScopedState, + env: Arc>, flags: BashFlags, cwd: PathBuf, memory_budget: crate::limits::MemoryBudget, @@ -1346,35 +1316,10 @@ struct SubshellSnapshot { bash_subshell: u32, } -/// Pending arithmetic evaluation error: bash reports the first one, naming the -/// whole expression, and the command exits 1 instead of using 0. -#[derive(Default)] -pub(crate) struct ArithError { - /// What failed and the offending token, once an error happened. - failure: Option<(String, String)>, - /// Expression text of the evaluation in progress. - expr: String, - /// Line the expression was on. - line: usize, -} - -impl ArithError { - /// bash's wording. `prefix` names the builtin when one reports the error - /// itself (`((: `, `let: `); expansion errors carry no prefix. - fn message(&self, prefix: &str, expr: Option<&str>) -> Option { - let (what, token) = self.failure.as_ref()?; - let expr = expr.unwrap_or(&self.expr); - Some(format!( - "bash: line {}: {prefix}{expr}: {what} (error token is \"{token}\")\n", - self.line - )) - } -} - /// Interpreter state. pub struct Interpreter { fs: Arc, - env: HashMap, + env: Arc>, // Important decision: the maps that get snapshotted by subshell ($(...)) // boundaries live in `scoped: ScopedState`, whose fields are `Arc` // so the snapshot is an O(1) refcount bump instead of an O(n) HashMap clone. @@ -1495,12 +1440,6 @@ pub struct Interpreter { /// Outputs of the `$(...)` parts of a `${x:-...}`-family operand, run /// ahead (async) and consumed in order by the sync `expand_operand`. operand_substs: std::collections::VecDeque, - /// Pending arithmetic error message, and the expression text it came from. - /// A `Mutex` because the evaluator runs behind `&self` and `Interpreter` - /// must stay `Sync`; the lock is only taken on the arithmetic path. - /// Reported and cleared where `nounset_error` is, so the command aborts - /// with status 1 instead of silently using 0, as bash does. - arith_error: std::sync::Mutex, /// PIPESTATUS: exit codes of the last pipeline's commands pipestatus: Vec, /// Aliases currently being expanded (prevents infinite recursion). @@ -1588,40 +1527,16 @@ pub struct Interpreter { script_depth: usize, /// Nested `bash`/`sh` child shells (TM-DOS-125). child_shell_depth: usize, -} - -struct ArithmeticExpansionState { - resolving_vars: Vec, - fuel: usize, -} - -impl ArithmeticExpansionState { - fn new(fuel: usize) -> Self { - Self { - resolving_vars: Vec::new(), - fuel, - } - } - - fn spend(&mut self, amount: usize) -> bool { - if self.fuel < amount { - return false; - } - self.fuel -= amount; - true - } - - fn enter_var(&mut self, name: &str) -> bool { - if self.resolving_vars.iter().any(|var| var == name) { - return false; - } - self.resolving_vars.push(name.to_string()); - true - } - - fn exit_var(&mut self) { - self.resolving_vars.pop(); - } + /// First arithmetic error raised by a read-only evaluation site (array + /// subscripts, `declare -i` values, substring offsets). The command + /// boundary turns it into a line abort, as bash does. + arith_error: StdMutex>, + /// Element words of `name=(...)` arguments of the command being run; + /// see `declare::COMPOUND_MARK`. + pending_compound_args: Vec>, + /// Set while prefix assignments (`x=1 cmd`) run: bash stores those + /// without applying `-i`/`-l`/`-u`. + assign_raw: bool, } impl Interpreter { @@ -2017,7 +1932,7 @@ impl Interpreter { Self { fs, - env: HashMap::new(), + env: Arc::new(HashMap::new()), scoped: ScopedState { variables: Arc::new(variables), arrays: Arc::new(arrays), @@ -2070,7 +1985,6 @@ impl Interpreter { output_stream_stderr_bytes: 0, nounset_error: None, operand_substs: std::collections::VecDeque::new(), - arith_error: std::sync::Mutex::new(ArithError::default()), pipestatus: Vec::new(), expanding_aliases: HashSet::new(), regex_cache: RuntimeRegexCache::default(), @@ -2101,6 +2015,9 @@ impl Interpreter { concurrent_jobs: true, script_depth: 0, child_shell_depth: 0, + arith_error: StdMutex::new(None), + pending_compound_args: Vec::new(), + assign_raw: false, } } @@ -2231,7 +2148,6 @@ impl Interpreter { output_stream_stderr_bytes: 0, nounset_error: None, operand_substs: std::collections::VecDeque::new(), - arith_error: std::sync::Mutex::new(ArithError::default()), pipestatus: Vec::new(), expanding_aliases: HashSet::new(), regex_cache: RuntimeRegexCache::default(), @@ -2263,6 +2179,9 @@ impl Interpreter { script_depth: 1, // Forks are polled on this task's stack, so they inherit its depth. child_shell_depth: self.child_shell_depth, + arith_error: StdMutex::new(None), + pending_compound_args: Vec::new(), + assign_raw: false, } } @@ -2283,6 +2202,14 @@ impl Interpreter { Arc::make_mut(&mut self.scoped.variables) } + /// Mutable exported environment. `env` is an `Arc` for the same reason + /// as `scoped`: subshell snapshots take it in O(1), so `(E=2)` cannot + /// leak an export into the parent. + #[inline] + fn env_mut(&mut self) -> &mut HashMap { + Arc::make_mut(&mut self.env) + } + #[inline] /// A simple command is a one-element pipeline: bash sets /// `PIPESTATUS=(status)` after it. Skips the write when unchanged so the @@ -2472,20 +2399,13 @@ impl Interpreter { /// transient and must be reset between exec() calls (TM-ISO-023). /// `shopt` options (SHOPT_expand_aliases, SHOPT_extglob, etc.) are /// persistent session configuration and are NOT reset. - const SET_OPTION_VARS: &'static [&'static str] = &[ - "SHOPT_a", - "SHOPT_b", - "SHOPT_e", - "SHOPT_f", - "SHOPT_h", - "SHOPT_m", - "SHOPT_n", - "SHOPT_u", - "SHOPT_v", - "SHOPT_x", - "SHOPT_C", - "SHOPT_pipefail", - ]; + /// (`interactive-comments` is shared with `shopt`, so it persists.) + fn set_option_vars() -> impl Iterator { + builtins::SET_O_OPTIONS + .iter() + .map(|(_, _, var, _)| *var) + .filter(|var| *var != "SHOPT_interactive_comments") + } /// THREAT[TM-ISO-005/006/007]: Reset per-exec transient state. /// Called by Bash::exec() before each top-level execution to prevent @@ -2509,8 +2429,8 @@ impl Interpreter { // the private stack and public array before reusing the Bash instance. self.bash_source_stack.clear(); self.arrays_mut().remove("BASH_SOURCE"); - for var in Self::SET_OPTION_VARS { - self.vars_mut().remove(*var); + for var in Self::set_option_vars() { + self.vars_mut().remove(var); if let Some(bit) = BashFlags::from_shopt_name(var) { self.flags.remove(bit); } @@ -2563,7 +2483,7 @@ impl Interpreter { /// frame created for per-invocation positional parameters, including on /// the error paths where the interpreter left frames behind. pub(crate) fn truncate_call_stack(&mut self, len: usize) { - self.call_stack.truncate(len); + self.unwind_call_stack(len); } /// Install `$0` and positional parameters for a top-level execution. @@ -2581,7 +2501,8 @@ impl Interpreter { ) { self.call_stack.push(CallFrame { name: name.unwrap_or_else(|| Self::DEFAULT_ARG0.to_string()), - locals: HashMap::new(), + saved_vars: HashMap::new(), + is_function: false, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional, @@ -2598,7 +2519,7 @@ impl Interpreter { /// Set an environment variable. pub fn set_env(&mut self, key: &str, value: &str) { - self.env.insert(key.to_string(), value.to_string()); + self.env_mut().insert(key.to_string(), value.to_string()); } /// Install the last-chance command resolver (public API for builder). @@ -2805,7 +2726,7 @@ impl Interpreter { // ShellState struct (which holds plain HashMaps so users can mutate // it freely). ShellState { - env: self.env.clone(), + env: (*self.env).clone(), variables: (*self.scoped.variables).clone(), var_attrs: self .scoped @@ -2833,7 +2754,7 @@ impl Interpreter { /// Capture a lightweight shell-state view for prompt/UI inspection. pub fn shell_state_view(&self) -> ShellStateView { ShellStateView { - env: self.env.clone(), + env: (*self.env).clone(), variables: (*self.scoped.variables).clone(), arrays: (*self.scoped.arrays).clone(), assoc_arrays: (*self.scoped.assoc_arrays).clone(), @@ -2846,7 +2767,7 @@ impl Interpreter { /// Restore shell state from a snapshot. pub fn restore_shell_state(&mut self, state: &ShellState) { - self.env = state.env.clone(); + self.env = Arc::new(state.env.clone()); let mut restored_variables = state.variables.clone(); let mut restored_var_attrs: HashMap = state .var_attrs @@ -3275,7 +3196,14 @@ impl Interpreter { let mut stopped = false; let top_level = self.script_depth == 1; + // Line of a command that aborted (bash DISCARD): the rest of that + // line is skipped and the shell resumes at the next one. + let mut aborted_line: Option = None; + let mut propagate_abort = false; for command in &script.commands { + if aborted_line == Some(Self::command_line(command)) { + continue; + } self.check_cancelled()?; let emit_before = self.output_emit_count; let emitted_before = ( @@ -3333,6 +3261,18 @@ impl Interpreter { exit_code = result.exit_code; self.last_exit_code = exit_code; + if result.control_flow == ControlFlow::Abort { + // A shell's top level (script, `bash -c`) resumes at the next + // line; `source`/`eval` bodies pass the abort to their caller. + if run_exit_trap { + aborted_line = Some(Self::command_line(command)); + continue; + } + propagate_abort = true; + stopped = true; + break; + } + // Stop on control flow (e.g. nounset error uses Return to abort) if result.control_flow != ControlFlow::None { if let ControlFlow::Exit(code) = result.control_flow { @@ -3464,7 +3404,11 @@ impl Interpreter { stdout, stderr, exit_code, - control_flow: ControlFlow::None, + control_flow: if propagate_abort { + ControlFlow::Abort + } else { + ControlFlow::None + }, stdout_truncated, stderr_truncated, final_env, @@ -3546,7 +3490,7 @@ impl Interpreter { return Ok(killed); } - match command { + let result = match command { Command::Simple(simple) => { // One Result local (not `?` + a second ExecResult copy): // this frame repeats per `$(...)` nesting level. @@ -3571,8 +3515,23 @@ impl Interpreter { Command::Compound(compound, redirects) => { // Own frame: keeps this arm's temporaries off the stack // of every `$(...)`/function nesting level. - self.execute_compound_with_redirects(compound, redirects) - .await + let result = self + .execute_compound_with_redirects(compound, redirects) + .await; + // `( )`, `[[ ]]` and `(( ))` set PIPESTATUS to their own + // status like a simple command; other compounds leave + // the last inner command's (bash). + if let Ok(r) = &result + && matches!( + compound, + CompoundCommand::Subshell(_) + | CompoundCommand::Conditional(_) + | CompoundCommand::Arithmetic(_) + ) + { + self.set_simple_pipestatus(r.exit_code); + } + result } Command::Function(func_def) => { // THREAT[TM-DOS-060]: Check function count/size budget @@ -3607,10 +3566,45 @@ impl Interpreter { } Ok(ExecResult::ok(String::new())) } - } + }; + self.abort_line_on_error(result) }) } + /// Turn a line-abort error (or an arithmetic error recorded by a + /// read-only evaluation site) into `ControlFlow::Abort`, keeping the + /// output produced so far. + fn abort_line_on_error(&mut self, result: Result) -> Result { + match result { + Err(crate::error::Error::LineAbort(msg)) => { + self.take_arith_error(); + Ok(ExecResult { + stderr: msg.into(), + exit_code: 1, + control_flow: ControlFlow::Abort, + ..Default::default() + }) + } + Ok(mut r) => { + if let Some(msg) = self.take_arith_error() { + r.stderr + .append(&crate::StreamData::from(self.arith_diag("", &msg))); + r.exit_code = 1; + r.control_flow = ControlFlow::Abort; + } + Ok(r) + } + other => other, + } + } + + /// Error for a pending arithmetic error, if any (checked after expansion + /// so the command does not run). + fn pending_arith_abort(&self) -> Option { + self.take_arith_error() + .map(|msg| crate::error::Error::LineAbort(self.arith_diag("", &msg))) + } + /// Charge every executable AST command, including optimized command forms. fn charge_command_execution(&mut self) -> Result<()> { self.execution_budget.consume_work(1)?; @@ -3689,12 +3683,18 @@ impl Interpreter { // Also clear errexit_suppressed: inner AND/OR suppression must not // escape the subshell boundary and prevent the parent set -e from // firing on the subshell's non-zero exit code. + let mut result = self.abort_line_on_error(result); if let Ok(ref mut res) = result { match res.control_flow { ControlFlow::Exit(code) | ControlFlow::Return(code) => { res.exit_code = code; res.control_flow = ControlFlow::None; } + // A line abort ends only the subshell, with status 1. + ControlFlow::Abort => { + res.exit_code = 1; + res.control_flow = ControlFlow::None; + } _ => {} } res.errexit_suppressed = false; @@ -3830,7 +3830,15 @@ impl Interpreter { // Set loop variable (respects nameref). `value` is moved // straight into `set_variable` — previously we cloned it // even though `values` already owned the String for us. - self.set_variable(for_cmd.variable.clone(), value); + // A nameref control variable is re-pointed at each word + // (bash: `declare -n r; for r in a b` binds r to a, then b). + if self.scoped.namerefs.contains_key(&for_cmd.variable) + && declare::valid_nameref_target(&value) + { + self.set_nameref(&for_cmd.variable, value); + } else { + self.set_variable(for_cmd.variable.clone(), value); + } // Execute body let emit_before = self.output_emit_count; @@ -4091,6 +4099,15 @@ impl Interpreter { ..Default::default() }); } + ControlFlow::Abort => { + return Ok(ExecResult { + stdout, + stderr, + exit_code: 1, + control_flow: ControlFlow::Abort, + ..Default::default() + }); + } ControlFlow::None => {} } } @@ -4174,7 +4191,7 @@ impl Interpreter { // the right side must NOT be expanded (to avoid set -u errors). let result = self.evaluate_conditional_words(words).await?; // If a nounset error occurred during evaluation, propagate it. - if let Some(err_msg) = self.take_expansion_error() { + if let Some(err_msg) = self.nounset_error.take() { self.last_exit_code = 1; return Ok(ExecResult { stderr: err_msg.into(), @@ -4391,6 +4408,7 @@ impl Interpreter { match args[0].as_str() { "-z" => args[1].is_empty(), "-n" => !args[1].is_empty(), + "-v" => self.cond_var_is_set(&args[1]), "-e" | "-a" => self.fs.exists(&resolve(&args[1])).await.unwrap_or(false), "-f" => self .fs @@ -4494,6 +4512,23 @@ impl Interpreter { }) } + /// `[[ -v name ]]`: a scalar is set when it has a value; an array name + /// alone tests element 0 (key `"0"` for assoc); `name[sub]` tests that + /// element and `name[@]` any element. + fn cond_var_is_set(&self, arg: &str) -> bool { + if arg.contains('[') && arg.ends_with(']') { + return self.resolve_param_expansion_name(arg).0; + } + let name = self.resolve_nameref(arg); + if let Some(arr) = self.scoped.arrays.get(name) { + return arr.contains_key(&0); + } + if let Some(arr) = self.scoped.assoc_arrays.get(name) { + return arr.contains_key("0"); + } + self.is_variable_set(name) + } + /// Perform regex match and set BASH_REMATCH array. fn regex_match(&mut self, string: &str, pattern: &str) -> bool { match self.regex_cache.get_or_compile(pattern) { @@ -4520,160 +4555,25 @@ impl Interpreter { } async fn execute_arithmetic_command(&mut self, expr: &str) -> Result { - let result = self.execute_arithmetic_with_side_effects(expr); - // `(( ))` reports the error itself and fails; it does not abort the - // script the way a failed `$(( ))` expansion does. - if let Some(msg) = self.take_arith_error("((: ") { - return Ok(ExecResult::err(msg, 1)); + let expr = if expr.contains("$(") { + self.expand_command_subs_in_arithmetic(expr).await? + } else { + expr.to_string() + }; + match self.try_evaluate_arithmetic_with_assign(&expr) { + Ok(v) => Ok(ExecResult { + exit_code: if v != 0 { 0 } else { 1 }, + ..Default::default() + }), + // `((...))` reports an arithmetic error and fails with status 1; + // unlike `$((...))` it does not abandon the line. + Err(msg) => Ok(ExecResult::err(self.arith_diag("((: ", &msg), 1)), } - let exit_code = if result != 0 { 0 } else { 1 }; - - Ok(ExecResult { - stdout: crate::StreamData::new(), - stderr: crate::StreamData::new(), - exit_code, - control_flow: ControlFlow::None, - ..Default::default() - }) } /// Execute arithmetic expression with side effects (assignments, ++, --) fn execute_arithmetic_with_side_effects(&mut self, expr: &str) -> i64 { - let expr = expr.trim(); - - // Handle comma-separated expressions - if expr.contains(',') { - let parts: Vec<&str> = expr.split(',').collect(); - let mut result = 0; - for part in parts { - result = self.execute_arithmetic_with_side_effects(part.trim()); - } - return result; - } - - // Handle assignment: var = expr or var op= expr - if let Some(eq_pos) = expr.find('=') { - // Check it's not ==, !=, <=, >= - // eq_pos is a byte offset from find(), so use byte-safe slicing - let before_eq = &expr[..eq_pos]; - let before = before_eq.chars().last(); - let after = expr[eq_pos + 1..].chars().next(); - - if after != Some('=') && !matches!(before, Some('!' | '<' | '>' | '=')) { - // This is an assignment - let lhs = expr[..eq_pos].trim(); - let rhs = expr[eq_pos + 1..].trim(); - - // Check for compound assignment (+=, -=, *=, /=, %=) - let (var_name, op, effective_rhs) = if lhs.ends_with('+') - || lhs.ends_with('-') - || lhs.ends_with('*') - || lhs.ends_with('/') - || lhs.ends_with('%') - { - let op = lhs.chars().last().unwrap(); - let name = lhs[..lhs.len() - 1].trim(); - (name, Some(op), rhs) - } else { - (lhs, None, rhs) - }; - - let rhs_value = self.execute_arithmetic_with_side_effects(effective_rhs); - let final_value = if let Some(op) = op { - let current = self.arith_lvalue_value(var_name); - // THREAT[TM-DOS-043]: wrapping to prevent overflow panic - match op { - '+' => current.wrapping_add(rhs_value), - '-' => current.wrapping_sub(rhs_value), - '*' => current.wrapping_mul(rhs_value), - '/' => { - if rhs_value != 0 && !(current == i64::MIN && rhs_value == -1) { - current / rhs_value - } else { - 0 - } - } - '%' => { - if rhs_value != 0 && !(current == i64::MIN && rhs_value == -1) { - current % rhs_value - } else { - 0 - } - } - _ => rhs_value, - } - } else { - rhs_value - }; - - if !is_arith_lvalue(var_name) { - return self.evaluate_arithmetic(expr); - } - self.set_arith_lvalue(var_name, final_value.to_string()); - return final_value; - } - } - - // Handle pre-increment/decrement: ++var or --var - if let Some(stripped) = expr.strip_prefix("++") { - let trimmed = stripped.trim_start(); - // Extract the variable name (leading identifier chars) - let var_end = arith_lvalue_end(trimmed); - let var_name = &trimmed[..var_end]; - if !var_name.is_empty() && is_arith_lvalue(var_name) { - let current = self.arith_lvalue_value(var_name); - // THREAT[TM-DOS-043]: Bash integer side effects wrap at i64 bounds. - let new_value = current.wrapping_add(1); - self.set_arith_lvalue(var_name, new_value.to_string()); - let rest = trimmed[var_end..].trim(); - if rest.is_empty() { - return new_value; - } - // Complex expression: substitute the incremented value and evaluate - // e.g. "++i > 3" → increment i, then evaluate "1 > 3" - let full_expr = format!("{new_value}{rest}"); - return self.evaluate_arithmetic(&full_expr); - } - } - if let Some(stripped) = expr.strip_prefix("--") { - let trimmed = stripped.trim_start(); - let var_end = arith_lvalue_end(trimmed); - let var_name = &trimmed[..var_end]; - if !var_name.is_empty() && is_arith_lvalue(var_name) { - let current = self.arith_lvalue_value(var_name); - let new_value = current.wrapping_sub(1); - self.set_arith_lvalue(var_name, new_value.to_string()); - let rest = trimmed[var_end..].trim(); - if rest.is_empty() { - return new_value; - } - let full_expr = format!("{new_value}{rest}"); - return self.evaluate_arithmetic(&full_expr); - } - } - - // Handle post-increment/decrement: var++ or var-- - if let Some(stripped) = expr.strip_suffix("++") { - let var_name = stripped.trim(); - if is_arith_lvalue(var_name) { - let current = self.arith_lvalue_value(var_name); - let new_value = current.wrapping_add(1); - self.set_arith_lvalue(var_name, new_value.to_string()); - return current; // Return old value for post-increment - } - } - if let Some(stripped) = expr.strip_suffix("--") { - let var_name = stripped.trim(); - if is_arith_lvalue(var_name) { - let current = self.arith_lvalue_value(var_name); - let new_value = current.wrapping_sub(1); - self.set_arith_lvalue(var_name, new_value.to_string()); - return current; // Return old value for post-decrement - } - } - - // No side effects, just evaluate - self.evaluate_arithmetic(expr) + self.evaluate_arithmetic_with_assign(expr) } /// Execute a while loop @@ -5488,7 +5388,8 @@ impl Interpreter { // Push call frame, apply options, execute, restore, pop self.call_stack.push(CallFrame { name: name_arg, - locals: HashMap::new(), + saved_vars: HashMap::new(), + is_function: false, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: positional_args, @@ -6249,11 +6150,14 @@ impl Interpreter { self.restore_subshell_state(snap); self.call_stack = call_stack; self.coproc_buffers = coproc; - let mut result = result?; + let mut result = self.abort_line_on_error(result)?; if let ControlFlow::Exit(code) | ControlFlow::Return(code) = result.control_flow { result.exit_code = code; result.control_flow = ControlFlow::None; } + if result.control_flow == ControlFlow::Abort { + result.control_flow = ControlFlow::None; + } result.errexit_suppressed = false; Ok(result) } @@ -6543,260 +6447,88 @@ impl Interpreter { } /// Process variable assignments from a command's prefix (e.g. `VAR=val cmd`). - async fn process_command_assignments(&mut self, assignments: &[Assignment]) -> Result<()> { - for assignment in assignments { - match &assignment.value { - AssignmentValue::Scalar(word) => { - let value = self.expand_word(word).await?; - if let Some(index_str) = &assignment.index { - let resolved_name = self.resolve_nameref(&assignment.name).to_string(); - if self.scoped.assoc_arrays.contains_key(&resolved_name) { - let key = self.expand_assoc_key(index_str).await?; - let existing = self - .scoped - .assoc_arrays - .get(&resolved_name) - .and_then(|a| a.get(&key)) - .cloned(); - let is_new_entry = existing.is_none(); - let old_len = existing.as_ref().map_or(0, String::len); - let new_len = if assignment.append { - old_len + value.len() - } else { - value.len() - }; - // A new entry also charges its key; an overwrite only - // charges the value delta. - let added = if is_new_entry { - key.len() + new_len - } else { - new_len - }; - if self.admit_array_write( - usize::from(is_new_entry), - added, - if is_new_entry { 0 } else { old_len }, - ) { - let arr = self.assoc_arrays_mut().entry(resolved_name).or_default(); - if assignment.append { - arr.insert(key, existing.unwrap_or_default() + &value); - } else { - arr.insert(key, value); - } - } - } else { - let index = - self.resolve_indexed_array_subscript(&resolved_name, index_str); - let existing = self - .scoped - .arrays - .get(&resolved_name) - .and_then(|a| a.get(&index)) - .cloned(); - let is_new_entry = existing.is_none(); - let old_len = existing.as_ref().map_or(0, String::len); - let new_len = if assignment.append { - old_len + value.len() - } else { - value.len() - }; - if self.admit_array_write( - usize::from(is_new_entry), - new_len, - if is_new_entry { 0 } else { old_len }, - ) { - let arr = self.arrays_mut().entry(resolved_name).or_default(); - if assignment.append { - arr.insert(index, existing.unwrap_or_default() + &value); - } else { - arr.insert(index, value); - } - } - } - } else if assignment.append { - let existing = self.expand_variable(&assignment.name); - self.set_variable(assignment.name.clone(), existing + &value); - } else { - self.set_variable(assignment.name.clone(), value); - } - } - AssignmentValue::Array(words) => { - self.assign_array_literal(assignment, words).await?; - } - } - } - Ok(()) - } - - /// `name=(words...)` / `name+=(words...)`. Boxed so the per-word brace, - /// glob and `[i]=v` handling stays off the recursive poll stack of every - /// simple command (TM-DOS-020 two-MiB stack budget). - fn assign_array_literal<'a>( + /// + /// `has_command`: the assignments prefix a command (`x=1 cmd`). Bash then + /// stores them without `-i`/`-l`/`-u`, and a readonly target only prints + /// an error (returned for stderr); a bare assignment to a readonly + /// variable abandons the line instead. + fn process_command_assignments<'a>( &'a mut self, - assignment: &'a Assignment, - words: &'a [Word], - ) -> std::pin::Pin> + Send + 'a>> { + assignments: &'a [Assignment], + has_command: bool, + ) -> std::pin::Pin> + Send + 'a>> { + // Boxed: keeps this future off execute_simple_command's frame, which + // repeats per function nesting level (THREAT[TM-DOS-020]). Box::pin(async move { - // Expand directly into the replacement array so word-split expansions cannot - // accumulate an unbounded temporary Vec before max_array_entries is enforced. - let arr_name = self.resolve_nameref(&assignment.name).to_string(); - let old_entries = self.scoped.arrays.get(&arr_name).map_or(0, |arr| arr.len()); - let remaining_entries = self - .memory_limits - .max_array_entries - .saturating_sub(self.memory_budget.array_entries); - let max_new_entries = old_entries.saturating_add(remaining_entries); - let mut next_arr = if assignment.append { - self.scoped - .arrays - .get(&arr_name) - .cloned() - .unwrap_or_default() - } else { - HashMap::new() - }; - let mut idx = if assignment.append { - next_arr.keys().max().map(|k| k + 1).unwrap_or(0) - } else { - 0 - }; - - // Brace expansion runs first, on the unexpanded word, as for - // command arguments; pathname expansion runs on the fields below. - let mut braced_words: Vec = Vec::with_capacity(words.len()); - for w in words.iter() { - match self.brace_expand_word(w) { - Some(ws) => braced_words.extend(ws), - None => braced_words.push(w.clone()), - } - } - - 'array_words: for word in braced_words.iter() { - // `[i]=value` sets one element; the value gets no field - // splitting or pathname expansion. - if let Some((index, value_word)) = split_indexed_element(word) { - let i = self.evaluate_arithmetic_with_assign(&index); - let value = self.expand_word(&value_word).await?; - if i < 0 { - continue; - } - let i = i as usize; - if !next_arr.contains_key(&i) && next_arr.len() >= max_new_entries { - break 'array_words; - } - next_arr.insert(i, value); - idx = i + 1; - continue; - } - let globs = !word.quoted || word.has_unquoted_glob; - let is_unquoted_expansion = !word.quoted - && word.parts.iter().any(|p| { - matches!( - p, - WordPart::Variable(_) - | WordPart::CommandSubstitution(_) - | WordPart::ArithmeticExpansion(_) - | WordPart::ParameterExpansion { .. } - | WordPart::ArrayAccess { .. } - ) - }); - // "${arr[@]}" or "$@" in array context should splat - // individual elements, not join into a single string. - let is_quoted_splat = word.quoted - && word.parts.len() == 1 - && matches!( - &word.parts[0], - WordPart::ArrayAccess { index, .. } if index == "@" - ); - let is_quoted_positional_splat = word.quoted - && word.parts.len() == 1 - && matches!( - &word.parts[0], - WordPart::Variable(name) if name == "@" - ); - - if is_unquoted_expansion { - let remaining = max_new_entries.saturating_sub(next_arr.len()); - if remaining == 0 { - break; - } - let expanded = self.expand_word(word).await?; - for field in self.ifs_split_limited(&expanded, remaining)? { - for item in self.glob_array_field(field, word, globs).await { - if next_arr.len() >= max_new_entries { - break 'array_words; + let mut stderr = String::new(); + for assignment in assignments { + match &assignment.value { + AssignmentValue::Scalar(word) => { + let value = self.expand_word(word).await?; + let target = match self.resolve_nameref_strict(&assignment.name) { + Ok(t) => t, + Err(()) => { + return Err(crate::error::Error::LineAbort(format!( + "bash: warning: {}: circular name reference\n", + assignment.name + ))); } - next_arr.insert(idx, item); - idx += 1; + }; + let base = target.split('[').next().unwrap_or(&target).to_string(); + // THREAT[TM-INJ-019]: assignments to readonly variables fail + // visibly, as in bash. + if self.is_var_readonly(&base) { + let msg = format!("bash: {base}: readonly variable\n"); + if has_command { + stderr.push_str(&msg); + continue; + } + return Err(crate::error::Error::LineAbort(msg)); } - } - if next_arr.len() >= max_new_entries { - break 'array_words; - } - } else if is_quoted_splat || is_quoted_positional_splat { - for field in self.expand_word_to_fields(word).await? { - if next_arr.len() >= max_new_entries { - break 'array_words; + if let Some(index_str) = &assignment.index { + self.assign_element(&base, index_str, value, assignment.append) + .await?; + } else if has_command { + self.assign_raw = true; + if assignment.append { + let existing = self.expand_variable(&assignment.name); + self.set_variable(assignment.name.clone(), existing + &value); + } else { + self.set_variable(assignment.name.clone(), value); + } + self.assign_raw = false; + } else if assignment.append { + self.append_scalar(&assignment.name, value); + } else { + self.set_variable(assignment.name.clone(), value); } - next_arr.insert(idx, field); - idx += 1; } - } else { - let value = self.expand_word(word).await?; - for item in self.glob_array_field(value, word, globs).await { - if next_arr.len() >= max_new_entries { - break 'array_words; + AssignmentValue::Array(words) => { + let arr_name = match self.resolve_nameref_strict(&assignment.name) { + Ok(n) => n, + Err(()) => { + return Err(crate::error::Error::LineAbort(format!( + "bash: warning: {}: circular name reference\n", + assignment.name + ))); + } + }; + // THREAT[TM-INJ-019]: arrays honour readonly like scalars. + if self.is_var_readonly(&arr_name) { + return Err(crate::error::Error::LineAbort(format!( + "bash: {arr_name}: readonly variable\n" + ))); } - next_arr.insert(idx, item); - idx += 1; + let assoc = self.scoped.assoc_arrays.contains_key(&arr_name); + self.assign_array_words(&arr_name, words, assignment.append, assoc) + .await?; } } } - - let _ = self.insert_array_checked(arr_name, next_arr); - Ok(()) + Ok(stderr) }) } - /// Pathname expansion for one array-literal field. A failglob miss keeps the - /// literal field. - // WTF: bash aborts the assignment with "no match" under failglob; assignment - // expansion has no error channel here, so the pattern is stored as-is. - async fn glob_array_field(&mut self, field: String, word: &Word, globs: bool) -> Vec { - if !globs { - return vec![field]; - } - match self - .expand_glob_item(&field, word.quoted && word.has_unquoted_glob) - .await - { - Ok(items) => items, - Err(_) => vec![field], - } - } - - /// The pending expansion error, if any: an arithmetic failure first, then a - /// `set -u` failure. Clears both. - fn take_expansion_error(&mut self) -> Option { - let arith = self.take_arith_error(""); - let nounset = self.nounset_error.take(); - arith.or(nounset) - } - - /// Take the pending arithmetic error, formatted with `prefix`. - fn take_arith_error(&self, prefix: &str) -> Option { - self.take_arith_error_for(prefix, None) - } - - /// As [`Self::take_arith_error`], with the expression text the caller saw - /// (`let` reports its whole argument, assignment included). - fn take_arith_error_for(&self, prefix: &str, expr: Option<&str>) -> Option { - let mut e = self.arith_error.lock().ok()?; - let msg = e.message(prefix, expr); - *e = ArithError::default(); - msg - } - /// Try alias expansion. Returns `Some(result)` if alias was expanded, `None` otherwise. async fn try_alias_expansion( &mut self, @@ -7023,7 +6755,7 @@ impl Interpreter { } }; - if let Some(err_msg) = self.take_expansion_error() { + if let Some(err_msg) = self.nounset_error.take() { self.last_exit_code = 1; self.discard_deferred_proc_subs_from(deferred_proc_sub_start); return Ok(ExecResult { @@ -7036,7 +6768,11 @@ impl Interpreter { } let pre_expanded_args = if !name.is_empty() { - match self.expand_command_args(command).await { + let decl = matches!( + name.as_str(), + "declare" | "typeset" | "local" | "export" | "readonly" | "builtin" | "command" + ); + match self.expand_command_args(command, decl).await { Ok(args) if name_extra_args.is_empty() => Some(args), Ok(args) => { let mut all = name_extra_args; @@ -7052,6 +6788,11 @@ impl Interpreter { None }; + if let Some(err) = self.pending_arith_abort() { + self.discard_deferred_proc_subs_from(deferred_proc_sub_start); + return Err(err); + } + let var_saves: Vec<(String, Option)> = command .assignments .iter() @@ -7060,7 +6801,29 @@ impl Interpreter { let pre_assign_subst_gen = self.subst_generation; - if let Err(err) = self.process_command_assignments(&command.assignments).await { + // Environment before the prefix assignments, so `V=1 cmd` on an + // exported `V` restores the old value afterwards. + let pre_assign_env: HashMap> = if name.is_empty() { + HashMap::new() + } else { + command + .assignments + .iter() + .map(|a| (a.name.clone(), self.env.get(&a.name).cloned())) + .collect() + }; + let assign_stderr = match self + .process_command_assignments(&command.assignments, !name.is_empty()) + .await + { + Ok(stderr) => stderr, + Err(err) => { + self.discard_deferred_proc_subs_from(deferred_proc_sub_start); + return Err(err); + } + }; + if let Some(err) = self.pending_arith_abort() { + self.restore_variables(var_saves); self.discard_deferred_proc_subs_from(deferred_proc_sub_start); return Err(err); } @@ -7124,10 +6887,13 @@ impl Interpreter { if assignment.index.is_none() && let Some(value) = self.scoped.variables.get(&assignment.name).cloned() { - env_saves - .entry(assignment.name.clone()) - .or_insert_with(|| self.env.get(&assignment.name).cloned()); - self.env.insert(assignment.name.clone(), value); + env_saves.entry(assignment.name.clone()).or_insert_with(|| { + pre_assign_env + .get(&assignment.name) + .cloned() + .unwrap_or(None) + }); + self.env_mut().insert(assignment.name.clone(), value); } } @@ -7155,10 +6921,10 @@ impl Interpreter { for (name, old) in env_saves { match old { Some(v) => { - self.env.insert(name, v); + self.env_mut().insert(name, v); } None => { - self.env.remove(&name); + self.env_mut().remove(&name); } } } @@ -7166,6 +6932,17 @@ impl Interpreter { // Restore variables self.restore_variables(var_saves); + let result = if assign_stderr.is_empty() { + result + } else { + result.map(|mut r| { + let mut stderr: crate::StreamData = assign_stderr.into(); + stderr.append(&r.stderr); + r.stderr = stderr; + r + }) + }; + // Prepend xtrace to stderr let mut result = if let Some(trace) = xtrace_line { result.map(|mut r| { @@ -7192,10 +6969,35 @@ impl Interpreter { fn expand_command_args<'a>( &'a mut self, command: &'a SimpleCommand, + decl: bool, ) -> std::pin::Pin>> + Send + 'a>> { Box::pin(async move { let mut args: Vec = Vec::new(); + let mut compounds: Vec> = Vec::new(); for word0 in &command.args { + // `name=(...)` operand of a declaration builtin: the builtin + // expands the elements; pass a placeholder naming them. + if let [ + WordPart::CompoundAssignment { + name, + append, + elements, + }, + ] = word0.parts.as_slice() + { + if decl { + let op = if *append { "+=" } else { "=" }; + args.push(format!( + "{name}{op}{m}{}{m}", + compounds.len(), + m = declare::COMPOUND_MARK + )); + compounds.push(elements.clone()); + } else { + args.push(word0.to_string()); + } + continue; + } // Brace expansion runs first, on the unexpanded word. let braced = self.brace_expand_word(word0); for word in braced.as_deref().unwrap_or(std::slice::from_ref(word0)) { @@ -7231,6 +7033,7 @@ impl Interpreter { } } } + self.pending_compound_args = compounds; Ok(args) }) } @@ -7276,7 +7079,7 @@ impl Interpreter { } // Check for nounset error from argument expansion - if let Some(err_msg) = self.take_expansion_error() { + if let Some(err_msg) = self.nounset_error.take() { self.last_exit_code = 1; return Ok(ExecResult { stdout: crate::StreamData::new(), @@ -7914,25 +7717,6 @@ impl Interpreter { self.apply_builtin_side_effects(&mut result).await; - // Sync successful export operands into env so subprocess isolation can see them. - // Keep syncing even if export returned nonzero for other args (bash-compatible). - if name == "export" { - for arg in args { - if let Some(eq_pos) = arg.find('=') { - let var_name = &arg[..eq_pos]; - if self.is_var_readonly(var_name) { - continue; - } - if let Some(value) = self.scoped.variables.get(var_name) { - self.env.insert(var_name.to_string(), value.clone()); - } - } else if let Some(value) = self.scoped.variables.get(arg.as_str()) { - // export NAME (without =) — mark existing variable as exported - self.env.insert(arg.to_string(), value.clone()); - } - } - } - let result = self.apply_redirections(result, redirects).await?; self.apply_after_tool(name, result) }) @@ -8036,13 +7820,32 @@ impl Interpreter { match name { "exec" => Some(self.execute_exec_builtin(args, redirects).await), - "local" => Some(self.execute_local_builtin(args, redirects).await), + "local" => Some( + self.execute_declaration_builtin(name, declare::DeclKind::Local, args, redirects) + .await, + ), + "export" => Some( + self.execute_declaration_builtin(name, declare::DeclKind::Export, args, redirects) + .await, + ), + "readonly" => Some( + self.execute_declaration_builtin( + name, + declare::DeclKind::Readonly, + args, + redirects, + ) + .await, + ), "bash" | "sh" => Some(self.execute_shell(name, args, stdin, redirects).await), "source" | "." => Some(self.execute_source(args, redirects).await), "eval" => Some(self.execute_eval(args, stdin, redirects).await), "command" => Some(self.execute_command_builtin(args, stdin, redirects).await), "builtin" => Some(self.execute_builtin_builtin(args, stdin, redirects).await), - "declare" | "typeset" => Some(self.execute_declare_builtin(args, redirects).await), + "declare" | "typeset" => Some( + self.execute_declaration_builtin(name, declare::DeclKind::Declare, args, redirects) + .await, + ), "let" => Some(self.execute_let_builtin(args, redirects).await), "unset" => Some(self.execute_unset_builtin(args, redirects).await), "getopts" => Some(self.execute_getopts(args, redirects).await), @@ -8436,7 +8239,7 @@ impl Interpreter { // Clear nounset_error to prevent parent expansion errors from leaking. // Reset attributes/namerefs/flags too — the child gets a fresh option // surface like real bash. - self.scoped.variables = Arc::new(self.env.clone()); + self.scoped.variables = Arc::new((*self.env).clone()); self.scoped.arrays = Arc::new(HashMap::new()); self.arrays_mut() .insert("BASH_VERSINFO".to_string(), compat_bash_versinfo_array()); @@ -8451,13 +8254,14 @@ impl Interpreter { self.last_exit_code = 0; self.nounset_error = None; if let Ok(mut e) = self.arith_error.lock() { - *e = ArithError::default(); + *e = None; } // Push call frame: $0 = script name, $1..N = args self.call_stack = vec![CallFrame { name: name.to_string(), - locals: HashMap::new(), + saved_vars: HashMap::new(), + is_function: false, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: args.to_vec(), @@ -8609,7 +8413,8 @@ impl Interpreter { if self.call_stack.is_empty() { self.call_stack.push(CallFrame { name: filename.clone(), - locals: HashMap::new(), + saved_vars: HashMap::new(), + is_function: false, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: source_args, @@ -8869,6 +8674,16 @@ impl Interpreter { fn push_alias_reparse_word_part(out: &mut String, part: &WordPart) { match part { + WordPart::CompoundAssignment { .. } => { + let word = Word { + parts: vec![part.clone()], + quoted: false, + has_unquoted_glob: false, + part_quoted: Vec::new(), + raw: None, + }; + out.push_str(&word.to_string()); + } WordPart::Literal(s) => Self::push_alias_reparse_literal(out, s, false), WordPart::Variable(name) => out.push_str(&format!("${}", name)), WordPart::CommandSubstitution(cmd) => out.push_str(&format!("$({:?})", cmd)), @@ -9025,7 +8840,8 @@ impl Interpreter { // Push call frame with positional parameters self.call_stack.push(CallFrame { name: name.to_string(), - locals: HashMap::new(), + saved_vars: HashMap::new(), + is_function: true, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: args, @@ -9139,236 +8955,30 @@ impl Interpreter { self.apply_redirections(result, redirects).await } - /// Execute the `local` builtin — set variables in function call frame. - async fn execute_local_builtin( - &mut self, - args: &[String], - redirects: &[Redirect], - ) -> Result { - let mut flags = DeclareFlags::default(); - let mut var_args: Vec<&String> = Vec::new(); - for arg in args { - if arg.starts_with('-') && !arg.contains('=') { - flags.parse_flag_chars(arg); - } else { - var_args.push(arg); - } - } - - let merged = merge_compound_assignments(&var_args); - - if !self.call_stack.is_empty() { - // In a function - set in locals - for arg in &merged { - if let Some(eq_pos) = arg.find('=') { - let var_name = &arg[..eq_pos]; - let value = &arg[eq_pos + 1..]; - if !is_valid_var_name(var_name) { - let result = ExecResult::err( - format!("local: `{}': not a valid identifier\n", arg), - 1, - ); - return self.apply_redirections(result, redirects).await; - } - // THREAT[TM-INJ-014]: Block internal variable prefix injection via local - if is_internal_variable(var_name) { - continue; - } - // Handle compound array assignment: local arr=(1 2 3) or local -a/-A arr=(...) - let is_compound = value.starts_with('(') && value.ends_with(')'); - if is_compound { - self.shadow_local_array_bindings(var_name, false, false); - let inner = &value[1..value.len() - 1]; - let inserted = if flags.assoc { - self.remember_local_assoc_array_binding(var_name); - let mut arr = HashMap::new(); - let mut rest = inner.trim(); - while let Some(bracket_start) = rest.find('[') { - if let Some(bracket_end) = rest[bracket_start..].find(']') { - let key = &rest[bracket_start + 1..bracket_start + bracket_end]; - let after = &rest[bracket_start + bracket_end + 1..]; - if let Some(eq_rest) = after.strip_prefix('=') { - let eq_rest = eq_rest.trim_start(); - let (val, remainder) = - if let Some(stripped) = eq_rest.strip_prefix('"') { - if let Some(end_q) = stripped.find('"') { - ( - &stripped[..end_q], - stripped[end_q + 1..].trim_start(), - ) - } else { - (stripped.trim_end_matches('"'), "") - } - } else { - match eq_rest.find(char::is_whitespace) { - Some(sp) => { - (&eq_rest[..sp], eq_rest[sp..].trim_start()) - } - None => (eq_rest, ""), - } - }; - arr.insert(key.to_string(), val.to_string()); - rest = remainder; - } else { - break; - } - } else { - break; - } - } - self.insert_assoc_array_checked(var_name.to_string(), arr) - } else { - self.remember_local_array_binding(var_name); - let mut arr = HashMap::new(); - for (idx, val) in inner.split_whitespace().enumerate() { - arr.insert(idx, val.trim_matches('"').to_string()); - } - self.insert_array_checked(var_name.to_string(), arr) - }; - // Mark local only when the backing array fit the memory budget. - if inserted { - self.insert_local_checked(var_name.to_string(), String::new()); - } - } else if flags.nameref { - self.shadow_local_array_bindings(var_name, false, false); - self.insert_local_checked(var_name.to_string(), String::new()); - } else if flags.integer { - self.shadow_local_array_bindings(var_name, false, false); - let int_val = self.evaluate_arithmetic_with_assign(value); - self.insert_local_checked(var_name.to_string(), int_val.to_string()); - self.add_var_attr(var_name, VarAttrs::INTEGER); - } else { - self.shadow_local_array_bindings(var_name, false, false); - self.insert_local_checked(var_name.to_string(), value.to_string()); - } - } else if !is_internal_variable(arg) { - if flags.assoc { - self.shadow_local_array_bindings(arg, false, false); - if self.insert_assoc_array_checked(arg.to_string(), HashMap::new()) { - self.insert_local_checked(arg.to_string(), String::new()); - } - } else if flags.array { - self.shadow_local_array_bindings(arg, false, false); - if self.insert_array_checked(arg.to_string(), HashMap::new()) { - self.insert_local_checked(arg.to_string(), String::new()); - } - } else { - self.shadow_local_array_bindings(arg, false, false); - self.insert_local_checked(arg.to_string(), String::new()); - } - if flags.integer { - self.add_var_attr(arg, VarAttrs::INTEGER); - } - } - } - // Set nameref markers (after frame borrow is released) - if flags.nameref { - for arg in &merged { - if let Some(eq_pos) = arg.find('=') { - let var_name = &arg[..eq_pos]; - let value = &arg[eq_pos + 1..]; - if !is_internal_variable(var_name) { - self.set_nameref(var_name, value.to_string()); - } - } - } - } - } else { - // Not in a function - set in global variables (bash behavior) - for arg in &merged { - if let Some(eq_pos) = arg.find('=') { - let var_name = &arg[..eq_pos]; - let value = &arg[eq_pos + 1..]; - // THREAT[TM-INJ-014]: Block internal variable prefix injection via local - if is_internal_variable(var_name) { - continue; - } - let is_compound = value.starts_with('(') && value.ends_with(')'); - if is_compound { - let inner = &value[1..value.len() - 1]; - if flags.assoc { - let mut arr = HashMap::new(); - let mut rest = inner.trim(); - while let Some(bracket_start) = rest.find('[') { - if let Some(bracket_end) = rest[bracket_start..].find(']') { - let key = &rest[bracket_start + 1..bracket_start + bracket_end]; - let after = &rest[bracket_start + bracket_end + 1..]; - if let Some(eq_rest) = after.strip_prefix('=') { - let eq_rest = eq_rest.trim_start(); - let (val, remainder) = - if let Some(stripped) = eq_rest.strip_prefix('"') { - if let Some(end_q) = stripped.find('"') { - ( - &stripped[..end_q], - stripped[end_q + 1..].trim_start(), - ) - } else { - (stripped.trim_end_matches('"'), "") - } - } else { - match eq_rest.find(char::is_whitespace) { - Some(sp) => { - (&eq_rest[..sp], eq_rest[sp..].trim_start()) - } - None => (eq_rest, ""), - } - }; - arr.insert(key.to_string(), val.to_string()); - rest = remainder; - } else { - break; - } - } else { - break; - } - } - let _ = self.insert_assoc_array_checked(var_name.to_string(), arr); - } else { - let mut arr = HashMap::new(); - for (idx, val) in inner.split_whitespace().enumerate() { - arr.insert(idx, val.trim_matches('"').to_string()); - } - let _ = self.insert_array_checked(var_name.to_string(), arr); - } - } else if flags.nameref { - self.set_nameref(var_name, value.to_string()); - } else { - self.insert_variable_checked(var_name.to_string(), value.to_string()); - } - } else if !is_internal_variable(arg) { - if flags.assoc { - self.assoc_arrays_mut().entry(arg.to_string()).or_default(); - } else if flags.array { - self.arrays_mut().entry(arg.to_string()).or_default(); - } else { - self.insert_variable_checked(arg.to_string(), String::new()); - } - } - } - } - Ok(ExecResult::ok(String::new())) - } - /// Execute the `let` builtin — evaluate arithmetic expressions. async fn execute_let_builtin( &mut self, args: &[String], redirects: &[Redirect], ) -> Result { + if args.is_empty() { + let result = ExecResult::err("bash: let: expression expected\n", 1); + return self.apply_redirections(result, redirects).await; + } let mut last_val = 0i64; for arg in args { - last_val = self.evaluate_arithmetic_with_assign(arg); - if let Some(msg) = self.take_arith_error_for("let: ", Some(arg.trim())) { - let result = ExecResult::err(msg, 1); - return self.apply_redirections(result, redirects).await; + match self.try_evaluate_arithmetic_with_assign(arg) { + Ok(v) => last_val = v, + // An error stops `let` at that expression with status 1. + Err(msg) => { + let result = ExecResult::err(self.arith_diag("let: ", &msg), 1); + return self.apply_redirections(result, redirects).await; + } } } let exit_code = if last_val == 0 { 1 } else { 0 }; let result = ExecResult { - stdout: crate::StreamData::new(), - stderr: crate::StreamData::new(), exit_code, - control_flow: ControlFlow::None, ..Default::default() }; self.apply_redirections(result, redirects).await @@ -9453,28 +9063,7 @@ impl Interpreter { if resolved == "LINENO" { self.lineno_unset = true; } - self.vars_mut().remove(&resolved); - self.env.remove(&resolved); - // THREAT[TM-DOS-114]: `unset arr` must return the array's entry - // slots and bytes to the budget. Without this a set/unset cycle - // drifts until a healthy script is refused. - let released = self - .arrays_mut() - .remove(&resolved) - .map_or((0, 0), |arr| (arr.len(), Self::indexed_array_bytes(&arr))); - let released_assoc = self - .assoc_arrays_mut() - .remove(&resolved) - .map_or((0, 0), |arr| (arr.len(), Self::assoc_array_bytes(&arr))); - self.memory_budget - .record_array_remove(released.0 + released_assoc.0); - self.memory_budget - .release_array_bytes(released.1 + released_assoc.1); - self.clear_var_attrs(&resolved); - self.remove_nameref(&resolved); - for frame in self.call_stack.iter_mut().rev() { - frame.locals.remove(&resolved); - } + self.unset_variable(&resolved); } } let result = ExecResult { @@ -9485,18 +9074,65 @@ impl Interpreter { self.apply_redirections(result, redirects).await } - /// `getopts` unsets OPTARG for options without one; a `local OPTARG` - /// stays local (emptied) so the caller's value is untouched. - fn unset_optarg(&mut self) { - if self + /// Remove every live binding of `name`: scalar value, arrays, + /// attributes, nameref and env entry. + fn clear_live_binding(&mut self, name: &str) { + self.remove_scalar_value(name); + self.env_mut().remove(name); + // THREAT[TM-DOS-114]: `unset arr` must return the array's entry + // slots and bytes to the budget. Without this a set/unset cycle + // drifts until a healthy script is refused. + let released = self + .arrays_mut() + .remove(name) + .map_or((0, 0), |arr| (arr.len(), Self::indexed_array_bytes(&arr))); + let released_assoc = self + .assoc_arrays_mut() + .remove(name) + .map_or((0, 0), |arr| (arr.len(), Self::assoc_array_bytes(&arr))); + self.memory_budget + .record_array_remove(released.0 + released_assoc.0); + self.memory_budget + .release_array_bytes(released.1 + released_assoc.1); + self.clear_var_attrs(name); + self.remove_nameref(name); + } + + /// `unset name` (bash semantics). A local of the current function stays + /// local but unset. A local of a calling function is popped instead, so + /// the binding it shadowed becomes visible again. + fn unset_variable(&mut self, name: &str) { + let owner = self .call_stack .iter() - .any(|frame| frame.locals.contains_key("OPTARG")) + .rposition(|f| f.saved_vars.contains_key(name)); + let current = self.local_frame_index(); + if let Some(idx) = owner + && Some(idx) != current { - self.set_variable("OPTARG".to_string(), String::new()); - } else { - self.vars_mut().remove("OPTARG"); + let frame = &mut self.call_stack[idx]; + let saved = frame.saved_vars.remove(name).unwrap_or_default(); + let indexed = frame.local_arrays.remove(name); + let assoc = frame.local_assoc_arrays.remove(name); + self.restore_saved_var(name, saved); + match indexed { + Some(prev) => self.restore_array_binding(name, prev), + None => self.restore_array_binding(name, None), + } + match assoc { + Some(prev) => self.restore_assoc_array_binding(name, prev), + None => self.restore_assoc_array_binding(name, None), + } + return; } + self.clear_live_binding(name); + } + + /// `getopts` unsets OPTARG for options without one. Locals use shallow + /// binding, so dropping the live value leaves a `local OPTARG`'s caller + /// value saved in the frame, untouched. + fn unset_optarg(&mut self) { + self.remove_scalar_value("OPTARG"); } /// Usage: `getopts optstring name [args...]` @@ -9884,347 +9520,6 @@ impl Interpreter { } } - /// Execute `declare`/`typeset` builtin — declare variables with attributes. - /// - /// - `declare var=value` — set variable - /// - `declare -i var=value` — integer attribute (stored as-is) - /// - `declare -r var=value` — readonly - /// - `declare -x var=value` — export - /// - `declare -a arr` — indexed array - /// - `declare -p [var]` — print variable declarations - async fn execute_declare_builtin( - &mut self, - args: &[String], - redirects: &[Redirect], - ) -> Result { - if args.is_empty() { - // declare with no args: print all variables, filtering hidden markers (TM-INF-017) - let mut output = String::new(); - let mut entries: Vec<_> = self.scoped.variables.iter().collect(); - entries.sort_by_key(|(k, _)| (*k).clone()); - for (name, value) in entries { - if is_hidden_variable(name) { - continue; - } - output.push_str(&format!("declare -- {}=\"{}\"\n", name, value)); - } - let mut result = ExecResult::ok(output); - result = self.apply_redirections(result, redirects).await?; - return Ok(result); - } - - let mut print_mode = false; - let mut is_readonly = false; - let mut is_export = false; - let mut is_function = false; - let mut function_names_only = false; - let mut flags = DeclareFlags::default(); - let mut remove_nameref = false; - let mut is_lowercase = false; - let mut is_uppercase = false; - let mut names: Vec<&str> = Vec::new(); - - for arg in args { - if arg.starts_with('-') && !arg.contains('=') { - flags.parse_flag_chars(arg); - for c in arg[1..].chars() { - match c { - 'p' => print_mode = true, - 'r' => is_readonly = true, - 'x' => is_export = true, - 'f' => is_function = true, - 'F' => { - is_function = true; - function_names_only = true; - } - 'l' => is_lowercase = true, - 'u' => is_uppercase = true, - _ => {} // n, a, A, i handled by flags - } - } - } else if arg.starts_with('+') && !arg.contains('=') { - // +n removes nameref attribute - for c in arg[1..].chars() { - if c == 'n' { - remove_nameref = true; - } - } - } else { - names.push(arg); - } - } - - // declare -f: function display mode - // `-F` lists names (`declare -f NAME` for all), `-f` prints bodies - // the way bash's print_cmd lays them out; a missing name prints - // nothing and makes the status 1. - if is_function { - let mut output = String::new(); - let mut exit_code = 0; - if names.is_empty() { - let mut func_names: Vec<_> = - self.scoped.functions.keys().cloned().collect::>(); - func_names.sort(); - for fname in &func_names { - if function_names_only { - output.push_str(&format!("declare -f {fname}\n")); - } else if let Some(f) = self.scoped.functions.get(fname) { - output.push_str(&crate::parser::function_string(fname, &f.body)); - output.push('\n'); - } - } - } else { - for name in &names { - match self.scoped.functions.get(*name) { - Some(_) if function_names_only => { - output.push_str(name); - output.push('\n'); - } - Some(f) => { - output.push_str(&crate::parser::function_string(name, &f.body)); - output.push('\n'); - } - None => exit_code = 1, - } - } - } - let mut result = ExecResult::with_code(output, exit_code); - result = self.apply_redirections(result, redirects).await?; - return Ok(result); - } - - if print_mode { - let mut output = String::new(); - if names.is_empty() { - // Print all variables, filtering internal markers (TM-INF-017) - let mut entries: Vec<_> = self.scoped.variables.iter().collect(); - entries.sort_by_key(|(k, _)| (*k).clone()); - for (name, value) in entries { - if is_internal_variable(name) { - continue; - } - output.push_str(&format!("declare -- {}=\"{}\"\n", name, value)); - } - } else { - for name in &names { - // Strip =value if present - let var_name = name.split('=').next().unwrap_or(name); - if let Some(value) = self.scoped.variables.get(var_name) { - let mut attrs = String::from("--"); - if self.is_var_readonly(var_name) { - attrs = String::from("-r"); - } - output.push_str(&format!("declare {} {}=\"{}\"\n", attrs, var_name, value)); - } else if let Some(arr) = self.scoped.assoc_arrays.get(var_name) { - let mut items: Vec<_> = arr.iter().collect(); - items.sort_by_key(|(k, _)| (*k).clone()); - let inner: String = items - .iter() - .map(|(k, v)| format!("[{}]=\"{}\"", k, v)) - .collect::>() - .join(" "); - output.push_str(&format!("declare -A {}=({})\n", var_name, inner)); - } else if let Some(arr) = self.scoped.arrays.get(var_name) { - let mut items: Vec<_> = arr.iter().collect(); - items.sort_by_key(|(k, _)| *k); - let inner: String = items - .iter() - .map(|(k, v)| format!("[{}]=\"{}\"", k, v)) - .collect::>() - .join(" "); - output.push_str(&format!("declare -a {}=({})\n", var_name, inner)); - } else { - return Ok(ExecResult::err( - format!("bash: declare: {}: not found\n", var_name), - 1, - )); - } - } - } - let mut result = ExecResult::ok(output); - result = self.apply_redirections(result, redirects).await?; - return Ok(result); - } - - // Reconstruct compound assignments: declare -A m=([a]="1" [b]="2") - let merged_names = merge_compound_assignments(&names); - - let mut declare_stderr = String::new(); - let mut declare_exit_code: i32 = 0; - - // Set variables - for name in &merged_names { - if let Some(eq_pos) = name.find('=') { - let var_name = &name[..eq_pos]; - let value = &name[eq_pos + 1..]; - - // THREAT[TM-INJ-012]: Block internal variable prefix injection via declare - if is_internal_variable(var_name) { - continue; - } - - // THREAT[TM-INJ-020]: Refuse to overwrite readonly variables and - // surface the error so callers cannot mistake a silent skip for success. - if self.is_var_readonly(var_name) { - declare_stderr - .push_str(&format!("bash: declare: {var_name}: readonly variable\n")); - declare_exit_code = 1; - continue; - } - - // Handle compound array assignment: declare -A m=([k]="v" ...) - if (flags.assoc || flags.array) && value.starts_with('(') && value.ends_with(')') { - let inner = &value[1..value.len() - 1]; - if flags.assoc { - let arr = self - .assoc_arrays_mut() - .entry(var_name.to_string()) - .or_default(); - arr.clear(); - // Parse [key]="value" pairs - let mut rest = inner.trim(); - while let Some(bracket_start) = rest.find('[') { - if let Some(bracket_end) = rest[bracket_start..].find(']') { - let key = &rest[bracket_start + 1..bracket_start + bracket_end]; - let after = &rest[bracket_start + bracket_end + 1..]; - if let Some(eq_rest) = after.strip_prefix('=') { - let eq_rest = eq_rest.trim_start(); - let (val, remainder) = if let Some(stripped) = - eq_rest.strip_prefix('"') - { - // Quoted value - if let Some(end_q) = stripped.find('"') { - (&stripped[..end_q], stripped[end_q + 1..].trim_start()) - } else { - (stripped.trim_end_matches('"'), "") - } - } else { - // Unquoted value — up to next space or end - match eq_rest.find(char::is_whitespace) { - Some(sp) => { - (&eq_rest[..sp], eq_rest[sp..].trim_start()) - } - None => (eq_rest, ""), - } - }; - arr.insert(key.to_string(), val.to_string()); - rest = remainder; - } else { - break; - } - } else { - break; - } - } - } else { - // Indexed array: declare -a arr=(a b c) - let arr = self.arrays_mut().entry(var_name.to_string()).or_default(); - arr.clear(); - for (idx, val) in inner.split_whitespace().enumerate() { - arr.insert(idx, val.trim_matches('"').to_string()); - } - } - } else if flags.nameref { - // declare -n ref=target: create nameref - self.set_nameref(var_name, value.to_string()); - } else if flags.integer { - // Evaluate as arithmetic expression - let int_val = self.evaluate_arithmetic_with_assign(value); - self.insert_variable_checked(var_name.to_string(), int_val.to_string()); - // Set persistent integer attribute marker - self.add_var_attr(var_name, VarAttrs::INTEGER); - } else { - // Apply case conversion attributes - let final_value = if is_lowercase { - value.to_lowercase() - } else if is_uppercase { - value.to_uppercase() - } else { - value.to_string() - }; - self.insert_variable_checked(var_name.to_string(), final_value); - } - - // Set case conversion attribute markers - if is_lowercase { - self.add_var_attr(var_name, VarAttrs::LOWER); - self.remove_var_attr(var_name, VarAttrs::UPPER); - } - if is_uppercase { - self.add_var_attr(var_name, VarAttrs::UPPER); - self.remove_var_attr(var_name, VarAttrs::LOWER); - } - if is_readonly { - self.add_var_attr(var_name, VarAttrs::READONLY); - } - if is_export { - self.env.insert( - var_name.to_string(), - self.scoped - .variables - .get(var_name) - .cloned() - .unwrap_or_default(), - ); - } - } else { - // Declare without value - if remove_nameref { - // typeset +n ref: remove nameref attribute - self.remove_nameref(name); - } else if flags.nameref { - // typeset -n ref (without =value): use existing variable value as target - if let Some(existing) = self.scoped.variables.get(name.as_str()).cloned() - && !existing.is_empty() - { - self.set_nameref(name, existing); - } - } else if flags.assoc { - // Initialize empty associative array - self.assoc_arrays_mut().entry(name.to_string()).or_default(); - } else if flags.array { - // Initialize empty indexed array - self.arrays_mut().entry(name.to_string()).or_default(); - } else if !self.scoped.variables.contains_key(name.as_str()) { - self.insert_variable_checked(name.to_string(), String::new()); - } - // Set case conversion attribute markers - if is_lowercase { - self.add_var_attr(name, VarAttrs::LOWER); - self.remove_var_attr(name, VarAttrs::UPPER); - } - if is_uppercase { - self.add_var_attr(name, VarAttrs::UPPER); - self.remove_var_attr(name, VarAttrs::LOWER); - } - if is_readonly { - self.add_var_attr(name, VarAttrs::READONLY); - } - if flags.integer { - self.add_var_attr(name, VarAttrs::INTEGER); - } - if is_export { - self.env.insert( - name.to_string(), - self.scoped - .variables - .get(name.as_str()) - .cloned() - .unwrap_or_default(), - ); - } - } - } - - let mut result = ExecResult { - stderr: declare_stderr.into(), - exit_code: declare_exit_code, - ..Default::default() - }; - result = self.apply_redirections(result, redirects).await?; - Ok(result) - } - /// Execute an [`ExecutionPlan`] returned by a builtin's `execution_plan()` method. /// /// This is the interpreter hook that fulfills sub-command execution requests @@ -10432,17 +9727,17 @@ impl Interpreter { // A child started with an empty environment sees no shell // variables at all; internal option markers stay. self.vars_mut().retain(|name, _| is_internal_variable(name)); - self.env.clear(); + self.env_mut().clear(); } for name in unset { - self.env.remove(&name); + self.env_mut().remove(&name); self.vars_mut().remove(&name); } for (name, value) in set { if is_internal_variable(&name) { continue; } - self.env.insert(name.clone(), value.clone()); + self.env_mut().insert(name.clone(), value.clone()); self.vars_mut().insert(name, value); } let mut result = Ok(None); @@ -10506,7 +9801,7 @@ impl Interpreter { .saturating_sub(baseline_bash_source_len); if leaked_call_frames > 0 { - self.call_stack.truncate(baseline_call_stack_len); + self.unwind_call_stack(baseline_call_stack_len); } if leaked_bash_source_entries > 0 { self.bash_source_stack.truncate(baseline_bash_source_len); @@ -10541,8 +9836,9 @@ impl Interpreter { // ~10 SHOPT_* entries — cheaper than threading a structured "shopt // changed" channel through every builtin. self.refresh_shopt_flags(); + let effects = std::mem::take(&mut result.side_effects); let mut shift_failed = false; - for effect in &result.side_effects { + for effect in &effects { match effect { builtins::BuiltinSideEffect::SetArray { name, elements } => { let mut arr = HashMap::new(); @@ -10581,7 +9877,8 @@ impl Interpreter { // synthetic frame keeps the default shell name. self.call_stack.push(CallFrame { name: Self::DEFAULT_ARG0.to_string(), - locals: HashMap::new(), + saved_vars: HashMap::new(), + is_function: false, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: new_positional.clone(), @@ -10598,10 +9895,21 @@ impl Interpreter { self.last_exit_code = *code; } builtins::BuiltinSideEffect::SetVariable { name, value } => { + // A builtin (read, getopts, ...) assigning through a + // circular nameref fails that command, as in bash, + // instead of abandoning the line like an assignment. + if self.resolve_nameref_strict(name).is_err() { + result + .stderr + .push_str(&format!("bash: warning: {name}: circular name reference\n")); + result.exit_code = 1; + continue; + } self.set_variable(name.clone(), value.clone()); } } } + result.side_effects = effects; if shift_failed { result.exit_code = 1; } @@ -10638,9 +9946,22 @@ impl Interpreter { if is_input { let mut stdout = String::new(); + self.bash_subshell += 1; + let mut failed = None; for cmd in commands { - let cmd_result = self.execute_command(cmd).await?; - stdout.push_str(&cmd_result.stdout.command_substitution_text()); + match self.execute_command(cmd).await { + Ok(cmd_result) => { + stdout.push_str(&cmd_result.stdout.command_substitution_text()) + } + Err(e) => { + failed = Some(e); + break; + } + } + } + self.bash_subshell -= 1; + if let Some(e) = failed { + return Err(e); } if self.fs.write_file(path, stdout.as_bytes()).await.is_err() { Ok(stdout) @@ -10669,6 +9990,7 @@ impl Interpreter { fn snapshot_subshell_state(&self) -> SubshellSnapshot { SubshellSnapshot { scoped: self.scoped.clone(), + env: self.env.clone(), flags: self.flags, cwd: self.cwd.clone(), memory_budget: self.memory_budget.clone(), @@ -10683,6 +10005,7 @@ impl Interpreter { fn restore_subshell_state(&mut self, snap: SubshellSnapshot) { self.scoped = snap.scoped; + self.env = snap.env; self.flags = snap.flags; self.cwd = snap.cwd; self.memory_budget = snap.memory_budget; @@ -10899,7 +10222,10 @@ impl Interpreter { break; } self.last_exit_code = cmd_result.exit_code; - if matches!(cmd_result.control_flow, ControlFlow::Exit(_)) { + if matches!( + cmd_result.control_flow, + ControlFlow::Exit(_) | ControlFlow::Abort + ) { break; } if inherit_errexit @@ -10999,16 +10325,30 @@ impl Interpreter { return; } // Resolve nameref: if `name` is a nameref, assign to the target - // instead. The common case (no nameref) reuses `name` without - // allocating; only nameref hops allocate a fresh owned target. - let resolved_string: String = { - let resolved = self.resolve_nameref(&name); - if std::ptr::eq(resolved.as_ptr(), name.as_ptr()) { - name - } else { - resolved.to_string() + // instead. The common case (no nameref) moves `name` through. + let resolved_string: String = if self.scoped.namerefs.is_empty() { + name + } else { + if let Some(target) = self.scoped.namerefs.get(&name) + && target.is_empty() + { + // `declare -n r; r=x`: the first value assigned is the target. + self.set_nameref(&name, value); + return; + } + match self.resolve_nameref_strict(&name) { + Ok(target) => target, + Err(()) => { + self.record_arith_error(format!("warning: {name}: circular name reference")); + return; + } } }; + // A nameref to an element (`declare -n r=a[1]`) writes the element. + if resolved_string.ends_with(']') && resolved_string.contains('[') { + self.set_parameter_expansion_target(&resolved_string, value); + return; + } let resolved: &str = resolved_string.as_str(); // RANDOM=N reseeds the PRNG (matches bash behavior) // SRANDOM ignores assignment (bash 5.1). @@ -11036,6 +10376,15 @@ impl Interpreter { if attrs.contains(VarAttrs::READONLY) { return; } + if attrs.contains(VarAttrs::NOVALUE) { + self.remove_var_attr(resolved, VarAttrs::NOVALUE); + } + // Prefix assignments (`x=1 cmd`) store the text as given. + let attrs = if self.assign_raw { + attrs - (VarAttrs::INTEGER | VarAttrs::LOWER | VarAttrs::UPPER) + } else { + attrs + }; // Apply integer attribute (declare -i): evaluate as arithmetic let value = if attrs.contains(VarAttrs::INTEGER) { self.evaluate_arithmetic_with_assign(&value).to_string() @@ -11050,57 +10399,21 @@ impl Interpreter { } else { value }; - // Check allexport (set -a): auto-export to env — now a bit test. - let allexport = self.flags.contains(BashFlags::ALLEXPORT); - - // Walk the call stack top-down looking for an existing local binding. - // The previous implementation cloned `resolved_string` for the - // `entry()` API in every frame even when the key was absent — for a - // tight `for i in {1..N}; do x=$((x+1)); done` inside a function this - // is one clone per iteration. Using `get_mut` skips the clone unless - // we actually have a local to update. - for frame_idx in (0..self.call_stack.len()).rev() { - if let Some(old_val_len) = self.call_stack[frame_idx] - .locals - .get(resolved) - .map(String::len) - { - if let Err(error) = self.memory_budget.check_variable_insert( - resolved.len(), - value.len(), - false, - resolved.len(), - old_val_len, - &self.memory_limits, - ) { - self.memory_limit_error.get_or_insert(error); - return; - } - self.memory_budget.record_variable_insert( - resolved.len(), - value.len(), - false, - resolved.len(), - old_val_len, - ); - if allexport { - let env_value = value.clone(); - self.call_stack[frame_idx] - .locals - .insert(resolved_string.clone(), value); - self.insert_env_checked(resolved_string, env_value); - return; - } - self.call_stack[frame_idx] - .locals - .insert(resolved_string, value); - return; - } + // Assigning an array name assigns element 0 (key "0" if associative). + if !self.scoped.arrays.is_empty() && self.scoped.arrays.contains_key(resolved) { + self.set_indexed_element_checked(resolved, 0, value); + return; } - // No local frame matched — insert at global scope. Only allexport - // needs the extra clone for the env mirror; the common path moves - // `value` straight into `variables`. - if allexport { + if !self.scoped.assoc_arrays.is_empty() && self.scoped.assoc_arrays.contains_key(resolved) { + self.set_assoc_element_checked(resolved_string, "0".to_string(), value); + return; + } + // Exported names (EXPORT attribute, `set -a`, or a name inherited + // from the host environment) mirror their value into `env`. + let exported = attrs.contains(VarAttrs::EXPORT) + || self.flags.contains(BashFlags::ALLEXPORT) + || self.env.contains_key(resolved); + if exported { let env_value = value.clone(); if self.insert_variable_checked(resolved_string.clone(), value) { self.insert_env_checked(resolved_string, env_value); @@ -11164,6 +10477,8 @@ impl Interpreter { return; } + // A scalar written through a subscript becomes element 0. + self.promote_scalar_to_indexed(&resolved_name); let index = self.resolve_indexed_array_subscript(&resolved_name, key); let old_len = self .scoped @@ -11235,42 +10550,99 @@ impl Interpreter { true } - /// Insert a variable into the current local frame with memory budget checking. - /// Records a fatal execution error if the budget would be exceeded. - fn insert_local_checked(&mut self, key: String, value: String) { - let Some(frame) = self.call_stack.last() else { - return; - }; - let is_new = !frame.locals.contains_key(&key); - let old_value_len = frame.locals.get(&key).map_or(0, String::len); - let (old_key_len, old_value_len) = if is_new { - (0, 0) - } else { - (key.len(), old_value_len) + /// Index of the frame `local` declares into: the innermost frame, when it + /// is a function frame. + fn local_frame_index(&self) -> Option { + let idx = self.call_stack.len().checked_sub(1)?; + self.call_stack[idx].is_function.then_some(idx) + } + + /// Is `name` a local of the innermost function frame? + fn is_local_in_current_frame(&self, name: &str) -> bool { + self.local_frame_index() + .is_some_and(|idx| self.call_stack[idx].saved_vars.contains_key(name)) + } + + /// Make `name` local to the current function (shallow binding): save the + /// caller-visible binding in the frame and leave `name` unset, keeping + /// only an inherited export attribute. A second `local` of the same name + /// in the same frame keeps the current local. Returns false outside a + /// function. + fn make_local(&mut self, name: &str) -> bool { + let Some(idx) = self.local_frame_index() else { + return false; }; + if self.call_stack[idx].saved_vars.contains_key(name) { + return true; + } + // The saved value stays charged to the budget while the frame holds it. + let value = Arc::make_mut(&mut self.scoped.variables).remove(name); + let attrs = self.var_attrs_mut().remove(name); + let nameref = self.namerefs_mut().remove(name); + let env = self.env.get(name).cloned(); + let exported = attrs.is_some_and(|a| a.contains(VarAttrs::EXPORT)) || env.is_some(); + self.call_stack[idx].saved_vars.insert( + name.to_string(), + SavedVar { + value, + attrs, + nameref, + env, + }, + ); + if exported { + self.add_var_attr(name, VarAttrs::EXPORT); + } + self.shadow_local_array_bindings(name, false, false); + true + } - if let Err(error) = self.memory_budget.check_variable_insert( - key.len(), - value.len(), - is_new, - old_key_len, - old_value_len, - &self.memory_limits, - ) { - self.memory_limit_error.get_or_insert(error); - return; + /// Remove the live scalar value of `name`, releasing its budget charge. + fn remove_scalar_value(&mut self, name: &str) -> Option { + let old = Arc::make_mut(&mut self.scoped.variables).remove(name)?; + if !Self::is_internal_variable(name) { + self.memory_budget + .record_variable_remove(name.len(), old.len()); } + Some(old) + } - self.memory_budget.record_variable_insert( - key.len(), - value.len(), - is_new, - old_key_len, - old_value_len, - ); + /// Put a binding saved by `local` back in place of the live one. + fn restore_saved_var(&mut self, name: &str, saved: SavedVar) { + self.remove_scalar_value(name); + if let Some(v) = saved.value { + Arc::make_mut(&mut self.scoped.variables).insert(name.to_string(), v); + } + match saved.attrs { + Some(a) => { + self.var_attrs_mut().insert(name.to_string(), a); + } + None => { + self.var_attrs_mut().remove(name); + } + } + match saved.nameref { + Some(t) => { + self.namerefs_mut().insert(name.to_string(), t); + } + None => { + self.namerefs_mut().remove(name); + } + } + match saved.env { + Some(e) => { + self.env_mut().insert(name.to_string(), e); + } + None => { + self.env_mut().remove(name); + } + } + } - if let Some(frame) = self.call_stack.last_mut() { - frame.locals.insert(key, value); + /// Pop call frames down to `len`, restoring every shadowed binding. + pub(crate) fn unwind_call_stack(&mut self, len: usize) { + while self.call_stack.len() > len { + self.pop_call_frame(); } } @@ -11305,12 +10677,15 @@ impl Interpreter { return; } - self.env.insert(key, value); + self.env_mut().insert(key, value); } /// Pop a call frame and restore any global array bindings shadowed by `local -a/-A`. fn pop_call_frame(&mut self) -> Option { - let frame = self.call_stack.pop()?; + let mut frame = self.call_stack.pop()?; + for (name, saved) in std::mem::take(&mut frame.saved_vars) { + self.restore_saved_var(&name, saved); + } for (name, previous) in &frame.local_arrays { self.restore_array_binding(name, previous.clone()); } @@ -11614,24 +10989,6 @@ impl Interpreter { } fn lookup_regular_variable(&self, name: &str) -> Option { - for frame in self.call_stack.iter().rev() { - if let Some(value) = frame.locals.get(name) { - // `local -a x=(...)` also leaves a scalar placeholder; the - // array it shadows-in is the real value. - if frame.local_arrays.contains_key(name) - && let Some(arr) = self.scoped.arrays.get(name) - { - return arr.get(&0).cloned(); - } - if frame.local_assoc_arrays.contains_key(name) - && let Some(arr) = self.scoped.assoc_arrays.get(name) - { - return arr.get("0").cloned(); - } - return Some(value.clone()); - } - } - if let Some(value) = self.scoped.variables.get(name) { return Some(value.clone()); } @@ -11707,23 +11064,8 @@ impl Interpreter { return String::new(); } "-" => { - // $- - Current option flags as a string - // Build from SHOPT_* variables - // In bash's order; `h` (hashall) and `B` (braceexpand) are - // on by default and cannot be turned off here. - let mut flags = String::new(); - for opt in ['a', 'b', 'e', 'f', 'h', 'm', 'n', 'u', 'v', 'x', 'B', 'C'] { - let on = matches!(opt, 'h' | 'B') - || self - .scoped - .variables - .get(&format!("SHOPT_{opt}")) - .is_some_and(|v| v == "1"); - if on { - flags.push(opt); - } - } - return flags; + // $- - Current option flags, from the SHOPT_* variables. + return builtins::dollar_dash(&self.scoped.variables); } "RANDOM" => { // $RANDOM - LCG matching bash behavior, seeded per-instance. @@ -11741,18 +11083,10 @@ impl Interpreter { } return u32::from_le_bytes(b).to_string(); } - // $LINENO - current line number from command span. Assignments - // do not stick, but a function's `local LINENO` holds its value - // and `unset LINENO` makes it ordinary. - "LINENO" if !self.lineno_unset => { - if let Some(v) = self - .call_stack - .iter() - .rev() - .find_map(|frame| frame.locals.get("LINENO")) - { - return v.clone(); - } + // $LINENO - current line number from command span. A + // `local LINENO` is an ordinary variable and `unset LINENO` + // makes it ordinary (bash). + "LINENO" if !self.lineno_unset && !self.is_local_anywhere("LINENO") => { return self.current_line.to_string(); } "PWD" => { @@ -11833,6 +11167,7 @@ impl Interpreter { | "HOSTNAME" | "BASH_VERSION" | "SECONDS" + | "BASH_SUBSHELL" ) { return true; } @@ -11847,12 +11182,6 @@ impl Interpreter { .map(|f| n <= f.positional.len()) .unwrap_or(false); } - // Local variables - for frame in self.call_stack.iter().rev() { - if frame.locals.contains_key(name) { - return true; - } - } // Shell variables if self.scoped.variables.contains_key(name) { return true; @@ -11935,14 +11264,6 @@ impl Interpreter { } } } - - /// Set a local variable in the current call frame - #[allow(dead_code)] - fn set_local(&mut self, name: &str, value: &str) { - if let Some(frame) = self.call_stack.last_mut() { - frame.locals.insert(name.to_string(), value.to_string()); - } - } } /// Turn a builtin's usage error into a failed command (exit 2) instead of @@ -11960,41 +11281,6 @@ fn builtin_usage_error(msg: &str) -> ExecResult { ExecResult::err(msg, 2) } -/// Split an unquoted `[index]=value` array-literal element into its index text -/// and a word for the value. -fn split_indexed_element(word: &Word) -> Option<(String, Word)> { - let WordPart::Literal(first) = word.parts.first()? else { - return None; - }; - let first_quoted = word.part_quoted.first().copied().unwrap_or(word.quoted); - if first_quoted || !first.starts_with('[') { - return None; - } - let close = first.find("]=")?; - let index = first[1..close].to_string(); - let tail = &first[close + 2..]; - let mut parts = Vec::with_capacity(word.parts.len()); - let mut part_quoted = Vec::with_capacity(word.parts.len()); - if !tail.is_empty() { - parts.push(WordPart::Literal(tail.to_string())); - part_quoted.push(false); - } - for (i, p) in word.parts.iter().enumerate().skip(1) { - parts.push(p.clone()); - part_quoted.push(word.part_quoted.get(i).copied().unwrap_or(word.quoted)); - } - Some(( - index, - Word { - parts, - quoted: true, - has_unquoted_glob: false, - part_quoted, - raw: None, - }, - )) -} - #[cfg(test)] mod tests { use super::*; @@ -12062,7 +11348,8 @@ mod tests { interp.set_variable("p".to_string(), replacement); interp.call_stack.push(CallFrame { name: "f".to_string(), - locals: HashMap::new(), + saved_vars: HashMap::new(), + is_function: false, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: vec!["x".to_string(); 6000], @@ -12180,7 +11467,8 @@ mod tests { interp.counters.function_depth = 1; interp.call_stack.push(CallFrame { name: "caller".to_string(), - locals: HashMap::new(), + saved_vars: HashMap::new(), + is_function: false, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: Vec::new(), @@ -12192,7 +11480,8 @@ mod tests { interp.call_stack.push(CallFrame { name: "bash".to_string(), - locals: HashMap::new(), + saved_vars: HashMap::new(), + is_function: false, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: Vec::new(), @@ -12361,23 +11650,25 @@ mod tests { interp.set_memory_limits(crate::limits::MemoryLimits::new().max_total_variable_bytes(20)); interp.call_stack.push(CallFrame { name: "f".to_string(), - locals: HashMap::from([("A".to_string(), "1".to_string())]), + saved_vars: HashMap::new(), + is_function: true, local_arrays: HashMap::new(), local_assoc_arrays: HashMap::new(), positional: Vec::new(), keeps_arg0: false, }); - interp - .memory_budget - .record_variable_insert(1, 1, true, 0, 0); interp.set_variable("FILL".to_string(), "123456789012".to_string()); + assert!(interp.make_local("A")); interp.flags.insert(BashFlags::ALLEXPORT); - interp.insert_env_checked("A".to_string(), "1".to_string()); + interp.set_variable("A".to_string(), "1".to_string()); interp.set_variable("A".to_string(), "1234567890".to_string()); - let frame = interp.call_stack.last().unwrap(); - assert_eq!(frame.locals.get("A").map(String::as_str), Some("1")); + assert!(interp.call_stack[0].saved_vars.contains_key("A")); + assert_eq!( + interp.scoped.variables.get("A").map(String::as_str), + Some("1") + ); assert_eq!(interp.env.get("A").map(String::as_str), Some("1")); } @@ -13573,8 +12864,10 @@ mod tests { #[tokio::test] async fn test_arithmetic_exponent_negative_no_panic() { + // bash: "exponent less than 0" aborts the line with status 1 let result = run_script("echo $(( 2 ** -1 ))").await; - assert_eq!(result.exit_code, 0); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("exponent less than 0")); } #[tokio::test] @@ -13640,9 +12933,10 @@ mod tests { #[tokio::test] async fn test_arithmetic_base_gt_36_invalid_digit() { - // Invalid char for base — should return 0 + // bash: base > 64 is an invalid arithmetic base, the line aborts let result = run_script("echo $(( 37#! ))").await; - assert_eq!(result.exit_code, 0); + assert_eq!(result.exit_code, 1); + assert!(result.stdout.is_empty()); } #[tokio::test] @@ -13667,8 +12961,9 @@ mod tests { } let script = format!("arr[0]=0; arr[1]=1; echo $(({expr}))"); let result = run_script(&script).await; - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.trim(), "0"); + // bash: "expression recursion level exceeded", status 1, no panic + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("recursion level exceeded")); } #[tokio::test] @@ -13680,7 +12975,8 @@ mod tests { .await .expect("self-referential arithmetic expression should be bounded"); - assert_eq!(result.exit_code, 0); + // bash reports "expression recursion level exceeded" (status 1) + assert_eq!(result.exit_code, 1); } #[tokio::test] @@ -13692,7 +12988,8 @@ mod tests { .await .expect("self-referential arithmetic array index should be bounded"); - assert_eq!(result.exit_code, 0); + // bash reports "expression recursion level exceeded" (status 1) + assert_eq!(result.exit_code, 1); } #[tokio::test] @@ -13829,8 +13126,10 @@ mod tests { .await .unwrap(); // With the bug, this would silently resolve to an arbitrary variable. - // With the fix, the cycle is detected and 'a' resolves to itself. - assert_eq!(result.exit_code, 0); + // As in bash, assigning through the cycle warns and abandons the line. + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("circular name reference")); + assert_eq!(result.stdout, ""); } // Issue #437: arithmetic expansion byte/char index mismatch @@ -15000,9 +14299,11 @@ cat /tmp/test_fd_exec_public.txt"#, let mut restored = Interpreter::new(Arc::new(InMemoryFs::new())); restored.restore_shell_state(&state); - let assign = Parser::new("POLICY=unsafe; echo $POLICY").parse().unwrap(); + // The refused assignment abandons its line (bash); the next runs. + let assign = Parser::new("POLICY=unsafe\necho $POLICY").parse().unwrap(); let out = restored.execute(&assign).await.unwrap(); assert_eq!(out.exit_code, 0); + assert!(out.stderr.contains("POLICY: readonly variable")); assert_eq!(out.stdout.trim(), "safe"); } @@ -15104,7 +14405,7 @@ cat /tmp/test_fd_exec_public.txt"#, assert_eq!(restored.resolve_nameref("alias_var"), "POLICY"); assert!(!restored.scoped.variables.contains_key("_NAMEREF_alias_var")); - let ast = Parser::new("alias_var=unsafe; echo $POLICY") + let ast = Parser::new("alias_var=unsafe\necho $POLICY") .parse() .unwrap(); let result = restored.execute(&ast).await.unwrap(); diff --git a/crates/bashkit/src/interpreter/state.rs b/crates/bashkit/src/interpreter/state.rs index 639e2db8e..a9a9e5992 100644 --- a/crates/bashkit/src/interpreter/state.rs +++ b/crates/bashkit/src/interpreter/state.rs @@ -13,6 +13,11 @@ pub enum ControlFlow { Return(i32), /// Exit the shell (with exit code) Exit(i32), + /// Abandon the rest of the current command line (bash `DISCARD`): an + /// arithmetic error in `$((...))`, a failed compound declaration, ... + /// Unwinds through functions, `eval` and `source`; a subshell turns it + /// into status 1; the shell's top level resumes at the next line. + Abort, } /// Structured side-effect channel for builtins that need to communicate @@ -194,6 +199,7 @@ impl LoopAccumulator { ControlFlow::Exit(code) => { LoopAction::Exit(Box::new(self.build_exit(ControlFlow::Exit(code)))) } + ControlFlow::Abort => LoopAction::Exit(Box::new(self.build_exit(ControlFlow::Abort))), ControlFlow::None => LoopAction::None, } } diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 16e7d3328..2ede11725 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -5048,9 +5048,14 @@ fn #[tokio::test] async fn test_local_basic() { let mut bash = Bash::new(); - // Test that local command runs without error - let result = bash.exec("local X=test; echo $X").await.unwrap(); - assert_eq!(result.stdout, "test\n"); + // `local` declares inside a function; outside one it fails (bash). + let result = bash + .exec("f() { local X=test; echo $X; }; f; echo \"[$X]\"") + .await + .unwrap(); + assert_eq!(result.stdout, "test\n[]\n"); + let result = bash.exec("local X=test; echo $?").await.unwrap(); + assert_eq!(result.stdout, "1\n"); } #[tokio::test] diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index fcf7a2d7d..c54f71741 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -420,6 +420,21 @@ impl fmt::Display for Word { WordPart::Transformation { name, operator } => { write!(f, "${{{}@{}}}", name, operator)? } + WordPart::CompoundAssignment { + name, + append, + elements, + } => { + let op = if *append { "+=" } else { "=" }; + write!(f, "{}{}(", name, op)?; + for (i, element) in elements.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + write!(f, "{}", element)?; + } + write!(f, ")")? + } } } Ok(()) @@ -431,6 +446,18 @@ impl fmt::Display for Word { pub enum WordPart { /// Literal text Literal(String), + /// A compound array assignment in argument position, as given to a + /// declaration builtin: `declare -a name=(a b)`, `local m+=([k]=v)`. + /// Elements are kept unexpanded; the builtin expands them like the + /// elements of an `name=(...)` statement. + CompoundAssignment { + /// Variable name (may carry a subscript, which bash rejects). + name: String, + /// `+=(...)` rather than `=(...)`. + append: bool, + /// The element words, `[key]=value` elements included. + elements: Vec, + }, /// Variable expansion ($VAR or ${VAR}) Variable(String), /// Command substitution ($(...)) diff --git a/crates/bashkit/src/parser/budget.rs b/crates/bashkit/src/parser/budget.rs index 797349fdc..4590b1166 100644 --- a/crates/bashkit/src/parser/budget.rs +++ b/crates/bashkit/src/parser/budget.rs @@ -218,6 +218,12 @@ fn validate_word_part( WordPart::CommandSubstitution(commands) => validate_commands(commands, ctx), WordPart::ProcessSubstitution { commands, .. } => validate_commands(commands, ctx), WordPart::Literal(s) if !word_is_quoted => check_brace_range(s), + WordPart::CompoundAssignment { elements, .. } => { + for element in elements { + validate_word(element, ctx)?; + } + Ok(()) + } _ => Ok(()), } } diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index b748b5892..d44301020 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -542,6 +542,45 @@ impl<'a> Lexer<'a> { self.read_word() } + /// Consume a backtick command substitution at the cursor and append it + /// to `word` as `$(cmd)`. + fn read_backtick_into(&mut self, word: &mut String) -> Result<(), String> { + self.advance(); // consume opening ` + word.push_str("$("); + let body_start = word.len(); + let mut closed = false; + while let Some(c) = self.peek_char() { + if c == '`' { + self.advance(); // consume closing ` + closed = true; + break; + } + if c == '\\' { + // In backticks, backslash only escapes $, `, \, newline + self.advance(); + if let Some(next) = self.peek_char() { + if matches!(next, '$' | '`' | '\\' | '\n') { + word.push(next); + self.advance(); + } else { + word.push('\\'); + word.push(next); + self.advance(); + } + } + } else { + word.push(c); + self.advance(); + } + } + if !closed { + return Err("unterminated backtick substitution".to_string()); + } + defer_backtick_syntax_error(word, body_start); + word.push(')'); + Ok(()) + } + fn read_word_starting_with(&mut self, prefix: &str) -> Option { let mut word = prefix.to_string(); let mut has_quoted_expansion = false; @@ -671,10 +710,15 @@ impl<'a> Lexer<'a> { } } continue; - } else if self.is_word_char(ch) || ch == ']' || ch == '}' { + } else if ch == '`' { + if let Err(e) = self.read_backtick_into(&mut word) { + return Some(Token::Error(e)); + } + } else if self.is_word_char(ch) || matches!(ch, ']' | '{' | '}') { // `}` included for the same reason as in `read_word`: it is a // reserved word, not a metacharacter, so it stays inside the - // word unless it stands alone (`echo }}` prints `}}`). + // word unless it stands alone (`echo }}` prints `}}`). `{` + // keeps a brace tail in the word (`[k]="v"{1,2}`). word.push(ch); self.advance(); } else { @@ -999,41 +1043,9 @@ impl<'a> Lexer<'a> { } else if ch == '`' { // Backtick command substitution: convert `cmd` to $(cmd) has_unquoted_expansion = true; - self.advance(); // consume opening ` - word.push_str("$("); - let body_start = word.len(); - let mut closed = false; - while let Some(c) = self.peek_char() { - if c == '`' { - self.advance(); // consume closing ` - closed = true; - break; - } - if c == '\\' { - // In backticks, backslash only escapes $, `, \, newline - self.advance(); - if let Some(next) = self.peek_char() { - if matches!(next, '$' | '`' | '\\' | '\n') { - word.push(next); - self.advance(); - } else { - word.push('\\'); - word.push(next); - self.advance(); - } - } - } else { - word.push(c); - self.advance(); - } + if let Err(e) = self.read_backtick_into(&mut word) { + return Some(Token::Error(e)); } - if !closed { - return Some(Token::Error( - "unterminated backtick substitution".to_string(), - )); - } - defer_backtick_syntax_error(&mut word, body_start); - word.push(')'); } else if ch == '\\' { self.advance(); if let Some(next) = self.peek_char() { @@ -1057,56 +1069,6 @@ impl<'a> Lexer<'a> { } else { word.push('\\'); } - } else if ch == '(' && word.ends_with('=') && self.looks_like_assoc_assign() { - // Associative compound assignment: var=([k]="v" ...) — keep entire - // (...) as part of word so declare -A m=([k]="v") stays one token. - word.push(ch); - self.advance(); - let mut depth = 1; - while let Some(c) = self.peek_char() { - word.push(c); - self.advance(); - match c { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - break; - } - } - '"' => { - while let Some(qc) = self.peek_char() { - word.push(qc); - self.advance(); - if qc == '"' { - break; - } - if qc == '\\' - && let Some(esc) = self.peek_char() - { - word.push(esc); - self.advance(); - } - } - } - '\'' => { - while let Some(qc) = self.peek_char() { - word.push(qc); - self.advance(); - if qc == '\'' { - break; - } - } - } - '\\' => { - if let Some(esc) = self.peek_char() { - word.push(esc); - self.advance(); - } - } - _ => {} - } - } } else if ch == '(' && word.ends_with(['@', '?', '*', '+', '!']) { // Extglob: @(...), ?(...), *(...), +(...), !(...) // Consume through matching ) including nested parens @@ -2154,7 +2116,13 @@ impl<'a> Lexer<'a> { // Continue reading any remaining word characters (e.g., [abc]def) while let Some(ch) = self.peek_char() { - if self.is_word_char(ch) { + if matches!(ch, '"' | '\'' | '$' | '`') { + // A quoted or expanded tail stays in the same word: + // `[k]="v w"` or `[k]=`cmd arg`` in a compound array is one + // element. + return self.read_word_starting_with(&word); + } else if self.is_word_char(ch) || matches!(ch, '{' | '}') { + // Braces are word text too (`[k]=v{1,2}`, `[ab]{x,y}`). word.push(ch); self.advance(); } else { @@ -2165,30 +2133,6 @@ impl<'a> Lexer<'a> { Some(Token::Word(word)) } - /// Peek ahead (without consuming) to see if `=(` starts an associative - /// compound assignment like `([key]=val ...)`. Returns true when the - /// first non-whitespace char after `(` is `[`. - fn looks_like_assoc_assign(&self) -> bool { - // Cap the lookahead: an uncapped scan - // over leading whitespace made `x=(` followed by megabytes of spaces - // O(n) per call (TM-DOS-024). - const MAX_LOOKAHEAD: usize = 10_000; - let mut chars = self.lookahead(); - // Skip the `(` we haven't consumed yet - if chars.next() != Some('(') { - return false; - } - // Skip optional whitespace - for ch in chars.take(MAX_LOOKAHEAD) { - match ch { - ' ' | '\t' => continue, - '[' => return true, - _ => return false, - } - } - false - } - /// Read the body of `(( ... ))` as written, after the opening `((`, /// through the matching `))` (consumed, not returned). Parentheses nest; /// quoted text is kept verbatim. Bash reads the arithmetic command this @@ -2650,13 +2594,13 @@ mod tests { #[test] fn test_assoc_compound_assignment() { - // declare -A m=([foo]="bar" [baz]="qux") should keep the compound - // assignment as a single Word token - let mut lexer = Lexer::new(r#"m=([foo]="bar" [baz]="qux")"#); - assert_eq!( - lexer.next_token(), - Some(Token::Word(r#"m=([foo]="bar" [baz]="qux")"#.to_string())) - ); + // m=([foo]="bar") lexes like any compound: the parser collects the + // element tokens between the parentheses. + let mut lexer = Lexer::new(r#"m=([foo]="bar")"#); + assert_eq!(lexer.next_token(), Some(Token::Word("m=".to_string()))); + assert_eq!(lexer.next_token(), Some(Token::LeftParen)); + assert!(matches!(lexer.next_token(), Some(Token::Word(_)))); + assert_eq!(lexer.next_token(), Some(Token::RightParen)); assert_eq!(lexer.next_token(), None); } diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 4d01b14e2..fcc85a587 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -2417,8 +2417,13 @@ impl<'a> Parser<'a> { &self.current_token, Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_)) ) { + let glob_quoted = + matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))); let mut w = self.parse_word(elem_clone); w.quoted = true; + // Mixed words like `"x"{1,2}` or `"a"*` keep their unquoted + // brace/glob text active, as for command arguments. + w.has_unquoted_glob = glob_quoted; w } else { self.parse_word(elem_clone) @@ -2577,37 +2582,42 @@ impl<'a> Parser<'a> { if !matches!(self.current_token, Some(tokens::Token::LeftParen)) { return None; } - self.advance(); // consume '(' - let mut compound = saved_w; - compound.push('('); - let mut raw_elems: Vec = Vec::new(); - loop { - match &self.current_token { - Some(tokens::Token::RightParen) => { - compound.push(')'); - self.advance(); - break; - } - Some(tokens::Token::Word(elem)) - | Some(tokens::Token::LiteralWord(elem)) - | Some(tokens::Token::QuotedWord(elem)) - | Some(tokens::Token::QuotedGlobWord(elem)) => { - if !compound.ends_with('(') { - compound.push(' '); - } - compound.push_str(elem); - raw_elems.push(self.current_raw.clone().unwrap_or_default()); - self.advance(); - } - None => break, - _ => { - self.advance(); - } - } + let lhs = saved_w.strip_suffix('=')?; + let (name, append) = match lhs.strip_suffix('+') { + Some(name) => (name, true), + None => (lhs, false), + }; + let base = name.split('[').next().unwrap_or(name); + let valid = base + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + if !valid { + return None; } - let mut word = self.parse_word(compound); - word.raw = saved_raw.map(|r| format!("{r}({})", raw_elems.join(" "))); - Some(word) + let name = name.to_string(); + self.advance(); // consume '(' + let elements = self.collect_array_elements(); + // Source text as written, for `type`/`declare -f`. + let raw = saved_raw.map(|r| { + let elems: Vec<&str> = elements + .iter() + .map(|e| e.raw.as_deref().unwrap_or("")) + .collect(); + format!("{r}({})", elems.join(" ")) + }); + Some(Word { + parts: vec![WordPart::CompoundAssignment { + name, + append, + elements, + }], + quoted: true, + has_unquoted_glob: false, + part_quoted: Vec::new(), + raw, + }) } /// Parse a heredoc redirect (`<<` or `<<-`) and any trailing redirects on the same line. diff --git a/crates/bashkit/src/tool.rs b/crates/bashkit/src/tool.rs index 537ea20da..fa25f507f 100644 --- a/crates/bashkit/src/tool.rs +++ b/crates/bashkit/src/tool.rs @@ -1179,7 +1179,7 @@ fn error_kind(e: &Error) -> String { Error::ResourceLimit(_) => "resource_limit".to_string(), Error::Network(_) => "network_error".to_string(), Error::Regex(_) => "regex_error".to_string(), - Error::CommandFailure(_) => "execution_error".to_string(), + Error::CommandFailure(_) | Error::LineAbort(_) => "execution_error".to_string(), Error::Internal(_) => "internal_error".to_string(), Error::Cancelled => "cancelled".to_string(), Error::SnapshotTooNew { .. } | Error::SnapshotCapabilityMismatch(_) => { diff --git a/crates/bashkit/tests/integration/blackbox_security_tests.rs b/crates/bashkit/tests/integration/blackbox_security_tests.rs index 8c9b338da..e382b228a 100644 --- a/crates/bashkit/tests/integration/blackbox_security_tests.rs +++ b/crates/bashkit/tests/integration/blackbox_security_tests.rs @@ -591,14 +591,16 @@ mod finding_shell_options_leak { #[tokio::test] async fn set_short_flags_do_not_leak_between_exec() { let mut bash = tight_bash(); - let result = bash.exec("set -bhm").await.unwrap(); - assert_eq!(result.exit_code, 0, "set -bhm should succeed"); + let result = bash.exec("set -bkm +hB; echo \"$-\"").await.unwrap(); + assert_eq!(result.exit_code, 0, "set -bkm +hB should succeed"); + assert_eq!(result.stdout.trim(), "bkmc"); + // The next exec starts from bash's defaults again (hashall and + // braceexpand on, the `c` of `bash -c`). let result = bash.exec("echo \"$-\"").await.unwrap(); - // Only the always-on defaults (hashall, braceexpand) remain. assert_eq!( result.stdout.trim(), - "hB", - "set -b/-m leaked across exec() calls through $-" + "hBc", + "set -b/-k/-m/+h/+B leaked across exec() calls through $-" ); } diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index c74cb394a..ce679dcaf 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -1186,12 +1186,16 @@ mod edge_cases { let depth = 500; let script = format!("echo $(({} 1 {}))", "(".repeat(depth), ")".repeat(depth),); let result = bash.exec(&script).await; - // Must not crash. With depth limit it returns 0 (depth exceeded → fallback) + // Must not crash. Past the depth limit the expansion fails like a + // bash arithmetic error: message on stderr, line abandoned. match result { Ok(r) => { - // Bounded arithmetic evaluator returns 0 when depth exceeded - let output = r.stdout.trim(); - assert!(!output.is_empty(), "should produce output, not crash"); + assert!( + r.stderr.contains("recursion level exceeded"), + "depth limit should be reported, got {:?}", + r.stderr + ); + assert_eq!(r.exit_code, 1); } Err(_) => { // Error also acceptable (parser-level rejection) @@ -1991,10 +1995,10 @@ mod nesting_depth_security { let depth = 1000; let script = format!("echo $(({} 7 {}))", "(".repeat(depth), ")".repeat(depth),); let result = bash.exec(&script).await; - // Must not crash — returns 0 (depth exceeded) or error + // Must not crash: the depth limit reports an arithmetic error. if let Ok(r) = result { - // With depth limiting, deeply nested expr returns 0 as fallback - assert!(!r.stdout.trim().is_empty(), "should produce output"); + assert!(r.stderr.contains("recursion level exceeded")); + assert_eq!(r.exit_code, 1); } } @@ -3766,7 +3770,8 @@ echo ${#big2} assert!(!lines.is_empty(), "should have produced some output"); } - /// TM-DOS-060: local builtin assignments must honor variable count budget. + /// TM-DOS-060: declaration builtin assignments must honor variable count + /// budget. (`local` outside a function assigns nothing, as in bash.) #[tokio::test] async fn tm_dos_060_local_assignment_respects_budget() { let mem = MemoryLimits::new().max_variable_count(2); @@ -3776,7 +3781,7 @@ echo ${#big2} .build(); let script = r#" -local a=1 b=2 c=3 +declare a=1 b=2 c=3 printf "%s\n" "${a:-unset}" "${b:-unset}" "${c:-unset}" "#; let error = bash.exec(script).await.unwrap_err(); @@ -3829,7 +3834,9 @@ f .lines() .map(|line| line.parse::().unwrap()) .collect(); - assert_eq!(counts, vec![0, 0]); + // At most the one budgeted entry is stored, never all three. + assert_eq!(counts.len(), 2); + assert!(counts.iter().all(|c| *c <= 1), "counts: {counts:?}"); } /// TM-INF-023: bare local declarations must shadow stale global array bindings. @@ -3903,6 +3910,27 @@ printf "%s %s\n" "${#arr[@]}" "${#extra[@]}" assert_eq!(result.stdout.trim(), "3 0"); } + /// TM-DOS-060: brace/glob expansion inside array literals stays capped. + #[tokio::test] + async fn tm_dos_060_array_literal_brace_expansion_respects_budget() { + let mem = MemoryLimits::new().max_array_entries(3); + let mut bash = Bash::builder() + .memory_limits(mem) + .session_limits(SessionLimits::unlimited()) + .build(); + + let script = r#" +arr=(x{1..1000}) +echo "${#arr[@]}" +unset arr +declare -a d=("y"{1..1000} z{a,b}) +echo "${#d[@]}" +"#; + let result = bash.exec(script).await.unwrap(); + + assert_eq!(result.stdout.trim(), "3\n3"); + } + /// TM-DOS-060: arithmetic writes to array elements are charged to the budget. #[tokio::test] async fn tm_dos_060_arithmetic_array_writes_respect_budget() { diff --git a/crates/bashkit/tests/spec_cases/bash/arrays.test.sh b/crates/bashkit/tests/spec_cases/bash/arrays.test.sh index c350cc94b..9b41bbbf4 100644 --- a/crates/bashkit/tests/spec_cases/bash/arrays.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/arrays.test.sh @@ -328,3 +328,79 @@ IFS=","; x="a,b,c"; arr=($x); echo "${#arr[@]}" ### expect 3 ### end + +### array_literal_brace_expansion +# Unquoted array-literal words brace-expand, mixed quoting included +arr=(x{1,2} "y"{a,b} v{x}) +declare -p arr +### expect +declare -a arr=([0]="x1" [1]="x2" [2]="ya" [3]="yb" [4]="v{x}") +### end + +### array_literal_pathname_expansion +# Unquoted array-literal words glob; no match keeps the pattern +mkdir -p /tmp/arr_glob && cd /tmp/arr_glob && touch a.txt b.txt +arr=(*.txt *.nope) +declare -p arr +### expect +declare -a arr=([0]="a.txt" [1]="b.txt" [2]="*.nope") +### end + +### array_literal_nullglob +# With nullglob an unmatched array-literal glob vanishes +mkdir -p /tmp/arr_nullglob && cd /tmp/arr_nullglob +shopt -s nullglob +arr=(*.nope) +echo "${#arr[@]}" +arr=(a *.nope b) +declare -p arr +### expect +0 +declare -a arr=([0]="a" [1]="b") +### end + +### array_literal_quoted_no_expansion +# Quoted array-literal words neither brace- nor pathname-expand +mkdir -p /tmp/arr_quoted && cd /tmp/arr_quoted && touch a.txt +arr=("*.txt" '{a,b}' "x{1,2}") +declare -p arr +### expect +declare -a arr=([0]="*.txt" [1]="{a,b}" [2]="x{1,2}") +### end + +### declare_a_literal_expansion +# declare -a compound values brace- and pathname-expand +mkdir -p /tmp/arr_declare && cd /tmp/arr_declare && touch a.txt b.txt +declare -a d=(*.txt x{1,2}) +declare -p d +### expect +declare -a d=([0]="a.txt" [1]="b.txt" [2]="x1" [3]="x2") +### end + +### local_a_literal_expansion +# local -a compound values brace- and pathname-expand +mkdir -p /tmp/arr_local && cd /tmp/arr_local && touch a.txt b.txt +f() { local -a l=(z{a,b} *.txt); declare -p l; } +f +### expect +declare -a l=([0]="za" [1]="zb" [2]="a.txt" [3]="b.txt") +### end + +### array_append_literal_expansion +# arr+=(...) brace- and pathname-expands the new words +mkdir -p /tmp/arr_append && cd /tmp/arr_append && touch a.txt b.txt +arr=(p) +arr+=(q{1,2} *.txt) +declare -p arr +### expect +declare -a arr=([0]="p" [1]="q1" [2]="q2" [3]="a.txt" [4]="b.txt") +### end + +### assoc_keyed_value_no_brace_expansion +# Keyed assoc values stay literal: no brace or pathname expansion +mkdir -p /tmp/arr_assoc && cd /tmp/arr_assoc && touch a.txt +declare -A m=([k]=v{1,2} [g]=*.txt) +echo "${m[k]} ${m[g]}" +### expect +v{1,2} *.txt +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-arithmetic-array.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-arithmetic-array.test.sh index c9d2ae7a9..63ec3ea07 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-arithmetic-array.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-arithmetic-array.test.sh @@ -4,7 +4,6 @@ ### bashbox_arithmetic_array_elements_read_and_write_by_index_nested_and_negative # elements read and write by index, nested and negative -### skip: TODO bashbox corpus gap, bashkit output differs from real bash a=(5 6 7); i=1; echo $((a[i+1] + a[0])) $((a[a[0]-4])) $(( a[-1] )) $(( a[ 1 ] )); ((a[1]+=10, a[5]=3)); echo ${a[@]}; ((a[2]++)); ((--a[0])); echo ${a[0]} ${a[2]} $((a[1]--)) ${a[1]} ### expect 12 6 7 6 @@ -14,7 +13,6 @@ a=(5 6 7); i=1; echo $((a[i+1] + a[0])) $((a[a[0]-4])) $(( a[-1] )) $(( a[ 1 ] ) ### bashbox_arithmetic_array_associative_keys_stay_text_and_a_scalar_is_its_own_element_0 # associative keys stay text, and a scalar is its own element 0 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -A m; m[x]=4; echo $((m[x]*2)); ((m[y]=7)); echo ${m[y]}; k=x; echo $((m[$k]+1)); b=3; echo $((b[0])) $((b[1])) ### expect 8 @@ -25,7 +23,6 @@ declare -A m; m[x]=4; echo $((m[x]*2)); ((m[y]=7)); echo ${m[y]}; k=x; echo $((m ### bashbox_arithmetic_array_an_unclosed_subscript_is_an_error # an unclosed subscript is an error -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo $((a[1 )) echo next ### expect @@ -34,7 +31,6 @@ next ### bashbox_arithmetic_array_an_empty_subscript_is_reported_twice_and_reads_0 # an empty subscript is reported twice and reads 0 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash a=(1 2); echo $((a[])) $((a[]+1)) ### expect 0 1 @@ -42,7 +38,6 @@ a=(1 2); echo $((a[])) $((a[]+1)) ### bashbox_arithmetic_array_division_by_0_names_the_divisor # division by 0 names the divisor -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo $(( 1/0 )) echo $(( 10 / (2-2) )) echo $(( 5 % 0 + 1 )) @@ -57,7 +52,6 @@ echo $(( 0 && 1/0 )) ### bashbox_arithmetic_array_syntax_errors_quote_the_token_bash_stopped_at # syntax errors quote the token bash stopped at -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo $(( 1 + )) echo $((1 +)) ((1 + )); echo $? @@ -73,7 +67,6 @@ let "x = 1 +"; echo $? ### bashbox_arithmetic_array_integers_wrap_around_at_64_bits # integers wrap around at 64 bits -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo $(( 9223372036854775807 + 1 )) $(( 9223372036854775807 * 3 )) $(( -9223372036854775807 - 2 )) $(( 99999999999999999999 )); x=-9223372036854775807; echo $(( (x-1) / -1 )) $(( (x-1) % -1 )) $(( -(x-1) )); y=9223372036854775807; ((y++)); echo $y; ((y*=2)); echo $y ### expect -9223372036854775808 9223372036854775805 9223372036854775807 7766279631452241919 @@ -84,7 +77,6 @@ echo $(( 9223372036854775807 + 1 )) $(( 9223372036854775807 * 3 )) $(( -92233720 ### bashbox_arithmetic_array_powers_and_shifts_wrap_like_c # powers and shifts wrap like C -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo $(( 2 ** 64 )) $(( 3 ** 41 )) $(( 2 ** 63 )) $(( 7 ** 0 )) $(( 1 << 64 )) $(( 1 << 63 )) $(( -1 >> 70 )) $(( 1 << -1 )) $(( 8 >> -62 )); z=1; ((z <<= 65)); echo $z ### expect 0 -420491770248316829 -9223372036854775808 1 1 -9223372036854775808 -1 -9223372036854775808 2 @@ -93,7 +85,6 @@ echo $(( 2 ** 64 )) $(( 3 ** 41 )) $(( 2 ** 63 )) $(( 7 ** 0 )) $(( 1 << 64 )) $ ### bashbox_arithmetic_array_let_stops_at_a_failing_expression # let stops at a failing expression -### skip: TODO bashbox corpus gap, bashkit output differs from real bash let "1/0" "y=2"; echo $? $y; let; echo $?; let x=1 "y=x+1"; echo $? $y; let 0; echo $? ### expect 1 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh index 8c48436ab..95c410642 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh @@ -11,7 +11,6 @@ FOO=1; printenv FOO; echo $? ### bashbox_attributes_export_marks_the_name_so_later_values_reach_commands # export marks the name, so later values reach commands -### skip: TODO bashbox corpus gap, bashkit output differs from real bash export X=1; X=2; printenv X; export Y; Y=3; printenv Y ### expect 2 @@ -20,7 +19,6 @@ export X=1; X=2; printenv X; export Y; Y=3; printenv Y ### bashbox_attributes_a_local_inherits_the_export_attribute # a local inherits the export attribute -### skip: TODO bashbox corpus gap, bashkit output differs from real bash export x=g; f(){ local x=1; printenv x; }; f ### expect 1 @@ -28,7 +26,6 @@ export x=g; f(){ local x=1; printenv x; }; f ### bashbox_attributes_local_x_exports_only_the_local # local -x exports only the local -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=g; f(){ local -x x=1; printenv x; }; f; printenv x; echo $? ### expect 1 @@ -44,7 +41,6 @@ export x=1; unset x; x=2; printenv x; echo $? ### bashbox_attributes_export_n_un_exports # export -n un-exports -### skip: TODO bashbox corpus gap, bashkit output differs from real bash export A=1; export -n A; printenv A; echo $? $A ### expect 1 1 @@ -52,7 +48,6 @@ export A=1; export -n A; printenv A; echo $? $A ### bashbox_attributes_declare_p_shows_r_and_x # declare -p shows r and x -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -rx R=1; declare -p R; declare -x S; declare -p S; export -p | grep -w '[RS]' ### expect declare -rx R="1" @@ -63,7 +58,6 @@ declare -x S ### bashbox_attributes_declare_x_un_exports # declare +x un-exports -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -x A=1; declare +x A; printenv A; echo $? ### expect 1 @@ -71,7 +65,6 @@ declare -x A=1; declare +x A; printenv A; echo $? ### bashbox_attributes_set_a_exports_every_assignment_while_on # set -a exports every assignment while on -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -a; B=2; printenv B; echo $-; set +a; C=3; printenv C; echo $? ### expect 2 @@ -81,7 +74,6 @@ ahBc ### bashbox_attributes_allexport_covers_locals_and_read_not_arrays # allexport covers locals and read, not arrays -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -o allexport; f(){ local l=3; declare -p l; }; f; read r <<< hi; declare -p r; arr=(1); declare -p arr ### expect declare -x l="3" @@ -109,7 +101,6 @@ export V=0; V=1 printenv V; printenv V ### bashbox_attributes_arrays_print_with_a_or_a_an_associative_one_with_a_trailing_ # arrays print with a or A, an associative one with a trailing space -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -a arr=(1 2); export arr; declare -p arr; declare -A m=([k]=v); declare -p m ### expect declare -ax arr=([0]="1" [1]="2") @@ -118,7 +109,6 @@ declare -A m=([k]="v" ) ### bashbox_attributes_a_local_hides_a_until_the_function_returns # a local hides -A until the function returns -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -A m=([a]=1); f(){ local m; declare -p m; local -A n; n[x]=1; declare -p n; }; f; declare -p m ### expect declare -- m @@ -128,7 +118,6 @@ declare -A m=([a]="1" ) ### bashbox_attributes_declare_rejects_names_that_are_not_identifiers_and_options_a # declare rejects names that are not identifiers, and options after the names -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare 1x=2 y=3 -z; echo $? $y ### expect 1 3 @@ -136,7 +125,6 @@ declare 1x=2 y=3 -z; echo $? $y ### bashbox_attributes_local_rejects_names_that_are_not_identifiers # local rejects names that are not identifiers -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local a-b=1 c=2; echo $? $c; }; f ### expect 1 2 @@ -144,7 +132,6 @@ f(){ local a-b=1 c=2; echo $? $c; }; f ### bashbox_attributes_readonly_rejects_names_that_are_not_identifiers # readonly rejects names that are not identifiers -### skip: TODO bashbox corpus gap, bashkit output differs from real bash readonly 9=1 r=1; echo $? $r; readonly | grep ' r=' ### expect 1 1 @@ -153,7 +140,6 @@ declare -r r="1" ### bashbox_attributes_export_rejects_names_that_are_not_identifiers # export rejects names that are not identifiers -### skip: TODO bashbox corpus gap, bashkit output differs from real bash export a.b x=1; echo $?; printenv x ### expect 1 @@ -162,7 +148,6 @@ export a.b x=1; echo $?; printenv x ### bashbox_attributes_export_rejects_unknown_options # export rejects unknown options -### skip: TODO bashbox corpus gap, bashkit output differs from real bash export -z; echo $? ### expect 2 @@ -170,7 +155,6 @@ export -z; echo $? ### bashbox_attributes_declare_assigns_an_array_element # declare assigns an array element -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare a[1]=x; declare -p a ### expect declare -a a=([1]="x") @@ -178,7 +162,6 @@ declare -a a=([1]="x") ### bashbox_attributes_readonly_p_shows_each_variable_s_attributes # readonly -p shows each variable's attributes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash readonly U; export V; readonly V; readonly -p | grep -w '[UV]' ### expect declare -r U @@ -187,7 +170,6 @@ declare -rx V ### bashbox_attributes_declare_p_escapes_quotes_dollars_backquotes_and_backslashes # declare -p escapes quotes, dollars, backquotes and backslashes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x='a"b$c`d\e'; declare -p x; a=("q\""); declare -p a ### expect declare -- x="a\"b\$c\`d\\e" @@ -196,7 +178,6 @@ declare -a a=([0]="q\"") ### bashbox_attributes_export_f_only_checks_that_the_functions_exist # export -f only checks that the functions exist -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ :; }; export -f f; echo $?; export -f nosuch; echo $? ### expect 0 @@ -205,7 +186,6 @@ f(){ :; }; export -f f; echo $?; export -f nosuch; echo $? ### bashbox_attributes_env_sees_only_exported_variables # env sees only exported variables -### skip: TODO bashbox corpus gap, bashkit output differs from real bash env | grep -c FOO; FOO=1 env | grep FOO; export E=1; (E=2; printenv E); printenv E; x=$(printenv E); echo $x ### expect 0 @@ -224,7 +204,6 @@ export -- G=1; printenv G ### bashbox_attributes_o_in_a_cluster_takes_the_next_argument_as_its_option_name # o in a cluster takes the next argument as its option name -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -euo pipefail; echo "[${1-}]" $-; shopt -po pipefail ### expect [] ehuBc @@ -233,7 +212,6 @@ set -o pipefail ### bashbox_attributes_several_o_options # several -o options -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -o errexit -o nounset; echo $-; set +o nounset; echo $- ### expect ehuBc @@ -242,7 +220,6 @@ ehBc ### bashbox_attributes_o_at_the_end_of_a_cluster_lists_the_options_after_applying_t # o at the end of a cluster lists the options after applying the others -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -euo | grep -E '^(errexit|nounset|xtrace) ' ### expect errexit on @@ -260,7 +237,6 @@ set +o pipefail ### bashbox_attributes_an_unknown_option_name_changes_nothing # an unknown option name changes nothing -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -o bogus -u; echo $? $- ### expect 2 hBc @@ -268,7 +244,6 @@ set -o bogus -u; echo $? $- ### bashbox_attributes_an_unknown_letter_changes_nothing # an unknown letter changes nothing -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -ez; echo $? $- ### expect 2 hBc @@ -283,7 +258,6 @@ set --bogus; echo $? ### bashbox_attributes_letters_with_no_effect_here_are_accepted # letters with no effect here are accepted -### skip: TODO bashbox corpus gap, bashkit output differs from real bash set -hkB; echo $? ### expect 0 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh index 1d2b1da2f..10c20ce92 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh @@ -4,7 +4,6 @@ ### bashbox_declare_i_evaluates_each_assignment_and_adds # -i evaluates each assignment, and += adds -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -i x=1+2; x+=3; echo $x; x='2*3'; echo $x; x=abc; echo $x; x=; echo $x; declare -p x ### expect 6 @@ -16,7 +15,6 @@ declare -i x="0" ### bashbox_declare_i_on_an_array_evaluates_each_element_as_it_is_assigned # -i on an array evaluates each element as it is assigned -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -i a=(1+1 2*3); a+=(5+5); a[7]=3+3; a[7]+=1; declare -p a ### expect declare -ai a=([0]="2" [1]="6" [2]="10" [7]="7") @@ -32,7 +30,7 @@ declare -i x; let x=4; ((x=x+1)); echo $x; read x <<< 2+2; echo $x ### bashbox_declare_read_and_printf_name_themselves_too # read and printf name themselves too -### skip: TODO bashbox corpus gap, bashkit output differs from real bash +### skip: TODO printf -v into an integer variable does not evaluate arithmetic (printf builtin gap) declare -i x; (read x <<< 1/0); (printf -v x 1/0); echo $? ### expect 1 @@ -40,7 +38,6 @@ declare -i x; (read x <<< 1/0); (printf -v x 1/0); echo $? ### bashbox_declare_a_subshell_ends_with_status_1 # a subshell ends with status 1 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash (declare -i x; x=1/0; echo in); echo out $? ### expect out 1 @@ -48,7 +45,6 @@ out 1 ### bashbox_declare_i_removes_the_attribute_and_a_local_does_not_inherit_it # +i removes the attribute, and a local does not inherit it -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -i x=1; f() { local x; x=1+1; echo $x; }; f; declare +i x; x=1+1; echo $x ### expect 1+1 @@ -57,7 +53,6 @@ declare -i x=1; f() { local x; x=1+1; echo $x; }; f; declare +i x; x=1+1; echo $ ### bashbox_declare_a_prefix_assignment_ignores_i_l_and_u # a prefix assignment ignores -i, -l and -u -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -u x; declare -i n; f() { echo "$x $n"; }; x=abc n=1/0 f ### expect abc 1/0 @@ -65,7 +60,6 @@ abc 1/0 ### bashbox_declare_l_and_u_convert_included # -l and -u convert, += included -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -l x=ABC; x+=DEF; echo $x; declare -u y=abc; echo $y; read y <<< mixed; echo $y; declare -p x y ### expect abcdef @@ -77,7 +71,6 @@ declare -u y="MIXED" ### bashbox_declare_l_and_u_replace_each_other_together_neither_changes # -l and -u replace each other; together neither changes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -l x=AB; declare -u x; echo $x; x=Cd; echo $x; declare -lu x; x=eF; echo $x; declare +u x; x=Gh; declare -p x ### expect ab @@ -88,7 +81,6 @@ declare -- x="Gh" ### bashbox_declare_u_applies_to_array_elements # -u applies to array elements -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -u a=(xa yb); a[3]=zz; a[3]+=q; declare -p a ### expect declare -au a=([0]="XA" [1]="YB" [3]="ZZQ") @@ -96,7 +88,6 @@ declare -au a=([0]="XA" [1]="YB" [3]="ZZQ") ### bashbox_declare_declare_p_prints_the_attributes_in_bash_s_order # declare -p prints the attributes in bash's order -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -ilrx x=5; declare -iAx m; declare -nr q=z; declare -p x m q ### expect declare -irxl x="5" @@ -106,7 +97,6 @@ declare -nr q="z" ### bashbox_declare_declared_but_unset_arrays_and_scalars_print_bare # declared but unset arrays and scalars print bare -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -a e; declare -A f; declare -i g; declare -p e f g; e+=(q); declare -p e; [[ -v f ]]; echo $? ### expect declare -a e @@ -118,7 +108,6 @@ declare -a e=([0]="q") ### bashbox_declare_a_scalar_declared_as_an_array_becomes_element_0 # a scalar declared as an array becomes element 0 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=1; declare -A x; declare -p x; y=2; declare -a y; declare -p y; declare -a z=v; declare -p z ### expect declare -A x=([0]="1" ) @@ -128,7 +117,6 @@ declare -a z=([0]="v") ### bashbox_declare_converting_between_indexed_and_associative_fails # converting between indexed and associative fails -### skip: TODO bashbox corpus gap, bashkit output differs from real bash a=(1 2); declare -A a; echo $?; declare -A m; declare -a m; echo $?; declare -p a m ### expect 1 @@ -139,7 +127,6 @@ declare -A m ### bashbox_declare_with_an_array_value_the_conversion_error_abandons_the_line # with an array value the conversion error abandons the line -### skip: TODO bashbox corpus gap, bashkit output differs from real bash a=(1); declare -A a=([x]=2); echo same echo next $?; declare -p a ### expect @@ -149,7 +136,6 @@ declare -a a=([0]="1") ### bashbox_declare_declare_with_only_attributes_lists_the_variables_that_have_t # declare with only attributes lists the variables that have them all -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -i I=2; declare -ia J=(1); declare -A M; declare -ai; declare -A | grep ' M'; declare -i | grep -E '^declare -a?i [IJ]' ### expect declare -ai J=([0]="1") @@ -160,7 +146,6 @@ declare -ai J=([0]="1") ### bashbox_declare_a_nameref_reads_writes_and_appends_through_to_its_target # a nameref reads, writes and appends through to its target -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n r=a; a=5; echo $r; r=7; echo $a; r+=x; echo $a; echo ${!r}; declare -p r a ### expect 5 @@ -173,7 +158,6 @@ declare -- a="7x" ### bashbox_declare_a_chain_of_namerefs # a chain of namerefs -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n r=x; declare -n s=r; s=5; echo $x ${!s} ### expect 5 x @@ -181,7 +165,6 @@ declare -n r=x; declare -n s=r; s=5; echo $x ${!s} ### bashbox_declare_a_nameref_to_an_element # a nameref to an element -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n r=a[1]; r=x; echo $r; declare -p a ### expect x @@ -198,7 +181,6 @@ declare -a arr=([0]="1" [1]="b" [2]="3") ### bashbox_declare_declare_acts_on_the_target_unless_n_is_given # declare acts on the target unless -n is given -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n r=x; declare r=5; declare -i r; r=1+1; declare -p r x ### expect declare -n r="x" @@ -207,7 +189,6 @@ declare -i x="2" ### bashbox_declare_n_turns_a_nameref_back_into_a_plain_variable # +n turns a nameref back into a plain variable -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n r=x; declare +n r; declare -p r ### expect declare -- r="x" @@ -215,7 +196,6 @@ declare -- r="x" ### bashbox_declare_a_nameref_declared_without_a_value_takes_the_first_value_ass # a nameref declared without a value takes the first value assigned -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n r; r=x; declare -p r; echo "[${!r}]" ### expect declare -n r="x" @@ -224,7 +204,6 @@ declare -n r="x" ### bashbox_declare_a_for_loop_points_a_nameref_at_each_word # a for loop points a nameref at each word -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n r; for r in a b; do r=v; done; declare -p a b r ### expect declare -- a="v" @@ -241,7 +220,6 @@ hi ### bashbox_declare_invalid_nameref_targets_are_refused # invalid nameref targets are refused -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n r='1x'; echo $?; declare -n r=@; echo $?; f() { local -n r='a b'; }; f; echo $? ### expect 1 @@ -251,7 +229,6 @@ declare -n r='1x'; echo $?; declare -n r=@; echo $?; f() { local -n r='a b'; }; ### bashbox_declare_a_value_that_can_t_be_a_name_stays_and_declare_n_still_succe # a value that can't be a name stays and declare -n still succeeds -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=1; declare -n x; echo $?; declare -p x; y=foo; declare -n y; declare -p y ### expect 1 @@ -268,7 +245,6 @@ f() { local -n r=r; echo $?; }; f ### bashbox_declare_a_circular_reference_reads_as_unset_with_a_warning # a circular reference reads as unset with a warning -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n a=b; declare -n b=a; echo "${a-u}"; declare -p a b ### expect u @@ -278,7 +254,6 @@ declare -n b="a" ### bashbox_declare_assigning_through_a_circular_reference_abandons_the_line # assigning through a circular reference abandons the line -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n a=b; declare -n b=a; a=1; echo same echo next $? ### expect @@ -287,7 +262,6 @@ next 1 ### bashbox_declare_read_through_a_circular_reference_fails # read through a circular reference fails -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -n a=b; declare -n b=a; read a <<< x; echo $? ### expect 1 @@ -295,7 +269,6 @@ declare -n a=b; declare -n b=a; read a <<< x; echo $? ### bashbox_declare_a_nameref_local_to_a_function_is_restored # a nameref local to a function is restored -### skip: TODO bashbox corpus gap, bashkit output differs from real bash r=global; f() { local -n r=x; r=1; }; f; echo $r $x ### expect global 1 @@ -303,7 +276,6 @@ global 1 ### bashbox_declare_readonly_accepts_only_a_a_and_f # readonly accepts only -a, -A and -f -### skip: TODO bashbox corpus gap, bashkit output differs from real bash readonly -i r=1+1; echo $?; readonly -A m=([k]=v); declare -p m; readonly -a m2; declare -p m2 ### expect 2 @@ -313,7 +285,6 @@ declare -r m2 ### bashbox_declare_readonly_a_converts_nothing # readonly -A converts nothing -### skip: TODO bashbox corpus gap, bashkit output differs from real bash a=(1); readonly -A a; echo $?; declare -p a ### expect 0 @@ -322,7 +293,6 @@ declare -ar a=([0]="1") ### bashbox_declare_export_and_readonly_assign_array_operands # export and readonly assign array operands -### skip: TODO bashbox corpus gap, bashkit output differs from real bash export a=(1 2); readonly b=(3); declare -p a b ### expect declare -ax a=([0]="1" [1]="2") @@ -331,7 +301,7 @@ declare -ar b=([0]="3") ### bashbox_declare_declare_p_quotes_control_characters_and_bytes_that_aren_t_ut # declare -p quotes control characters and bytes that aren't UTF-8 like bash -### skip: TODO bashbox corpus gap, bashkit output differs from real bash +### skip: TODO $'\x..' decodes each byte as a char (\x01 dropped, \xc3\xa9 not UTF-8) and non-UTF-8 bytes are not representable (L-STREAM-001) x=$'a\x01b\e\'"\\c$`d\a\b\f\v\r\t\n\x7f'; declare -p x; y=$'\xc3\xa9\n'; z=$'\xff'; declare -p y z; a=($'x\ny' z); declare -p a ### expect declare -- x=$'a\001b\E\'"\\c$`d\a\b\f\v\r\t\n\177' @@ -342,7 +312,6 @@ declare -a a=([0]=$'x\ny' [1]="z") ### bashbox_declare_associative_keys_with_shell_metacharacters_are_quoted # associative keys with shell metacharacters are quoted -### skip: TODO bashbox corpus gap, bashkit output differs from real bash declare -A m=(["a b"]=1) n=(['$x']=2) o=([c]=3); declare -p m n o ### expect declare -A m=(["a b"]="1" ) @@ -359,7 +328,6 @@ declare -- x="1" ### bashbox_declare_plain_declare_lists_like_set # plain declare lists like set -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=1; declare | grep '^x=' ### expect x=1 @@ -367,7 +335,7 @@ x=1 ### bashbox_declare_printf_v_assigns_the_output_newlines_and_all # printf -v assigns the output, newlines and all -### skip: TODO bashbox corpus gap, bashkit output differs from real bash +### skip: TODO printf -v gaps (-vNAME, element targets) belong to the printf builtin printf -v x '%s-%s\n' a b; echo "[$x]"; printf -vy %s a; echo $y; printf -v 'a[1+1]' %s z; declare -p a ### expect [a-b @@ -378,7 +346,7 @@ declare -a a=([2]="z") ### bashbox_declare_printf_v_takes_and_a_local # printf -v takes -- and a local -### skip: TODO bashbox corpus gap, bashkit output differs from real bash +### skip: TODO printf -v gaps (-- after -v) belong to the printf builtin printf -v x -- %s a; echo $x; f() { local y; printf -v y %s in; echo $y; }; f; echo ${y-unset} ### expect a @@ -388,7 +356,7 @@ unset ### bashbox_declare_printf_v_errors # printf -v errors -### skip: TODO bashbox corpus gap, bashkit output differs from real bash +### skip: TODO printf -v gaps (usage errors, invalid names) belong to the printf builtin printf -v; echo $?; printf -v x; echo $? ${x-unset}; printf -v 1x %s a; echo $?; printf -v 'a[' %s x; echo $? ### expect 2 @@ -399,7 +367,7 @@ printf -v; echo $?; printf -v x; echo $? ${x-unset}; printf -v 1x %s a; echo $?; ### bashbox_declare_printf_v_into_a_readonly_variable_fails # printf -v into a readonly variable fails -### skip: TODO bashbox corpus gap, bashkit output differs from real bash +### skip: TODO printf -v gaps (readonly target) belong to the printf builtin readonly r; printf -v r %s a; echo $? ### expect 1 @@ -407,7 +375,7 @@ readonly r; printf -v r %s a; echo $? ### bashbox_declare_printf_without_v_is_the_printf_command # printf without -v is the printf command -### skip: TODO bashbox corpus gap, bashkit output differs from real bash +### skip: TODO printf -- handling belongs to the printf builtin printf -- -v; echo ### expect -v diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-local-scope.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-local-scope.test.sh index 244dbd01e..a9c327c1d 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-local-scope.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-local-scope.test.sh @@ -12,7 +12,6 @@ g ### bashbox_local_scope_local_a_with_keys # local -A with keys -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local -A m=([k]=v [j]=w); echo "${!m[@]} ${m[k]}"; }; f; declare -p m; echo $? ### expect k j v @@ -29,7 +28,6 @@ y ### bashbox_local_scope_declare_in_a_function_is_local # declare in a function is local -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ declare -a a=(1 2); declare s=v; }; f; echo "[${a[@]}] [$s]" ### expect [] [] @@ -86,7 +84,6 @@ g ### bashbox_local_scope_unset_from_a_callee_uncovers_the_global # unset from a callee uncovers the global -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local a=(1 2); g; echo "[${a[@]}]"; }; g(){ unset a; }; a=(gg); f; echo "${a[@]}" ### expect [gg] @@ -95,7 +92,6 @@ gg ### bashbox_local_scope_unset_from_a_callee_uncovers_a_global_scalar # unset from a callee uncovers a global scalar -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local x=1; g; echo "[${x-unset}]"; }; g(){ unset x; }; x=g; f; echo $x ### expect [g] @@ -104,7 +100,6 @@ g ### bashbox_local_scope_a_local_scalar_hides_a_global_array # a local scalar hides a global array -### skip: TODO bashbox corpus gap, bashkit output differs from real bash a=(1 2 3); f(){ local a=x; echo "${a[@]} ${#a[@]}"; declare -p a; }; f; echo "${a[@]}" ### expect x 1 @@ -114,7 +109,6 @@ declare -- a="x" ### bashbox_local_scope_a_local_array_hides_a_global_scalar # a local array hides a global scalar -### skip: TODO bashbox corpus gap, bashkit output differs from real bash a=s; f(){ local -a a=(1 2); echo "${a[@]}"; }; f; echo "${a[@]}"; declare -p a ### expect 1 2 @@ -124,7 +118,6 @@ declare -- a="s" ### bashbox_local_scope_local_without_a_value_is_unset # local without a value is unset -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=g; f(){ local x; echo "[${x-unset}]"; }; f; echo $x ### expect [unset] @@ -133,7 +126,6 @@ g ### bashbox_local_scope_local_again_keeps_the_value # local again keeps the value -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local x=1; local x; echo $x; }; f ### expect 1 @@ -141,7 +133,6 @@ f(){ local x=1; local x; echo $x; }; f ### bashbox_local_scope_local_of_a_readonly_variable # local of a readonly variable -### skip: TODO bashbox corpus gap, bashkit output differs from real bash readonly x=1; f(){ local x; echo "[$x]"; }; f; echo $? ### expect [1] @@ -150,7 +141,6 @@ readonly x=1; f(){ local x; echo "[$x]"; }; f; echo $? ### bashbox_local_scope_local_g_is_global # local -g is global -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local -g x=1; }; f; echo $x ### expect 1 @@ -158,7 +148,6 @@ f(){ local -g x=1; }; f; echo $x ### bashbox_local_scope_local_with_no_names_lists_nothing # local with no names lists nothing -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local x=1; local; }; f; echo $? ### expect declare -- x="1" @@ -167,7 +156,6 @@ declare -- x="1" ### bashbox_local_scope_local_outside_a_function # local outside a function -### skip: TODO bashbox corpus gap, bashkit output differs from real bash local x=1; echo $? ### expect 1 @@ -182,7 +170,6 @@ x=g; f(){ local x=1; printenv x; }; f; echo $? ### bashbox_local_scope_local_lineno_is_an_ordinary_variable # local LINENO is an ordinary variable -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local LINENO=5; echo $LINENO; }; f; echo $LINENO ### expect 5 @@ -191,7 +178,6 @@ f(){ local LINENO=5; echo $LINENO; }; f; echo $LINENO ### bashbox_local_scope_a_local_array_readonly_declaration # a local array readonly declaration -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ declare -ra a=(1); declare -p a; }; f; a=(2); echo "${a[@]}" ### expect declare -ar a=([0]="1") @@ -200,7 +186,6 @@ declare -ar a=([0]="1") ### bashbox_local_scope_a_scalar_is_element_0_of_an_array # a scalar is element 0 of an array -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=5; echo "${x[@]} ${#x[@]} ${!x[@]} ${x[0]}"; x[1]=6; echo "${x[@]} $x"; declare -p x ### expect 5 1 0 5 @@ -210,7 +195,6 @@ declare -a x=([0]="5" [1]="6") ### bashbox_local_scope_assigning_an_array_name_sets_element_0 # assigning an array name sets element 0 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash a=(1 2); a=x; echo "${a[@]}"; a+=y; echo "${a[@]}" ### expect x 2 @@ -219,7 +203,6 @@ xy 2 ### bashbox_local_scope_arithmetic_on_a_scalar_element # arithmetic on a scalar element -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=4; echo $((x[0] + 1)); ((x[1] = 7)); echo "${x[@]}" ### expect 5 @@ -228,7 +211,6 @@ x=4; echo $((x[0] + 1)); ((x[1] = 7)); echo "${x[@]}" ### bashbox_local_scope_declare_a_converts_a_scalar # declare -a converts a scalar -### skip: TODO bashbox corpus gap, bashkit output differs from real bash x=v; declare -a x; declare -p x ### expect declare -a x=([0]="v") @@ -244,7 +226,6 @@ q ### bashbox_local_scope_local_lists_the_function_locals # local lists the function locals -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local x; local -a a=(1); local y=2 z; local -r r=1; local; }; f ### expect declare -a a=([0]="1") @@ -256,7 +237,6 @@ declare -- z ### bashbox_local_scope_declare_p_of_a_local_without_a_value # declare -p of a local without a value -### skip: TODO bashbox corpus gap, bashkit output differs from real bash f(){ local x2; declare -p x2; }; f ### expect declare -- x2 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-read-names.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-read-names.test.sh index 785ea0f3f..e8560179f 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-read-names.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-read-names.test.sh @@ -4,7 +4,6 @@ ### bashbox_read_names_an_option_after_a_name_is_an_invalid_identifier # an option after a name is an invalid identifier -### skip: TODO bashbox corpus gap, bashkit output differs from real bash read a -r <<< 'x y'; echo "$? [$a]" ### expect 1 [x] @@ -12,7 +11,6 @@ read a -r <<< 'x y'; echo "$? [$a]" ### bashbox_read_names_names_before_a_bad_one_are_assigned # names before a bad one are assigned -### skip: TODO bashbox corpus gap, bashkit output differs from real bash read a 1x b <<< 'x y z'; echo "$? [$a] [$b]" ### expect 1 [x] [] @@ -20,7 +18,6 @@ read a 1x b <<< 'x y z'; echo "$? [$a] [$b]" ### bashbox_read_names_a_bad_name_is_reported_at_end_of_input_too # a bad name is reported at end of input too -### skip: TODO bashbox corpus gap, bashkit output differs from real bash read a -r < /dev/null; echo $? ### expect 1 @@ -28,7 +25,6 @@ read a -r < /dev/null; echo $? ### bashbox_read_names_an_array_element_is_a_valid_name # an array element is a valid name -### skip: TODO bashbox corpus gap, bashkit output differs from real bash read 'a[1]' b <<< 'x y'; echo "$? [${a[1]}] [$b]" ### expect 0 [x] [y] @@ -36,7 +32,6 @@ read 'a[1]' b <<< 'x y'; echo "$? [${a[1]}] [$b]" ### bashbox_read_names_read_a_needs_a_plain_name # read -a needs a plain name -### skip: TODO bashbox corpus gap, bashkit output differs from real bash read -a 1x <<< 'x'; echo $?; read -a 'a[1]' <<< 'x'; echo $? ### expect 1 @@ -45,7 +40,6 @@ read -a 1x <<< 'x'; echo $?; read -a 'a[1]' <<< 'x'; echo $? ### bashbox_read_names_read_n_with_a_bad_name # read -N with a bad name -### skip: TODO bashbox corpus gap, bashkit output differs from real bash read -N 2 a 1x <<< 'xyz'; echo "$? [$a]" ### expect 1 [xy] @@ -53,7 +47,6 @@ read -N 2 a 1x <<< 'xyz'; echo "$? [$a]" ### bashbox_read_names_read_n_into_an_array # read -N into an array -### skip: TODO bashbox corpus gap, bashkit output differs from real bash read -N 3 -a arr <<< 'x y'; echo "$? [${arr[0]}] ${#arr[@]}" ### expect 0 [x y] 1 @@ -61,7 +54,6 @@ read -N 3 -a arr <<< 'x y'; echo "$? [${arr[0]}] ${#arr[@]}" ### bashbox_read_names_read_n_a_with_a_bad_name # read -N -a with a bad name -### skip: TODO bashbox corpus gap, bashkit output differs from real bash read -N 1 -a 1x <<< 'x'; echo $? ### expect 1 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-shell-variables.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-shell-variables.test.sh index 0a994fee6..d083dc2e5 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-shell-variables.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-shell-variables.test.sh @@ -4,7 +4,6 @@ ### bashbox_shell_variables_bash_subshell_counts_subshells_and # BASH_SUBSHELL counts subshells, $(...) and <(...) -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo $BASH_SUBSHELL; (echo $BASH_SUBSHELL; (echo $BASH_SUBSHELL)); echo $(echo $BASH_SUBSHELL); x=$( (echo $BASH_SUBSHELL) ); echo $x; cat <(echo $BASH_SUBSHELL) ### expect 0 @@ -17,7 +16,6 @@ echo $BASH_SUBSHELL; (echo $BASH_SUBSHELL; (echo $BASH_SUBSHELL)); echo $(echo $ ### bashbox_shell_variables_unsetting_them_works_like_bash # unsetting them works like bash -### skip: TODO bashbox corpus gap, bashkit output differs from real bash unset HOME USER PATH IFS; echo "[$HOME|$USER|$PATH|$IFS]" "${IFS-unset}"; cd; echo $? x="a b c"; for w in $x; do echo "<$w>"; done; set -- p q; echo "$*" IFS=; for w in $x; do echo "<$w>"; done; echo "$*" @@ -51,7 +49,6 @@ false; echo ${PIPESTATUS[@]}; ! true; echo ${PIPESTATUS[@]}; f() { return 3; }; ### bashbox_shell_variables_an_assignment_and_a_subshell_each_set_it # an assignment, ((...)), [[...]] and a subshell each set it -### skip: TODO bashbox corpus gap, bashkit output differs from real bash false | true; x=$(false); echo ${PIPESTATUS[@]}; (( 0 )); echo ${PIPESTATUS[@]}; [[ a == a ]]; echo ${PIPESTATUS[@]}; (exit 3); echo ${PIPESTATUS[@]} ### expect 1 diff --git a/crates/bashkit/tests/spec_cases/bash/unset-exported-var.test.sh b/crates/bashkit/tests/spec_cases/bash/unset-exported-var.test.sh index 5c1a484d2..a2a5fc847 100644 --- a/crates/bashkit/tests/spec_cases/bash/unset-exported-var.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/unset-exported-var.test.sh @@ -8,7 +8,6 @@ gone ### end ### unset_then_check_defined -### skip: [[ -v VAR ]] unary operator not yet implemented # unset var should not be -v defined export UNSET_DEF_TEST=x unset UNSET_DEF_TEST diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index d28068009..0bd7d8ab4 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -235,6 +235,37 @@ Internal builtins that need interpreter state receive it via `Context.shell`: - `let`, arithmetic evaluation with assignment - `getopts`, complex variable + call stack interaction +### Declaration builtins and variable scope + +`declare`/`typeset`, `local`, `export` and `readonly` share one engine +(`interpreter/declare.rs`, `execute_declaration_builtin`); they differ only in +the option letters they accept (declare/local `aAfFgiIlnprtux`, export `fnp`, +readonly `aAfp`; others exit 2 with usage). Options are read only before the +first operand. `declare -p`/`set` listings use bash's quoting (`$'...'` for +control bytes) and print associative arrays in bash's hash-bucket order +(FNV-1 32-bit, `h & 1023`), so listings diff cleanly against real bash. + +Locals use **shallow binding**, like bash: the live maps (`variables`, +`var_attrs`, `namerefs`, arrays, `env`) always hold the visible binding; +`local x` saves the caller's binding in the frame (`CallFrame::saved_vars`) +and function return restores it. Lookups never walk frames. `unset` of a +local belonging to a calling frame pops that local and uncovers the outer +value (bash's dynamic-scope unset); `declare -g` writes the outermost saved +binding when a local hides the global. Declared-but-unassigned variables carry +`VarAttrs::NOVALUE` (`declare x; declare -p x` prints `declare -- x`). + +Compound operands of declaration builtins (`declare -A m=([k]=v)`) are kept +as a `WordPart::CompoundAssignment` so their elements expand as array +elements (no word splitting of the whole operand, keyed `[k]=v` split after +expansion), and script analysis walks the substitutions inside them. + +Bash quirks emulated on purpose: `local` outside a function fails with +status 1 but still assigns its compound operands as globals; prefix +assignments (`x=v cmd`) ignore `-i/-l/-u`; assigning a readonly variable +without a command abandons the rest of the line (`r=3 cmd` reports and still +runs `cmd`); assigning through a circular nameref warns and abandons the +line. + `time` is deliberately absent from the builtin registry. Bash grammar makes it a reserved-word wrapper around a complete pipeline, so the interpreter measures the AST directly. This preserves groups, functions, pipeline status, redirects, diff --git a/knowledge/foundations/parser.md b/knowledge/foundations/parser.md index c3c75becb..6048a206f 100644 --- a/knowledge/foundations/parser.md +++ b/knowledge/foundations/parser.md @@ -91,8 +91,22 @@ against GNU bash 5.2. ### Arithmetic Expressions -`$((expr))` supports: `+`, `-`, `*`, `/`, `%`, comparisons, logical `&&`/`||` -(short-circuit), bitwise operators, ternary `?:`, variable references. +`interpreter/arithmetic.rs` tokenizes the expression, then evaluates it with a +precedence-climbing parser following bash's `expr.c` grammar (comma, assignment +ops, `?:`, `||`, `&&`, bitwise, equality, relational, shifts, `+ -`, `* / %`, +right-associative `**`, unary, pre/post `++ --`). Numbers take `0x`, octal and +`base#digits` (bases 2..64). Short-circuit branches parse without evaluating +(`noeval`); writes are collected and applied after a successful evaluation. + +The body of a `(( ... ))` command is read from source as raw text +(`Lexer::read_arith_raw`), so `<<=` or `>>` never become redirect tokens. + +Errors follow bash: `division by 0`, `syntax error`, `bad array subscript`, +`exponent less than 0`, `expression recursion level exceeded`, invalid base. +In `$((...))` or an arithmetic subscript the error aborts the rest of the +current line (`ControlFlow::Abort` / `Error::LineAbort`): at the top level the +shell continues with the next line, inside a function or subshell the whole +compound stops, status 1. `((...))` and `let` just return status 1. ### Error Recovery diff --git a/knowledge/log.md b/knowledge/log.md index 25f79e3a6..0e25bed39 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -72,6 +72,11 @@ * **Decision**: CPython stdlib ships as bytecode only. Non-preloaded imports compiled source on Pulley every call (3-7 s each); unchecked-hash `.pyc` cut that ~10x. Unusable modules (`ctypes`, `ssl`, network clients/servers, `pdb`, `pydoc`, `_pyrepl`) and pure-Python C twins dropped so the crate stays under the 10 MiB cap. Dropping preloaded modules was rejected: ~1-2 ms first-call gain, seconds per import lost. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md), [Limitations](operations/limitations.md) L-CPY-009. * **Performance**: Profiled CPython start. A warm `python3 -c 'print(1)'` was ~4.5 ms of Pulley interpreting ~500K guest instructions plus ~1.5 ms in ~340 copy-on-write faults; the first call adds ~13 ms of one-time memory-image build (the 40 MB snapshot span copied into a memfd). Snapshot objects are now immortal (no refcount writes, faults -20%) and the per-call environment is installed from C; warm calls went from ~6.2 to ~5.1 ms. Native AOT instead of Pulley measured 2.3 ms per call but needs executable memory and a 41 MB copy on first load; not taken yet. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md). * **Fix**: Coreutils parity from the BashBox corpus (bash-verified cases unskipped). `sort` follows GNU keys/ordering (`-k` fields and chars, `-b/-d/-f/-g/-h/-i/-M/-n/-R/-r/-V`, last-resort compare, stable `-s`, `-u` on key equality, `-m`, `-c/-C`); `uniq` adds `-s/-w/-D/-z`; `tr` decodes octal escapes and adds `-t`; `cut` keeps lines without the delimiter; `expand`/`unexpand` follow GNU tab-stop lists; `xargs` ports GNU input parsing (quotes, `-E`, `-L`, `-s`, `-d`, `-a`, 123/124 exits); `grep` uses the shared POSIX translator with real `-w` and newline-separated patterns; `printf` keeps `\c` literal in FORMAT and pads `%b`/`%q`; `echo -e` adds `\e`, `\u`, `\U`; `seq` matches GNU precision, `-w` and `-f`; `paste` shares one stdin cursor across `-` operands and adds `-z`; `nl` implements logical-page sections, `-d/-h/-f/-l/-p` and `pBRE`; `fold` uses GNU's column model; `comm` adds `-z`, `--output-delimiter`, `--total`; `tac` implements `-s/-b/-r`; `sleep` takes unit suffixes, hex and summed operands; `stat` adds `%N`; `od` ports GNU's block layout (multiple `-t`, sizes, `z`, `*` runs, default `-t o2`); `find` adds `-fprint*`/`-fls`; `du` prints operand-relative paths with GNU options and rounding; `date`, `base64`, `head`/`tail`, `basename`, `rmdir`, `mktemp` parse GNU options; `cp -r` copies trees (it failed on every directory). Options go through the new `gnu_getopt` helper (see [Builtins](foundations/builtins.md#gnu-option-parsing)). New limitations L-FIND-003, L-DU-001, L-OD-001, L-FOLD-001. +* **Fix**: Array-literal words brace-expand, then glob, as in bash, for `a=(...)`, `a+=(...)`, `declare -a`/`typeset -a` and `local -a`. `a=("x"{1,2})` gives `x1 x2` (mixed quoted words were left literal), quoted words stay literal, `nullglob` drops unmatched patterns. Keyed elements follow bash: an assoc value stays literal (`declare -A m=([k]=v{1,2})` stored `[k]=v [1]=2`), while an indexed `[2]=v{1,2}` brace-expands into the plain words `[2]=v1 [2]=v2`. The array-entry budget still caps expansion. +* **Fix**: Shell options and special variables follow bash. `set` knows every `set -o` option and letter (`-hkBEHPT...`, recorded even where they have no effect), validates the whole command before changing anything (`set -ez` and `set -o bogus -u` change nothing, exit 2), lets each `o` in a cluster take the next argument (`set -euo pipefail`) and lists options for a trailing `-o`; `shopt -o`/`-po` work on them. `$-` prints letters in bash order with the defaults `h`, `B` and the `c` of `bash -c` (`ehuBc`). `$BASH_SUBSHELL` (main's counter) also counts `<(...)`, and `set +h`/`+B` turn those `$-` letters off. `( )`, `[[ ]]` and `(( ))` set `PIPESTATUS`. A subshell's `export` no longer leaks into the parent (`env` is now snapshotted with subshell state, as an `Arc`). `local LINENO` is an ordinary variable; `cd` with HOME unset fails. Known gap: in CLI streaming mode a multi-command `$(...)` body still streams its output. +* **Fix**: Arrays, scalars and `read` names follow bash. A scalar is element 0 of an array (`x=5; echo ${x[@]} ${#x[@]} ${!x[@]} ${x[0]}` prints `5 1 0 5`, `((x[1]=7))` promotes it); `${a[@]}`/`${!a[@]}` of an associative array use bash's hash order, like `declare -p`. `[[ -v name ]]` was missing (always true); it now tests set-ness, element 0 for an array, `name[sub]` and `name[@]`. `${!ref}` gives the end of a nameref chain, a nameref `for` variable is re-pointed at each word, and `read` through a circular nameref fails with status 1. `read` takes options only before the first name, stops with status 1 at an invalid name (earlier names keep their fields), validates `-a` names, and supports `-N` (exact count, no splitting). +* **Fix**: Declaration builtins and local scope match bash. `declare`/`typeset`/`local`/`export`/`readonly` share one engine with bash's option sets, errors and `declare -p` format (quoting, assoc hash order); locals use shallow binding saved in the call frame, so nested functions, `unset` of a caller's local, `declare -g` past a local, and `local -n` behave as in bash. Compound operands stay one `CompoundAssignment` word part (keyed `[k]="v w"` and `` [k]=`cmd` `` elements lex as one word). Readonly assignment and circular namerefs abandon the line. See [Builtins](foundations/builtins.md#declaration-builtins-and-variable-scope). +* **Fix**: Arithmetic follows bash's grammar and error model. New tokenizer + precedence-climbing evaluator (`interpreter/arithmetic.rs`): comma lists, chained/compound assignment, `**` right-assoc, `base#n` up to base 64, `noeval` short-circuit. `$((1/0))`, syntax errors and bad subscripts now print bash's message and abort the rest of the line (status 1) instead of yielding 0; `((...))` and `let` return 1. `((z <<= 65))` is read as raw text, not a heredoc. Bounds unchanged (`MAX_ARITHMETIC_DEPTH`, fuel; TM-DOS-026). See [Parser](foundations/parser.md#arithmetic-expressions). ## 2026-10-06 diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 2cbd83155..f8b59c102 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -103,7 +103,6 @@ Target: IEEE 1003.1-2024 Shell Command Language. | Feature | What Works | What's Missing | |---------|------------|----------------| | Prefix env assignments | `VAR=val cmd` temporarily sets env for cmd | Array prefix assignments not in env | -| `local` | Declaration | Proper scoping in nested functions | | `return` | Basic usage | Return value propagation | | `time` | Reserved-word pipelines; `-p`, `--`, GNU `-f/-o/-a/-v`; elapsed time, exit status, and Bashkit counters | Host user/system CPU, RSS, and other process metrics are reported as `unavailable`, never fabricated | | `timeout` | Basic usage | `-k` kill timeout |