218 lines
6.6 KiB
TypeScript
218 lines
6.6 KiB
TypeScript
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<DistOutput[]> {
|
|
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<ReleaseCandidateManifest> {
|
|
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) => left.path.localeCompare(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,
|
|
});
|
|
}
|
|
|
|
export async function verifyReleaseCandidate(
|
|
value: unknown,
|
|
repositoryRoot = process.cwd(),
|
|
): Promise<Readonly<{
|
|
manifest: ReleaseCandidateManifest | null;
|
|
currentDistSha256: string | null;
|
|
failures: readonly string[];
|
|
}>> {
|
|
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<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const files: string[] = [];
|
|
for (const entry of entries.sort((left, right) =>
|
|
left.name.localeCompare(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;
|
|
}
|