Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and versions are tracked in the repo-root `VERSION` file.

### Fixed

- Hardened named-output helpers across std, string, arg, git, and GitHub
libraries against caller variable names that collide with helper internals.
- Made `gh_run` report failed GitHub CLI commands even when callers enable
`set -e`, and preserved argument boundaries in GitHub command failure logs.
- Documented and tested `str_split` trailing-separator behavior.
Expand Down
32 changes: 16 additions & 16 deletions lib/bash/arg/lib_arg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ fi
readonly __lib_arg_sourced__=1

__arg_set_assoc_value__() {
local array_name="$1" key="$2" value="$3"
local __arg_assoc_name="$1" __arg_assoc_key="$2" __arg_assoc_value="$3"

# The variable name is validated before callers reach this helper.
# shellcheck disable=SC1087
printf -v "$array_name[$key]" '%s' "$value"
printf -v "$__arg_assoc_name[$__arg_assoc_key]" '%s' "$__arg_assoc_value"
}

__arg_parse_specs__() {
local specs_name="$1"
local __arg_specs_name="$1"
local __arg_token_kind_name="$2" __arg_token_name_name="$3"
local -a __arg_specs=() __arg_tokens=()
local __arg_spec __arg_remainder __arg_name __arg_kind __arg_tokens_part __arg_token
local __arg_name_re='^[A-Za-z_][A-Za-z0-9_]*$'

eval "__arg_specs=(\"\${${specs_name}[@]}\")"
eval "__arg_specs=(\"\${${__arg_specs_name}[@]}\")"

for __arg_spec in "${__arg_specs[@]}"; do
__arg_name="${__arg_spec%%|*}"
Expand Down Expand Up @@ -76,7 +76,7 @@ __arg_parse_specs__() {
# arg_parse options positionals specs -- "$@"
#
arg_parse() {
local options_name="${1-}" positionals_name="${2-}" specs_name="${3-}"
local __arg_options_name="${1-}" __arg_positionals_name="${2-}" __arg_specs_name="${3-}"
local __arg_current __arg_option_token __arg_option_value __arg_option_name __arg_option_kind
local -a __arg_positionals=()
local -A __arg_token_kind=() __arg_token_name=()
Expand All @@ -87,25 +87,25 @@ arg_parse() {
return 2
fi

assert_variable_name "$options_name" "$positionals_name" "$specs_name"
assert_variable_name "$__arg_options_name" "$__arg_positionals_name" "$__arg_specs_name"

if ! __std_declares_array_kind__ "$options_name" "A"; then
if ! __std_declares_array_kind__ "$__arg_options_name" "A"; then
log_error "arg_parse: options variable must be an associative array declared by the caller."
return 2
fi
if ! __std_declares_array_kind__ "$positionals_name" "a"; then
if ! __std_declares_array_kind__ "$__arg_positionals_name" "a"; then
log_error "arg_parse: positionals variable must be an indexed array declared by the caller."
return 2
fi
if ! __std_declares_array_kind__ "$specs_name" "a"; then
if ! __std_declares_array_kind__ "$__arg_specs_name" "a"; then
log_error "arg_parse: specs variable must be an indexed array declared by the caller."
return 2
fi

__arg_parse_specs__ "$specs_name" __arg_token_kind __arg_token_name || return $?
__arg_parse_specs__ "$__arg_specs_name" __arg_token_kind __arg_token_name || return $?

eval "$options_name=()"
eval "$positionals_name=()"
eval "$__arg_options_name=()"
eval "$__arg_positionals_name=()"
shift 4

while (($# > 0)); do
Expand All @@ -132,7 +132,7 @@ arg_parse() {
return 2
fi

__arg_set_assoc_value__ "$options_name" "$__arg_option_name" "$__arg_option_value"
__arg_set_assoc_value__ "$__arg_options_name" "$__arg_option_name" "$__arg_option_value"
continue
fi

Expand All @@ -147,7 +147,7 @@ arg_parse() {
fi

if [[ "$__arg_option_kind" == "flag" ]]; then
__arg_set_assoc_value__ "$options_name" "$__arg_option_name" "1"
__arg_set_assoc_value__ "$__arg_options_name" "$__arg_option_name" "1"
continue
fi

Expand All @@ -162,13 +162,13 @@ arg_parse() {

__arg_option_value="$1"
shift
__arg_set_assoc_value__ "$options_name" "$__arg_option_name" "$__arg_option_value"
__arg_set_assoc_value__ "$__arg_options_name" "$__arg_option_name" "$__arg_option_value"
continue
fi

__arg_positionals+=("$__arg_current")
done

eval "$positionals_name=(\"\${__arg_positionals[@]}\")"
eval "$__arg_positionals_name=(\"\${__arg_positionals[@]}\")"
return 0
}
16 changes: 16 additions & 0 deletions lib/bash/arg/tests/lib_arg.bats
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ create_script() {
[ "${positionals[2]}" = "gamma" ]
}

@test "arg_parse supports shadowing-prone caller array names" {
local -a specs_name=(
"verbose|flag|--verbose|-v"
"output|value|--output|-o"
)
local -A options_name=()
local -a positionals_name=()

arg_parse options_name positionals_name specs_name -- -v --output result.txt item

[ "${options_name[verbose]}" = "1" ]
[ "${options_name[output]}" = "result.txt" ]
[ "${#positionals_name[@]}" -eq 1 ]
[ "${positionals_name[0]}" = "item" ]
}

@test "arg_parse accepts long option equals values and repeated options" {
local -a specs=(
"verbose|flag|--verbose|-v"
Expand Down
28 changes: 14 additions & 14 deletions lib/bash/gh/lib_gh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,38 +61,38 @@ gh_run() {
}

gh_repo_from_remote_url() {
local remote_url="$1"
local result_var="${2:-}"
local parsed_repo
local __gh_remote_url="$1"
local __gh_result_name="${2:-}"
local __gh_parsed_repo

if [[ -z "$remote_url" || -z "$result_var" ]]; then
if [[ -z "$__gh_remote_url" || -z "$__gh_result_name" ]]; then
log_error "Usage: gh_repo_from_remote_url <remote_url> <result_variable_name>"
return 1
fi
assert_variable_name "$result_var"
assert_variable_name "$__gh_result_name"

case "$remote_url" in
case "$__gh_remote_url" in
git@github.com:*.git)
parsed_repo="${remote_url#git@github.com:}"
parsed_repo="${parsed_repo%.git}"
__gh_parsed_repo="${__gh_remote_url#git@github.com:}"
__gh_parsed_repo="${__gh_parsed_repo%.git}"
;;
git@github.com:*)
parsed_repo="${remote_url#git@github.com:}"
__gh_parsed_repo="${__gh_remote_url#git@github.com:}"
;;
https://github.com/*.git)
parsed_repo="${remote_url#https://github.com/}"
parsed_repo="${parsed_repo%.git}"
__gh_parsed_repo="${__gh_remote_url#https://github.com/}"
__gh_parsed_repo="${__gh_parsed_repo%.git}"
;;
https://github.com/*)
parsed_repo="${remote_url#https://github.com/}"
__gh_parsed_repo="${__gh_remote_url#https://github.com/}"
;;
*)
return 1
;;
esac

[[ "$parsed_repo" == */* && "$parsed_repo" != */*/* ]] || return 1
printf -v "$result_var" '%s' "$parsed_repo"
[[ "$__gh_parsed_repo" == */* && "$__gh_parsed_repo" != */*/* ]] || return 1
printf -v "$__gh_result_name" '%s' "$__gh_parsed_repo"
}

gh_infer_repo_from_origin() {
Expand Down
11 changes: 11 additions & 0 deletions lib/bash/gh/tests/lib_gh.bats
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,17 @@ EOF
[ "$repo" = "owner/repo" ]
}

@test "gh_repo_from_remote_url supports shadowing-prone output variable names" {
local result_var=""
local parsed_repo=""

gh_repo_from_remote_url "https://github.com/owner/repo.git" result_var
gh_repo_from_remote_url "git@github.com:other/project.git" parsed_repo

[ "$result_var" = "owner/repo" ]
[ "$parsed_repo" = "other/project" ]
}

@test "gh_repo_from_remote_url rejects non-GitHub and malformed remotes" {
local repo="sentinel"

Expand Down
22 changes: 11 additions & 11 deletions lib/bash/git/lib_git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -244,41 +244,41 @@ git_update_repo() {
# - The function itself returns an exit code of 0 on success, 1 on invalid usage.
#
git_get_current_branch() {
local target_dir="$1"
local result_var_name="${2:-}"
local __git_branch_target_dir="$1"
local __git_branch_result_name="${2:-}"

# --- Argument Validation ---
if [[ -z "$target_dir" || -z "$result_var_name" ]]; then
if [[ -z "$__git_branch_target_dir" || -z "$__git_branch_result_name" ]]; then
log_error "Usage: git_get_current_branch <directory> <result_variable_name>"
return 1
fi
if ! __is_valid_variable_name__ "$result_var_name"; then
if ! __is_valid_variable_name__ "$__git_branch_result_name"; then
log_error "git_get_current_branch: result variable name must be a valid Bash variable name."
return 1
fi

printf -v "$result_var_name" '%s' ""
printf -v "$__git_branch_result_name" '%s' ""

if [[ ! -d "$target_dir" ]]; then
if [[ ! -d "$__git_branch_target_dir" ]]; then
return 0
fi

# Check if we are inside a Git repository.
if ! git -C "$target_dir" rev-parse --is-inside-work-tree > /dev/null 2>&1; then
if ! git -C "$__git_branch_target_dir" rev-parse --is-inside-work-tree > /dev/null 2>&1; then
# Not a Git repo, result is already an empty string.
return 0
fi

# Use 'git symbolic-ref' to get the branch name.
# It's the most reliable way to distinguish a branch from a detached HEAD.
# -q (--quiet) suppresses errors and returns a non-zero exit code on failure.
local branch_name
if branch_name=$(git -C "$target_dir" symbolic-ref --short -q HEAD); then
local __git_branch_name
if __git_branch_name=$(git -C "$__git_branch_target_dir" symbolic-ref --short -q HEAD); then
# Success: We are on a named branch.
printf -v "$result_var_name" '%s' "$branch_name"
printf -v "$__git_branch_result_name" '%s' "$__git_branch_name"
else
# Failure: We are in a detached HEAD state.
printf -v "$result_var_name" '%s' "detached head"
printf -v "$__git_branch_result_name" '%s' "detached head"
fi

return 0
Expand Down
14 changes: 14 additions & 0 deletions lib/bash/git/tests/lib_git.bats
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ setup() {
[ "$branch" = "main" ]
}

@test "git_get_current_branch supports shadowing-prone output variable names" {
local repo="$TEST_TMPDIR/repo"
local result_var_name=""
local branch_name=""

init_git_repo "$repo"

git_get_current_branch "$repo" result_var_name
git_get_current_branch "$repo" branch_name

[ "$result_var_name" = "main" ]
[ "$branch_name" = "main" ]
}

@test "git_get_current_branch reports detached head" {
local repo="$TEST_TMPDIR/repo"
local branch=""
Expand Down
Loading
Loading