198 lines
6.2 KiB
JavaScript
Executable File
198 lines
6.2 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import {
|
|
lstat,
|
|
mkdir,
|
|
readFile,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import { canonicalValueBytes } from "../src/security/canonical-digest.ts";
|
|
import {
|
|
createDeterministicTar,
|
|
enumerateRegularFiles,
|
|
packageIndexFor,
|
|
parseDeterministicTar,
|
|
validatePackageFiles,
|
|
verifySignedRelease,
|
|
verifySignedReleaseForTest,
|
|
writeArchiveFiles,
|
|
} from "./deploy/lib/release-common.mjs";
|
|
|
|
function parseArgs(args) {
|
|
const allowed = new Set([
|
|
"dist",
|
|
"signed-manifest",
|
|
"sbom",
|
|
"output",
|
|
"authority-root",
|
|
"protected-state",
|
|
]);
|
|
const values = {};
|
|
for (let index = 0; index < args.length; index += 2) {
|
|
const token = args[index];
|
|
const value = args[index + 1];
|
|
if (
|
|
typeof token !== "string" ||
|
|
!token.startsWith("--") ||
|
|
typeof value !== "string" ||
|
|
value.length === 0
|
|
)
|
|
throw new Error(
|
|
"Release package arguments must be complete name/value pairs.",
|
|
);
|
|
const name = token.slice(2);
|
|
if (!allowed.has(name) || Object.hasOwn(values, name))
|
|
throw new Error(
|
|
`Unknown or duplicate release package argument: ${token}`,
|
|
);
|
|
values[name] = value;
|
|
}
|
|
if ([...allowed].some((name) => !Object.hasOwn(values, name)))
|
|
throw new Error("Release package arguments are incomplete.");
|
|
let protectedState;
|
|
try {
|
|
protectedState = JSON.parse(values["protected-state"]);
|
|
} catch {
|
|
throw new Error("Protected state must be valid JSON.");
|
|
}
|
|
return {
|
|
distDir: resolve(values.dist),
|
|
signedManifestPath: resolve(values["signed-manifest"]),
|
|
sbomPath: resolve(values.sbom),
|
|
outputPath: resolve(values.output),
|
|
authorityRoot: resolve(values["authority-root"]),
|
|
protectedState,
|
|
};
|
|
}
|
|
|
|
async function buildReleasePackageWith(input, verify) {
|
|
const outputInfo = await lstat(input.outputPath).catch((error) =>
|
|
error?.code === "ENOENT" ? null : Promise.reject(error),
|
|
);
|
|
if (outputInfo !== null)
|
|
throw new Error("Release archive output already exists.");
|
|
const verified = await verify(input);
|
|
const { admission, manifest, signedReleaseBytes } = verified;
|
|
const sourceFiles = await enumerateRegularFiles(input.distDir);
|
|
const actualPaths = sourceFiles.map((file) => file.publicPath);
|
|
const expectedPaths = [...manifest.deployFiles].sort((left, right) =>
|
|
Buffer.compare(Buffer.from(left), Buffer.from(right)),
|
|
);
|
|
if (
|
|
actualPaths.length !== expectedPaths.length ||
|
|
actualPaths.some((path, index) => path !== expectedPaths[index])
|
|
) {
|
|
throw new Error("dist files must exactly match the signed deploy closure.");
|
|
}
|
|
const sourceByPath = new Map(
|
|
sourceFiles.map((file) => [file.publicPath, file.path]),
|
|
);
|
|
const artifactFiles = [];
|
|
for (const artifact of manifest.artifacts) {
|
|
if (artifact.path.startsWith(".release/"))
|
|
throw new Error("Signed deploy closure uses the reserved .release path.");
|
|
const source = sourceByPath.get(artifact.path);
|
|
if (source === undefined)
|
|
throw new Error(`Signed artifact is missing: ${artifact.path}`);
|
|
const bytes = await readFile(source);
|
|
const digest = (
|
|
await import("../src/security/canonical-digest.ts")
|
|
).byteDigest(bytes);
|
|
if (digest !== artifact.sha256 || bytes.length !== artifact.bytes)
|
|
throw new Error(`Artifact hash or size mismatch: ${artifact.path}`);
|
|
artifactFiles.push({ path: artifact.path, bytes });
|
|
}
|
|
const sbomBytes = await readFile(input.sbomPath);
|
|
const index = packageIndexFor({
|
|
admission,
|
|
manifest,
|
|
signedBytes: signedReleaseBytes,
|
|
sbomBytes,
|
|
});
|
|
const archiveBytes = createDeterministicTar([
|
|
...artifactFiles,
|
|
{ path: ".release/package-index.json", bytes: canonicalValueBytes(index) },
|
|
{ path: ".release/sbom.cdx.json", bytes: sbomBytes },
|
|
{
|
|
path: ".release/signed-release-manifest.json",
|
|
bytes: signedReleaseBytes,
|
|
},
|
|
]);
|
|
const temporary = `${input.outputPath}.partial-${process.pid}`;
|
|
await mkdir(dirname(input.outputPath), { recursive: true });
|
|
await writeFile(temporary, archiveBytes, { flag: "wx", mode: 0o400 });
|
|
const verifyRoot = `${input.outputPath}.verify-${process.pid}`;
|
|
try {
|
|
const parsed = parseDeterministicTar(await readFile(temporary));
|
|
validatePackageFiles(parsed);
|
|
await writeArchiveFiles(parsed, verifyRoot);
|
|
const extracted = await enumerateRegularFiles(verifyRoot);
|
|
if (extracted.length !== parsed.length)
|
|
throw new Error("Extracted release archive closure mismatch.");
|
|
await rename(temporary, input.outputPath);
|
|
} finally {
|
|
await rm(verifyRoot, { recursive: true, force: true });
|
|
await rm(temporary, { force: true });
|
|
}
|
|
return {
|
|
releaseId: index.releaseId,
|
|
manifestDigest: admission.manifestDigest,
|
|
archivePath: input.outputPath,
|
|
};
|
|
}
|
|
|
|
export async function buildReleasePackage(input) {
|
|
const allowed = [
|
|
"distDir",
|
|
"signedManifestPath",
|
|
"sbomPath",
|
|
"outputPath",
|
|
"authorityRoot",
|
|
"protectedState",
|
|
];
|
|
if (Object.keys(input).sort().join("\0") !== allowed.sort().join("\0"))
|
|
throw new Error(
|
|
"Production package builder rejects caller verifier, policy, or time inputs.",
|
|
);
|
|
return buildReleasePackageWith(input, (request) =>
|
|
verifySignedRelease({
|
|
signedManifestPath: request.signedManifestPath,
|
|
authorityRoot: request.authorityRoot,
|
|
protectedState: request.protectedState,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function buildReleasePackageForTest(input) {
|
|
return buildReleasePackageWith(input, (request) =>
|
|
verifySignedReleaseForTest({
|
|
signedManifestPath: request.signedManifestPath,
|
|
trustPolicyPath: request.trustPolicyPath,
|
|
verifySignedReleaseEnvelope: request.verifySignedReleaseEnvelope,
|
|
protectedState: request.protectedState,
|
|
now: request.now,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const result = await buildReleasePackage(parseArgs(process.argv.slice(2)));
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
) {
|
|
main().catch((error) => {
|
|
process.stderr.write(
|
|
`Release packaging failed: ${error instanceof Error ? error.message : "unknown error"}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|