diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 3bdf0af56f07..acd092ac952d 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -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() { diff --git a/src/css/declaration.rs b/src/css/declaration.rs index 41b7e5ccce50..0b41021c03c1 100644 --- a/src/css/declaration.rs +++ b/src/css/declaration.rs @@ -91,6 +91,28 @@ impl<'bump> DeclarationBlock<'bump> { self.declarations.len() + self.important_declarations.len() } + /// Recursive `TokenOrValue` count across every unparsed / custom + /// property in this block. Parsed property kinds carry no raw + /// `TokenOrValue` nodes and are not counted here; note that list-typed + /// parsed values (`font-family`, `background-image`, ...) are not + /// fixed-size and have their own clone cost, which this raw-token cap + /// does not budget. 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, + } + } + 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), diff --git a/src/css/error.rs b/src/css/error.rs index 37a850e2ff49..9f5e3115fb4e 100644 --- a/src/css/error.rs +++ b/src/css/error.rs @@ -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. @@ -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"), } } diff --git a/src/css/properties/custom.rs b/src/css/properties/custom.rs index 4251ecf3b610..b96e92aad021 100644 --- a/src/css/properties/custom.rs +++ b/src/css/properties/custom.rs @@ -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` 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(); + } + } + 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() { diff --git a/src/css/rules/mod.rs b/src/css/rules/mod.rs index 4c8e3027fa14..ad8578adfee7 100644 --- a/src/css/rules/mod.rs +++ b/src/css/rules/mod.rs @@ -664,6 +664,29 @@ impl CssRuleList { } 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); + } + } + } _ => {} } @@ -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`. /// @@ -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 + } } diff --git a/src/css/rules/style.rs b/src/css/rules/style.rs index 8474c12b251c..114a6ba6ba8b 100644 --- a/src/css/rules/style.rs +++ b/src/css/rules/style.rs @@ -389,12 +389,12 @@ impl StyleRule { &self, context: &mut MinifyContext<'_, '_>, ) -> Result<(), MinifyErr> { + let copies = context + .selector_expansion_multiplier + .saturating_mul(self.selectors.v.len().max(1)); if context.selector_expansion_multiplier > 1 { - context.selector_expansion_total = context.selector_expansion_total.saturating_add( - 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, @@ -403,6 +403,24 @@ impl StyleRule { return Err(MinifyErr::minify_err); } } + // Same fan-out 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. Gate on `copies > 1` so a flat top-level rule with + // N > 1 selectors that `minify_style_arm` partitions is charged even + // while the enclosing multiplier is still 1, and on + // `should_compile_selectors()` because with no selector compilation + // configured the partition never runs and nothing is cloned. + if copies > 1 + && context.targets.should_compile_selectors() + && 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(()) } diff --git a/test/js/bun/css/nested-selector-list-expansion.test.ts b/test/js/bun/css/nested-selector-list-expansion.test.ts index d4faa3c18a61..9dbbfd513213 100644 --- a/test/js/bun/css/nested-selector-list-expansion.test.ts +++ b/test/js/bun/css/nested-selector-list-expansion.test.ts @@ -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 @@ -263,3 +264,187 @@ 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 of indices that every + // deep_clone reallocates; with the list uncounted the cap could be undershot + // while the cloned Vec 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("token limit covers a flat top-level rule split into many incompatible selectors", () => { + // No nesting (multiplier == 1), but an N-selector list the targets can't + // collapse into `:is()` is still partitioned into N rules that each + // deep-clone the declaration block. Charged as copies = N x W. + // :user-valid is unsupported everywhere; chrome 80 lacks :is(), so no + // collapse. 2000 selectors x ~10,000 tokens = ~20M > 1M. + const sels = Array.from({ length: 2000 }, (_, i) => `.s${i}:user-valid`).join(", "); + const payload = Buffer.alloc(10000, "x ").toString(); + const src = sels + " { --foo: f(" + payload + "var(--x)) }"; + expect(() => minifyTest(src, "", OLD_TARGETS)).toThrow(TOKEN_LIMIT_ERROR); +}); + +test("a flat top-level single-selector rule with a large unparsed value is not charged", () => { + // copies == 1: nothing is cloned, so nothing is charged regardless of W. + const payload = Buffer.alloc(10000, "x ").toString(); + const src = ".s:user-valid { --foo: f(" + payload + "var(--x)) }"; + const out = minifyTest(src, "", OLD_TARGETS); + expect(out).toContain("var(--x)"); +}); + +test("a flat multi-selector rule with a large unparsed value is not charged when no targets are configured", () => { + // No targets: `should_compile_selectors()` is false, `minify_style_arm` + // never partitions, so nothing is cloned and nothing should be charged. + const sels = Array.from({ length: 2000 }, (_, i) => `.s${i}`).join(", "); + const payload = Buffer.alloc(10000, "x ").toString(); + const src = sels + " { --foo: f(" + payload + "var(--x)) }"; + const out = minifyTest(src, ""); + expect(out).toContain("var(--x)"); + expect(out.length).toBeLessThan(src.length + 100); +}); + +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, + }); + expect(await Bun.file(`${dir}/out/input.css`).exists()).toBe(false); +});