Skip to content

Commit a0ad474

Browse files
mihai-stancuclaude
andcommitted
Sanitize nameref targets in args:opt/arg/varg/sub for hyphenated long flags
args:flag already strips non-alphanumeric chars when building the nameref target (so `--dry-run` can write to a caller-declared `dry_run` variable); the other four functions did not. Calling `args:opt gh-source ""` would fail with "invalid variable name for name reference" because bash variable names can't contain hyphens, even though the long-flag scan pattern (which preserves the hyphen) was correct. Apply the same `${1//[^_0-9a-zA-Z]/_}` substitution across args:opt, args:arg, args:varg, args:sub. Existing alphanumeric-only call sites are unaffected (substitution is a no-op). Two regression tests added (smoke:opt:hyphenated-long-name, smoke:flag:hyphenated-long-name). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d677453 commit a0ad474

3 files changed

Lines changed: 38 additions & 6 deletions

File tree

bash-args.sh

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ function args:opt() {
7979
local err=""; (($# >= 2)) && [[ "${*: -2:1}" == "--err" ]] && err="${*: -1}" && set -- "${@:1:$#-2}";
8080
local required="false"; [[ "${1:-}" == "-r" || "${1:-}" == "--required" ]] && required="true" && shift;
8181
local accumulate="false"; [[ "${1:-}" == "-a" || "${1:-}" == "--accumulate" ]] && accumulate="true" && shift;
82-
local -n __value_="${1?ERROR: args:opt requires <long>}";
82+
[[ -n "${1:-}" ]] || { echo "ERROR: args:opt requires <long>" >&2; return 1; };
83+
# Strip non-alphanumeric chars (hyphens, dots) from the nameref target so
84+
# hyphenated long flags like `--gh-source` can map to caller-declared
85+
# variables like `local gh_source`. The unsanitized form is kept below for
86+
# the `$long` flag-name scan pattern.
87+
local -n __value_="${1//[^_0-9a-zA-Z]/_}";
8388
local long="${1}"; shift;
8489
local short="${1?ERROR: args:opt requires <short>}"; shift;
8590
local pattern="${1:-(.*)}"; shift || true;
@@ -128,8 +133,9 @@ function args:arg() {
128133
local err=""; (($# >= 2)) && [[ "${*: -2:1}" == "--err" ]] && err="${*: -1}" && set -- "${@:1:$#-2}";
129134

130135
local optional="false"; [[ "${1:-}" == "-o" || "${1:-}" == "--optional" ]] && optional="true" && shift;
136+
[[ -n "${1:-}" ]] || { echo "ERROR: args:arg requires <name>" >&2; return 1; };
131137
# shellcheck disable=SC2178 # nameref is a string, points to an array
132-
local -n __value_="${1?ERROR: args:arg requires <name>}"; shift;
138+
local -n __value_="${1//[^_0-9a-zA-Z]/_}"; shift;
133139
local pattern="${1:-(.*)}"; shift || true;
134140

135141
[[ "${TOKENS[0]:-}" == "--" ]] && TOKENS=("${TOKENS[@]:1}");
@@ -173,8 +179,9 @@ function args:varg() {
173179

174180
local err=""; (($# >= 2)) && [[ "${*: -2:1}" == "--err" ]] && err="${*: -1}" && set -- "${@:1:$#-2}";
175181
local optional="false"; [[ "${1:-}" == "-o" || "${1:-}" == "--optional" ]] && optional="true" && shift;
182+
[[ -n "${1:-}" ]] || { echo "ERROR: args:varg requires <name>" >&2; return 1; };
176183
# shellcheck disable=SC2178 # nameref is a string, points to an array
177-
local -n __value_="${1?ERROR: args:varg requires <name>}"; shift;
184+
local -n __value_="${1//[^_0-9a-zA-Z]/_}"; shift;
178185

179186
[[ "${TOKENS[0]:-}" == "--" ]] && TOKENS=("${TOKENS[@]:1}");
180187

@@ -211,9 +218,11 @@ function args:sub() {
211218

212219
local err=""; (($# >= 2)) && [[ "${*: -2:1}" == "--err" ]] && err="${*: -1}" && set -- "${@:1:$#-2}";
213220
local optional="false"; [[ "${1:-}" == "-o" || "${1:-}" == "--optional" ]] && optional="true" && shift;
221+
[[ -n "${1:-}" ]] || { echo "ERROR: args:sub requires <name>" >&2; return 1; };
214222
# shellcheck disable=SC2178 # nameref is a string, points to an array
215-
local -n __value_="${1?ERROR: args:sub requires <name>}"; shift;
216-
local -n __rest_="${1?ERROR: args:sub requires <rest>}"; shift;
223+
local -n __value_="${1//[^_0-9a-zA-Z]/_}"; shift;
224+
[[ -n "${1:-}" ]] || { echo "ERROR: args:sub requires <rest>" >&2; return 1; };
225+
local -n __rest_="${1//[^_0-9a-zA-Z]/_}"; shift;
217226
local pattern="${1?ERROR: args:sub requires <pattern>}"; shift;
218227

219228
local i captured;

bash-args.test

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,25 @@ test:smoke:opt:absent() {
5151
assert "$name" "" || return 1;
5252
}
5353

54+
test:smoke:opt:hyphenated-long-name() {
55+
# Hyphenated long flags (--gh-source) must map to caller-declared
56+
# variables (gh_source) -- bash variable names can't contain hyphens, so
57+
# the nameref target is sanitized while the flag name is preserved.
58+
local ARGS=(--gh-source release);
59+
local gh_source;
60+
args:opt gh-source "" || return 1;
61+
assert "$gh_source" "release" || return 1;
62+
assert:array-eq ARGS "" || return 1;
63+
}
64+
65+
test:smoke:flag:hyphenated-long-name() {
66+
local ARGS=(--dry-run);
67+
local dry_run;
68+
args:flag dry-run "" || return 1;
69+
assert "$dry_run" "true" || return 1;
70+
assert:array-eq ARGS "" || return 1;
71+
}
72+
5473
test:smoke:arg:positional() {
5574
local ARGS=(foo);
5675
local file;
@@ -1249,6 +1268,8 @@ flag:absent | flag | args:flag verbose v | *(no
12491268
opt:long-space | opt | args:opt name n | --name foo | foo | 0 | $(t smoke:opt:long-space)
12501269
opt:short-space | opt | args:opt name n | -n foo | foo | 0 | $(t smoke:opt:short-space)
12511270
opt:absent | opt | args:opt name n | *(none)* | "" | 0 | $(t smoke:opt:absent)
1271+
opt:hyphenated | opt | args:opt gh-source "" | --gh-source release | release | 0 | $(t smoke:opt:hyphenated-long-name)
1272+
flag:hyphenated | flag | args:flag dry-run "" | --dry-run | true | 0 | $(t smoke:flag:hyphenated-long-name)
12521273
arg:positional | arg | args:arg file | foo | foo | 0 | $(t smoke:arg:positional)
12531274
arg:missing | arg | args:arg file | *(none)* | - | 1 | $(t smoke:arg:missing)
12541275
arg:optional-missing | arg | args:arg -o file | *(none)* | "" | 0 | $(t smoke:arg:optional-missing)

bash-args.test.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ flag:absent | flag | args:flag verbose v | *(no
1010
opt:long-space | opt | args:opt name n | --name foo | foo | 0 | ✅
1111
opt:short-space | opt | args:opt name n | -n foo | foo | 0 | ✅
1212
opt:absent | opt | args:opt name n | *(none)* | "" | 0 | ✅
13+
opt:hyphenated | opt | args:opt gh-source "" | --gh-source release | release | 0 | ✅
14+
flag:hyphenated | flag | args:flag dry-run "" | --dry-run | true | 0 | ✅
1315
arg:positional | arg | args:arg file | foo | foo | 0 | ✅
1416
arg:missing | arg | args:arg file | *(none)* | - | 1 | ✅
1517
arg:optional-missing | arg | args:arg -o file | *(none)* | "" | 0 | ✅
@@ -224,5 +226,5 @@ multiple-args-across-separator | args:arg a1; args:arg a2; args:arg a3
224226

225227
| ✅ Pass | ❌ Fail | ⚠️ Error |
226228
|---------|---------|----------|
227-
| 157 | 0 | 0 |
229+
| 159 | 0 | 0 |
228230

0 commit comments

Comments
 (0)