diff --git a/craft/scripts/e2e-capture-fidelity.mjs b/craft/scripts/e2e-capture-fidelity.mjs index 2404e3a..9261ebc 100644 --- a/craft/scripts/e2e-capture-fidelity.mjs +++ b/craft/scripts/e2e-capture-fidelity.mjs @@ -28,6 +28,7 @@ const PAGE = `
@@ -36,6 +37,9 @@ body{margin:0;background:#ffffff}
+
+
+
`; // [label, x, y, expected rgb] const POINTS = [ @@ -48,6 +52,17 @@ const POINTS = [ ['inline `background: linear-gradient(var(--wsb-accent), ...)`', 640, 550, [0xd9, 0xa2, 0x4a]], ['inline `padding: var(--pad) 0 0 0` keeps the 40px gap (white above the green box)', 640, 620, [0xff, 0xff, 0xff]], ['... and the green box starts below the padding', 640, 670, [0x00, 0xaa, 0x00]], + // A card written `border: ... var(); border-top: ` (the AI's "accent top" card, eval 2026-11-01): + // the CSSOM cannot serialise the overridden var() shorthand, so the left/right/bottom sides were lost. + ...[['inline `border:var()` + `border-top:var()` card', 700, [0xd9, 0xa2, 0x4a]], + ['inline `border:var()` + literal `border-top:#00aa00` card', 780, [0x00, 0xaa, 0x00]], + ['stylesheet `.sheet-card{border:var(); border-top:var()}`', 860, [0xd9, 0xa2, 0x4a]]].flatMap(([label, y0, top]) => [ + [`${label}: top border`, 640, y0 + 7, top], + [`${label}: left border`, 2, y0 + 50, [0xd4, 0x37, 0x2c]], + [`${label}: right border`, 1277, y0 + 50, [0xd4, 0x37, 0x2c]], + [`${label}: bottom border`, 640, y0 + 77, [0xd4, 0x37, 0x2c]], + [`${label}: interior stays white`, 640, y0 + 45, [0xff, 0xff, 0xff]], + ]), ]; const srv = http.createServer((q, r) => { r.setHeader('content-type', 'text/html'); r.end('hosthost'); }).listen(0); @@ -67,6 +82,13 @@ async function run(name, launcher, exe) { // The recovered shorthand goes through the same URL gate: a var() fallback url() to a non-allowed target is dropped. const hostile = await page.evaluate((h) => window.sanitizeCaptureHtml(h), '
x
'); check(name, 'recovered var() shorthand with a disallowed url() fallback is dropped (inline + sheet)', hostile.ok && !/beacon|evil\.example/.test(hostile.html) && /border:\s*1px solid var\(--b\)/.test(hostile.html), hostile.ok ? hostile.html.slice(0, 300) : hostile.reason); + check(name, 'sanitiser keeps an overridden `border: ... var()` before its `border-top` override (inline + sheet)', clean.ok + && /border:\s*6px solid var\(--wsb-border\);\s*border-top:\s*14px solid var\(--wsb-accent\)/.test(clean.html) + && /border:\s*6px solid var\(--wsb-border\);\s*border-top(-width)?:\s*14px/.test(clean.html) + && /\.sheet-card\s*\{[^}]*border:\s*6px solid var\(--wsb-border\);\s*border-top:/.test(clean.html)); + // Recovered-from-source shorthands pass the same URL gate. + const hostile2 = await page.evaluate((h) => window.sanitizeCaptureHtml(h), '
x
'); + check(name, 'overridden var() shorthand with a disallowed url() fallback is still dropped (inline + sheet)', hostile2.ok && !/beacon2|evil\.example/.test(hostile2.html) && /border:\s*1px solid var\(--b\)/.test(hostile2.html), hostile2.ok ? '' : hostile2.reason); const res = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [PAGE, POINTS.map((p) => [p[1], p[2]])]); check(name, 'capture succeeded', res.ok === true, res.ok ? '' : res.reason); if (res.ok) POINTS.forEach(([label, , , want], i) => check(name, label, near(res.samples[i], want), `got rgb(${res.samples[i].join(',')}) want rgb(${want.join(',')})`)); diff --git a/craft/src/utils/sanitize-capture-html.ts b/craft/src/utils/sanitize-capture-html.ts index 417835e..47e01a5 100644 --- a/craft/src/utils/sanitize-capture-html.ts +++ b/craft/src/utils/sanitize-capture-html.ts @@ -167,38 +167,133 @@ const COVER_EXTRA: Record = { 'place-self': /^(align|justify)-self$/, columns: /^column-(width|count)$/, }; +function covers(sh: string, prop: string): boolean { + return prop.startsWith(sh + '-') || !!COVER_EXTRA[sh]?.test(prop); +} function shorthandFor(style: CSSStyleDeclaration, prop: string): string | null { for (const sh of SHORTHANDS) { - if (!(prop.startsWith(sh + '-') || COVER_EXTRA[sh]?.test(prop))) continue; + if (!covers(sh, prop)) continue; const v = style.getPropertyValue(sh); if (v && /var\(/i.test(v)) return sh; } return null; } -function declarations(style: CSSStyleDeclaration, pol: Policy, font = false): string { +/** var() shorthands the CSSOM can no longer serialise, recovered from the source text (shorthand -> declaration). */ +type Recovered = Map; + +/** + * Split a declaration block's SOURCE text into `prop: value` pairs (top-level `;`, strings, parens, comments). + * Only used to find candidates; every candidate is re-parsed by the CSSOM and judged by the normal gate. + */ +function sourceDecls(text: string): Array<[string, string]> { + const out: Array<[string, string]> = []; + let depth = 0; let quote = ''; let start = 0; + const push = (end: number) => { + const d = text.slice(start, end); + const c = d.indexOf(':'); + if (c > 0) out.push([d.slice(0, c).trim().toLowerCase(), d.slice(c + 1).trim()]); + }; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = ''; continue; } + if (ch === '"' || ch === "'") quote = ch; + else if (ch === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; } + else if (ch === '(') depth++; + else if (ch === ')') depth = Math.max(0, depth - 1); + else if (ch === ';' && depth === 0) { push(i); start = i + 1; } + } + push(text.length); + return out; +} + +/** + * The CSSOM keeps a var() shorthand only while ALL its longhands still come from it. Once a later declaration + * overrides one (`border: 1px solid var(--b); border-top: 4px solid var(--a)` -- the AI's accent-top card), + * the shorthand reads back as "" in Chromium and Firefox and the other sides were lost from the capture. + * Recover each such shorthand from the block's source text: the LAST source declaration of it, re-parsed ALONE + * by the CSSOM (must be one valid declaration producing only its own longhands), canonical value kept. + */ +function recoverShorthands(blockText: string, style: CSSStyleDeclaration): Recovered | null { + let rec: Recovered | null = null; + for (const [prop, raw] of sourceDecls(blockText)) { + if (!(SHORTHANDS as readonly string[]).includes(prop) || !/var\(/i.test(raw) || style.getPropertyValue(prop)) continue; + const rules = parseSheet(`a{${prop}:${raw}}`); + if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) continue; + const one = (rules[0] as CSSStyleRule).style; + if ((rules[0] as CSSStyleRule).selectorText.trim() !== 'a' || !one.length) continue; + let own = true; + for (let i = 0; i < one.length; i++) if (!covers(prop, one[i]) && one[i] !== prop) own = false; + const value = one.getPropertyValue(prop); + if (!own || !value) continue; + (rec ??= new Map()).set(prop, { value, important: !!one.getPropertyPriority(prop) }); + } + return rec; +} + +function declarations(style: CSSStyleDeclaration, pol: Policy, font = false, recovered: Recovered | null = null): string { const out: string[] = []; const seenShorthand = new Set(); for (let i = 0; i < style.length; i++) { let prop = style[i]; if (!prop || /[^A-Za-z0-9_-]/.test(prop) || BAD_PROP.test(prop)) continue; let value = style.getPropertyValue(prop); + let pri = style.getPropertyPriority(prop) ? ' !important' : ''; if (!value) { - // A var() shorthand: emit the shorthand once, where its first longhand stood (cascade order kept), - // judged by exactly the same value gate as any other declaration. - const sh = shorthandFor(style, prop); + // A var() shorthand: emit the shorthand once, where its first longhand stood (cascade order kept: the + // CSSOM lists overriding longhands after it), judged by exactly the same value gate as any other declaration. + let sh = shorthandFor(style, prop); + if (sh) { value = style.getPropertyValue(sh); pri = style.getPropertyPriority(sh) ? ' !important' : ''; } + else if (recovered) { + sh = SHORTHANDS.find((s) => recovered.has(s) && covers(s, prop)) ?? null; + if (sh) { const r = recovered.get(sh)!; value = r.value; pri = r.important ? ' !important' : ''; } + } if (!sh || seenShorthand.has(sh)) continue; seenShorthand.add(sh); prop = sh; - value = style.getPropertyValue(sh); } if (!value || !valueOk(value, pol, font || prop === 'src')) continue; - const pri = style.getPropertyPriority(prop) ? ' !important' : ''; out.push(`${prop}:${canonicalize(value, pol, font || prop === 'src')}${pri}`); } return out.join(';'); } +/** Innermost `{...}` blocks of a stylesheet's source text (declaration blocks), in order. */ +function sourceBlocks(css: string): string[] { + const out: string[] = []; + const open: Array<{ at: number; nested: boolean }> = []; + let quote = ''; + for (let i = 0; i < css.length; i++) { + const ch = css[i]; + if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = ''; continue; } + if (ch === '"' || ch === "'") quote = ch; + else if (ch === '/' && css[i + 1] === '*') { const e = css.indexOf('*/', i + 2); i = e < 0 ? css.length : e + 1; } + else if (ch === '{') { if (open.length) open[open.length - 1].nested = true; open.push({ at: i + 1, nested: false }); } + else if (ch === '}') { const b = open.pop(); if (b && !b.nested) out.push(css.slice(b.at, i)); } + } + return out; +} + +/** Style-rule cssText -> recovered shorthands of the source blocks that serialise to it (consumed in order). */ +type RecoverIndex = Map>; +function recoverIndex(css: string): RecoverIndex | null { + if (!/var\(/i.test(css)) return null; + let idx: RecoverIndex | null = null; + for (const block of sourceBlocks(css)) { + if (!/var\(/i.test(block)) continue; + const rules = parseSheet(`a{${block}}`); + if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) continue; + const st = (rules[0] as CSSStyleRule).style; + const rec = recoverShorthands(block, st); + if (!rec) continue; + idx ??= new Map(); + const k = st.cssText; + if (!idx.has(k)) idx.set(k, []); + idx.get(k)!.push(rec); + } + return idx; +} + function parseSheet(css: string): CSSRuleList | null { try { if (typeof CSSStyleSheet !== 'undefined') { @@ -216,14 +311,14 @@ function parseSheet(css: string): CSSRuleList | null { } catch { return null; } } -function rulesToCss(rules: CSSRuleList, pol: Policy): string { +function rulesToCss(rules: CSSRuleList, pol: Policy, idx: RecoverIndex | null = null): string { const out: string[] = []; for (const r of Array.from(rules)) { switch (r.type) { case RULE.STYLE: { const sr = r as CSSStyleRule; if (/\\/.test(sr.selectorText) || /[<>]/.test(sr.selectorText.replace(/[>+~]/g, ''))) break; - const d = declarations(sr.style, pol); + const d = declarations(sr.style, pol, false, idx?.get(sr.style.cssText)?.shift() ?? null); if (d) out.push(`${sr.selectorText}{${d}}`); break; } @@ -236,7 +331,7 @@ function rulesToCss(rules: CSSRuleList, pol: Policy): string { const mr = r as CSSMediaRule; const cond = mr.media.mediaText; if (/[\\{}<]/.test(cond)) break; - const inner = rulesToCss(mr.cssRules, pol); + const inner = rulesToCss(mr.cssRules, pol, idx); if (inner) out.push(`@media ${cond}{${inner}}`); break; } @@ -244,7 +339,7 @@ function rulesToCss(rules: CSSRuleList, pol: Policy): string { const sr = r as CSSRule & { conditionText?: string; cssRules: CSSRuleList }; const cond = sr.conditionText ?? ''; if (!cond || /[\\{}<]/.test(cond)) break; - const inner = rulesToCss(sr.cssRules, pol); + const inner = rulesToCss(sr.cssRules, pol, idx); if (inner) out.push(`@supports ${cond}{${inner}}`); break; } @@ -271,7 +366,7 @@ function rulesToCss(rules: CSSRuleList, pol: Policy): string { /** Sanitise a stylesheet's text through the CSSOM. Empty string when nothing safe is left. */ export function cleanCss(css: string, pol: Policy = { allow: new Set() }): string { const rules = parseSheet(css); - return rules ? rulesToCss(rules, pol) : ''; + return rules ? rulesToCss(rules, pol, recoverIndex(css)) : ''; } /** Sanitise a style="" attribute value; '' when it is not a plain declaration list. */ @@ -280,7 +375,7 @@ export function cleanInlineStyle(value: string, pol: Policy = { allow: new Set() if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) return ''; const sr = rules[0] as CSSStyleRule; if (sr.selectorText.trim() !== 'a') return ''; - return declarations(sr.style, pol); + return declarations(sr.style, pol, false, /var\(/i.test(value) ? recoverShorthands(value, sr.style) : null); } // ---------------------------------------------------------------- HTML