Skip to content

fix(expansion): don't trip nounset on a whole-word quoted ${var:-default} - #416

Open
trieloff wants to merge 2 commits into
vercel-labs:mainfrom
trieloff:bb/fix-expansion-var-word-as-a-whole-word-trips-set-thr_a3zhuvbaj8
Open

trieloff wants to merge 2 commits into
vercel-labs:mainfrom
trieloff:bb/fix-expansion-var-word-as-a-whole-word-trips-set-thr_a3zhuvbaj8

Conversation

@trieloff

@trieloff trieloff commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

The bug

Under set -u, a word that is exactly one double-quoted part containing exactly one ${var<op>word} reports "unbound variable", even though the operator is supposed to suppress nounset. Adjacent literal text takes a different code path and already worked, which made the failure look arbitrary:

set -u; echo "${U:-d}"    # before: bash: U: unbound variable   GNU bash: d
set -u; echo "${U:-d}b"   # db     (already correct)
set -u; echo "a${U:-d}"   # ad     (already correct)

Every operator that suppresses nounset was affected: :-, -, :=, =, :+, +.

The practical impact is that the idiomatic "works inside and outside GitHub Actions" guard could not run at all:

set -euo pipefail
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then render >> "$GITHUB_STEP_SUMMARY"; fi

Root cause

That exact AST shape (wordParts.length === 1 && DoubleQuoted && dqPart.parts.length === 1 && DefaultValue/UseAlternative/AssignDefault) is claimed by handleArrayDefaultValue() in packages/just-bash/src/interpreter/expansion/array-prefix-suffix.ts, reached from word-glob-expansion.ts:276. Its scalar branch probed the current value with getVariable(ctx, varName) at array-prefix-suffix.ts:124, and getVariable's checkNounset parameter defaults to true (expansion/variable.ts:119-124), so nounset fired before the operator ever got a chance to supply the default.

The general path already gets this right — expansion.ts:1000-1009 computes skipNounset for DefaultValue/AssignDefault/UseAlternative/ErrorIfUnset and passes !skipNounset. This change makes the scalar branch agree by passing false.

Scope of the fix

One line plus a comment. Checked and deliberately left alone:

  • The array branch (arrayMatch) of the same function reads through getArrayElements() / ctx.state.env.get(), neither of which consults nounset — verified "${arr[@]:-d}" and "${arr[@]:+d}" were already correct, and there is a regression test for them.
  • isVariableSet() does not check nounset either, so it needed no change.
  • "${#U}" and a bare "${U}" still report an unbound variable under set -u, matching GNU bash. There are explicit tests pinning that.

Behaviour changes only for the previously-throwing case: for a set variable, checkNounset has no effect on the returned value.

Verification

Every operator now matches system bash byte for byte via pnpm dev:exec --real-bash:

$ printf 'set -u\necho "[${U:-d}]"\necho "[${U-d}]"\necho "[${U:+d}]"\necho "[${U+d}]"\necho "[${A:=d1}][$A]"\necho "[${B=d2}][$B]"\n' | pnpm dev:exec --real-bash
just-bash:  "[d]\n[d]\n[]\n[]\n[d1][d1]\n[d2][d2]\n"
real bash:  "[d]\n[d]\n[]\n[]\n[d1][d1]\n[d2][d2]\n"

Tests

  • src/comparison-tests/nounset-quoted-default.comparison.test.ts (+ recorded fixtures) — all six operators as whole double-quoted words, the set/empty/unset distinction, the array default, the [ -n "${VAR:-}" ] guard end to end, and the two shapes that must still error.
  • src/interpreter/expansion/nounset-quoted-default.test.ts — the same matrix asserting full stdout and stderr plus exit status, including bash: JB_UNSET: unbound variable\n / exit 1 for "${#JB_UNSET}" and "${JB_UNSET}".

The two must-still-error comparison cases pass compareExitCode: false: bash -c reports an expansion error as 127 while just-bash reports 1. That is a pre-existing, unrelated divergence I did not touch; the unit tests pin just-bash's own status and message, and the comparison still asserts stdout parity (nothing printed, echo reached never runs).

Reverting just the changed line makes 5 tests in each new file fail, and restoring it makes them pass.

Validation

pnpm typecheck, pnpm lint (incl. banned-patterns + workflow security), and pnpm knip are clean. pnpm test:run is 15632 passed / 6 failed — the 6 failures are pre-existing and environmental (Python WASM 5s timeouts and real-DNS lookups in src/security/** and src/network/allow-list/dns-rebinding-integration.test.ts); they reproduce identically with this change reverted.

Credit

Reported downstream as ai-ecoverse/slicc#2978, filed there as "set -u expands variables in a non-taken if branch". That diagnosis was wrong — untaken branches are never evaluated — but the report is what surfaced this, and the guard in it is the real-world script that could not run.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

@claude is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@auto-maintain

auto-maintain Bot commented Sep 9, 2026

Copy link
Copy Markdown

🤖 auto-maintain review

Automated, advisory triage for @trieloff's PR. Facts below are read from the GitHub API.

Check Result
Author's merged PRs (this repo) 18
Account established ✅ (age 6481d · 135 followers · 203 public repos)
Commits signed/verified ✅ 2/2
Changeset included ✅ (.changeset/nounset-quoted-default-expansion.md)

Review panel: 🟡 medium highest severity

just-bash maintainer code review: 🟡 medium

The scalar fix works for simple variables but remains incorrect for side-effecting indexed parameters under nounset.

  • packages/just-bash/src/interpreter/expansion/array-prefix-suffix.ts:126 — The nounset-disabled read still follows `isVariableSet`, so indexed parameters are evaluated multiple times. For example, with `values=(zero value); i=0`, `"${values[i += 1]:-fallback}"` probes index 1, then index 2 here, and may fall through for a third evaluation, producing the wrong value and mutating `i` repeatedly. Resolve/probe the parameter once or leave non-array forms to the general expansion path.

General code review: 🟢 low

The nounset fix is correct and the regression coverage is comprehensive; no actionable issues found.

Adversarial security: 🟢 low

No actionable security issues found in the complete diff.

Adversarial security (second opinion): 🟢 low

The diff is a single-line fix passing checkNounset=false when probing the current value in the scalar branch of handleArrayDefaultValue, reached only for DefaultValue/UseAlternative/AssignDefault operators — all of which suppress nounset in GNU bash; ErrorIfUnset and bare/${#var} shapes are unaffected and still error. No new I/O, network, process execution, dependency, or CI changes; the remaining files are tests, recorded fixtures, and a changeset, all consistent with the stated intent. No security or correctness findings.

Standard Bash and host portability: 🟢 low

No actionable Bash compatibility or host-portability issues found.

Posted by auto-maintain. This automated code review is advisory; a human maintainer makes the call.

…ult}

Under `set -u`, a word that is exactly one double-quoted part holding
exactly one `${var<op>word}` is routed through `handleArrayDefaultValue()`,
which read the variable with nounset still armed. Every operator that is
supposed to suppress nounset was affected: `:-`, `-`, `:=`, `=`, `:+`, `+`.

Adjacent literal text ("${U:-d}b", "a${U:-d}") takes the general path,
which already computes `skipNounset`, so the failure looked arbitrary.

`"${#var}"` and a bare `"${var}"` still report an unbound variable, as
in GNU bash.

Reported downstream as ai-ecoverse/slicc#2978.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lars Trieloff <lars@trieloff.net>
@trieloff
trieloff force-pushed the bb/fix-expansion-var-word-as-a-whole-word-trips-set-thr_a3zhuvbaj8 branch from a1c75d8 to 61e4f3f Compare September 9, 2026 10:04
@trieloff

trieloff commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — the finding is real, but the attribution is wrong: it is a pre-existing defect, not something this PR introduces.

The claim is that "disabling nounset now reaches the array-default shortcut". The shortcut is reached whenever the outer variable is unset, which does not require set -u — with nounset off, getVariable() already returned "" and fell straight into the same branch. Reproducing on main, no set -u anywhere:

$ printf 'arr=(x y)\nprintf "<%%s>" "${U:-pre${arr[@]}post}"; echo\n' | pnpm dev:exec --real-bash
just-bash:  <x><y>
real bash:  <prex><ypost>

So the behaviour is identical before and after this change. What the change does is make that branch reachable under set -u as well — where the previous behaviour was not "correct output" but an abort with a spurious U: unbound variable.

The actual defect is narrower than "drops adjacent operation-word parts": it only bites when the default word mixes an array expansion with adjacent literal text. handleArrayDefaultValue() finds the first ${arr[@]} in op.word.parts and returns its elements, ignoring every other part of that word:

expression just-bash GNU bash
1 "${U:-pre${arr[@]}post}" <x><y> <prex><ypost>
2 "${U:+pre${arr[@]}post}" <> <>
3 "${arr[@]:-pre${b[@]}post}" <x><y> <x><y>
4 "${U:-pre${arr[@]}}" <x><y> <prex><y>
5 "${U:-${arr[@]}post}" <x><y> <x><ypost>
6 "${U:-${arr[@]}}" <x><y> <x><y>

(arr=(x y), b=(p q), all with set +u.)

The correct semantics are the prefix/suffix gluing that handleArrayPatternWithPrefixSuffix() already implements — prefix onto the first element, suffix onto the last. Fixing it means threading deps.expandPart into handleArrayDefaultValue() (it is available at the word-glob-expansion.ts:276 call site but not currently passed) and expanding the parts either side of the array expansion.

I have deliberately left that out of this PR, which is a one-line nounset fix: it is an orthogonal pre-existing bug, the correct fix is not a one-liner, and it deserves its own comparison-test coverage. Filed separately as #419.

The comparison-tests CI job re-records fixtures on Linux and fails on any
diff. The two "must still error" fixtures were recorded against macOS bash
3.2, whose unbound-variable diagnostic omits the "line 1: " prefix that
bash 5 emits, so re-recording drifted them.

Adjust both to the Linux form and mark them locked, as CLAUDE.md
prescribes for fixtures adjusted to Linux behaviour. Record mode is now
idempotent. The recorded stderr is never compared -- compareOutputs only
checks stdout and (here, disabled) exit code -- so this only stops drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@trieloff
trieloff marked this pull request as ready for review September 9, 2026 10:57
@trieloff
trieloff requested a review from cramforce as a code owner September 9, 2026 10:57
Copilot AI lite review requested due to automatic review settings September 9, 2026 10:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants