Skip to content

shell: widen the brace expansion output slot counter - #37921

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/f2966ac6/braces-out-key-overflow
Aug 18, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/f2966ac6/braces-out-key-overflow

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A shell word (or $.braces() input) that expands to exactly 65536 variants, e.g. {a,b} repeated 16 times, aborts debug/assert builds with panic: attempt to add with overflow at bun_shell_parser::braces::expand_flat (src/shell_parser/braces.rs:754, *out_key_counter += 1). expand_nested has the same arithmetic.
  • out_key_counter (a u16, src/shell_parser/braces.rs:543) is the index of the next unclaimed output slot. It starts at 1 and is bumped once per slot claimed, so after the last of N slots it holds N. The callers' cap (MAX_BRACE_EXPANSIONS = 65536 in src/runtime/shell/states/Expansion.rs and src/runtime/api/BunObject.rs) admits N = 65536, which is one more than a u16 holds.
  • Release builds wrap on that final bump and nothing reads the counter afterwards, so their output is correct; 65537 and above are already rejected by the cap. Only the N = 65536 case is affected, and only on builds with overflow checks.

Fix

  • out_key and out_key_counter in expand, expand_flat and expand_nested are now usize, the type they are used as (they index out). The counter's final value is out.len(), which always fits. The 65536 cap and release output are unchanged.
  • Tests in test/js/bun/shell/brace.test.ts: one word with 16 flat groups run through the shell (expand_flat, the reported repro) and one with 8 nested 4-way groups through $.braces() (expand_nested). Each runs in a subprocess and checks the count plus the first and last variant; the last variant is written into the slot whose claim used to overflow.
  • Verified: both tests fail on a debug build of main without the src change (child aborts with the panic above) and pass with it; test/js/bun/shell/brace.test.ts and bunshell.test.ts pass on the debug build; cargo test -p bun_shell_parser passes.

Background

  • Brace expansion preallocates out, one Vec<u8> per result, sized by calculate_expanded_amount (a u32, capped by the callers). The expander then walks the word: the first variant of a group keeps writing into the current slot, and every other variant claims a fresh slot from the counter and copies the prefix built so far into it before continuing. Every slot after the first is claimed exactly once, which is why the counter ends at out.len() rather than at the largest slot index.

[review] gate passed · iteration 6 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/shell/brace.test.ts
bun test v1.4.0 (8326d1bd3)

test/js/bun/shell/brace.test.ts:
(pass) $.braces > no-op [2.51ms]
(pass) $.braces > 2 [2.07ms]
(pass) $.braces > 3 [1.88ms]
(pass) $.braces > nested [1.63ms]
(pass) $.braces > nested 2 [1.99ms]
(pass) $.braces > nested sibling product [2.00ms]
(pass) $.braces > nested sibling product with surrounding text [1.39ms]
(pass) $.braces > nested sibling product mixed with variants [3.13ms]
(pass) $.braces > nested sibling product triple [2.15ms]
(pass) $.braces > nested with empty variant > {x,a{,}b} [1.48ms]
(pass) $.braces > nested with empty variant > {x,{a,}}z [0.63ms]
(pass) $.braces > nested with empty variant > {x,{,a}}z [0.46ms]
(pass) $.braces > nested with empty variant > {x,{,}}z [0.47ms]
(pass) $.braces > nested with empty variant > a{b,c{d,}}e [0.44ms]
(pass) $.braces > nested with empty variant > a{b,c{,d}}e [0.48ms]
(pass) $.braces > nested with empty variant > {x,{a,,b}} [0.79ms]
(pass) $.braces > nested with empty variant > {x,{a,b,}} [0.44ms]
(pass) $.braces > nest
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (8326d1bd3)

test/js/bun/shell/brace.test.ts:
(pass) $.braces > no-op [0.05ms]
(pass) $.braces > 2 [0.02ms]
(pass) $.braces > 3 [0.01ms]
(pass) $.braces > nested [0.05ms]
(pass) $.braces > nested 2 [0.02ms]
(pass) $.braces > nested sibling product [0.02ms]
(pass) $.braces > nested sibling product with surrounding text [0.01ms]
(pass) $.braces > nested sibling product mixed with variants [0.04ms]
(pass) $.braces > nested sibling product triple [0.03ms]
(pass) $.braces > nested with empty variant > {x,a{,}b} [0.02ms]
(pass) $.braces > nested with empty variant > {x,{a,}}z [0.01ms]
(pass) $.braces > nested with empty variant > {x,{,a}}z
(pass) $.braces > nested with empty variant > {x,{,}}z
(pass) $.braces > nested with empty variant > a{b,c{d,}}e
(pass) $.braces > nested with empty variant > a{b,c{,d}}e
(pass) $.braces > nested with empty variant > {x,{a,,b}}
(pass) $.braces > nested with empty variant > {x,{a,b,}}
(pass) $.braces > nested with empty variant > {{a,},x}
(pass) $.braces > nested with empty variant > p{q,{r,}{s,}}t
(pass) $.braces > very deeply nested [0.07ms]
(pass) $.braces > literal outer group around hundreds of nested groups
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/shell/brace.test.ts
bun test v1.4.0 (8326d1bd3)

test/js/bun/shell/brace.test.ts:
(pass) $.braces > no-op [1.90ms]
(pass) $.braces > 2 [1.97ms]
(pass) $.braces > 3 [1.90ms]
(pass) $.braces > nested [1.60ms]
(pass) $.braces > nested 2 [2.01ms]
(pass) $.braces > nested sibling product [1.57ms]
(pass) $.braces > nested sibling product with surrounding text [1.77ms]
(pass) $.braces > nested sibling product mixed with variants [3.16ms]
(pass) $.braces > nested sibling product triple [1.81ms]
(pass) $.braces > nested with empty variant > {x,a{,}b} [1.76ms]
(pass) $.braces > nested with empty variant > {x,{a,}}z [0.61ms]
(pass) $.braces > nested with empty variant > {x,{,a}}z [0.46ms]
(pass) $.braces > nested with empty variant > {x,{,}}z [0.48ms]
(pass) $.braces > nested with empty variant > a{b,c{d,}}e [0.45ms]
(pass) $.braces > nested with empty variant > a{b,c{,d}}e [0.46ms]
(pass) $.braces > nested with empty variant > {x,{a,,b}} [0.45ms]
(pass) $.braces > nested with empty variant > {x,{a,b,}} [0.78ms]
(pass) $.braces > nest
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 631ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/740] cc obj/vendor/tinycc/tccdbg.c.o
[2/740] cc obj/vendor/tinycc/tccelf.c.o
[3/740] cc obj/vendor/tinycc/x86_64-link.c.o
[4/740] cc obj/vendor/tinycc/x86_64-gen.c.o
[5/740] cc obj/vendor/tinycc/tccpp.c.o
[6/740] cc obj/vendor/tinycc/tccrun.c.o
[7/740] cc obj/vendor/tinycc/i386-asm.c.o
[8/740] cc obj/vendor/tinycc/tccgen.c.o
[9/740] cc obj/vendor/tinycc/tccasm.c.o
[10/740] fetch lshpack
[lshpack] up to date
[11/738] fetch lsquic
[lsquic] up to date
[12/669] fetch cares
[cares] up to date
[13/578] fetch boringssl
[boringssl] up to date
[14/156] fetch WebKit (prebuilt)
[WebKit] up to date
[15/156] fetch lolhtml
[lolhtml] up to date
[16/156] cc obj/codegen/InternalModuleRegistryConstants.S.o
[17/156] cc obj/packages/bun-usockets/src/fault_inject.c.o
[18/156] cc obj/packages/bun-usockets/src/context.c.o
[19/156] cc obj/packages/bun-usockets/src/bsd.c.o
[20/156] cc obj/packages/bun-usockets/src/loop.c.o
[21/156] cc obj/packages/bun-usockets/src/quic.c.o
[22/156] cc obj/packages/bun-usockets/src/udp.c.o
[23/1
... (truncated)
diff hotspot
src/shell_parser/braces.rs      | 34 +++++++++++++++---------------
 test/js/bun/shell/brace.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 63 insertions(+), 17 deletions(-)

gate history · 2 passed · 0 rejected · iteration 6

evidence per changed file
file                             reads  edits  tests
src/shell_parser/braces.rs           6     10      0
test/js/bun/shell/brace.test.ts      3      5      0

root cause · written by the author bot

The brace expansion output slot counter in braces.rs was a u16 that starts at 1 and is incremented once per output slot, so it reaches N after the last slot, while the callers' cap admits exactly 65,536 expansions, one more than u16::MAX, causing the final increment to overflow (a panic in overflow-checked builds, a harmless wrap in release since nothing reads it afterwards). The fix widens the out_key and out_key_counter values in expand, expand_flat, and expand_nested to usize, which is the natural type since they are only used to index the output slice, leaving the expansion limit and ou…

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on a debug build of main ({a,b} x16 through the shell and {{a,b},{c,d}} x8 through $.braces() both abort with panic: attempt to add with overflow in braces.rs). Both new tests in test/js/bun/shell/brace.test.ts fail without the src/ change and pass with it.

Latest push (4aac840) only removes a comment flagged by review; the code change is still the u16 to usize widening. Branch is rebased on current main. Ready for review.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 769a9128-b648-4699-97b6-905a77d80e5b

📥 Commits

Reviewing files that changed from the base of the PR and between ea7a685 and 4aac840.

📒 Files selected for processing (1)
  • src/shell_parser/braces.rs
💤 Files with no reviewable changes (1)
  • src/shell_parser/braces.rs

Included review availability: Your plan includes up to 5 reviews per rolling hour; 2 remain after this review.


Walkthrough

Changes

Brace expansion output keys and counters now use usize in flat and nested paths. Regression tests cover 65,536 shell and $.braces() expansion variants.

Brace expansion

Layer / File(s) Summary
Widen expansion output keys
src/shell_parser/braces.rs
Flat and nested expansion paths use usize counters, direct output indexing, and direct prefix copying.
Validate maximum expansion count
test/js/bun/shell/brace.test.ts
Subprocess tests verify 65,536 outputs, boundary values, clean stderr, and successful exit status for flat and nested expansion.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: widening the brace expansion output slot counter.
Description check ✅ Passed The description explains the problem, fix, scope, regression tests, and verification results in sufficient detail.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:29 PM PT - Aug 16th, 2026

✅ @robobun, your commit 4aac840dd2fa34b7e96dc7d83be82428ab5a87f6 passed in Build #99691! 🎉


🧪   To try this PR locally:

bunx bun-pr 37921

That installs a local version of the PR into your bun-37921 executable, so you can run:

bun-37921 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — mechanical u16 → usize widening for the brace-expansion output slot counter.

What was reviewed:

  • Confirmed all out_key/out_key_counter sites in expand, expand_flat, and both arms of expand_nested are updated consistently; the removed usize::from() wrappers were the only conversions.
  • Checked that out is caller-preallocated from calculate_expanded_amount (a u32 capped at 65536), so usize is the natural index type and the counter's final value out.len() always fits.
  • Tests cover both code paths (flat via shell, nested via $.braces()) at exactly N=65536, run in subprocesses so the pre-fix abort is observable, and assert the last slot's content.
Extended reasoning...

Overview

This PR widens out_key and out_key_counter from u16 to usize in the shell brace-expansion expander (src/shell_parser/braces.rs: expand, expand_flat, expand_nested). The counter starts at 1 and is bumped once per output slot claimed, so after N slots it holds N. Callers cap N at MAX_BRACE_EXPANSIONS = 65536, which is exactly one more than a u16 holds — so a word with exactly 65536 variants panicked on the final += 1 in overflow-checked builds. The Rust change is a pure type widening plus removal of the now-redundant usize::from(out_key) at each indexing site. Two new subprocess tests in test/js/bun/shell/brace.test.ts exercise the 2^16 flat case and the 4^8 nested case.

Security risks

None. This is an internal counter type change on a debug-only panic path; release builds already wrapped harmlessly (nothing reads the counter after the last bump). No user-controlled data is interpreted differently, and the 65536 expansion cap enforced by callers is unchanged.

Level of scrutiny

Low-to-medium. The diff is a mechanical type substitution with no control-flow change. I traced every touched line: each was either usize::from(out_key) → out_key (identity now that out_key: usize) or the type annotation on the parameter/local. The counter is only ever compared implicitly via split_at_mut bounds and read as an index into out: &mut [Vec<u8>], so usize is the correct type. Both recursive functions and both match arms of expand_nested are updated — no sibling site was missed.

Other factors

The tests follow repo conventions well: subprocess isolation (the pre-fix behavior aborts), Buffer.alloc(n, fill) instead of .repeat(), test.concurrent for independent spawns, concurrent drain of stdout/stderr/exited, and assertions on the count plus first/last variant (the last variant lives in the slot whose claim previously overflowed, so it's load-bearing). The 16-group and 8×3-group inputs stay well under MAX_BRACE_GROUPS = 256. The PR description states both tests fail on unpatched debug builds and pass with the fix, and that the existing brace/bunshell suites and cargo test -p bun_shell_parser pass. No prior review comments on the timeline.

The counter that hands out output slots is bumped once per slot, so after
the last of N slots it holds N. The expansion cap admits N = 65536, which
does not fit the u16 it was stored in, so a word expanding to exactly
65536 variants hit the overflow check in debug builds (release builds
wrapped harmlessly because nothing reads the counter after the last
bump). Store the slot index and the counter as usize.
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/f2966ac6/braces-out-key-overflow branch from 04ae697 to ea7a685 Compare August 17, 2026 01:06
Comment thread src/shell_parser/braces.rs Outdated
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — mechanical u16 → usize widening on the output-slot counter, which was already used only as a slice index.

What was reviewed:

  • Confirmed both callers (Expansion.rs, BunObject.rs) cap at count > 65536, so N = 65536 is admitted and the counter's terminal value of N fits in usize = out.len().
  • Both sibling paths (expand_flat, expand_nested) get the same widening; expand's public signature is unchanged.
  • New tests cover both paths at the exact boundary in subprocesses, drain pipes concurrently, and assert on the combined {stdout, stderr, exitCode} object; the comment-cop feedback was addressed in 4aac840.
Extended reasoning...

Overview

This PR widens out_key and out_key_counter from u16 to usize in src/shell_parser/braces.rs (expand, expand_flat, expand_nested). The counter starts at 1 and is bumped once per output slot claimed, so after the last of N slots it holds N. Both callers cap expansion at count > 65536, admitting N = 65536, which is one past u16::MAX and panics on the final bump in overflow-checked builds. Release builds wrap harmlessly (nothing reads the counter after the last bump). The values are only ever used to index out: &mut [Vec<u8>], so usize is the natural type; all usize::from(...) conversions become direct uses. Two subprocess regression tests are added covering the flat and nested expanders at the exact 65536 boundary.

Security risks

None. This is a pure integer-type widening on an internal counter; input caps (MAX_BRACE_EXPANSIONS, MAX_BRACE_GROUPS) are unchanged and still enforced before allocation.

Level of scrutiny

Low. The Rust diff is a mechanical type change with no control-flow or logic changes — every touched line either changes a parameter/local type or drops a now-redundant usize::from(). The public expand signature is unchanged. The fix is applied to all three sites that share the counter (whole bug class). The PR description demonstrates the mechanism precisely and the evidence block shows the tests fail on main (debug) and pass with the fix on both debug and release.

Other factors

The tests follow repo conventions: subprocess isolation for a process-aborting panic, Buffer.alloc(n, fill).toString() instead of .repeat(), test.concurrent for independent spawns, concurrent pipe draining via Promise.all, and a single toEqual on {stdout, stderr, exitCode}. The one prior review comment (comment-cop on a long explanatory comment) was resolved in 4aac840. No outstanding reviewer feedback remains.

@Jarred-Sumner
Jarred-Sumner merged commit 20d0467 into main Aug 18, 2026
11 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/f2966ac6/braces-out-key-overflow branch August 18, 2026 03:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants