refactor: generate CI workflow from gate contracts
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
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 {
|
||||
buildManifestArtifactSchema,
|
||||
bundlePerformanceArtifactSchema,
|
||||
dependencyDiffArtifactSchema,
|
||||
dependencyInventoryArtifactSchema,
|
||||
fieldWebVitalsArtifactSchema,
|
||||
jsonSchemaDocumentArtifactSchema,
|
||||
labPerformanceArtifactSchema,
|
||||
licenseReportArtifactSchema,
|
||||
moduleInventoryArtifactSchema,
|
||||
provenanceArtifactSchema,
|
||||
registryGovernanceRunArtifactSchema,
|
||||
registrySnapshotArtifactSchema,
|
||||
releaseVerificationArtifactSchema,
|
||||
runbookRecordArtifactSchema,
|
||||
sbomArtifactSchema,
|
||||
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 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(),
|
||||
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({
|
||||
"generic-json-object": jsonObjectSchema,
|
||||
"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,
|
||||
});
|
||||
|
||||
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":
|
||||
if (!/^\s*(?:<!doctype\s+html\s*>\s*)?<html\b[^>]*>[\s\S]*<\/html\s*>\s*$/iu.test(text)) {
|
||||
throw new TypeError(`invalid HTML artifact: ${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);
|
||||
}
|
||||
|
||||
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();
|
||||
const stack: string[] = [];
|
||||
let root: string | undefined;
|
||||
let rootClosed = false;
|
||||
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 (open < 0) break;
|
||||
if (source.startsWith("<!--", open)) {
|
||||
const close = source.indexOf("-->", open + 4);
|
||||
if (close < 0 || source.slice(open + 4, close).includes("--")) 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();
|
||||
cursor = close + 3;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("<?", open)) {
|
||||
const close = source.indexOf("?>", open + 2);
|
||||
if (root || close < 0) throw invalid();
|
||||
cursor = close + 2;
|
||||
continue;
|
||||
}
|
||||
const close = source.indexOf(">", 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;
|
||||
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]!);
|
||||
else if (stack.length === 0) rootClosed = true;
|
||||
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;
|
||||
names.add(match[1]!);
|
||||
remaining = remaining.slice(match[0].length);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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})`);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const CANDIDATE_ARCHIVE_USAGE =
|
||||
"Usage: verify-ci-candidate-archive --archive <path> [--extract-to <path>] [--github-output <path>]\n";
|
||||
|
||||
export function parseCandidateArchiveArguments(arguments_: readonly string[]): Readonly<{
|
||||
archivePath: string;
|
||||
extractTo?: string;
|
||||
githubOutput?: string;
|
||||
}> | null {
|
||||
const allowed = new Set(["--archive", "--extract-to", "--github-output"]);
|
||||
const values = new Map<string, string>();
|
||||
for (let index = 0; index < arguments_.length; index += 2) {
|
||||
const flag = arguments_[index];
|
||||
const value = arguments_[index + 1];
|
||||
if (!flag || !allowed.has(flag) || values.has(flag) || !value || value.startsWith("--")) {
|
||||
return null;
|
||||
}
|
||||
values.set(flag, value);
|
||||
}
|
||||
const archivePath = values.get("--archive");
|
||||
if (!archivePath) return null;
|
||||
return Object.freeze({
|
||||
archivePath,
|
||||
...(values.has("--extract-to") ? { extractTo: values.get("--extract-to")! } : {}),
|
||||
...(values.has("--github-output") ? { githubOutput: values.get("--github-output")! } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
distSha256,
|
||||
releaseCandidateManifestSchema,
|
||||
type ReleaseCandidateManifest,
|
||||
} from "./release-candidate.ts";
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
import {
|
||||
assertSafePublishLeaf,
|
||||
ensureSafePublishDirectory,
|
||||
} from "./ci-gate-log.ts";
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 268_435_456;
|
||||
const MAX_CANDIDATE_FILES = 4_096;
|
||||
const MAX_ARCHIVE_MEMBERS = 8_192;
|
||||
const MAX_MEMBER_PATH_BYTES = 1_024;
|
||||
const TAR_EXECUTABLE = "/usr/bin/tar";
|
||||
const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" });
|
||||
|
||||
export async function verifyCiCandidateArchive(
|
||||
input: Readonly<{
|
||||
archivePath: string;
|
||||
expectedSha256?: string;
|
||||
extractTo?: string;
|
||||
repositoryRoot?: string;
|
||||
}>,
|
||||
dependencies: Readonly<{ afterArchiveRead?: () => Promise<void> }> = {},
|
||||
): Promise<Readonly<{
|
||||
archiveSha256: string;
|
||||
memberCount: number;
|
||||
manifest: ReleaseCandidateManifest;
|
||||
}>> {
|
||||
if (input.expectedSha256 && !/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
|
||||
throw new TypeError("expected candidate archive SHA-256 is invalid");
|
||||
}
|
||||
const absolute = path.resolve(input.archivePath);
|
||||
const before = await lstat(absolute);
|
||||
if (!before.isFile() || before.isSymbolicLink()) {
|
||||
throw new TypeError("candidate archive must be a regular non-symlink file");
|
||||
}
|
||||
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
|
||||
}
|
||||
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
let archive: Buffer;
|
||||
try {
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
archive = await readCapturedArchive(handle, before.size);
|
||||
assertSameIdentity(before, await handle.stat());
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
if (archive.byteLength !== before.size) {
|
||||
throw new Error("candidate archive changed size during capture");
|
||||
}
|
||||
await dependencies.afterArchiveRead?.();
|
||||
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
|
||||
if (input.expectedSha256 && archiveSha256 !== input.expectedSha256) {
|
||||
throw new Error("candidate archive SHA-256 mismatch");
|
||||
}
|
||||
|
||||
const extractionTarget = input.extractTo ? path.resolve(input.extractTo) : undefined;
|
||||
let extractionRoot: string;
|
||||
let extractionParentIdentity: Awaited<ReturnType<typeof ensureSafePublishDirectory>> | undefined;
|
||||
if (extractionTarget) {
|
||||
if (!input.repositoryRoot) {
|
||||
throw new TypeError("repositoryRoot is required when publishing an extracted candidate");
|
||||
}
|
||||
const repositoryRoot = path.resolve(input.repositoryRoot);
|
||||
extractionParentIdentity = await ensureSafePublishDirectory(
|
||||
repositoryRoot,
|
||||
path.dirname(extractionTarget),
|
||||
);
|
||||
await assertSafePublishLeaf(extractionTarget, input.extractTo);
|
||||
extractionRoot = await mkdtemp(
|
||||
path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`),
|
||||
);
|
||||
} else {
|
||||
extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-"));
|
||||
}
|
||||
let published = false;
|
||||
try {
|
||||
const captured = await materializeCapturedArchive(archive);
|
||||
try {
|
||||
const preflightManifest = preflightArchiveHandle(captured.handle);
|
||||
extractArchiveHandle(captured.handle, extractionRoot);
|
||||
const verified = await verifyExtractedTree(extractionRoot, preflightManifest);
|
||||
if (extractionTarget) {
|
||||
const repositoryRoot = path.resolve(input.repositoryRoot!);
|
||||
const currentParentIdentity = await ensureSafePublishDirectory(
|
||||
repositoryRoot,
|
||||
path.dirname(extractionTarget),
|
||||
);
|
||||
if (
|
||||
!extractionParentIdentity ||
|
||||
extractionParentIdentity.dev <= 0 ||
|
||||
extractionParentIdentity.ino <= 0 ||
|
||||
currentParentIdentity.dev !== extractionParentIdentity.dev ||
|
||||
currentParentIdentity.ino !== extractionParentIdentity.ino
|
||||
) {
|
||||
throw new Error("verified extraction parent identity changed");
|
||||
}
|
||||
await assertSafePublishLeaf(extractionTarget, input.extractTo);
|
||||
if (await pathExists(extractionTarget)) {
|
||||
throw new Error(`verified extraction target already exists: ${input.extractTo}`);
|
||||
}
|
||||
await rename(extractionRoot, extractionTarget);
|
||||
published = true;
|
||||
}
|
||||
return Object.freeze({
|
||||
archiveSha256,
|
||||
memberCount: verified.memberCount,
|
||||
manifest: verified.manifest,
|
||||
});
|
||||
} finally {
|
||||
await captured.handle.close();
|
||||
await rm(captured.root, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
if (!published) await rm(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyCapturedCiCandidateArchive(
|
||||
archive: Buffer,
|
||||
expectedSha256: string,
|
||||
dependencies: Readonly<{
|
||||
verifyExtracted?: (
|
||||
extractionRoot: string,
|
||||
manifest: ReleaseCandidateManifest,
|
||||
) => Promise<void>;
|
||||
}> = {},
|
||||
): Promise<Readonly<{
|
||||
archiveSha256: string;
|
||||
memberCount: number;
|
||||
manifest: ReleaseCandidateManifest;
|
||||
}>> {
|
||||
if (archive.byteLength <= 0 || archive.byteLength > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) {
|
||||
throw new TypeError("expected candidate archive SHA-256 is invalid");
|
||||
}
|
||||
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
|
||||
if (archiveSha256 !== expectedSha256) {
|
||||
throw new Error("candidate archive SHA-256 mismatch");
|
||||
}
|
||||
const captured = await materializeCapturedArchive(archive);
|
||||
const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-"));
|
||||
try {
|
||||
const manifest = preflightArchiveHandle(captured.handle);
|
||||
extractArchiveHandle(captured.handle, extractionRoot);
|
||||
const verified = await verifyExtractedTree(extractionRoot, manifest);
|
||||
await dependencies.verifyExtracted?.(extractionRoot, verified.manifest);
|
||||
return Object.freeze({
|
||||
archiveSha256,
|
||||
memberCount: verified.memberCount,
|
||||
manifest: verified.manifest,
|
||||
});
|
||||
} finally {
|
||||
await rm(extractionRoot, { recursive: true, force: true });
|
||||
await captured.handle.close();
|
||||
await rm(captured.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateManifest {
|
||||
const listed = spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
["--list", "--verbose", "--numeric-owner", "--full-time", "--gzip", "--file", "/proc/self/fd/3"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16_777_216,
|
||||
timeout: 10_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
);
|
||||
if (listed.status !== 0 || listed.signal || listed.error) {
|
||||
throw new Error(
|
||||
`candidate archive listing failed: ${listed.stderr || listed.error?.message || listed.signal}`,
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const regularMembers = new Set<string>();
|
||||
const directoryMembers = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
const lines = listed.stdout.split(/\r?\n/u).filter(Boolean);
|
||||
if (lines.length === 0 || lines.length > MAX_ARCHIVE_MEMBERS) {
|
||||
throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`);
|
||||
}
|
||||
for (const line of lines) {
|
||||
const match = /^(?<mode>.{10})\s+\d+\/\d+\s+(?<bytes>\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:\s+[+-]\d{4})?\s+(?<path>.+)$/u.exec(line);
|
||||
if (!match?.groups) throw new Error(`candidate archive listing is unparseable: ${line}`);
|
||||
const member = match.groups.path!.endsWith("/")
|
||||
? match.groups.path!.slice(0, -1)
|
||||
: match.groups.path!;
|
||||
assertSafeMemberPath(member);
|
||||
if (seen.has(member)) throw new Error(`candidate archive duplicate member: ${member}`);
|
||||
seen.add(member);
|
||||
const mode = match.groups.mode!;
|
||||
if (!mode.startsWith("-") && !mode.startsWith("d")) {
|
||||
throw new Error(`candidate archive contains non-regular member: ${member}`);
|
||||
}
|
||||
if (mode.startsWith("-")) {
|
||||
const memberBytes = Number(match.groups.bytes);
|
||||
if (
|
||||
member === RELEASE_CANDIDATE_MANIFEST_PATH &&
|
||||
memberBytes > 8_388_608
|
||||
) {
|
||||
throw new RangeError("candidate manifest exceeds 8388608 bytes");
|
||||
}
|
||||
totalBytes += memberBytes;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError("candidate archive expanded bytes exceed the bound");
|
||||
}
|
||||
regularMembers.add(member);
|
||||
} else {
|
||||
directoryMembers.add(member);
|
||||
}
|
||||
}
|
||||
const manifest = readManifestFromArchive(archiveHandle);
|
||||
validateManifestSemantics(manifest);
|
||||
const expectedFiles = new Set([
|
||||
...manifest.files.map(({ path: member }) => member),
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
]);
|
||||
for (const member of expectedFiles) assertSafeMemberPath(member);
|
||||
const expectedDirectories = new Set(
|
||||
directoryAncestors([...expectedFiles]).filter(
|
||||
(member) => member === "dist" || member.startsWith("dist/"),
|
||||
),
|
||||
);
|
||||
if (
|
||||
JSON.stringify([...regularMembers].sort(asciiCompare)) !==
|
||||
JSON.stringify([...expectedFiles].sort(asciiCompare)) ||
|
||||
JSON.stringify([...directoryMembers].sort(asciiCompare)) !==
|
||||
JSON.stringify([...expectedDirectories].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("candidate archive exact member set drift before extraction");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void {
|
||||
const extracted = spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
[
|
||||
"--extract",
|
||||
"--gzip",
|
||||
"--file",
|
||||
"/proc/self/fd/3",
|
||||
"--directory",
|
||||
extractionRoot,
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1_048_576,
|
||||
timeout: 30_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
);
|
||||
if (extracted.status !== 0 || extracted.signal || extracted.error) {
|
||||
throw new Error(
|
||||
`candidate archive isolated extraction failed: ${extracted.stderr || extracted.error?.message || extracted.signal}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateManifestSemantics(manifest: ReleaseCandidateManifest): void {
|
||||
if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) {
|
||||
throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`);
|
||||
}
|
||||
const canonicalFiles = [...manifest.files].sort((left, right) =>
|
||||
asciiCompare(left.path, right.path),
|
||||
);
|
||||
if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) {
|
||||
throw new Error("candidate manifest files are not in canonical ASCII order");
|
||||
}
|
||||
const expectedFiles = new Map<string, Readonly<{ bytes: number; sha256: string }>>();
|
||||
let declaredBytes = 0;
|
||||
for (const file of manifest.files) {
|
||||
assertSafeMemberPath(file.path);
|
||||
if (expectedFiles.has(file.path)) {
|
||||
throw new Error(`candidate manifest duplicate file: ${file.path}`);
|
||||
}
|
||||
declaredBytes += file.bytes;
|
||||
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError("candidate manifest declared bytes exceed the archive bound");
|
||||
}
|
||||
expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 });
|
||||
}
|
||||
const evidencePaths = [...expectedFiles.keys()]
|
||||
.filter((member) => !member.startsWith("dist/"))
|
||||
.sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(evidencePaths) !==
|
||||
JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("candidate manifest evidence member set drift");
|
||||
}
|
||||
const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/"));
|
||||
if (distFiles.length === 0) throw new Error("candidate manifest has no dist files");
|
||||
const lockfile = expectedFiles.get("pnpm-lock.yaml");
|
||||
if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) {
|
||||
throw new Error("candidate manifest lockfile digest summary mismatch");
|
||||
}
|
||||
if (
|
||||
distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !==
|
||||
manifest.distSha256
|
||||
) {
|
||||
throw new Error("candidate manifest dist digest summary mismatch");
|
||||
}
|
||||
if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) {
|
||||
throw new Error("candidate manifest bundle digest summary mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyExtractedTree(
|
||||
extractionRoot: string,
|
||||
preflightManifest: ReleaseCandidateManifest,
|
||||
): Promise<Readonly<{ memberCount: number; manifest: ReleaseCandidateManifest }>> {
|
||||
const entries = await walkExtractedTree(extractionRoot);
|
||||
if (entries.length === 0 || entries.length > MAX_ARCHIVE_MEMBERS) {
|
||||
throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`);
|
||||
}
|
||||
const manifest = releaseCandidateManifestSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(path.join(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
|
||||
) as unknown,
|
||||
);
|
||||
if (JSON.stringify(manifest) !== JSON.stringify(preflightManifest)) {
|
||||
throw new Error("candidate manifest changed between preflight and extraction");
|
||||
}
|
||||
if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) {
|
||||
throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`);
|
||||
}
|
||||
const canonicalFiles = [...manifest.files].sort((left, right) =>
|
||||
asciiCompare(left.path, right.path),
|
||||
);
|
||||
if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) {
|
||||
throw new Error("candidate manifest files are not in canonical ASCII order");
|
||||
}
|
||||
const expectedFiles = new Map<string, Readonly<{ bytes: number; sha256: string }>>();
|
||||
let declaredBytes = 0;
|
||||
for (const file of manifest.files) {
|
||||
assertSafeMemberPath(file.path);
|
||||
if (expectedFiles.has(file.path)) throw new Error(`candidate manifest duplicate file: ${file.path}`);
|
||||
declaredBytes += file.bytes;
|
||||
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) {
|
||||
throw new RangeError("candidate manifest declared bytes exceed the archive bound");
|
||||
}
|
||||
expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 });
|
||||
}
|
||||
const evidencePaths = [...expectedFiles.keys()]
|
||||
.filter((member) => !member.startsWith("dist/"))
|
||||
.sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(evidencePaths) !==
|
||||
JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("candidate manifest evidence member set drift");
|
||||
}
|
||||
const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/"));
|
||||
if (distFiles.length === 0) throw new Error("candidate manifest has no dist files");
|
||||
const lockfile = expectedFiles.get("pnpm-lock.yaml");
|
||||
if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) {
|
||||
throw new Error("candidate manifest lockfile digest summary mismatch");
|
||||
}
|
||||
if (
|
||||
distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !==
|
||||
manifest.distSha256
|
||||
) {
|
||||
throw new Error("candidate manifest dist digest summary mismatch");
|
||||
}
|
||||
if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) {
|
||||
throw new Error("candidate manifest bundle digest summary mismatch");
|
||||
}
|
||||
|
||||
const expectedFilePaths = new Set([
|
||||
...expectedFiles.keys(),
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
]);
|
||||
const expectedDirectories = new Set(directoryAncestors([...expectedFilePaths]));
|
||||
for (const entry of entries) {
|
||||
assertSafeMemberPath(entry.path);
|
||||
if (entry.type === "directory") {
|
||||
if (!expectedDirectories.has(entry.path)) {
|
||||
throw new Error(`candidate archive contains unexpected directory: ${entry.path}`);
|
||||
}
|
||||
} else if (!expectedFilePaths.has(entry.path)) {
|
||||
throw new Error(`candidate archive contains unexpected file: ${entry.path}`);
|
||||
}
|
||||
}
|
||||
const actualFiles = new Set(
|
||||
entries.filter(({ type }) => type === "file").map(({ path: member }) => member),
|
||||
);
|
||||
for (const expected of expectedFilePaths) {
|
||||
if (!actualFiles.has(expected)) throw new Error(`candidate archive is missing file: ${expected}`);
|
||||
}
|
||||
for (const [member, expected] of expectedFiles) {
|
||||
const bytes = await readFile(path.join(extractionRoot, member));
|
||||
if (bytes.byteLength !== expected.bytes) {
|
||||
throw new Error(`candidate archive member size mismatch: ${member}`);
|
||||
}
|
||||
if (createHash("sha256").update(bytes).digest("hex") !== expected.sha256) {
|
||||
throw new Error(`candidate archive member digest mismatch: ${member}`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ memberCount: entries.length, manifest });
|
||||
}
|
||||
|
||||
async function walkExtractedTree(
|
||||
root: string,
|
||||
relativeDirectory = "",
|
||||
): Promise<ReadonlyArray<Readonly<{ path: string; type: "file" | "directory" }>>> {
|
||||
const children = await readdir(path.join(root, relativeDirectory), {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const entries: Array<Readonly<{ path: string; type: "file" | "directory" }>> = [];
|
||||
for (const child of children.sort((left, right) => asciiCompare(left.name, right.name))) {
|
||||
const relative = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name;
|
||||
assertSafeMemberPath(relative);
|
||||
const metadata = await lstat(path.join(root, relative));
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new Error(`candidate archive contains non-regular member: ${relative}`);
|
||||
}
|
||||
if (metadata.isDirectory() && child.isDirectory()) {
|
||||
entries.push(Object.freeze({ path: relative, type: "directory" }));
|
||||
entries.push(...(await walkExtractedTree(root, relative)));
|
||||
} else if (metadata.isFile() && child.isFile()) {
|
||||
if (metadata.nlink !== 1) {
|
||||
throw new Error(`candidate archive contains hard-linked member: ${relative}`);
|
||||
}
|
||||
entries.push(Object.freeze({ path: relative, type: "file" }));
|
||||
} else {
|
||||
throw new Error(`candidate archive contains non-regular member: ${relative}`);
|
||||
}
|
||||
if (entries.length > MAX_ARCHIVE_MEMBERS) {
|
||||
throw new RangeError(`candidate archive exceeds ${MAX_ARCHIVE_MEMBERS} members`);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function assertSameIdentity(
|
||||
before: Awaited<ReturnType<typeof lstat>>,
|
||||
after: Awaited<ReturnType<typeof lstat>>,
|
||||
): void {
|
||||
if (
|
||||
!after.isFile() ||
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
before.size !== after.size
|
||||
) {
|
||||
throw new Error("candidate archive file identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
async function readCapturedArchive(
|
||||
handle: FileHandle,
|
||||
expectedSize: number,
|
||||
): Promise<Buffer> {
|
||||
const captured = Buffer.allocUnsafe(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 Error("candidate archive changed size during bounded capture");
|
||||
}
|
||||
return captured.subarray(0, offset);
|
||||
}
|
||||
|
||||
function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateManifest {
|
||||
const extracted = spawnSync(
|
||||
TAR_EXECUTABLE,
|
||||
[
|
||||
"--extract",
|
||||
"--gzip",
|
||||
"--to-stdout",
|
||||
"--file",
|
||||
"/proc/self/fd/3",
|
||||
"--",
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
],
|
||||
{
|
||||
maxBuffer: 8_388_609,
|
||||
timeout: 10_000,
|
||||
env: TAR_ENVIRONMENT,
|
||||
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
|
||||
},
|
||||
);
|
||||
if (extracted.status !== 0 || extracted.signal || extracted.error) {
|
||||
throw new Error(
|
||||
`candidate manifest preflight failed: ${String(extracted.stderr) || extracted.error?.message || extracted.signal}`,
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.from(extracted.stdout);
|
||||
if (bytes.byteLength === 0 || bytes.byteLength > 8_388_608) {
|
||||
throw new RangeError("candidate manifest preflight size is outside 1..8388608");
|
||||
}
|
||||
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
return releaseCandidateManifestSchema.parse(JSON.parse(source) as unknown);
|
||||
}
|
||||
|
||||
async function materializeCapturedArchive(
|
||||
archive: Buffer,
|
||||
): Promise<Readonly<{ root: string; handle: FileHandle }>> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-"));
|
||||
const file = path.join(root, "candidate.tar.gz");
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await open(
|
||||
file,
|
||||
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
await handle.writeFile(archive);
|
||||
await handle.sync();
|
||||
await unlink(file);
|
||||
return Object.freeze({ root, handle });
|
||||
} catch (error) {
|
||||
if (handle) await handle.close().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeMemberPath(member: string): void {
|
||||
if (
|
||||
!member ||
|
||||
member.startsWith("-") ||
|
||||
Buffer.byteLength(member, "utf8") > MAX_MEMBER_PATH_BYTES ||
|
||||
member.includes("\\") ||
|
||||
[...member].some((character) => {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
}) ||
|
||||
path.posix.isAbsolute(member) ||
|
||||
path.posix.normalize(member) !== member ||
|
||||
member === ".." ||
|
||||
member.startsWith("../") ||
|
||||
member.includes("/../")
|
||||
) {
|
||||
throw new TypeError(`candidate archive contains unsafe member path: ${member}`);
|
||||
}
|
||||
}
|
||||
|
||||
function directoryAncestors(files: readonly string[]): string[] {
|
||||
const directories = new Set<string>();
|
||||
for (const file of files) {
|
||||
let directory = path.posix.dirname(file);
|
||||
while (directory !== ".") {
|
||||
directories.add(directory);
|
||||
directory = path.posix.dirname(directory);
|
||||
}
|
||||
}
|
||||
return [...directories];
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
|
||||
async function pathExists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ciContractReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(2),
|
||||
nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u),
|
||||
gateCount: z.literal(26),
|
||||
commandDefinitionCount: z.number().int().positive(),
|
||||
commandReferenceCount: z.number().int().positive(),
|
||||
artifactCount: z.number().int().positive(),
|
||||
jobCount: z.literal(9),
|
||||
workflowSha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
durationStatus: z.string().min(1),
|
||||
negativeFixtures: z.array(
|
||||
z
|
||||
.object({
|
||||
readiness: z.enum([
|
||||
"MERGE_READY",
|
||||
"RELEASE_READY",
|
||||
"PROD_PROMOTION_READY",
|
||||
"FIELD_SLO_READY",
|
||||
"DOCUMENTATION_READY",
|
||||
]),
|
||||
failedGate: z.string().regex(/^FE-GATE-\d{3}$/u),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
failures: z.array(z.string()),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((report, context) => {
|
||||
const fail = (path: PropertyKey[], message: string) =>
|
||||
context.addIssue({ code: "custom", path, message });
|
||||
if ((report.passed === true) !== (report.failures.length === 0)) {
|
||||
fail(["passed"], "passed must agree with failures");
|
||||
}
|
||||
if (
|
||||
report.negativeFixtures.length !== 5 ||
|
||||
report.negativeFixtures.some((fixture) => !fixture.passed)
|
||||
) {
|
||||
fail(["negativeFixtures"], "every readiness negative fixture must pass");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
|
||||
|
||||
export async function writeCiGateLogAtomic(input: Readonly<{
|
||||
root: string;
|
||||
relativePath: string;
|
||||
content: string;
|
||||
maxBytes?: number;
|
||||
}>): Promise<void> {
|
||||
const root = path.resolve(input.root);
|
||||
const relative = normalizeRepositoryRelativePath(input.relativePath, "CI gate log path");
|
||||
const target = path.join(root, relative);
|
||||
const maxBytes = input.maxBytes ?? 67_108_864;
|
||||
const contentBytes = Buffer.byteLength(input.content, "utf8");
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || contentBytes < 1 || contentBytes > maxBytes) {
|
||||
throw new RangeError(`CI gate log size is outside 1..${maxBytes}: ${relative}`);
|
||||
}
|
||||
const parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target));
|
||||
await assertSafePublishLeaf(target, relative);
|
||||
const temporary = path.join(
|
||||
path.dirname(target),
|
||||
`.${path.basename(target)}.${randomUUID()}.tmp`,
|
||||
);
|
||||
let ownsTemporary = false;
|
||||
try {
|
||||
const handle = await open(
|
||||
temporary,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o644,
|
||||
);
|
||||
ownsTemporary = true;
|
||||
let failure: unknown;
|
||||
try {
|
||||
await handle.writeFile(input.content, "utf8");
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
failure ??= error;
|
||||
}
|
||||
if (failure) throw failure;
|
||||
await assertDirectoryIdentity(path.dirname(target), parentIdentity, relative);
|
||||
await assertSafePublishLeaf(target, relative);
|
||||
await rename(temporary, target);
|
||||
ownsTemporary = false;
|
||||
const directory = await open(path.dirname(target), constants.O_RDONLY);
|
||||
try {
|
||||
try {
|
||||
await directory.sync();
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
||||
}
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (ownsTemporary) {
|
||||
try {
|
||||
await rm(temporary, { force: true });
|
||||
} catch {
|
||||
// Preserve the publication failure and clean only the owned sibling temp.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSafePublishDirectory(
|
||||
rootInput: string,
|
||||
directoryInput: string,
|
||||
): Promise<Stats> {
|
||||
const root = path.resolve(rootInput);
|
||||
const directory = path.resolve(directoryInput);
|
||||
const relativeDirectory = path.relative(root, directory);
|
||||
if (
|
||||
relativeDirectory === ".." ||
|
||||
relativeDirectory.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativeDirectory)
|
||||
) {
|
||||
throw new TypeError("CI publish directory escapes root");
|
||||
}
|
||||
const rootMetadata = await lstat(root);
|
||||
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
||||
throw new TypeError("CI gate log root is unsafe");
|
||||
}
|
||||
let ancestor = root;
|
||||
for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) {
|
||||
ancestor = path.join(ancestor, segment);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await lstat(ancestor);
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
try {
|
||||
await mkdir(ancestor);
|
||||
} catch (mkdirError) {
|
||||
if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError;
|
||||
}
|
||||
metadata = await lstat(ancestor);
|
||||
}
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new TypeError(`CI publish ancestor is unsafe: ${relativeDirectory}`);
|
||||
}
|
||||
}
|
||||
return lstat(directory);
|
||||
}
|
||||
|
||||
export async function assertSafePublishLeaf(
|
||||
target: string,
|
||||
label = target,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(target);
|
||||
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
||||
throw new TypeError(`CI publish leaf is unsafe: ${label}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertSafeExistingPublishPath(
|
||||
rootInput: string,
|
||||
targetInput: string,
|
||||
): Promise<boolean> {
|
||||
const root = path.resolve(rootInput);
|
||||
const target = path.resolve(targetInput);
|
||||
const relative = path.relative(root, target);
|
||||
if (
|
||||
relative === "" ||
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError("CI publish target escapes root");
|
||||
}
|
||||
const rootMetadata = await lstat(root);
|
||||
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
||||
throw new TypeError("CI publish root is unsafe");
|
||||
}
|
||||
const segments = relative.split(path.sep).filter(Boolean);
|
||||
let current = root;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
current = path.join(current, segment);
|
||||
let metadata: Stats;
|
||||
try {
|
||||
metadata = await lstat(current);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
}
|
||||
const leaf = index === segments.length - 1;
|
||||
if (metadata.isSymbolicLink() || (leaf ? !metadata.isFile() : !metadata.isDirectory())) {
|
||||
throw new TypeError(`CI publish path is unsafe: ${relative}`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function assertDirectoryIdentity(
|
||||
directory: string,
|
||||
expected: Stats,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const actual = await lstat(directory);
|
||||
if (
|
||||
actual.isSymbolicLink() ||
|
||||
!actual.isDirectory() ||
|
||||
expected.dev <= 0 ||
|
||||
expected.ino <= 0 ||
|
||||
actual.dev !== expected.dev ||
|
||||
actual.ino !== expected.ino
|
||||
) {
|
||||
throw new TypeError(`CI publish directory identity changed: ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const packageScriptInvocation = /\b(?:(?:corepack\s+)?pnpm(?:\s+--?[A-Za-z][A-Za-z-]*(?:=[^\s;&|]+)?)*(?:\s+run)?|npm\s+run)\s+([A-Za-z0-9:_-]+)/gu;
|
||||
const pnpmNonScriptCommands = new Set(["dlx", "exec", "install"]);
|
||||
|
||||
export function validatePackageScriptGraph(
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
entryScript: string,
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
|
||||
const visit = (scriptName: string): void => {
|
||||
if (visiting.has(scriptName)) {
|
||||
const start = stack.indexOf(scriptName);
|
||||
failures.push(`package script cycle: ${[...stack.slice(start), scriptName].join(" -> ")}`);
|
||||
return;
|
||||
}
|
||||
if (visited.has(scriptName)) return;
|
||||
const command = scripts[scriptName];
|
||||
if (command === undefined) {
|
||||
failures.push(`package script missing: ${scriptName}`);
|
||||
return;
|
||||
}
|
||||
visiting.add(scriptName);
|
||||
stack.push(scriptName);
|
||||
if (/\bscripts\/run-ci-gate(?:\.[cm]?[jt]s)?\b/u.test(command)) {
|
||||
failures.push(`${scriptName} must not invoke the CI gate runner`);
|
||||
}
|
||||
if (/\bci:gate\b/u.test(command)) {
|
||||
failures.push(`${scriptName} must not invoke ci:gate`);
|
||||
}
|
||||
packageScriptInvocation.lastIndex = 0;
|
||||
const dependencies = Array.from(
|
||||
command.matchAll(packageScriptInvocation),
|
||||
(match) => match[1]!,
|
||||
).filter((dependency) => !pnpmNonScriptCommands.has(dependency));
|
||||
for (const dependency of dependencies) {
|
||||
if (dependency !== "ci:gate") {
|
||||
if (!(dependency in scripts)) {
|
||||
failures.push(`package script missing: ${scriptName} -> ${dependency}`);
|
||||
} else {
|
||||
visit(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
visiting.delete(scriptName);
|
||||
visited.add(scriptName);
|
||||
};
|
||||
|
||||
visit(entryScript);
|
||||
return [...new Set(failures)];
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { createHash, createPublicKey, randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdtemp, open, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
providerVerificationArtifactSchema,
|
||||
provenanceProviderAttestationSchema,
|
||||
vulnerabilityProviderReportSchema,
|
||||
} from "./provider-evidence.ts";
|
||||
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { verifyReleaseCandidate } from "./release-candidate.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import {
|
||||
assertSafePublishLeaf,
|
||||
ensureSafePublishDirectory,
|
||||
} from "./ci-gate-log.ts";
|
||||
import { PROMOTED_STAGING_PATHS } from "../contracts/promotion-artifacts.ts";
|
||||
|
||||
export { PROMOTED_STAGING_PATHS };
|
||||
|
||||
type PromotionSource = Readonly<{
|
||||
sourcePath: string;
|
||||
destinationName: string;
|
||||
maxBytes: number;
|
||||
validate: (bytes: Buffer) => void;
|
||||
}>;
|
||||
|
||||
type StagedFile = Readonly<{
|
||||
destinationName: string;
|
||||
bytes: Buffer;
|
||||
digest: string;
|
||||
}>;
|
||||
|
||||
export async function stageVerifiedPromotion(input: Readonly<{
|
||||
repositoryRoot: string;
|
||||
archivePath: string;
|
||||
expectedArchiveSha256: string;
|
||||
vulnerabilityReportPath: string;
|
||||
provenanceAttestationPath: string;
|
||||
vulnerabilityPublicKeyPath: string;
|
||||
vulnerabilityKeyId: string;
|
||||
provenancePublicKeyPath: string;
|
||||
provenanceKeyId: string;
|
||||
}>, dependencies: Readonly<{
|
||||
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
||||
afterCapture?: () => Promise<void>;
|
||||
beforePublishRename?: () => Promise<void>;
|
||||
}> = {}): Promise<ReadonlyArray<Readonly<{ path: string; sha256: string }>>> {
|
||||
const root = path.resolve(input.repositoryRoot);
|
||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedArchiveSha256)) {
|
||||
throw new TypeError("promotion archive SHA-256 is invalid");
|
||||
}
|
||||
const sources: PromotionSource[] = [
|
||||
{
|
||||
sourcePath: input.archivePath,
|
||||
destinationName: "release-candidate.tar.gz",
|
||||
maxBytes: 268_435_456,
|
||||
validate: (bytes) => {
|
||||
if (sha256(bytes) !== input.expectedArchiveSha256) {
|
||||
throw new Error("promotion archive SHA-256 changed before staging");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
sourcePath: input.vulnerabilityReportPath,
|
||||
destinationName: "vulnerability-report.json",
|
||||
maxBytes: 16_777_216,
|
||||
validate: (bytes) => vulnerabilityProviderReportSchema.parse(parseJson(bytes)),
|
||||
},
|
||||
{
|
||||
sourcePath: input.provenanceAttestationPath,
|
||||
destinationName: "provenance-attestation.json",
|
||||
maxBytes: 16_777_216,
|
||||
validate: (bytes) => provenanceProviderAttestationSchema.parse(parseJson(bytes)),
|
||||
},
|
||||
{
|
||||
sourcePath: "artifacts/security/provider-verification.json",
|
||||
destinationName: "provider-verification.json",
|
||||
maxBytes: 4_194_304,
|
||||
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
|
||||
},
|
||||
{
|
||||
sourcePath: "artifacts/security/promotion-verification.json",
|
||||
destinationName: "promotion-verification.json",
|
||||
maxBytes: 4_194_304,
|
||||
validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)),
|
||||
},
|
||||
];
|
||||
const [captured, vulnerabilityPublicKey, provenancePublicKey] = await Promise.all([
|
||||
Promise.all(
|
||||
sources.map(async (source) => {
|
||||
const relativePath = repositoryRelative(root, source.sourcePath);
|
||||
const bytes = await readBoundedRegularFile({
|
||||
root,
|
||||
relativePath,
|
||||
maxBytes: source.maxBytes,
|
||||
});
|
||||
source.validate(bytes);
|
||||
return Object.freeze({ ...source, bytes, digest: sha256(bytes) });
|
||||
}),
|
||||
),
|
||||
capture(root, input.vulnerabilityPublicKeyPath, 1_048_576),
|
||||
capture(root, input.provenancePublicKeyPath, 1_048_576),
|
||||
]);
|
||||
await dependencies.afterCapture?.();
|
||||
let capturedLocalStatus: "PASS" | "FAIL" = "FAIL";
|
||||
const archive = await verifyCapturedCiCandidateArchive(
|
||||
captured[0]!.bytes,
|
||||
input.expectedArchiveSha256,
|
||||
{
|
||||
verifyExtracted: async (extractionRoot, manifest) => {
|
||||
const candidate = await verifyReleaseCandidate(manifest, extractionRoot);
|
||||
if (candidate.failures.length > 0) {
|
||||
throw new Error(`captured candidate failed final verification: ${candidate.failures.join(", ")}`);
|
||||
}
|
||||
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
|
||||
repositoryRoot: extractionRoot,
|
||||
candidate: manifest,
|
||||
});
|
||||
if (local.status !== "PASS" || local.failures.length > 0) {
|
||||
throw new Error(`captured local evidence failed final verification: ${local.failures.join(", ")}`);
|
||||
}
|
||||
capturedLocalStatus = local.status;
|
||||
},
|
||||
},
|
||||
);
|
||||
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(captured[1]!.bytes));
|
||||
const provenance = provenanceProviderAttestationSchema.parse(parseJson(captured[2]!.bytes));
|
||||
const reevaluated = evaluatePromotionEvidence({
|
||||
candidate: archive.manifest,
|
||||
currentDistSha256: archive.manifest.distSha256,
|
||||
localStatus: capturedLocalStatus,
|
||||
vulnerabilityReport: vulnerability,
|
||||
provenanceAttestation: provenance,
|
||||
vulnerabilityTrust: {
|
||||
keyId: input.vulnerabilityKeyId,
|
||||
publicKey: createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(vulnerabilityPublicKey),
|
||||
),
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: input.provenanceKeyId,
|
||||
publicKey: createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(provenancePublicKey),
|
||||
),
|
||||
},
|
||||
});
|
||||
if (reevaluated.status !== "PASS" || reevaluated.failures.length > 0) {
|
||||
throw new Error(`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`);
|
||||
}
|
||||
const expectedBindings = {
|
||||
candidateArchiveSha256: captured[0]!.digest,
|
||||
vulnerabilityReportSha256: captured[1]!.digest,
|
||||
provenanceAttestationSha256: captured[2]!.digest,
|
||||
};
|
||||
for (const [index, expectedArtifactType] of [
|
||||
[3, "provider-verification"],
|
||||
[4, "promotion-verification"],
|
||||
] as const) {
|
||||
const verification = providerVerificationArtifactSchema.parse(parseJson(captured[index]!.bytes));
|
||||
if (verification.artifactType !== expectedArtifactType) {
|
||||
throw new Error(
|
||||
`${captured[index]!.destinationName} artifactType role mismatch: expected ${expectedArtifactType}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
verification.status !== reevaluated.status ||
|
||||
verification.vulnerabilityStatus !== reevaluated.vulnerabilityStatus ||
|
||||
verification.provenanceAttestationStatus !== reevaluated.provenanceAttestationStatus ||
|
||||
verification.failures.length > 0
|
||||
) {
|
||||
throw new Error(`${captured[index]!.destinationName} status disagrees with trusted revalidation`);
|
||||
}
|
||||
if (verification.lockfileSha256 !== archive.manifest.lockfileSha256) {
|
||||
throw new Error(`${captured[index]!.destinationName} lockfileSha256 digest mismatch`);
|
||||
}
|
||||
if (verification.distSha256 !== archive.manifest.distSha256) {
|
||||
throw new Error(`${captured[index]!.destinationName} distSha256 digest mismatch`);
|
||||
}
|
||||
for (const [binding, expectedDigest] of Object.entries(expectedBindings) as ReadonlyArray<
|
||||
readonly [keyof typeof expectedBindings, string]
|
||||
>) {
|
||||
if (verification[binding] !== expectedDigest) {
|
||||
throw new Error(`${captured[index]!.destinationName} ${binding} digest mismatch`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const stagedFiles: readonly StagedFile[] = captured;
|
||||
|
||||
const releaseRoot = path.join(root, ".release");
|
||||
const releaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
|
||||
const stagingRoot = path.join(releaseRoot, "promoted-staging");
|
||||
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
|
||||
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
|
||||
const temporary = await mkdtemp(path.join(root, `.promoted-staging.${randomUUID()}.`));
|
||||
let ownsTemporary = true;
|
||||
try {
|
||||
for (const source of stagedFiles) {
|
||||
const handle = await open(
|
||||
path.join(temporary, source.destinationName),
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(source.bytes);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
await syncDirectory(temporary);
|
||||
await dependencies.beforePublishRename?.();
|
||||
const currentReleaseIdentity = await ensureSafePublishDirectory(root, releaseRoot);
|
||||
if (
|
||||
releaseIdentity.dev <= 0 ||
|
||||
releaseIdentity.ino <= 0 ||
|
||||
currentReleaseIdentity.dev !== releaseIdentity.dev ||
|
||||
currentReleaseIdentity.ino !== releaseIdentity.ino
|
||||
) {
|
||||
throw new Error("promotion staging parent identity changed");
|
||||
}
|
||||
await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging");
|
||||
if (await exists(stagingRoot)) throw new Error("promotion staging target already exists");
|
||||
await rename(temporary, stagingRoot);
|
||||
ownsTemporary = false;
|
||||
await syncDirectory(releaseRoot);
|
||||
} finally {
|
||||
if (ownsTemporary) await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
return Object.freeze(
|
||||
stagedFiles.map(({ destinationName, digest }) =>
|
||||
Object.freeze({ path: `.release/promoted-staging/${destinationName}`, sha256: digest }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function capture(root: string, configuredPath: string, maxBytes: number): Promise<Buffer> {
|
||||
const absolute = path.resolve(root, configuredPath);
|
||||
const relative = path.relative(root, absolute);
|
||||
const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
||||
return readBoundedRegularFile({
|
||||
root: outside ? path.dirname(absolute) : root,
|
||||
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
|
||||
maxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): unknown {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||
}
|
||||
|
||||
function repositoryRelative(root: string, configuredPath: string): string {
|
||||
const absolute = path.resolve(root, configuredPath);
|
||||
const relative = path.relative(root, absolute);
|
||||
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new TypeError(`promotion source escapes repository: ${configuredPath}`);
|
||||
}
|
||||
return relative.replaceAll(path.sep, "/");
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
async function exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await open(directory, constants.O_RDONLY);
|
||||
try {
|
||||
try {
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createPublicKey } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createHash, createPublicKey } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
type ProviderVerificationArtifactType,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import {
|
||||
@@ -12,20 +12,58 @@ import {
|
||||
verifyReleaseCandidate,
|
||||
} from "./release-candidate.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
|
||||
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
|
||||
|
||||
export type VerifyPromotionInputsOptions = Readonly<{
|
||||
artifactType: ProviderVerificationArtifactType;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
repositoryRoot?: string;
|
||||
providerEvidenceRoot?: string;
|
||||
trustRoot?: string;
|
||||
verifyLocalEvidence?: LocalEvidenceVerifier;
|
||||
}>;
|
||||
|
||||
export async function verifyPromotionInputs(
|
||||
options: VerifyPromotionInputsOptions = {},
|
||||
options: VerifyPromotionInputsOptions,
|
||||
) {
|
||||
const environment = options.environment ?? process.env;
|
||||
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
|
||||
const trustRoot = path.resolve(options.trustRoot ?? repositoryRoot);
|
||||
const providerEvidenceRoot = path.resolve(
|
||||
options.providerEvidenceRoot ?? repositoryRoot,
|
||||
);
|
||||
const inputFailures: string[] = [];
|
||||
const archive = await captureOptionalInput(
|
||||
providerEvidenceRoot,
|
||||
environment.CANDIDATE_ARCHIVE_PATH,
|
||||
268_435_456,
|
||||
"candidate archive",
|
||||
inputFailures,
|
||||
);
|
||||
if (!environment.CANDIDATE_ARCHIVE_SHA256) {
|
||||
inputFailures.push("candidate archive expected SHA-256 is missing");
|
||||
} else if (
|
||||
archive.sha256 &&
|
||||
archive.sha256 !== environment.CANDIDATE_ARCHIVE_SHA256
|
||||
) {
|
||||
inputFailures.push("candidate archive SHA-256 does not match immutable output");
|
||||
}
|
||||
const vulnerabilityCapture = await captureOptionalInput(
|
||||
providerEvidenceRoot,
|
||||
environment.VULNERABILITY_REPORT_PATH,
|
||||
16_777_216,
|
||||
"vulnerability report",
|
||||
inputFailures,
|
||||
);
|
||||
const provenanceCapture = await captureOptionalInput(
|
||||
providerEvidenceRoot,
|
||||
environment.PROVENANCE_ATTESTATION_PATH,
|
||||
16_777_216,
|
||||
"provenance attestation",
|
||||
inputFailures,
|
||||
);
|
||||
const manifestDocument = await requiredJson(
|
||||
repositoryRoot,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
@@ -38,38 +76,34 @@ export async function verifyPromotionInputs(
|
||||
const localEvidence = await (
|
||||
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
|
||||
)({ repositoryRoot, candidate: manifest });
|
||||
const vulnerabilityReport = await optionalJson(
|
||||
repositoryRoot,
|
||||
environment.VULNERABILITY_REPORT_PATH,
|
||||
);
|
||||
const provenanceAttestation = await optionalJson(
|
||||
repositoryRoot,
|
||||
environment.PROVENANCE_ATTESTATION_PATH,
|
||||
);
|
||||
const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes);
|
||||
const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes);
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: manifest,
|
||||
currentDistSha256: candidate.currentDistSha256 ?? "",
|
||||
localStatus: localEvidence.status,
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: await readTrust(
|
||||
repositoryRoot,
|
||||
vulnerabilityTrust: await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||
environment.VULNERABILITY_KEY_ID,
|
||||
),
|
||||
provenanceTrust: await readTrust(
|
||||
repositoryRoot,
|
||||
provenanceTrust: await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||
environment.PROVENANCE_KEY_ID,
|
||||
),
|
||||
});
|
||||
const failures = [
|
||||
...inputFailures,
|
||||
...candidate.failures,
|
||||
...localEvidence.failures,
|
||||
...result.failures,
|
||||
];
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
schemaVersion: 2 as const,
|
||||
artifactType: options.artifactType,
|
||||
status:
|
||||
failures.length === 0 && result.status === "PASS"
|
||||
? ("PASS" as const)
|
||||
@@ -78,11 +112,14 @@ export async function verifyPromotionInputs(
|
||||
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
candidateArchiveSha256: archive.sha256,
|
||||
vulnerabilityReportSha256: vulnerabilityCapture.sha256,
|
||||
provenanceAttestationSha256: provenanceCapture.sha256,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
async function readTrust(
|
||||
export async function readProviderTrust(
|
||||
repositoryRoot: string,
|
||||
publicKeyPath: string | undefined,
|
||||
keyId: string | undefined,
|
||||
@@ -92,7 +129,9 @@ async function readTrust(
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey: createPublicKey(
|
||||
await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"),
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
|
||||
),
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
@@ -100,15 +139,35 @@ async function readTrust(
|
||||
}
|
||||
}
|
||||
|
||||
async function optionalJson(
|
||||
repositoryRoot: string,
|
||||
file: string | undefined,
|
||||
): Promise<unknown> {
|
||||
if (!file) return null;
|
||||
async function captureOptionalInput(
|
||||
root: string,
|
||||
configuredPath: string | undefined,
|
||||
maxBytes: number,
|
||||
label: string,
|
||||
failures: string[],
|
||||
): Promise<Readonly<{ bytes: Buffer | null; sha256: string | null }>> {
|
||||
if (!configuredPath) {
|
||||
failures.push(`${label} path is missing`);
|
||||
return Object.freeze({ bytes: null, sha256: null });
|
||||
}
|
||||
try {
|
||||
return JSON.parse(
|
||||
await readFile(path.resolve(repositoryRoot, file), "utf8"),
|
||||
) as unknown;
|
||||
const bytes = await boundedConfiguredFile(root, configuredPath, maxBytes);
|
||||
return Object.freeze({
|
||||
bytes,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${label} capture failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return Object.freeze({ bytes: null, sha256: null });
|
||||
}
|
||||
}
|
||||
|
||||
function parseCapturedJson(bytes: Buffer | null): unknown {
|
||||
if (!bytes) return null;
|
||||
try {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -119,10 +178,28 @@ async function requiredJson(
|
||||
file: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const value: unknown = JSON.parse(
|
||||
await readFile(path.join(repositoryRoot, file), "utf8"),
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await boundedConfiguredFile(repositoryRoot, file, 8_388_608),
|
||||
),
|
||||
);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${file} must be a JSON object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function boundedConfiguredFile(
|
||||
configuredRoot: string,
|
||||
configuredPath: string,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer> {
|
||||
const root = path.resolve(configuredRoot);
|
||||
const absolute = path.resolve(root, configuredPath);
|
||||
const relative = path.relative(root, absolute);
|
||||
const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
||||
return readBoundedRegularFile({
|
||||
root: outside ? path.dirname(absolute) : root,
|
||||
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
|
||||
maxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,15 +44,58 @@ export const provenanceProviderAttestationSchema = z
|
||||
|
||||
export const providerVerificationArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
schemaVersion: z.literal(2),
|
||||
artifactType: z.enum(["provider-verification", "promotion-verification"]),
|
||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
lockfileSha256: sha256,
|
||||
distSha256: sha256,
|
||||
candidateArchiveSha256: sha256.nullable(),
|
||||
vulnerabilityReportSha256: sha256.nullable(),
|
||||
provenanceAttestationSha256: sha256.nullable(),
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
const passing =
|
||||
artifact.status === "PASS" &&
|
||||
artifact.vulnerabilityStatus === "PASS" &&
|
||||
artifact.provenanceAttestationStatus === "PASS" &&
|
||||
artifact.failures.length === 0;
|
||||
if ((artifact.status === "PASS") !== passing) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message: "verification PASS must agree with provider statuses and failures",
|
||||
});
|
||||
}
|
||||
if (
|
||||
artifact.status === "PASS" &&
|
||||
[
|
||||
artifact.candidateArchiveSha256,
|
||||
artifact.vulnerabilityReportSha256,
|
||||
artifact.provenanceAttestationSha256,
|
||||
].some((digest) => digest === null)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["candidateArchiveSha256"],
|
||||
message: "passing verification requires every exact input digest",
|
||||
});
|
||||
}
|
||||
if (artifact.status === "FAIL_UNVERIFIED" && artifact.failures.length === 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["failures"],
|
||||
message: "failed verification requires a failure diagnostic",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ProviderVerificationArtifactType = z.infer<
|
||||
typeof providerVerificationArtifactSchema
|
||||
>["artifactType"];
|
||||
|
||||
export type ProviderTrust = Readonly<{
|
||||
keyId: string;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
provenanceProviderAttestationSchema,
|
||||
vulnerabilityProviderReportSchema,
|
||||
} from "./provider-evidence.ts";
|
||||
import {
|
||||
verifyReleaseCandidate,
|
||||
} from "./release-candidate.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import { verifyCiCandidateArchive } from "./ci-candidate-archive.ts";
|
||||
|
||||
export async function validateProviderUpload(input: Readonly<{
|
||||
kind: "vulnerability" | "provenance";
|
||||
candidateRoot: string;
|
||||
archivePath: string;
|
||||
expectedArchiveSha256: string;
|
||||
reportPath: string;
|
||||
workspaceRoot?: string;
|
||||
expectedDistSha256: string;
|
||||
}>): Promise<unknown> {
|
||||
if (!/^[a-f0-9]{64}$/u.test(input.expectedDistSha256)) {
|
||||
throw new TypeError("expected candidate dist SHA-256 is invalid");
|
||||
}
|
||||
const archive = await verifyCiCandidateArchive({
|
||||
archivePath: input.archivePath,
|
||||
expectedSha256: input.expectedArchiveSha256,
|
||||
});
|
||||
const manifest = archive.manifest;
|
||||
if (manifest.distSha256 !== input.expectedDistSha256) {
|
||||
throw new Error("provider input candidate dist digest mismatch");
|
||||
}
|
||||
const verifiedCandidate = await verifyReleaseCandidate(manifest, input.candidateRoot);
|
||||
if (verifiedCandidate.failures.length > 0) {
|
||||
throw new Error(
|
||||
`provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`,
|
||||
);
|
||||
}
|
||||
const reportAbsolute = path.resolve(input.reportPath);
|
||||
const reportRoot = path.resolve(input.workspaceRoot ?? process.cwd());
|
||||
const reportRelative = path.relative(reportRoot, reportAbsolute).replaceAll(path.sep, "/");
|
||||
const report = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await readBoundedRegularFile({
|
||||
root: reportRoot,
|
||||
relativePath: reportRelative,
|
||||
maxBytes: 8_388_608,
|
||||
}),
|
||||
),
|
||||
) as unknown;
|
||||
if (input.kind === "vulnerability") {
|
||||
const parsed = vulnerabilityProviderReportSchema.parse(report);
|
||||
const lockfile = await readBoundedRegularFile({
|
||||
root: input.candidateRoot,
|
||||
relativePath: "pnpm-lock.yaml",
|
||||
maxBytes: 67_108_864,
|
||||
});
|
||||
const lockfileSha256 = createHash("sha256").update(lockfile).digest("hex");
|
||||
if (
|
||||
parsed.scannedDistSha256 !== manifest.distSha256 ||
|
||||
parsed.scannedLockfileSha256 !== manifest.lockfileSha256 ||
|
||||
lockfileSha256 !== manifest.lockfileSha256
|
||||
) {
|
||||
throw new Error("vulnerability provider evidence candidate digest mismatch");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
const parsed = provenanceProviderAttestationSchema.parse(report);
|
||||
if (parsed.subject.digest.sha256 !== manifest.distSha256) {
|
||||
throw new Error("provenance provider evidence candidate digest mismatch");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export async function createReleaseCandidateManifest(
|
||||
sha256,
|
||||
})),
|
||||
...evidence,
|
||||
].sort((left, right) => left.path.localeCompare(right.path));
|
||||
].sort((left, right) => asciiCompare(left.path, right.path));
|
||||
const dependencyInventory = JSON.parse(
|
||||
await readFile(
|
||||
path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"),
|
||||
@@ -134,6 +134,10 @@ export async function createReleaseCandidateManifest(
|
||||
});
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function verifyReleaseCandidate(
|
||||
value: unknown,
|
||||
repositoryRoot = process.cwd(),
|
||||
@@ -202,7 +206,7 @@ async function regularFilesWithin(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries.sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
asciiCompare(left.name, right.name),
|
||||
)) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const testEvidenceReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(2),
|
||||
sourceRoot: z.string().min(1),
|
||||
status: z.enum(["PASS", "FAIL"]),
|
||||
facts: z
|
||||
.object({
|
||||
scannedFiles: z.number().int().nonnegative(),
|
||||
visualBaselines: z.number().int().nonnegative(),
|
||||
sharedScenarios: z.number().int().nonnegative(),
|
||||
declaredScenarioExecutions: z.number().int().nonnegative(),
|
||||
executedScenarioExecutions: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((report, context) => {
|
||||
const passed = report.status === "PASS";
|
||||
if (passed !== (report.failures.length === 0)) {
|
||||
context.addIssue({ code: "custom", path: ["status"], message: "status must agree with failures" });
|
||||
}
|
||||
if (
|
||||
passed &&
|
||||
report.facts.declaredScenarioExecutions !==
|
||||
report.facts.executedScenarioExecutions
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["facts", "executedScenarioExecutions"],
|
||||
message: "PASS requires exact declared/executed scenario agreement",
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user