Skip to content

css: budget selector expansion and nesting substitution by bytes - #31916

Open
robobun wants to merge 3 commits into
mainfrom
farm/ba9436ec/css-expansion-byte-budgets
Open

css: budget selector expansion and nesting substitution by bytes#31916
robobun wants to merge 3 commits into
mainfrom
farm/ba9436ec/css-expansion-byte-budgets

Conversation

@robobun

@robobun robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a CSS minifier OOM family found by fuzzing (signature oom:css:_RNvXs1_NtNtC8bun_core4util2ioINtNtC5alloc3vec3VechENtB5_5Write9write_allC11bun_css_jsc|_RNvMNtC7bun_css10css_pa…, i.e. <Vec<u8> as Write>::write_all growing the output buffer under a css_parser frame, on 1.4.0-canary.1+d2a6506df).

The minimized 45-byte input from the report (a:lang(en, fr) { color: 0red; } with a chrome 80 target) does not reproduce on its own: its downlevel output is 83 bytes and a fixed point under re-minification, verified at the exact reported revision under ASAN. Like the sibling report fixed in #31277, the published repro lost the context that makes the family blow up. The real trigger is the same :lang()/::part() downlevel path inside nested rules with fat selectors.

Root cause

The selector-expansion limits added for earlier fuzz reports bound expansion by count, not bytes:

Each counted unit's serialized size is input-controlled: long identifiers, multi-argument :lang() (which downlevels to :is(:lang(a), :lang(b)) / :-webkit-any(...) for old targets), ::part(), raw custom pseudo-class arguments. Count x size stays under every cap while the output explodes. On current main (all existing caps intact, release build):

// minify side: 30 KB in -> 265,879,552 bytes out, 581 MB RSS, no error
// (1.2 GB peak RSS on a debug/ASAN build)
const c = require("bun:internal-for-testing").cssInternals;
const id = "x".repeat(1000);
let css = "";
for (let i = 0; i < 15; i++) css += `.${id}a${i}::part(p), .${id}b${i}::part(p) {\n`;
css += "color: red;\n}";
c.minifyTest(css, "", { chrome: 80 << 16 });
// print side: 13 KB in -> 107,674,291 bytes out, no error
const idy = "y".repeat(800);
let css2 = "";
for (let i = 0; i < 16; i++) css2 += `&:is(.${idy}${i}, &.z${i}) {\n`;
css2 += "color: red;\n" + "}\n".repeat(16);
c.minifyTest(css2, "", { chrome: 80 << 16 });

The same shapes reach the same paths through bun build --minify (default browser targets compile nesting away). In a fuzzing session with a memory cap, allocation fails while the printer grows its output Vec, which is exactly the reported crash stack.

Fix

Budget both expansions by estimated serialized bytes, alongside the existing count caps (checked second, so ordinary-sized selectors keep reporting the existing count errors):

  • Minify side: charge_selector_expansion now also charges multiplier x (own list weight + ancestor chain weight) against MAX_SELECTOR_EXPANSION_BYTES (64 MB, matching MAX_PREFIX_EXPANSION_BYTES from css: bound vendor-prefix fan-out when serializing nested rules #31642). The chain weight is tracked next to the multiplier in MinifyContext. Selector weights are estimated by selector_list_weight (identifier/payload byte lengths, recursing into :is()/:not()/:nth-child(of)/custom pseudo-class token lists). Reported as a new selector_expansion_bytes_limit_exceeded minify error.
  • Print side: serialize_nesting meters the byte span each outermost & substitution writes into a stylesheet-wide Printer.nesting_expansion_bytes, bounded by MAX_NESTING_EXPANSION_BYTES (64 MB) with the existing maximum_nesting_expansion printer error.

The weight walk only runs for rules inside compiled nesting (selector_expansion_multiplier > 1 or a bumping level), so flat stylesheets and modern targets pay nothing.

Relationship to #31913 (same fuzzing campaign, different signature hang:css:…PropertyIdTag…): that PR bounds the payload (declarations, nested subtrees, token lists) deep-cloned when splitting target-incompatible selector lists. It does not bound the printer's & substitution path (the second repro above stays exponential under it: no splits happen, the multiplier stays 1, and no clones are made), and its declaration-payload shapes stay under this PR's selector-weight budgets, so the two are complementary. Textual conflicts in error.rs/rules/mod.rs/css_parser.rs are expected with whichever lands second.

Verification

  • New tests in test/js/bun/css/selector-expansion-bytes.test.ts. On an unfixed build the three blowup tests fail (the 265 MB / 120 MB / 107 MB outputs are returned instead of errors); on this branch all 7 pass.
  • The reported 45-byte fuzz input's downlevel output is asserted byte-for-byte for minifyTest, _test, and prefixTest with the chrome 80 target.
  • Below the budgets output is unchanged: a 2^13-selector expansion still compiles (asserted), valid multi-arg :lang() and :dir() downlevel outputs are byte-identical, and the thin-selector deep-nesting case still reports the existing 65,536-selector count error.
  • test/js/bun/css/ (2135 pass; the 5 css-fuzz.test.ts/fuzz ansi256 debug-build timeouts are pre-existing, reproduced on an unfixed debug build) and test/bundler/css/ (167 pass).

The selector-expansion limits bound how many selectors compiling nesting
away produces (MAX_SELECTOR_EXPANSION) and how many & substitutions the
printer performs (MAX_NESTING_EXPANSIONS), but both count units, not
bytes. Per-selector size is input-controlled (long identifiers,
multi-argument :lang(), ::part(), raw custom pseudo-class arguments), so
a ~30 KB stylesheet could stay under both count limits while expanding
to hundreds of MB of cloned rules and output.

Charge the minify-side expansion by an estimated serialized byte weight
(MAX_SELECTOR_EXPANSION_BYTES, 64 MB) in addition to the count, and
meter the bytes emitted by & parent-selector substitutions against a
stylesheet-wide budget (MAX_NESTING_EXPANSION_BYTES, 64 MB) at print
time. Past either budget, a catchable error is reported instead of
materializing the blowup. Stylesheets with ordinary-sized selectors keep
hitting the existing count limits first.
@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Worried about impact? Review this PR in Change Stack to explore blast radius before you approve or request changes.

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 58 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9f90b473-8c7f-45d0-85b3-9ca0d519e55f

📥 Commits

Reviewing files that changed from the base of the PR and between bea4922 and 94751a7.

📒 Files selected for processing (3)
  • src/css/rules/mod.rs
  • src/css/rules/style.rs
  • src/css/selectors/selector.rs

Walkthrough

This PR extends the CSS minifier to enforce byte-based budgets on selector expansion alongside existing selector-count limits. Two independent layers meter output: nesting serialization meters & parent-selector substitution bytes, and nested-rule charging meters selector "own bytes" plus ancestor-chain bytes. New selector-weight utilities estimate serialized costs, and a comprehensive test suite validates budget enforcement across large expansions.

Changes

CSS Selector Expansion Byte-Based Budgeting

Layer / File(s) Summary
Byte-based selector expansion contracts and initialization
src/css/rules/mod.rs, src/css/error.rs, src/css/css_parser.rs
Introduces MAX_SELECTOR_EXPANSION_BYTES constant, a new selector_expansion_bytes_limit_exceeded error variant, and extends MinifyContext with byte-accounting fields (selector_expansion_chain_bytes, selector_expansion_bytes_total) initialized during stylesheet minification.
Selector weight estimation utilities
src/css/selectors/selector.rs
Adds selector_list_weight and selector_weight functions to compute approximate serialized-byte costs for selectors and their components, accounting for nesting features (:is(), :not(), :lang() lists) using saturating arithmetic.
Nesting serialization byte budgeting for parent-selector expansion
src/css/printer.rs, src/css/selectors/selector.rs
Printer gains nesting_expansion_bytes and nesting_expansion_meter_depth fields to track bytes during & parent-selector substitution. serialize_nesting meters output for outermost substitutions and errors when the byte budget is exceeded via the existing maximum_nesting_expansion error path.
Nested rule selector expansion byte charging
src/css/rules/style.rs
charge_selector_expansion computes selector "own bytes" plus "ancestor chain bytes" contribution, scales by the expansion multiplier, and errors with selector_expansion_bytes_limit_exceeded when the byte budget is exceeded. minify_nested_rules saves/restores the chain bytes and updates them when nesting requires selector compilation.
Test suite for byte-budget enforcement
test/js/bun/css/selector-expansion-bytes.test.ts
Validates byte-budget enforcement for large ::part() and multi-argument :lang() nested expansions, deep & parent-selector substitutions, mid-size successful compilations, selector-count limits on thin deep nesting, and unaffected ordinary nesting for older targets.

Possibly related PRs

  • oven-sh/bun#31276: Adds the original count-based nesting_expansions cap and uses the same serialize_nesting/Printer expansion-metering code paths.
  • oven-sh/bun#31482: Earlier refactor of StyleRule::charge_selector_expansion and minify_nested_rules that this PR's new byte-budget logic directly builds upon.
  • oven-sh/bun#31277: Changes nested-rule selector-expansion guarding by adding MinifyContext counters and new MinifyErrorKind variants, using a selector-count limit instead of estimated bytes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: adding byte-based budgeting for CSS selector expansion and nesting substitution.
Description check ✅ Passed The description provides comprehensive coverage of both required sections with detailed root cause analysis, fix explanation, and verification methods.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions github-actions Bot added the claude label Jun 6, 2026
@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Jun 5th, 2026

@robobun, your commit 94751a7 has 2 failures in Build #60959 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31916

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

bun-31916 --bun

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/css/selectors/selector.rs`:
- Around line 242-404: The estimator must stop truncating lengths to u32 and
must charge numeric token payloads: change all weight-related types and local
vars in selector_list_weight, selector_weight, component_weight,
ident_or_ref_weight, pseudo_class_weight, pseudo_element_weight,
token_list_weight and token_weight to use u64 (including BASE/NAME constants),
replace all len() casts to u64 and use saturating_add on u64, and in
token_weight include the numeric portion for Token::Dimension (charge dim.value
serialized size or its decimal string length) and similarly charge numeric-like
tokens (e.g., Number/Percentage/Dimension) instead of flat-rates so numeric
payloads are counted; ensure var/env fallbacks and function/name lengths are
also u64-consistent and return the new u64 weight type throughout the call
chain.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: da6a57c5-8ce3-4fa9-be6f-57e2e2e4394d

📥 Commits

Reviewing files that changed from the base of the PR and between a7839df and bea4922.

📒 Files selected for processing (7)
  • src/css/css_parser.rs
  • src/css/error.rs
  • src/css/printer.rs
  • src/css/rules/mod.rs
  • src/css/rules/style.rs
  • src/css/selectors/selector.rs
  • test/js/bun/css/selector-expansion-bytes.test.ts

Comment thread src/css/selectors/selector.rs
Selector weight estimation truncated byte lengths to u32, so payloads
past 4 GB could wrap to a small weight instead of charging the budget.
Keep the whole weight chain in u64 (usize to u64 is lossless on every
supported target). Charge numeric tokens with a constant bound derived
from dtoa's shortest round-trip form instead of the generic flat rate.
@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff-relevant lanes are green. The new regression tests in test/js/bun/css/selector-expansion-bytes.test.ts passed on every platform, as did the existing css and bundler css suites.

The remaining failures in build 60959 are unrelated to this change:

Ready for review; nothing here depends on those lanes.

@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.

No bugs found here, but this adds a fair amount of new estimation logic (the selector/token weight walk and the chain-bytes accounting heuristic) on top of the existing expansion caps, so it's worth a human pass before merging — especially given the expected conflicts with #31913.

Extended reasoning...

Overview

This PR adds byte-based budgets alongside the existing count-based caps for CSS selector expansion when compiling nesting away for older targets. It touches src/css/{css_parser,error,printer,rules/mod,rules/style,selectors/selector}.rs plus a new test file. The bulk of the new code is a ~170-line selector-weight estimator (selector_list_weighttoken_weight) that walks the Component/PseudoClass/PseudoElement/TokenList AST, two new MinifyContext fields with chain-bytes accounting in charge_selector_expansion/minify_nested_rules, and a refactored serialize_nesting that meters emitted bytes via a depth counter on Printer.

Security risks

None introduced. This is a hardening change against adversarial-input OOM (fuzzing crash); it adds defensive limits rather than removing them. No auth, crypto, filesystem, or network surface is touched. The 64 MB thresholds are generous enough that legitimate stylesheets should never trip them, and the existing count caps are checked first so ordinary cases keep their existing error.

Level of scrutiny

Moderate. The change follows the established pattern of #31276/#31277/#31482/#31642 and is well-tested (7 new tests covering both blowup → error and below-budget → unchanged-output cases, plus byte-exact regression locks on the minimized fuzz input). However, the weight estimator is a new heuristic that touches many AST node types, the chain-bytes contribution uses an average-weight-per-level approximation, and the serialize_nesting refactor introduces depth tracking to avoid double-counting — these are the kind of subtleties where a second pair of eyes on the accounting model is valuable.

Other factors

  • CodeRabbit raised one concern (u32 truncation + uncharged numeric tokens) which was addressed in 94751a7 and confirmed resolved.
  • The PR description notes expected textual conflicts with #31913 in error.rs/rules/mod.rs/css_parser.rs; whichever lands second needs a manual merge.
  • The musl build failures in CI appear to be an unrelated LTO/toolchain issue (data-layout mismatch in regular-lto-flag-stub.bc), not caused by these source changes.
  • The meter-depth decrement in serialize_nesting is correctly placed before result?, so the depth is restored on error paths.

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.

1 participant