741 lines
27 KiB
TypeScript
741 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,
|
|
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,
|
|
});
|
|
|
|
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})`);
|
|
}
|