diff --git a/changelog.d/gst-940-style-charref.md b/changelog.d/gst-940-style-charref.md
index fb3a46cc6..79927bc62 100644
--- a/changelog.d/gst-940-style-charref.md
+++ b/changelog.d/gst-940-style-charref.md
@@ -1,3 +1,3 @@
### Fixed
-- **Three markup tricks no longer hide CSS in an SVG chart (GST-940).** `</style>` or `<!--` inside a `` let CSS that loads a remote image read as a plain drawing, and a non-ASCII namespace prefix such as `` hid a script. Any of these could get the SVG a light review. The style walker now reads the ``, comment that only an HTML parser ends early, or quoted `>` can hide CSS (GST-940); anything else, a
+entity declaration, and no DOCTYPE but a bare `` or W3C's own SVG DTD, read after character references are decoded. A ``, comment that only an HTML parser ends early, quoted `>` or Unicode space can hide CSS. A non-ASCII character in an element or attribute name, such as a `gé:` prefix, keeps the SVG code too (GST-940); anything else, a
script or `foreignObject` included, keeps it code. More than
2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed`
and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts`
diff --git a/lib/review-depth.ts b/lib/review-depth.ts
index a99689a59..f656c0731 100644
--- a/lib/review-depth.ts
+++ b/lib/review-depth.ts
@@ -88,9 +88,16 @@ const SVG_MARKUP = /^<(?:!--|!\[CDATA\[|\?xml\s|!DOCTYPE\s+svg(?:\s+PUBLIC\s+(["
export const SVG_REFUSED = [/&(?!(?:amp|lt|gt|quot|apos);)/, /\\/, /@import/i, /url\((?!\s*#|['"]\s*#)/i, /expression\s*\(/i, /-moz-binding|\bbehavior\s*:/i];
/** A drawing's CSS, a style element's text or a style attribute's value, holds declarations only: no @ rule and no function but url(#id). */
const SVG_STYLE_OPEN = /<(?:[A-Za-z_][\w.-]*:)?style\b/gi;
-const SVG_STYLE_CLOSE = /^<\/(?:[A-Za-z_][\w.-]*:)?style\s*>/i;
-/** A style element's opening tag, read quote by quote: an attribute value holding < or > refuses, so it cannot hide a closing tag. */
-const SVG_STYLE_TAG = /^<(?:[A-Za-z_][\w.-]*:)?style(?:\s+[\w.:-]+\s*=\s*(?:"[^"<>]*"|'[^'<>]*'))*\s*>/i;
+const SVG_STYLE_CLOSE = /^<\/(?:[A-Za-z_][\w.-]*:)?style[ \t\n\r]*>/i;
+/**
+ * A style element's opening tag, read as quoted name="value" pairs separated by the ASCII whitespace XML and HTML share: any
+ * other shape (a value holding < or >, an unquoted or valueless attribute, />, another space) refuses, so it cannot hide a closing tag.
+ */
+const SVG_STYLE_TAG = /^<(?:[A-Za-z_][\w.-]*:)?style(?:[ \t\n\r]+[\w.:-]+[ \t\n\r]*=[ \t\n\r]*(?:"[^"<>]*"|'[^'<>]*'))*[ \t\n\r]*>/i;
+/** An element name, ended by ASCII whitespace, / or >: a non-ASCII character in the name (a prefix such as gé:) refuses rather than reading as its ASCII start. */
+const SVG_TAG_NAME = /^<\/?(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)(?=[ \t\n\r/>]|$)/;
+/** Every name= in the text, non-ASCII letters included so a prefix such as xé:href is read whole and refused. */
+const SVG_ATTR = /(?=]+)))/g;
const SVG_CSS_REF = /url\(\s*(['"]?)#[\w.:-]*\1\s*\)/gi;
const SVG_CSS_REFUSED = /[@(]/;
/** An href or src value a drawing may hold: a same-document reference, or a raster image inline. */
@@ -99,7 +106,8 @@ const SVG_HREF = /^\s*(?:#|data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+
/**
* Whether an SVG's whole text is a drawing (GST-911): every element in SVG_ELEMENTS, no event handler, every href or src a
* same-document reference or an inline raster image, CSS of declarations only, and nothing SVG_REFUSED names, read after character references are
- * decoded. Every name= in the text is read, so a stray quote cannot hide a later attribute inside its value; an unquoted value
+ * decoded. Every name= in the text is read (SVG_ATTR), so a stray quote cannot hide a later attribute inside its value, and a name
+ * holding a non-ASCII character refuses, like an element name (SVG_TAG_NAME); an unquoted value
* stops at the next =, which keeps the scan linear. A style element's opening tag is read quote by quote (SVG_STYLE_TAG), and its CSS is walked with indexOf to its closing tag over the raw
* text, so a character reference cannot fake a closing tag or a comment, and any & in it refuses (GST-940). Comments and CDATA are
* read as CSS, so neither can hide a closing tag and an HTML-only comment end ( or --!>) exposes nothing unread; any other
@@ -136,10 +144,11 @@ export function svgDrawing(text: string): boolean {
}
for (let at = t.indexOf('<'); at !== -1; at = t.indexOf('<', at + 1)) {
if (SVG_MARKUP.test(t.slice(at, at + 512))) continue;
- const tag = /^<\/?(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/.exec(t.slice(at, at + 256));
+ const tag = SVG_TAG_NAME.exec(t.slice(at, at + 256));
if (!tag || !SVG_ELEMENTS.has(tag[1]!.toLowerCase())) return false;
}
- for (const m of t.matchAll(/(?=]+)))/g)) {
+ for (const m of t.matchAll(SVG_ATTR)) {
+ if (/[^\x00-\x7f]/.test(m[1]!)) return false;
const local = m[1]!.slice(m[1]!.lastIndexOf(':') + 1).toLowerCase();
const value = m[2] ?? m[3] ?? m[4] ?? '';
if (local.startsWith('on') || ((local === 'href' || local === 'src') && !SVG_HREF.test(value)) || (local === 'style' && !cssDrawing(value))) return false;
@@ -150,7 +159,7 @@ export function svgDrawing(text: string): boolean {
/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */
export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({
RISK_RULES, SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, BATCH_SCOPED, SCOPED_RANGE_LINES, ROLE_EFFORT, SVG_ELEMENTS: [...SVG_ELEMENTS],
- patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_STYLE_TAG: [SVG_STYLE_TAG], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
+ patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG, SVG_MARKUP: [SVG_MARKUP], SVG_REFUSED, SVG_HREF: [SVG_HREF], SVG_STYLE_OPEN: [SVG_STYLE_OPEN], SVG_STYLE_CLOSE: [SVG_STYLE_CLOSE], SVG_STYLE_TAG: [SVG_STYLE_TAG], SVG_TAG_NAME: [SVG_TAG_NAME], SVG_ATTR: [SVG_ATTR], SVG_CSS_REF: [SVG_CSS_REF], SVG_CSS_REFUSED: [SVG_CSS_REFUSED] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]),
})).digest('hex').slice(0, 16);
/**
diff --git a/test/review-packets-removed.test.ts b/test/review-packets-removed.test.ts
index d1871e7ef..2cadcc601 100644
--- a/test/review-packets-removed.test.ts
+++ b/test/review-packets-removed.test.ts
@@ -61,7 +61,7 @@ const NO_LOSS_REPLAYS = {
'GST-911': "GST-911 no-loss replay (GSTA-249/13): the planner over the last 100 first-parent main commits to f5cb6ec1e, origin/main's code against this branch, gate scan fixed at 0, changes no commit's depth, class, risk classes, targeted classes or SCOPE_AUTH; the three misreads (a drawing-only SVG, a session-named data file, a bare base) are proven on fixtures (test/review-planner-misreads.test.ts), where every SVG that can run script stays code with the security class; reviewer text is unchanged",
'R51': "R51 risk-tier routing no-loss re-score (GSTA-167): the saved panel findings (349 records over 24 of the 26 no-loss units) re-scored against the branch's plan lose no blocker whose only finders are roles it drops, and every gate, security, user-data and money fixture keeps main's reviewers plus the full panel (test/review-targeted.test.ts, test/review-risk-routing.test.ts); reviewer text is unchanged",
'GST-898': "GST-898 verdict-identity no-loss check: the linear rm rule, and the rmSync rule (which also raises security on any call with more than 500 characters before its closing parenthesis), give the same verdict as R51's on every changed line of the last 100 main commits (69,023 lines, a563f47 back 100 first-parent commits) and on 60,000 seeded lines per rule (test/review-risk-redos.test.ts), and no commit changes depth, class or risk class, so every diff dispatches main's reviewers unchanged; reviewer text is unchanged",
- 'GST-940': "GST-940 verdict no-loss check: svgDrawing reads a style element's opening tag quote by quote, walks its body over the raw text, refuses any & in it and reads comments as CSS; on 62,805 unique SVG files (21,658 with a style element) it gives the same verdict as main at 4bbad2422 on every file, and the planner over the last 100 first-parent main commits to 4bbad2422 changes no commit's depth, class or risk classes; the only verdicts it changes are the GST-940 cases in test/review-planner-misreads.test.ts",
+ 'GST-940': "GST-940 verdict no-loss check: svgDrawing reads a style element's opening tag as quoted pairs with ASCII whitespace, walks its body over the raw text, refuses any & in it, reads comments as CSS and refuses a non-ASCII element or attribute name; on 62,805 unique SVG files (21,658 with a style element) it gives the same verdict as main at 4bbad2422 on every file, and the planner over the last 100 first-parent main commits to 4bbad2422 changes no commit's depth, class or risk classes; the only verdicts it changes are the GST-940 cases in test/review-planner-misreads.test.ts",
} as const;
type Replay = keyof typeof NO_LOSS_REPLAYS;
const REVIEWER_BRIEFS: Record = {
@@ -83,7 +83,7 @@ const REVIEWER_BRIEFS: Record = {
'review/specialists/testing.md': { sha: 'd6cc2bef7be562387d0c0a22fefe646dff9c295f1d34d8453a6cb496584150e4', replay: '#3638' },
'review/specialists/targeted.md': { sha: '7f92a0d0a8319c3c0e076fb3b1a4689c45120b2be2c14dfc7d4cb03aafa9d6a5', replay: 'GST-257' },
'scripts/resolvers/fix-check.ts': { sha: '712bf5b0c439e96bac32f19cbe25394dabb4c94aa66600100bfde9b100e15a08', replay: '#3638' },
- 'lib/review-depth.ts': { sha: 'bd99c178da5fa1352ed4012a8e26c1f57ccef9179ec24d6bd178ed7ca10217cc', replay: 'GST-940' },
+ 'lib/review-depth.ts': { sha: '8b0671cfc0248e3ad9c1d20aef54f948d328f32441a5366af6a2d310de464333', replay: 'GST-940' },
'lib/review-risk.ts': { sha: '126eff0f1de6982e03dc24d8017c67e6807c91dfa518b18d2db3aa910aac911f', replay: 'GST-911' },
};