css: bound selector-list expansion when compiling nesting for older targets (#31277)
### What does this PR do?
Fixes an OOM found by CSS fuzzing (signature
`oom:css:__rust_alloc|alloc::vec::spec_from_iter_nested…`): a 924-byte
stylesheet of 23 unclosed nested rules, each with a two-selector list,
```css
co :is(.bar), .bar :is(.baz) {
co :is(.bar), .bar :is(.baz) {
… ×23 …
color: red;
}
```
makes the CSS minifier allocate 4+ GB. The published repro calls
`minifyTest(input, "")` with **no targets** and stays linear (534 bytes
of output) — the blowup needs browser targets that force `:is()`/nesting
to be compiled away, which is what the real entry points use:
```sh
# default --target=browser targets are edge88/firefox78/chrome87/safari14 (no :is(), no native nesting)
bun build input.css --outdir out --minify # 18 levels → 24 MB output; 23 levels → multi-GB RSS
```
or `minifyTest(input, "", { chrome: 80 << 16 })`. The `::part()` OOM
from the same fuzzing campaign is the identical path (`::part()` can
never be wrapped in `:is()`).
### Cause
Two multiplicative behaviors combine in `minify_style_arm`
(`src/css/rules/mod.rs`) / `StyleRule::minify` when nesting has to be
compiled for the targets:
1. Selectors the targets don't support can't stay in the same rule (one
unsupported selector would make browsers drop the whole list), so each
incompatible selector is split into its own rule — **including a
`deep_clone` of the entire, already-expanded nested-rule subtree**
(`rules: sty.rules.deep_clone(...)`). That clone's `Vec::from_iter` is
the allocation stack in the fuzz signature.
2. Every nesting level's selector list multiplies into the level below.
With two incompatible selectors at each of 23 levels the minified tree
holds ~2²³ style-rule structures before anything is printed → multi-GB
RSS from a sub-KB input. Upstream lightningcss behaves the same way
(inherited, like the backtracking issue in #31243), so there is no
upstream fix to port.
### Fix
`MinifyContext` now tracks the expansion:
* `selector_expansion_multiplier` — product of the enclosing style
rules' selector-list lengths, bumped only when the targets force nesting
to be compiled (or the rule's selectors to be split for compatibility),
* `selector_expansion_total` — running total of selectors that expansion
will produce.
Past `MAX_SELECTOR_EXPANSION` (65,536) minify stops with a new
`MinifyErrorKind::selector_expansion_limit_exceeded` error instead of
materializing the explosion:
```
error: Nested CSS rules expand to more than 65536 selectors when compiled for the configured browser targets. Reduce the nesting depth or the number of selectors per rule, or target browsers that support CSS nesting.
```
The check runs before descending into nested rules, so nothing
exponential is allocated on the error path. Real-world stylesheets don't
get anywhere near the limit (65,536 expanded selectors is already
megabytes of output); stylesheets below it, stylesheets minified without
browser targets, and targets that support native nesting are unaffected.
Two pieces of plumbing so the error actually reaches users:
* `StyleSheet::minify` previously hit `panic!("TODO: Handle")` when rule
minification failed (the path was unreachable until now). It now returns
the recorded `MinifyError` with its source location; the bundler
(`ParseTask`) reports it as a build error and `cssInternals.minifyTest`
throws it.
* `css_jsc/error_jsc.rs::to_error_instance` deref'd the message string
after calling `bun_string_jsc::to_error_instance`, which already
consumes the caller's reference. The over-deref freed the
`WTFStringImpl` while the JS error still referenced it and crashed debug
builds (libpas "Alloc bit not set" / ASAN) the first time a CSS minify
error was actually thrown.
### Relationship to other open fuzz fixes
Same fuzzing campaign, different mechanisms, complementary fixes:
* #31276 caps the **printer-side** `&` parent-selector substitution
fan-out (a single selector with multiple `&` references per level — no
selector-list splitting involved). It doesn't bound this report, because
here the OOM happens in minify's `deep_clone` before printing starts;
and this PR doesn't bound that one, because a single selector per level
keeps the multiplier at 1.
* #31270 removes duplicate re-serialization across vendor-prefix passes.
Overlap is only textual (adjacent code in `style.rs`/`error.rs`; the
test file here is named `nested-selector-list-expansion.test.ts` to
avoid colliding with #31276's).
### Verification
New `test/js/bun/css/nested-selector-list-expansion.test.ts`:
* 17 levels of `co :is(.bar), .bar :is(.baz)` with `{ chrome: 80 << 16
}` now throws the limit error (previously ~2¹⁸ cloned rules),
* same for the `::part()` shape,
* `bun build --minify` with default targets reports the error and exits
1 (previously 12+ MB of output at this depth, OOM at the fuzzer's
depth),
* below-limit nesting still compiles for old targets, the original
fuzzer repro with no targets still minifies to the same small output,
and deep nesting is preserved untouched for targets that support CSS
nesting.
Without the fix 3/6 tests fail; with it 6/6 pass.
Existing suites on the fixed debug (ASAN) build: `css.test.ts` +
`nested-function-backtracking.test.ts` (1054 pass / 0 fail),
`doesnt_crash.test.ts` (60 pass), `color.test.ts` +
`small-list-grow.test.ts` (917 pass; the only failure is the
pre-existing `fuzz ansi256` debug-ASAN timeout also noted in #31243),
`test/bundler/css/` (166 pass). R
robobun committed
2fbfcb9833cab78d336450e801ae851ed811905c
Parent: 3fa9635
Committed by GitHub <noreply@github.com>
on 5/24/2026, 8:55:10 PM