fix: promote immutable verified release bundles
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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([
|
||||
"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 };
|
||||
return releaseCandidateManifestSchema.parse({
|
||||
schemaVersion: 1,
|
||||
distSha256: distSha256(outputs),
|
||||
lockfileSha256: dependencyInventory.lockfileSha256,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user