diff --git a/src/css/printer.rs b/src/css/printer.rs index 42bc1815367a..d62c48f718a1 100644 --- a/src/css/printer.rs +++ b/src/css/printer.rs @@ -158,6 +158,18 @@ pub struct Printer<'a> { /// `serialize::serialize_nesting` so deeply nested rules with multiple /// `&` references per level cannot expand exponentially. pub nesting_expansions: u32, + /// Running total of bytes emitted by `&` parent-selector substitution + /// across the whole stylesheet (accumulated per rule prelude, never + /// reset). Complements `nesting_expansions`: that bounds the per-prelude + /// substitution count, but a selector like `& > &` (one selector, two + /// `&` references) is charged as one selector by the minify-time + /// expansion multiplier yet fans out twice during printing, so many + /// sibling rules each stay under the per-prelude count while the total + /// output grows without bound. Measured around each prelude's + /// `serialize_selector_list` call in `StyleRule::to_css_base` and + /// `ScopeRule::to_css`, and bounded there by + /// `MAX_NESTING_EXPANSION_BYTES`. + pub nesting_expansion_bytes: usize, /// Running total of bytes emitted by duplicate vendor-prefix passes. A rule /// whose selector list carries more than one vendor prefix (e.g. a list /// mixing `:-webkit-autofill` with an unprefixed pseudo-class, or a single @@ -347,6 +359,7 @@ impl<'a> Printer<'a> { css_module: None, ctx: None, nesting_expansions: 0, + nesting_expansion_bytes: 0, prefix_expansion_bytes: 0, error_kind: None, } diff --git a/src/css/rules/scope.rs b/src/css/rules/scope.rs index cf880646bf44..b4d68c9f3695 100644 --- a/src/css/rules/scope.rs +++ b/src/css/rules/scope.rs @@ -30,6 +30,16 @@ impl ScopeRule { // compiling nesting, like style rule preludes do (see // `serialize::serialize_nesting`). dest.nesting_expansions = 0; + // Meter the prelude's `&` substitution output against the + // stylesheet-wide nesting-expansion byte budget (see + // `StyleRule::to_css_base`). Meter when `&` can expand: either an + // outer parent context is set, or `` will be serialized + // with `` as a temporary parent (below). Without + // either, `&` serializes as-is and the preludes are linear in their + // own tokens. + let has_expanding_context = + dest.ctx.is_some() || (self.scope_start.is_some() && self.scope_end.is_some()); + let prelude_bytes_before = has_expanding_context.then(|| dest.bytes_written()); if let Some(scope_start) = &self.scope_start { dest.write_char(b'(')?; // scope_start.to_css(dest)?; @@ -60,11 +70,21 @@ impl ScopeRule { )?; } else { let ctx = dest.ctx; - return serialize_selector_list(scope_end.v.slice(), dest, ctx, false); + serialize_selector_list(scope_end.v.slice(), dest, ctx, false)?; } dest.write_char(b')')?; dest.whitespace()?; } + if let Some(before) = prelude_bytes_before { + let emitted = dest.bytes_written().saturating_sub(before); + dest.nesting_expansion_bytes = dest.nesting_expansion_bytes.saturating_add(emitted); + if dest.nesting_expansion_bytes > super::style::MAX_NESTING_EXPANSION_BYTES { + return dest.new_error( + crate::error::PrinterErrorKind::maximum_nesting_expansion, + None, + ); + } + } dest.write_char(b'{')?; dest.indent(); dest.newline()?; diff --git a/src/css/rules/style.rs b/src/css/rules/style.rs index 8474c12b251c..9d3e00dd99f1 100644 --- a/src/css/rules/style.rs +++ b/src/css/rules/style.rs @@ -91,6 +91,25 @@ impl StyleRule { /// (`selectors/selector.rs`) and `MAX_SELECTOR_EXPANSION` (`rules/mod.rs`). const MAX_PREFIX_EXPANSION_BYTES: usize = 64 << 20; +/// Maximum number of bytes that `&` parent-selector substitution may emit +/// across the whole stylesheet when compiling nesting away. +/// +/// `MAX_NESTING_EXPANSIONS` bounds the substitution count per rule prelude +/// and is reset between preludes. A selector with multiple `&` references +/// (e.g. `& > &`) is one selector in its list, so the minify-time +/// `selector_expansion_multiplier` (which multiplies by the list length) does +/// not see it as fan-out; but each `&` expands the parent during printing, so +/// each nesting level fans out by the number of `&` references it holds. Many +/// sibling leaf rules under such a chain each stay under the per-prelude +/// count while the total output grows by (leaf count) x (product of per-level +/// `&` counts). The bytes emitted by each prelude's substitutions are measured +/// and accumulated here, so a few kilobytes of that shape cannot expand into +/// gigabytes of output. Real stylesheets emit little here; anything past this +/// limit is a runaway expansion, so bail out with the existing +/// `maximum_nesting_expansion` error instead. Matches +/// `MAX_PREFIX_EXPANSION_BYTES`. +pub(crate) const MAX_NESTING_EXPANSION_BYTES: usize = 64 << 20; + impl StyleRule { pub fn to_css(&self, dest: &mut Printer) -> Result<(), PrintErr> { if self.vendor_prefix.is_empty() { @@ -179,12 +198,27 @@ impl StyleRule { // Each rule prelude gets its own budget for `&` substitutions when // compiling nesting (see `serialize::serialize_nesting`). dest.nesting_expansions = 0; + // Only meter if there is a parent context: preludes with no + // ancestors serialize verbatim (linear in the selector's own + // tokens) and charging them would penalize large flat + // stylesheets for no reason. + let prelude_bytes_before = ctx.map(|_| dest.bytes_written()); selector::serialize::serialize_selector_list( self.selectors.v.slice(), dest, ctx, false, )?; + if let Some(before) = prelude_bytes_before { + let emitted = dest.bytes_written().saturating_sub(before); + dest.nesting_expansion_bytes = dest.nesting_expansion_bytes.saturating_add(emitted); + if dest.nesting_expansion_bytes > MAX_NESTING_EXPANSION_BYTES { + return dest.new_error( + css::error::PrinterErrorKind::maximum_nesting_expansion, + None, + ); + } + } dest.whitespace()?; dest.write_char(b'{')?; dest.indent(); diff --git a/test/js/bun/css/nested-selector-expansion.test.ts b/test/js/bun/css/nested-selector-expansion.test.ts index b048a0dcb588..2296e14a85c3 100644 --- a/test/js/bun/css/nested-selector-expansion.test.ts +++ b/test/js/bun/css/nested-selector-expansion.test.ts @@ -119,6 +119,211 @@ test.concurrent("ordinary nested CSS still compiles for older targets", async () expect(exitCode).toBe(0); }); +// A selector with multiple `&` references per compound chain (e.g. `& > &`) +// 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 such +// nesting level still fans out by its `&` count. The per-prelude substitution +// counter 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) * 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`). The printer now +// also accumulates the bytes emitted by nested-prelude substitution across the +// whole stylesheet and reports the existing "Maximum nesting expansion +// exceeded" error past 64 MB instead of serializing without bound. + +const siblingUnderAmpChainScript = ` + const { cssInternals } = require("bun:internal-for-testing"); + const depth = parseInt(process.env.AMP_CHAIN_DEPTH, 10); + const leaves = parseInt(process.env.AMP_CHAIN_LEAVES, 10); + const root = + "." + Buffer.alloc(500, "a").toString() + "::part(x), " + + "." + Buffer.alloc(500, "b").toString() + "::part(y)"; + let body = ""; + for (let i = 0; i < leaves; i++) body += ".l" + i + " { color: rgb(" + (i % 256) + ", 0, 0) } "; + let inner = body; + for (let i = 0; i < depth; i++) inner = "& > & { " + inner + " }"; + const css = root + " { " + inner + " }"; + try { + const out = cssInternals._test(css, "", { firefox: 100 << 16 }); + console.log("OK " + out.length); + } catch (err) { + console.log("ERR " + err.message); + } +`; + +async function runSiblingUnderAmpChain(depth: number, leaves: number) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", siblingUnderAmpChainScript], + env: { + ...bunEnv, + BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1", + AMP_CHAIN_DEPTH: String(depth), + AMP_CHAIN_LEAVES: String(leaves), + }, + stdout: "pipe", + stderr: "pipe", + // Kill switch: before the fix this allocated hundreds of MB to GB of + // output. Kill the child so a regression fails the assertions below + // instead of exhausting memory or hanging the runner. + timeout: 60_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode, signalCode: proc.signalCode }; +} + +test.concurrent("many sibling rules under a `& > &` chain error out instead of serializing gigabytes", async () => { + // Two `::part()` selectors with 500-byte identifiers at the root, nested + // 13 levels deep in `& > &` (2^13 = 8192 substitutions per leaf prelude, + // well under the 65536 per-prelude cap), with 200 sibling leaf rules + // underneath. Before the fix this serialized ~1.7 GB of output; now the + // stylesheet-wide byte budget fires after ~64 MB and reports the existing + // nesting-expansion error. + const { stdout, stderr, signalCode, exitCode } = await runSiblingUnderAmpChain(13, 200); + 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( + "top-level `@scope (...) to (...)` preludes are charged against the nesting-expansion byte budget", + async () => { + // `` is serialized with `` as its parent + // context even when there is no outer style-rule context, so each `&` + // in `` repeats ``. A 4 KB start identifier with + // a 2000-`&` end expands one ~8 KB rule into ~8 MB of prelude; nine such + // sibling rules stay under the 64 MB budget, ten exceed it. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { cssInternals } = require("bun:internal-for-testing"); + const start = "." + Buffer.alloc(4000, "a").toString(); + const end = Array(2000).fill("&").join(" "); + const rule = "@scope (" + start + ") to (" + end + ") { .x { color: red } }\\n"; + for (const n of [1, 10]) { + try { + const out = cssInternals._test(rule.repeat(n), "", { firefox: 100 << 16 }); + console.log("n=" + n + " OK " + out.length); + } catch (err) { + console.log("n=" + n + " ERR " + err.message); + } + } + `, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1" }, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(proc.signalCode).toBeNull(); + const lines = stdout.trim().split("\n"); + // One rule is well under the budget and serializes unchanged. + expect(lines[0]).toStartWith("n=1 OK "); + // Ten sibling rules push the accumulated preludes past 64 MB and error. + expect(lines[1]).toContain("n=10 ERR Maximum nesting expansion exceeded"); + expect(exitCode).toBe(0); + }, +); + +test.concurrent("`@scope to (...)` without a scope-start serializes its closing `)`, body, and `}`", async () => { + // `ScopeRule::to_css` used to early-return after serializing + // `` when `` was absent, leaving the prelude + // unclosed and dropping the rule body (and skipping the + // nesting-expansion byte budget check below). Fall through instead so + // the rule serializes in full. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { cssInternals } = require("bun:internal-for-testing"); + console.log(cssInternals.minifyTest("@scope to (.a) { .x { color: red } }", "")); + `, + ], + 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(""); + expect(proc.signalCode).toBeNull(); + expect(stdout.trim()).toBe("@scope to (.a){.x{color:red}}"); + expect(exitCode).toBe(0); +}); + +test.concurrent( + "sibling `@scope to (& ...)` rules under a `& > &` chain error out instead of serializing gigabytes", + async () => { + // `@scope to (...)` without a scope-start serializes `` with + // the outer parent context, so `&` in it expands the enclosing `& > &` + // chain. Many such sibling `@scope` rules each stay under the + // per-prelude substitution cap (reset in `ScopeRule::to_css`) but their + // preludes are now charged against the stylesheet-wide byte budget, so + // this shape errors out after ~64 MB instead of serializing ~1.7 GB. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { cssInternals } = require("bun:internal-for-testing"); + const root = + "." + Buffer.alloc(500, "a").toString() + "::part(x), " + + "." + Buffer.alloc(500, "b").toString() + "::part(y)"; + let body = ""; + for (let i = 0; i < 200; i++) body += "@scope to (& .l" + i + ") { } "; + let inner = body; + for (let i = 0; i < 13; i++) inner = "& > & { " + inner + " }"; + try { + const out = cssInternals._test(root + " { " + inner + " }", "", { firefox: 100 << 16 }); + console.log("OK " + out.length); + } catch (err) { + console.log("ERR " + err.message); + } + `, + ], + env: { ...bunEnv, BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1" }, + stdout: "pipe", + stderr: "pipe", + // Kill switch: before the fix this allocated ~1.7 GB of output. + timeout: 60_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(proc.signalCode).toBeNull(); + expect(stdout.trim()).toContain("ERR Maximum nesting expansion exceeded"); + expect(exitCode).toBe(0); + }, +); + +test.concurrent( + "a few sibling rules under a `& > &` chain still serialize without hitting the byte budget", + async () => { + // Same shape at a non-pathological scale: 3 sibling leaves under 13 + // levels of `& > &` expand to ~25 MB of preludes, under the 64 MB budget, + // so output is unchanged. + const { stdout, stderr, signalCode, exitCode } = await runSiblingUnderAmpChain(13, 3); + expect(stderr).toBe(""); + expect(signalCode).toBeNull(); + expect(stdout).toStartWith("OK "); + // Output is the three expanded leaf rules: large but bounded and stable. + const bytes = parseInt(stdout.slice("OK ".length), 10); + expect(bytes).toBeGreaterThan(1_000_000); + expect(bytes).toBeLessThan(64 << 20); + expect(exitCode).toBe(0); + }, +); + 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),