Skip to content
1 change: 1 addition & 0 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2595,6 +2595,7 @@ mod stylesheet_impl {
err: None,
selector_expansion_multiplier: 1,
selector_expansion_total: 0,
token_expansion_total: 0,
};

if self.rules.minify(&mut minify_ctx, false).is_err() {
Expand Down
19 changes: 19 additions & 0 deletions src/css/declaration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@
self.declarations.len() + self.important_declarations.len()
}

/// Recursive `TokenOrValue` count across every unparsed / custom
/// property in this block. Other property kinds are fixed-size values
/// whose clone cost is already bounded by the selector-expansion cap.
/// See [`css_rules::MAX_TOKEN_EXPANSION`](crate::css_rules::MAX_TOKEN_EXPANSION).
pub fn token_weight(&self) -> usize {
fn one(p: &css::Property) -> usize {
match p {
css::Property::Unparsed(u) => u.value.token_weight(),
css::Property::Custom(c) => c.value.token_weight(),
_ => 0,
}
}

Check warning on line 105 in src/css/declaration.rs

View check run for this annotation

Claude / Claude Code Review

token_weight() _ => 0 arm: typed comma-list properties are not fixed-size; doc-comment claim is inaccurate and cap is bypassable

The doc comment's claim that "Other property kinds are fixed-size values whose clone cost is already bounded by the selector-expansion cap" is inaccurate — ~30 `Property` variants (`FontFamily(Vec<FontFamily>)`, `BackgroundImage`, `MaskImage`, `TransitionProperty`, `BoxShadow`, …) carry unbounded comma-lists that parse as typed (not Unparsed/Custom), hit the `_ => 0` arm, and are fully reallocated by `Property::deep_clone` on every partition clone. Under 14 `::part()` levels with a ~5000-entry l
Comment thread
robobun marked this conversation as resolved.
self.declarations
.iter()
.chain(self.important_declarations.iter())
.map(one)
.sum()
}

pub fn new_in(bump: &'bump Bump) -> Self {
Self {
important_declarations: DeclarationList::new_in(bump),
Expand Down
9 changes: 9 additions & 0 deletions src/css/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,10 @@ pub enum MinifyErrorKind {
/// Compiling nested rules for the configured browser targets would expand to
/// more than [`crate::css_rules::MAX_SELECTOR_EXPANSION`] selectors.
selector_expansion_limit_exceeded,
/// Compiling nested rules for the configured browser targets would clone
/// unparsed property values totalling more than
/// [`crate::css_rules::MAX_TOKEN_EXPANSION`] raw tokens.
token_expansion_limit_exceeded,
/// Rule minification failed without recording a more specific diagnostic on
/// `MinifyContext::err`. Defensive fallback — every failing path is expected
/// to record one before returning an error.
Expand All @@ -540,6 +544,11 @@ impl fmt::Display for MinifyErrorKind {
"Nested CSS rules expand to more than {} selectors when compiled for the configured browser targets. Reduce the nesting depth or the number of selectors per rule, or target browsers that support CSS nesting.",
crate::css_rules::MAX_SELECTOR_EXPANSION,
),
Self::token_expansion_limit_exceeded => write!(
f,
"Nested CSS rules expand to more than {} raw property tokens when compiled for the configured browser targets. Reduce the nesting depth, the number of selectors per rule, or the size of unparsed property values, or target browsers that support CSS nesting.",
crate::css_rules::MAX_TOKEN_EXPANSION,
),
Self::unknown => write!(f, "CSS minification failed"),
}
}
Expand Down
38 changes: 38 additions & 0 deletions src/css/properties/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,44 @@ impl TokenList {
res
}

/// Number of `TokenOrValue` nodes in this list, counting through nested
/// `Function`/`var()`/`env()`/`light-dark()` etc. so the result reflects
/// the allocation a `deep_clone` of this list performs. Used by the
/// minify-time token-expansion budget (see
/// [`css_rules::MAX_TOKEN_EXPANSION`](crate::css_rules::MAX_TOKEN_EXPANSION)).
pub fn token_weight(&self) -> usize {
let mut n = self.v.len();
for t in self.v.iter() {
match t {
TokenOrValue::Function(f) => n += f.arguments.token_weight(),
TokenOrValue::Var(v) => {
if let Some(fallback) = &v.fallback {
n += fallback.token_weight();
}
}
TokenOrValue::Env(e) => {
// `indices` is an unbounded `Vec<i32>` that every
// deep_clone reallocates; count each index as one unit
// (conservative: i32 is much smaller than TokenOrValue).
n += e.indices.len();
if let Some(fallback) = &e.fallback {
n += fallback.token_weight();
}
}
Comment thread
robobun marked this conversation as resolved.
TokenOrValue::UnresolvedColor(c) => match c {
UnresolvedColor::RGB { alpha, .. } | UnresolvedColor::HSL { alpha, .. } => {
n += alpha.token_weight();
}
UnresolvedColor::LightDark { light, dark } => {
n += light.token_weight() + dark.token_weight();
}
},
_ => {}
}
}
n
}

pub fn get_necessary_fallbacks(&self, targets: &css::targets::Targets) -> ColorFallbackKind {
let mut fallbacks = ColorFallbackKind::empty();
for token_or_value in self.v.iter() {
Expand Down
57 changes: 57 additions & 0 deletions src/css/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,29 @@ impl<R> CssRuleList<R> {
}
CssRule::FontPaletteValues(_) => {}
CssRule::Property(_) => {}
CssRule::Unknown(unk) => {
// An unknown at-rule nested inside a style rule is
// deep-cloned once per enclosing selector combination
// along with the rest of the nested subtree, and its
// prelude + block are raw `TokenList`s. Charge them
// against the token-expansion cap so a large unknown
// block under the selector cap can't still clone
// into gigabytes of `TokenOrValue`.
if context.selector_expansion_multiplier > 1 {
let weight = unk.prelude.token_weight()
+ unk.block.as_ref().map_or(0, |b| b.token_weight());
if context.charge_token_expansion(
context.selector_expansion_multiplier,
weight,
) {
context.err = Some(crate::error::MinifyError {
kind: crate::error::MinifyErrorKind::token_expansion_limit_exceeded,
loc: unk.loc,
});
return Err(MinifyErr::minify_err);
}
}
}
_ => {}
}

Expand Down Expand Up @@ -1281,6 +1304,21 @@ pub struct StyleContext<'a> {
/// instead.
pub const MAX_SELECTOR_EXPANSION: u32 = 65_536;

/// Upper bound on the number of raw `TokenOrValue` nodes that compiling
/// nested rules for the configured targets may clone across a stylesheet.
///
/// Companion to [`MAX_SELECTOR_EXPANSION`]: that cap counts expanded rules,
/// this one counts the raw-token payload those rules carry. A rule split
/// for an incompatible selector deep-clones its declarations (and the
/// already-expanded nested tree), so a large unparsed property value under
/// a handful of split levels is duplicated once per expanded rule. The
/// selector cap alone permits up to 65,536 copies, which for a
/// multi-thousand-token value is gigabytes of `TokenOrValue` allocations
/// long before that cap is reached. One million tokens is on the order of
/// 100 MB of in-memory `TokenOrValue` and a few MB of printed output;
/// real stylesheets stay far below it.
pub const MAX_TOKEN_EXPANSION: usize = 1 << 20;

/// Per-stylesheet minification state threaded through `CssRuleList::minify`
/// and every leaf rule's `minify`.
///
Expand Down Expand Up @@ -1316,4 +1354,23 @@ pub struct MinifyContext<'a, 'bump> {
/// Running total of selectors that compiling nested rules for the targets
/// will expand to, checked against [`MAX_SELECTOR_EXPANSION`].
pub selector_expansion_total: u32,
/// Running total of raw `TokenOrValue` nodes that compiling nested rules
/// for the targets will clone, checked against [`MAX_TOKEN_EXPANSION`].
pub token_expansion_total: usize,
}

impl MinifyContext<'_, '_> {
/// Charge `copies * weight` raw `TokenOrValue` nodes against
/// [`MAX_TOKEN_EXPANSION`]. Returns `true` when the cap is exceeded, in
/// which case the caller records a `token_expansion_limit_exceeded`
/// error at its own location.
pub(crate) fn charge_token_expansion(&mut self, copies: u32, weight: usize) -> bool {
if weight == 0 {
return false;
}
self.token_expansion_total = self
.token_expansion_total
.saturating_add((copies as usize).saturating_mul(weight));
self.token_expansion_total > MAX_TOKEN_EXPANSION
}
}
21 changes: 16 additions & 5 deletions src/css/rules/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,18 +390,29 @@ impl<R> StyleRule<R> {
context: &mut MinifyContext<'_, '_>,
) -> Result<(), MinifyErr> {
if context.selector_expansion_multiplier > 1 {
Comment thread
robobun marked this conversation as resolved.
context.selector_expansion_total = context.selector_expansion_total.saturating_add(
context
.selector_expansion_multiplier
.saturating_mul(self.selectors.v.len().max(1)),
);
let copies = context
.selector_expansion_multiplier
.saturating_mul(self.selectors.v.len().max(1));
context.selector_expansion_total =
context.selector_expansion_total.saturating_add(copies);
if context.selector_expansion_total > super::MAX_SELECTOR_EXPANSION {
context.err = Some(crate::error::MinifyError {
kind: crate::error::MinifyErrorKind::selector_expansion_limit_exceeded,
loc: self.loc,
});
return Err(MinifyErr::minify_err);
}
// Same expansion multiplies this rule's unparsed/custom property
// token lists. A large raw value under the selector cap still
// deep-clones into gigabytes of `TokenOrValue`, so budget the
// token payload separately.
if context.charge_token_expansion(copies, self.declarations.token_weight()) {
context.err = Some(crate::error::MinifyError {
kind: crate::error::MinifyErrorKind::token_expansion_limit_exceeded,
loc: self.loc,
});
return Err(MinifyErr::minify_err);
}
}
Ok(())
}
Expand Down
156 changes: 155 additions & 1 deletion test/js/bun/css/nested-selector-list-expansion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import { bunEnv, bunExe, tempDir } from "harness";
// plain `bun build` since the default bundler targets predate `:is()` and
// native nesting. The minifier now bounds the expansion and reports an error.

const { minifyTest, prefixTest } = cssInternals;
const { minifyTest, prefixTest, _test } = cssInternals;

const LIMIT_ERROR = "Nested CSS rules expand to more than";
const TOKEN_LIMIT_ERROR = "raw property tokens when compiled for the configured browser targets";

// `outer` plain-nested two-selector rules, then `atRule`'s block, then `inner`
// more nested two-selector rules. The blocks are left unclosed (the CSS parser
Expand Down Expand Up @@ -263,3 +264,156 @@ test("bun build does not hang on deeply nested multi-selector css spanning @star
expect(exitCode).toBe(1);
expect(await Bun.file(`${dir}/out/input.css`).exists()).toBe(false);
});

// Regression test for unbounded token-list cloning when compiling CSS nesting
// for browser targets that don't support it.
//
// The selector-expansion cap counts the number of rules the expansion
// produces, but each split rule also deep-clones its declarations. An
// unparsed property value (any value the property-specific parser couldn't
// read) is stored as a raw TokenList and copied in full for every clone, so
// a few thousand tokens under ten ::part()-selector nesting levels expanded
// into gigabytes of in-memory tokens while the selector count stayed well
// under its 65,536 cap. Found by fuzzing. The minifier now bounds the total
// cloned-token count and reports an error.

/** `depth` nested ::part() rules with a large unparsed `color:` value at the
* bottom. `::part()` is a pseudo-element so the selector list can never be
* collapsed into `:is()`; each level is split into one cloned rule per
* selector, and the clone carries a full copy of the inner value. */
function nestedWithLargeUnparsedValue(depth: number, tokens: number): string {
// `x ` parses to two tokens (ident + whitespace); the unknown function
// `f(...)` around it keeps the whole thing one raw TokenList.
const payload = Buffer.alloc(tokens * 2, "x ").toString();
return (
"x::part(a), y::part(b) {\n".repeat(depth) + ".inner { color: f(" + payload + "var(--x)) }\n" + "}\n".repeat(depth)
);
}

test("nested selector splits with a large unparsed value error instead of exploding (minify)", () => {
// 8 two-selector levels = 256 copies of a ~6000-token value = ~1.5M tokens,
// past the 1M cap. Before the fix this emitted ~800 KB of output (and at
// slightly larger depths allocated gigabytes before the selector cap was
// reached).
const src = nestedWithLargeUnparsedValue(8, 3000);
expect(() => minifyTest(src, "", OLD_TARGETS)).toThrow(TOKEN_LIMIT_ERROR);
});

test("nested selector splits with a large unparsed value error instead of exploding (prefix)", () => {
const src = nestedWithLargeUnparsedValue(8, 3000);
expect(() => prefixTest(src, "", OLD_TARGETS)).toThrow(TOKEN_LIMIT_ERROR);
});

test("nested selector splits with a large unparsed value error instead of exploding (_test)", () => {
// The fuzzer entrypoint.
const src = nestedWithLargeUnparsedValue(8, 3000);
expect(() => _test(src, "", OLD_TARGETS)).toThrow(TOKEN_LIMIT_ERROR);
});

test("nested selector splits with a large unparsed value below the token limit still compile for old targets", () => {
// 6 levels = 64 copies of ~6000 tokens = ~384K tokens, under the 1M cap.
const src = nestedWithLargeUnparsedValue(6, 3000);
const out = minifyTest(src, "", OLD_TARGETS);
expect(out).toContain("var(--x)");
expect(out.length).toBeLessThan(1_000_000);
});

test("unparsed-value output below the token limit is unchanged by the cap", () => {
// Shallow enough that neither cap applies: the cap must not affect what
// valid expansions emit.
const src = nestedWithLargeUnparsedValue(2, 20);
expect(minifyTest(src, "", OLD_TARGETS)).toMatchInlineSnapshot(
`":is(x::part(a),y::part(b)) x::part(a) .inner{color:f(x x x x x x x x x x x x x x x x x x x x var(--x))}:is(x::part(a),y::part(b)) y::part(b) .inner{color:f(x x x x x x x x x x x x x x x x x x x x var(--x))}"`,
);
});

test("large unparsed values are preserved as-is for targets that support CSS nesting", () => {
// No split, no clone: the input passes through with native nesting intact
// regardless of value size.
const src = nestedWithLargeUnparsedValue(12, 3000);
const out = minifyTest(src, "", MODERN_TARGETS);
expect(out).toContain("var(--x)");
expect(out.length).toBeLessThan(20_000);
});

test("large unparsed values are preserved as-is when no targets are configured", () => {
const src = nestedWithLargeUnparsedValue(12, 3000);
const out = minifyTest(src, "");
expect(out).toContain("var(--x)");
expect(out.length).toBeLessThan(20_000);
});

test("token limit still applies when the large unparsed value sits inside a context-preserving at-rule", () => {
// Same `@starting-style` hiding mechanism as the selector-cap tests above:
// the token charge must follow the multiplier through the at-rule.
const payload = Buffer.alloc(6000, "x ").toString();
const src =
"x::part(a), y::part(b) {\n".repeat(4) +
"@starting-style {\n" +
"x::part(a), y::part(b) {\n".repeat(4) +
".inner { color: f(" +
payload +
"var(--x)) }";
expect(() => minifyTest(src, "", OLD_TARGETS)).toThrow(TOKEN_LIMIT_ERROR);
});

test("token limit covers nested unknown at-rule bodies", () => {
// An unknown at-rule nested inside a style rule stores its block as a raw
// TokenList and is deep-cloned by the same per-selector split, so its
// tokens must count against the cap too (not just declaration values).
const payload = Buffer.alloc(6000, "x ").toString();
const src = "x::part(a), y::part(b) {\n".repeat(8) + "@foo { " + payload + "}";
expect(() => minifyTest(src, "", OLD_TARGETS)).toThrow(TOKEN_LIMIT_ERROR);
});

test("token limit covers nested unknown at-rule preludes", () => {
// Same as above with the payload in the prelude instead of the block.
const payload = Buffer.alloc(6000, "x ").toString();
const src = "x::part(a), y::part(b) {\n".repeat(8) + "@foo " + payload + ";";
expect(() => minifyTest(src, "", OLD_TARGETS)).toThrow(TOKEN_LIMIT_ERROR);
});

test("small nested unknown at-rules below the token limit still compile for old targets", () => {
const src = "x::part(a), y::part(b) {\n".repeat(2) + "@foo a b c { x y z }";
expect(minifyTest(src, "", OLD_TARGETS)).toMatchInlineSnapshot(`"@foo a b c{x y z}@foo a b c{x y z}"`);
});

test("token limit covers env() index lists", () => {
// `env(name i i i ...)` parses an unbounded Vec<i32> of indices that every
// deep_clone reallocates; with the list uncounted the cap could be undershot
// while the cloned Vec<i32> still reached gigabytes. 60,000 indices under
// 8 two-selector levels charges 256 x 60,001 = ~15M > 1M.
const indices = Buffer.alloc(120000, " 1").toString();
const src = "x::part(a), y::part(b) {\n".repeat(8) + ".inner { --foo: env(x" + indices + ") }";
expect(() => minifyTest(src, "", OLD_TARGETS)).toThrow(TOKEN_LIMIT_ERROR);
});

test("bun build reports an error instead of OOMing on deeply nested selectors with a large unparsed value", async () => {
using dir = tempDir("css-token-expansion", {
// 12 levels and a ~6000-token value: before the fix this allocated on the
// order of a gigabyte of cloned TokenOrValue before reaching the selector
// cap.
"input.css": nestedWithLargeUnparsedValue(12, 3000),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "input.css", "--outdir", "out", "--minify"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
// Kill switch for a regression: before the fix this allocated past the
// container's memory budget, so let the child terminate itself instead of
// hanging the runner.
timeout: 20_000,
killSignal: "SIGKILL",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Must terminate on its own (reporting the token-expansion error), not be
// SIGKILLed by the timeout or OOM-killed by the OS.
expect({ signalCode: proc.signalCode, stderr, stdout, exitCode }).toMatchObject({
signalCode: null,
stderr: expect.stringContaining(TOKEN_LIMIT_ERROR),
exitCode: 1,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
expect(await Bun.file(`${dir}/out/input.css`).exists()).toBe(false);
});
Loading