Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 16 additions & 0 deletions src/css/rules/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ impl<R> ScopeRule<R> {
// 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`). Only meter if there is a parent
// context: without one, `&` serializes as-is and the preludes are
// linear in their own tokens.
let prelude_bytes_before = dest.ctx.map(|_| dest.bytes_written());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if let Some(scope_start) = &self.scope_start {
dest.write_char(b'(')?;
// scope_start.to_css(dest)?;
Expand Down Expand Up @@ -65,6 +71,16 @@ impl<R> ScopeRule<R> {
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,
);
}
}
Comment thread
robobun marked this conversation as resolved.
dest.write_char(b'{')?;
dest.indent();
dest.newline()?;
Expand Down
35 changes: 35 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,28 @@ 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
90 changes: 90 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,96 @@ 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(
"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