From 82a203e22c66ef18bf42631ec267eb1066108b9e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 16:35:50 +0000 Subject: [PATCH 01/11] test(spec): import 1,044 bash-verified cases from the BashBox test suite Copies (script, stdout) pairs from shipfastlabs/bashbox (MIT) into spec_cases/bash/bashbox-*.test.sh, keeping only cases whose recorded output matches host bash. 624 pass and join the bash comparison gate; 420 are marked TODO skips: real gaps where bashkit differs from bash. Attribution in NOTICE; process in knowledge/operations/testing.md. --- NOTICE | 12 + THIRD_PARTY_LICENSES/README.md | 2 +- .../bash/bashbox-ansi-c-quoting.test.sh | 45 + .../bash/bashbox-arithmetic-array.test.sh | 103 ++ .../bash/bashbox-attributes.test.sh | 313 +++++ .../spec_cases/bash/bashbox-base64.test.sh | 10 + .../tests/spec_cases/bash/bashbox-cat.test.sh | 56 + .../spec_cases/bash/bashbox-checksum.test.sh | 30 + .../spec_cases/bash/bashbox-chmod.test.sh | 9 + .../spec_cases/bash/bashbox-comm.test.sh | 24 + .../bash/bashbox-compound-syntax.test.sh | 74 ++ .../tests/spec_cases/bash/bashbox-cut.test.sh | 31 + .../spec_cases/bash/bashbox-date.test.sh | 26 + .../spec_cases/bash/bashbox-declare.test.sh | 414 +++++++ .../tests/spec_cases/bash/bashbox-du.test.sh | 24 + .../spec_cases/bash/bashbox-echo.test.sh | 66 ++ .../bash/bashbox-env-commands.test.sh | 18 + .../spec_cases/bash/bashbox-err-trap.test.sh | 189 +++ .../spec_cases/bash/bashbox-expand.test.sh | 65 + ...ox-filesystem-backends-integration.test.sh | 10 + .../bash/bashbox-find-predicates.test.sh | 41 + .../spec_cases/bash/bashbox-find.test.sh | 72 ++ .../spec_cases/bash/bashbox-fold.test.sh | 69 ++ .../spec_cases/bash/bashbox-grep.test.sh | 143 +++ .../spec_cases/bash/bashbox-head-tail.test.sh | 35 + .../bash/bashbox-interpreter-builtins.test.sh | 533 +++++++++ .../bash/bashbox-interpreter-core.test.sh | 1053 +++++++++++++++++ .../bash/bashbox-just-bash-compat.test.sh | 403 +++++++ .../spec_cases/bash/bashbox-lexer.test.sh | 260 ++++ .../bash/bashbox-line-number.test.sh | 71 ++ .../bash/bashbox-local-scope.test.sh | 263 ++++ .../tests/spec_cases/bash/bashbox-ls.test.sh | 9 + .../bash/bashbox-marker-bytes.test.sh | 50 + .../spec_cases/bash/bashbox-named-fd.test.sh | 186 +++ .../tests/spec_cases/bash/bashbox-nl.test.sh | 44 + .../tests/spec_cases/bash/bashbox-od.test.sh | 26 + .../spec_cases/bash/bashbox-options.test.sh | 101 ++ .../spec_cases/bash/bashbox-parser.test.sh | 235 ++++ .../spec_cases/bash/bashbox-paste.test.sh | 46 + .../spec_cases/bash/bashbox-path-name.test.sh | 34 + .../spec_cases/bash/bashbox-printf.test.sh | 184 +++ .../bash/bashbox-process-substitution.test.sh | 149 +++ .../bash/bashbox-read-names.test.sh | 68 ++ .../spec_cases/bash/bashbox-rmdir.test.sh | 11 + .../bash/bashbox-script-file.test.sh | 73 ++ .../bash/bashbox-secure-http-client.test.sh | 9 + .../spec_cases/bash/bashbox-security.test.sh | 22 + .../tests/spec_cases/bash/bashbox-sed.test.sh | 794 +++++++++++++ .../tests/spec_cases/bash/bashbox-seq.test.sh | 79 ++ .../bash/bashbox-shell-variables.test.sh | 86 ++ .../bash/bashbox-shopt-glob.test.sh | 89 ++ .../bash/bashbox-shopt-pattern.test.sh | 144 +++ .../spec_cases/bash/bashbox-shopt.test.sh | 70 ++ .../spec_cases/bash/bashbox-sleep.test.sh | 68 ++ .../spec_cases/bash/bashbox-sort.test.sh | 559 +++++++++ .../spec_cases/bash/bashbox-stat.test.sh | 19 + .../bash/bashbox-syntax-error.test.sh | 113 ++ .../tests/spec_cases/bash/bashbox-tac.test.sh | 26 + .../spec_cases/bash/bashbox-temp-path.test.sh | 10 + .../tests/spec_cases/bash/bashbox-tr.test.sh | 89 ++ .../bash/bashbox-type-printer.test.sh | 507 ++++++++ .../spec_cases/bash/bashbox-unexpand.test.sh | 115 ++ .../spec_cases/bash/bashbox-uniq.test.sh | 88 ++ .../spec_cases/bash/bashbox-utf8.test.sh | 78 ++ .../tests/spec_cases/bash/bashbox-wc.test.sh | 11 + .../bash/bashbox-word-expander.test.sh | 310 +++++ .../spec_cases/bash/bashbox-xargs.test.sh | 344 ++++++ knowledge/operations/testing.md | 20 + 68 files changed, 9329 insertions(+), 1 deletion(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-ansi-c-quoting.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-arithmetic-array.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-cat.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-checksum.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-chmod.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-cut.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-du.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-env-commands.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-err-trap.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-filesystem-backends-integration.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-head-tail.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-builtins.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-core.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-line-number.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-local-scope.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-ls.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-marker-bytes.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-named-fd.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-nl.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-od.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-options.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-parser.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-paste.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-path-name.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-printf.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-process-substitution.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-read-names.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-rmdir.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-script-file.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-secure-http-client.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-security.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-sed.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-seq.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-shell-variables.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-shopt-glob.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-shopt-pattern.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-shopt.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-sleep.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-sort.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-tac.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-temp-path.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-tr.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-type-printer.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-utf8.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-wc.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-word-expander.test.sh create mode 100644 crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh diff --git a/NOTICE b/NOTICE index 92c390b33..b10cdc157 100644 --- a/NOTICE +++ b/NOTICE @@ -58,6 +58,18 @@ Our jq builtin aims for compatibility with jq's query syntax and behavior. The implementation uses the jaq Rust crates (MIT licensed) rather than jq source code directly. +-------------------------------------------------------------------------------- +BashBox +-------------------------------------------------------------------------------- +Copyright (c) Pushpak Chhajed +Licensed under the MIT License + +Repository: https://github.com/shipfastlabs/bashbox + +The spec cases in crates/bashkit/tests/spec_cases/bash/bashbox-*.test.sh are +copied from BashBox's test suite (script and expected stdout, recorded from +GNU bash). The full MIT license text is in THIRD_PARTY_LICENSES/MIT.txt. + ================================================================================ RUST DEPENDENCIES ================================================================================ diff --git a/THIRD_PARTY_LICENSES/README.md b/THIRD_PARTY_LICENSES/README.md index f67432975..f1d20ed7e 100644 --- a/THIRD_PARTY_LICENSES/README.md +++ b/THIRD_PARTY_LICENSES/README.md @@ -24,7 +24,7 @@ Bashkit's design or whose test case formats have inspired our testing approach. | File | Projects | License Type | |------|----------|--------------| | `APACHE-2.0.txt` | just-bash, Oils | Apache License 2.0 | -| `MIT.txt` | jq, jaq crates, most dependencies | MIT License | +| `MIT.txt` | jq, jaq crates, BashBox spec cases, most dependencies | MIT License | | `LUCENT.txt` | One True AWK | Lucent Public License | ## Full Dependency Licenses diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-ansi-c-quoting.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-ansi-c-quoting.test.sh new file mode 100644 index 000000000..f3c466baa --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-ansi-c-quoting.test.sh @@ -0,0 +1,45 @@ +# BashBox ansi-c-quoting cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_ansi_c_quoting_the_result_is_quoted_text +# the result is quoted text +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=$'a\nb'; echo "$x"; echo "$'q'" '$'"'q'"; echo $"dq $x"; echo $'it\'s' $'a''b' x$'\t'y; touch zz; echo $'z'* $'*' +### expect +a +b +$'q' $'q' +dq a +b +it's ab x y +zz * +### end + +### bashbox_ansi_c_quoting_type_shows_it_single_quoted +# type shows it single-quoted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { echo $'a\tb' $'it\'s' "$'x'" $"y"; } +type f +### expect +f is a function +f () +{ + echo 'a b' 'it'\''s' "$'x'" "y" +} +### end + +### bashbox_ansi_c_quoting_here_documents_expand_as_in_double_quotes_quotes_stay +# here-documents expand as in double quotes, quotes stay +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; cat <> 70 )) $(( 1 << -1 )) $(( 8 >> -62 )); z=1; ((z <<= 65)); echo $z +### expect +0 -420491770248316829 -9223372036854775808 1 1 -9223372036854775808 -1 -9223372036854775808 2 +2 +### end + +### 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 +1 +0 2 +1 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh new file mode 100644 index 000000000..8c48436ab --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh @@ -0,0 +1,313 @@ +# BashBox attributes cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_attributes_a_plain_assignment_is_not_exported +# a plain assignment is not exported +FOO=1; printenv FOO; echo $? +### expect +1 +### end + +### 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 +3 +### end + +### 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 +### end + +### 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 +1 +### end + +### bashbox_attributes_unset_drops_the_export_attribute +# unset drops the export attribute +export x=1; unset x; x=2; printenv x; echo $? +### expect +1 +### end + +### 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 +### end + +### 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" +declare -x S +declare -rx R="1" +declare -x S +### end + +### 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 +### end + +### 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 +ahBc +1 +### end + +### 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" +declare -x r="hi" +declare -a arr=([0]="1") +### end + +### bashbox_attributes_prefix_assignments_are_exported_for_one_command +# prefix assignments are exported for one command +FOO=1 printenv FOO; printenv FOO; echo $?; f(){ printenv V; }; V=7 f; printenv V; echo $? +### expect +1 +1 +7 +1 +### end + +### bashbox_attributes_a_prefix_assignment_to_an_exported_variable_restores_its_val +# a prefix assignment to an exported variable restores its value +export V=0; V=1 printenv V; printenv V +### expect +1 +0 +### end + +### 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") +declare -A m=([k]="v" ) +### end + +### 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 +declare -A n=([x]="1" ) +declare -A m=([a]="1" ) +### end + +### 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 +### end + +### 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 +### end + +### 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 +declare -r r="1" +### end + +### 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 +1 +### end + +### 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 +### end + +### 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") +### end + +### 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 +declare -rx V +### end + +### 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" +declare -a a=([0]="q\"") +### end + +### 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 +1 +### end + +### 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 +FOO=1 +2 +1 +1 +### end + +### bashbox_attributes_options_end_at +# options end at -- +export -- G=1; printenv G +### expect +1 +### end + +### 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 +set -o pipefail +### end + +### 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 +ehBc +### end + +### 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 +nounset on +xtrace off +### end + +### bashbox_attributes_o_at_the_end_prints_them_as_commands +# +o at the end prints them as commands +set -e +o | grep -E 'errexit|pipefail' +### expect +set -o errexit +set +o pipefail +### end + +### 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 +### end + +### 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 +### end + +### bashbox_attributes_a_bad_option_inside_is_reported_as +# a bad option inside -- is reported as - +set --bogus; echo $? +### expect +2 +### end + +### 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 +### end + +### bashbox_attributes_words_after_the_options_become_positional_parameters +# words after the options become positional parameters +set -e a b; echo "$@"; set -ex -- c d; echo "$@" +### expect +a b +c d +### end + +### bashbox_attributes_with_nothing_after_it_clears_them +# -- with nothing after it clears them +set -- a b; set --; echo $# +### expect +0 +### end + +### bashbox_attributes_a_lone_keeps_them +# a lone - keeps them +set -- a b; set -; echo $# "$@"; set - c; echo "$@" +### expect +2 a b +c +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh new file mode 100644 index 000000000..3887babb1 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh @@ -0,0 +1,10 @@ +# BashBox base64 cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_base64_empty_input_encodes_to_nothing +# empty input encodes to nothing +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '' | base64 +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-cat.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-cat.test.sh new file mode 100644 index 000000000..2da16f3fe --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-cat.test.sh @@ -0,0 +1,56 @@ +# BashBox cat cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_cat_b_numbers_only_non_blank_lines_and_wins_over_n +# -b numbers only non-blank lines and wins over -n +printf 'a\n\nb\n' | cat -nb +### expect + 1 a + + 2 b +### end + +### bashbox_cat_s_squeezes_blank_runs_across_files +# -s squeezes blank runs across files +printf 'a\n\n' > c1; printf '\n\nb\n' > c2; cat -sn c1 c2 +### expect + 1 a + 2 + 3 b +### end + +### bashbox_cat_t_shows_tabs +# -T shows tabs +printf 'a\tb\n' | cat -T +### expect +a^Ib +### end + +### bashbox_cat_v_shows_control_and_high_bytes +# -v shows control and high bytes +printf 'a\t\001\177\200\211\240\377\n' | cat -v +### expect +a ^A^?M-^@M-^IM- M-^? +### end + +### bashbox_cat_a_is_vet +# -A is -vET +printf 'a\tb\r\n' | cat -A +### expect +a^Ib^M$ +### end + +### bashbox_cat_e_is_ve +# -e is -vE +printf '\ta\r\n' | cat -e +### expect + a^M$ +### end + +### bashbox_cat_t_is_vt +# -t is -vT +printf '\ta\r\n' | cat -t +### expect +^Ia^M +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-checksum.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-checksum.test.sh new file mode 100644 index 000000000..d340a1697 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-checksum.test.sh @@ -0,0 +1,30 @@ +# BashBox checksum cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_checksum_stdin +# stdin +printf 'hello\n' | md5sum +### expect +b1946ac92492d2347c6235b4d2611184 - +### end + +### bashbox_checksum_newline_in_name +# newline in name +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf z > $'n\nl'; md5sum $'n\nl' +### expect +\fbade9e36a3f36d3d676c1b808451dd7 n\nl +### end + +### bashbox_checksum_check_quiet +# check quiet +md5sum --quiet -c good.md5 +### expect +### end + +### bashbox_checksum_check_status_ok +# check status ok +md5sum --status -c good.md5 +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-chmod.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-chmod.test.sh new file mode 100644 index 000000000..57e662cb6 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-chmod.test.sh @@ -0,0 +1,9 @@ +# BashBox chmod cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_chmod_f_stays_quiet_but_still_fails +# -f stays quiet but still fails +chmod -f 644 nope; ln -s nope dl; chmod -f 644 dl +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh new file mode 100644 index 000000000..6d94cd6a7 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh @@ -0,0 +1,24 @@ +# BashBox comm cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_comm_suppress_all +# suppress all +comm -123 a b +### expect +### end + +### bashbox_comm_zero_terminated +# zero terminated +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\0b\0' > z1; printf 'b\0c\0' > z2; comm -z z1 z2 | od -c +### expect +0000000 a \0 \t \t b \0 \t c \0 +0000011 +### end + +### bashbox_comm_empty_files +# empty files +printf '' > e; comm e e +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh new file mode 100644 index 000000000..ec666839c --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-compound-syntax.test.sh @@ -0,0 +1,74 @@ +# BashBox compound-syntax cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_compound_syntax_until_loop +# until loop +i=0; until (( i >= 3 )); do echo $i; i=$((i+1)); done +### expect +0 +1 +2 +### end + +### bashbox_compound_syntax_c_style_for +# c-style for +for ((i=0; i<6; i+=2)); do echo $i; done +### expect +0 +2 +4 +### end + +### bashbox_compound_syntax_case +# [[ && ( || ) ]] +[[ -n a && ( -z "" || x == y ) ]] && echo yes +### expect +yes +### end + +### bashbox_compound_syntax_case_2 +# [[ || ! ]] +[[ a == b || ! -n "" ]]; echo $?; [[ a == b || -z x ]]; echo $? +### expect +0 +1 +### end + +### bashbox_compound_syntax_bare_word +# [[ bare word ]] +[[ word ]] && echo set; [[ "" ]] || echo empty +### expect +set +empty +### end + +### bashbox_compound_syntax_quoted_and_escaped_parens_inside +# quoted and escaped parens inside $(...) +echo $(echo 'a)' "b)" \) ) +### expect +a) b) ) +### end + +### bashbox_compound_syntax_a_brace_inside_inside +# a brace inside $(...) inside ${...} +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo ${x:-$(echo })} +### expect +} +### end + +### bashbox_compound_syntax_quoted_and_escaped_braces_inside +# quoted and escaped braces inside ${...} +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +y=ab; echo ${x:-"}"} ${x:-\}} ${x:-'a}'} ${y/a/\}} "${y/b/'}'}" +### expect +} } a} }b a} +### end + +### bashbox_compound_syntax_single_quotes_stay_literal_in_a_double_quoted_default_word +# single quotes stay literal in a double-quoted default word +y=1; echo "${x:-'}'}" "${x:-"a b"}" "${x:-'a'"b"}" "${y:+'a'}" +### expect +'}' a b 'a'b 'a' +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-cut.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-cut.test.sh new file mode 100644 index 000000000..9bb3bccd0 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-cut.test.sh @@ -0,0 +1,31 @@ +# BashBox cut cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_cut_fields_with_a_delimiter +# fields with a delimiter +printf 'a:b:c:d\nnodelim\n' | cut -d: -f1,3- +### expect +a:c:d +nodelim +### end + +### bashbox_cut_fields_default_to_tab_delimited +# fields default to tab-delimited +printf 'a\tb\n' | cut -f2 +### expect +b +### end + +### bashbox_cut_separate_delimiter_argument_and_open_start_range +# separate delimiter argument and open start range +echo a:b:c | cut -d : -f -2 +### expect +a:b +### end + +### bashbox_cut_empty_input +# empty input +printf '' | cut -f1 +### expect +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh new file mode 100644 index 000000000..0a00e27d3 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh @@ -0,0 +1,26 @@ +# BashBox date cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_date_default_format +# default format +date -u -d @0 +### expect +Thu Jan 1 00:00:00 UTC 1970 +### end + +### bashbox_date_combined_ud +# combined -ud +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +date -ud @86400; date -ud@86400 +%F +### expect +Fri Jan 2 00:00:00 UTC 1970 +1970-01-02 +### end + +### bashbox_date_parses_date_strings +# parses date strings +date -u -d '2024-02-29 13:05:09' '+%j %H:%M:%S' +### expect +060 13:05:09 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh new file mode 100644 index 000000000..1d2b1da2f --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh @@ -0,0 +1,414 @@ +# BashBox declare cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### 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 +6 +0 +0 +declare -i x="0" +### end + +### 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") +### end + +### bashbox_declare_i_reads_and_let_agree +# -i reads and let/(( )) agree +declare -i x; let x=4; ((x=x+1)); echo $x; read x <<< 2+2; echo $x +### expect +5 +4 +### end + +### 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 +declare -i x; (read x <<< 1/0); (printf -v x 1/0); echo $? +### expect +1 +### end + +### 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 +### end + +### 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 +1+1 +### end + +### 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 +### end + +### 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 +ABC +MIXED +declare -l x="abcdef" +declare -u y="MIXED" +### end + +### 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 +CD +eF +declare -- x="Gh" +### end + +### 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") +### end + +### 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" +declare -Aix m +declare -nr q="z" +### end + +### 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 +declare -A f +declare -i g +declare -a e=([0]="q") +1 +### end + +### 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" ) +declare -a y=([0]="2") +declare -a z=([0]="v") +### end + +### 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 +1 +declare -a a=([0]="1" [1]="2") +declare -A m +### end + +### 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 +next 1 +declare -a a=([0]="1") +### end + +### 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") +declare -A M +declare -i I="2" +declare -ai J=([0]="1") +### end + +### 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 +7 +7x +a +declare -n r="a" +declare -- a="7x" +### end + +### 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 +### end + +### 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 +declare -a a=([1]="x") +### end + +### bashbox_declare_a_nameref_to_an_array +# a nameref to an array +declare -n r=arr; r=(1 2 3); echo ${r[1]} ${#r[@]} "${!r[@]}"; r[1]=b; declare -p arr +### expect +2 3 0 1 2 +declare -a arr=([0]="1" [1]="b" [2]="3") +### end + +### 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" +declare -i x="2" +### end + +### 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" +### end + +### 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" +[x] +### end + +### 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" +declare -- b="v" +declare -n r="b" +### end + +### bashbox_declare_local_n_refers_to_the_caller_s_variable +# local -n refers to the caller's variable +f() { local -n r=$1; r=hi; }; f v; echo $v +### expect +hi +### end + +### 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 +1 +1 +### end + +### 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 +declare -- x="1" +declare -n y="foo" +### end + +### bashbox_declare_in_a_function_a_self_reference_only_warns +# in a function a self reference only warns +f() { local -n r=r; echo $?; }; f +### expect +0 +### end + +### 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 +declare -n a="b" +declare -n b="a" +### end + +### 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 +next 1 +### end + +### 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 +### end + +### 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 +### end + +### 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 +declare -Ar m=([k]="v" ) +declare -r m2 +### end + +### 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 +declare -ar a=([0]="1") +### end + +### 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") +declare -ar b=([0]="3") +### end + +### 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 +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' +declare -- y=$'é\n' +declare -- z=$'\377' +declare -a a=([0]=$'x\ny' [1]="z") +### end + +### 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" ) +declare -A n=(["\$x"]="2" ) +declare -A o=([c]="3" ) +### end + +### bashbox_declare_declare_p_with_no_names_lists_every_variable +# declare -p with no names lists every variable +unset PIPESTATUS; x=1; declare -p | grep -E '^declare -- x=' +### expect +declare -- x="1" +### end + +### 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 +### end + +### 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 +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 +] +a +declare -a a=([2]="z") +### end + +### 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 +printf -v x -- %s a; echo $x; f() { local y; printf -v y %s in; echo $y; }; f; echo ${y-unset} +### expect +a +in +unset +### end + +### bashbox_declare_printf_v_errors +# printf -v errors +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf -v; echo $?; printf -v x; echo $? ${x-unset}; printf -v 1x %s a; echo $?; printf -v 'a[' %s x; echo $? +### expect +2 +2 unset +2 +2 +### end + +### 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 +readonly r; printf -v r %s a; echo $? +### expect +1 +### end + +### 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 +printf -- -v; echo +### expect +-v +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-du.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-du.test.sh new file mode 100644 index 000000000..9f6a6e998 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-du.test.sh @@ -0,0 +1,24 @@ +# BashBox du cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_du_human_rounding +# human rounding +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +for n in 1 1023 1024 1025 1536 10239 10240 10241 1047552 1047553 1048575 1048576 1048577 5000000; do printf "%${n}s" '' > f; du -bh f; done +### expect +1 f +1023 f +1.0K f +1.1K f +1.5K f +10K f +10K f +11K f +1023K f +1.0M f +1.0M f +1.0M f +1.1M f +4.8M f +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh new file mode 100644 index 000000000..4650cba79 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh @@ -0,0 +1,66 @@ +# BashBox echo cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_echo_joins_arguments +# joins arguments +echo a b +### expect +a b +### end + +### bashbox_echo_escapes_are_literal_by_default +# escapes are literal by default +echo 'a\tb' +### expect +a\tb +### end + +### bashbox_echo_e_expands_escapes +# -e expands escapes +echo -e 'a\tb\\c\n' +### expect +a b\c + +### end + +### bashbox_echo_e_unknown_escape_and_trailing_backslash_stay +# -e unknown escape and trailing backslash stay +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo -e '\z\xg\' +### expect +\z\xg\ +### end + +### bashbox_echo_e_hex +# -e hex +echo -e '\x41\x4a2' +### expect +AJ2 +### end + +### bashbox_echo_e_c_stops_output_and_the_newline +# -e \c stops output and the newline +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo -e 'a\cb'; echo -e x +### expect +ax +### end + +### bashbox_echo_last_of_e_e_wins +# last of -e/-E wins +echo -eE 'a\tb'; echo -Ee 'a\tb' +### expect +a\tb +a b +### end + +### bashbox_echo_options_stop_at_the_first_non_option +# options stop at the first non-option +echo -nx; echo -- -n; echo -; echo a -n +### expect +-nx +-- -n +- +a -n +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-env-commands.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-env-commands.test.sh new file mode 100644 index 000000000..c7f5fb969 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-env-commands.test.sh @@ -0,0 +1,18 @@ +# BashBox env-commands cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_env_commands_i_starts_empty +# -i starts empty +env -i X=1; env - Y=2 +### expect +X=1 +Y=2 +### end + +### bashbox_env_commands_the_command_keeps_its_options_and_stdin +# the command keeps its options and stdin +echo hi | env -i cat -n +### expect + 1 hi +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-err-trap.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-err-trap.test.sh new file mode 100644 index 000000000..afc0282ad --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-err-trap.test.sh @@ -0,0 +1,189 @@ +# BashBox err-trap cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_err_trap_functions_do_not_inherit_the_err_trap +# functions do not inherit the ERR trap +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap 'echo err' ERR; f(){ false; echo in; }; f; echo out +### expect +in +out +### end + +### bashbox_err_trap_the_failing_call_still_fires_it +# the failing call still fires it +trap 'echo err' ERR; f(){ g; }; g(){ false; }; f; echo end +### expect +err +end +### end + +### bashbox_err_trap_set_e_passes_it_to_functions +# set -E passes it to functions +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -E; trap 'echo err $LINENO' ERR; f(){ false; }; f; echo $-; set +E; f; echo end +### expect +err 1 +err 1 +hBEc +err 1 +end +### end + +### bashbox_err_trap_a_trap_set_in_a_function_stays +# a trap set in a function stays +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ trap 'echo ferr $LINENO' ERR; false; } +f +f +false +echo end +### expect +ferr 1 +ferr 1 +ferr 3 +ferr 4 +end +### end + +### bashbox_err_trap_the_call_fires_only_a_trap_set_before_it +# the call fires only a trap set before it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ trap 'echo t' ERR; return 1; }; f; false; echo end +### expect +t +end +### end + +### bashbox_err_trap_eval_fires_the_trap_it_found +# eval fires the trap it found +trap 'echo a' ERR; eval false; eval "trap 'echo b' ERR; false"; echo end +### expect +a +a +b +b +end +### end + +### bashbox_err_trap_conditions_do_not_fire_it +# conditions do not fire it +trap 'echo err' ERR; if false; then :; elif false; then :; fi; while false; do :; done; until true; do :; done; echo end +### expect +end +### end + +### bashbox_err_trap_tested_function_calls_do_not_fire_it +# tested function calls do not fire it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap 'echo err' ERR; f(){ false; echo in; }; f || true; f && true; ! f; if f; then :; fi; echo end +### expect +in +in +in +in +end +### end + +### bashbox_err_trap_and_or_lists_fire_only_for_the_last +# and-or lists fire only for the last +trap 'echo err' ERR; false && true; false || false; echo end +### expect +err +end +### end + +### bashbox_err_trap_compound_commands_fire_only_through_their_commands +# compound commands fire only through their commands +trap 'echo err' ERR; { false; }; if true; then false; fi; for i in 1; do false; done; case x in x) false;; esac; echo end +### expect +err +err +err +err +end +### end + +### bashbox_err_trap_a_failing_subshell_fires_it_once +# a failing subshell fires it once +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap 'echo err' ERR; (false); (false; true); echo end +### expect +err +end +### end + +### bashbox_err_trap_set_e_reaches_subshells +# set -E reaches subshells +set -E; trap 'echo err' ERR; (false; true); echo end +### expect +err +end +### end + +### bashbox_err_trap_command_substitution_drops_the_err_trap +# command substitution drops the ERR trap +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap 'echo err' ERR; x=$(false; echo in); echo "[$x]"; f(){ false; }; y=$(f); echo "[$y]" +### expect +[in] +err +[] +### end + +### bashbox_err_trap_set_e_keeps_it_in_command_substitution +# set -E keeps it in command substitution +set -E; trap 'echo err' ERR; x=$(false; echo in); echo "[$x]" +### expect +[err +in] +### end + +### bashbox_err_trap_arithmetic_and_fire_it +# arithmetic and [[ ]] fire it +trap 'echo err' ERR; [[ 1 = 2 ]]; (( 0 )); echo end +### expect +err +err +end +### end + +### bashbox_err_trap_errexit_ignores_conditions +# errexit ignores conditions +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -e; if false; then :; fi; while false; do :; done; f(){ false; echo in; }; f || true; f && true; ! f; echo end +### expect +in +in +in +end +### end + +### bashbox_err_trap_set_t_passes_the_return_trap_to_functions +# set -T passes the RETURN trap to functions +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -T; echo $-; trap 'echo r' RETURN; g(){ :; }; f(){ g; }; f; echo end +### expect +hBTc +r +r +end +### end + +### bashbox_err_trap_without_t_it_stays_out_of_nested_calls +# without -T it stays out of nested calls +trap 'echo r' RETURN; g(){ :; }; f(){ g; }; f; echo end +### expect +end +### end + +### bashbox_err_trap_set_o_lists_the_options +# set -o lists the options +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -o | grep -E "errtrace|functrace"; set -E; set +o | grep errtrace +### expect +errtrace off +functrace off +set -o errtrace +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh new file mode 100644 index 000000000..858120c47 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh @@ -0,0 +1,65 @@ +# BashBox expand cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_expand_past_the_last_stop_is_one_space +# past the last stop is one space +printf 'a\tb\tc\td\n' | expand -t 2,4 +### expect +a b c d +### end + +### bashbox_expand_extend +# extend +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\tc\td\te\n' | expand -t 2,5,/3 +### expect +a b c d e +### end + +### bashbox_expand_increment +# increment +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\tc\td\te\n' | expand -t 2,5,+3 +### expect +a b c d e +### end + +### bashbox_expand_only_extend +# only extend +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\n' | expand -t /3 +### expect +a b +### end + +### bashbox_expand_only_increment +# only increment +printf 'a\tb\n' | expand -t +3 +### expect +a b +### end + +### bashbox_expand_repeated_t_adds_stops +# repeated -t adds stops +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\tc\n' | expand -t 2 -t 6 +### expect +a b c +### end + +### bashbox_expand_stdin +# stdin +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '\tz\n' | expand -t 3 - +### expect + z +### end + +### bashbox_expand_slash_then_value_later +# slash then value later +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\tb\tc\n' | expand -t /,4 +### expect +a b c +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-filesystem-backends-integration.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-filesystem-backends-integration.test.sh new file mode 100644 index 000000000..bac1198f2 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-filesystem-backends-integration.test.sh @@ -0,0 +1,10 @@ +# BashBox filesystem-backends-integration cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_filesystem_backends_integration_a_file_that_may_not_be_created +# a file that may not be created +echo x > ro/new; echo $? +### expect +1 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh new file mode 100644 index 000000000..752565146 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh @@ -0,0 +1,41 @@ +# BashBox find-predicates cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_find_predicates_newer_with_h_follows_the_reference +# -newer with -H follows the reference +find -H t/fl -newer t/fl +### expect +### end + +### bashbox_find_predicates_inum_0 +# -inum 0 +find t -inum 0 +### expect +### end + +### bashbox_find_predicates_user_root +# -user root +find t -user root +### expect +### end + +### bashbox_find_predicates_nouser_and_nogroup +# -nouser and -nogroup +find t -nouser -o -nogroup +### expect +### end + +### bashbox_find_predicates_newermt_is_strictly_newer +# -newermt is strictly newer +find t -newermt '2020-01-02 03:04:05' -name f +### expect +### end + +### bashbox_find_predicates_fprint_creates_the_file_even_without_matches +# -fprint creates the file even without matches +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo old > out; find t -name nomatch -fprint out; wc -c < out +### expect +0 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh new file mode 100644 index 000000000..2c5b4e92d --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh @@ -0,0 +1,72 @@ +# BashBox find cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_find_name_is_case_sensitive +# -name is case sensitive +find . -name "main.php" +### expect +### end + +### bashbox_find_file_start_point_filtered_out +# file start point filtered out +find a.txt -type d +### expect +### end + +### bashbox_find_fractional_mtime +# fractional -mtime +find proj -type f -mtime -1.5 -mtime +0.5 +### expect +### end + +### bashbox_find_mmin_n_matches_the_minute_before +# -mmin N matches the minute before +find proj -type f -mmin 30 -o -type f -mmin 5 +### expect +### end + +### bashbox_find_perm_symbolic +# -perm symbolic +find proj ! -type l -perm u=rw,go=r -name 's*' +### expect +### end + +### bashbox_find_perm_copying_another_class +# -perm copying another class +find proj ! -type l -perm u=rw,g=u,o=u +### expect +### end + +### bashbox_find_perm_with_nothing +# -perm = with nothing +find proj ! -type l -perm = +### expect +### end + +### bashbox_find_perm_t_and_t +# -perm =t and +t +find proj -perm +t +### expect +### end + +### bashbox_find_quit_alone_prints_nothing +# -quit alone prints nothing +find proj -quit +### expect +### end + +### bashbox_find_execdir_from_the_root +# -execdir from the root +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +find / -maxdepth 0 -execdir echo {} \; +### expect +/ +### end + +### bashbox_find_printf_h_and_f_of_the_root +# -printf %h and %f of the root +find / -maxdepth 0 -printf '[%h][%f]\n' +### expect +[][/] +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh new file mode 100644 index 000000000..a9ec6dbde --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh @@ -0,0 +1,69 @@ +# BashBox fold cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_fold_default_width_80 +# default width 80 +printf '%0100d\n' 0 | fold +### expect +00000000000000000000000000000000000000000000000000000000000000000000000000000000 +00000000000000000000 +### end + +### bashbox_fold_spaces_long_word +# spaces long word +printf 'abcdefghijklmnop qr\n' | fold -s -w 5 +### expect +abcde +fghij +klmno +p qr +### end + +### bashbox_fold_backspace_at_column_0 +# backspace at column 0 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '\x08abcde\n' | fold -w 3 +### expect +abc +de +### end + +### bashbox_fold_tab_wider_than_width +# tab wider than width +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '\tab\n' | fold -w 4 +### expect + +ab +### end + +### bashbox_fold_space_overflows_the_line +# space overflows the line +printf 'abcde fgh\n' | fold -s -w 5 +### expect +abcde + fgh +### end + +### bashbox_fold_line_ending_in_a_blank +# line ending in a blank +printf 'ab cd ef gh ij\n' | fold -s -w 3 +### expect +ab +cd +ef +gh +ij +### end + +### bashbox_fold_tab_after_break_with_s +# tab after break with -s +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'ab\tcdefghij\n' | fold -s -w 6 +### expect +ab + +cdefgh +ij +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh new file mode 100644 index 000000000..3a82b833a --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh @@ -0,0 +1,143 @@ +# BashBox grep cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_grep_stdin +# stdin +printf 'apple\nbanana\napricot' | grep ap +### expect +apple +apricot +### end + +### bashbox_grep_h_names_stdin +# -H names stdin +echo apple | grep -Hc apple +### expect +(standard input):1 +### end + +### bashbox_grep_quiet +# quiet +grep -q cherry a.txt +### expect +### end + +### bashbox_grep_quiet_stops_at_first_match +# quiet stops at first match +grep -q apple a.txt nope +### expect +### end + +### bashbox_grep_only_matching_skips_empty_matches +# only matching skips empty matches +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo aaa | grep -o 'b*' +### expect +### end + +### bashbox_grep_only_matching_with_v_prints_nothing +# only matching with -v prints nothing +grep -ov an a.txt +### expect +### end + +### bashbox_grep_pattern_starting_with +# pattern starting with - +echo x-v | grep -e -v +### expect +x-v +### end + +### bashbox_grep_newline_separates_patterns +# newline separates patterns +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\n' | grep "$(printf 'a\nc')" +### expect +a +c +### end + +### bashbox_grep_fixed_strings +# fixed strings +printf 'a.c\nabc\n' | grep -F 'a.c' +### expect +a.c +### end + +### bashbox_grep_word_match +# word match +printf 'pie\npies\n_pie\n' | grep -w pie +### expect +pie +### end + +### bashbox_grep_word_match_with_alternation +# word match with alternation +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'ab\nxa\nb\n' | grep -wE 'a|b' +### expect +b +### end + +### bashbox_grep_word_match_with_fixed_string +# word match with fixed string +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'foo.bar\nfoo.\n' | grep -wF 'foo.' +### expect +foo. +### end + +### bashbox_grep_bre_is_literal +# BRE + is literal +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a+b\nab\n' | grep 'a+b' +### expect +a+b +### end + +### bashbox_grep_bre_alternation_and_groups +# BRE alternation and groups +printf 'cat\ndog\ncow\n' | grep '^\(cat\|dog\)$' +### expect +cat +dog +### end + +### bashbox_grep_bre_back_reference +# BRE back-reference +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abab\nabba\n' | grep '\(ab\)\1' +### expect +abab +### end + +### bashbox_grep_bre_leading_star_is_literal +# BRE leading star is literal +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '*a\nb\n' | grep '*a' +### expect +*a +### end + +### bashbox_grep_ere +# ERE +printf 'aa\na\n' | grep -E '^a{2}$' +### expect +aa +### end + +### bashbox_grep_g_resets_to_bre +# -G resets to BRE +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a|b\na\n' | grep -G 'a|b' +### expect +a|b +### end + +### bashbox_grep_slash_in_pattern +# slash in pattern +echo 'a/b' | grep 'a/b' +### expect +a/b +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-head-tail.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-head-tail.test.sh new file mode 100644 index 000000000..5a0f4e20a --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-head-tail.test.sh @@ -0,0 +1,35 @@ +# BashBox head-tail cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_head_tail_head_defaults_to_10_lines +# head defaults to 10 lines +seq 12 | head +### expect +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +### end + +### bashbox_head_tail_tail_defaults_to_10_lines +# tail defaults to 10 lines +seq 12 | tail +### expect +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-builtins.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-builtins.test.sh new file mode 100644 index 000000000..e56258158 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-builtins.test.sh @@ -0,0 +1,533 @@ +# BashBox interpreter-builtins cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_interpreter_builtins_for_without_in_walks_the_positional_parameters +# for without in walks the positional parameters +set -- a b c; for x; do echo $x; done +### expect +a +b +c +### end + +### bashbox_interpreter_builtins_c_style_for_with_continue_and_break +# c-style for with continue and break +for ((i=0; i<10; i++)); do test $i = 1 && continue; test $i = 3 && break; echo $i; done +### expect +0 +2 +### end + +### bashbox_interpreter_builtins_c_style_for_with_no_clauses_loops_until_break +# c-style for with no clauses loops until break +i=0; for ((;;)); do i=$((i+1)); test $i -gt 2 && break; echo $i; done +### expect +1 +2 +### end + +### bashbox_interpreter_builtins_loop_status_is_the_last_body_status +# loop status is the last body status +for ((i=0; i<2; i++)); do false; done; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_while_with_continue_and_break +# while with continue and break +i=0; while test $i -lt 5; do i=$((i+1)); test $i = 2 && continue; test $i = 4 && break; echo $i; done +### expect +1 +3 +### end + +### bashbox_interpreter_builtins_while_break_2_from_a_nested_while +# while break 2 from a nested while +for a in x y; do while true; do while true; do break 2; done; done; echo $a; done +### expect +x +y +### end + +### bashbox_interpreter_builtins_while_continue_3_resumes_the_outer_for +# while continue 3 resumes the outer for +for a in x y; do while true; do while true; do continue 3; done; done; echo no; done; echo done +### expect +done +### end + +### bashbox_interpreter_builtins_while_that_never_runs_exits_0 +# while that never runs exits 0 +while false; do :; done; echo $? +### expect +0 +### end + +### bashbox_interpreter_builtins_until_counts_up +# until counts up +i=0; until test $i -ge 3; do echo $i; i=$((i+1)); done +### expect +0 +1 +2 +### end + +### bashbox_interpreter_builtins_until_with_continue_and_break +# until with continue and break +i=0; until false; do i=$((i+1)); test $i = 2 && continue; test $i = 4 && break; echo $i; done +### expect +1 +3 +### end + +### bashbox_interpreter_builtins_until_status_is_the_last_body_status +# until status is the last body status +i=0; until test $i = 2; do i=$((i+1)); false; done; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_runs_the_next_body_without_testing_it +# ;& runs the next body without testing it +case a in a) echo 1;& b) echo 2;; c) echo 3;; esac +### expect +1 +2 +### end + +### bashbox_interpreter_builtins_goes_on_testing_the_next_patterns +# ;;& goes on testing the next patterns +case ab in a*) echo 1;;& *b) echo 2;;& c) echo 3;; esac +### expect +1 +2 +### end + +### bashbox_interpreter_builtins_no_match_exits_0 +# no match exits 0 +case x in y) echo no;; esac; echo $? +### expect +0 +### end + +### bashbox_interpreter_builtins_status_of_the_matched_body +# status of the matched body +case x in x) false;; esac; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_quoted_backslash +# quoted backslash +case 'a\' in 'a\') echo bs;; esac +### expect +bs +### end + +### bashbox_interpreter_builtins_unclosed_bracket_is_literal +# unclosed bracket is literal +case ab in a[b) echo open;; *) echo other;; esac +### expect +other +### end + +### bashbox_interpreter_builtins_exit_ends_only_the_subshell +# exit ends only the subshell +(exit 3); echo $? +### expect +3 +### end + +### bashbox_interpreter_builtins_return_ends_only_the_subshell +# return ends only the subshell +f(){ (return 4); echo $?; }; f +### expect +4 +### end + +### bashbox_interpreter_builtins_errexit_ends_only_the_subshell +# errexit ends only the subshell +(set -e; false; echo no); echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_keeps_earlier_output_in_place +# keeps earlier output in place +echo pre; x=$(echo b); echo "[$x]" +### expect +pre +[b] +### end + +### bashbox_interpreter_builtins_exit_status_reaches +# exit status reaches $? +x=$(exit 4); echo $? +### expect +4 +### end + +### bashbox_interpreter_builtins_state_changes_stay_inside +# state changes stay inside +x=1; y=$(x=2; f(){ :; }; echo $x); echo $x $y; type f >/dev/null 2>&1; echo $? +### expect +1 2 +1 +### end + +### bashbox_interpreter_builtins_does_not_fire_the_exit_trap +# does not fire the EXIT trap +trap 'echo bye' EXIT; x=$(echo a); echo $x +### expect +a +bye +### end + +### bashbox_interpreter_builtins_an_expansion_error_ends_only_the_substitution +# an expansion error ends only the substitution +x=$(echo ${zz:?boom}); echo "after $?" +### expect +after 1 +### end + +### bashbox_interpreter_builtins_fires_after_the_body_of_the_function_that_set_it +# fires after the body of the function that set it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ trap 'echo ret' RETURN; echo in; }; f; echo after +### expect +in +ret +after +### end + +### bashbox_interpreter_builtins_is_not_inherited_by_functions +# is not inherited by functions +trap 'echo ret' RETURN; f(){ echo f; }; f; echo end +### expect +f +end +### end + +### bashbox_interpreter_builtins_stays_set_but_other_functions_do_not_see_it +# stays set but other functions do not see it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ trap 'echo ret' RETURN; }; g(){ echo g; }; f; echo top; g; h(){ g; }; h +### expect +ret +top +g +g +### end + +### bashbox_interpreter_builtins_sees_the_function_locals +# sees the function locals +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ local v=loc; trap 'echo $v' RETURN; }; f +### expect +loc +### end + +### bashbox_interpreter_builtins_input_from_a_missing_file +# input from a missing file +cat < missing.txt; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_creates_the_file +# <> creates the file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +cat <> rw.txt; echo $?; test -f rw.txt && echo created +### expect +0 +created +### end + +### bashbox_interpreter_builtins_2_1_before_dev_null_keeps_stderr_on_the_pipe +# 2>&1 before >/dev/null keeps stderr on the pipe +{ echo out; echo err >&2; } 2>&1 >/dev/null | cat +### expect +err +### end + +### bashbox_interpreter_builtins_closing_stderr +# closing stderr +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +ls /nope 2>&-; echo y +### expect +y +### end + +### bashbox_interpreter_builtins_file_sends_both_streams_to_the_file +# >&file sends both streams to the file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +{ echo z; echo e >&2; } >& both.txt; cat both.txt +### expect +z +e +### end + +### bashbox_interpreter_builtins_and_write_and_append_both_streams +# &> and &>> write and append both streams +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +{ echo a; echo b >&2; } &> all.txt; { echo c; } &>> all.txt; cat all.txt +### expect +a +b +c +### end + +### bashbox_interpreter_builtins_dev_stderr_is_fd_2_as_of_that_redirection +# /dev/stderr is fd 2 as of that redirection +echo x > /dev/stderr 2>/dev/null; echo y +### expect +y +### end + +### bashbox_interpreter_builtins_a_missing_directory_fails +# a missing directory fails > +echo a > /no/such/dir/f; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_a_missing_directory_fails_2 +# a missing directory fails >& +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo z >& /no/such/dir/f; echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_quoted_heredoc_stays_literal +# quoted heredoc stays literal +cat <<'EOF' +$HOME x +EOF +### expect +$HOME x +### end + +### bashbox_interpreter_builtins_strips_leading_tabs_and_expands +# <<- strips leading tabs and expands +cat <<-EOF + tab $((1+1)) + EOF +### expect +tab 2 +### end + +### bashbox_interpreter_builtins_here_string +# here-string +cat <<< "here $((2+3))" +### expect +here 5 +### end + +### bashbox_interpreter_builtins_an_empty_array +# an empty array +a=(); echo ${#a[@]}; a=(1 2 3); a=(); echo ${#a[@]} +### expect +0 +0 +### end + +### bashbox_interpreter_builtins_to_a_readonly_variable_fails +# to a readonly variable fails +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly r=1 +r=2 +echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_xtrace_of_a_prefix_assignment +# xtrace of a prefix assignment +set -x; X=1 echo hi +### expect +hi +### end + +### bashbox_interpreter_builtins_and_short_circuit +# && and || short-circuit +x=0; echo $((0 && (x=5))) $x $((1 || (x=6))) $x $((1 && 2)) $((0 || 0)) $((0 || 3)) +### expect +0 0 1 0 1 0 1 +### end + +### bashbox_interpreter_builtins_unary_operators +# unary operators +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo $((-(2+3))) $((+4)) $((!0)) $((!5)) $((~0)) $((++5)) $((--5)) +### expect +-5 4 1 0 -1 5 5 +### end + +### bashbox_interpreter_builtins_increment_and_decrement +# increment and decrement +x=5; echo $((x++)) $x $((x--)) $x $((++x)) $((--x)); echo $((y++)) $y +### expect +5 6 6 5 6 5 +0 1 +### end + +### bashbox_interpreter_builtins_ternary +# ternary +echo $((1?2:3)) $((0?2:3)) +### expect +2 3 +### end + +### bashbox_interpreter_builtins_variables_holding_expressions +# variables holding expressions +x=1+2; echo $((x*3)) $(($x*3)) +### expect +9 7 +### end + +### bashbox_interpreter_builtins_positional_parameters +# positional parameters +set -- 5; echo $(($1+1)); (( $1 == 5 )) && echo five +### expect +6 +five +### end + +### bashbox_interpreter_builtins_expansions_and_quotes_inside +# expansions and quotes inside +a=(1 2 3); echo $(("1"+1)) $(( $(echo 2) * 3 )) $(( ${#a[@]} + 1 )) +### expect +2 6 4 +### end + +### bashbox_interpreter_builtins_associative_array_elements +# associative array elements +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -A m; m[foo]=5; echo $((m[foo]*2)); (( m[bar]=3 )); echo ${m[bar]} +### expect +10 +3 +### end + +### bashbox_interpreter_builtins_command_status +# command status +((0)); echo $?; ((2)); echo $? +### expect +1 +0 +### end + +### bashbox_interpreter_builtins_c_style_for_header_with_a_substitution +# c-style for header with a substitution +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +for ((i=$(echo 1); i<3; i++)); do echo $i; done +### expect +1 +2 +### end + +### bashbox_interpreter_builtins_case +# /= +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=5; ((x/=0)); echo $? $x +### expect +1 5 +### end + +### bashbox_interpreter_builtins_case_2 +# %= +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=5; ((x%=0)); echo $? $x +### expect +1 5 +### end + +### bashbox_interpreter_builtins_case_3 +# / +((5/0)); echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_case_4 +# % +((5%0)); echo $? +### expect +1 +### end + +### bashbox_interpreter_builtins_string_comparisons +# string comparisons +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +[[ abc != a* ]]; echo $?; [[ abc != x* ]]; echo $?; [[ a < b ]]; echo $?; [[ b > a ]]; echo $?; [[ b < a ]]; echo $? +### expect +1 +0 +0 +0 +1 +### end + +### bashbox_interpreter_builtins_integer_comparisons +# integer comparisons +[[ 3 -eq 3 && 3 -ne 4 && 2 -lt 3 && 3 -le 3 && 4 -gt 3 && 4 -ge 4 ]]; echo $?; [[ 3 -eq 4 ]]; echo $? +### expect +0 +1 +### end + +### bashbox_interpreter_builtins_regex_match +# regex match +[[ abc123 =~ [0-9]+ ]]; echo $?; [[ abc =~ ^b ]]; echo $? +### expect +0 +1 +### end + +### bashbox_interpreter_builtins_regex_fills_bash_rematch +# regex fills BASH_REMATCH +re="(b)(c)"; [[ abc =~ $re ]]; echo $? ${BASH_REMATCH[0]} ${BASH_REMATCH[2]}; [[ a/b =~ a/b ]]; echo $? +### expect +0 bc c +0 +### end + +### bashbox_interpreter_builtins_empty_and_non_empty_strings +# empty and non-empty strings +[[ -z "" && -n x ]]; echo $?; [[ -z x ]]; echo $?; [[ x ]]; echo $?; [[ "" ]]; echo $? +### expect +0 +1 +0 +1 +### end + +### bashbox_interpreter_builtins_variable_is_set +# variable is set +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +v=1; a=(1 2); [[ -v v ]]; echo $?; [[ -v a ]]; echo $?; [[ -v nope ]]; echo $? +### expect +0 +0 +1 +### end + +### bashbox_interpreter_builtins_and_match_newlines +# * and ? match newlines +x=$(printf "a\nb"); [[ $x == a* ]]; echo $?; case $x in a?b) echo nl;; esac +### expect +0 +nl +### end + +### bashbox_interpreter_builtins_unset_in_its_own_function_keeps_a_local_hiding_the_global +# unset in its own function keeps a local hiding the global +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ local x=1; unset x; echo "[${x-unset}]"; x=2; echo $x; }; x=g; f; echo $x +### expect +[unset] +2 +g +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-core.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-core.test.sh new file mode 100644 index 000000000..cfb628064 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-interpreter-core.test.sh @@ -0,0 +1,1053 @@ +# BashBox interpreter-core cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_interpreter_core_command_substitution_captures_only_its_own_output +# command substitution captures only its own output +echo a; x=$(echo b); echo "[$x]" +### expect +a +[b] +### end + +### bashbox_interpreter_core_exit_inside_command_substitution_ends_only_the_subshell +# exit inside command substitution ends only the subshell +x=$(echo in; exit 3); echo "$? [$x]" +### expect +3 [in] +### end + +### bashbox_interpreter_core_assignment_only_command_reports_the_substitution_status +# assignment-only command reports the substitution status +x=$(false); echo $? +### expect +1 +### end + +### bashbox_interpreter_core_exit_trap_waits_for_the_whole_script +# EXIT trap waits for the whole script +trap "echo bye" EXIT; eval "echo hi"; x=$(echo in); echo "after $x" +### expect +hi +after in +bye +### end + +### bashbox_interpreter_core_empty_exit_trap_is_ignored +# empty EXIT trap is ignored +trap "" EXIT; echo x +### expect +x +### end + +### bashbox_interpreter_core_skipped_still_reaches +# skipped && still reaches || +false && echo a || echo b +### expect +b +### end + +### bashbox_interpreter_core_skips_on_success +# || skips on success +true || echo no; echo $? +### expect +0 +### end + +### bashbox_interpreter_core_err_trap_fires_for_a_failing_command +# ERR trap fires for a failing command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap "echo caught \$?" ERR; false; echo next $? +### expect +caught 1 +next 1 +### end + +### bashbox_interpreter_core_err_trap_skips_non_final_members_of_an_and_or_list +# ERR trap skips non-final members of an and-or list +trap "echo caught" ERR; false && true; false || true; echo done +### expect +done +### end + +### bashbox_interpreter_core_set_e_ignores_a_failure_before +# set -e ignores a failure before && +set -e; false && true; echo yes +### expect +yes +### end + +### bashbox_interpreter_core_set_e_ignores_a_negated_pipeline +# set -e ignores a negated pipeline +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -e; ! true; echo yes +### expect +yes +### end + +### bashbox_interpreter_core_set_o_errexit_then_set_e +# set -o errexit then set +e +set -o errexit; set +e; false; echo yes +### expect +yes +### end + +### bashbox_interpreter_core_pipeline_status_is_the_last_command +# pipeline status is the last command +false | true; echo $? +### expect +0 +### end + +### bashbox_interpreter_core_negated_pipeline +# negated pipeline +! false; echo $?; ! true; echo $? +### expect +0 +1 +### end + +### bashbox_interpreter_core_pipefail_reports_the_rightmost_failure +# pipefail reports the rightmost failure +set -o pipefail; false | true; echo $?; set +o pipefail; false | true; echo $? +### expect +1 +0 +### end + +### bashbox_interpreter_core_pipes_stderr_too +# |& pipes stderr too +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +{ echo out; echo err >&2; } |& cat +### expect +out +err +### end + +### bashbox_interpreter_core_stderr_of_a_piped_command_is_not_piped +# stderr of a piped command is not piped +{ echo out; echo err >&2; } | cat +### expect +out +### end + +### bashbox_interpreter_core_compound_command_output_is_redirected +# compound command output is redirected +{ echo a; echo b >&2; } > out 2>&1; cat out +### expect +a +b +### end + +### bashbox_interpreter_core_loop_reads_from_a_redirected_file +# loop reads from a redirected file +printf "1\n2\n" > in; while read l; do echo $l; done < in +### expect +1 +2 +### end + +### bashbox_interpreter_core_failed_compound_redirection_skips_the_command +# failed compound redirection skips the command +while read l; do echo "<$l>"; done < nofile; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_every_compound_command_kind_runs +# every compound command kind runs +if true; then echo t; fi | cat; case a in a) echo c;; esac; (echo sub); ((1)) && [[ a ]] && echo arith; until true; do :; done; for ((i=0; i<1; i++)); do echo $i; done +### expect +t +c +sub +arith +0 +### end + +### bashbox_interpreter_core_break_and_continue_with_levels +# break and continue with levels +for i in 1 2 3; do for j in a b; do test $j = b && continue 2; test $i = 3 && break 2; echo $i$j; done; done; until false; do break; done; echo end +### expect +1a +2a +end +### end + +### bashbox_interpreter_core_break_and_continue_outside_a_loop +# break and continue outside a loop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +break; continue 2; echo $? +### expect +0 +### end + +### bashbox_interpreter_core_break_inside_a_function_cannot_leave_the_caller_loop +# break inside a function cannot leave the caller loop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { break; }; for i in 1; do f; echo no; done +### expect +no +### end + +### bashbox_interpreter_core_command_not_found +# command not found +nope; echo $? +### expect +127 +### end + +### bashbox_interpreter_core_command_name_is_field_split +# command name is field split +CMD="echo a b"; $CMD c +### expect +a b c +### end + +### bashbox_interpreter_core_empty_expansion_leaves_only_the_assignment +# empty expansion leaves only the assignment +e=; x=1 $e; echo $x +### expect +1 +### end + +### bashbox_interpreter_core_prefix_assignment_reaches_a_command_env_only +# prefix assignment reaches a command env only +FOO=bar printenv FOO; echo "[$FOO]" +### expect +bar +[] +### end + +### bashbox_interpreter_core_prefix_assignment_is_temporary_for_functions +# prefix assignment is temporary for functions +f() { echo "x=$x"; }; x=1 f; echo "[$x]" +### expect +x=1 +[] +### end + +### bashbox_interpreter_core_prefix_assignment_is_temporary_for_builtins +# prefix assignment is temporary for builtins +y=0; y=5 eval "echo \$y"; echo $y +### expect +5 +0 +### end + +### bashbox_interpreter_core_repeated_prefix_assignment_restores_the_original +# repeated prefix assignment restores the original +x=0; x=1 x=2 printenv x; echo $x +### expect +2 +0 +### end + +### bashbox_interpreter_core_readonly_prefix_assignment_is_reported_the_command_still_run +# readonly prefix assignment is reported, the command still runs +readonly R=1; R=2 echo "[$R]"; echo "s=$?" +### expect +[1] +s=0 +### end + +### bashbox_interpreter_core_xtrace_prints_assignments_and_commands +# xtrace prints assignments and commands +set -x; x=1 printenv x; y=2 +### expect +1 +### end + +### bashbox_interpreter_core_bad_substitution_fails_only_its_command +# bad substitution fails only its command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo ${x!} +echo after $? +### expect +after 1 +### end + +### bashbox_interpreter_core_expansion_error_abandons_the_rest_of_its_line +# expansion error abandons the rest of its line +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { echo ${x!}; echo in; } +f; echo same +echo next +### expect +next +### end + +### bashbox_interpreter_core_cannot_assign_fails_only_its_command +# cannot assign fails only its command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo ${1:=x} +echo after $? +### expect +after 1 +### end + +### bashbox_interpreter_core_return_1_wraps_to_255 +# return -1 wraps to 255 +f() { return -1; }; f; echo $? +### expect +255 +### end + +### bashbox_interpreter_core_return_outside_a_function +# return outside a function +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +return; echo $? +### expect +2 +### end + +### bashbox_interpreter_core_export_tracks_later_assignments +# export tracks later assignments +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export X=1; X=2; printenv X; export Y; Y=3; printenv Y; export -n Z=4; echo $Z +### expect +2 +3 +4 +### end + +### bashbox_interpreter_core_export_of_a_readonly_variable +# export of a readonly variable +readonly R=1; export R=2; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_export_lists_variables +# export lists variables +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +export B=2 A=1; export | grep "[AB]=" +### expect +declare -x A="1" +declare -x B="2" +### end + +### bashbox_interpreter_core_unset_f_and_v +# unset -f and -v +f() { echo fn; }; unset -f f; f; a=1; unset -v a; echo "[$a]" +### expect +[] +### end + +### bashbox_interpreter_core_unset_an_array_element +# unset an array element +a=(1 2 3); unset "a[1]"; echo ${a[@]} +### expect +1 3 +### end + +### bashbox_interpreter_core_unset_a_whole_array +# unset a whole array +a=(1 2); unset a; echo "[${a[@]}]" +### expect +[] +### end + +### bashbox_interpreter_core_unset_a_readonly_variable +# unset a readonly variable +readonly r=1; unset r; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_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 +### end + +### bashbox_interpreter_core_local_variables_vanish_after_the_function +# local variables vanish after the function +f() { local a=1 b; b=2; echo $a$b; }; f; echo "[$a$b]" +### expect +12 +[] +### end + +### bashbox_interpreter_core_local_of_a_readonly_variable +# local of a readonly variable +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly r=1; f() { local r=2; }; f; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_set_lists_variables_quoting_when_needed +# set lists variables, quoting when needed +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=1; b="x y"; c=""; d="it's"; set | grep "^[abcd]=" +### expect +a=1 +b='x y' +c= +d='it'\''s' +### end + +### bashbox_interpreter_core_set_positional_parameters +# set positional parameters +set -- x y; echo $# $1; set a b; echo $2 +### expect +2 x +b +### end + +### bashbox_interpreter_core_set_c_and_c +# set -C and +C +set -C; echo a > f; echo b > f; echo $?; set +C; echo c > f; cat f +### expect +1 +c +### end + +### bashbox_interpreter_core_set_f_disables_globbing +# set -f disables globbing +touch a.txt; set -f; echo *.txt; set +f; echo *.txt +### expect +*.txt +a.txt +### end + +### bashbox_interpreter_core_set_v_has_no_effect_on_a_one_line_script +# set -v has no effect on a one-line script +set -v; echo hi +### expect +hi +### end + +### bashbox_interpreter_core_set_u_and_u +# set -u and +u +set -u; set +u; echo "[$nope]" +### expect +[] +### end + +### bashbox_interpreter_core_set_x_stops_tracing +# set +x stops tracing +set -x; set +x; echo hi +### expect +hi +### end + +### bashbox_interpreter_core_shopt_succeeds +# shopt succeeds +shopt -s nullglob; echo $? +### expect +0 +### end + +### bashbox_interpreter_core_source_without_a_file +# source without a file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +source; echo $? +### expect +2 +### end + +### bashbox_interpreter_core_source_a_missing_file +# source a missing file +source nope.sh; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_source_arguments_become_positional_parameters +# source arguments become positional parameters +echo "echo sourced \$1 \$#" > s.sh; set -- p q; source s.sh arg; . ./s.sh; echo $1 +### expect +sourced arg 1 +sourced p 2 +p +### end + +### bashbox_interpreter_core_source_without_arguments_can_set_positional_parameters +# source without arguments can set positional parameters +echo "set -- z" > s.sh; source s.sh; echo $1 +### expect +z +### end + +### bashbox_interpreter_core_return_ends_a_sourced_file +# return ends a sourced file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "return 4; echo no" > s.sh; source s.sh; echo $? +### expect +4 +### end + +### bashbox_interpreter_core_return_in_a_file_sourced_by_a_function +# return in a file sourced by a function +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "x=1; return 5; x=2" > s; f() { source ./s; echo "s=$? x=$x"; }; f +### expect +s=5 x=1 +### end + +### bashbox_interpreter_core_eval_runs_in_the_current_shell +# eval runs in the current shell +eval "a=1;" "echo \$a"; eval; echo $? +### expect +1 +0 +### end + +### bashbox_interpreter_core_eval_output_follows_its_redirection +# eval output follows its redirection +echo a; eval "echo b" > f; cat f +### expect +a +b +### end + +### bashbox_interpreter_core_declare_without_a_value_keeps_the_variable +# declare without a value keeps the variable +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; declare x; echo $x; declare -p x nope; echo $? +### expect +1 +declare -- x="1" +1 +### end + +### bashbox_interpreter_core_declare_p_readonly_and_arrays +# declare -p readonly and arrays +declare -r y=2; declare -p y; a=(1 "b c"); declare -p a +### expect +declare -r y="2" +declare -a a=([0]="1" [1]="b c") +### end + +### bashbox_interpreter_core_declare_in_a_function_is_local_unless_g +# declare in a function is local unless -g +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { declare x=1; declare -g g=2; }; f; echo "[$x][$g]" +### expect +[][2] +### end + +### bashbox_interpreter_core_declare_a +# declare -a +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -a arr; arr[0]=x; echo ${arr[0]}; declare -a b=v; echo ${b[0]} +### expect +x +v +### end + +### bashbox_interpreter_core_declare_of_a_readonly_variable +# declare of a readonly variable +readonly RO=1; declare RO=2; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_declare_x +# declare -x +declare -x E=1; printenv E +### expect +1 +### end + +### bashbox_interpreter_core_declare_alone_lists_variables +# declare alone lists variables +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +q=1; declare | grep "^q=" +### expect +q=1 +### end + +### bashbox_interpreter_core_let_without_arguments +# let without arguments +let; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_let_status_follows_the_last_value +# let status follows the last value +let x=0; echo $?; let y=2 z=3; echo $y$z $? +### expect +1 +23 0 +### end + +### bashbox_interpreter_core_shift +# shift +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- a b c; shift; echo $*; shift 2; echo $# $?; shift; echo $? +### expect +b c +0 0 +1 +### end + +### bashbox_interpreter_core_getopts_walks_grouped_options_and_arguments +# getopts walks grouped options and arguments +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- -ac -bval -- -z rest1; while getopts "ab:c" o; do echo "$o ${OPTARG-unset} $OPTIND"; done; shift $((OPTIND-1)); echo "rest: $*" +### expect +a unset 1 +c unset 2 +b val 3 +rest: -z rest1 +### end + +### bashbox_interpreter_core_getopts_silent_mode +# getopts silent mode +set -- -z -b; while getopts ":ab:" o; do echo "$o ${OPTARG-unset}"; done +### expect +? z +: b +### end + +### bashbox_interpreter_core_getopts_reports_errors +# getopts reports errors +set -- -z -b; while getopts "ab:" o; do echo "$o ${OPTARG-unset}"; done +### expect +? unset +? unset +### end + +### bashbox_interpreter_core_getopts_with_explicit_arguments +# getopts with explicit arguments +getopts a o -a -b; echo "$o $?"; getopts a o -a -b; echo "$o $? ${OPTARG-u}"; getopts a o -a -b; echo "$o $?" +### expect +a 0 +? 0 u +? 1 +### end + +### bashbox_interpreter_core_getopts_restarts_when_optind_is_reset +# getopts restarts when OPTIND is reset +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +getopts ab o -ab; echo $o; OPTIND=1; getopts xy o -x; echo $o +### expect +a +x +### end + +### bashbox_interpreter_core_getopts_stops_at_a_non_option +# getopts stops at a non-option +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- a -b; getopts b o; echo "$? $o $OPTIND" +### expect +1 ? 1 +### end + +### bashbox_interpreter_core_getopts_option_argument_in_the_next_word +# getopts option argument in the next word +getopts "a:" o -a val; echo "$o $OPTARG $OPTIND" +### expect +a val 3 +### end + +### bashbox_interpreter_core_getopts_usage +# getopts usage +getopts; echo $? +### expect +2 +### end + +### bashbox_interpreter_core_type_t +# type -t +f(){ :; }; type -t f cd ls echo nope; echo $? +### expect +function +builtin +file +builtin +1 +### end + +### bashbox_interpreter_core_type_describes_each_name +# type describes each name +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +type nope cd ls; echo $? +### expect +cd is a shell builtin +ls is /usr/bin/ls +1 +### end + +### bashbox_interpreter_core_command_v +# command -v +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f(){ :; }; command -v cd ls f nope; echo $?; command -v nope; echo $? +### expect +cd +/usr/bin/ls +f +0 +1 +### end + +### bashbox_interpreter_core_command_skips_functions +# command skips functions +f(){ :; }; command f; echo $?; command; echo $?; echo() { :; }; command echo hi +### expect +127 +0 +hi +### end + +### bashbox_interpreter_core_command_runs_builtins +# command runs builtins +command cd /tmp; pwd +### expect +/tmp +### end + +### bashbox_interpreter_core_builtin +# builtin +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +builtin cd /tmp && pwd; builtin echo hi; builtin cat; echo $?; builtin; echo $? +### expect +/tmp +hi +1 +0 +### end + +### bashbox_interpreter_core_exec_runs_a_command_and_ends_the_script +# exec runs a command and ends the script +exec echo done; echo no +### expect +done +### end + +### bashbox_interpreter_core_exec_without_a_command +# exec without a command +exec; echo still +### expect +still +### end + +### bashbox_interpreter_core_alias_define_list_and_query +# alias define, list and query +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +alias a=b c="it's"; alias; alias a; alias zz; echo $? +### expect +alias a='b' +alias c='it'\''s' +alias a='b' +1 +### end + +### bashbox_interpreter_core_unalias +# unalias +alias a=b; unalias a zz; echo $?; alias x=y; unalias -a; alias +### expect +1 +### end + +### bashbox_interpreter_core_trap_listing_order_and_quoting +# trap listing order and quoting +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap "echo b" ERR; trap "echo a" EXIT; trap "" INT; trap "it's" SIGTERM; trap +### expect +trap -- 'echo a' EXIT +trap -- '' SIGINT +trap -- 'it'\''s' SIGTERM +trap -- 'echo b' ERR +a +### end + +### bashbox_interpreter_core_trap_with_a_lone_signal_resets_it +# trap with a lone signal resets it +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap "echo x" 0; trap; trap EXIT; trap; echo done +### expect +trap -- 'echo x' EXIT +done +### end + +### bashbox_interpreter_core_err_trap_keeps +# ERR trap keeps $? +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +trap "true" ERR; false; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_pushd_and_popd +# pushd and popd +cd /tmp; mkdir -p /tmp/a /tmp/b; pushd /tmp/a; pushd /tmp/b; pushd; popd; popd; popd; echo $? +### expect +/tmp/a /tmp +/tmp/b /tmp/a /tmp +/tmp/a /tmp/b /tmp +/tmp/b /tmp +/tmp +1 +### end + +### bashbox_interpreter_core_pushd_without_a_stack +# pushd without a stack +pushd; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_pushd_to_a_missing_directory +# pushd to a missing directory +pushd /nope; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_pushd_abbreviates_home +# pushd abbreviates HOME +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +cd /tmp; pushd ~ +### expect +~ /tmp +### end + +### bashbox_interpreter_core_hash +# hash +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +hash; hash -r; : a b; echo $? +### expect +hash: hash table empty +0 +### end + +### bashbox_interpreter_core_reply_is_not_trimmed +# REPLY is not trimmed +echo " a b " | { read; echo "[$REPLY]"; } +### expect +[ a b ] +### end + +### bashbox_interpreter_core_non_whitespace_ifs_keeps_the_rest_intact +# non-whitespace IFS keeps the rest intact +echo "a:b:c:d" | { IFS=: read x y; echo "[$x][$y]"; } +### expect +[a][b:c:d] +### end + +### bashbox_interpreter_core_adjacent_separators_make_empty_fields +# adjacent separators make empty fields +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "a::b" | { IFS=: read -a x; echo "${#x[@]} [${x[1]}]"; } +### expect +3 [] +### end + +### bashbox_interpreter_core_unterminated_line_assigns_but_fails +# unterminated line assigns but fails +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "a b" | { read x; echo "$? [$x]"; } +### expect +1 [a b] +### end + +### bashbox_interpreter_core_escaped_separator_stays_in_the_field +# escaped separator stays in the field +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "a\\ b c" | { read x y; echo "[$x][$y]"; } +### expect +[a b][c] +### end + +### bashbox_interpreter_core_empty_ifs_disables_splitting +# empty IFS disables splitting +printf "x y" | { IFS= read -r v; echo "[$v]"; } +### expect +[x y] +### end + +### bashbox_interpreter_core_nul_delimited_records +# NUL delimited records +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "a\0b\0" | while read -r -d "" v; do echo "[$v]"; done +### expect +[a] +[b] +### end + +### bashbox_interpreter_core_mapfile_on_nul_delimiters +# mapfile on NUL delimiters +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "a\0b\0" | { mapfile -d "" arr; echo ${#arr[@]}; } +### expect +2 +### end + +### bashbox_interpreter_core_readarray_t_into_mapfile +# readarray -t into MAPFILE +printf "a\nb" | { readarray -t; echo "${MAPFILE[1]}"; } +### expect +b +### end + +### bashbox_interpreter_core_read_from_a_pipe_into_eval +# read from a pipe into eval +echo hi | eval "read x; echo \$x" +### expect +hi +### end + +### bashbox_interpreter_core_default +# default +time echo hi +### expect +hi +### end + +### bashbox_interpreter_core_posix +# posix +time -p true +### expect +### end + +### bashbox_interpreter_core_an_invalid_regex_fails_with_status_2 +# an invalid regex fails [[ with status 2 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +re='('; [[ a =~ $re ]]; echo $?; re='[[:foo:]]'; [[ a =~ $re ]]; echo $?; [[ abc =~ b(c) ]]; echo $? ${BASH_REMATCH[@]} +### expect +2 +2 +0 bc c +### end + +### bashbox_interpreter_core_evaluates_integer_operands_without_expanding_them_again +# [[ ]] evaluates integer operands without expanding them again +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x='$(echo hi >&2; echo 1)'; [[ $x -eq 1 ]]; echo $?; x='1+1'; [[ $x -eq 2 ]]; echo $?; [[ 08 -eq 1 ]]; echo $? +### expect +1 +0 +1 +### end + +### bashbox_interpreter_core_assignment_subscripts_are_expanded_and_arithmetic_for_indexe +# assignment subscripts are expanded, and arithmetic for indexed arrays +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +i=3; a[$i]=x; a[i+1]=y; a[-1]=z; b=([i+1]=p [6]=q); read 'c[1+1]' <<< r; declare -p a b c +### expect +declare -a a=([3]="x" [4]="z") +declare -a b=([4]="p" [6]="q") +declare -a c=([2]="r") +### end + +### bashbox_interpreter_core_an_associative_array_s_subscript_is_its_key +# an associative array's subscript is its key +declare -A m; k='a b'; m[$k]=1; m[1+1]=2; echo "${m[a b]} ${m[1+1]}" +### expect +1 2 +### end + +### bashbox_interpreter_core_a_negative_subscript_counts_back_from_the_end +# a negative subscript counts back from the end +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(1 2 3); a[-1]+=x; declare -p a; a[-5]=q; echo same +echo next $? +### expect +declare -a a=([0]="1" [1]="2" [2]="3x") +next 1 +### end + +### bashbox_interpreter_core_unset_takes_options_first +# unset takes options first +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +unset -v x -n y; echo $?; unset -z; echo $?; unset -fn; echo $?; unset -n; echo $?; unset 'a b'; echo $? +### expect +1 +2 +0 +0 +0 +### end + +### bashbox_interpreter_core_unset_of_an_element_and_of_a_readonly_variable +# unset of an element and of a readonly variable +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(1 2 3); unset 'a[1]'; declare -p a; readonly r; unset -n r; echo $?; readonly -a ra=(1); unset 'ra[0]'; echo $? +### expect +declare -a a=([0]="1" [2]="3") +1 +1 +### end + +### bashbox_interpreter_core_set_n_reads_the_rest_without_running_it +# set -n reads the rest without running it +echo a; set -n; echo b +### expect +a +### end + +### bashbox_interpreter_core_set_o_noexec_too +# set -o noexec too +set -o noexec; echo hi +### expect +### end + +### bashbox_interpreter_core_set_k_takes_assignments_from_anywhere_in_a_command +# set -k takes assignments from anywhere in a command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() { echo "$x"; }; set -k; echo a=b c; f x=5; echo $-; set +k; echo a=b +### expect +c +5 +hkBc +a=b +### end + +### bashbox_interpreter_core_a_subscript_may_quote_or_escape_a +# a subscript may quote or escape a ] +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +declare -A a b c; a["x]"]=1; b[\]]=2; c['y z']=3; declare -p a b c +### expect +declare -A a=(["x]"]="1" ) +declare -A b=(["]"]="2" ) +declare -A c=(["y z"]="3" ) +### end + +### bashbox_interpreter_core_unset_stops_taking_options_at +# unset stops taking options at -- +x=1; unset -- x; echo $? ${x-unset} +### expect +0 unset +### end + +### bashbox_interpreter_core_redirections_report_why_a_file_can_t_be_opened +# redirections report why a file can't be opened +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +true <> /tmp; echo $?; touch f; echo hi > f/x; echo $?; cat < f/x; echo $?; ln -s loop loop; echo hi > loop; echo $? +### expect +1 +1 +1 +1 +### end + +### bashbox_interpreter_core_reading_a_directory_fails +# reading a directory fails +cat < /tmp; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_source_names_a_directory_it_can_t_read +# source names a directory it can't read +source /tmp; echo $? +### expect +1 +### end + +### bashbox_interpreter_core_set_o_keyword_shows_as_k +# set -o keyword shows as k +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -o keyword; echo $-; set -o | grep -E '^(keyword|noexec) ' +### expect +hkBc +keyword on +noexec off +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh new file mode 100644 index 000000000..b892ee413 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-just-bash-compat.test.sh @@ -0,0 +1,403 @@ +# BashBox just-bash-compat cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_just_bash_compat_read_consumes_stdin_line_by_line +# read consumes stdin line by line +printf "a\nb\n" | while read l; do echo "<$l>"; done +### expect + + +### end + +### bashbox_just_bash_compat_loop_reads_redirected_file +# loop reads redirected file +printf "1\n2\n" > in; while read l; do echo $l | cat; done < in +### expect +1 +2 +### end + +### bashbox_just_bash_compat_nested_reads_share_stdin +# nested reads share stdin +printf "1\n2\n" | { { read a; }; read b; echo $a$b; } +### expect +12 +### end + +### bashbox_just_bash_compat_read_returns_1_on_unterminated_last_line +# read returns 1 on unterminated last line +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "x" | { read v; echo "$?:$v"; } +### expect +1:x +### end + +### bashbox_just_bash_compat_pipe_into_if +# pipe into if +echo hi | if true; then cat; fi +### expect +hi +### end + +### bashbox_just_bash_compat_loop_output_goes_through_the_pipe +# loop output goes through the pipe +for i in b a; do echo $i; done | sort +### expect +a +b +### end + +### bashbox_just_bash_compat_loop_redirected_to_dev_null +# loop redirected to /dev/null +while true; do echo x; break; done >/dev/null; echo ok +### expect +ok +### end + +### bashbox_just_bash_compat_group_redirected_to_file_with_2_1 +# group redirected to file with 2>&1 +{ echo o; echo e >&2; } > f 2>&1; cat f +### expect +o +e +### end + +### bashbox_just_bash_compat_stderr_to_dev_null +# stderr to /dev/null +cat nope 2>/dev/null; echo rc=$? +### expect +rc=1 +### end + +### bashbox_just_bash_compat_stderr_into_the_pipe +# stderr into the pipe +ls nope 2>&1 | grep -c nope +### expect +1 +### end + +### bashbox_just_bash_compat_stdout_to_stderr +# stdout to stderr +echo e >&2 2>/dev/null; echo done +### expect +done +### end + +### bashbox_just_bash_compat_missing_input_file_skips_the_command +# missing input file skips the command +cat < nofile 2>/dev/null; echo rc=$? +### expect +rc=1 +### end + +### bashbox_just_bash_compat_break_exits_loop_with_status_0 +# break exits loop with status 0 +while :; do false; break; done; echo $? +### expect +0 +### end + +### bashbox_just_bash_compat_bare_assignment_status +# bare assignment status +false; x=1; echo $? +### expect +0 +### end + +### bashbox_just_bash_compat_assignment_takes_substitution_status +# assignment takes substitution status +x=$(false); echo $? +### expect +1 +### end + +### bashbox_just_bash_compat_quoted_keeps_words +# quoted $@ keeps words +set -- "a b" c; printf "[%s]" "$@"; echo +### expect +[a b][c] +### end + +### bashbox_just_bash_compat_empty_quoted_vanishes +# empty quoted $@ vanishes +set --; printf "[%s]" "$@" x; echo +### expect +[x] +### end + +### bashbox_just_bash_compat_joins_with_surrounding_text +# $@ joins with surrounding text +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- a b; printf "[%s]" "x$@y"; echo +### expect +[xa][by] +### end + +### bashbox_just_bash_compat_quoted_array_expansion +# quoted array expansion +a=("x y" z); printf "[%s]" "${a[@]}" "${a[*]}"; echo +### expect +[x y][z][x y z] +### end + +### bashbox_just_bash_compat_1_idiom +# ${1+"$@"} idiom +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- "a b" c; for w in "${1+"$@"}"; do echo "[$w]"; done +### expect +[a b] +[c] +### end + +### bashbox_just_bash_compat_positional_slice +# positional slice +set -- a b c; echo "${@:2}" +### expect +b c +### end + +### bashbox_just_bash_compat_positional_beyond_9 +# positional beyond 9 +set -- 1 2 3 4 5 6 7 8 9 10 11; echo ${10} ${11} +### expect +10 11 +### end + +### bashbox_just_bash_compat_unquoted_variable_is_split +# unquoted variable is split +x="a b"; printf "[%s]" $x "$x"; echo +### expect +[a][b][a b] +### end + +### bashbox_just_bash_compat_unquoted_empty_variable_vanishes +# unquoted empty variable vanishes +e=; printf "[%s]" $e "$e" x; echo +### expect +[][x] +### end + +### bashbox_just_bash_compat_custom_ifs_splits_expansions_only +# custom IFS splits expansions only +IFS=:; x=a:b; printf "[%s]" $x; echo +### expect +[a][b] +### end + +### bashbox_just_bash_compat_command_word_is_split +# command word is split +C="echo hi"; $C there +### expect +hi there +### end + +### bashbox_just_bash_compat_default_and_alternative_operators +# default and alternative operators +unset u; e=; echo "[${u-d}][${u+a}][${e-d}][${e+a}][${e:-d}]" +### expect +[d][][][a][d] +### end + +### bashbox_just_bash_compat_cannot_assign_to_positional +# cannot assign to positional +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +: ${1:=x}; echo after +### expect +### end + +### bashbox_just_bash_compat_error_if_unset_stops_the_script +# error-if-unset stops the script +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a; echo ${zz:?boom}; echo after +### expect +a +### end + +### bashbox_just_bash_compat_glob_matches_directories_only_with +# glob matches directories only with */ +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir d1 d2; touch f1; for d in */; do echo $d; done +### expect +d1/ +d2/ +### end + +### bashbox_just_bash_compat_quoted_glob_stays_literal +# quoted glob stays literal +touch a.txt b.txt; echo *.txt "*.txt" \*.txt +### expect +a.txt b.txt *.txt *.txt +### end + +### bashbox_just_bash_compat_glob_skips_dotfiles +# glob skips dotfiles +touch .h v; echo * +### expect +v +### end + +### bashbox_just_bash_compat_glob_bracket_expression +# glob bracket expression +touch a1 a2 b1; echo a[12] [!a]1 +### expect +a1 a2 b1 +### end + +### bashbox_just_bash_compat_glob_in_a_directory_component +# glob in a directory component +mkdir -p p/x p/y; touch p/x/f p/y/f; echo p/*/f +### expect +p/x/f p/y/f +### end + +### bashbox_just_bash_compat_sed_append_keeps_leading_blanks +# sed append keeps leading blanks +echo x | sed "a\ indented" +### expect +x + indented +### end + +### bashbox_just_bash_compat_sed_addresses_and_commands +# sed addresses and commands +printf "1\n2\n3\n" | sed -n 2p; printf "1\n2\n3\n" | sed 2d; printf "a\nb\n" | sed "1c X" +### expect +2 +1 +3 +X +b +### end + +### bashbox_just_bash_compat_sed_range_and_negation +# sed range and negation +printf "x\nstart\ny\nend\nz\n" | sed -n "/start/,/end/p"; printf "1\n2\n3\n" | sed "\$!d" +### expect +start +y +end +3 +### end + +### bashbox_just_bash_compat_sed_multiple_commands +# sed multiple commands +echo abc | sed "s/a/A/;s/c/C/" +### expect +AbC +### end + +### bashbox_just_bash_compat_sed_bre_groups_and_escaped_backslash +# sed BRE groups and escaped backslash +echo ab | sed "s/\(a\)\(b\)/\2\1/"; echo ab | sed "s/\(a\)/\\\\\1/" +### expect +ba +\ab +### end + +### bashbox_just_bash_compat_sed_nth_occurrence +# sed nth occurrence +echo aaa | sed s/a/X/2; echo aaa | sed s/a/X/2g +### expect +aXa +aXX +### end + +### bashbox_just_bash_compat_sed_n_and_hold_space +# sed N and hold space +printf "1\n2\n3\n4\n" | sed "N;s/\n/,/"; printf "1\n2\n" | sed -n "h;n;G;p" +### expect +1,2 +3,4 +2 +1 +### end + +### bashbox_just_bash_compat_head_and_tail_n_shorthand +# head and tail -N shorthand +printf "1\n2\n3\n" | head -2; printf "1\n2\n3\n" | tail -2 +### expect +1 +2 +2 +3 +### end + +### bashbox_just_bash_compat_tr_octal_escapes +# tr octal escapes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a | tr a "\101" +### expect +A +### end + +### bashbox_just_bash_compat_cp_f_and_n +# cp -f and -n +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a > f; echo b > g; cp -f f h && cat h; cp -n g f; cat f +### expect +a +a +### end + +### bashbox_just_bash_compat_yes_is_finite_in_a_pipe +# yes is finite in a pipe +yes | head -2; yes ab | head -1 +### expect +y +y +ab +### end + +### bashbox_just_bash_compat_realpath +# realpath +mkdir -p r/s; cd r; realpath s . | sed "s|.*/||" +### expect +s +r +### end + +### bashbox_just_bash_compat_mktemp_creates_a_private_file +# mktemp creates a private file +f=$(mktemp); test -f "$f" && echo file; d=$(mktemp -d); test -d "$d" && echo dir +### expect +file +dir +### end + +### bashbox_just_bash_compat_declare_a_compound_assignment_keeps_quoted_values +# declare -A compound assignment keeps quoted values +declare -A m=([k]="hello world" [j]=x); echo "${m[k]}|${m[j]}|${#m[@]}" +### expect +hello world|x|2 +### end + +### bashbox_just_bash_compat_local_a_compound_assignment +# local -A compound assignment +f(){ local -A m=([a]=1 [b]="2 3"); echo "${m[a]}${m[b]}"; }; f +### expect +12 3 +### end + +### bashbox_just_bash_compat_keyed_elements_in_an_indexed_array +# keyed elements in an indexed array +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(x [5]=y z); echo ${!a[@]} ${a[6]} +### expect +0 5 6 z +### end + +### bashbox_just_bash_compat_array_keys_are_expanded +# array keys are expanded +k=key; declare -A m=([$k]="v w"); echo "${m[key]}" +### expect +v w +### end + +### bashbox_just_bash_compat_readonly_array_declaration +# readonly array declaration +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +readonly -a r=(1 2); echo ${r[1]} +### expect +2 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh new file mode 100644 index 000000000..35f532161 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-lexer.test.sh @@ -0,0 +1,260 @@ +# BashBox lexer cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_lexer_comments +# comments +echo a # not printed +# whole line +echo b +### expect +a +b +### end + +### bashbox_lexer_trailing_whitespace +# trailing whitespace +echo a +### expect +a +### end + +### bashbox_lexer_line_continuation_between_words +# line continuation between words +echo a \ + b +### expect +a b +### end + +### bashbox_lexer_line_continuation_inside_a_word +# line continuation inside a word +echo a\ +b +### expect +ab +### end + +### bashbox_lexer_trailing_backslash +# trailing backslash +echo a\ +### expect +a\ +### end + +### bashbox_lexer_newlines_inside_quotes +# newlines inside quotes +echo 'a +b' "c +d" +### expect +a +b c +d +### end + +### bashbox_lexer_escapes_inside_backticks +# escapes inside backticks +echo `echo \`echo hi\`` +### expect +hi +### end + +### bashbox_lexer_parens_inside_quoted_command_substitution +# parens inside quoted command substitution +echo $(echo ")") $(echo "a\"b") $(echo 'x)') +### expect +) a"b x) +### end + +### bashbox_lexer_lone_dollar_signs +# lone dollar signs +echo $% a$ $ +### expect +$% a$ $ +### end + +### bashbox_lexer_nested_braces_in_parameter_expansion +# nested braces in parameter expansion +echo ${x:-{a}} +### expect +{a} +### end + +### bashbox_lexer_empty_braces_are_a_word +# empty braces are a word +echo {} +### expect +{} +### end + +### bashbox_lexer_bang_followed_by_text_is_a_word +# bang followed by text is a word +echo !x +### expect +!x +### end + +### bashbox_lexer_double_brackets_followed_by_text_are_a_word +# double brackets followed by text are a word +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo ]]x +### expect +]]x +### end + +### bashbox_lexer_nested_subshell_closing_with +# nested subshell closing with )) +( (echo hi)) +### expect +hi +### end + +### bashbox_lexer_nested_parens_in_arithmetic_command +# nested parens in arithmetic command +(( ((1+2)) == 3 && (1+(2)) == 3 )) && echo yes +### expect +yes +### end + +### bashbox_lexer_is_a_shift_inside +# << is a shift inside (( )) +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +(( x = 1 << 2 )) +echo $x +### expect +4 +### end + +### bashbox_lexer_inside_c_style_for_header +# ;; inside C-style for header +for ((;;)); do echo x; break; done +### expect +x +### end + +### bashbox_lexer_parens_inside_in_double_quotes +# parens inside $(...) in double quotes +echo "a $(echo ")") b" +### expect +a ) b +### end + +### bashbox_lexer_backticks_in_double_quotes +# backticks in double quotes +echo "a `echo ")"` b" +### expect +a ) b +### end + +### bashbox_lexer_quoted_brace_in +# quoted brace in ${...} +echo ${x:-'}'} +### expect +} +### end + +### bashbox_lexer_escaped_quote_in_ansi_c_quoting +# escaped quote in ANSI-C quoting +echo $'a\'b' +### expect +a'b +### end + +### bashbox_lexer_nested_parens_in +# nested parens in $((...)) +echo $((1+(2))) foo +### expect +3 foo +### end + +### bashbox_lexer_single_quoted +# single quoted +x=1; cat << 'EOF' +$x +EOF +### expect +$x +### end + +### bashbox_lexer_double_quoted +# double quoted +x=1; cat <<"EOF" +$x +EOF +### expect +$x +### end + +### bashbox_lexer_backslash_escaped +# backslash escaped +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; cat <<\E\OF +$x +EOF +### expect +$x +### end + +### bashbox_lexer_partly_quoted +# partly quoted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=1; cat < + + +p q + +pq +### end + +### bashbox_shell_variables_a_pipeline_sets_one_status_per_command +# a pipeline sets one status per command +true | false | true; echo "${PIPESTATUS[*]}" ${#PIPESTATUS[@]}; declare -p PIPESTATUS +### expect +0 1 0 3 +declare -a PIPESTATUS=([0]="0") +### end + +### bashbox_shell_variables_a_single_command_and_a_negated_one_set_it_too +# a single command and a negated one set it too +false; echo ${PIPESTATUS[@]}; ! true; echo ${PIPESTATUS[@]}; f() { return 3; }; f; echo ${PIPESTATUS[@]} +### expect +1 +0 +3 +### end + +### 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 +1 +0 +3 +### end + +### bashbox_shell_variables_other_compound_commands_and_definitions_leave_the_last_one_i +# other compound commands and definitions leave the last one inside them +false | true; { false | true; }; echo ${PIPESTATUS[@]}; false | true; case x in y) ;; esac; echo ${PIPESTATUS[@]}; false | true; f() { :; }; echo ${PIPESTATUS[@]} +### expect +1 0 +1 0 +1 0 +### end + +### bashbox_shell_variables_a_substitution_sees_the_status_from_before_it +# a substitution sees the status from before it +false | true; echo $(echo ${PIPESTATUS[@]}) +### expect +1 0 +### end + +### bashbox_shell_variables_pipefail_changes_but_not_pipestatus_and_the_shell_overwrites +# pipefail changes $? but not PIPESTATUS, and the shell overwrites it even when readonly +set -o pipefail; false | true; echo $? ${PIPESTATUS[@]}; PIPESTATUS=(9 9); echo ${PIPESTATUS[@]}; readonly PIPESTATUS; true | false; echo ${PIPESTATUS[@]} +### expect +1 1 0 +0 +0 1 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-glob.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-glob.test.sh new file mode 100644 index 000000000..b4bfc67ff --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-glob.test.sh @@ -0,0 +1,89 @@ +# BashBox shopt-glob cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_shopt_glob_nullglob_drops_a_pattern_that_matches_nothing +# nullglob drops a pattern that matches nothing +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s nullglob; echo a *.nope b; x=(*.nope); echo ${#x[@]}; echo "*.nope" +### expect +a b +0 +*.nope +### end + +### bashbox_shopt_glob_dotglob_lets_match_dotfiles +# dotglob lets * match dotfiles +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s dotglob; echo *; echo .*; shopt -u dotglob; echo *; echo .* +### expect +.hid C.TXT a.txt b.txt d +.hid +C.TXT a.txt b.txt d +.hid +### end + +### bashbox_shopt_glob_nocaseglob +# nocaseglob +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s nocaseglob; echo *.txt; echo [a-c]*; echo c.txt +### expect +C.TXT a.txt b.txt +C.TXT a.txt b.txt +c.txt +### end + +### bashbox_shopt_glob_globstar_makes_recurse +# globstar makes ** recurse +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s globstar; echo **; echo **/*.txt; echo d/**; echo d/**/; shopt -u globstar; echo **/*.txt +### expect +C.TXT a.txt b.txt d d/e d/e/y.txt d/x.txt +a.txt b.txt d/e/y.txt d/x.txt +d/ d/e d/e/y.txt d/x.txt +d/ d/e/ +d/x.txt +### end + +### bashbox_shopt_glob_a_literal_last_part_must_exist +# a literal last part must exist +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +echo */x.txt */nope.txt; echo d/*/; echo "*".txt \*.txt +### expect +d/x.txt */nope.txt +d/e/ +*.txt *.txt +### end + +### bashbox_shopt_glob_extglob_patterns_in_pathnames +# extglob patterns in pathnames +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +shopt -s extglob +echo !(*.txt); echo @(a|b).txt; echo *(a).txt; echo +([a-c]).txt; echo ?(a).txt; echo !(d|*.txt|C*) +echo "@(a)".txt; x='@(a|b).txt'; echo $x; echo "$x"; echo @(nomatch) +### expect +C.TXT d +a.txt b.txt +a.txt +a.txt b.txt +a.txt +!(d|*.txt|C*) +@(a).txt +a.txt b.txt +@(a|b).txt +@(nomatch) +### end + +### bashbox_shopt_glob_without_extglob_the_parentheses_are_literal +# without extglob the parentheses are literal +mkdir -p d/e; touch a.txt b.txt .hid C.TXT d/x.txt d/e/y.txt +x='@(a).txt'; echo $x +shopt -s extglob +echo $x +### expect +@(a).txt +a.txt +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-pattern.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-pattern.test.sh new file mode 100644 index 000000000..58188e9cc --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt-pattern.test.sh @@ -0,0 +1,144 @@ +# BashBox shopt-pattern cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_shopt_pattern_always_understands_extglob +# [[ ]] always understands extglob +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=abc; [[ $x == @(a|b)* ]] && echo y1; [[ $x == +([a-c]) ]] && echo y2; [[ $x == !(a*) ]] || echo y3; [[ "" == !(x) ]] && echo y4; [[ "a|b" == @(a\|b) ]] && echo y5; [[ abab == +(ab|a) ]] && echo y6; [[ b == *(a)b ]] && echo y7; [[ ab == ?(a)b ]] && echo y8 +### expect +y1 +y2 +y3 +y4 +y5 +y6 +y7 +y8 +### end + +### bashbox_shopt_pattern_case_needs_shopt_s_extglob +# case needs shopt -s extglob +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +p='@(abc)'; case abc in $p) echo no;; *) echo plain;; esac +shopt -s extglob +case abc in $p) echo yes;; esac; case abc in @(abc)) echo yes;; esac; case a in !(a)*) echo m1;; esac +### expect +plain +yes +yes +m1 +### end + +### bashbox_shopt_pattern_pattern_removal_and_substitution_with_extglob +# pattern removal and substitution with extglob +shopt -s extglob +x=abc; echo "${x//*(z)/-}|${x/*(z)/-}|${x//!(b)/-}|${x/!(b)/-}|${x#!(b)}|${x##!(b)}|${x%!(b)}|${x%%!(b)}|${x/#!(a)/-}|${x/%!(c)/-}" +echo ${x/@(b|c)/X} ${x//[ac]/Y}; y=aXbXc; echo ${y//!(X)/-} ${x//?(b)/-} +x=aab; echo ${x##+(a)} ${x#+(a)} ${x%%+(b)} ${x/+(a)/-}; z=a; echo "${z//!(a)/-}" "${z/#!(b)/-}" "${x/%!(z)/-}" "${x/!(a*)/-}" "${x/!(*)/-}" +### expect +-a-b-c|-abc|-|-|abc||abc||-|- +aXc YbY +- -a--c +b ab aa -b +-a - - -aab aab +### end + +### bashbox_shopt_pattern_quoted_parts_of_a_pattern_match_literally +# quoted parts of a pattern match literally +[[ abc == "a"* ]] && echo q1; [[ abc == "a*" ]] || echo q2; case "a*" in "a*") echo q3;; esac; case ab in "a"?) echo q4;; esac +p='a*'; [[ abc == $p ]] && echo q5; [[ abc == "$p" ]] || echo q6; [[ x != "x" ]] || echo q7 +### expect +q1 +q2 +q3 +q4 +q5 +q6 +q7 +### end + +### bashbox_shopt_pattern_nocasematch_covers_case_and_substitution_not_removal +# nocasematch covers case, [[ ]] and substitution, not removal +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=ABC; shopt -s nocasematch +case ABC in a*) echo m1;; esac; [[ ABC == ab? ]] && echo m2; [[ ABC =~ ^ab ]] && echo m3; [[ ABC = abc ]] && echo m4; echo ${x#a} ${x/b/z} ${x%c} ${x//b/z} ${x/#a/q}; [[ $x != abc ]]; echo $? +shopt -u nocasematch; [[ ABC == abc ]] || echo m5 +### expect +m1 +m2 +m3 +m4 +ABC AzC ABC AzC qBC +1 +m5 +### end + +### bashbox_shopt_pattern_bracket_expressions_in_removal_patterns +# bracket expressions in removal patterns +x=ABC; echo ${x#[AB]} ${x##[[:upper:]]*} ${x%[!C]C} ${x/[z-a]/q} ${x#[z-a]} +### expect +BC A ABC ABC +### end + +### bashbox_shopt_pattern_array_and_positional_operations_apply_per_element +# array and positional operations apply per element +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- ab ac; echo "${@#a}" "${@/c/x}"; a=(xa ya); echo "${a[@]%a}" "${a[*]^^}"; x=aab; echo ${x/q/z} ${x/#/-} ${x/%/-} "${x//b/\$}"; declare -A m=([k1]=a [k2]=b); for k in "${!m[@]}"; do echo "<$k>"; done +### expect +b c ab ax +x y XA YA +aab -aab aab- aa$ + + +### end + +### bashbox_shopt_pattern_quotes_inside_a_group +# quotes inside a group +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +[[ "b)" == @(a|'b)') ]] && echo q1; [[ 'a"' == @("a\"") ]] && echo q2; [[ 'a|' == @(a\|) ]] && echo q3 +### expect +q1 +q2 +q3 +### end + +### bashbox_shopt_pattern_an_unbalanced_group_from_a_variable_is_literal +# an unbalanced group from a variable is literal +p='@(a'; [[ '@(a' == $p ]] && echo lit +### expect +lit +### end + +### bashbox_shopt_pattern_a_negation_nested_in_another_group +# a negation nested in another group +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +[[ c == @(!(a)|b) ]] && echo y1; [[ a == @(!(a)) ]] || echo y2; [[ ab == !(x)? ]] && echo y3 +### expect +y1 +y2 +y3 +### end + +### bashbox_shopt_pattern_groups_next_to_a_negation +# groups next to a negation +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +[[ abab == !(x)+(ab) ]] && echo y1; [[ ab == @(a)!(x) ]] && echo y2; [[ b == ?(a)!(z) ]] && echo y3; [[ aab == *(a)!(z) ]] && echo y4; [[ ab == +(a|ab)!(z) ]] && echo y5; [[ aaa == !(z)*(a|aa) ]] && echo y6; [[ x == !(z)+(a) ]] || echo y7; [[ ab == !(x)+(a|b) ]] && echo y8 +### expect +y1 +y2 +y3 +y4 +y5 +y6 +y7 +y8 +### end + +### bashbox_shopt_pattern_no_match_leaves_the_value_alone +# no match leaves the value alone +shopt -s extglob +x=abc; echo ${x#!(*)} ${x/#!(*)/-} ${x//!(*)/-} ${x%%!(*)} +### expect +abc abc abc abc +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-shopt.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt.test.sh new file mode 100644 index 000000000..1f632abae --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-shopt.test.sh @@ -0,0 +1,70 @@ +# BashBox shopt cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_shopt_unknown_names_are_reported_but_the_rest_still_apply +# unknown names are reported but the rest still apply +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +shopt -s extglob nope nullglob; echo s=$?; shopt -p nullglob extglob; shopt -q nope; echo s=$? +### expect +s=1 +shopt -s nullglob +shopt -s extglob +s=1 +### end + +### bashbox_shopt_bad_flags +# bad flags +shopt -s -u x; echo s=$?; shopt -x; echo s=$?; shopt -sx; echo s=$? +### expect +s=1 +s=2 +s=2 +### end + +### bashbox_shopt_o_with_a_bad_name +# -o with a bad name +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +shopt -so nope; echo s=$?; shopt -uo nope; echo s=$?; shopt -o nope; echo s=$?; shopt -po nope errexit; echo s=$? +### expect +s=0 +s=0 +s=1 +set +o errexit +s=1 +### end + +### bashbox_shopt_lastpipe_is_accepted +# lastpipe is accepted +shopt -s lastpipe; echo a | read v; echo "[$v]" +### expect +[a] +### end + +### bashbox_shopt_aliases_expand_only_with_expand_aliases_from_the_next_line_o +# aliases expand only with expand_aliases, from the next line on +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +alias e='echo hi' +e there +shopt -s expand_aliases +e there +alias echo='echo [e]'; echo x; x=1 e y > f; cat f +alias c='cat'; c < f; sort -o f f; cat f +### expect +a +b +### end + +### bashbox_sort_c_on_sorted_input +# -c on sorted input +printf 'a\nb\nb\n' | sort -c +### expect +### end + +### bashbox_sort_m_merges_sorted_inputs +# -m merges sorted inputs +printf 'a\nc\ne\n' > f1; printf 'b\nc\nd\n' > f2; sort -m f1 f2 +### expect +a +b +c +c +d +e +### end + +### bashbox_sort_m_does_not_sort +# -m does not sort +printf 'b\na\n' > f1; printf 'c\n' > f2; sort -m f1 f2 +### expect +b +a +c +### end + +### bashbox_sort_m_with_keys_and_reverse +# -m with keys and reverse +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '3 a\n1 b\n' > f1; printf '2 c\n' > f2; sort -m -k1,1nr f1 f2 +### expect +3 a +2 c +1 b +### end + +### bashbox_sort_long_options +# long options +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'b:2\na:10\n' | sort --field-separator=: --key=2 --numeric-sort --reverse +### expect +a:10 +b:2 +### end + +### bashbox_sort_sort_selects_the_comparison +# --sort selects the comparison +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '10\n9\n' | sort --sort=num +### expect +9 +10 +### end + +### bashbox_sort_ignored_performance_options +# ignored performance options +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'b\na\n' | sort -S 1M -T /tmp --parallel=2 --compress-program=gzip +### expect +a +b +### end + +### bashbox_sort_r_groups_equal_lines +# -R groups equal lines +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\na\nb\n' | sort -R | uniq | sort +### expect +a +b +### end + +### bashbox_sort_d_is_compatible_with_v +# -d is compatible with -V +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a-2\na-10\n' | sort -dV +### expect +a-2 +a-10 +### end + +### bashbox_sort_r_with_f +# -R with -f +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nA\nb\na\n' | sort -fR | tr A a | uniq | sort +### expect +a +b +### end + +### bashbox_sort_the_same_tab_twice +# the same tab twice +printf 'b:1\na:2\n' | sort -t: -t: -k2 +### expect +b:1 +a:2 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh new file mode 100644 index 000000000..69396acf4 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh @@ -0,0 +1,19 @@ +# BashBox stat cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_stat_name_needing_double_quotes +# name needing double quotes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf x > "a'b c"; stat -c %N "a'b c" +### expect +"a'b c" +### end + +### bashbox_stat_name_needing_escapes +# name needing escapes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf x > $'a\nb'; stat -c %N $'a\nb' +### expect +'a'$'\n''b' +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh new file mode 100644 index 000000000..565a365c6 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-syntax-error.test.sh @@ -0,0 +1,113 @@ +# BashBox syntax-error cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_syntax_error_eval_reports_and_returns_2 +# eval reports and returns 2 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +eval fi; echo $? +### expect +2 +### end + +### bashbox_syntax_error_a_sourced_file_reports_under_its_name_and_returns_2 +# a sourced file reports under its name and returns 2 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo 'if then' > bad.sh; source ./bad.sh; echo $? +### expect +2 +### end + +### bashbox_syntax_error_a_backquoted_substitution_is_parsed_when_it_runs_and_fails_w +# a backquoted substitution is parsed when it runs and fails with 2 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=`fi`; echo "[$x] $?" +### expect +[] 2 +### end + +### bashbox_syntax_error_the_exit_trap_reports_its_own_syntax_error +# the EXIT trap reports its own syntax error +trap fi EXIT; echo a +### expect +a +### end + +### bashbox_syntax_error_the_err_trap_reports_its_own_syntax_error +# the ERR trap reports its own syntax error +trap fi ERR; false; echo $? +### expect +1 +### end + +### bashbox_syntax_error_the_return_trap_reports_its_own_syntax_error +# the RETURN trap reports its own syntax error +f(){ trap fi RETURN; }; f; echo $? +### expect +0 +### end + +### bashbox_syntax_error_a_case_pattern +# a case pattern +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +x=$(case a in a) echo A;; esac); echo "[$x]" +### expect +[A] +### end + +### bashbox_syntax_error_a_case_pattern_in_double_quotes +# a case pattern in double quotes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo "$(case b in a) echo A;; b) echo B;; esac)" +### expect +B +### end + +### bashbox_syntax_error_a_comment +# a comment +x=$( # comment with ) +echo hi); echo "[$x]" +### expect +[hi] +### end + +### bashbox_syntax_error_a_comment_after_a_command +# a comment after a command +x=$(echo a #) +); echo $x +### expect +a +### end + +### bashbox_syntax_error_a_here_document +# a here-document +x=$(cat <out 2>&1 out 2>&1 < in; + x=1 y= z+=2 +} +### end + +### bashbox_type_printer_pipelines_and_or_lists_and_background_jobs +# pipelines, |&, !, and-or lists and background jobs +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +g() { echo a | grep b |& cat; ! true && false || echo x; sleep 1 & echo bg; } +type g +### expect +g is a function +g () +{ + echo a | grep b 2>&1 | cat; + ! true && false || echo x; + sleep 1 & echo bg +} +### end + +### bashbox_type_printer_a_trailing_and_a_list_of_background_jobs +# a trailing & and a list of background jobs +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +d() { a & } +e() { a & +b & } +type d e +### expect +d is a function +d () +{ + a & +} +e is a function +e () +{ + a & b & +} +### end + +### bashbox_type_printer_if_elif_and_else_nest_like_bash_stores_them +# if, elif and else nest like bash stores them +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +h() { if a; then b; elif c; then d; else e; fi; } +type h +### expect +h is a function +h () +{ + if a; then + b; + else + if c; then + d; + else + e; + fi; + fi +} +### end + +### bashbox_type_printer_for_a_bare_for_while_and_until +# for, a bare for, while and until +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +h() { for i in 1 2; do echo $i; done; for j; do :; done; while x; do y; done; until x; do y; done; } +type h +### expect +h is a function +h () +{ + for i in 1 2; + do + echo $i; + done; + for j in "$@"; + do + :; + done; + while x; do + y; + done; + until x; do + y; + done +} +### end + +### bashbox_type_printer_case_with_alternatives_fallthrough_and_an_empty_body +# case with alternatives, fallthrough and an empty body +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +k() { case $1 in a|b) echo ab;; c) echo c;& d) ;;& *) echo def; esac; case x in (a) ;; esac; case w in esac; } +type k +### expect +k is a function +k () +{ + case $1 in + a | b) + echo ab + ;; + c) + echo c + ;& + d) + + ;;& + *) + echo def + ;; + esac; + case x in + a) + + ;; + esac; + case w in + esac +} +### end + +### bashbox_type_printer_subshells_groups_arithmetic_and_conditionals +# subshells, groups, arithmetic and conditionals +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +k() { ( sub; shell ); { grp; }; (( x++ )); ((y=1)); [[ -n $a && ( $b == c* || ! -f x ) ]]; [[ x ]]; [[ ! ( a == b ) ]]; [[ (a) ]]; } +type k +### expect +k is a function +k () +{ + ( sub; + shell ); + { + grp + }; + (( x++ )); + ((y=1)); + [[ -n $a && ( $b == c* || ! -f x ) ]]; + [[ -n x ]]; + [[ ! ( a == b ) ]]; + [[ ( -n a ) ]] +} +### end + +### bashbox_type_printer_c_style_for_keeps_each_part_as_written_empty_parts_read_1 +# C-style for keeps each part as written, empty parts read 1 +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a() { for ((i=0;i<3;i++)); do echo; done; for (( i = 0 ; i < 3 ; i++ )); do :; done; for ((;;)); do break; done; } +type a +### expect +a is a function +a () +{ + for ((i=0; i<3; i++)) + do + echo; + done; + for ((i = 0 ; i < 3 ; i++ )) + do + :; + done; + for ((1; 1; 1)) + do + break; + done +} +### end + +### bashbox_type_printer_a_subshell_body +# a subshell body +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +m() ( echo subshell-body ) +type m +### expect +m is a function +m () +{ + ( echo subshell-body ) +} +### end + +### bashbox_type_printer_a_compound_body_that_is_not_a_group +# a compound body that is not a group +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +f() if true; then :; fi +g() for i in a; do :; done 2>/dev/null +type f g +### expect +f is a function +f () +{ + if true; then + :; + fi +} +g is a function +g () +{ + for i in a; + do + :; + done 2> /dev/null +} +### end + +### bashbox_type_printer_here_documents_follow_the_line_that_opens_them +# here-documents follow the line that opens them +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +n() { cat < f + lit + Q +echo after; } +type n +### expect +n is a function +n () +{ + cat < f +lit +Q + + echo after +} +### end + +### bashbox_type_printer_a_here_document_ending_the_body +# a here-document ending the body +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +c() { cat <f; echo hi +one +A +two +B +} +w() { cat < f +one +A +two +B + + echo hi +} +w is a function +w () +{ + cat < /dev/null 2>&1 +type q +### expect +q is a function +q () +{ + echo a +} > /dev/null 2>&1 +### end + +### bashbox_type_printer_comments_and_continuations_disappear +# comments and continuations disappear +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +t() { echo # comment +} +v() { + # leading comment + echo one # trailing + echo "multi +line" \ + cont +} +type t v +### expect +t is a function +t () +{ + echo +} +v is a function +v () +{ + echo one; + echo "multi +line" cont +} +### end + +### bashbox_type_printer_quoting_is_kept_as_written +# quoting is kept as written +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +j() { echo a\ b "c\"d" 'e' \$x x=1; } +type j +### expect +j is a function +j () +{ + echo a\ b "c\"d" 'e' \$x x=1 +} +### end + +### bashbox_type_printer_declare_f_prints_every_function_sorted +# declare -f prints every function, sorted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +b() { :; } +a() { echo; } +declare -f +### expect +a () +{ + echo +} +b () +{ + : +} +### end + +### bashbox_type_printer_declare_f_with_names_failing_quietly_for_a_missing_one +# declare -f with names, failing quietly for a missing one +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +b() { :; } +declare -f b nope; echo "s=$?" +### expect +b () +{ + : +} +s=1 +### end + +### bashbox_type_printer_declare_f_lists_names +# declare -F lists names +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +b() { :; } +a() { :; } +declare -F; declare -F b nope a; echo "s=$?" +### expect +declare -f a +declare -f b +b +a +s=1 +### end + +### bashbox_type_printer_type_reports_each_name +# type reports each name +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +b() { :; } +type b nope; echo "s=$?"; type -t b +### expect +b is a function +b () +{ + : +} +s=1 +function +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh new file mode 100644 index 000000000..893d5144c --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh @@ -0,0 +1,115 @@ +# BashBox unexpand cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_unexpand_single_space_before_stop +# single space before stop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abcdefg h\n' | unexpand -a +### expect +abcdefg h +### end + +### bashbox_unexpand_single_blank_becomes_tab +# single blank becomes tab +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abcdefg \tx\n' | unexpand -a +### expect +abcdefg x +### end + +### bashbox_unexpand_list +# list +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c\n' | unexpand -t 2,6 +### expect + a b c +### end + +### bashbox_unexpand_list_exhausted +# list exhausted +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c d\n' | unexpand -t 2,6 +### expect + a b c d +### end + +### bashbox_unexpand_extend +# extend +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c d\n' | unexpand -t 2,/3 +### expect + a b c d +### end + +### bashbox_unexpand_increment +# increment +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c d\n' | unexpand -t 2,+3 +### expect + a b c d +### end + +### bashbox_unexpand_obsolete_list +# obsolete list +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c\n' | unexpand -2,6 +### expect + a b c +### end + +### bashbox_unexpand_obsolete_trailing_comma +# obsolete trailing comma +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' a b c\n' | unexpand -2,6, +### expect + a b c +### end + +### bashbox_unexpand_trailing_blanks_without_newline +# trailing blanks without newline +printf 'a ' | unexpand -a | od -c +### expect +0000000 a \t +0000002 +### end + +### bashbox_unexpand_non_blank_stops_leading_conversion +# non-blank stops leading conversion +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'x y\n' | unexpand +### expect +x y +### end + +### bashbox_unexpand_tab_at_stop +# tab at stop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '\t\t a\n' | unexpand -t 4 +### expect + a +### end + +### bashbox_unexpand_spaces_then_tab +# spaces then tab +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf ' \ta\n' | unexpand +### expect + a +### end + +### bashbox_unexpand_one_space_then_tab +# one space then tab +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abcdefg \t \tx\n' | unexpand -a +### expect +abcdefg x +### end + +### bashbox_unexpand_blank_past_last_stop +# blank past last stop +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abc d\n' | unexpand -t 2 +### expect +abc d +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh new file mode 100644 index 000000000..1378b66b3 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh @@ -0,0 +1,88 @@ +# BashBox uniq cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_uniq_c_counts_runs +# -c counts runs +printf 'a\na\nb\na\n' | uniq -c +### expect + 2 a + 1 b + 1 a +### end + +### bashbox_uniq_d_keeps_only_repeated_lines +# -d keeps only repeated lines +printf 'a\na\nb\n' | uniq -d +### expect +a +### end + +### bashbox_uniq_u_keeps_only_unique_lines +# -u keeps only unique lines +printf 'a\na\nb\n' | uniq -u +### expect +b +### end + +### bashbox_uniq_adds_a_missing_final_newline +# adds a missing final newline +printf 'x\nx' | uniq +### expect +x +### end + +### bashbox_uniq_empty_input +# empty input +printf '' | uniq +### expect +### end + +### bashbox_uniq_second_operand_is_the_output_file +# second operand is the output file +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\na\n' > in; uniq in out; cat out +### expect +a +### end + +### bashbox_uniq_i_ignores_case +# -i ignores case +printf 'a\nA\nb\n' | uniq -ic +### expect + 2 a + 1 b +### end + +### bashbox_uniq_f_skips_fields +# -f skips fields +printf 'x a\ny a\nz b\n' | uniq -f1 +### expect +x a +z b +### end + +### bashbox_uniq_f_past_the_last_field_compares_empty_keys +# -f past the last field compares empty keys +printf 'a b\nc b\n' | uniq -f 5 +### expect +a b +### end + +### bashbox_uniq_s_skips_bytes +# -s skips bytes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'xa\nya\nzb\n' | uniq --skip-chars=1 +### expect +xa +zb +### end + +### bashbox_uniq_w_compares_a_prefix +# -w compares a prefix +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'ab1\nab2\nac\n' | uniq -w2 +### expect +ab1 +ac +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-utf8.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-utf8.test.sh new file mode 100644 index 000000000..9c1bd0c4a --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-utf8.test.sh @@ -0,0 +1,78 @@ +# BashBox utf8 cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_utf8_length_counts_characters +# length counts characters +x='héllo wörld'; echo ${#x}; y='日本語'; echo ${#y} +### expect +11 +3 +### end + +### bashbox_utf8_array_element_length +# array element length +a=('héllo' 'ö'); echo "${#a[0]} ${#a[1]} ${#a[@]}" +### expect +5 1 2 +### end + +### bashbox_utf8_positional_length +# positional length +set -- 'é' 'ab'; echo ${#1} ${#2} +### expect +1 2 +### end + +### bashbox_utf8_substring_by_character +# substring by character +x='héllo'; echo "${x:1:2}|${x: -3}|${x:0:1}|${x:4}|${x:(-4):2}" +### expect +él|llo|h|o|él +### end + +### bashbox_utf8_substring_of_wide_characters +# substring of wide characters +x='日本語'; echo "${x:1} ${x: -1:1}" +### expect +本語 語 +### end + +### bashbox_utf8_invalid_utf_8_counts_bytes +# invalid UTF-8 counts bytes +x=$'\xff\xfe'; echo ${#x}; y=$'a\xffb'; echo ${#y} "${y:2}" +### expect +2 +3 b +### end + +### bashbox_utf8_patterns_match_characters +# patterns match characters +x='héllo'; echo "${x#?}|${x%??}|${x/?/X}|${x//[é]/E}|${x/#h?/Y}" +### expect +éllo|hél|Xéllo|hEllo|Yllo +### end + +### bashbox_utf8_case_and_patterns +# case and [[ ]] patterns +x='héllo'; case $x in h?llo) echo case;; esac; [[ $x == h?llo ]] && echo glob; [[ $x =~ ^h.llo$ ]] && echo regex +### expect +case +glob +regex +### end + +### bashbox_utf8_filename_globbing_by_character +# filename globbing by character +touch é1 ab1; echo ?1 +### expect +é1 +### end + +### bashbox_utf8_patterns_on_invalid_utf_8 +# patterns on invalid UTF-8 +x=$'\xffa'; echo "${x#?}"; case $x in ?a) echo case;; esac +### expect +a +case +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-wc.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-wc.test.sh new file mode 100644 index 000000000..a0db97347 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-wc.test.sh @@ -0,0 +1,11 @@ +# BashBox wc cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_wc_stdin_reserves_seven_columns +# stdin reserves seven columns +echo hi | wc; echo hi | wc -lc +### expect + 1 1 3 + 1 3 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-word-expander.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-word-expander.test.sh new file mode 100644 index 000000000..b2735fea8 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-word-expander.test.sh @@ -0,0 +1,310 @@ +# BashBox word-expander cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_word_expander_braced_positional_and_counts +# braced positional and counts +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +set -- a b; echo ${1} ${#} ${#@} ${#*} +### expect +a 2 2 2 +### end + +### bashbox_word_expander_positional_slices +# positional slices +set -- a b c; echo ${@:2} ${*: -1} +### expect +b c c +### end + +### bashbox_word_expander_name_i_is_not_an_array_reference +# $name[i] is not an array reference +a=(x y); echo $a[1] +### expect +x[1] +### end + +### bashbox_word_expander_bare_array_name_is_element_0 +# bare array name is element 0 +a=(x y); echo $a ${a} +### expect +x x +### end + +### bashbox_word_expander_substring_with_negative_offset_length +# substring with negative offset/length +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +s=hello; echo ${s: -2} ${s:1:-1} ${s: -3:2} "[${s: -10}]" ${s:1+1:2} +### expect +lo ell ll [] ll +### end + +### bashbox_word_expander_array_slices +# array slices +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +a=(1 2 3 4); echo ${a[@]: -2} ${a[@]:1:2} "[${a[@]: -9}]" +### expect +3 4 2 3 [] +### end + +### bashbox_word_expander_subscripts_are_arithmetic +# subscripts are arithmetic +a=(x y z); i=1; echo ${a[$i]} ${a[i]} ${a[i+1]} ${a[-1]} +### expect +y y z z +### end + +### bashbox_word_expander_associative_subscript_is_expanded +# associative subscript is expanded +declare -A m; m[k]=v; key=k; echo ${m[$key]} ${m[k]} +### expect +v v +### end + +### bashbox_word_expander_element_length +# element length +a=(foo bar); echo ${#a[1]} +### expect +3 +### end + +### bashbox_word_expander_keys_and_counts +# keys and counts +a=(b a); echo ${!a[@]} ${#a[@]} ${a[*]} +### expect +0 1 2 b a +### end + +### bashbox_word_expander_indirection +# indirection +r=x; x=val; echo "[${!r}]" +### expect +[val] +### end + +### bashbox_word_expander_indirection_to_an_element +# indirection to an element +a=(p q); r="a[1]"; echo ${!r} +### expect +q +### end + +### bashbox_word_expander_nested_default +# nested default +x=; echo ${x:-${y:-z}} +### expect +z +### end + +### bashbox_word_expander_element_default +# element default +a=(p q); echo ${a[0]:-d} ${a[5]:-d} +### expect +p d +### end + +### bashbox_word_expander_set_vs_null_tests +# set vs null tests +unset x; echo ${x-unset} ${x+set}; x=; echo ${x-unset} ${x+set} ${x:+nonempty} +### expect +unset +set +### end + +### bashbox_word_expander_empty_uses_default +# empty $@ uses default +set --; echo "[${@-x}] [${@:-y}]" +### expect +[x] [y] +### end + +### bashbox_word_expander_assign_default +# assign default +echo ${z=assigned} $z +### expect +assigned assigned +### end + +### bashbox_word_expander_assign_default_to_element +# assign default to element +echo ${a[1]:=x} ${a[1]} +### expect +x x +### end + +### bashbox_word_expander_error_if_unset_passes_a_set_value +# error-if-unset passes a set value +x=v; echo ${x?never} ${x:?never} +### expect +v v +### end + +### bashbox_word_expander_pattern_operand_is_expanded +# pattern operand is expanded +p=b; x=abc; echo ${x#*$p} +### expect +c +### end + +### bashbox_word_expander_quoted_pattern_characters_are_literal +# quoted pattern characters are literal +x='a*b'; echo ${x#*\*} ${x%"*"b} +### expect +b a +### end + +### bashbox_word_expander_prefix_suffix_removal +# prefix/suffix removal +x=a.b.c; echo ${x#*.} ${x##*.} ${x%.*} ${x%%.*} ${x#?} +### expect +b.c c a.b a .b.c +### end + +### bashbox_word_expander_anchored_replacement +# anchored replacement +x=abc; echo ${x/#a/X} ${x/%c/Y} ${x/b} +### expect +Xbc abY ac +### end + +### bashbox_word_expander_replacement_is_not_a_regex_template +# replacement is not a regex template +x=abc; r='\0$1'; echo ${x/b/$r} +### expect +a\0$1c +### end + +### bashbox_word_expander_replace_first_all +# replace first/all +x=abab; echo ${x/b/X} ${x//b/X} +### expect +aXab aXaX +### end + +### bashbox_word_expander_case_modification +# case modification +x=hello; echo ${x^} ${x^^} ${x,} ${x,,} +### expect +Hello HELLO hello hello +### end + +### bashbox_word_expander_nested_arithmetic_parens +# nested arithmetic parens +echo $(( ((1+2)) * 3 )) $(( (1+(2)) )) +### expect +9 3 +### end + +### bashbox_word_expander_quoted_parens_in +# quoted parens in $(...) +echo $(echo ")" "(" '(') $(echo "a)b\")") +### expect +) ( ( a)b") +### end + +### bashbox_word_expander_joins_with_first_ifs_char +# "$*" joins with first IFS char +set -- a b; IFS=:; echo "$*" +### expect +a:b +### end + +### bashbox_word_expander_trailing_backslash_pattern +# trailing backslash pattern +x="a\\"; p="\\"; echo ${x%$p} +### expect +a +### end + +### bashbox_word_expander_lone_or_unknown +# lone or unknown $ +echo a$%b $ +### expect +a$%b $ +### end + +### bashbox_word_expander_unset_variable_is_empty +# unset variable is empty +echo $nothing. +### expect +. +### end + +### bashbox_word_expander_double_quote_escapes +# double-quote escapes +echo "a\"b\$c\\d\q" +### expect +a"b$c\d\q +### end + +### bashbox_word_expander_backtick_keeps_other_backslashes +# backtick keeps other backslashes +x=`printf "%s" "a\qb"`; echo $x +### expect +a\qb +### end + +### bashbox_word_expander_nested_backticks +# nested backticks +x=`echo \`echo hi\``; echo $x +### expect +hi +### end + +### bashbox_word_expander_ifs_splitting_with_non_whitespace +# IFS splitting with non-whitespace +IFS=:; x=":a::b:"; printf "[%s]" $x; echo +### expect +[][a][][b] +### end + +### bashbox_word_expander_empty_unquoted_word_vanishes +# empty unquoted word vanishes +x=; printf "[%s]" $x a; echo +### expect +[a] +### end + +### bashbox_word_expander_empty_ifs_does_not_split +# empty IFS does not split +IFS=; x="a b"; printf "[%s]" $x; echo +### expect +[a b] +### end + +### bashbox_word_expander_whitespace_ifs_collapses +# whitespace IFS collapses +x=" a b "; printf "[%s]" $x; echo +### expect +[a][b] +### end + +### bashbox_word_expander_keeps_words +# "$@" keeps words +f(){ for x in "$@"; do echo "[$x]"; done; }; f a "b c" +### expect +[a] +[b c] +### end + +### bashbox_word_expander_escaped_comma_and_non_expanding_braces +# escaped comma and non-expanding braces +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo {a\,b,c} {a}b {abc} {1..c} +### expect +a,b c {a}b {abc} {1..c} +### end + +### bashbox_word_expander_descending_ranges +# descending ranges +echo {5..1} {c..a} +### expect +5 4 3 2 1 c b a +### end + +### bashbox_word_expander_stepped_ranges +# stepped ranges +echo {1..9..3} {a..e..2} {1..5..-2} +### expect +1 4 7 a c e 1 3 5 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh new file mode 100644 index 000000000..a6c6ca98c --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh @@ -0,0 +1,344 @@ +# BashBox xargs cases +# Imported from github.com/shipfastlabs/bashbox tests/ (MIT, Copyright (c) Pushpak Chhajed; see NOTICE). +# Expected stdout was recorded from GNU bash and re-verified against host bash before import. + +### bashbox_xargs_defaults_to_echo +# defaults to echo +printf 'a b\n c' | xargs +### expect +a b c +### end + +### bashbox_xargs_appends_items_to_the_command +# appends items to the command +echo a b | xargs echo X +### expect +X a b +### end + +### bashbox_xargs_n_limits_items_per_run +# -n limits items per run +echo a b c | xargs -n 2; echo a b | xargs -n1 echo - +### expect +a b +c +- a +- b +### end + +### bashbox_xargs_options_after_the_command_belong_to_it +# options after the command belong to it +echo a b | xargs -n1 echo -n; echo +### expect +ab +### end + +### bashbox_xargs_i_runs_once_per_line_keeping_trailing_blanks +# -I runs once per line, keeping trailing blanks +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'one\n\n two \n' | xargs -I {} echo '<{}>' {} +### expect + one + two +### end + +### bashbox_xargs_i_with_an_attached_placeholder +# -I with an attached placeholder +echo x | xargs -I% echo [%] +### expect +[x] +### end + +### bashbox_xargs_runs_once_on_empty_input +# runs once on empty input +printf '' | xargs echo hi +### expect +hi +### end + +### bashbox_xargs_r_skips_empty_input +# -r skips empty input +printf '' | xargs -r echo hi +### expect +### end + +### bashbox_xargs_quotes_and_backslashes_group_words +# quotes and backslashes group words +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '%s\n' '"a b" '\''c d'\'' e\\ f g\\\\h' 'i\\' 'j' | xargs -n1 echo +### expect +a b +c d +e\ +f +g\\h +i\ +j +### end + +### bashbox_xargs_an_empty_quoted_word_is_kept_but_a_final_one_is_dropped +# an empty quoted word is kept but a final one is dropped +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf '"" a\nb ""' | xargs -n1 echo -; printf "c '" | xargs echo +### expect +- +- a +- b +c +### end + +### bashbox_xargs_a_backslash_escapes_a_newline +# a backslash escapes a newline +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\\\nb\n' | xargs -n1 echo; printf 'c\\' | xargs echo +### expect +a +b +c +### end + +### bashbox_xargs_a_nul_ends_an_argument_with_a_warning +# a NUL ends an argument, with a warning +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\0b c\0d\n' | xargs echo +### expect +a c +### end + +### bashbox_xargs_0_splits_at_nuls +# -0 splits at NULs +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b\0\0c\0' | xargs -0 -n1 echo - +### expect +- a b +- +- c +### end + +### bashbox_xargs_d_takes_a_character_or_an_escape +# -d takes a character or an escape +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a,b,,c' | xargs -d, echo; printf 'a\tb\n' | xargs -d '\t' echo; printf 'aXb' | xargs -d '\x58' echo; printf 'aXb' | xargs --delimiter='\130' echo; printf 'a\ab' | xargs -d '\a' echo +### expect +a b c +a b + +a b +a b +a b +### end + +### bashbox_xargs_d_numbers_are_read_like_strtoul +# -d numbers are read like strtoul +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'aAb' | xargs -d '\x 41' echo; printf 'aAb' | xargs -d '\x+0x41' echo; printf 'a\0b' | xargs -d '\x' echo; printf 'a\0b' | xargs -d '\x-0' echo +### expect +a b +a b +a b +a b +### end + +### bashbox_xargs_d_items_keep_blanks_and_quotes +# -d items keep blanks and quotes +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf "a 'b\nc\n" | xargs -d '\n' -n1 echo +### expect +a 'b +c +### end + +### bashbox_xargs_e_stops_at_the_end_of_file_word +# -E stops at the end-of-file word +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b\nSTOP\nc\n' | xargs -E STOP echo; printf 'a STOP b\n' | xargs -eSTOP echo; printf 'STOP\nc\n' | xargs --eof=STOP echo x; printf 'a STOP' | xargs -E STOP; printf 'STOP' | xargs -E STOP echo y +### expect +a b +a +x +a STOP +y +### end + +### bashbox_xargs_e_on_a_line_ending +# -E on a line ending +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a STOP\nb\n' | xargs -E STOP -L1 echo +### expect +a +### end + +### bashbox_xargs_a_bare_e_turns_e_off +# a bare -e turns -E off +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a x b | xargs -E x -e echo; echo a x b | xargs -E x --eof echo +### expect +a x b +a x b +### end + +### bashbox_xargs_e_has_no_effect_with_0_or_d +# -E has no effect with -0 or -d +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\0x\0b' | xargs -E x -0 echo +### expect +a x b +### end + +### bashbox_xargs_l_runs_per_lines +# -L runs per lines +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\nd\ne\n' | xargs -L2 echo +### expect +a b +c d +e +### end + +### bashbox_xargs_l_continues_a_line_ending_in_a_blank +# -L continues a line ending in a blank +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b \nc\nd\\ \ne\n' | xargs -L1 echo +### expect +a b c +d e +### end + +### bashbox_xargs_l_defaults_to_one_line +# -l defaults to one line +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\n' | xargs -l echo; printf 'a\nb\nc\n' | xargs -l2 echo; printf 'a\nb\n' | xargs --max-lines echo +### expect +a +b +c +a b +c +a +b +### end + +### bashbox_xargs_a_bare_optional_option_at_the_end_of_a_bundle +# a bare optional option at the end of a bundle +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a x b | xargs -E x -tl echo; echo y | xargs -ti echo {}; echo z | xargs -it echo {} +### expect +a +y +{} +### end + +### bashbox_xargs_l_n_and_i_exclude_each_other +# -L, -n and -I exclude each other +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\n' | xargs -n2 -L1 echo; printf 'a\nb\nc\n' | xargs -L1 -n2 echo; printf 'a\nb\n' | xargs -I{} -l echo {} +### expect +a +b +c +a b +c +{} a +{} b +### end + +### bashbox_xargs_i_excludes_n_and_l +# -I excludes -n and -L +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\n' | xargs -L1 -I{} echo {}; printf 'a\nb\n' | xargs -n2 -I{} echo {}; printf 'a\nb\n' | xargs -I{} -n2 echo {} +### expect +a +b +a +b +{} a b +### end + +### bashbox_xargs_n1_after_i_is_ignored +# -n1 after -I is ignored +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b\nc\n' | xargs -I{} -n1 echo [{}] +### expect +[a b] +[c] +### end + +### bashbox_xargs_i_defaults_to +# -i defaults to {} +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\n' | xargs -i echo {}; echo c | xargs --replace echo {}; echo d | xargs -iX echo X +### expect +a +b +c +d +### end + +### bashbox_xargs_numbers_may_have_blanks_and_a_sign +# numbers may have blanks and a sign +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +echo a b c | xargs -n ' +2' echo; echo d | xargs -P 2 echo +### expect +a b +c +d +### end + +### bashbox_xargs_s_limits_the_command_length +# -s limits the command length +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\nc\n' | xargs -s 9 echo +### expect +a b +c +### end + +### bashbox_xargs_x_alone_still_splits +# -x alone still splits +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b c d\n' | xargs -x -s 9 echo +### expect +a b +c d +### end + +### bashbox_xargs_i_size_limits +# -I size limits +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'abc\n' | xargs -I{} -s 8 echo {} {}; printf 'abcde\n' | xargs -I{} -s 6 echo x{}; printf 'abcdef\n' | xargs -I{} -s 6 echo x{}; printf 'a\n' | xargs -I{} -s 3 echo {}; printf '' | xargs -I{} -s 3 echo {} +### expect +### end + +### bashbox_xargs_i_with_0 +# -I with -0 +printf 'a b\0c\0' | xargs -0 -I{} echo [{}] +### expect +[a b] +[c] +### end + +### bashbox_xargs_a_reads_standard_input +# -a - reads standard input +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a\nb\n' | xargs -a - echo +### expect +a b +### end + +### bashbox_xargs_a_with_a_directory_reads_nothing +# -a with a directory reads nothing +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +mkdir d; xargs -a d echo x +### expect +x +### end + +### bashbox_xargs_t_prints_each_command +# -t prints each command +### skip: TODO bashbox corpus gap, bashkit output differs from real bash +printf 'a b\nc\n' | xargs -t -n2 echo; printf "it's\ta b\n" | xargs -d '\n' --verbose +### expect +a b +c +it's a b +### end diff --git a/knowledge/operations/testing.md b/knowledge/operations/testing.md index c27e6ded1..c9b7bc915 100644 --- a/knowledge/operations/testing.md +++ b/knowledge/operations/testing.md @@ -295,6 +295,26 @@ Quarterly import process: 4. CI runs the same feature-complete, network-free lane. Schema, provenance, unique IDs, classifications, and limitation references are test-enforced. +## Imported BashBox Corpus + +`spec_cases/bash/bashbox-*.test.sh` holds bash-parity cases copied from the +test suite of [BashBox](https://github.com/shipfastlabs/bashbox) (MIT, PHP port +of just-bash; attribution in `NOTICE`). One file per BashBox test file. +Imported 2026-10-07: 1,753 (script, stdout) pairs extracted, kept only those +whose recorded stdout matched host bash 5.2 under `LANG=C.UTF-8`, and whose +expectation is representable in the spec format (1,044 cases). + +- 624 pass in Bashkit and run in both the spec suite and the bash comparison gate. +- 420 are `### skip: TODO bashbox corpus gap` — Bashkit's stdout or exit status + differs from real bash. These are bugs, not intentional divergences: fixing + one means deleting its `skip` line (the comparison gate then guards it). + Clusters: `declare`/`local`/attributes, `sort` and `xargs` flags, arithmetic on + arrays, `declare -f`/`type` printing, named fds, ERR trap, `printf`, `tr`. + +Do not re-import over existing files; fix cases in place. If an intentional +divergence is found, switch the case to `### bash_diff` with a canonical `L-*` +row in [Known Limitations](limitations.md). + ## SQLite CLI Differential Tests CSV comparisons explicitly set the host CLI row separator to LF. SQLite From 81dcea86fd4dd2677d5ff63ca4eaf9f700e81fa3 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 17:15:51 +0000 Subject: [PATCH 02/11] fix(arithmetic): bash grammar evaluator, errors abort the line Replace the string-splitting arithmetic evaluator with a tokenizer and a precedence-climbing parser that follows bash's expr.c grammar (comma, assignment operators, ternary, short-circuit with noeval, right-assoc `**`, base#digits up to 64, pre/post increment on elements). Errors now behave as in bash: `$((...))` errors print the bash message and abort the rest of the current line (new ControlFlow::Abort / Error::LineAbort); `((...))` and `let` return status 1. The body of a `((...))` command is read raw so `<<=`/`>>` are not lexed as redirects. Unskips the bashbox-arithmetic-array cases and bashbox_declare_a_subshell_ends_with_status_1. --- crates/bashkit/src/error.rs | 7 + crates/bashkit/src/interpreter/arithmetic.rs | 1752 ++++++++--------- crates/bashkit/src/interpreter/expansion.rs | 4 +- crates/bashkit/src/interpreter/mod.rs | 336 ++-- crates/bashkit/src/interpreter/state.rs | 6 + crates/bashkit/src/parser/lexer.rs | 57 + crates/bashkit/src/parser/mod.rs | 12 + crates/bashkit/src/tool.rs | 2 +- .../bash/bashbox-arithmetic-array.test.sh | 9 - .../spec_cases/bash/bashbox-declare.test.sh | 1 - knowledge/foundations/parser.md | 18 +- knowledge/log.md | 1 + 12 files changed, 1072 insertions(+), 1133 deletions(-) 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 90f500300..3e0f8c341 100644 --- a/crates/bashkit/src/interpreter/arithmetic.rs +++ b/crates/bashkit/src/interpreter/arithmetic.rs @@ -1,498 +1,889 @@ //! 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(); +/// 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 = 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, 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 { - 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 r = self.additive(p)?; + v = self.binop(op, v, r, 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 r = self.power(p)?; + v = self.binop(op, v, r, 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(); + self.enter()?; + let exp = self.power(p); + self.leave(); + return self.binop("**", base, exp?, 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")), + } + } + + fn binop(&mut self, op: &str, l: i64, r: i64, 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); } + return Err(p.err("division by 0")); + } + 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); + } + } + + 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]; @@ -506,42 +897,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('#')) @@ -550,7 +922,6 @@ impl Interpreter { return self.expand_param_op_in_arithmetic(inner); } - // ${var} — plain variable self.expand_variable(inner) } @@ -591,7 +962,6 @@ impl Interpreter { _ => {} } } - // Fallback self.expand_name_or_array_element(inner) } @@ -614,468 +984,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 i64::from_str_radix(value_str, base).unwrap_or(0); - } 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 i64::from_str_radix(&expr[2..], 16).unwrap_or(0); - } - if expr.starts_with('0') && expr.len() > 1 && expr.chars().all(|c| c.is_ascii_digit()) { - return i64::from_str_radix(&expr[1..], 8).unwrap_or(0); - } - - // 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. - 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; - } - 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 right = self.parse_arithmetic_impl(&expr[bo[i] + 1..], arith_depth + 1); - return Some(match chars[i] { - '/' if right != 0 => left.wrapping_div(right), - '%' if right != 0 => left.wrapping_rem(right), - _ => 0, - }); - } - _ => {} - } - } - - // 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); - 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 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`. @@ -1090,38 +1005,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/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index 6eb8dc095..31d8cecf9 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -265,7 +265,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(format!("bash: {msg}\n")))?; Self::append_expansion_for_word(&mut result, word, &value.to_string()); } WordPart::Length(name) => { diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 07d40e436..c4df0e08b 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -11,7 +11,6 @@ #![allow(clippy::unwrap_used)] mod arithmetic; -use arithmetic::{arith_lvalue_end, is_arith_lvalue}; mod brace_expansion; mod expansion; mod glob; @@ -1520,40 +1519,10 @@ 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>, } impl Interpreter { @@ -2022,6 +1991,7 @@ impl Interpreter { concurrent_jobs: true, script_depth: 0, child_shell_depth: 0, + arith_error: StdMutex::new(None), } } @@ -2176,6 +2146,7 @@ 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), } } @@ -3158,7 +3129,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 mut result = self.execute_command(command).await?; @@ -3206,6 +3184,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 { @@ -3337,7 +3327,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, @@ -3419,7 +3413,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. @@ -3470,10 +3464,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(format!("bash: {msg}\n"))); + 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(format!("bash: {msg}\n"))) + } + /// Charge every executable AST command, including optimized command forms. fn charge_command_execution(&mut self) -> Result<()> { self.execution_budget.consume_work(1)?; @@ -3551,12 +3580,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; @@ -3944,6 +3979,15 @@ impl Interpreter { ..Default::default() }); } + ControlFlow::Abort => { + return Ok(ExecResult { + stdout, + stderr, + exit_code: 1, + control_flow: ControlFlow::Abort, + ..Default::default() + }); + } ControlFlow::None => {} } } @@ -4373,155 +4417,25 @@ impl Interpreter { } async fn execute_arithmetic_command(&mut self, expr: &str) -> Result { - let result = self.execute_arithmetic_with_side_effects(expr); - 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() - }) + 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(format!("bash: {msg}\n"), 1)), + } } /// 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 @@ -6048,11 +5962,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) } @@ -6753,6 +6670,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() @@ -6765,6 +6687,11 @@ impl Interpreter { 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); + } // Alias expansion if let Some(result) = self @@ -8862,16 +8789,24 @@ impl Interpreter { 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); + 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(format!("bash: let: {msg}\n"), 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 @@ -10327,7 +10262,10 @@ impl Interpreter { let cmd_result = self.execute_command(cmd).await?; stdout.try_push_str(&cmd_result.stdout.command_substitution_text())?; 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; } } @@ -12930,8 +12868,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] @@ -12997,9 +12937,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] @@ -13024,8 +12965,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] @@ -13037,7 +12979,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] @@ -13049,7 +12992,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] diff --git a/crates/bashkit/src/interpreter/state.rs b/crates/bashkit/src/interpreter/state.rs index c46d41ed9..d14102164 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 @@ -193,6 +198,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/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 696a19055..93e90ba4f 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -2216,6 +2216,63 @@ impl<'a> Lexer<'a> { false } + /// Read the raw body of a `(( ... ))` arithmetic command, right after + /// the `((` token, up to the matching `))` (consumed). Operators such as + /// `<<=` or `>>` stay text instead of becoming redirect tokens. Returns + /// `None` when no matching `))` follows (caller falls back to tokens). + pub fn read_arith_raw(&mut self) -> Option { + if !self.reinject_buf.is_empty() { + return None; + } + let mut raw = String::new(); + let mut depth = 0usize; + // Scan on a clone first so a failed scan leaves the lexer untouched. + let mut ahead = self.chars.clone(); + let mut closed = false; + while let Some(ch) = ahead.next() { + match ch { + '(' => depth += 1, + ')' => { + if depth == 0 { + if ahead.peek() == Some(&')') { + closed = true; + break; + } + return None; + } + depth -= 1; + } + '\\' => { + raw.push(ch); + if let Some(next) = ahead.next() { + raw.push(next); + } + continue; + } + '\'' | '"' => { + raw.push(ch); + for c in ahead.by_ref() { + raw.push(c); + if c == ch { + break; + } + } + continue; + } + _ => {} + } + raw.push(ch); + } + if !closed { + return None; + } + // Commit: consume the raw text plus the closing `))`. + for _ in 0..raw.chars().count() + 2 { + self.advance(); + } + Some(raw) + } + /// Read the raw source of a `[[ ... =~ REGEX ]]` operand, as bash does: /// one word that ends at unquoted whitespace or `;&<>` outside /// parentheses. Inside parentheses spaces belong to the regex; `|` and diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index c7c339b64..a8c92a75f 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -1852,6 +1852,18 @@ impl<'a> Parser<'a> { } fn parse_arithmetic_command(&mut self) -> Result { + // Read the body as raw text when no token was looked ahead, so + // operators like `<<=` or `>>` are not lexed as redirections. + if self.peeked_token.is_none() + && let Some(raw) = self.lexer.read_arith_raw() + { + self.advance(); + let expr: String = raw + .chars() + .map(|c| if c == '\n' { ' ' } else { c }) + .collect(); + return Ok(CompoundCommand::Arithmetic(expr.trim().to_string())); + } self.advance(); // consume '((' // Read expression until we find )) 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/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-declare.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh index 1d2b1da2f..351f0ff52 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh @@ -40,7 +40,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 diff --git a/knowledge/foundations/parser.md b/knowledge/foundations/parser.md index 8c9927df2..fa382226f 100644 --- a/knowledge/foundations/parser.md +++ b/knowledge/foundations/parser.md @@ -80,8 +80,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 5af8d06e8..8f0d4519c 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **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). * **Fix**: `${x#pat}`/`${x%pat}` follow the glob grammar. A lone `*` matches empty for the short forms (`${x#*}` and `${x%*}` keep `x`; they dropped a character), and patterns with `?` or several `*` (`${x%?}`, `${p#*/*/}`, `${p##*.*.}`) now match instead of being compared as literal text. Single-`*` patterns keep their linear fast path. * **Fix**: Quoted or escaped glob characters are literal everywhere. `echo a\*`, `a"*"` and `a'?'` no longer glob; `case`/`[[ == ]]` patterns match `"$x"`, `'a*'`, `a\*` and `\[s\]` literally; `"*"zz*` with no match prints `*zz*` (escapes no longer leak); `"$x"zz*` no longer reads `$xzz`; `'a'$x` expands and `'a'*` globs; `a{1,2}"*"` still brace-expands. The lexer escapes quoted glob characters in mixed words (the existing `QuotedGlobWord` convention, now with quote markers); `expand_word` returns unescaped text for non-glob uses; patterns use `expand_pattern_word`. Known gap: data from an unquoted expansion inside such a word loses `\` before glob characters when nothing matches. * **Fix**: A backslash outside quotes keeps `$` and `` ` `` literal. `echo \$HOME` printed the home directory; it now prints `$HOME`, as do `a\$x`, `\${x}` and `x=\$y` (the lexer emits the same NUL literal marker as quoted `"\$"`). Found while working gap telemetry. From fa25efd9190c83cdf678c5611e2b6f09a3c10bcd Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 18:05:22 +0000 Subject: [PATCH 03/11] fix(declare): shared declaration engine, shallow-binding locals declare/typeset/local/export/readonly now run through one engine (interpreter/declare.rs) with bash's option sets, error messages and declare -p output (quoting, assoc arrays in bash hash order). Locals use shallow binding saved in the call frame: nested functions, unset of a caller's local, declare -g past a local and local -n follow bash. Compound operands stay one CompoundAssignment word part, so keyed elements with quotes or backticks lex as one element and analysis walks substitutions inside them. Readonly assignment and circular namerefs abandon the line as in bash. Unskips 60 bashbox declare/attributes/local-scope/read-names cases. --- crates/bashkit/src/analysis.rs | 14 + crates/bashkit/src/interpreter/declare.rs | 1536 +++++++++++++++++ crates/bashkit/src/interpreter/expansion.rs | 12 + crates/bashkit/src/interpreter/mod.rs | 1349 +++++---------- crates/bashkit/src/lib.rs | 11 +- crates/bashkit/src/parser/ast.rs | 27 + crates/bashkit/src/parser/budget.rs | 6 + crates/bashkit/src/parser/lexer.rs | 170 +- crates/bashkit/src/parser/mod.rs | 52 +- .../tests/integration/threat_model_tests.rs | 27 +- .../bash/bashbox-attributes.test.sh | 18 - .../spec_cases/bash/bashbox-declare.test.sh | 27 - .../bash/bashbox-local-scope.test.sh | 14 - .../bash/bashbox-read-names.test.sh | 1 - knowledge/foundations/builtins.md | 31 + knowledge/log.md | 1 + knowledge/operations/limitations.md | 1 - 17 files changed, 2153 insertions(+), 1144 deletions(-) create mode 100644 crates/bashkit/src/interpreter/declare.rs 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/interpreter/declare.rs b/crates/bashkit/src/interpreter/declare.rs new file mode 100644 index 000000000..7ea824213 --- /dev/null +++ b/crates/bashkit/src/interpreter/declare.rs @@ -0,0 +1,1536 @@ +//! 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]`. +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, + }; + 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? + 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 { + let show = |name: &str| { + if opts.function_names { + format!("declare -f {name}\n") + } else { + format!("{name} ()\n{{\n ...\n}}\n") + } + }; + 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)); + } + } + } 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)); + } + } + } + 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.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 { + if let Some((kw, vw, kappend)) = split_keyed_word(word) { + 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(format!("bash: {msg}\n"))); + } + }; + 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> { + 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 { + let expanded = self.expand_word(word).await?; + return self.ifs_split_limited(&expanded, limit); + } + 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(_))); + if word.quoted || !literal_only { + return Ok(vec![value]); + } + let mut fields = Vec::new(); + for item in self.expand_braces(&value) { + match self.expand_glob_item(&item, false).await { + Ok(items) => fields.extend(items), + Err(_) => fields.push(item), + } + if fields.len() >= limit { + fields.truncate(limit); + break; + } + } + 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 31d8cecf9..5cc3c50cc 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -196,6 +196,18 @@ 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(), + } + .to_string(); + result.push_str(&text); + } WordPart::Literal(s) => { // Tilde expansion: ~ at start of word expands to $HOME if is_first_part && s.starts_with('~') { diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index c4df0e08b..a44072256 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -12,6 +12,7 @@ mod arithmetic; mod brace_expansion; +mod declare; mod expansion; mod glob; mod jobs; @@ -429,8 +430,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 @@ -922,56 +923,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 @@ -979,8 +944,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. @@ -1215,6 +1183,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; } } @@ -1523,6 +1493,12 @@ pub struct Interpreter { /// 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 { @@ -1992,6 +1968,8 @@ impl Interpreter { script_depth: 0, child_shell_depth: 0, arith_error: StdMutex::new(None), + pending_compound_args: Vec::new(), + assign_raw: false, } } @@ -2147,6 +2125,8 @@ impl Interpreter { // 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, } } @@ -2439,7 +2419,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. @@ -2457,7 +2437,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, @@ -5248,7 +5229,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, @@ -6248,171 +6230,82 @@ 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<()> { + /// + /// `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. + async fn process_command_assignments( + &mut self, + assignments: &[Assignment], + has_command: bool, + ) -> Result { + 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 + ))); + } + }; + 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 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); - } - } + 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 { - 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); - } - } + self.set_variable(assignment.name.clone(), value); } + self.assign_raw = false; } else if assignment.append { - let existing = self.expand_variable(&assignment.name); - self.set_variable(assignment.name.clone(), existing + &value); + self.append_scalar(&assignment.name, value); } else { self.set_variable(assignment.name.clone(), value); } } AssignmentValue::Array(words) => { - // 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 - }; - - 'array_words: for word in words.iter() { - 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)? { - next_arr.insert(idx, field); - idx += 1; - } - 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; - } - next_arr.insert(idx, field); - idx += 1; - } - } else { - let value = self.expand_word(word).await?; - if next_arr.len() >= max_new_entries { - break; - } - next_arr.insert(idx, value); - idx += 1; + 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" + ))); } - - let _ = self.insert_array_checked(arr_name, next_arr); + let assoc = self.scoped.assoc_arrays.contains_key(&arr_name); + self.assign_array_words(&arr_name, words, assignment.append, assoc) + .await?; } } } - Ok(()) + Ok(stderr) } /// Try alias expansion. Returns `Some(result)` if alias was expanded, `None` otherwise. @@ -6654,7 +6547,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; @@ -6683,10 +6580,27 @@ impl Interpreter { let pre_assign_subst_gen = self.subst_generation; - if let Err(err) = self.process_command_assignments(&command.assignments).await { - self.discard_deferred_proc_subs_from(deferred_proc_sub_start); - return Err(err); - } + // 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); @@ -6752,9 +6666,12 @@ 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()); + env_saves.entry(assignment.name.clone()).or_insert_with(|| { + pre_assign_env + .get(&assignment.name) + .cloned() + .unwrap_or(None) + }); self.env.insert(assignment.name.clone(), value); } } @@ -6794,6 +6711,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| { @@ -6820,10 +6748,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 word 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, + }, + ] = word.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(word.to_string()); + } + continue; + } // Use field expansion so "${arr[@]}" produces multiple args let fields = self.expand_word_to_fields(word).await?; @@ -6856,6 +6809,7 @@ impl Interpreter { } } } + self.pending_compound_args = compounds; Ok(args) }) } @@ -7407,25 +7361,6 @@ impl Interpreter { self.apply_builtin_side_effects(&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) }) @@ -7529,13 +7464,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), @@ -7932,7 +7886,8 @@ impl Interpreter { // 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(), @@ -8074,7 +8029,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, @@ -8323,6 +8279,15 @@ 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(), + }; + 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)), @@ -8477,7 +8442,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, @@ -8573,216 +8539,6 @@ 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, @@ -8888,28 +8644,7 @@ impl Interpreter { exit_code = 1; continue; } - 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 { @@ -8920,6 +8655,60 @@ impl Interpreter { self.apply_redirections(result, redirects).await } + /// 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.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() + .rposition(|f| f.saved_vars.contains_key(name)); + let current = self.local_frame_index(); + if let Some(idx) = owner + && Some(idx) != current + { + 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); + } + /// Usage: `getopts optstring name [args...]` /// /// Parses options from positional params (or `args`). @@ -9296,331 +9085,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 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, - '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 - if is_function { - let mut output = String::new(); - if names.is_empty() { - // List all functions - let mut func_names: Vec<_> = - self.scoped.functions.keys().cloned().collect::>(); - func_names.sort(); - for fname in &func_names { - output.push_str(&format!("{} ()\n{{\n ...\n}}\n", fname)); - } - } else { - // Print specific functions — return 1 if any not found - for name in &names { - if self.scoped.functions.contains_key(*name) { - output.push_str(&format!("{} ()\n{{\n ...\n}}\n", name)); - } else { - let mut result = ExecResult::with_code(String::new(), 1); - result = self.apply_redirections(result, redirects).await?; - return Ok(result); - } - } - } - let mut result = ExecResult::ok(output); - 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 @@ -9902,7 +9366,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); @@ -9976,7 +9440,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(), @@ -10356,16 +9821,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). @@ -10389,6 +9868,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() @@ -10403,57 +9891,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; + } + 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; } - // 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 { + // 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); @@ -10588,42 +10040,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.insert(name.to_string(), e); + } + None => { + self.env.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(); } } @@ -10663,7 +10172,10 @@ impl Interpreter { /// 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()); } @@ -10967,24 +10479,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()); } @@ -11181,12 +10675,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; @@ -11269,14 +10757,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 @@ -11361,7 +10841,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], @@ -11478,7 +10959,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(), @@ -11489,7 +10971,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(), @@ -11657,22 +11140,24 @@ 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(), }); - 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")); } @@ -13130,8 +12615,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 @@ -14301,9 +13788,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"); } @@ -14405,7 +13894,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/lib.rs b/crates/bashkit/src/lib.rs index 127cdf194..50570f676 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -5047,9 +5047,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 6d2accb66..b59a8fc3b 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -408,6 +408,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(()) @@ -419,6 +434,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 93e90ba4f..cf6a09bd2 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -430,6 +430,43 @@ 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 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()); + } + 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; @@ -559,6 +596,10 @@ impl<'a> Lexer<'a> { } } continue; + } 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) || ch == ']' || ch == '}' { // `}` included for the same reason as in `read_word`: it is a // reserved word, not a metacharacter, so it stays inside the @@ -900,39 +941,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 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(), - )); - } - word.push(')'); } else if ch == '\\' { self.advance(); if let Some(next) = self.peek_char() { @@ -956,56 +967,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 @@ -2181,7 +2142,12 @@ 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) { word.push(ch); self.advance(); } else { @@ -2192,30 +2158,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 like looks_like_brace_expansion: 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.chars.clone(); - // 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 raw body of a `(( ... ))` arithmetic command, right after /// the `((` token, up to the matching `))` (consumed). Operators such as /// `<<=` or `>>` stay text instead of becoming redirect tokens. Returns @@ -2686,13 +2628,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 a8c92a75f..4326cbdd1 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -2463,33 +2463,33 @@ 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('('); - 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); - 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; } - Some(self.parse_word(compound)) + let name = name.to_string(); + self.advance(); // consume '(' + let elements = self.collect_array_elements(); + Some(Word { + parts: vec![WordPart::CompoundAssignment { + name, + append, + elements, + }], + quoted: true, + has_unquoted_glob: false, + part_quoted: Vec::new(), + }) } /// Parse a heredoc redirect (`<<` or `<<-`) and any trailing redirects on the same line. diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index 502db105f..a61da1970 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -1177,12 +1177,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) @@ -1982,10 +1986,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); } } @@ -3757,7 +3761,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); @@ -3767,7 +3772,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(); @@ -3820,7 +3825,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. 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..a0f6a464f 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 @@ -81,7 +75,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 +102,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 +110,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 +119,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 +126,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 +133,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 +141,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 +149,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 +156,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 +163,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 +171,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 +179,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 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 351f0ff52..6ba539140 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") @@ -47,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 @@ -56,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 @@ -64,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 @@ -76,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 @@ -87,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") @@ -95,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" @@ -117,7 +109,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" ) @@ -127,7 +118,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 @@ -138,7 +128,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 @@ -148,7 +137,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") @@ -159,7 +147,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 @@ -180,7 +167,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 @@ -197,7 +183,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" @@ -206,7 +191,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" @@ -214,7 +198,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" @@ -240,7 +223,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 @@ -250,7 +232,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 @@ -267,7 +248,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 @@ -277,7 +257,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 @@ -294,7 +273,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 @@ -302,7 +280,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 @@ -312,7 +289,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 @@ -321,7 +297,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") @@ -341,7 +316,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" ) @@ -358,7 +332,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 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..228b3ace4 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 @@ -29,7 +29,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 +85,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 +93,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] @@ -124,7 +121,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 +129,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 +136,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 +144,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 +151,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 +159,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 @@ -191,7 +182,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") @@ -210,7 +200,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 @@ -228,7 +217,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 +232,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 +243,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..ca8795cb0 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 @@ -28,7 +28,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] diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index 482da10b4..b1837e7dd 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -221,6 +221,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/log.md b/knowledge/log.md index 8f0d4519c..eb19ec95e 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **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). * **Fix**: `${x#pat}`/`${x%pat}` follow the glob grammar. A lone `*` matches empty for the short forms (`${x#*}` and `${x%*}` keep `x`; they dropped a character), and patterns with `?` or several `*` (`${x%?}`, `${p#*/*/}`, `${p##*.*.}`) now match instead of being compared as literal text. Single-`*` patterns keep their linear fast path. * **Fix**: Quoted or escaped glob characters are literal everywhere. `echo a\*`, `a"*"` and `a'?'` no longer glob; `case`/`[[ == ]]` patterns match `"$x"`, `'a*'`, `a\*` and `\[s\]` literally; `"*"zz*` with no match prints `*zz*` (escapes no longer leak); `"$x"zz*` no longer reads `$xzz`; `'a'$x` expands and `'a'*` globs; `a{1,2}"*"` still brace-expands. The lexer escapes quoted glob characters in mixed words (the existing `QuotedGlobWord` convention, now with quote markers); `expand_word` returns unescaped text for non-glob uses; patterns use `expand_pattern_word`. Known gap: data from an unquoted expansion inside such a word loses `\` before glob characters when nothing matches. diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 9d57724ac..2fc53f43c 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -102,7 +102,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 | From 031473a05f38031db420a0bc0128360773734a87 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 18:16:24 +0000 Subject: [PATCH 04/11] fix(expansion): scalars as arrays, [[ -v ]], read name validation A scalar is element 0 of an array for ${x[@]}, ${#x[@]}, ${!x[@]}, ${x[0]} and arithmetic element writes. Associative arrays expand in bash's hash order. [[ -v ]] was missing and is now implemented. ${!ref} resolves a nameref chain, a nameref for-loop variable is re-pointed per word, and builtin assignment through a circular nameref fails the command. read takes options only before names, stops at an invalid name, validates -a names and supports -N. Unskips 15 bashbox declare/local-scope/read-names cases. --- crates/bashkit/src/builtins/read.rs | 61 ++++- crates/bashkit/src/interpreter/declare.rs | 2 +- crates/bashkit/src/interpreter/expansion.rs | 214 ++++++++---------- crates/bashkit/src/interpreter/mod.rs | 48 +++- .../spec_cases/bash/bashbox-declare.test.sh | 4 - .../bash/bashbox-local-scope.test.sh | 5 - .../bash/bashbox-read-names.test.sh | 7 - .../bash/unset-exported-var.test.sh | 1 - knowledge/log.md | 1 + 9 files changed, 199 insertions(+), 144 deletions(-) diff --git a/crates/bashkit/src/builtins/read.rs b/crates/bashkit/src/builtins/read.rs index ec7f67a0b..ee029c3f0 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::() @@ -139,6 +157,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; } @@ -190,7 +212,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, @@ -340,6 +373,11 @@ impl Builtin for Read { // Assign words to variables via side effects (respects local scoping) let mut result = ExecResult::ok(String::new()); 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; @@ -372,6 +410,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") +} + #[cfg(feature = "terminal")] enum TerminalRead { Line(String), diff --git a/crates/bashkit/src/interpreter/declare.rs b/crates/bashkit/src/interpreter/declare.rs index 7ea824213..499ead677 100644 --- a/crates/bashkit/src/interpreter/declare.rs +++ b/crates/bashkit/src/interpreter/declare.rs @@ -106,7 +106,7 @@ pub(super) enum VarKind { } /// Is `s` usable as a nameref target: a name or `name[subscript]`. -fn valid_nameref_target(s: &str) -> bool { +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), diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index 5cc3c50cc..2b347691e 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -9,6 +9,65 @@ use super::*; 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); @@ -23,18 +82,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) { @@ -53,13 +101,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 } @@ -293,13 +341,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) }; @@ -359,18 +401,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, @@ -398,11 +430,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 { @@ -431,11 +460,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 { @@ -494,13 +522,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 @@ -584,46 +613,20 @@ 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); } // "${!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)); } } @@ -768,25 +771,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] @@ -811,7 +797,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 * @@ -847,18 +836,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 a44072256..347f243ff 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -3702,7 +3702,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; @@ -4269,6 +4277,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 @@ -4372,6 +4381,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) { @@ -7359,7 +7385,7 @@ impl Interpreter { .unwrap_or(u64::MAX), )?; - self.apply_builtin_side_effects(&result).await; + self.apply_builtin_side_effects(&mut result).await; let result = self.apply_redirections(result, redirects).await?; self.apply_after_tool(name, result) @@ -9393,7 +9419,7 @@ impl Interpreter { } /// Process structured side effects from builtin execution. - async fn apply_builtin_side_effects(&mut self, result: &ExecResult) { + async fn apply_builtin_side_effects(&mut self, result: &mut ExecResult) { // Builtins that mutate SHOPT_* directly via `ctx.variables` (e.g. the // `set -e` / `set +u` paths in the `set` builtin) don't update the // cached `flags` bitfield. Resync once after every builtin so the @@ -9401,7 +9427,8 @@ impl Interpreter { // ~10 SHOPT_* entries — cheaper than threading a structured "shopt // changed" channel through every builtin. self.refresh_shopt_flags(); - for effect in &result.side_effects { + let effects = std::mem::take(&mut result.side_effects); + for effect in &effects { match effect { builtins::BuiltinSideEffect::SetArray { name, elements } => { let mut arr = HashMap::new(); @@ -9457,10 +9484,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; } /// Resolve a path relative to cwd, normalizing `.` and `..` components. @@ -9969,6 +10007,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 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 6ba539140..ffdef5577 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh @@ -97,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 @@ -159,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 @@ -206,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" @@ -265,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 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 228b3ace4..c1d95190f 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 @@ -101,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 @@ -111,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 @@ -190,7 +187,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 @@ -208,7 +204,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 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 ca8795cb0..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 @@ -35,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 @@ -44,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] @@ -52,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 @@ -60,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/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/log.md b/knowledge/log.md index eb19ec95e..5940b6540 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **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). * **Fix**: `${x#pat}`/`${x%pat}` follow the glob grammar. A lone `*` matches empty for the short forms (`${x#*}` and `${x%*}` keep `x`; they dropped a character), and patterns with `?` or several `*` (`${x%?}`, `${p#*/*/}`, `${p##*.*.}`) now match instead of being compared as literal text. Single-`*` patterns keep their linear fast path. From 53f4ad19546cb09989f5c38736a07758c6d4f3a3 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 18:38:35 +0000 Subject: [PATCH 05/11] fix(set): bash option set, $-, BASH_SUBSHELL, PIPESTATUS, subshell env set knows every set -o option and letter, validates before changing anything, lets each o in a cluster take the next argument and lists options for a trailing -o; shopt -o/-po operate on them. $- uses bash's letter order and defaults (hBc). BASH_SUBSHELL counts subshells, command and process substitutions. ( ), [[ ]] and (( )) set PIPESTATUS. The exported environment is snapshotted with subshell state, so (E=2) no longer leaks. local LINENO is an ordinary variable and cd with HOME unset fails. Unskips 12 bashbox attributes/local-scope/shell-variables cases; the remaining declare skips name their reason (printf -v, ANSI-C bytes). --- crates/bashkit/src/builtins/mod.rs | 1 + crates/bashkit/src/builtins/navigation.rs | 7 +- crates/bashkit/src/builtins/vars.rs | 339 +++++++++++------- crates/bashkit/src/interpreter/declare.rs | 4 +- crates/bashkit/src/interpreter/mod.rs | 146 +++++--- .../integration/blackbox_security_tests.rs | 11 +- .../bash/bashbox-attributes.test.sh | 8 - .../spec_cases/bash/bashbox-declare.test.sh | 14 +- .../bash/bashbox-local-scope.test.sh | 1 - .../bash/bashbox-shell-variables.test.sh | 3 - knowledge/log.md | 1 + 11 files changed, 331 insertions(+), 204 deletions(-) diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 7e6ada38c..bc2c4a4c2 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -260,6 +260,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/vars.rs b/crates/bashkit/src/builtins/vars.rs index 9b3c2cf12..a9cfae55b 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)); + positional = Some(&ctx.args[i + 1..]); } - } 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()); - } - } - } 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) } } @@ -457,6 +501,48 @@ 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() + }; + let mut out = String::new(); + let mut err = String::new(); + let mut status = 0; + for name in names { + let Some((_, _, var, default)) = set_option_by_name(name) else { + err.push_str(&format!("bash: shopt: {name}: invalid option name\n")); + status = 1; + 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)), + _ => out.push_str(&format_dash_o_line(variables, name)), + } + } + let mut result = ExecResult::ok(out); + result.stderr = err.into(); + result.exit_code = status; + result +} + /// shopt builtin - set/unset bash-specific shell options. /// /// Usage: @@ -486,11 +572,16 @@ impl Builtin for Shopt { let mut mode: Option = None; // 's'=set, 'u'=unset, 'q'=query, 'p'=print 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() { for ch in arg.chars().skip(1) { match ch { + 'o' => set_o = true, + // -p combines with -s/-u (print those); keep the + // action letter. + 'p' if matches!(mode, Some('s' | 'u')) => {} 's' | 'u' | 'q' | 'p' => mode = Some(ch), _ => { return Ok(ExecResult::err( @@ -505,6 +596,10 @@ impl Builtin for Shopt { } } + if set_o { + return Ok(shopt_set_o(ctx.variables, mode, &opts)); + } + match mode { Some('s') => { // Set options diff --git a/crates/bashkit/src/interpreter/declare.rs b/crates/bashkit/src/interpreter/declare.rs index 499ead677..d158859b5 100644 --- a/crates/bashkit/src/interpreter/declare.rs +++ b/crates/bashkit/src/interpreter/declare.rs @@ -359,7 +359,7 @@ impl Interpreter { } /// Is `name` local to any active function frame? - fn is_local_anywhere(&self, name: &str) -> bool { + pub(super) fn is_local_anywhere(&self, name: &str) -> bool { self.call_stack .iter() .any(|f| f.saved_vars.contains_key(name)) @@ -1088,7 +1088,7 @@ impl Interpreter { self.insert_variable_checked(name.to_string(), v); } self.remove_var_attr(name, VarAttrs::EXPORT); - self.env.remove(name); + self.env_mut().remove(name); } } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 347f243ff..718b619ef 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1289,7 +1289,9 @@ struct PipelineStageScope { } struct SubshellSnapshot { + bash_subshell: usize, scoped: ScopedState, + env: Arc>, flags: BashFlags, cwd: PathBuf, memory_budget: crate::limits::MemoryBudget, @@ -1303,7 +1305,7 @@ struct SubshellSnapshot { /// 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. @@ -1489,6 +1491,9 @@ pub struct Interpreter { script_depth: usize, /// Nested `bash`/`sh` child shells (TM-DOS-125). child_shell_depth: usize, + /// `$BASH_SUBSHELL`: subshell nesting, `(...)`, `$(...)`, `<(...)` and + /// forked pipeline stages each add one; saved in `SubshellSnapshot`. + bash_subshell: usize, /// 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. @@ -1891,7 +1896,7 @@ impl Interpreter { Self { fs, - env: HashMap::new(), + env: Arc::new(HashMap::new()), scoped: ScopedState { variables: Arc::new(variables), arrays: Arc::new(arrays), @@ -1967,6 +1972,7 @@ impl Interpreter { concurrent_jobs: true, script_depth: 0, child_shell_depth: 0, + bash_subshell: 0, arith_error: StdMutex::new(None), pending_compound_args: Vec::new(), assign_raw: false, @@ -2124,6 +2130,7 @@ 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, + bash_subshell: self.bash_subshell, arith_error: StdMutex::new(None), pending_compound_args: Vec::new(), assign_raw: false, @@ -2147,6 +2154,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 @@ -2328,20 +2343,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 @@ -2365,8 +2373,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); } @@ -2454,7 +2462,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). @@ -2661,7 +2669,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 @@ -2689,7 +2697,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(), @@ -2702,7 +2710,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 @@ -3409,8 +3417,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 @@ -3512,6 +3535,7 @@ impl Interpreter { // The Arc-wrapped maps make each snapshot an O(1) refcount // bump; only mutations inside the subshell pay a clone. let snap = self.snapshot_subshell_state(); + self.bash_subshell += 1; let saved_call_stack = self.call_stack.clone(); let saved_exit = self.last_exit_code; let saved_coproc = self.coproc_buffers.clone(); @@ -5315,6 +5339,8 @@ impl Interpreter { /// keep only exported scalars in `variables`. The caller is expected to /// have just taken a snapshot to undo this on return. See issue #1777. fn reset_state_for_child_shell(&mut self) { + // A new bash process starts at BASH_SUBSHELL=0. + self.bash_subshell = 0; let exported_names: Vec = self .scoped .var_attrs @@ -5943,6 +5969,9 @@ impl Interpreter { self.coproc_buffers.clone(), ) }); + if subshell { + self.bash_subshell += 1; + } let mut scope = PipelineStageScope { saved, prev_pipeline_stdin: None, @@ -6698,7 +6727,7 @@ impl Interpreter { .cloned() .unwrap_or(None) }); - self.env.insert(assignment.name.clone(), value); + self.env_mut().insert(assignment.name.clone(), value); } } @@ -6726,10 +6755,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); } } } @@ -7894,7 +7923,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()); @@ -8685,7 +8714,7 @@ impl Interpreter { /// attributes, nameref and env entry. fn clear_live_binding(&mut self, name: &str) { self.remove_scalar_value(name); - self.env.remove(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. @@ -9318,17 +9347,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); @@ -9532,9 +9561,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) @@ -9562,7 +9604,9 @@ impl Interpreter { /// for a real HashMap clone, and only the maps it actually touched. fn snapshot_subshell_state(&self) -> SubshellSnapshot { SubshellSnapshot { + bash_subshell: self.bash_subshell, scoped: self.scoped.clone(), + env: self.env.clone(), flags: self.flags, cwd: self.cwd.clone(), memory_budget: self.memory_budget.clone(), @@ -9575,7 +9619,9 @@ impl Interpreter { } fn restore_subshell_state(&mut self, snap: SubshellSnapshot) { + self.bash_subshell = snap.bash_subshell; self.scoped = snap.scoped; + self.env = snap.env; self.flags = snap.flags; self.cwd = snap.cwd; self.memory_budget = snap.memory_budget; @@ -9728,6 +9774,7 @@ impl Interpreter { // Command substitution runs in a subshell: snapshot all // mutable state so mutations don't leak to the parent. let snapshot = self.snapshot_subshell_state(); + self.bash_subshell += 1; // THREAT[TM-DOS-111]: Expansion happens before top-level output caps, // so reserve every byte before growing the substitution buffer. let mut stdout = BudgetedString::new(Some(&self.execution_budget))?; @@ -10161,10 +10208,10 @@ impl Interpreter { } match saved.env { Some(e) => { - self.env.insert(name.to_string(), e); + self.env_mut().insert(name.to_string(), e); } None => { - self.env.remove(name); + self.env_mut().remove(name); } } } @@ -10207,7 +10254,7 @@ 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`. @@ -10592,22 +10639,8 @@ impl Interpreter { return String::new(); } "-" => { - // $- - Current option flags as a string - // Build from SHOPT_* variables - let mut flags = String::new(); - for opt in ['e', 'x', 'u', 'f', 'n', 'v', 'a', 'b', 'h', 'm'] { - let opt_name = format!("SHOPT_{}", opt); - if self - .scoped - .variables - .get(&opt_name) - .map(|v| v == "1") - .unwrap_or(false) - { - 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. @@ -10625,8 +10658,10 @@ impl Interpreter { } return u32::from_le_bytes(b).to_string(); } - "LINENO" => { - // $LINENO - current line number from command span + "BASH_SUBSHELL" => return self.bash_subshell.to_string(), + "LINENO" if !self.is_local_anywhere("LINENO") => { + // $LINENO - current line number from command span. A + // `local LINENO` is an ordinary variable (bash). return self.current_line.to_string(); } "PWD" => { @@ -10701,6 +10736,7 @@ impl Interpreter { | "HOSTNAME" | "BASH_VERSION" | "SECONDS" + | "BASH_SUBSHELL" ) { return true; } diff --git a/crates/bashkit/tests/integration/blackbox_security_tests.rs b/crates/bashkit/tests/integration/blackbox_security_tests.rs index 7fcab0980..e382b228a 100644 --- a/crates/bashkit/tests/integration/blackbox_security_tests.rs +++ b/crates/bashkit/tests/integration/blackbox_security_tests.rs @@ -591,13 +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(); assert_eq!( result.stdout.trim(), - "", - "set -b/-h/-m leaked across exec() calls through $-" + "hBc", + "set -b/-k/-m/+h/+B leaked across exec() calls through $-" ); } 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 a0f6a464f..95c410642 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-attributes.test.sh @@ -65,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 @@ -187,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 @@ -206,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 @@ -215,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 @@ -224,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 @@ -242,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 @@ -250,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 @@ -265,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 ffdef5577..10c20ce92 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-declare.test.sh @@ -30,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 @@ -301,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' @@ -335,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 @@ -346,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 @@ -356,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 @@ -367,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 @@ -375,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 c1d95190f..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 @@ -170,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 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/knowledge/log.md b/knowledge/log.md index 5940b6540..d342bfcb9 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **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` counts `(...)`, `$(...)`, `<(...)` and forked pipeline stages. `( )`, `[[ ]]` 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). From 0f955261927b4f3aaabf502aa1c07bd35a3e6664 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 20:03:30 +0000 Subject: [PATCH 06/11] chore(bench): parallel_execution results after declaration engine merge --- ...ion-parallel-vm-linux-x86_64-1791401002.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 crates/bashkit/benches/results/criterion-parallel-vm-linux-x86_64-1791401002.md 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. From b975907d70fce82336b7e9146ca5174e67cf441c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 20:29:34 +0000 Subject: [PATCH 07/11] fix(arrays): brace and pathname expansion for array-literal words Shared by a=(...), a+=(...), declare -a, typeset -a and local -a. Mixed quoted words like "x"{1,2} expand; keyed assoc values stay literal; [k]=v{1,2} lexes as one word. --- crates/bashkit/src/interpreter/declare.rs | 52 +++++++++---- crates/bashkit/src/parser/lexer.rs | 8 +- crates/bashkit/src/parser/mod.rs | 5 ++ .../tests/spec_cases/bash/arrays.test.sh | 76 +++++++++++++++++++ knowledge/log.md | 1 + 5 files changed, 126 insertions(+), 16 deletions(-) diff --git a/crates/bashkit/src/interpreter/declare.rs b/crates/bashkit/src/interpreter/declare.rs index d158859b5..340fbece7 100644 --- a/crates/bashkit/src/interpreter/declare.rs +++ b/crates/bashkit/src/interpreter/declare.rs @@ -1369,7 +1369,11 @@ impl Interpreter { ); let mut next = map.keys().max().map_or(0, |k| k + 1); 'words: for word in words { - if let Some((kw, vw, kappend)) = split_keyed_word(word) { + // 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, @@ -1428,6 +1432,26 @@ impl Interpreter { /// 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!( @@ -1457,20 +1481,22 @@ impl Interpreter { } let value = self.expand_word(word).await?; let literal_only = word.parts.iter().all(|p| matches!(p, WordPart::Literal(_))); - if word.quoted || !literal_only { + let globbable = if word.quoted { + word.has_unquoted_glob + } else { + literal_only + }; + if !globbable { return Ok(vec![value]); } - let mut fields = Vec::new(); - for item in self.expand_braces(&value) { - match self.expand_glob_item(&item, false).await { - Ok(items) => fields.extend(items), - Err(_) => fields.push(item), - } - if fields.len() >= limit { - fields.truncate(limit); - break; - } - } + 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) } } diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 15670af7a..65f45d658 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -631,10 +631,11 @@ impl<'a> Lexer<'a> { if let Err(e) = self.read_backtick_into(&mut word) { return Some(Token::Error(e)); } - } else if self.is_word_char(ch) || ch == ']' || ch == '}' { + } 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 { @@ -2013,7 +2014,8 @@ impl<'a> Lexer<'a> { // `[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) { + } 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 { diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 761890c7d..a4063fd23 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -2299,8 +2299,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) 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/knowledge/log.md b/knowledge/log.md index e5a00d4b5..950de2fd0 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **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). From 86af5c0ab4b03fa644a09fae2bd34d93ab303925 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 20:42:42 +0000 Subject: [PATCH 08/11] test(arrays): array-literal brace expansion stays within entry budget --- .../tests/integration/threat_model_tests.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index a666b2332..ce679dcaf 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -3910,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() { From 3a5aca5b536029f5e867209c9b647bb87b0d73c3 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 21:01:33 +0000 Subject: [PATCH 09/11] test(bindings): division by zero aborts the line like bash --- crates/bashkit-js/__test__/security.spec.ts | 14 ++++++++------ crates/bashkit-python/tests/_security_core.py | 12 ++++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/bashkit-js/__test__/security.spec.ts b/crates/bashkit-js/__test__/security.spec.ts index b12f8a940..0074a4249 100644 --- a/crates/bashkit-js/__test__/security.spec.ts +++ b/crates/bashkit-js/__test__/security.spec.ts @@ -959,18 +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"); - // Bashkit returns 0 for div-by-zero (differs from bash which errors). + // Like bash: an expansion error aborts the line with exit 1. // Key security property: interpreter must not crash or panic. - t.is(typeof r.exitCode, "number", "division by zero must not crash"); - t.is(r.stdout.trim(), "0", "bashkit returns 0 for div-by-zero"); + 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: bashkit returns 0 instead of erroring. - t.is(typeof r.exitCode, "number", "modulo by zero must not crash"); - t.is(r.stdout.trim(), "0", "bashkit returns 0 for mod-by-zero"); + // 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 8ffc9b549..c40797a4b 100644 --- a/crates/bashkit-python/tests/_security_core.py +++ b/crates/bashkit-python/tests/_security_core.py @@ -255,16 +255,20 @@ 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 return an error value, not panic.""" r = Bash().execute_sync("echo $((1 / 0)) 2>&1") - assert isinstance(r.exit_code, int) - assert r.stdout.strip() == "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) 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.stdout.strip() == "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) From 764d79959a3741938d44fecc19cf39fc415452bd Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 21:20:24 +0000 Subject: [PATCH 10/11] fix(interpreter): keep function calls off the builtin dispatch frame A function call is the recursion path. Dispatching it from the same future as the builtin and PATH-search arms put that whole frame on the stack at every call level, so 16 nested calls overflowed a 2 MiB stack in debug builds. Functions now get their own small future, and prefix assignments are boxed. Debug CLI now reaches the depth limit with 1.75 MiB of stack (main needed more than 1.94 MiB). --- crates/bashkit/src/interpreter/mod.rs | 149 ++++++++++++++------------ 1 file changed, 83 insertions(+), 66 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index babc6c765..acf1cff59 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -6432,77 +6432,81 @@ impl Interpreter { /// 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. - async fn process_command_assignments( - &mut self, - assignments: &[Assignment], + fn process_command_assignments<'a>( + &'a mut self, + assignments: &'a [Assignment], has_command: bool, - ) -> Result { - 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 - ))); - } - }; - 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; + ) -> 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 { + 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 + ))); + } + }; + 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)); } - return Err(crate::error::Error::LineAbort(msg)); - } - 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); + 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); } - self.assign_raw = false; - } else if assignment.append { - self.append_scalar(&assignment.name, value); - } else { - self.set_variable(assignment.name.clone(), value); } - } - AssignmentValue::Array(words) => { - let arr_name = match self.resolve_nameref_strict(&assignment.name) { - Ok(n) => n, - Err(()) => { + 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: warning: {}: circular name reference\n", - assignment.name + "bash: {arr_name}: readonly variable\n" ))); } - }; - // 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" - ))); + let assoc = self.scoped.assoc_arrays.contains_key(&arr_name); + self.assign_array_words(&arr_name, words, assignment.append, assoc) + .await?; } - let assoc = self.scoped.assoc_arrays.contains_key(&arr_name); - self.assign_array_words(&arr_name, words, assignment.append, assoc) - .await?; } } - } - Ok(stderr) + Ok(stderr) + }) } /// Try alias expansion. Returns `Some(result)` if alias was expanded, `None` otherwise. @@ -7852,14 +7856,27 @@ impl Interpreter { args: Vec, stdin: Option, ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - // Check for functions first - if let Some(func_def) = self.scoped.functions.get(name).cloned() { - return self - .execute_function_call(name, &func_def, args, stdin, &command.redirects) - .await; - } + // Functions first, in their own small future: a function call is the + // recursion path, and the builtin/path-search arms below would + // otherwise add their large frame to every call level + // (THREAT[TM-DOS-020]: bounded recursion on a 2 MiB stack). + if let Some(func_def) = self.scoped.functions.get(name).cloned() { + return Box::pin(async move { + self.execute_function_call(name, &func_def, args, stdin, &command.redirects) + .await + }); + } + self.dispatch_non_function(name, command, args, stdin) + } + fn dispatch_non_function<'a>( + &'a mut self, + name: &'a str, + command: &'a SimpleCommand, + args: Vec, + stdin: Option, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { // Interpreter-level special builtins if Self::is_special_builtin_name(name) { return self From ff72480e79d1e9407711a2da2eb26d6a5a5978b9 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 21:50:11 +0000 Subject: [PATCH 11/11] test(python): negative exponent aborts the line like bash --- crates/bashkit-python/tests/_security_core.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/bashkit-python/tests/_security_core.py b/crates/bashkit-python/tests/_security_core.py index c40797a4b..0b4b44439 100644 --- a/crates/bashkit-python/tests/_security_core.py +++ b/crates/bashkit-python/tests/_security_core.py @@ -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)