refactor: adapter 구현중..
This commit is contained in:
@@ -10,21 +10,37 @@ import type {
|
||||
} 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";
|
||||
@@ -40,10 +56,6 @@ import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"
|
||||
import { secretScanSarifSchema } from "./secret-scan-evaluator.ts";
|
||||
import { testEvidenceReportSchema } from "./test-evidence-artifact.ts";
|
||||
|
||||
const jsonObjectSchema = z.record(z.string(), z.json()).refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
"generic JSON artifact must be a non-empty object",
|
||||
);
|
||||
const coverageCounterSchema = z
|
||||
.object({
|
||||
total: z.number().int().nonnegative(),
|
||||
@@ -184,7 +196,22 @@ type ExecutableJsonSchemaId = Extract<
|
||||
>["executableSchemaId"];
|
||||
|
||||
const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> = Object.freeze({
|
||||
"generic-json-object": jsonObjectSchema,
|
||||
"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,
|
||||
@@ -213,6 +240,12 @@ const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> =
|
||||
"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(
|
||||
@@ -253,9 +286,7 @@ export async function validateCiArtifact(
|
||||
if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`);
|
||||
return;
|
||||
case "html":
|
||||
if (!/^\s*(?:<!doctype\s+html\s*>\s*)?<html\b[^>]*>[\s\S]*<\/html\s*>\s*$/iu.test(text)) {
|
||||
throw new TypeError(`invalid HTML artifact: ${relative}`);
|
||||
}
|
||||
assertWellFormedHtml(text, relative);
|
||||
return;
|
||||
case "junit":
|
||||
assertWellFormedJUnitXml(text, relative);
|
||||
@@ -355,51 +386,85 @@ async function readHandleBounded(
|
||||
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;
|
||||
}
|
||||
const close = source.indexOf(">", open + 1);
|
||||
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] !== "/") stack.push(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")) {
|
||||
@@ -412,14 +477,236 @@ function hasValidXmlAttributes(source: string): boolean {
|
||||
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);
|
||||
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) ||
|
||||
|
||||
Reference in New Issue
Block a user