Skip to content

css: bound nested-prelude substitution output across the stylesheet - #32451

Open
robobun wants to merge 6 commits into
mainfrom
farm/122506dc/css-nesting-expansion-bytes-hang
Open

css: bound nested-prelude substitution output across the stylesheet#32451
robobun wants to merge 6 commits into
mainfrom
farm/122506dc/css-nesting-expansion-bytes-hang

Conversation

@robobun

@robobun robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a CSS serializer hang found by fuzzing: a ~7 KB stylesheet expands into ~1.7 GB of output (scales to an unbounded hang with more sibling rules) when compiling nesting away for older targets.

Fuzzer signature: hang:css:_RNvXNvNtNtNtC8bun_core4util2io5Write9write_fmtINtB2_6BridgeINtNtC5alloc3vec3VechEENtNtC4core3fmt5Write9write_s (i.e. <Write::write_fmt::Bridge<Vec<u8>> as fmt::Write>::write_str: the printer spinning while growing its output buffer).

// before: 1,691,652,252 bytes out (~1.7 GB); after: throws "Maximum nesting expansion exceeded"
const c = require("bun:internal-for-testing").cssInternals;
const root = "." + "a".repeat(500) + "::part(x), ." + "b".repeat(500) + "::part(y)";
let body = "";
for (let i = 0; i < 200; i++) body += ".l" + i + " { color: rgb(" + (i % 256) + ", 0, 0) } ";
let inner = body;
for (let i = 0; i < 13; i++) inner = "& > & { " + inner + " }";
c._test(root + " { " + inner + " }", "", { firefox: 100 << 16 });

Cause

A selector like & > & is a single entry in its rule's selector list, so the minify-time selector_expansion_multiplier (which multiplies by the list length, #31277) does not see it as fan-out and stays at 1. At print time every & expands the parent, so each such nesting level still fans out by the number of & references it holds.

The per-prelude substitution counter MAX_NESTING_EXPANSIONS (#31276) bounds one rule's prelude to 65,536 substitutions, but it is reset between sibling rules in StyleRule::to_css_base. With a depth-13 & > & chain, each leaf rule's prelude expands to $2^{13} = 8192$ substitutions (under the per-prelude cap); 200 sibling leaves then emit $200 \times 8192$ copies of the root selector list with none of the existing caps firing. The total output grows by (leaf count) x (product of per-level & counts); at ~15 KB of input this was ~2 GB of output.

Fix

Measure the bytes each nested rule's prelude emits while compiling nesting away and accumulate them into a new stylesheet-wide Printer::nesting_expansion_bytes (never reset, like prefix_expansion_bytes from #31642). Past MAX_NESTING_EXPANSION_BYTES (64 MB, matching MAX_PREFIX_EXPANSION_BYTES), report the existing maximum_nesting_expansion error instead of serializing without bound. Only preludes with a parent context are metered, so large flat stylesheets are unaffected and output for non-pathological input is byte-for-byte unchanged.

The same metering is applied around @scope's preludes, which serialize with the parent context the same way.

Related: #31916 adds a byte budget on both the minify side (estimated selector weight) and the printer side (measured per-substitution) for a different trigger (fat selectors x moderate count). This PR is the narrower printer-side fix for the many-siblings trigger; it measures per-prelude rather than per-substitution, so it needs no recursion-depth tracking.

Verification

New tests in test/js/bun/css/nested-selector-expansion.test.ts:

  • many sibling rules under a \& > &` chain error out instead of serializing gigabytesfails on the unfixed build (outputsOK 1691652252`) and passes on the fixed build (throws after ~64 MB).
  • a few sibling rules under a \& > &` chain still serialize without hitting the byte budget` confirms the same shape at a non-pathological scale (3 leaves, ~25 MB) is unchanged.

The existing nesting-expansion, selector-list-expansion, vendor-prefix-duplication, and css.test.ts suites all pass unchanged.

A selector like `& > &` is a single entry in its rule's selector list,
so the minify-time selector-expansion multiplier (which multiplies by
the list length) does not see it as fan-out. At print time every `&`
expands the parent, so each nesting level still fans out by its `&`
count. The per-prelude substitution counter (MAX_NESTING_EXPANSIONS)
bounds one rule's prelude but is reset between sibling rules, so many
sibling leaf rules under a `& > &` chain each stay under the
per-prelude cap while the total output grows by (leaf count) x 2^depth.
A ~7 KB stylesheet expanded into ~1.7 GB of output this way (found by
CSS fuzzing, stack sampled inside write_fmt on the growing Vec<u8>).

Measure the bytes each nested rule's prelude emits while compiling
nesting away and accumulate them across the whole stylesheet. Past 64 MB
(matching MAX_PREFIX_EXPANSION_BYTES), report the existing
maximum_nesting_expansion error instead of serializing without bound.
Only preludes with a parent context are metered, so large flat
stylesheets are unaffected.
@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:17 PM PT - Jun 16th, 2026

@robobun, your commit f6f7665 has 3 failures in Build #63092 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32451

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

bun-32451 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. css: budget selector expansion and nesting substitution by bytes #31916 - Both PRs add a nesting_expansion_bytes byte budget on Printer bounded by MAX_NESTING_EXPANSION_BYTES (64 MB) to prevent unbounded CSS output from nested selector expansion; css: budget selector expansion and nesting substitution by bytes #31916 is a superset that also includes minify-side byte budgeting

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 17, 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: 11577646-107d-4c73-bff8-19a00b2fb8dd

📥 Commits

Reviewing files that changed from the base of the PR and between 9519df0 and f6f7665.

📒 Files selected for processing (2)
  • src/css/rules/scope.rs
  • test/js/bun/css/nested-selector-expansion.test.ts

Walkthrough

Adds a stylesheet-wide byte budget (MAX_NESTING_EXPANSION_BYTES = 64 MiB) for output growth caused by & parent-selector substitution. A new nesting_expansion_bytes accumulator on Printer is charged in StyleRule::to_css_base and ScopeRule::to_css; both return a maximum_nesting_expansion error when the limit is exceeded. Regression tests verify the error fires for pathological sibling counts and that bounded expansion still succeeds.

Changes

CSS Nesting Expansion Byte Budget

Layer / File(s) Summary
Budget constant and Printer accumulator field
src/css/rules/style.rs, src/css/printer.rs
MAX_NESTING_EXPANSION_BYTES (64 MiB) is defined with documentation in style.rs. The nesting_expansion_bytes: usize public field is added to Printer and initialized to 0 in Printer::new.
StyleRule and ScopeRule enforcement
src/css/rules/style.rs, src/css/rules/scope.rs
StyleRule::to_css_base meters selector-prelude bytes only when a parent context is present and fails with maximum_nesting_expansion when the accumulated total exceeds the budget. ScopeRule::to_css applies the same measurement and error check around @scope prelude serialization, detecting expansion possibility from parent context or both scope_start/scope_end presence.
Regression tests for nesting expansion budgeting
test/js/bun/css/nested-selector-expansion.test.ts
Adds a parameterized siblingUnderAmpChainScript that generates deeply nested & > & CSS and a runSiblingUnderAmpChain spawner with SIGKILL timeout. Five concurrent tests: one asserting the error fires for depth=13/leaves=200, one verifying @scope prelude expansion shares the same budget at n=10 while n=1 succeeds, one verifying @scope to (...) without scope-start serializes completely, one asserting sibling @scope to (& ...) rules error under deep & > & chains, and one asserting success with output between 1 MB and 64 MiB for depth=13/leaves=3.

Possibly related PRs

  • oven-sh/bun#31276: Implements the same maximum_nesting_expansion error and enforces a per-rule nesting budget in the same Printer/StyleRule/ScopeRule paths, but budgets substitution counts (nesting_expansions) rather than emitted bytes.
  • oven-sh/bun#31642: Both PRs extend Printer with a running byte counter and meter emitted bytes via dest.bytes_written() inside StyleRule serialization paths; this PR budgets & nesting-expansion bytes while the other budgets vendor-prefix fan-out bytes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: bounding nested-prelude substitution output across the stylesheet to prevent unbounded expansion.
Description check ✅ Passed The description comprehensively covers both required sections: it explains what the PR does (fixes a CSS serializer hang with detailed cause analysis and fix explanation) and how it was verified (new tests with before/after behavior and verification of existing test suite).
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.

@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/rules/scope.rs`:
- Around line 33-38: The byte metering gate at the location of
prelude_bytes_before only activates when dest.ctx is already set, but the
scope-end expansion can create a temporary context at lines 59-66 that bypasses
this gate. Move the byte metering initialization to occur before the temporary
context creation from scope_start so that all expansion paths, including
top-level `@scope` rules, are properly accounted against the
nesting_expansion_bytes budget. Additionally, add a regression test case that
verifies top-level `@scope` rules with repeated & fan-out patterns respect the
nesting_expansion_bytes limit and do not allow runaway output growth.
🪄 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: da8406aa-ce37-424d-a51d-4c91e6cff465

📥 Commits

Reviewing files that changed from the base of the PR and between 0c537fe and 44ebd81.

📒 Files selected for processing (4)
  • src/css/printer.rs
  • src/css/rules/scope.rs
  • src/css/rules/style.rs
  • test/js/bun/css/nested-selector-expansion.test.ts

Comment thread src/css/rules/scope.rs Outdated
…pe-start>

<scope-end> is serialized with <scope-start> as a temporary parent
context even when there is no outer style-rule context, so top-level
@scope (huge) to (& & ... &) rules fan out each & in <scope-end> into
a copy of <scope-start>. Gate the byte metering on that path too so
many such sibling rules are charged against the stylesheet-wide
nesting-expansion budget.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/css/rules/scope.rs:74-83 — The new metering block at lines 74–83 is placed after the pre-existing early return serialize_selector_list(...) at line 69, so when scope_end is Some and scope_start is None (i.e. @scope to (...) { }) the prelude's &-substitution bytes are emitted but never charged to nesting_expansion_bytes. Many sibling @scope to (& ...) {} rules nested under a deep & > & chain therefore reproduce the same unbounded blow-up this PR fixes for StyleRule. The metering should run before this early return (or the early return should be dropped so control falls through — it already looks like a pre-existing bug since it also skips ), {, the body, and }).

    Extended reasoning...

    What the bug is

    ScopeRule::to_css captures prelude_bytes_before at line 38 (when dest.ctx is Some) and then, after both scope preludes have been serialized, accumulates the emitted bytes into dest.nesting_expansion_bytes and checks them against MAX_NESTING_EXPANSION_BYTES at lines 74–83. But between those two points there is a pre-existing early return at line 69:

    } else {
        let ctx = dest.ctx;
        return serialize_selector_list(scope_end.v.slice(), dest, ctx, false);
    }

    This branch is taken when scope_end is Some and scope_start is None — i.e. for @scope to (<scope-end>) { ... } with no <scope-start>. The return exits to_css before reaching the new metering block, so prelude_bytes_before is captured but never used: the bytes serialize_selector_list writes are not added to nesting_expansion_bytes and never compared against the 64 MB budget.

    Why it is reachable

    The parser (css_parser.rs) parses scope_start and scope_end independently, so @scope to (...) without a scope-start is accepted and produces scope_start = None, scope_end = Some(...). When such an @scope rule is nested inside a style rule and nesting is being compiled away, StyleRule::to_css_base prints its nested rules via dest.with_context(&self.selectors, ...), so dest.ctx is Some when ScopeRule::to_css runs. That makes prelude_bytes_before Some at line 38, and line 69 passes dest.ctx as ctx, so & in the scope-end selector is substituted with the full parent chain — exactly the expansion the PR is metering. Line 32 also resets dest.nesting_expansions = 0, so each sibling @scope prelude gets a fresh per-prelude substitution budget.

    Why existing guards don't catch it

    The per-prelude MAX_NESTING_EXPANSIONS cap (65 536) is reset at line 32 for each @scope rule, and the minify-time selector_expansion_multiplier doesn't see & > & as fan-out (it's one selector in its list) — these are precisely the gaps the PR's new stylesheet-wide byte budget is meant to close. The PR description says "The same metering is applied around @scope's preludes", but this code path is the exception.

    Step-by-step proof

    1. Root: .aaa…::part(x), .bbb…::part(y) (~1 KB).
    2. Wrap 13 times in & > & { … } (so each leaf prelude's & does 2^13 = 8192 substitutions — under the 65 536 per-prelude cap).
    3. Innermost body: 200 sibling @scope to (& .leaf-i) {} rules.
    4. Compile with a target that lacks native nesting (e.g. { firefox: 100 << 16 }).

    For each sibling @scope:

    • dest.ctx is Some (set by the enclosing StyleRule::to_css_basewith_context), so prelude_bytes_before = Some(n) at line 38.
    • scope_start is None, scope_end is Some, so control reaches the else at line 67.
    • Line 69 calls serialize_selector_list(scope_end, dest, dest.ctx, false): & .leaf-i expands the 13-deep & > & chain into ~8192 copies of the ~1 KB root, emitting ~8 MB.
    • The function then returns. Lines 74–83 never execute; nesting_expansion_bytes is unchanged.

    200 siblings × ~8 MB ≈ ~1.6 GB of output with the new byte budget never checked on this path — the same DoS shape the PR fixes for StyleRule, reachable via a trivially different leaf rule.

    Impact

    This is an incomplete fix for the DoS the PR targets: the same fuzzer that found the StyleRule variant could find this one by swapping .lN { … } leaves for @scope to (& .lN) {} leaves. The unbounded output is written by serialize_selector_list before the early return, so the (separate, pre-existing) fact that the early return also skips )/{/body/} does not mitigate it.

    How to fix

    Either move/duplicate the metering check so it also runs on the line-69 path (e.g. compute and check nesting_expansion_bytes immediately before the return), or — probably better — drop the return so control falls through to the existing metering block and the rest of the rule body. The early return itself looks like a pre-existing bug (it leaves the prelude unclosed and never prints the rule body), so removing it would fix both issues at once.

robobun and others added 2 commits June 17, 2026 05:05
ScopeRule::to_css early-returned after serializing <scope-end> when
<scope-start> was absent, so @scope to (...) { } emitted an unclosed
prelude with no body. The early return also bypassed the
nesting-expansion byte budget check, letting many sibling
@scope to (& .lN) {} rules under a `& > &` chain fan out each & in
<scope-end> against the outer parent context without bound (the same
~1.7 GB blow-up this PR fixes for style-rule leaves). Drop the early
return so control falls through to close the prelude, charge its
bytes against the budget, and emit the rule body.
Comment thread src/css/rules/scope.rs
@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build 63092 is red on one lane (darwin 26 aarch64 - test-bun) with failures unrelated to this diff:

  • test/integration/next-pages/test/dev-server-ssr-100.test.ts and next-build.test.ts: Puppeteer chrome-headless-shell download failed ("The browser folder ... exists but the executable ... is missing")
  • test/js/bun/s3/s3.test.ts: R2 large-file upload timeouts
  • test/cli/hot/hot.test.ts on 2019 x64 (warning-level, retried)

None of these touch src/css/ or run the test file this PR changes. test/js/bun/css/nested-selector-expansion.test.ts (the file this PR adds tests to) passes on all 228 other test lanes. The previous build (63087) failed the same darwin lane with a uv_os_get_passwd ENOENT runner crash before reaching tests, so the lane itself appears unhealthy.

The diff is green; these are unrelated infra/external-service flakes on the darwin 26 aarch64 runner.

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