Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/css/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down
20 changes: 20 additions & 0 deletions src/css/rules/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@
// 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 `<scope-end>` will be serialized
// with `<scope-start>` 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)?;
Expand Down Expand Up @@ -65,6 +75,16 @@
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,
);
}
}

Check failure on line 87 in src/css/rules/scope.rs

View check run for this annotation

Claude / Claude Code Review

@scope to (...) without scope-start: early return bypasses nesting-expansion byte metering

The `@scope to (...)` (no `<scope-start>`) branch does `return serialize_selector_list(...)` at line 73, which exits before the new `nesting_expansion_bytes` charge/check at lines 78–87. When such a rule is nested inside a parent style rule with nesting compiled away, `dest.ctx` is set so `has_expanding_context` is true and `&` in `<scope-end>` expands the parent — but the bytes are never charged, so many sibling `@scope to (& & & ...)` rules under a large parent selector still bypass the budget
Comment thread
robobun marked this conversation as resolved.
dest.write_char(b'{')?;
dest.indent();
dest.newline()?;
Expand Down
34 changes: 34 additions & 0 deletions src/css/rules/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@ impl<R> StyleRule<R> {
/// (`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<R> StyleRule<R> {
pub fn to_css(&self, dest: &mut Printer) -> Result<(), PrintErr> {
if self.vendor_prefix.is_empty() {
Expand Down Expand Up @@ -179,12 +198,27 @@ impl<R> StyleRule<R> {
// 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();
Expand Down
132 changes: 132 additions & 0 deletions test/js/bun/css/nested-selector-expansion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,138 @@ 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<u8>`). 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 () => {
// `<scope-end>` is serialized with `<scope-start>` as its parent
// context even when there is no outer style-rule context, so each `&`
// in `<scope-end>` repeats `<scope-start>`. 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(
"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),
Expand Down
Loading