From d8a83ae5f69b79a34109c5b2ba9f749d41050408 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 23 May 2026 08:17:39 +0000 Subject: [PATCH 1/6] css: cap `&` parent-selector expansion when compiling nesting for older targets When browser targets lack CSS nesting support, every `&` in a nested rule's selector is replaced with the parent selector at print time. The parent may itself contain `&`, so selectors with multiple `&` references per nesting level expand to (references per level)^depth copies of their ancestors; a few KB of deeply nested input makes the printer allocate without bound. Budget the number of parent-selector substitutions per rule prelude (65,536) and report a "Maximum nesting expansion exceeded" printer error instead of expanding forever. Preserved nesting (no targets) and ordinary nested CSS are unaffected. --- src/css/error.rs | 6 + src/css/printer.rs | 8 ++ src/css/rules/style.rs | 3 + src/css/selectors/selector.rs | 21 +++ .../bun/css/nested-selector-expansion.test.ts | 136 ++++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 test/js/bun/css/nested-selector-expansion.test.ts diff --git a/src/css/error.rs b/src/css/error.rs index de98b85a9ed2..84aa2377c9f9 100644 --- a/src/css/error.rs +++ b/src/css/error.rs @@ -234,6 +234,9 @@ pub enum PrinterErrorKind { invalid_composes_selector, /// The CSS modules pattern must end with `[local]` for use in CSS grid. invalid_css_modules_pattern_in_grid, + /// Substituting parent selectors for `&` while compiling CSS nesting for + /// the configured targets exceeded the expansion limit. + maximum_nesting_expansion, no_import_records, } @@ -255,6 +258,9 @@ impl fmt::Display for PrinterErrorKind { Self::invalid_css_modules_pattern_in_grid => { f.write_str("CSS modules pattern must end with '[local]' when used in CSS grid") } + Self::maximum_nesting_expansion => f.write_str( + "Maximum nesting expansion exceeded when compiling CSS nesting for the configured targets", + ), Self::no_import_records => f.write_str("No import records found"), } } diff --git a/src/css/printer.rs b/src/css/printer.rs index c35f78da40e8..b8f7c0985c27 100644 --- a/src/css/printer.rs +++ b/src/css/printer.rs @@ -144,6 +144,13 @@ pub struct Printer<'a> { // TODO(port): lifetime — ctx is set to a stack-local during with_context() and restored // after; `&'a StyleContext<'a>` will not borrow-check there. May need raw `*const StyleContext`. pub ctx: Option<&'a css::StyleContext<'a>>, + /// Number of parent-selector substitutions performed for `&` while + /// serializing the current rule prelude with compiled nesting (targets + /// without CSS nesting support). Reset per prelude in + /// `StyleRule::to_css_base` and bounded in `serialize::serialize_nesting` + /// so deeply nested rules with multiple `&` references per level cannot + /// expand exponentially. + pub nesting_expansions: u32, pub scratchbuf: BumpVec<'a, u8>, pub error_kind: Option, pub import_info: Option>, @@ -310,6 +317,7 @@ impl<'a> Printer<'a> { in_calc: false, css_module: None, ctx: None, + nesting_expansions: 0, error_kind: None, } } diff --git a/src/css/rules/style.rs b/src/css/rules/style.rs index 6ef29c8a1f62..065eebdbe628 100644 --- a/src/css/rules/style.rs +++ b/src/css/rules/style.rs @@ -122,6 +122,9 @@ impl StyleRule { // PORT NOTE: `dest.context()` borrows `dest`; copy the (Copy) raw // ctx field out so it doesn't conflict with the `&mut *dest` below. let ctx = dest.ctx; + // Each rule prelude gets its own budget for `&` substitutions when + // compiling nesting (see `serialize::serialize_nesting`). + dest.nesting_expansions = 0; selector::serialize::serialize_selector_list( self.selectors.v.slice(), dest, diff --git a/src/css/selectors/selector.rs b/src/css/selectors/selector.rs index 9fff0cc7fafe..4571b3d68d6f 100644 --- a/src/css/selectors/selector.rs +++ b/src/css/selectors/selector.rs @@ -1361,12 +1361,33 @@ pub mod serialize { Ok(()) } + /// Maximum number of parent-selector substitutions allowed while + /// serializing a single rule prelude with compiled nesting. + /// + /// When the targets don't support CSS nesting, every `&` is replaced with + /// the parent selector, which may itself contain `&` referring to the + /// grandparent, and so on. A selector with multiple `&` references per + /// nesting level therefore expands to (references per level)^depth copies + /// of its ancestors, so a few KB of deeply nested input can print + /// gigabytes of output. Real-world nesting needs at most a handful of + /// substitutions per rule; anything past this limit is a runaway + /// expansion, so bail out with an error instead of allocating without + /// bound. + const MAX_NESTING_EXPANSIONS: u32 = 65_536; + pub fn serialize_nesting( dest: &mut Printer, context: Option<&StyleContext>, first: bool, ) -> Result<(), PrintErr> { if let Some(ctx) = context { + dest.nesting_expansions += 1; + if dest.nesting_expansions > MAX_NESTING_EXPANSIONS { + return dest.new_error( + crate::error::PrinterErrorKind::maximum_nesting_expansion, + None, + ); + } // If there's only one simple selector, just serialize it directly. // Otherwise, use an :is() pseudo class. // Type selectors are only allowed at the start of a compound selector, diff --git a/test/js/bun/css/nested-selector-expansion.test.ts b/test/js/bun/css/nested-selector-expansion.test.ts new file mode 100644 index 000000000000..f86c09c6cc74 --- /dev/null +++ b/test/js/bun/css/nested-selector-expansion.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import path from "node:path"; + +// Regression test for unbounded memory growth when compiling CSS nesting for +// targets that don't support it (found by CSS fuzzing). +// +// When the browser targets lack native nesting support, every `&` in a nested +// rule's selector is replaced with the parent selector at print time. The +// parent selector itself may contain `&` referring to the grandparent, so a +// selector with multiple `&` references per nesting level expands to +// (references per level)^depth copies of its ancestors. A ~3 KB stylesheet +// with ~20 nesting levels of `&:is(.bar, &.baz)` makes the printer allocate an +// effectively unbounded output buffer: `bun build` (whose default browser +// target predates CSS nesting) and `minifyTest` with explicit targets both +// spin forever while memory grows. +// +// The serializer now budgets the number of `&` substitutions per rule prelude +// and reports "Maximum nesting expansion exceeded" instead of expanding +// without bound. Preserving nesting (no targets) and ordinary nested CSS with +// old targets are unaffected. + +/** Deeply nested rules where each level references the parent twice (`&` appears twice per selector). */ +function explodingNestedCss(depth: number): string { + let css = ""; + for (let i = 0; i < depth; i++) { + css += "&:is(.bar, &.baz) { color: red; }\n"; + css += "&:is(.bar, &.baz) { colo\n"; // unclosed block, same shape as the fuzz input + } + css += "&:is(.bar, &.baz) { color: red; }\n"; + css += "}"; + return css; +} + +const minifyTestScript = ` + const { cssInternals } = require("bun:internal-for-testing"); + const depth = parseInt(process.env.NESTED_CSS_DEPTH, 10); + const targets = process.env.NESTED_CSS_TARGETS === "1" ? { safari: 13 << 16 } : undefined; + let css = ""; + for (let i = 0; i < depth; i++) { + css += "&:is(.bar, &.baz) { color: red; }\\n"; + css += "&:is(.bar, &.baz) { colo\\n"; + } + css += "&:is(.bar, &.baz) { color: red; }\\n"; + css += "}"; + try { + const out = targets ? cssInternals.minifyTest(css, "", targets) : cssInternals.minifyTest(css, ""); + console.log("OK " + out.length); + } catch (err) { + console.log("ERR " + err.message); + } +`; + +async function runMinifyTest(depth: number, withTargets: boolean) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", minifyTestScript], + env: { + ...bunEnv, + BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1", + NESTED_CSS_DEPTH: String(depth), + NESTED_CSS_TARGETS: withTargets ? "1" : "0", + }, + stdout: "pipe", + stderr: "pipe", + // Kill switch: before the fix these spins were unbounded. Let the child be + // killed so a regression fails the assertions below instead of hanging the + // test runner and exhausting memory. + timeout: 20_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode, signalCode: proc.signalCode }; +} + +test("deeply nested `&` selectors error out instead of expanding without bound when compiling nesting", async () => { + const { stdout, stderr, signalCode } = await runMinifyTest(24, true); + expect(stderr).toBe(""); + expect(signalCode).toBeNull(); // not killed by the kill switch + expect(stdout).toContain("ERR Maximum nesting expansion exceeded"); +}); + +test("deeply nested `&` selectors still minify when nesting is preserved (no targets)", async () => { + const { stdout, stderr, signalCode } = await runMinifyTest(24, false); + expect(stderr).toBe(""); + expect(signalCode).toBeNull(); + // Without targets the nesting is preserved, so the output stays small. + expect(stdout).toStartWith("OK "); +}); + +test("ordinary nested CSS still compiles for older targets", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { cssInternals } = require("bun:internal-for-testing"); + // 8 levels deep, one parent reference per level: well within the budget. + let css = ".a { color: red; "; + for (let i = 0; i < 8; i++) css += "&:hover .b" + i + " { color: blue; "; + css += "}".repeat(9); + console.log(cssInternals.minifyTest(css, "", { safari: 13 << 16 })); + `, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1" }, + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // The innermost rule's `&` chain is fully expanded. + expect(stdout).toContain(".a:hover .b0:hover .b1:hover .b2:hover .b3:hover .b4:hover .b5:hover .b6:hover .b7"); + expect(exitCode).toBe(0); +}); + +test("bun build does not hang on deeply nested `&` selectors with the default browser target", async () => { + using dir = tempDir("css-nested-selector-expansion", { + "explode.css": explodingNestedCss(24), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", path.join(String(dir), "explode.css"), "--outdir", path.join(String(dir), "out")], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + // Kill switch: before the fix this build spun forever while allocating. + timeout: 20_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The build must terminate on its own (the printer reports an error for the + // runaway rule) instead of being killed by the 20s kill switch. + expect(proc.signalCode).toBeNull(); + expect(exitCode).not.toBeNull(); +}); From 8c57e8d5bb5d7e4271687f8ae163f393d1ed7beb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 23 May 2026 08:36:48 +0000 Subject: [PATCH 2/6] test: run nested-selector-expansion tests concurrently --- test/js/bun/css/nested-selector-expansion.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/js/bun/css/nested-selector-expansion.test.ts b/test/js/bun/css/nested-selector-expansion.test.ts index f86c09c6cc74..86a6125c42e2 100644 --- a/test/js/bun/css/nested-selector-expansion.test.ts +++ b/test/js/bun/css/nested-selector-expansion.test.ts @@ -72,14 +72,14 @@ async function runMinifyTest(depth: number, withTargets: boolean) { return { stdout, stderr, exitCode, signalCode: proc.signalCode }; } -test("deeply nested `&` selectors error out instead of expanding without bound when compiling nesting", async () => { +test.concurrent("deeply nested `&` selectors error out instead of expanding without bound when compiling nesting", async () => { const { stdout, stderr, signalCode } = await runMinifyTest(24, true); expect(stderr).toBe(""); expect(signalCode).toBeNull(); // not killed by the kill switch expect(stdout).toContain("ERR Maximum nesting expansion exceeded"); }); -test("deeply nested `&` selectors still minify when nesting is preserved (no targets)", async () => { +test.concurrent("deeply nested `&` selectors still minify when nesting is preserved (no targets)", async () => { const { stdout, stderr, signalCode } = await runMinifyTest(24, false); expect(stderr).toBe(""); expect(signalCode).toBeNull(); @@ -87,7 +87,7 @@ test("deeply nested `&` selectors still minify when nesting is preserved (no tar expect(stdout).toStartWith("OK "); }); -test("ordinary nested CSS still compiles for older targets", async () => { +test.concurrent("ordinary nested CSS still compiles for older targets", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -114,7 +114,7 @@ test("ordinary nested CSS still compiles for older targets", async () => { expect(exitCode).toBe(0); }); -test("bun build does not hang on deeply nested `&` selectors with the default browser target", async () => { +test.concurrent("bun build does not hang on deeply nested `&` selectors with the default browser target", async () => { using dir = tempDir("css-nested-selector-expansion", { "explode.css": explodingNestedCss(24), }); From 7f64a95c25afb9c917c833e67365eac7febf13af Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 08:39:45 +0000 Subject: [PATCH 3/6] [autofix.ci] apply automated fixes --- test/js/bun/css/nested-selector-expansion.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/js/bun/css/nested-selector-expansion.test.ts b/test/js/bun/css/nested-selector-expansion.test.ts index 86a6125c42e2..9bf1b39fc1da 100644 --- a/test/js/bun/css/nested-selector-expansion.test.ts +++ b/test/js/bun/css/nested-selector-expansion.test.ts @@ -72,12 +72,15 @@ async function runMinifyTest(depth: number, withTargets: boolean) { return { stdout, stderr, exitCode, signalCode: proc.signalCode }; } -test.concurrent("deeply nested `&` selectors error out instead of expanding without bound when compiling nesting", async () => { - const { stdout, stderr, signalCode } = await runMinifyTest(24, true); - expect(stderr).toBe(""); - expect(signalCode).toBeNull(); // not killed by the kill switch - expect(stdout).toContain("ERR Maximum nesting expansion exceeded"); -}); +test.concurrent( + "deeply nested `&` selectors error out instead of expanding without bound when compiling nesting", + async () => { + const { stdout, stderr, signalCode } = await runMinifyTest(24, true); + expect(stderr).toBe(""); + expect(signalCode).toBeNull(); // not killed by the kill switch + expect(stdout).toContain("ERR Maximum nesting expansion exceeded"); + }, +); test.concurrent("deeply nested `&` selectors still minify when nesting is preserved (no targets)", async () => { const { stdout, stderr, signalCode } = await runMinifyTest(24, false); From 6429c8a3cc621923f885284d0ee97da184e5f638 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 23 May 2026 08:47:11 +0000 Subject: [PATCH 4/6] test: assert exit codes in nested-selector-expansion spawn tests --- test/js/bun/css/nested-selector-expansion.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/js/bun/css/nested-selector-expansion.test.ts b/test/js/bun/css/nested-selector-expansion.test.ts index 9bf1b39fc1da..b048a0dcb588 100644 --- a/test/js/bun/css/nested-selector-expansion.test.ts +++ b/test/js/bun/css/nested-selector-expansion.test.ts @@ -75,19 +75,21 @@ async function runMinifyTest(depth: number, withTargets: boolean) { test.concurrent( "deeply nested `&` selectors error out instead of expanding without bound when compiling nesting", async () => { - const { stdout, stderr, signalCode } = await runMinifyTest(24, true); + const { stdout, stderr, signalCode, exitCode } = await runMinifyTest(24, true); expect(stderr).toBe(""); expect(signalCode).toBeNull(); // not killed by the kill switch expect(stdout).toContain("ERR Maximum nesting expansion exceeded"); + expect(exitCode).toBe(0); }, ); test.concurrent("deeply nested `&` selectors still minify when nesting is preserved (no targets)", async () => { - const { stdout, stderr, signalCode } = await runMinifyTest(24, false); + const { stdout, stderr, signalCode, exitCode } = await runMinifyTest(24, false); expect(stderr).toBe(""); expect(signalCode).toBeNull(); // Without targets the nesting is preserved, so the output stays small. expect(stdout).toStartWith("OK "); + expect(exitCode).toBe(0); }); test.concurrent("ordinary nested CSS still compiles for older targets", async () => { From cb5bf4ae474931c5872ffe9c2893247e6de0b91a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 23 May 2026 10:10:21 +0000 Subject: [PATCH 5/6] ci: retrigger From b20c268eb29ababb7f9d3fc2b41b178cc51b80a3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 23 May 2026 10:27:02 +0000 Subject: [PATCH 6/6] css: give @scope preludes their own nesting expansion budget --- src/css/printer.rs | 8 ++++---- src/css/rules/scope.rs | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/css/printer.rs b/src/css/printer.rs index b8f7c0985c27..5d9e233be8dd 100644 --- a/src/css/printer.rs +++ b/src/css/printer.rs @@ -146,10 +146,10 @@ pub struct Printer<'a> { pub ctx: Option<&'a css::StyleContext<'a>>, /// Number of parent-selector substitutions performed for `&` while /// serializing the current rule prelude with compiled nesting (targets - /// without CSS nesting support). Reset per prelude in - /// `StyleRule::to_css_base` and bounded in `serialize::serialize_nesting` - /// so deeply nested rules with multiple `&` references per level cannot - /// expand exponentially. + /// without CSS nesting support). Reset per prelude (in + /// `StyleRule::to_css_base` and `ScopeRule::to_css`) and bounded in + /// `serialize::serialize_nesting` so deeply nested rules with multiple + /// `&` references per level cannot expand exponentially. pub nesting_expansions: u32, pub scratchbuf: BumpVec<'a, u8>, pub error_kind: Option, diff --git a/src/css/rules/scope.rs b/src/css/rules/scope.rs index 3fe73238db22..63f9fc7d35b3 100644 --- a/src/css/rules/scope.rs +++ b/src/css/rules/scope.rs @@ -26,6 +26,10 @@ impl ScopeRule { dest.write_str("@scope")?; dest.whitespace()?; + // The scope preludes get their own budget for `&` substitutions when + // compiling nesting, like style rule preludes do (see + // `serialize::serialize_nesting`). + dest.nesting_expansions = 0; if let Some(scope_start) = &self.scope_start { dest.write_char(b'(')?; // scope_start.to_css(dest)?;