fix: recover and fence atomic activation

This commit is contained in:
2026-09-02 11:49:49 +03:00
parent 79e5d04395
commit a8fa09e21e
12 changed files with 1733 additions and 543 deletions

View File

@@ -1,81 +1,182 @@
#!/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, writeArchiveFiles,
} from './deploy/lib/release-common.mjs';
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', 'verifier-module', 'trust-policy', 'protected-state', 'now']);
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.');
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}`);
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.');
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.'); }
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), verifierModulePath: resolve(values['verifier-module']), trustPolicyPath: resolve(values['trust-policy']),
protectedState, now: values.now,
distDir: resolve(values.dist),
signedManifestPath: resolve(values["signed-manifest"]),
sbomPath: resolve(values.sbom),
outputPath: resolve(values.output),
authorityRoot: resolve(values["authority-root"]),
protectedState,
};
}
export async function buildReleasePackage(input) {
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 verifySignedRelease(input);
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 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 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.');
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}`);
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}`);
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 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 },
{ 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 });
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.');
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 };
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() {
@@ -83,6 +184,14 @@ async function main() {
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; });
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;
});
}