import { createPublicKey } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { evaluatePromotionEvidence, type ProviderTrust, } from "./provider-evidence.ts"; import { RELEASE_CANDIDATE_MANIFEST_PATH, releaseCandidateManifestSchema, verifyReleaseCandidate, } from "./release-candidate.ts"; import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence; export type VerifyPromotionInputsOptions = Readonly<{ environment?: NodeJS.ProcessEnv; repositoryRoot?: string; verifyLocalEvidence?: LocalEvidenceVerifier; }>; export async function verifyPromotionInputs( options: VerifyPromotionInputsOptions = {}, ) { const environment = options.environment ?? process.env; const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd()); const manifestDocument = await requiredJson( repositoryRoot, RELEASE_CANDIDATE_MANIFEST_PATH, ); const manifest = releaseCandidateManifestSchema.parse(manifestDocument); const candidate = await verifyReleaseCandidate( manifestDocument, repositoryRoot, ); 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 result = evaluatePromotionEvidence({ candidate: manifest, currentDistSha256: candidate.currentDistSha256 ?? "", localStatus: localEvidence.status, vulnerabilityReport, provenanceAttestation, vulnerabilityTrust: await readTrust( repositoryRoot, environment.VULNERABILITY_PUBLIC_KEY_PATH, environment.VULNERABILITY_KEY_ID, ), provenanceTrust: await readTrust( repositoryRoot, environment.PROVENANCE_PUBLIC_KEY_PATH, environment.PROVENANCE_KEY_ID, ), }); const failures = [ ...candidate.failures, ...localEvidence.failures, ...result.failures, ]; return Object.freeze({ schemaVersion: 1 as const, status: failures.length === 0 && result.status === "PASS" ? ("PASS" as const) : ("FAIL_UNVERIFIED" as const), vulnerabilityStatus: result.vulnerabilityStatus, provenanceAttestationStatus: result.provenanceAttestationStatus, lockfileSha256: manifest.lockfileSha256, distSha256: manifest.distSha256, failures: Object.freeze(failures), }); } async function readTrust( repositoryRoot: string, publicKeyPath: string | undefined, keyId: string | undefined, ): Promise { if (!publicKeyPath || !keyId?.trim()) return null; try { return Object.freeze({ keyId, publicKey: createPublicKey( await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"), ), }); } catch { return null; } } async function optionalJson( repositoryRoot: string, file: string | undefined, ): Promise { if (!file) return null; try { return JSON.parse( await readFile(path.resolve(repositoryRoot, file), "utf8"), ) as unknown; } catch { return null; } } async function requiredJson( repositoryRoot: string, file: string, ): Promise> { const value: unknown = JSON.parse( await readFile(path.join(repositoryRoot, file), "utf8"), ); if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`${file} must be a JSON object`); } return value as Record; }