From c28632ac015a318bf0103e98e7f314d0c2b68a15 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:03:27 +0530 Subject: [PATCH] bug: localize internal list cli and app scratch state (#338) --- CHANGELOG.md | 2 ++ lib/bash/app/lib_app.sh | 1 + lib/bash/app/tests/lib_app.bats | 10 ++++++++ lib/bash/cli/lib_cli.sh | 37 ++++++++++++++++++------------ lib/bash/cli/tests/lib_cli.bats | 26 +++++++++++++++++++++ lib/bash/list/lib_list.sh | 2 +- lib/bash/list/tests/lib_list.bats | 10 ++++++++ tests/namespace-contract.bats | 38 +++++++++++++++++++++++++++++++ 8 files changed, 110 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 255557b..4d76d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and versions are tracked in the repo-root `VERSION` file. ### Fixed +- Prevented list, CLI, and application call paths from creating or overwriting + caller-visible variables through undeclared internal scratch assignments. - Eliminated an intermittent macOS Bash process-group race in supervised commands by limiting job-control setup to the caller command rather than its short-lived timeout watchdog. diff --git a/lib/bash/app/lib_app.sh b/lib/bash/app/lib_app.sh index 3bc7b80..a225d3f 100644 --- a/lib/bash/app/lib_app.sh +++ b/lib/bash/app/lib_app.sh @@ -112,6 +112,7 @@ __base_bash_libs_app_trim__() { __base_bash_libs_app_validate_value__() { local model="$1" key="$2" value="$3" type enum validator item + local -a __base_bash_libs_app_enum_values=() type="${__base_bash_libs_app_config["$model|$key|type"]-}" enum="${__base_bash_libs_app_config["$model|$key|enum"]-}" validator="${__base_bash_libs_app_config["$model|$key|validator"]-}" diff --git a/lib/bash/app/tests/lib_app.bats b/lib/bash/app/tests/lib_app.bats index d6b3f10..6b04afc 100644 --- a/lib/bash/app/tests/lib_app.bats +++ b/lib/bash/app/tests/lib_app.bats @@ -65,6 +65,16 @@ assert_demo_snapshot() { [ "$value" = cli-secret ] } +@test "enum validation preserves a caller variable with the internal scratch name" { + local -a __base_bash_libs_app_enum_values=(caller-owned) + + declare_test_config + export APP_TEST_SECRET=secret + base_app_config_load demo --cli mode=prod + + [ "${__base_bash_libs_app_enum_values[*]}" = caller-owned ] +} + @test "configuration files are data and reject malformed or unknown records" { local config_file="$TEST_TMPDIR/config.conf" declare_test_config diff --git a/lib/bash/cli/lib_cli.sh b/lib/bash/cli/lib_cli.sh index 30784a3..e975479 100644 --- a/lib/bash/cli/lib_cli.sh +++ b/lib/bash/cli/lib_cli.sh @@ -160,17 +160,20 @@ __base_bash_libs_cli_ancestors_for__() { } __base_bash_libs_cli_option_lookup__() { - local model="$1" path="$2" token="$3" ancestor + local model="$1" path="$2" token="$3" + local name_result="$4" path_result="$5" type_result="$6" ancestor found_name - __base_bash_libs_cli_option_name="" - __base_bash_libs_cli_option_path="" - __base_bash_libs_cli_option_type="" + printf -v "$name_result" '%s' '' + printf -v "$path_result" '%s' '' + printf -v "$type_result" '%s' '' __base_bash_libs_cli_ancestors_for__ "$path" for ancestor in "${__base_bash_libs_cli_ancestors[@]}"; do if [[ -n "${__base_bash_libs_cli_models["$model|option|$ancestor|token|$token"]+set}" ]]; then - __base_bash_libs_cli_option_name="${__base_bash_libs_cli_models["$model|option|$ancestor|token|$token"]}" - __base_bash_libs_cli_option_path="$ancestor" - __base_bash_libs_cli_option_type="${__base_bash_libs_cli_models["$model|option|$ancestor|meta|$__base_bash_libs_cli_option_name|type"]}" + found_name="${__base_bash_libs_cli_models["$model|option|$ancestor|token|$token"]}" + printf -v "$name_result" '%s' "$found_name" + printf -v "$path_result" '%s' "$ancestor" + printf -v "$type_result" '%s' \ + "${__base_bash_libs_cli_models["$model|option|$ancestor|meta|$found_name|type"]}" return 0 fi done @@ -209,6 +212,7 @@ __base_bash_libs_cli_add_repeat__() { __base_bash_libs_cli_validate_value__() { local model="$1" path="$2" kind="$3" name="$4" value="$5" local enum validator item matched=0 + local -a __base_bash_libs_cli_enum_values=() enum="" validator="" @@ -246,6 +250,7 @@ __base_bash_libs_cli_validate_value__() { __base_bash_libs_cli_collect_options__() { local model="$1" path="$2" ancestor name seen_key + local -a __base_bash_libs_cli_local_option_names=() __base_bash_libs_cli_option_names=() __base_bash_libs_cli_option_paths=() @@ -278,6 +283,7 @@ __base_bash_libs_cli_option_declared_for_path__() { __base_bash_libs_cli_collect_positionals__() { local model="$1" path="$2" name + local -a __base_bash_libs_cli_local_positionals=() __base_bash_libs_cli_positional_names=() IFS=, read -r -a __base_bash_libs_cli_local_positionals <<< "${__base_bash_libs_cli_models["$model|command|positionals|$path"]-}" @@ -867,7 +873,7 @@ __base_bash_libs_cli_usage_line__() { base_cli_help() { local model="${1-}" path="${2-}" child_list child description name alias handler local label help_label_width=0 index option_path tokens help metavar required default sensitive - local -a help_labels=() help_descriptions=() help_sections=() + local -a help_labels=() help_descriptions=() help_sections=() __base_bash_libs_cli_children=() local has_commands=0 has_arguments=0 if (($# > 2)); then @@ -1065,7 +1071,9 @@ __base_bash_libs_cli_apply_positionals__() { # base_cli_parse - Parses a model and publishes results in BASE_BASH_LIBS_CLI_RESULT_*. # Usage: base_cli_parse model -- [argv...] base_cli_parse() { - local model="${1-}" current path="" token option_value name type child_path + local model="${1-}" current path="" token option_value name type child_path option_path + # shellcheck disable=SC2034 # Pass-by-name outputs used only to probe whether an option token is registered. + local probe_name probe_path probe_type local parse_options=1 if (($# < 2)) || [[ "$2" != -- ]]; then @@ -1109,12 +1117,10 @@ base_cli_parse() { token="${current%%=*}" option_value="${current#*=}" fi - if ! __base_bash_libs_cli_option_lookup__ "$model" "$path" "$token"; then + if ! __base_bash_libs_cli_option_lookup__ "$model" "$path" "$token" name option_path type; then __base_bash_libs_cli_usage_error__ "$model" "$path" "unknown option '$token'." return 2 fi - name="$__base_bash_libs_cli_option_name" - type="$__base_bash_libs_cli_option_type" if [[ "$type" == flag ]]; then if [[ -n "$option_value" ]]; then __base_bash_libs_cli_usage_error__ "$model" "$path" "flag '$token' does not accept a value." @@ -1131,13 +1137,14 @@ base_cli_parse() { option_value="$1" shift if [[ "$option_value" != -- && "$option_value" == -* ]]; then - if __base_bash_libs_cli_option_lookup__ "$model" "$path" "$option_value"; then + if __base_bash_libs_cli_option_lookup__ "$model" "$path" "$option_value" \ + probe_name probe_path probe_type; then __base_bash_libs_cli_usage_error__ "$model" "$path" "option '$token' requires a value before '$option_value'." return 2 fi fi fi - if ! __base_bash_libs_cli_validate_value__ "$model" "$__base_bash_libs_cli_option_path" option "$name" "$option_value"; then + if ! __base_bash_libs_cli_validate_value__ "$model" "$option_path" option "$name" "$option_value"; then __base_bash_libs_cli_usage_error__ "$model" "$path" "invalid value for option '$name'." return 2 fi @@ -1203,7 +1210,7 @@ __base_bash_libs_cli_completion_add__() { # Usage: base_cli_complete model -- [complete argv including the current prefix] base_cli_complete() { local model="${1-}" current prefix path="" word token option_path name index - local -a words=() completed=() children=() + local -a words=() completed=() children=() __base_bash_libs_cli_option_tokens=() if (($# < 2)) || [[ "$2" != -- ]]; then __base_bash_libs_cli_error__ 'base_cli_complete: usage: base_cli_complete -- [words...]' diff --git a/lib/bash/cli/tests/lib_cli.bats b/lib/bash/cli/tests/lib_cli.bats index d3d9659..2538687 100644 --- a/lib/bash/cli/tests/lib_cli.bats +++ b/lib/bash/cli/tests/lib_cli.bats @@ -204,6 +204,32 @@ EOF [[ "$output" == *"complete -F _demo_complete demo"* ]] } +@test "public CLI paths preserve caller variables that match internal scratch names" { + local __base_bash_libs_cli_option_name=caller-name + local __base_bash_libs_cli_option_path=caller-path + local __base_bash_libs_cli_option_type=caller-type + local -a __base_bash_libs_cli_enum_values=(caller-enum) + local -a __base_bash_libs_cli_local_option_names=(caller-options) + local -a __base_bash_libs_cli_local_positionals=(caller-positionals) + local -a __base_bash_libs_cli_children=(caller-children) + local -a __base_bash_libs_cli_option_tokens=(caller-tokens) + + declare_demo_model + base_cli_help demo > "$TEST_TMPDIR/root-help.out" + base_cli_help demo admin/user > "$TEST_TMPDIR/child-help.out" + base_cli_parse demo -- admin user target-name --color green + base_cli_complete demo -- admin user -- > "$TEST_TMPDIR/completion.out" + + [ "$__base_bash_libs_cli_option_name" = caller-name ] + [ "$__base_bash_libs_cli_option_path" = caller-path ] + [ "$__base_bash_libs_cli_option_type" = caller-type ] + [ "${__base_bash_libs_cli_enum_values[*]}" = caller-enum ] + [ "${__base_bash_libs_cli_local_option_names[*]}" = caller-options ] + [ "${__base_bash_libs_cli_local_positionals[*]}" = caller-positionals ] + [ "${__base_bash_libs_cli_children[*]}" = caller-children ] + [ "${__base_bash_libs_cli_option_tokens[*]}" = caller-tokens ] +} + @test "the declarative runtime contains no eval dependency" { ! grep -Ev '^[[:space:]]*#' "$BASE_BASH_DIR/cli/lib_cli.sh" | grep -Eq '(^|[[:space:];])eval([[:space:];]|$)' } diff --git a/lib/bash/list/lib_list.sh b/lib/bash/list/lib_list.sh index b45dc85..f89af2c 100644 --- a/lib/bash/list/lib_list.sh +++ b/lib/bash/list/lib_list.sh @@ -69,7 +69,7 @@ base_list_prepend() { base_list_remove() { base_std_assert_arg_count "$#" 2 __base_bash_libs_std_assert_public_variable_names__ base_list_remove "${1-}" || return 1 - local __base_bash_libs_list_array_name="$1" __base_bash_libs_list_needle="$2" + local __base_bash_libs_list_array_name="$1" __base_bash_libs_list_needle="$2" __base_bash_libs_list_item local -a __base_bash_libs_list_current=() __base_bash_libs_list_filtered=() base_std_assert_variable_name "$__base_bash_libs_list_array_name" diff --git a/lib/bash/list/tests/lib_list.bats b/lib/bash/list/tests/lib_list.bats index 40d1194..eafca72 100644 --- a/lib/bash/list/tests/lib_list.bats +++ b/lib/bash/list/tests/lib_list.bats @@ -110,6 +110,16 @@ create_script() { [ "${values[1]}" = "gamma" ] } +@test "base_list_remove preserves a caller scratch variable with the loop-item name" { + local __base_bash_libs_list_item=caller-owned + local -a values=(alpha beta) + + base_list_remove values alpha + + [ "$__base_bash_libs_list_item" = caller-owned ] + [ "${values[*]}" = beta ] +} + @test "base_list_contains checks membership without printing" { local -a values=("alpha" "beta gamma" "") local stdout_file="$TEST_TMPDIR/list-contains.out" diff --git a/tests/namespace-contract.bats b/tests/namespace-contract.bats index d92d1b5..efce12c 100644 --- a/tests/namespace-contract.bats +++ b/tests/namespace-contract.bats @@ -68,6 +68,44 @@ setup() { [[ "$output" == *"collision-safe=yes"* ]] } +@test "representative public calls do not create undeclared internal scratch globals" { + run env -i PATH="$PATH" bash --noprofile --norc -c ' + source "$1/std/lib_std.sh" + declare -a init_args=() + base_init init_args --source "$1/tests/namespace-contract.bats" -- + base_std_import list/lib_list.sh cli/lib_cli.sh app/lib_app.sh + + before="$(compgen -A variable | LC_ALL=C sort | grep "^__base_bash_libs_" || true)" + + declare -a values=(alpha beta) + base_list_remove values alpha + + base_cli_model_init scope name=scope + base_cli_command scope admin "Administration" + base_cli_command scope admin/user "User" + base_cli_option scope admin/user color value --color enum=blue,green + base_cli_positional scope admin/user target required=true + base_cli_help scope >/dev/null + base_cli_help scope admin/user >/dev/null + base_cli_parse scope -- admin user target --color green + base_cli_complete scope -- admin user -- >/dev/null + + base_app_init app + base_app_config_define app mode enum enum=dev,prod default=dev + base_app_config_load app + + after="$(compgen -A variable | LC_ALL=C sort | grep "^__base_bash_libs_" || true)" + if [[ "$after" != "$before" ]]; then + printf "internal scratch variable set changed:\n" + diff <(printf "%s\n" "$before") <(printf "%s\n" "$after") || true + exit 1 + fi + ' bash "$BASE_BASH_DIR" + + [ "$status" -eq 0 ] + [[ "$output" == "" ]] +} + @test "source files contain no legacy generic function definitions or guards" { run bash -c ' for file in "$1"/*/lib_*.sh; do