fix(core): bound inert region scans in timing compiler (#3717)

* fix(core): bound inert region scans in timing compiler

* fix(core): use literal search for comment terminators

* fix(core): recognize end-bang HTML comment boundaries
This commit is contained in:
James Russo
2026-09-05 16:40:39 -04:00
committed by GitHub
parent fe2cc92050
commit e78da303f4
2 changed files with 105 additions and 7 deletions
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { JSDOM } from "jsdom";
import { describe, it, expect } from "vitest";
import {
compileTimingAttrs,
@@ -18,6 +19,82 @@ it("source contains no raw NUL bytes", () => {
expect(src.includes("\x00")).toBe(false);
});
describe("inert region scanning", () => {
const media = '<video id="v" data-start="1" data-duration="2">';
it.each([
`<!-- ${media} <!-- nested -->`,
`<ScRiPt type="text/javascript">${media}</sCrIpT \n>`,
`<STYLE>${media}</STYLE\u00a0>`,
`<script-data>${media}</script>`,
`<style:${media}</style>`,
`<!-- <script>${media} -->`,
`<script><!-- ${media}</script>`,
`<style><script>${media}</style>`,
])("preserves the existing complete-region boundaries in %j", (region) => {
const result = compileTimingAttrs(region + media);
expect(result).toEqual({ html: region + compileTimingAttrs(media).html, unresolved: [] });
expect(extractResolvedMedia(region + media)).toEqual(extractResolvedMedia(media));
});
it.each(["<!--", "<script>", "<style>", "<scripture>", "<stylesheet>"])(
"keeps media outside a complete inert region after %j visible",
(prefix) => {
expect(compileTimingAttrs(prefix + media).html).toBe(prefix + compileTimingAttrs(media).html);
expect(extractResolvedMedia(prefix + media)).toEqual(extractResolvedMedia(media));
},
);
it.each(["-->", "--!>"])("recognizes %j as the first comment end like the browser", (end) => {
const hidden = '<video id="hidden" data-duration="1">';
const visible = '<video id="visible" data-start="1" data-duration="2">';
const comment = `<!-- ${hidden} ${end}`;
// The trailing delimiter must not extend the comment over visible media.
const html = comment + visible + " -->";
const dom = new JSDOM(html);
expect([...dom.window.document.querySelectorAll("video")].map((el) => el.id)).toEqual([
"visible",
]);
dom.window.close();
expect(compileTimingAttrs(html)).toEqual({
html: comment + compileTimingAttrs(visible).html + " -->",
unresolved: [],
});
expect(extractResolvedMedia(html).map((el) => el.id)).toEqual(["visible"]);
});
it("closes an end-bang comment without a later standard delimiter", () => {
const comment = '<!-- <video id="hidden" data-duration="1"> --!>';
expect(compileTimingAttrs(comment + media)).toEqual({
html: comment + compileTimingAttrs(media).html,
unresolved: [],
});
expect(extractResolvedMedia(comment + media)).toEqual(extractResolvedMedia(media));
});
it.each(["<!--", "<script", "<style"])(
"handles many unclosed %j prefixes while masking other region kinds",
(prefix) => {
const unclosed = prefix.repeat(100_000);
const hidden = prefix === "<!--" ? `<style>${media}</style>` : `<!--${media}-->`;
expect(compileTimingAttrs(unclosed + hidden + media)).toEqual({
html: unclosed + hidden + compileTimingAttrs(media).html,
unresolved: [],
});
expect(extractResolvedMedia(unclosed + hidden + media)).toEqual(extractResolvedMedia(media));
},
);
it("uses the first closing delimiter and resumes scanning after it", () => {
const hidden = `<script>${media}</script>`;
const html = hidden + media + `</script><!--${media}--><style>${media}</style>`;
expect(compileTimingAttrs(html).html).toBe(
hidden + compileTimingAttrs(media).html + `</script><!--${media}--><style>${media}</style>`,
);
expect(extractResolvedMedia(html)).toEqual(extractResolvedMedia(media));
});
});
describe("compileTimingAttrs", () => {
it.each(["", " ", "0s", "0abc", "0px", "-1s", "Infinity", "NaN"])(
"does not partially parse invalid literal data-duration=%j",
+28 -7
View File
@@ -113,18 +113,39 @@ function setAttr(tag: string, attr: string, value: string): string {
// `<video>`/`<audio>` gets rewritten as if it were a real element (issue #1938).
// Mask those inert regions with placeholders (no `<`, so the tag regexes skip
// them) before scanning, then restore them verbatim.
const INERT_REGION_RE =
/<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>/gi;
// The NUL delimiters must stay as \u0000 escapes: raw 0x00 bytes make this file
// binary to git and are corrupted by Bun's transpiler when bundled (issue #2139).
function maskInertRegions(html: string): { masked: string; restore: (s: string) => string } {
const stash: string[] = [];
const masked = html.replace(INERT_REGION_RE, (region) => {
const parts: string[] = [];
const opening = /<!--|<script\b|<style\b/gi;
const closings = new Map([
["<!--", /--!?>/g],
["<script", /<\/script\s*>/gi],
["<style", /<\/style\s*>/gi],
]);
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = opening.exec(html)) !== null) {
const kind = match[0].toLowerCase();
const closing = closings.get(kind);
if (!closing) continue;
closing.lastIndex = opening.lastIndex;
const end = closing.exec(html) ? closing.lastIndex : -1;
if (end < 0) {
// No later opener of this kind can close either. Search each unmatched
// suffix only once, while still allowing other kinds of inert regions.
closings.delete(kind);
continue;
}
const token = `\u0000HFMASK${stash.length}\u0000`;
stash.push(region);
return token;
});
parts.push(html.slice(cursor, match.index), token);
stash.push(html.slice(match.index, end));
cursor = end;
opening.lastIndex = cursor;
}
parts.push(html.slice(cursor));
const masked = parts.join("");
const restore = (s: string): string =>
// oxlint-disable-next-line no-control-regex -- NUL cannot appear in HTML, which is what makes it a safe mask delimiter
s.replace(/\u0000HFMASK(\d+)\u0000/g, (_, i) => stash[Number(i)] ?? "");