Files
clean-architecture-frontend…/scripts/lib/ci-artifact-validator.ts
T
DongHyeonkaandClaude Opus 5 dfb7734674 fix: run the provider sandbox and admit a release to a named environment
The provider sandbox never ran. bubblewrap 0.9.0 stops parsing an `--args`
file at the first non-option and never hands the remainder back, so the
command written into that file was silently dropped: bwrap printed its usage
text, exited 1, and the provider produced no evidence at all. The options
still travel in the args file — that is what keeps host paths and credentials
out of `/proc/<pid>/cmdline` — but the command now rides on real argv, and
`encodeProviderBwrapInput` refuses a `--` so the drop cannot come back.

The scope wrapper then could not exit. It read the supervisor's liveness pipe
through `fs`, which runs a blocking `read(2)` on a threadpool thread; the
supervisor holds that pipe open for the scope's whole life, so the read never
returned and closing the descriptor did not interrupt it. Once bubblewrap
finished the wrapper deadlocked in `process.exit`, the scope outlived the
provider, and a completed run was reported as a timeout kill. The channel is
now read through the event loop, so teardown is observable and terminal.

Creation modes were left to the ambient umask. `mkdir(mode)` and `open(mode)`
are requests the kernel subtracts the umask from, so a runner exporting a
restrictive umask produced directories it could not enter and handed `tar` a
file it could not re-open. Private modes are pinned instead of inherited.

Promotion cleanup deleted before it checked. Removals run through a pinned
descriptor, so a leaf substituted after validation had this promotion's exact
five destroyed first and the substitution reported afterwards, leaving a
half-emptied directory a retry could not tell from a completed one. The name
is re-bound to the inode before anything is removed, so the failure is total.

Separately, release coherence proved the artifacts agreed with each other but
never that they belonged where they were going: a build whose runtime document
said `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API is coherent with
itself and passed every gate. `public/` is copied verbatim into `dist/`, so
that local document shipped with every build regardless of what the build was
for. Runtime configuration now comes from a declared profile, and FE-GATE-027
refuses to admit an artifact to an environment it does not match — including
refusing an undeclared destination, so nothing is admitted by omission.

`REQUEST_TIMEOUT_MS` and `VITE_ROUTER_BASE_PATH` were validated and then
dropped: the V3 executor ran every operation on its contract's own deadline,
and Vite emitted root-absolute assets for a sub-path deployment. The timeout is
now a ceiling that may tighten a contract but never loosen one, and one base
path feeds the router, the Service Worker scope and the asset base together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:38:19 +09:00

743 lines
27 KiB
TypeScript

import { constants, type Stats } from "node:fs";
import { lstat, open, realpath } from "node:fs/promises";
import path from "node:path";
import { z, type ZodType } from "zod";
import type {
CiGateArtifact,
CiGateArtifactSchema,
} from "../contracts/ci-gates.ts";
import { ciContractReportSchema } from "./ci-contract-report.ts";
import {
architectureDependencyReportArtifactSchema,
automatedA11yArtifactSchema,
buildManifestArtifactSchema,
bundlePerformanceArtifactSchema,
compatibilityFixturesArtifactSchema,
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
designSystemReportArtifactSchema,
diagnosticsReportArtifactSchema,
documentationReviewArtifactSchema,
fieldWebVitalsArtifactSchema,
hostingHeadersArtifactSchema,
i18nReportArtifactSchema,
jsonSchemaDocumentArtifactSchema,
labPerformanceArtifactSchema,
licenseReportArtifactSchema,
manualA11yReportArtifactSchema,
moduleInventoryArtifactSchema,
optionalRecipeFixturesArtifactSchema,
optionalRecipesArtifactSchema,
provenanceArtifactSchema,
realtimeBoundariesArtifactSchema,
registryGovernanceRunArtifactSchema,
registryCompatibilityFixturesArtifactSchema,
registrySnapshotArtifactSchema,
deploymentAdmissionArtifactSchema,
releaseVerificationArtifactSchema,
reproducibleBuildArtifactSchema,
runbookRecordArtifactSchema,
sbomArtifactSchema,
supplyChainFixturesArtifactSchema,
supplyChainProviderFixturesArtifactSchema,
supplyChainVerificationArtifactSchema,
vulnerabilityReportArtifactSchema,
} from "../contracts/release-artifacts.ts";
import { httpScenarioReceiptSchema } from "./http-scenario-evidence.ts";
import { supplyChainCoherenceReportSchema } from "./local-release-evidence.ts";
import {
providerVerificationArtifactSchema,
provenanceProviderAttestationSchema,
vulnerabilityProviderReportSchema,
} from "./provider-evidence.ts";
import { releaseCandidateManifestSchema } from "./release-candidate.ts";
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
import { secretScanSarifSchema } from "./secret-scan-evaluator.ts";
import { testEvidenceReportSchema } from "./test-evidence-artifact.ts";
const coverageCounterSchema = z
.object({
total: z.number().int().nonnegative(),
covered: z.number().int().nonnegative(),
skipped: z.number().int().nonnegative(),
pct: z.number().min(0).max(100),
})
.strict()
.superRefine((counter, context) => {
if (counter.covered + counter.skipped > counter.total) {
context.addIssue({ code: "custom", message: "coverage counter exceeds total" });
}
const expected = counter.total === 0
? 100
: Math.floor((counter.covered / counter.total) * 10_000) / 100;
if (counter.pct !== expected) {
context.addIssue({ code: "custom", path: ["pct"], message: "coverage pct is not exact" });
}
});
const coverageSummarySchema = z
.record(
z.string(),
z
.object({
lines: coverageCounterSchema,
statements: coverageCounterSchema,
functions: coverageCounterSchema,
branches: coverageCounterSchema,
})
.strict(),
)
.refine((value) => "total" in value, "coverage summary lacks total");
const riskCoverageArtifactSchema = z
.object({
schemaVersion: z.literal(3),
policy: z.string().min(1),
summary: z.string().min(1),
status: z.enum(["PASS", "FAIL"]),
selectedTotal: z.number().int().nonnegative(),
repositoryTotal: z.number().int().positive(),
counterBearingTotal: z.number().int().nonnegative(),
instrumentedCounterBearingTotal: z.number().int().nonnegative(),
counterlessTotal: z.number().int().nonnegative(),
counterlessModules: z.array(z.string()),
preExclusionTotal: z.number().int().positive(),
generatedExclusionCount: z.number().int().nonnegative(),
generatedExclusions: z.array(z.string()),
ownershipScope: z.literal("ALL_POLICY_HIGH_RISK"),
ownedHighRiskPaths: z.array(z.string()),
waivedHighRiskPaths: z.array(z.string()),
uncoveredModules: z.array(z.string()),
results: z
.array(
z
.object({
scope: z.string().min(1),
metric: z.enum(["lines", "statements", "functions", "branches"]),
threshold: z.number().min(0).max(100),
received: z.number().min(0).max(100),
passed: z.boolean(),
})
.strict(),
)
.min(4),
failures: z.array(z.string()),
})
.strict()
.superRefine((artifact, context) => {
const fail = (path: PropertyKey[], message: string) =>
context.addIssue({ code: "custom", path, message });
if (artifact.counterBearingTotal + artifact.counterlessTotal !== artifact.repositoryTotal) {
fail(["counterBearingTotal"], "counter partition must equal repositoryTotal");
}
if (artifact.instrumentedCounterBearingTotal > artifact.counterBearingTotal) {
fail(["instrumentedCounterBearingTotal"], "instrumented counters exceed counter-bearing total");
}
if (artifact.counterlessModules.length !== artifact.counterlessTotal) {
fail(["counterlessModules"], "counterless list length drift");
}
if (artifact.generatedExclusions.length !== artifact.generatedExclusionCount) {
fail(["generatedExclusions"], "generated exclusion list length drift");
}
if (
artifact.preExclusionTotal !==
artifact.repositoryTotal + artifact.generatedExclusionCount
) {
fail(["preExclusionTotal"], "pre-exclusion inventory total drift");
}
if (
artifact.selectedTotal > artifact.repositoryTotal ||
artifact.uncoveredModules.length !== artifact.repositoryTotal - artifact.selectedTotal
) {
fail(["selectedTotal"], "selected/uncovered repository totals drift");
}
if (
(artifact.status === "PASS") !==
(artifact.failures.length === 0 && artifact.results.every(({ passed }) => passed))
) {
fail(["status"], "status must agree with failures and threshold results");
}
artifact.results.forEach((result, index) => {
if (result.passed !== (result.received >= result.threshold)) {
fail(["results", index, "passed"], "threshold result is inconsistent");
}
});
for (const [field, values] of [
["counterlessModules", artifact.counterlessModules],
["generatedExclusions", artifact.generatedExclusions],
["ownedHighRiskPaths", artifact.ownedHighRiskPaths],
["waivedHighRiskPaths", artifact.waivedHighRiskPaths],
["uncoveredModules", artifact.uncoveredModules],
] as const) {
if (new Set(values).size !== values.length) fail([field], "path list contains duplicates");
}
const owned = new Set(artifact.ownedHighRiskPaths);
if (artifact.waivedHighRiskPaths.some((modulePath) => owned.has(modulePath))) {
fail(["waivedHighRiskPaths"], "owned and waived high-risk paths overlap");
}
const resultsByScope = new Map<string, Set<string>>();
artifact.results.forEach(({ scope, metric }, index) => {
const metrics = resultsByScope.get(scope) ?? new Set<string>();
if (metrics.has(metric)) {
fail(["results", index, "metric"], "threshold metric is duplicated within scope");
}
metrics.add(metric);
resultsByScope.set(scope, metrics);
});
for (const [scope, metrics] of resultsByScope) {
if (metrics.size !== 4) {
fail(["results"], `threshold scope must contain all four metrics: ${scope}`);
}
}
});
type ExecutableJsonSchemaId = Extract<
CiGateArtifactSchema,
Readonly<{ kind: "json" }>
>["executableSchemaId"];
const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> = Object.freeze({
"automated-a11y": automatedA11yArtifactSchema,
"manual-a11y": manualA11yReportArtifactSchema,
"architecture-dependency-report": architectureDependencyReportArtifactSchema,
"design-system-contract": designSystemReportArtifactSchema,
"i18n-contract": i18nReportArtifactSchema,
"diagnostics-contract": diagnosticsReportArtifactSchema,
"realtime-boundaries": realtimeBoundariesArtifactSchema,
"optional-recipes": optionalRecipesArtifactSchema,
"optional-recipe-fixtures": optionalRecipeFixturesArtifactSchema,
"registry-compatibility-fixtures": registryCompatibilityFixturesArtifactSchema,
"reproducible-build": reproducibleBuildArtifactSchema,
"supply-chain-fixtures": supplyChainFixturesArtifactSchema,
"supply-chain-provider-fixtures": supplyChainProviderFixturesArtifactSchema,
"compatibility-fixtures": compatibilityFixturesArtifactSchema,
"documentation-review": documentationReviewArtifactSchema,
"hosting-headers": hostingHeadersArtifactSchema,
"coverage-summary-v8": coverageSummarySchema,
"risk-coverage-v3": riskCoverageArtifactSchema,
"build-manifest": buildManifestArtifactSchema,
"module-inventory": moduleInventoryArtifactSchema,
"dependency-inventory": dependencyInventoryArtifactSchema,
"registry-snapshot": registrySnapshotArtifactSchema,
"registry-governance-run": registryGovernanceRunArtifactSchema,
"bundle-performance": bundlePerformanceArtifactSchema,
sbom: sbomArtifactSchema,
provenance: provenanceArtifactSchema,
"dependency-diff": dependencyDiffArtifactSchema,
"license-report": licenseReportArtifactSchema,
"vulnerability-report": vulnerabilityReportArtifactSchema,
"field-web-vitals": fieldWebVitalsArtifactSchema,
"lab-performance": labPerformanceArtifactSchema,
"release-verification": releaseVerificationArtifactSchema,
"runbook-record": runbookRecordArtifactSchema,
"supply-chain-verification": supplyChainVerificationArtifactSchema,
"release-candidate": releaseCandidateManifestSchema,
"supply-chain-coherence": supplyChainCoherenceReportSchema,
"http-scenario-receipt": httpScenarioReceiptSchema,
"test-evidence-report": testEvidenceReportSchema,
"provider-vulnerability": vulnerabilityProviderReportSchema,
"provider-provenance": provenanceProviderAttestationSchema,
"provider-verification": providerVerificationArtifactSchema,
"ci-contract-report": ciContractReportSchema,
"deployment-admission": deploymentAdmissionArtifactSchema,
});
export function hasCiArtifactSemanticValidator(
schema: CiGateArtifactSchema,
): boolean {
return schema.kind !== "json" || schema.executableSchemaId in executableJsonSchemas;
}
type ReadHandle = Readonly<{
stat(): Promise<Stats>;
read(
buffer: Buffer,
offset: number,
length: number,
position: number,
): Promise<Readonly<{ bytesRead: number }>>;
close(): Promise<unknown>;
}>;
type ValidatorDependencies = Readonly<{
lstatPath?: typeof lstat;
realpathPath?: typeof realpath;
openFile?: (target: string, flags: number) => Promise<ReadHandle>;
}>;
export async function validateCiArtifact(
input: Readonly<{
root: string;
artifact: CiGateArtifact;
schema: CiGateArtifactSchema;
}>,
dependencies: ValidatorDependencies = {},
): Promise<void> {
const relative = normalizeRepositoryRelativePath(input.artifact.path, "CI artifact path");
assertExtensionCoherence(relative, input.schema.kind);
const bytes = await readBoundedRegularFile(
{ root: input.root, relativePath: relative, maxBytes: input.schema.maxBytes },
dependencies,
);
if (input.schema.kind === "candidate-archive") return;
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
if (!text.trim()) throw new TypeError(`CI artifact is empty: ${relative}`);
switch (input.schema.kind) {
case "text":
return;
case "markdown":
if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`);
return;
case "html":
assertWellFormedHtml(text, relative);
return;
case "junit":
assertWellFormedJUnitXml(text, relative);
return;
case "sarif":
secretScanSarifSchema.parse(JSON.parse(text) as unknown);
return;
case "json-schema":
jsonSchemaDocumentArtifactSchema.parse(JSON.parse(text) as unknown);
return;
case "json": {
const schema = executableJsonSchemas[input.schema.executableSchemaId];
if (!schema) throw new TypeError(`unknown executable artifact schema: ${input.schema.executableSchemaId}`);
schema.parse(JSON.parse(text) as unknown);
return;
}
}
}
export async function readBoundedRegularFile(
input: Readonly<{ root: string; relativePath: string; maxBytes: number }>,
dependencies: ValidatorDependencies = {},
): Promise<Buffer> {
const root = path.resolve(input.root);
const relative = normalizeRepositoryRelativePath(input.relativePath, "bounded file path");
const maxBytes = input.maxBytes;
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 268_435_456) {
throw new RangeError("bounded file maximum must be within 1..268435456");
}
const lstatPath = dependencies.lstatPath ?? lstat;
const realpathPath = dependencies.realpathPath ?? realpath;
const openFile = dependencies.openFile ?? (async (target, flags) => open(target, flags));
const rootMetadata = await lstatPath(root);
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
throw new TypeError("bounded file root is unsafe");
}
const rootRealpath = await realpathPath(root);
let ancestor = root;
const segments = relative.split("/");
for (const segment of segments.slice(0, -1)) {
ancestor = path.join(ancestor, segment);
const metadata = await lstatPath(ancestor);
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new TypeError(`CI artifact ancestor is unsafe: ${relative}`);
}
}
const absolute = path.join(root, relative);
const before = await lstatPath(absolute);
if (before.isSymbolicLink() || !before.isFile()) {
throw new TypeError(`CI artifact is not a regular file: ${relative}`);
}
if (before.size <= 0 || before.size > maxBytes) {
throw new RangeError(`CI artifact size is outside 1..${maxBytes}: ${relative}`);
}
const resolved = await realpathPath(absolute);
const outside = path.relative(rootRealpath, resolved);
if (outside === ".." || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) {
throw new TypeError(`CI artifact escapes repository: ${relative}`);
}
const handle = await openFile(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const opened = await handle.stat();
assertSameIdentity(before, opened, relative);
const bytes = await readHandleBounded(handle, before.size, maxBytes, relative);
const after = await handle.stat();
assertSameIdentity(opened, after, relative);
if (bytes.byteLength <= 0 || bytes.byteLength > maxBytes || after.size !== bytes.byteLength) {
throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`);
}
return bytes;
} finally {
await handle.close();
}
}
async function readHandleBounded(
handle: ReadHandle,
expectedSize: number,
maxBytes: number,
relative: string,
): Promise<Buffer> {
const captured = Buffer.allocUnsafe(Math.min(maxBytes + 1, expectedSize + 1));
let offset = 0;
while (offset < captured.byteLength) {
const { bytesRead } = await handle.read(
captured,
offset,
captured.byteLength - offset,
offset,
);
if (bytesRead === 0) break;
offset += bytesRead;
}
if (offset !== expectedSize) {
throw new RangeError(`CI artifact changed size or exceeds bound: ${relative}`);
}
return captured.subarray(0, offset);
}
const MAX_DOCUMENT_DEPTH = 256;
const MAX_DOCUMENT_UNITS = 100_000;
function assertWellFormedJUnitXml(source: string, relative: string): void {
const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`);
if (/<!DOCTYPE\b|<!ENTITY\b/iu.test(source)) throw invalid();
if (!hasOnlyXmlCharacters(source)) throw invalid();
const stack: string[] = [];
let root: string | undefined;
let rootClosed = false;
let declarationSeen = false;
let units = 0;
let cursor = 0;
while (cursor < source.length) {
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if ((text.includes("]]>") || !hasValidXmlEntities(text)) && text.length > 0) throw invalid();
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<![CDATA[", open)) {
const close = source.indexOf("]]>", open + 9);
if (stack.length === 0 || close < 0) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<?", open)) {
const close = source.indexOf("?>", open + 2);
if (root || close < 0) throw invalid();
const processingInstruction = source.slice(open, close + 2);
const match = /^<\?([A-Za-z_][\w:.-]*)(?:\s+[\s\S]*?)?\?>$/u.exec(
processingInstruction,
);
if (!match) throw invalid();
if (match[1]!.toLowerCase() === "xml") {
if (
declarationSeen ||
source.slice(0, open).trim() ||
!/^<\?xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?\s*\?>$/u.test(
processingInstruction,
)
) {
throw invalid();
}
declarationSeen = true;
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 2;
continue;
}
if (source.startsWith("<!", open)) throw invalid();
const close = markupEnd(source, open + 1);
if (close < 0) throw invalid();
const tag = source.slice(open, close + 1);
const closing = /^<\/([A-Za-z_][\w:.-]*)\s*>$/u.exec(tag);
if (closing) {
if (stack.pop() !== closing[1]) throw invalid();
if (stack.length === 0) rootClosed = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
continue;
}
const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid();
root ??= opening[1];
if (opening[3] !== "/") {
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
stack.push(opening[1]!);
}
else if (stack.length === 0) rootClosed = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
}
if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) {
throw invalid();
}
}
function hasValidXmlAttributes(source: string): boolean {
let remaining = source;
const names = new Set<string>();
while (remaining.length > 0) {
if (!remaining.trim()) return true;
const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/u.exec(remaining);
if (!match || names.has(match[1]!)) return false;
if (!hasValidXmlEntities(match[2] ?? match[3] ?? "")) return false;
names.add(match[1]!);
remaining = remaining.slice(match[0].length);
}
return true;
}
function hasOnlyXmlCharacters(source: string): boolean {
for (const character of source) {
const codePoint = character.codePointAt(0)!;
if (
codePoint !== 0x09 &&
codePoint !== 0x0a &&
codePoint !== 0x0d &&
(codePoint < 0x20 ||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
(codePoint > 0xfffd && codePoint < 0x10000) ||
codePoint > 0x10ffff)
) {
return false;
}
}
return true;
}
function hasValidXmlEntities(source: string): boolean {
let cursor = 0;
while (cursor < source.length) {
const ampersand = source.indexOf("&", cursor);
if (ampersand < 0) return true;
const semicolon = source.indexOf(";", ampersand + 1);
if (semicolon < 0) return false;
const entity = source.slice(ampersand + 1, semicolon);
if (!["amp", "lt", "gt", "apos", "quot"].includes(entity)) {
const decimal = /^#([0-9]+)$/u.exec(entity);
const hexadecimal = /^#x([a-fA-F0-9]+)$/u.exec(entity);
if (!decimal && !hexadecimal) return false;
const codePoint = Number.parseInt((decimal ?? hexadecimal)![1]!, decimal ? 10 : 16);
if (
!Number.isSafeInteger(codePoint) ||
(codePoint !== 0x09 &&
codePoint !== 0x0a &&
codePoint !== 0x0d &&
(codePoint < 0x20 ||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
(codePoint > 0xfffd && codePoint < 0x10000) ||
codePoint > 0x10ffff))
) {
return false;
}
}
cursor = semicolon + 1;
}
return true;
}
const HTML_VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
const HTML_RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
function assertWellFormedHtml(source: string, relative: string): void {
const invalid = () => new TypeError(`invalid HTML artifact: ${relative}`);
if (/<!ENTITY\b|<!DOCTYPE\s+html\s+[^>]*\[/iu.test(source)) throw invalid();
const stack: string[] = [];
let cursor = 0;
let units = 0;
let doctypeSeen = false;
let rootSeen = false;
let rootClosed = false;
let playwrightPayloadSeen = false;
while (cursor < source.length) {
const rawElement = stack.at(-1);
if (rawElement && HTML_RAW_TEXT_ELEMENTS.has(rawElement)) {
const closingStart = source.toLowerCase().indexOf(`</${rawElement}`, cursor);
if (closingStart < 0) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = closingStart;
}
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
const declarationEnd = source.indexOf(">", open + 2);
if (source.slice(open, open + 9).toLowerCase() === "<!doctype") {
if (
declarationEnd < 0 ||
doctypeSeen ||
rootSeen ||
source.slice(open, declarationEnd + 1).toLowerCase() !== "<!doctype html>"
) {
throw invalid();
}
doctypeSeen = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = declarationEnd + 1;
continue;
}
if (source.startsWith("<!", open) || source.startsWith("<?", open)) throw invalid();
const close = markupEnd(source, open + 1);
if (close < 0) throw invalid();
const tag = source.slice(open, close + 1);
const closing = /^<\/([A-Za-z][A-Za-z0-9:-]*)\s*>$/u.exec(tag);
if (closing) {
const name = closing[1]!.toLowerCase();
if (stack.pop() !== name) throw invalid();
if (stack.length === 0) {
if (name === "html") rootClosed = true;
else if (name === "template" && playwrightPayloadSeen) {
// Playwright emits its base64 report template after </html>; HTML5
// reparents this token into the document body. It is the sole
// permitted generated-report sidecar and does not create a new root.
} else {
throw invalid();
}
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
continue;
}
const opening = /^<([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
if (!opening) throw invalid();
const name = opening[1]!.toLowerCase();
const attributes = parseHtmlAttributes(opening[2] ?? "");
if (!attributes || name.includes(":")) throw invalid();
if (!rootSeen) {
if (name !== "html" || opening[3] === "/") throw invalid();
rootSeen = true;
} else if (name === "html") {
throw invalid();
}
if (rootClosed && stack.length === 0) {
if (
playwrightPayloadSeen ||
name !== "template" ||
attributes.get("id") !== "playwrightReportBase64" ||
opening[3] === "/"
) {
throw invalid();
}
playwrightPayloadSeen = true;
}
if (!HTML_VOID_ELEMENTS.has(name) && opening[3] !== "/") {
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
stack.push(name);
} else if (stack.length === 0 && name === "html") {
rootClosed = true;
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
}
if (stack.length > 0 || !rootSeen || !rootClosed) throw invalid();
}
function markupEnd(source: string, start: number): number {
let quote: "\"" | "'" | undefined;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (character === quote) quote = undefined;
} else if (character === "\"" || character === "'") {
quote = character;
} else if (character === ">") {
return index;
}
}
return -1;
}
function parseHtmlAttributes(source: string): ReadonlyMap<string, string> | null {
const attributes = new Map<string, string>();
let cursor = 0;
while (cursor < source.length) {
const whitespace = /^\s+/u.exec(source.slice(cursor));
if (!whitespace) return source.slice(cursor).trim() ? null : attributes;
cursor += whitespace[0].length;
if (cursor >= source.length) return attributes;
const nameMatch = /^[A-Za-z_:][A-Za-z0-9:._-]*/u.exec(source.slice(cursor));
if (!nameMatch) return null;
const name = nameMatch[0].toLowerCase();
if (attributes.has(name) || name.includes(":")) return null;
cursor += nameMatch[0].length;
const spacing = /^\s*/u.exec(source.slice(cursor))![0];
cursor += spacing.length;
let value = "";
if (source[cursor] === "=") {
cursor += 1;
cursor += /^\s*/u.exec(source.slice(cursor))![0].length;
const quote = source[cursor];
if (quote === "\"" || quote === "'") {
const end = source.indexOf(quote, cursor + 1);
if (end < 0) return null;
value = source.slice(cursor + 1, end);
if (value.includes("<")) return null;
cursor = end + 1;
} else {
const unquoted = /^[^\s"'`=<>]+/u.exec(source.slice(cursor));
if (!unquoted) return null;
value = unquoted[0];
cursor += value.length;
}
}
attributes.set(name, value);
}
return attributes;
}
function assertSameIdentity(before: Stats, after: Stats, relative: string): void {
if (
!Number.isSafeInteger(before.dev) ||
!Number.isSafeInteger(before.ino) ||
before.dev <= 0 ||
before.ino <= 0 ||
before.dev !== after.dev ||
before.ino !== after.ino ||
!after.isFile()
) {
throw new TypeError(`CI artifact file identity changed: ${relative}`);
}
}
function assertExtensionCoherence(relative: string, kind: CiGateArtifactSchema["kind"]): void {
const valid =
kind === "json" || kind === "json-schema"
? relative.endsWith(".json")
: kind === "sarif"
? relative.endsWith(".sarif")
: kind === "junit"
? relative.endsWith(".xml")
: kind === "html"
? relative.endsWith(".html")
: kind === "markdown"
? relative.endsWith(".md")
: kind === "candidate-archive"
? relative.endsWith(".tar.gz")
: !/\.(?:json|sarif|xml|html|md|tar\.gz)$/u.test(relative);
if (!valid) throw new TypeError(`CI artifact extension/kind mismatch: ${relative} (${kind})`);
}