From 4b006458cc67f86a1aff3a9f08465c16d38242f4 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:46:37 +0530 Subject: [PATCH 1/2] fix: validate caller-owned output attributes --- docs/v2-api-contract.md | 16 +++-- lib/bash/app/lib_app.sh | 6 +- lib/bash/app/tests/lib_app.bats | 52 ++++++++++++++ lib/bash/arg/README.md | 4 ++ lib/bash/arg/lib_arg.sh | 6 +- lib/bash/arg/tests/lib_arg.bats | 45 ++++++++++++ lib/bash/cli/lib_cli.sh | 6 +- lib/bash/cli/tests/lib_cli.bats | 22 ++++++ lib/bash/gh/lib_gh.sh | 6 +- lib/bash/gh/tests/lib_gh.bats | 15 ++++ lib/bash/git/lib_git.sh | 6 +- lib/bash/git/tests/lib_git.bats | 16 +++++ lib/bash/list/README.md | 8 ++- lib/bash/list/lib_list.sh | 10 +-- lib/bash/list/tests/lib_list.bats | 29 ++++++++ lib/bash/std/README.md | 8 +++ lib/bash/std/lib_std.sh | 113 +++++++++++++++++++++++++++--- lib/bash/std/tests/lib_std.bats | 48 +++++++++++++ lib/bash/str/README.md | 4 ++ lib/bash/str/lib_str.sh | 12 ++-- lib/bash/str/tests/lib_str.bats | 58 +++++++++++++++ 21 files changed, 449 insertions(+), 41 deletions(-) diff --git a/docs/v2-api-contract.md b/docs/v2-api-contract.md index 739bd91..f7ec7a6 100644 --- a/docs/v2-api-contract.md +++ b/docs/v2-api-contract.md @@ -47,12 +47,20 @@ platform-specific utility's exact status. - Diagnostics, warnings, logs, usage text, traces, and failure explanations go to standard error. They never contaminate command-substitution data. - APIs that return a value use one pass-by-name result as their first argument, - followed by inputs. Result arrays are caller-declared indexed arrays and - scalar results are caller-owned variables. + followed by inputs. Each API documents whether its result is a scalar, + integer, indexed array, or associative array. Arrays must be declared with + the required kind before the call; ordinary scalar results accept an + untyped/exported scalar, and numeric results also accept an integer (`-i`) + scalar. - Before any side effect, an output name is checked for valid Bash identifier syntax, the reserved `__` prefix, readonly status, correct array kind, and - aliases with an input or another output. On failure, outputs remain - unchanged unless an API explicitly documents partial mutation. + aliases with an input or another output. Integer (`-i`) and case-converting + (`-l`/`-u`) attributes are rejected for string and array results because + Bash would silently coerce published values. Readonly variables and + namerefs are rejected; namerefs are never followed, so readonly targets, + cycles, and reserved targets fail safely on Bash versions that support them. + On failure, outputs remain unchanged unless an API explicitly documents + partial mutation. - Named-output APIs are preferred over command substitution for values that may contain newlines, whitespace, or leading dashes. diff --git a/lib/bash/app/lib_app.sh b/lib/bash/app/lib_app.sh index 59724fe..e96c26e 100644 --- a/lib/bash/app/lib_app.sh +++ b/lib/bash/app/lib_app.sh @@ -625,7 +625,7 @@ base_app_config_get() { __base_bash_libs_std_validate_variable_names__ base_app_config_get \ "$__base_bash_libs_app_config_get_result_name" || return 2 __base_bash_libs_std_assert_writable_output__ base_app_config_get \ - "$__base_bash_libs_app_config_get_result_name" || return 2 + "$__base_bash_libs_app_config_get_result_name" scalar || return 2 [[ -n "${__base_bash_libs_app_values["$__base_bash_libs_app_config_get_model|$__base_bash_libs_app_config_get_key"]+set}" ]] || return 1 printf -v "$__base_bash_libs_app_config_get_result_name" '%s' \ "${__base_bash_libs_app_values["$__base_bash_libs_app_config_get_model|$__base_bash_libs_app_config_get_key"]}" @@ -644,7 +644,7 @@ base_app_config_provenance() { __base_bash_libs_std_validate_variable_names__ base_app_config_provenance \ "$__base_bash_libs_app_config_provenance_result_name" || return 2 __base_bash_libs_std_assert_writable_output__ base_app_config_provenance \ - "$__base_bash_libs_app_config_provenance_result_name" || return 2 + "$__base_bash_libs_app_config_provenance_result_name" scalar || return 2 [[ -n "${__base_bash_libs_app_provenance["$__base_bash_libs_app_config_provenance_model|$__base_bash_libs_app_config_provenance_key"]+set}" ]] || return 1 printf -v "$__base_bash_libs_app_config_provenance_result_name" '%s' \ "${__base_bash_libs_app_provenance["$__base_bash_libs_app_config_provenance_model|$__base_bash_libs_app_config_provenance_key"]}" @@ -852,7 +852,7 @@ base_app_status() { __base_bash_libs_std_validate_variable_names__ base_app_status \ "$__base_bash_libs_app_status_result_name" || return 2 __base_bash_libs_std_assert_writable_output__ base_app_status \ - "$__base_bash_libs_app_status_result_name" || return 2 + "$__base_bash_libs_app_status_result_name" integer || return 2 __base_bash_libs_app_model_exists__ "$__base_bash_libs_app_status_model" || return 1 printf -v "$__base_bash_libs_app_status_result_name" '%s' \ "${__base_bash_libs_app_models["$__base_bash_libs_app_status_model|last-status"]-0}" diff --git a/lib/bash/app/tests/lib_app.bats b/lib/bash/app/tests/lib_app.bats index 4ece5f1..3041868 100644 --- a/lib/bash/app/tests/lib_app.bats +++ b/lib/bash/app/tests/lib_app.bats @@ -73,6 +73,58 @@ assert_demo_snapshot() { [ "$value" = cli-secret ] } +@test "string configuration outputs reject integer and case-converting variables" { + local stderr_file="$TEST_TMPDIR/config-typed-output.err" + local rc output_name + local -i integer_output=42 + local -u uppercase_output=MiXeD + local -a app_args=() + + base_init app_args --source "${BASH_SOURCE[0]}" -- + base_app_init typed_output name=typed-output + base_app_config_define typed_output greeting string default=hello + + if base_app_config_get typed_output greeting integer_output 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "$integer_output" -eq 42 ] + [[ "$(<"$stderr_file")" == *"attributes incompatible with the scalar output contract"* ]] + + if base_app_config_get typed_output greeting uppercase_output 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "$uppercase_output" = MIXED ] + + if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3))); then + local readonly_target=unchanged + local __private_target=private + readonly readonly_target + local -n readonly_alias=readonly_target + local -n reserved_alias=__private_target + local cycle_left cycle_right + local -n cycle_left=cycle_right + local -n cycle_right=cycle_left + + for output_name in readonly_alias reserved_alias cycle_left; do + if base_app_config_get typed_output greeting "$output_name" 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [[ "$(<"$stderr_file")" == *"is a nameref; named outputs require a direct variable"* ]] + done + [ "$readonly_target" = unchanged ] + [ "$__private_target" = private ] + fi +} + @test "empty environment bindings do not override file configuration" { local project_file="$TEST_TMPDIR/project.conf" value source diff --git a/lib/bash/arg/README.md b/lib/bash/arg/README.md index ccdfdc0..f2a865b 100644 --- a/lib/bash/arg/README.md +++ b/lib/bash/arg/README.md @@ -18,6 +18,10 @@ helpers are available. The options, positionals, and specs arrays must have distinct names. Every repeatable option's output array must also be distinct from those three arrays. Aliasing is rejected before any caller-owned output is changed. +The options output must be a caller-declared associative array; positionals +and repeatable option outputs must be caller-declared indexed arrays. Arrays +with integer or case-converting attributes, readonly outputs, and nameref +outputs are rejected before parsing or publication. ## Usage diff --git a/lib/bash/arg/lib_arg.sh b/lib/bash/arg/lib_arg.sh index 864202b..1c4a826 100644 --- a/lib/bash/arg/lib_arg.sh +++ b/lib/bash/arg/lib_arg.sh @@ -102,7 +102,7 @@ __base_bash_libs_arg_parse_specs__() { base_std_log_error -l base_bash_libs.arg "base_arg_parse: repeatable option '$__base_bash_libs_arg_name' requires a caller-declared indexed array." return 2 fi - __base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_name" indexed-array || return 2 eval "$__base_bash_libs_arg_repeatable_names_name+=(\"\$__base_bash_libs_arg_name\")" fi @@ -167,8 +167,8 @@ base_arg_parse() { __base_bash_libs_arg_assert_distinct_names__ "$__base_bash_libs_arg_options_name" "$__base_bash_libs_arg_positionals_name" "$__base_bash_libs_arg_specs_name" || return 2 __base_bash_libs_std_validate_array_kind__ base_arg_parse A "$__base_bash_libs_arg_options_name" || return 2 __base_bash_libs_std_validate_array_kind__ base_arg_parse a "$__base_bash_libs_arg_positionals_name" "$__base_bash_libs_arg_specs_name" || return 2 - __base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_options_name" || return 2 - __base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_positionals_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_options_name" associative-array || return 2 + __base_bash_libs_std_assert_writable_output__ base_arg_parse "$__base_bash_libs_arg_positionals_name" indexed-array || return 2 __base_bash_libs_arg_parse_specs__ "$__base_bash_libs_arg_specs_name" __base_bash_libs_arg_token_kind __base_bash_libs_arg_token_name __base_bash_libs_arg_repeatable_names \ "$__base_bash_libs_arg_options_name" "$__base_bash_libs_arg_positionals_name" "$__base_bash_libs_arg_specs_name" || return $? diff --git a/lib/bash/arg/tests/lib_arg.bats b/lib/bash/arg/tests/lib_arg.bats index e19ce91..cf8393d 100644 --- a/lib/bash/arg/tests/lib_arg.bats +++ b/lib/bash/arg/tests/lib_arg.bats @@ -87,6 +87,51 @@ create_script() { [ "${positionals[2]}" = "gamma" ] } +@test "base_arg_parse rejects coercing attributes on its output arrays before publication" { + local stderr_file="$TEST_TMPDIR/arg-typed-output.err" + local rc + local -Au options=([existing]=sentinel) + local -ai positionals=(23) + local -a specs=("verbose|flag|--verbose|-v") + + if base_arg_parse options positionals specs -- --verbose new 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "${options[existing]}" = SENTINEL ] + [ "${positionals[0]}" -eq 23 ] + [[ "$(<"$stderr_file")" == *"attributes incompatible with the associative-array output contract"* ]] + + local -A plain_options=([existing]=keep) + if base_arg_parse plain_options positionals specs -- --verbose new 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "${plain_options[existing]}" = keep ] + [ "${positionals[0]}" -eq 23 ] + [[ "$(<"$stderr_file")" == *"attributes incompatible with the indexed-array output contract"* ]] + + local -A repeatable_options=([existing]=keep) + local -a repeatable_positionals=(old) + local -ai tag_values=(23) + local -a repeatable_specs=("tag_values|repeatable|--tag") + if base_arg_parse repeatable_options repeatable_positionals repeatable_specs -- \ + --tag new 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "${repeatable_options[existing]}" = keep ] + [ "${repeatable_positionals[0]}" = old ] + [ "${tag_values[0]}" -eq 23 ] + [[ "$(<"$stderr_file")" == *"attributes incompatible with the indexed-array output contract"* ]] +} + @test "base_arg_parse rejects readonly output arrays before parsing" { local script="$TEST_TMPDIR/arg-readonly-output.sh" diff --git a/lib/bash/cli/lib_cli.sh b/lib/bash/cli/lib_cli.sh index 40ef9d0..9625f55 100644 --- a/lib/bash/cli/lib_cli.sh +++ b/lib/bash/cli/lib_cli.sh @@ -1593,7 +1593,7 @@ base_cli_result_get() { __base_bash_libs_std_validate_variable_names__ base_cli_result_get \ "$__base_bash_libs_cli_result_get_result_name" || return 2 __base_bash_libs_std_assert_writable_output__ base_cli_result_get \ - "$__base_bash_libs_cli_result_get_result_name" || return 2 + "$__base_bash_libs_cli_result_get_result_name" scalar || return 2 [[ -n "${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[$__base_bash_libs_cli_result_get_key]+set}" ]] || return 1 printf -v "$__base_bash_libs_cli_result_get_result_name" '%s' \ "${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[$__base_bash_libs_cli_result_get_key]}" @@ -1614,7 +1614,7 @@ base_cli_result_get_positional() { __base_bash_libs_std_validate_variable_names__ base_cli_result_get_positional \ "$__base_bash_libs_cli_result_get_positional_result_name" || return 2 __base_bash_libs_std_assert_writable_output__ base_cli_result_get_positional \ - "$__base_bash_libs_cli_result_get_positional_result_name" || return 2 + "$__base_bash_libs_cli_result_get_positional_result_name" scalar || return 2 ((${#BASE_BASH_LIBS_CLI_RESULT_POSITIONALS[@]} > 0)) || return 1 __base_bash_libs_cli_result_get_positional_normalized="${__base_bash_libs_cli_result_get_positional_index#"${__base_bash_libs_cli_result_get_positional_index%%[!0]*}"}" [[ -n "$__base_bash_libs_cli_result_get_positional_normalized" ]] || @@ -1649,7 +1649,7 @@ base_cli_result_count() { __base_bash_libs_std_validate_variable_names__ base_cli_result_count \ "$__base_bash_libs_cli_result_count_result_name" || return 2 __base_bash_libs_std_assert_writable_output__ base_cli_result_count \ - "$__base_bash_libs_cli_result_count_result_name" || return 2 + "$__base_bash_libs_cli_result_count_result_name" integer || return 2 printf -v "$__base_bash_libs_cli_result_count_result_name" '%s' \ "${BASE_BASH_LIBS_CLI_RESULT_REPEATABLE_COUNTS[$__base_bash_libs_cli_result_count_key]-0}" } diff --git a/lib/bash/cli/tests/lib_cli.bats b/lib/bash/cli/tests/lib_cli.bats index ed4c939..cf8c069 100644 --- a/lib/bash/cli/tests/lib_cli.bats +++ b/lib/bash/cli/tests/lib_cli.bats @@ -194,6 +194,28 @@ EOF [ "$count" -eq 2 ] } +@test "CLI named outputs enforce scalar and integer attributes" { + local stderr_file="$TEST_TMPDIR/cli-typed-output.err" + local rc + local -u color_value=sentinel + local -i repeatable_count=99 + + declare_demo_model + base_cli_parse demo -- manage u target-name --color green --tag one + + if base_cli_result_get color color_value 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "$color_value" = SENTINEL ] + [[ "$(<"$stderr_file")" == *"attributes incompatible with the scalar output contract"* ]] + + base_cli_result_count tag repeatable_count + [ "$repeatable_count" -eq 1 ] +} + @test "help is deterministic and includes aliases, defaults, and nested usage" { declare_demo_model diff --git a/lib/bash/gh/lib_gh.sh b/lib/bash/gh/lib_gh.sh index 081fd58..2f9f487 100644 --- a/lib/bash/gh/lib_gh.sh +++ b/lib/bash/gh/lib_gh.sh @@ -283,7 +283,7 @@ base_gh_repo_from_remote_url() { base_std_log_error -l base_bash_libs.gh "Usage: base_gh_repo_from_remote_url " return 2 fi - __base_bash_libs_std_assert_writable_output__ base_gh_repo_from_remote_url "$__base_bash_libs_gh_result_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_gh_repo_from_remote_url "$__base_bash_libs_gh_result_name" scalar || return 2 __base_bash_libs_gh_parse_repo_from_remote_url__ "$__base_bash_libs_gh_remote_url" __base_bash_libs_gh_parsed_repo || return 2 printf -v "$__base_bash_libs_gh_result_name" '%s' "$__base_bash_libs_gh_parsed_repo" @@ -305,7 +305,7 @@ base_gh_infer_repo_from_origin() { base_std_log_error -l base_bash_libs.gh "Usage: base_gh_infer_repo_from_origin [--optional]" return 2 fi - __base_bash_libs_std_assert_writable_output__ base_gh_infer_repo_from_origin "$__base_bash_libs_gh_infer_result_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_gh_infer_repo_from_origin "$__base_bash_libs_gh_infer_result_name" scalar || return 2 if [[ "${3:-}" == "--optional" ]]; then __base_bash_libs_gh_infer_optional=1 @@ -340,7 +340,7 @@ base_gh_repo_default_branch() { base_std_log_error -l base_bash_libs.gh "Usage: base_gh_repo_default_branch " return 2 fi - __base_bash_libs_std_assert_writable_output__ base_gh_repo_default_branch "$__base_bash_libs_gh_repo_result_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_gh_repo_default_branch "$__base_bash_libs_gh_repo_result_name" scalar || return 2 base_gh_require_cli || return 1 __base_bash_libs_gh_repo_default_branch="$(gh repo view "$__base_bash_libs_gh_repo" --json defaultBranchRef --jq .defaultBranchRef.name 2> /dev/null)" || __base_bash_libs_gh_repo_status=$? diff --git a/lib/bash/gh/tests/lib_gh.bats b/lib/bash/gh/tests/lib_gh.bats index 5ed05a8..abba85d 100644 --- a/lib/bash/gh/tests/lib_gh.bats +++ b/lib/bash/gh/tests/lib_gh.bats @@ -370,6 +370,21 @@ EOF [[ "$(cat "$stderr_file")" == *"result variable 'repo' is readonly"* ]] } +@test "GitHub result parser rejects coercing scalar output before publishing" { + local stderr_file="$TEST_TMPDIR/gh-typed-output.err" + local rc + local -i repo=42 + + if base_gh_repo_from_remote_url "https://github.com/basefoundry/base-bash-libs.git" repo 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "$repo" -eq 42 ] + [[ "$(<"$stderr_file")" == *"attributes incompatible with the scalar output contract"* ]] +} + @test "GitHub result helpers reject exact internal holder names before locals or mutation" { local -r __base_bash_libs_gh_result_name=parsed local -r __base_bash_libs_gh_infer_result_name=inferred diff --git a/lib/bash/git/lib_git.sh b/lib/bash/git/lib_git.sh index 66d3e52..07ca868 100644 --- a/lib/bash/git/lib_git.sh +++ b/lib/bash/git/lib_git.sh @@ -64,7 +64,7 @@ base_git_detect_default_branch() { base_std_log_error -l base_bash_libs.git "Usage: base_git_detect_default_branch " return 2 fi - __base_bash_libs_std_assert_writable_output__ base_git_detect_default_branch "$__base_bash_libs_git_detect_result_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_git_detect_default_branch "$__base_bash_libs_git_detect_result_name" scalar || return 2 if __base_bash_libs_git_detect_branch="$(__base_bash_libs_git_detect_default_branch__ "$__base_bash_libs_git_detect_repo_dir")"; then printf -v "$__base_bash_libs_git_detect_result_name" '%s' "$__base_bash_libs_git_detect_branch" @@ -128,7 +128,7 @@ base_git_worktree_path_for_branch() { } if [[ -n "$__base_bash_libs_git_worktree_result_name" ]]; then __base_bash_libs_std_validate_variable_names__ base_git_worktree_path_for_branch "$__base_bash_libs_git_worktree_result_name" || return 2 - __base_bash_libs_std_assert_writable_output__ base_git_worktree_path_for_branch "$__base_bash_libs_git_worktree_result_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_git_worktree_path_for_branch "$__base_bash_libs_git_worktree_result_name" scalar || return 2 fi if ! __base_bash_libs_git_capture_worktree_records__ \ @@ -521,7 +521,7 @@ base_git_get_current_branch() { base_std_log_error -l base_bash_libs.git "base_git_get_current_branch: result variable name must be a valid Bash variable name." return 2 fi - __base_bash_libs_std_assert_writable_output__ base_git_get_current_branch "$__base_bash_libs_git_branch_result_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_git_get_current_branch "$__base_bash_libs_git_branch_result_name" scalar || return 2 printf -v "$__base_bash_libs_git_branch_result_name" '%s' "" diff --git a/lib/bash/git/tests/lib_git.bats b/lib/bash/git/tests/lib_git.bats index 8a54410..0b2aeb6 100644 --- a/lib/bash/git/tests/lib_git.bats +++ b/lib/bash/git/tests/lib_git.bats @@ -416,6 +416,22 @@ setup() { [[ "$(cat "$stderr_file")" == *"result variable 'branch' is readonly"* ]] } +@test "base_git_get_current_branch rejects coercing scalar outputs before repository access" { + local repo="$TEST_TMPDIR/repo" + local stderr_file="$TEST_TMPDIR/git-typed-output.err" + local rc + local -i branch=42 + + if base_git_get_current_branch "$repo" branch 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "$branch" -eq 42 ] + [[ "$(<"$stderr_file")" == *"attributes incompatible with the scalar output contract"* ]] +} + @test "Git result helpers reject exact internal holder names before locals or mutation" { local -r __base_bash_libs_git_detect_result_name=detected local -r __base_bash_libs_git_branch_result_name=current diff --git a/lib/bash/list/README.md b/lib/bash/list/README.md index dc933fb..5fc064d 100644 --- a/lib/bash/list/README.md +++ b/lib/bash/list/README.md @@ -45,9 +45,11 @@ fi Mutating helpers update the caller-owned array in place. Array arguments and array result variables must already be declared as indexed arrays, for example -with `declare -a values=()`. Scalar result helpers accept the name of the output -variable, validate it with `base_std_assert_variable_name`, and avoid stdout capture for -caller state. +with `declare -a values=()`. `base_list_length` accepts an untyped or integer +(`-i`) scalar result; other string-valued scalar outputs must be untyped or +exported only. Integer and case-converting attributes are rejected when they +could coerce array or string data. Readonly variables and nameref outputs are +rejected before mutation. For `base_list_unique` and `base_list_length`, the result and source variable names must be distinct. An alias is rejected before the source is changed. diff --git a/lib/bash/list/lib_list.sh b/lib/bash/list/lib_list.sh index 70cfe79..5e3c021 100644 --- a/lib/bash/list/lib_list.sh +++ b/lib/bash/list/lib_list.sh @@ -32,7 +32,7 @@ base_list_append() { local -a __base_bash_libs_list_values=() __base_bash_libs_std_validate_array_kind__ base_list_append a "$__base_bash_libs_list_array_name" || return 2 - __base_bash_libs_std_assert_writable_output__ base_list_append "$__base_bash_libs_list_array_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_list_append "$__base_bash_libs_list_array_name" indexed-array || return 2 shift __base_bash_libs_list_values=("$@") eval "$__base_bash_libs_list_array_name+=(\"\${__base_bash_libs_list_values[@]}\")" @@ -49,7 +49,7 @@ base_list_prepend() { local -a __base_bash_libs_list_values=() __base_bash_libs_list_current=() __base_bash_libs_list_combined=() __base_bash_libs_std_validate_array_kind__ base_list_prepend a "$__base_bash_libs_list_array_name" || return 2 - __base_bash_libs_std_assert_writable_output__ base_list_prepend "$__base_bash_libs_list_array_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_list_prepend "$__base_bash_libs_list_array_name" indexed-array || return 2 shift __base_bash_libs_list_values=("$@") eval "if [[ -n \"\${${__base_bash_libs_list_array_name}[@]+set}\" ]]; then __base_bash_libs_list_current=(\"\${${__base_bash_libs_list_array_name}[@]}\"); fi" @@ -75,7 +75,7 @@ base_list_remove() { local -a __base_bash_libs_list_current=() __base_bash_libs_list_filtered=() __base_bash_libs_std_validate_array_kind__ base_list_remove a "$__base_bash_libs_list_array_name" || return 2 - __base_bash_libs_std_assert_writable_output__ base_list_remove "$__base_bash_libs_list_array_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_list_remove "$__base_bash_libs_list_array_name" indexed-array || return 2 eval "if [[ -n \"\${${__base_bash_libs_list_array_name}[@]+set}\" ]]; then __base_bash_libs_list_current=(\"\${${__base_bash_libs_list_array_name}[@]}\"); fi" for __base_bash_libs_list_item in "${__base_bash_libs_list_current[@]+"${__base_bash_libs_list_current[@]}"}"; do @@ -123,7 +123,7 @@ base_list_unique() { __base_bash_libs_list_assert_distinct_names__ base_list_unique "$__base_bash_libs_list_result_name" "$__base_bash_libs_list_array_name" || return 2 __base_bash_libs_std_validate_array_kind__ base_list_unique a "$__base_bash_libs_list_result_name" "$__base_bash_libs_list_array_name" || return 2 - __base_bash_libs_std_assert_writable_output__ base_list_unique "$__base_bash_libs_list_result_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_list_unique "$__base_bash_libs_list_result_name" indexed-array || return 2 eval "if [[ -n \"\${${__base_bash_libs_list_array_name}[@]+set}\" ]]; then __base_bash_libs_list_current=(\"\${${__base_bash_libs_list_array_name}[@]}\"); fi" for __base_bash_libs_list_item in "${__base_bash_libs_list_current[@]+"${__base_bash_libs_list_current[@]}"}"; do @@ -153,7 +153,7 @@ base_list_length() { __base_bash_libs_list_assert_distinct_names__ base_list_length "$__base_bash_libs_list_result_name" "$__base_bash_libs_list_array_name" || return 2 __base_bash_libs_std_validate_array_kind__ base_list_length a "$__base_bash_libs_list_array_name" || return 2 - __base_bash_libs_std_assert_writable_output__ base_list_length "$__base_bash_libs_list_result_name" || return 2 + __base_bash_libs_std_assert_writable_output__ base_list_length "$__base_bash_libs_list_result_name" integer || return 2 eval "if [[ -n \"\${${__base_bash_libs_list_array_name}[@]+set}\" ]]; then __base_bash_libs_list_current=(\"\${${__base_bash_libs_list_array_name}[@]}\"); fi" # shellcheck disable=SC2199 # The + expansion safely detects Bash 4.2 empty arrays under nounset. diff --git a/lib/bash/list/tests/lib_list.bats b/lib/bash/list/tests/lib_list.bats index f827d3f..fe05634 100644 --- a/lib/bash/list/tests/lib_list.bats +++ b/lib/bash/list/tests/lib_list.bats @@ -266,6 +266,35 @@ EOF [[ "$(cat "$stderr_file")" == *"result variable 'values' is readonly"* ]] } +@test "list arrays reject coercing attributes and length accepts integer outputs" { + local stderr_file="$TEST_TMPDIR/list-typed-output.err" + local rc + local -au values=(alpha) + local -i count=99 + local -u uppercase_count=sentinel + + if base_list_append values beta 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "${values[*]}" = ALPHA ] + [[ "$(<"$stderr_file")" == *"attributes incompatible with the indexed-array output contract"* ]] + + local -a plain_values=(one two) + base_list_length count plain_values + [ "$count" -eq 2 ] + + if base_list_length uppercase_count plain_values 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + [ "$rc" -eq 2 ] + [ "$uppercase_count" = SENTINEL ] +} + @test "list helpers reject invalid variable names without echoing values" { local script="$TEST_TMPDIR/list-invalid-vars.sh" diff --git a/lib/bash/std/README.md b/lib/bash/std/README.md index b8da776..15251a9 100644 --- a/lib/bash/std/README.md +++ b/lib/bash/std/README.md @@ -64,6 +64,14 @@ documented arguments. Public helpers that write through caller-supplied variable or array names reserve the `__` prefix for library-internal state; such output names are rejected before caller state is changed. +Named scalar outputs accept untyped or exported scalar variables. Numeric +outputs also accept integer (`-i`) scalars. Array outputs must use the exact +indexed or associative kind documented by the API; integer and case-converting +(`-l`/`-u`) attributes are rejected where they could coerce data. Readonly +variables and nameref outputs are rejected before writes. Namerefs are not +resolved, which avoids following readonly, cyclic, or reserved targets on Bash +versions that support namerefs. + ### Runtime and Imports - `base_init [--source