fix(site-builder): address Task 25 review findings on <style> scoping
Four issues from adversarial review of the block-scoped <style> feature: 1. (Critical) transformBlock() recursed once per @media/@supports/@container nesting level with no cap -- ~7000 nested rules blew the call stack, and nothing between a Custom HTML block's toHtml() and the publish pipeline catches exceptions, so this took down the whole page's publish and crashed the live editor on every keystroke. Added MAX_NESTING_DEPTH=20 (pass the body through unscoped beyond it) and wrapped scopeCss() so it never throws on any input, matching repairOrphanNodes's existing contract. Caught and fixed a variable-shadowing bug in my own first pass at this: the new depth parameter was silently shadowed by a pre-existing `let depth` used for brace-matching in the same block, which would have defeated the cap with no type error. 2. (Important) FORCE_BODY: true was unconditional, but it isn't a no-op for style-free input: it also changes how the parser preserves whitespace after a LEADING html comment, which this repo's own fixture starts with. Verified via a raw byte-diff against HtmlBlock.tsx@6a9b227 (extracted verbatim, run standalone against real dompurify+jsdom) that the fixture gained bytes. Fixed by applying FORCE_BODY only when the input has a real (non-comment) <style> tag to rescue -- confirmed empirically that this is a true no-op for every other input. Pinned the old output as a checked-in regression fixture and added a raw toBe() diff test. 3. (Important) scopeStyleBlocks() wasn't idempotent -- pasting previously published/exported output into a fresh block nested a second wrapper and re-prefixed every selector. Added isAlreadyScoped(), which detects a lone root wrapper whose <style> content is already a no-op under scopeCss for that wrapper's own class (reusing scopeCss's own idempotency guarantee) and leaves it untouched. 4. (Minor) Documented, not fixed: the 32-bit scope-id hash is brute-forceable (CSS-only impact, same trust tier as other accepted risks here), and DOMPurify's SAFE_FOR_XML silently drops an entire <style> block when its content merely looks tag-like (e.g. content: "<Read More>"). 1155/1155 tests passing (was 1141), tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -250,3 +250,102 @@ describe('scopeCss -- misc/edge cases', () => {
|
||||
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
/** Wraps `inner` in `depth` levels of nested `@media`, each with a trivial
|
||||
* always-true-shaped condition. Used to probe/prove the recursion depth cap. */
|
||||
function nestMedia(inner: string, depth: number): string {
|
||||
let css = inner;
|
||||
for (let i = 0; i < depth; i++) css = `@media (min-width: 1px) {${css}}`;
|
||||
return css;
|
||||
}
|
||||
|
||||
describe('scopeCss -- review finding: bounded recursion depth (was: unbounded, crashed on ~7000 nested @media)', () => {
|
||||
test('nesting comfortably under the cap: the innermost selector IS scoped', () => {
|
||||
const input = nestMedia('h1{color:red}', 5);
|
||||
const out = scopeCss(input, SCOPE);
|
||||
expect(out).toContain(`${SCOPE} h1{color:red}`);
|
||||
});
|
||||
|
||||
test('nesting far past the cap does not throw, and stops scoping beyond the cap (unscoped fallback, not a crash)', () => {
|
||||
const input = nestMedia('h1{color:red}', 1000);
|
||||
expect(() => scopeCss(input, SCOPE)).not.toThrow();
|
||||
const out = scopeCss(input, SCOPE);
|
||||
// The innermost rule sits far beyond MAX_NESTING_DEPTH -- it must come
|
||||
// through UNSCOPED (the documented fallback), not silently dropped and
|
||||
// not scoped from some unexpected point.
|
||||
expect(out).not.toContain(SCOPE);
|
||||
expect(out).toContain('h1{color:red}');
|
||||
});
|
||||
|
||||
test('the exact review repro: ~7000 nested @media, ~190KB-shaped input, does not throw', () => {
|
||||
const input = nestMedia('h1{color:red}', 7000);
|
||||
expect(() => scopeCss(input, SCOPE)).not.toThrow();
|
||||
// Structural integrity: every opened @media brace is still closed --
|
||||
// the cap changes WHAT gets scoped, never the brace structure/count.
|
||||
const out = scopeCss(input, SCOPE);
|
||||
const opens = (out.match(/\{/g) || []).length;
|
||||
const closes = (out.match(/\}/g) || []).length;
|
||||
expect(opens).toBe(closes);
|
||||
expect(opens).toBe(7001); // 7000 @media wrapper braces + the innermost rule's own brace pair
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCss -- review finding: never throws, on any input (property test over malformed/adversarial strings)', () => {
|
||||
// Deterministic pseudo-random generator (mulberry32) -- NOT Math.random.
|
||||
// A property test that can flake between CI runs is worse than no
|
||||
// property test: a failure must be reproducible from the fixed seed
|
||||
// below, every time, so it can actually be debugged.
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const ALPHABET = ['{', '}', '(', ')', ';', ',', '"', "'", '@', '/', '*', ':', 'a', 'h1', ' ', '\n', '\\', '<', '>'];
|
||||
|
||||
function randomGarbageCss(rand: () => number, length: number): string {
|
||||
let out = '';
|
||||
while (out.length < length) {
|
||||
out += ALPHABET[Math.floor(rand() * ALPHABET.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('1000 random malformed CSS strings (unbalanced braces, dangling quotes/comments, stray @/,/:) never throw', () => {
|
||||
const rand = mulberry32(42);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const garbage = randomGarbageCss(rand, 1 + Math.floor(rand() * 200));
|
||||
expect(() => scopeCss(garbage, SCOPE)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('specific known-nasty malformed inputs never throw', () => {
|
||||
const nasty = [
|
||||
'{{{{{{{{{{',
|
||||
'}}}}}}}}}}',
|
||||
'{'.repeat(5000),
|
||||
'/*'.repeat(2000),
|
||||
'"'.repeat(2000),
|
||||
'@media'.repeat(2000),
|
||||
'h1'.repeat(50000), // pathologically long single token, no braces at all
|
||||
'',
|
||||
' ',
|
||||
' | ||||