Carries eight template commits: the provider sandbox actually running, release
admission to a named environment, the product feature manifest with its runtime
kill switch, architecture and documentation rules that match what is enforced,
the removability fixtures, and the browser, visual and performance evidence.
Product identity is unchanged. `package.json` keeps `tech-log-frontend` and the
catalog keeps the Tech Log naming; the home page was not in the delta. The
visual baselines are this product's own — the template's were excluded from the
transplant and these were regenerated here, where the only difference is the
platform overview's new product-feature section.
What this repository gains operationally: `config/runtime/{local,development,
staging,production}.json` with `FE-GATE-027` refusing an artifact whose runtime
document does not match the environment it is being admitted to, and
`FEATURE_OVERRIDES` for taking an installed feature out of service without a
rebuild.
Verified here: eight gates green, build green, visual 5/5, and 1,858 of 1,859
tests in the suites that do not need a sandbox — the one failure passes in
isolation and is a jsdom lazy-chunk timeout under parallel load. The provider
suites cannot run on this machine at all: `kernel.apparmor_restrict_unprivileged
_userns=1` makes `bwrap --unshare-net` fail, reproducible without any code from
either repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
743 lines
27 KiB
TypeScript
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})`);
|
|
}
|