import { createHash } from "node:crypto"; import { lstat, readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { gzipSync } from "node:zlib"; import { z } from "zod"; import { supplyChainDigest } from "./supply-chain.ts"; const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); const candidateFileSchema = z .object({ path: z.string().min(1), bytes: z.int().nonnegative(), sha256, }) .strict(); export const releaseCandidateManifestSchema = z .object({ schemaVersion: z.literal(1), distSha256: sha256, lockfileSha256: sha256, bundleSha256: sha256, files: z.array(candidateFileSchema).min(1), }) .strict(); export type ReleaseCandidateManifest = z.infer< typeof releaseCandidateManifestSchema >; export const RELEASE_CANDIDATE_MANIFEST_PATH = "artifacts/release/release-candidate.json"; export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([ "pnpm-lock.yaml", "artifacts/performance/bundle.json", "artifacts/quality/vite-module-inventory.json", "artifacts/release/build-manifest.json", "artifacts/release/checksums.txt", "artifacts/release/dependency-inventory.json", "artifacts/release/provenance.json", "artifacts/release/verification.json", "artifacts/release/sbom.cdx.json", "artifacts/security/dependency-diff.json", "artifacts/security/license-report.json", "artifacts/security/scan.sarif", "artifacts/security/supply-chain-coherence.json", "artifacts/security/supply-chain-verification.json", "artifacts/security/vulnerability-report.json", ]); export type DistOutput = Readonly<{ path: string; bytes: number; gzipBytes: number; sha256: string; }>; export async function collectDistOutputs( repositoryRoot = process.cwd(), ): Promise { const distRoot = path.resolve(repositoryRoot, "dist"); const files = await regularFilesWithin(distRoot); if (files.length === 0) { throw new Error("dist is missing or empty; run the production build first"); } return Promise.all( files.map(async (absolutePath) => { const content = await readFile(absolutePath); return Object.freeze({ path: path .relative(repositoryRoot, absolutePath) .replaceAll(path.sep, "/"), bytes: content.byteLength, gzipBytes: gzipSync(content).byteLength, sha256: createHash("sha256").update(content).digest("hex"), }); }), ); } export function distSha256(outputs: readonly DistOutput[]): string { return supplyChainDigest( outputs.map(({ path: outputPath, bytes, sha256 }) => ({ path: outputPath, bytes, sha256, })), ); } export async function createReleaseCandidateManifest( repositoryRoot = process.cwd(), ): Promise { const outputs = await collectDistOutputs(repositoryRoot); const evidence = await Promise.all( RELEASE_CANDIDATE_EVIDENCE_PATHS.map((file) => digestRequiredFile(repositoryRoot, file), ), ); const files = [ ...outputs.map(({ path: outputPath, bytes, sha256 }) => ({ path: outputPath, bytes, sha256, })), ...evidence, ].sort((left, right) => asciiCompare(left.path, right.path)); const dependencyInventory = JSON.parse( await readFile( path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"), "utf8", ), ) as { lockfileSha256?: unknown }; const rawLockfileSha256 = evidence.find( (file) => file.path === "pnpm-lock.yaml", )?.sha256; if ( typeof rawLockfileSha256 !== "string" || dependencyInventory.lockfileSha256 !== rawLockfileSha256 ) { throw new Error( "raw pnpm-lock digest mismatch with dependency inventory", ); } return releaseCandidateManifestSchema.parse({ schemaVersion: 1, distSha256: distSha256(outputs), lockfileSha256: rawLockfileSha256, bundleSha256: supplyChainDigest(files), files, }); } function asciiCompare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } export async function verifyReleaseCandidate( value: unknown, repositoryRoot = process.cwd(), ): Promise> { const parsed = releaseCandidateManifestSchema.safeParse(value); if (!parsed.success) { return Object.freeze({ manifest: null, currentDistSha256: null, failures: Object.freeze(["release candidate manifest schema mismatch"]), }); } const failures: string[] = []; let actual: ReleaseCandidateManifest | null = null; try { actual = await createReleaseCandidateManifest(repositoryRoot); } catch (error) { failures.push( `release candidate inputs unreadable: ${error instanceof Error ? error.message : String(error)}`, ); } if (actual) { if (parsed.data.distSha256 !== actual.distSha256) { failures.push("release candidate dist digest mismatch"); } if (parsed.data.lockfileSha256 !== actual.lockfileSha256) { failures.push("release candidate lockfile digest mismatch"); } if (parsed.data.bundleSha256 !== actual.bundleSha256) { failures.push("release candidate bundle digest mismatch"); } if (JSON.stringify(parsed.data.files) !== JSON.stringify(actual.files)) { failures.push("release candidate file set or file digest mismatch"); } } return Object.freeze({ manifest: parsed.data, currentDistSha256: actual?.distSha256 ?? null, failures: Object.freeze(failures), }); } async function digestRequiredFile(repositoryRoot: string, file: string) { const absolutePath = path.resolve(repositoryRoot, file); const relative = path.relative(repositoryRoot, absolutePath); if (relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error(`candidate path escapes repository root: ${file}`); } const metadata = await lstat(absolutePath); if (!metadata.isFile()) { throw new Error(`candidate input is not a regular file: ${file}`); } const content = await readFile(absolutePath); return Object.freeze({ path: file, bytes: content.byteLength, sha256: createHash("sha256").update(content).digest("hex"), }); } async function regularFilesWithin(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); const files: string[] = []; for (const entry of entries.sort((left, right) => asciiCompare(left.name, right.name), )) { const target = path.join(directory, entry.name); if (entry.isDirectory()) { files.push(...(await regularFilesWithin(target))); } else if (entry.isFile()) { files.push(target); } else { throw new Error(`dist contains a non-regular entry: ${target}`); } } return files; }