css: bound the payload cloned when splitting target-incompatible selectors - #31913
css: bound the payload cloned when splitting target-incompatible selectors#31913robobun wants to merge 8 commits into
Conversation
…ctors Splitting a selector list that is incompatible with the browser targets deep-clones the rule's declarations and entire nested-rule subtree once per split-off selector. Under CSS nesting the subtree at each level already contains the clones made at deeper levels, so the cloned payload compounds exponentially with depth. The existing selector-count cap bounds how many rules this produces but not their size, so a few hundred KB of input could clone and print gigabytes while staying under it. Charge a weight estimate (rules, declarations, selector components, raw tokens and their text) against a 64 MB budget before each split and report a minify error past it, mirroring the selector-count cap. Also widen the printer's indent counter from u8 to u32: pretty-printing a valid stylesheet with 128+ nested levels overflowed it, a panic in debug builds.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughThe PR implements a clone-weight budget to prevent CSS minifier pathological expansion when splitting incompatible nested selectors. It adds weight estimation for cloned AST payloads, accumulates weights during minification, enforces an upper limit, and widens the printer indent type to support deeper nesting without overflow. ChangesClone-weight budget mechanism
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Heads up: #31916 fixes the OOM half of this fuzz family (signature |
The weight walk only counted plain ident-like token text, so a dimension token's unit, a dashed ident, or a var()/env()/function name carrying the same input-sized text was charged the flat token constant and bypassed the budget. Count every token variant that stores its text inline. url() stores only an import-record index; its text is not reachable during minify, so like parsed leaf values it stays on the selector-count bound (noted in the module doc).
Custom-property names (both the Custom and Unknown spellings) and selector component text (classes, ids, element and attribute names, attribute values, namespace urls, ::part names, unknown pseudo-class and pseudo-element names, :lang lists) carry input-sized borrowed text that is re-emitted per split clone, but were charged only the flat constants. Moving a large payload from a property value into the property name or a nested rule's selector bypassed the budget. Charge their text lengths. CSS-modules local references print a symbol-table name rather than inline text and keep the flat charge.
…ctors Sweep the remaining inline-text carriers reachable inside a style rule's subtree: view-transition part names, ::cue()/::cue-region() and :local()/:global() wrapped selectors, unknown at-rule names, and the names, preludes, and conditions of every at-rule that can nest inside a style rule (@media queries, @supports conditions, @container names and conditions including style() queries, @layer names, @scope selector lists). Each of these amplified to 33+ MB from ~30 KB of input while charging only the flat constants.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/bun/css/nested-selector-list-expansion.test.ts`:
- Around line 336-349: The test spawns a Bun process into variable proc with
stdout set to "pipe" but never consumes proc.stdout, risking a deadlock; update
the await that collects outputs to concurrently drain stdout as well as stderr
and exit by replacing the Promise.all([...]) that currently only awaits
proc.stderr.text() and proc.exited with one that also awaits proc.stdout.text()
(e.g., Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited()])),
ensuring you still check proc.signalCode and use the captured stdout/stderr as
needed.
- Around line 401-409: The test currently masks all unexpected stderr by using
stderr.includes("panic") ? stderr : "" which hides other failures; update the
expectation to assert a concrete stderr contract instead: replace the
conditional stderr expression (the use of stderr.includes("panic")) so the
expected object checks for the exact stderr value you expect (e.g., an empty
string) and keep the indentedLength and exitCode checks (refer to
indentedLength, parseInt(stdout, 10), exitCode, and stderr.includes in the
existing snippet) so the test fails on any unexpected stderr output rather than
only on "panic".
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 8e8dfc79-605a-43d2-85ff-e11cd2289cd7
📒 Files selected for processing (5)
src/css/css_parser.rssrc/css/error.rssrc/css/printer.rssrc/css/rules/mod.rstest/js/bun/css/nested-selector-list-expansion.test.ts
A namespaced attribute selector ([ns|attr]) stores its prefix and url on the boxed attribute struct, separate from the type-selector namespace components that were already charged; a large prefix bypassed the budget. Charge both fields. Also drain stdout in the bun build clone-limit test.
Same pattern as the clone-limit test fixed in the previous commit.
There was a problem hiding this comment.
All four rounds of clone-weight coverage gaps I flagged are now addressed and the bug hunter found nothing new, but ~400 lines of new heuristic weight-estimation logic in the production CSS minifier path (plus the noted overlap with #31916) is worth a human pass before merging.
Extended reasoning...
Overview
This PR adds a per-stylesheet clone-weight budget (MAX_SELECTOR_SPLIT_CLONE_WEIGHT = 64 MB) to bound the exponential payload duplication that occurs when minify_style_arm splits target-incompatible selector lists under deep CSS nesting. It introduces a ~330-line clone_weight module in src/css/rules/mod.rs that walks rules, declarations, selector components, pseudo-classes/elements, at-rule preludes, and raw token lists to estimate cloned-AST weight; threads a split_clone_weight_total accumulator through MinifyContext; adds a new MinifyErrorKind variant; widens Printer::indent_amt from u8 to u32 to fix a debug-build overflow panic at 128+ nesting levels; and adds ~190 lines of regression tests including a 19-case test.each matrix and an embedded fuzzer input.
Security risks
None introduced. The change is purely defensive — it converts an adversarial-input OOM/hang (449 KB → 1 GB output, 2.1 GB RSS) into a catchable error. Output below the budget is asserted byte-for-byte unchanged. No new parsing, no new external input handling, no auth/crypto/permissions surface.
Level of scrutiny
This warrants human review. The clone_weight module is a heuristic that must enumerate every AST variant carrying input-sized borrowed text; missing one reopens the OOM via that vector. Over four review rounds I found and the author confirmed/fixed gaps for Token::Dimension units, DashedIdent/var()/env() names, custom-property names, selector component text (class/id/type/attribute), view-transition part names, ::cue()/:local()/:global() wrapped selectors, unknown-at-rule names, nestable at-rule preludes, and finally attribute-selector namespace prefixes — the round-3 "full sweep" claim was followed by a round-4 miss. The author has since re-walked struct fields, the test matrix covers each fixed vector, and the bug hunter found nothing this pass, so I believe coverage has converged. But the iteration history itself is the signal that a human should sanity-check the final variant coverage rather than rely solely on bot review.
Other factors
- The minifier runs on every
bun buildwith CSS, so correctness of the "output unchanged below budget" guarantee matters; it is asserted at depth 12 (8,595,441 bytes) plus the existing 1300+ CSS tests pass per the description. - The
u8→u32indent widening and error-variant addition are mechanical and low-risk on their own. - robobun noted PR #31916 touches adjacent lines in
error.rs,rules/mod.rs, andcss_parser.rs; whichever lands second needs a rebase, which a human should coordinate. - No CODEOWNERS entry for
src/css. - All inline comments (mine and CodeRabbit's) are resolved; the one declined CodeRabbit suggestion (strict
stderr === "") was declined with a sound rationale matching established repo conventions.
|
Status summary for reviewers:
|
|
Another fuzzer report landed with the same root cause, signature The 16,483-byte minimized input nests 12 levels of Verified against this branch (patch applied onto main, debug+ASAN): // 5.4 KB in -> 42 MB out, 3.1 GB RSS on main; errors under this branch
const c = require("bun:internal-for-testing").cssInternals;
let inner = ".x { color: f(" + "a ".repeat(2500) + ") }";
for (let i = 0; i < 14; i++) inner = "a::part(h), .b::part(i) { " + inner + " }";
c._test(inner, "", {chrome: 80<<16});Fuzzer repro (gzip+base64)const c = require("bun:internal-for-testing").cssInternals;
const i = Buffer.from(Bun.gunzipSync(Buffer.from("H4sIAAAAAAACA+2b3W7TMBTHdwvSXoArSwipRfNIm35sqdjFPiqNj4ltDITEjZs4qZljB8dt2lWT9ga8ApfwFCAeZc/APTjJythGt3VfwHriC1fn/I9/qe3YjmIvoAVI/10a7Xp76RKuM/S6EmexxrHuc4pZSAJ6UjJeedOT1Z329z7BMwVpnPTjBmGRjJlmUkxYFU+de8C9vMDntIcDJZPL3bIvRToOK6rd9jHft4/w1Fw6Xem81JXMpdgnIeP9W1tl0+cUBlRQxVycVYpZUFBfJ0R5MUJIOk5ElC60KfGoKs6gWV+mNpcKTVXqaUmvX0SDu6O15KTlfHGHqu8fvny5hcrUjAbZyOEgIVVI+DBbFT4TTPdjqnGLS3fbDC0m3EF46Il6DVOA88OXbudOLOWgJZWh4YR5uu3Mw3WBC1moFPV2T6nX0lylUqtXKlbdrlvz1WqpVqqeHWWNJck6BYkj6mqsiJn9HVSl2C5X0CNkI9LREoEMZCA7lF3Ng3f7JGn1mQkpv3BCW9vMzCeyh9NFr4NKjUNnKHdGeeLMiPN3kS496j0odLiKnkqOuH8trjMrNNR1Sq5tMbO/9/mKix13LTuiJ2vaM+9cbIdi4r3rxNkqStDGsOMPs27IRD6SuJJL5aAllOY4ZL0CE4iG7aSFuBQBVWi63aEzL9a3mi+erayl97q4vvW8vrHVXH7JemvGvvZs5dXy1koQb1rNxY0dvbzRbK6vsuTp0uaTzpvXJe72V2urTe0tCesxevgI8ID/K/ipW/X3309y43/dg84PeMADftLw45mhqQA/ufiQqIAJaADAAx7wgAc84P9x/ETXqtNl5oMC9aBvAR7wgAc84AEPeMADHvCABzzgAX8RPGChiwH+hvDIGApWz8+viJnzX/nPID1calsPUMV6UMxltm15NEBDYxHt/trTGJKy+XqHmeBMUGw2TfrMHOmNKTdbnw2+1SHCnO9NzwJij3LSD7Q2xwIHv8Wrg/g/Rh9Tyy5VPpcJThSJDkNzdOOM4OP6UTDicGIOJ7ttxj2qzYGhgfFnlaqotzuyuHtZcdPn3u051B3silUsaBto3nj3W3bZpFwUuUd8WVbwmCMF7+d3GUemZnFko9laxbTVHJotzZcr1arJa/V6qVw8ncZJq2DaFVVrszVkzxcbPwGA/CP/Y0AAAA==", "base64"))).toString("latin1");
c._test(i, "", {chrome: 80<<16}); // ~2.4 GB RSS on main
c.prefixTest(i, "", {chrome: 80<<16}); // ~280 MB RSS on mainNo new PR; this one already covers it. |
|
One more signature from the same fuzz family on 1.4.0-canary.1+55f6c899f, recording for dedup: ( Same shape as the 03:37 comment above: 24,477-byte input with the |
|
Another fuzzer hit with the same root cause, signature The 49,765-byte minimized input is the same shape as above: 12 unclosed // 26 KB in -> 107 MB out, 2.3 GB RSS on main
const tail = ".x { color: xyz(" + "border-right-color: #b32323;\n".repeat(900);
const css = "a::part(p), b::part(q) {\n".repeat(12) + tail;
await Bun.build({ entrypoints: ["./e.css"], files: { "./e.css": css }, target: "browser", throw: false });
|
|
Another fuzzer report landed for this family, signature The new input's shape is a dozen levels of Repro: bun -e 'await Bun.build({entrypoints:["./e.css"], files:{"./e.css": Buffer.from(Bun.gunzipSync(Buffer.from("H4sIAAAAAAACA+2b227bNhiAe7sBeYEBAwgMBeyhTCUfGxnrRQ4GsrVGkzYbBvSGliiZC0UKFGXZMQL0DfYKvWzv+gYF+ii7313v219W3BxWtw7idDn84gWF/8CPh18kDZMPycOTqa/NaQGmS0vzVc9hIJi/HxmdqYCKmEV8Yd8LYP+3JEVqaWrHkn+2sZDOV97K0quI0Xp7Uyj56FpV+MM3hCU6FVZo9bVJp8d65ZsXCgNfuz8QMlhKDe5ck0l9aQZFPFJYG/ILhrVWxaRruPUHZ3TvXuJnf+G01EVoqIXPachiIcdXq5lDLbOYL7HElQUNI664ET6ddg3sIXhoc2aClBCiPS9hxlYGnAXcVO+R1VAXMp8ry02h6etgXCWT7+fbsv9KFvM7tnr/99u3N9CyEJPJdP7wiNImZnKWbatQKGHHKbe0L7W/DxMMuHuEzjTJqAMFeB9C7WffpVpP4BcH0GguAjvwHOIQNxkdfsHEfdBotNqNhtOut521ZtNtuc2veznnMpm2j6UJ9y01DJY3jzQ5rdca5D6pE5ZZTdDsXGb/vHi9kGVzToEb37AVy4mhm2dSdB9ME+VDc97fF/CV6xEtNiQecTvHylgfzNOkUyEt941Dflp7VOhsh3MnP6X+tPGZSnGgLtPk0pYYmAmWXOx5dxhzItnyEeyHxQGnLPgrS6drm+KdWeDPsmEsVDmT+Fpq45ENUuQ0FqOKUITHg7xPpFYRN2RlkPF7T3b2uk8ebfWKuq7v7D1u7+51N5+JUQ/kvUdbv2/ubUXpU6e7vntgN3e73Z1tkf+28fTX7M8/XOmPt1vbXRtsKOcX8vN9xCMe8dcF/zybuK4Dz+FV6Id/37y4SSPx7iVGIuIRj3jEIx7xiEf8MX4dOx/xiEc84q8e/j12PuIRj3jEIx7xiEf85ePh7w/s/1uGhzHH/kc84hGP+CuE//F2tP7EHb7iXBlGG+KvDZ6AoOKMwvJJBFwSKl8jOMv9qu7cJQ3nbrU0q9edgEdkJqySw0+HK2NWi4SiQkmhOIXTm6GAS54pl3AGG/D9jCm48VlcIKMBl2wcWQt3ySYn/M2R/2e9z1jrITeh1DnNDUuOXUt052Y6n7Wf10fMk2x2745buAYzKSPB8OBwbmE/TAtbWfis7Mzu6EyxEdEAkGXE/dSv1yCVRol/SjfNKoHwtJLjso5pAuFAkzpZbTUgwB6QVXet1mg2IW+1226t+mWaZP0KBCNptlZbpL5W7XwEQuIof+k+AAA=", "base64"))).toString("latin1")}, target:"browser", minify:false, sourcemap:"external", throw:false})'Or as a synthetic distilled to the essentials (15 levels of let css = "";
for (let i = 0; i < 15; i++) css += "a::part(x), b::part(y) {\n";
css += "--big: " + "x".repeat(10000) + ";\n" + "}\n".repeat(15);
await Bun.build({ entrypoints: ["./e.css"], files: { "./e.css": css }, target: "browser", throw: false });No new PR opened; this one already covers it. |
A known property name with a value that fails typed parsing (e.g. box-shadow with an arbitrary ident) falls back to UnparsedProperty, whose token list is charged via the Property::Unparsed arm of clone_weight::property. Another fuzzer input (41 KB, 13 nested two-selector rules around a ~40 KB unparsed box-shadow value; signature oom:build:...Vec<TokenOrValue>...) reaches the same clone path through this arm and peaks at 6.3 GB on main; under the budget it reports the expected error.
|
Another fuzzer report with the same root cause, signature The 41,169-byte minimized input nests 13 levels of Verified against this branch (merged onto current main, debug+ASAN): The payload reaches the budget through the Repro: |
|
Another fuzzer finding lands here. Signature Repro (the blob posted to the fuzz channel had one flipped byte; corrected here, CRC-verified against the gzip footer): await Bun.build({
entrypoints: ["./e.css"],
files: {
"./e.css": Buffer.from(
Bun.gunzipSync(
Buffer.from(
"H4sIAAAAAAAC/+2azW4bNxDHe20Av0BPBIoAkmE6u1p9xCskh9gWoDY1bCdOUSAXand2xYRLLriUV7JgwG/QV+ixfYoAfZQ8Q+4JVyv5K1Ecx1KDKrNzoDHzH/7oIUXywMfkMdr/zuaHXt65h2WmLssEzwzNzEgA5QmL4WPJ7fpbwyV2bmv3sAZo1+z9fwhLVcYNVxL3+mUJIgFDGmuV322kkZLFLqzBBP1rsX//wh/NnW2hp9Kx4gHQiCVcjFb37PpCYQwSNA/opCj2OgGRyZkOM0KI8v2UaVPpAwtBVzfIZqQKXwDSgC4iPRWOqmR8b76Wfez5srwL1bs/37xZQWXhJuPJzuETqXTCxKzpyohLbkYZGNoTKnhttxab7hM6i6TDtu3Afx+pYPBjptS4p7Sl0ZyHpu83gHq1OnGImw5PP6NzH9brzVa97rS8lrPVaLhNt3FzlnMryeSfZFkKgaGa2cPMJ9PhPSAeYQOjCMpWU7aYBbR6kqJ8dqMoP5pD7zW3v3M1pMVlxCdu+yKYqJN5kWzipOUV8RiuRqedzm43P+RXwueXnokXJ2qZkqUdMm/P/llwt7e9Y8xZyQaG9i7MT4Cy8NUgm5xuEtqzhT9rjhMuy50kUEJpn2yToqUJH1a4JJD08x4RSsagyVp/ABv7B0ed/ae7e8VYnxwc/dY6POrsPOfDPevfe7r7YudoN86eOZ0nhydm57DTOejy/NftZ78M/vjdFcGo2+x2TLgtnUdk/QHiEY94xCMe8d8Kv75g/Bir/83xPpchGNCWxQxgyZeFhyQ1IxqAEBmuPcQjHvGIRzziEb/K+JTpDLrSfNUwsLqIRzziEY/4ReJfDsau07HfKU7DkvBMVnDdIR7xiEc84hGPeMQjHvGI/37wWOXvD0+so+IMo/JLuQn65Z8xeXv2t+fcJ3XnfrWUeZ4TQkxmzio5PX+amrBazCXlUnAJVPl+xAXQDAQExuJ7AyZ5UjxklzQEwUaxMUpeetqaMD3N/2T2NbU6Bh0JldNcs/QitUS3b0i+rp8HY75gmaFBn4sQjAE9LkuqITyd29lPk87WZk/4r7xYJtMny+1LdZsKNI/7FlVO2c89r2atfE2eBldik6YScl9JMSrHlqW2njT1yGazbmfoIdl0t2r1RsO2zVbLrVXbn6UJ1qvY2SSN5maTeFvV9gchPg7utj8AAA==",
"base64",
),
),
).toString("latin1"),
},
target: "browser",
minify: true,
sourcemap: "external",
throw: false,
});16,310 bytes in, 214 MB out, 3.4 GB RSS on main (release). The shape is ~12 levels of nested Built this branch merged onto main: the clone-weight budget catches it on every pass ( A minimal synthetic that hits the same path, in case it's useful for the test matrix: const pad = Buffer.alloc(10000, "x").toString();
let css = "";
for (let i = 0; i < 14; i++) css += "o::part(h), .foo::part(b) {\n";
css += ".inner { color: unk(" + pad + "); }\n";
for (let i = 0; i < 14; i++) css += "}\n";
// main: 83 MB output; this branch: CLONE_LIMIT_ERROR |
There was a problem hiding this comment.
No new issues found — all four rounds of weight-walk gaps I flagged earlier are resolved and covered by the test matrix. Deferring to a human for sign-off given the size of the new clone_weight AST walk, the documented flat-charge exclusions, and the pending rebase interaction with #31916.
Extended reasoning...
Overview
This PR adds a ~400-line clone_weight module in src/css/rules/mod.rs that walks the CSS AST to estimate the byte-weight of rule subtrees cloned during the incompatible-selector split in minify_style_arm, charging it against a new 64 MB per-stylesheet budget (MAX_SELECTOR_SPLIT_CLONE_WEIGHT). It also adds a new MinifyErrorKind variant, a split_clone_weight_total field on MinifyContext, widens Printer::indent_amt from u8 to u32 to fix a debug-build overflow panic at 128+ nesting levels, and adds ~200 lines of regression tests including a 19-case parameterized matrix and an embedded fuzzer input.
Security risks
The change is itself a DoS hardening: it bounds an exponential clone-amplification path where ~450 KB of adversarial CSS could produce 1+ GB of output and multi-GB RSS through bun build --minify. The fix is purely additive (a pre-clone budget check that returns a catchable error) and uses saturating arithmetic throughout, so no new overflow or injection surface is introduced. The u8→u32 widening is a strict relaxation. No auth, crypto, or filesystem paths are touched.
Level of scrutiny
This warrants human review. The clone_weight walk has to enumerate every AST variant that carries input-sized borrowed text — and over four prior review rounds I found bypasses (dimension units, dashed idents, custom-property names, selector text, view-transition/cue/local wrapped selectors, nested at-rule payloads, attribute namespace prefixes), each of which restored the multi-GB amplification. All were fixed and are now in the test matrix, and the author re-audited every reachable struct field after round 4. But the walk's correctness depends on completeness, the 64 MB threshold and the documented flat-charge exclusions (url() import-record text, CSS-modules locals, parsed leaf values) are design choices, and the change sits in the core minifier hot path.
Other factors
- Test coverage is strong: 52 tests in the file, 22 of which fail on an unfixed build per the author's verification; byte-for-byte output stability below the budget is asserted.
- All prior review threads (mine and CodeRabbit's) are resolved.
- robobun has since logged four more fuzzer signatures (today, 2026-06-17) that this branch already fixes without further changes; the latest commit just adds a
Property::Unparsedcase to the test matrix. - There is a known small rebase needed against #31916 (touches adjacent lines in error.rs / rules/mod.rs / css_parser.rs); whoever merges should be aware.
- CI failures noted in the status summary are an unrelated network-dependent
bunx.test.tsflake; CSS suites pass on every lane that ran them.
…yloads Another fuzz input for the same bug class: ~12 levels of two-selector ::part()-bearing nested rules whose deepest declaration value is an unclosed function token swallowing several KB of trailing text. For a chrome 80 target every nested level splits (no :is() support), cloning the payload-bearing subtree at each level; before the clone-weight budget this produced ~155 MB of output and ~2.3 GB of RSS (the lab() @supports fallback is cloned along with the subtree and accounts for roughly half of that).
|
Pushed 3bd202f with another fuzzer reproduction for the same bug class (signature The 16.5 KB input is ~12 levels of two-selector On main this produces ~155 MB of output and ~2.3 GB of RSS (the The new test OOMs the process on an unfixed build (same shape as the existing minimized-fuzzer test in this file). |
|
Another fuzzer OOM in this class surfaced today, signature The 15 KB minimized input is twelve Verified this branch catches it: the build fails with the split-clone limit error at ~628 MB RSS (debug) instead of OOMing. I had a narrower variant that charges declaration bytes at |
|
Another fuzzer signature in the same family, filed today: Minimized trigger is ~12 levels of const c = require("bun:internal-for-testing").cssInternals;
const blob = Buffer.alloc(8192 * 2, "x ").toString();
const src = "a::part(x), .b::part(y) {\n".repeat(12)
+ ".leaf { color: f(" + blob + ") }";
c.minifyTest(src, "", { chrome: 80 << 16 }); // ~3.2 GB RSSThis PR's |
|
Another fuzzer signature covered by this fix: The 15.5 KB minimized input is ~10 levels of two-selector I independently landed on the same approach on |
When the configured browser targets don't support :is(), minify_style_arm splits a multi-selector rule into one cloned rule per incompatible selector, each carrying a deep_clone of the nested subtree. Minify runs bottom-up, so nesting such rules roughly doubles the cloned subtree at every level. The existing MAX_SELECTOR_EXPANSION (65536) bounds how many rules this produces at print time but not how many CssRule structs sit in the heap while minify is running, so a ~9.5 KB fuzzer input that charges only 61436 selectors still drove peak memory past ~260 MB. Add MAX_PARTITION_CLONE_RULES (16384) and charge each split's subtree rule count against it before cloning. Reuses the existing selector_expansion_limit_exceeded error kind since the remedy is the same. (Superseded by #31913, which weights clones by bytes instead of rule count; kept for comparison.)
|
Another fuzzer signature in this family on 1.4.0-canary.1+55f6c899f: The 9.5 KB minimized input is ~10 levels of Verified this PR's split-clone weight budget catches it: |
|
Another fuzz signature this PR covers: The minimized 16 KB input is ~14 levels of const { cssInternals } = require("bun:internal-for-testing");
let css = ".leaf { color: " + Array.from({length: 1000}, (_, i) => "a" + (i % 10)).join(" ") + "; }";
for (let i = 0; i < 14; i++) css = "a::part(x), b::part(y) { " + css + " }";
cssInternals._test(css, "", { safari: (13 << 16) | (2 << 8) });This PR's |
|
Also fixes a newer fuzzer OOM with signature |
|
Another fuzzer report with the same root cause, signature Repro (50 KB input -> 825 MB let css = '';
for (let i = 0; i < 14; i++) css += 'o::part(a), .f::part(b) {\n';
css += '.leaf { border-right-color: lab(40% 56.6 39); --big: ' + 'x'.repeat(50000) + '; }';
// bun build this.css --minify -> 825 MB14 levels of two-selector I pushed a simpler alternative on |
What does this PR do?
Fixes a CSS minifier hang/OOM family found by fuzzing (signature:
hang:css:...PropertyIdTag::name, an 11.6 KB input that hung or ate 6+ GB on the canary it was found on), and a related debug-build panic.Root cause
When a style rule's selector list is incompatible with the configured browser targets (and cannot be collapsed into
:is()),minify_style_arm(src/css/rules/mod.rs) splits it: each split-off selector gets a deep clone of the rule's declarations and its entire nested-rule subtree:Minify recurses bottom-up, so at each nesting level the subtree already contains the clones split off at deeper levels. With two-selector lists the cloned payload doubles per level: exponential time and memory in nesting depth. Caught live under gdb on the fuzzer input: the stack is
CssRuleList::deep_clonerecursing intoTokenListclones fromminify_style_arm.The existing caps don't bound this.
MAX_SELECTOR_EXPANSION(65,536) counts selectors, not bytes; each cloned rule can carry arbitrarily large token lists. On current main (all caps intact, release build):The same shape reaches the same path through
bun build --minify(default browser targets compile nesting away), so the bundler amplifies identically.Fix
Charge a weight estimate of each split's clones against a per-stylesheet budget,
MAX_SELECTOR_SPLIT_CLONE_WEIGHT = 64 MB, before cloning. The weight walk counts every structure whose count or size scales with user input: rules, declarations, raw token lists with their text (including dimension units and dashed idents), custom-property and var()/env()/function/pseudo names, selector text (classes, ids, element, attribute and namespace names, attribute values, wrapped::cue()/:local()/view-transition selectors), and the names, preludes, and conditions of every at-rule that can nest inside a style rule (@media, @supports, @container, @layer, @scope, unknown at-rules). Past the budget, minification reports a catchable error ("Splitting nested CSS rules with selectors unsupported by the configured browser targets duplicates too much CSS."), mirroring the existingMAX_SELECTOR_EXPANSIONandMAX_PREFIX_EXPANSION_BYTES(#31642) bounds. The weight walk only runs for rules that actually split, so stylesheets that never split pay nothing, and its cost is proportional to what it charges.Also fixes a bug in the same input family: the printer's indent counter was a
u8incremented by 2 per nesting level, so pretty-printing a valid stylesheet with 128+ nested levels overflowed it; that is a panic ("attempt to add with overflow") in debug builds. Widened tou32.Not changed: the
>-combinator token floods from the fuzzer input parse and minify in linear time already (consecutive combinators collapse during selector parsing; raw token lists in unknown declarations round-trip linearly); tests now pin that down.Verification
css.test.ts(1093),nested-selector-list-expansion.test.ts,nested-selector-expansion.test.ts,nested-vendor-prefix-duplication.test.ts,doesnt_crash.test.ts,test/bundler/css/(164),test/bundler/esbuild/css.test.ts(53) all pass.test/js/bun/css/nested-selector-list-expansion.test.ts, including a 19-casetest.eachmatrix moving the payload into every chargeable text carrier. On an unfixed build: the clone-budget tests fail (33 MB / 69 MB outputs are returned instead of the error,bun buildwrites the file), and the indent test fails on unfixed debug builds with the overflow panic.(
css-fuzz.test.tsdebug-build timeouts are pre-existing on main and the file is skipped on CI.)