Skip to content

ref(bash): codify dynamic-scope safety for outvar helpers#675

Merged
Chemaclass merged 4 commits into
mainfrom
ref/674-dynamic-scope-safety
May 12, 2026
Merged

ref(bash): codify dynamic-scope safety for outvar helpers#675
Chemaclass merged 4 commits into
mainfrom
ref/674-dynamic-scope-safety

Conversation

@Chemaclass
Copy link
Copy Markdown
Member

@Chemaclass Chemaclass commented May 12, 2026

Summary

  • Hot-path helpers in src/runner.sh return via dedicated global slots (_BASHUNIT_RUNNER_*_OUT) instead of stdout. No $(...) fork, no eval, no shadowing risk — caller-named locals are simply never touched.
  • Spy state in src/test_doubles.sh is namespaced under _BASHUNIT_SPY_*, so a test that uses a local foo_times_file=... cannot collide with the framework's _BASHUNIT_SPY_foo_TIMES_FILE.
  • .claude/rules/bash-style.md documents three patterns in priority order:
    1. Preferred: dedicated global return slot.
    2. For helpers that build dynamic names: namespace the constructed name.
    3. Last resort: outvar-by-name with __bu_ prefix + regression test (none in tree).
  • Regression tests in tests/unit/runner_test.sh confirm helpers do not touch caller-named locals.

Why

Bash local is dynamically scoped, so any helper that touches caller-named variables (via eval "$name=...", printf -v, ${!name} or export "$name"=...) can silently corrupt a caller that picks a natural name. The fix is to avoid that pattern entirely: return through a fixed framework-owned slot, or namespace the names we construct.

Test plan

  • ./bashunit tests/unit/ green (839 passed, 1m20s)
  • ./bashunit --parallel tests/ green (1074 passed, 46.58s)
  • make sa clean
  • make lint clean
  • Bash 3.0+ compat preserved (no declare -n, no printf -v)

Closes #674

Bash 'local' is dynamically scoped, so helpers that take a caller-named
variable as an outvar must defend against the callee's locals shadowing
the caller's same-named variable; otherwise an eval-based assignment
silently writes to the local and the caller's variable keeps its old
value. This regression caused 12 parallel test failures in #662 before
the prefix was added.

- Document the convention in .claude/rules/bash-style.md (Outvar helpers
  section) with the failing example, the required pattern, and a pointer
  to the intentional dynamic-scope use in src/coverage.sh.
- Defensive rename in src/test_doubles.sh: all internal locals on the
  indirect-read paths (${!file_var}) use the __bu_ prefix so a caller
  local cannot shadow the resolved global file path.
- Add regression tests in tests/unit/runner_test.sh that pass each
  helper's documented internal variable name as the caller's outvar
  to catch any future drop of the prefix.

Closes #674
@Chemaclass Chemaclass added documentation Improvements or additions to documentation refactoring Refactoring or cleaning related labels May 12, 2026
@Chemaclass Chemaclass self-assigned this May 12, 2026
After review feedback on PR #674: mock/spy were left unprotected in the
first pass. Both helpers take a caller-named command and write derived
globals via 'export "${variable}_..._file"=...' — if a caller has a
local with the same constructed name in scope, the export updates the
local instead of creating a clean env binding (dynamic scoping). Same
hazard class as the indirect-read sites, applied consistently now.

Updates the rule in .claude/rules/bash-style.md to call out the whole
test_doubles.sh surface, not just the indirect-read sites.
@Chemaclass
Copy link
Copy Markdown
Member Author

Good catch on the audit gap. Extended in 60a31d1:

  • bashunit::mock and bashunit::spy now also use the __bu_ prefix on all internal locals. Both helpers take a caller-named command and write derived globals via export "${variable}_times_file"=.... If a caller has a local matching that constructed name, export NAME=value updates the local in-place (dynamic scoping) instead of creating a clean env binding — same hazard class as the indirect-read sites.
  • Rule note in .claude/rules/bash-style.md widened to cover the full test_doubles.sh surface (mock/spy/unmock + the assert_have_been_called family), not just the read direction.

Final audit state across src/*.sh:

File Pattern Status
src/runner.sh eval outvar __bu_ prefix (PR #672)
src/test_doubles.sh export + ${!var} __bu_ prefix (this PR)
src/coverage.sh intentional dynamic-scope documented exception, kept
src/helpers.sh unset_if_exists unset "$1" no internal locals — safe
src/env.sh:298-299 ${!key} iterates hardcoded BASHUNIT_* names — no caller-provided names, safe
src/assert*.sh, src/main.sh eval "$cmd" command-string execution, not outvar pattern — safe

make sa clean, make lint clean, unit suite 848 passed.

Drops the eval-based outvar pattern (and its mandatory __bu_ prefix on
internal locals) in favor of dedicated global return slots, one per
helper:

  _BASHUNIT_RUNNER_FIELD_OUT    (extract_encoded_field)
  _BASHUNIT_RUNNER_TOTAL_OUT    (compute_total_assertions)
  _BASHUNIT_RUNNER_TYPE_OUT     (extract_subshell_type)
  _BASHUNIT_RUNNER_OUTPUT_OUT   (format_subshell_output)

Same perf as outvar (no $(...) fork per call) but eliminates the
ergonomic cost of __bu_-prefixed internal locals and removes the dynamic
scoping pitfall entirely — the helper never touches caller-named
variables.

For test_doubles.sh the real fix wasn't on the helper's locals at all:
spy state is keyed by command name and stored via 'export NAME=value'
where NAME is constructed from the command. The fix is to namespace
the constructed name itself under _BASHUNIT_SPY_*, so a caller doing
'local foo_times_file=...' cannot collide with the helper's
_BASHUNIT_SPY_foo_TIMES_FILE. All test_doubles.sh internal locals are
restored to natural names.

The bash-style.md rule is rewritten around three patterns:
  1. Preferred: dedicated global return slot
  2. For helpers that build dynamic names: namespace the constructed name
  3. Last-resort outvar-by-name (requires __bu_ prefix + regression test)

Bash 3.0+ compatibility preserved.
@Chemaclass
Copy link
Copy Markdown
Member Author

Chemaclass commented May 12, 2026

Switched to simpler option after review.

runner.sh hot-path helpers now return via dedicated global slots, no eval, no prefix:

  • _BASHUNIT_RUNNER_FIELD_OUT (extract_encoded_field)
  • _BASHUNIT_RUNNER_TOTAL_OUT (compute_total_assertions)
  • _BASHUNIT_RUNNER_TYPE_OUT (extract_subshell_type)
  • _BASHUNIT_RUNNER_OUTPUT_OUT (format_subshell_output)

Internal locals back to natural names (input, val, subshell_output, etc.). Same perf as the eval-outvar version (no $(...) fork) and the dynamic-scope hazard is structurally gone — helper never touches caller-named variables.

test_doubles.sh — the real fix was on the constructed global names, not on helper locals. Internal locals restored to natural names; spy state moved from ${variable}_times_file to _BASHUNIT_SPY_${variable}_TIMES_FILE. A caller doing local foo_times_file=... can't collide with _BASHUNIT_SPY_foo_TIMES_FILE.

Rule in bash-style.md rewritten as three patterns in priority order:

  1. Preferred: dedicated global return slot (the new runner pattern + the existing _BASHUNIT_BRANCH_ARMS_OUT in coverage.sh)
  2. For helpers that build dynamic names: namespace the constructed name (_BASHUNIT_SPY_*)
  3. Last-resort outvar-by-name with __bu_ prefix on locals + regression test (none in tree right now)

@Chemaclass Chemaclass merged commit 1d64333 into main May 12, 2026
30 checks passed
@Chemaclass Chemaclass deleted the ref/674-dynamic-scope-safety branch May 12, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation refactoring Refactoring or cleaning related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ref(bash): codify dynamic-scope safety for helpers that take variable names

2 participants