SIGN IN SIGN UP
oven-sh / bun UNCLAIMED

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one

0 0 150 Rust

css: cap `&` parent-selector expansion when compiling nesting for older targets (#31276)

### What does this PR do?

Fixes a hang with unbounded memory growth in the CSS printer, found by
CSS fuzzing (signature
`hang:css:…alloc::raw_vec::RawVecInner::finish_grow…` — the printer
keeps reallocating an ever-growing output buffer; the follow-up OOM
reports are the same mechanism hitting the allocator limit).

The fuzzer's ~2.8 KB input is ~21 nesting levels of

```css
&:is(.bar, &.baz) { color: red; }
&:is(.bar, &.baz) { colo   /* unclosed block -> everything below nests one level deeper */
```

The published repro calls `minifyTest(input, "")` with **no targets**
and does not hang (nesting is preserved, 1.5 KB output). The same input
hangs as soon as CSS nesting is compiled away, which is what the real
entry points do:

```sh
# default --target=browser lowers nesting (safari14/chrome87/edge88/firefox78 defaults)
bun build explode.css --outdir out      # never returns, memory grows until OOM
```

or `minifyTest(input, "", { safari: 13 << 16 })`. Upstream lightningcss
1.32.0 hangs on the same input with the same targets, so there is no
upstream fix to port.

### Cause

When targets lack nesting support, `serialize_nesting` replaces each `&`
with the parent selector (`StyleContext` chain). The parent's selector
may itself contain `&` referring to the grandparent, so a selector with
k `&` references per level expands to ~k^depth copies of its ancestors.
With this input the printed output grows ~4× per nesting level: depth 6
→ 1 MB, depth 8 → 17 MB, depth 10 → 274 MB, depth 21 → effectively
unbounded. The work and the output are inherently exponential — the
process isn't stuck, it's printing a stylesheet that would be terabytes.

### Fix

`Printer` now tracks the number of parent-selector substitutions
performed for the current rule prelude (reset in
`StyleRule::to_css_base`, counted in `serialize_nesting`). Past 65,536
substitutions for a single prelude the printer reports a new
`PrinterErrorKind::maximum_nesting_expansion` ("Maximum nesting
expansion exceeded when compiling CSS nesting for the configured
targets") instead of allocating without bound.

Real-world nesting needs at most a handful of substitutions per rule
(depth × `&`-per-level), so the budget is far beyond anything
legitimate; only runaway expansions hit it. Behavior is unchanged for:

* stylesheets where nesting is preserved (no targets / modern targets) —
the fuzz input still minifies to the same 1.5 KB,
* ordinary nested CSS compiled for older targets (e.g. 8 levels of
`&:hover` still expands fully),
* everything in the existing CSS suites (see below).

With the budget, `bun build` on the hostile input terminates in
milliseconds-to-seconds instead of hanging. Note the bundler currently
maps *any* CSS printer error to an empty chunk rather than a build
diagnostic (pre-existing, same as the Zig implementation); surfacing
printer errors as build errors is a separate follow-up.

Related: #31270 fixes a different exponential blowup from the same
fuzzing campaign (duplicate re-serialization across vendor-prefix
passes). The two mechanisms are independent — that fix does not bound
this input (no prefixed selectors here), and this budget resets per
prelude so it does not bound that one — the fixes are complementary and
touch adjacent code.

### Verification

* New `test/js/bun/css/nested-selector-expansion.test.ts`:
* deeply nested `&:is(.bar, &.baz)` with targets now errors with
"Maximum nesting expansion exceeded" instead of hanging (spawned with a
20 s kill switch so a regression fails instead of hanging the runner),
  * the same input with nesting preserved (no targets) still minifies,
  * ordinary 8-level nesting still compiles fully for older targets,
  * `bun build` with the default browser target terminates on its own.
* Without the fix, the first and last test fail (child killed by the
kill switch); with the fix all four pass.
* Existing suites with the fix (debug build):
`test/js/bun/css/css.test.ts` (1049 pass), `test/js/bun/css/`
nested-function-backtracking / small-list-grow / doesnt_crash /
css-modules (all pass), `test/bundler/css/` (166 pass). The only failure
seen locally is the pre-existing `fuzz ansi256` 16.7M-iteration timeout
on debug ASAN builds (color integer math, unrelated to this change).
* `cargo clippy -p bun_css` is clean.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
R
robobun committed
02380d3e14b813174f73536a5e90fbb6169a924e
Parent: d2f274d
Committed by GitHub <noreply@github.com> on 5/24/2026, 4:36:08 AM