feat: package atomic reversible nginx releases

This commit is contained in:
2026-09-02 10:27:28 +03:00
parent 8592581b18
commit d3e746b905
17 changed files with 1443 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
export interface BuildReleaseInput {
distDir: string;
signedManifestPath: string;
sbomPath: string;
outputPath: string;
verifierModulePath: string;
trustPolicyPath: string;
protectedState: Record<string, unknown>;
now: string;
verifySignedReleaseEnvelope?: (input: any) => any;
}
export function buildReleasePackage(input: BuildReleaseInput): Promise<{
releaseId: string;
manifestDigest: string;
archivePath: string;
}>;

88
scripts/build-release.mjs Executable file
View File

@@ -0,0 +1,88 @@
#!/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';
function parseArgs(args) {
const allowed = new Set(['dist', 'signed-manifest', 'sbom', 'output', 'verifier-module', 'trust-policy', 'protected-state', 'now']);
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), verifierModulePath: resolve(values['verifier-module']), trustPolicyPath: resolve(values['trust-policy']),
protectedState, now: values.now,
};
}
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);
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 };
}
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; });
}

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { activateRelease } from './lib/release-transaction.mjs';
async function main() {
const args = process.argv.slice(2);
if (args.length < 7 || args.length > 8) throw new Error('Usage: activate-release.mjs <root> <transaction-id> <expected-current|none> <expected-fence> <verifier-module> <trust-policy> <trusted-now> [test-crash-point]');
const [root, transactionId, expectedCurrentManifestDigest, expectedFence, verifierModulePath, trustPolicyPath, now, crashPoint] = args;
if (crashPoint !== undefined && process.env.PORTFOLIO_DEPLOY_TEST_HOOKS !== '1') throw new Error('Crash hooks are disabled outside the isolated test harness.');
const receipt = await activateRelease({ root: resolve(root), transactionId, expectedCurrentManifestDigest, expectedFence: Number(expectedFence),
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), now, crashPoint });
process.stdout.write(`${JSON.stringify(receipt)}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { process.stderr.write(`Release activation failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
if [[ $# -lt 1 ]]; then
echo 'Usage: activate-release.sh <dedicated-root> ...' >&2
exit 64
fi
release_root=$1
if [[ "$release_root" != /* || "$release_root" == / || ! -f "$release_root/.portfolio-release-root" ]]; then
echo 'Release root must be an explicit dedicated directory with a valid sentinel.' >&2
exit 65
fi
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
exec node "$script_dir/activate-release.mjs" "$@"

View File

@@ -0,0 +1,7 @@
export interface ArchiveFile {
path: string;
bytes: Uint8Array;
}
export function createDeterministicTar(files: readonly ArchiveFile[]): Buffer;
export function parseDeterministicTar(source: Uint8Array): ArchiveFile[];

View File

@@ -0,0 +1,343 @@
import { createHash } from 'node:crypto';
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import { byteDigest, canonicalJsonDigest, canonicalValueBytes, isSha256Hex, parseCanonicalJson } from '../../../src/security/canonical-digest.ts';
import { validatePublicPath } from '../../../src/security/production-input-root.ts';
import { validateReleaseManifest } from '../../../src/security/release-manifest.ts';
const encoder = new TextEncoder();
const reservedPrefix = '.release/';
const safeToken = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
export function exactObject(value, fields, label) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
const actual = Object.keys(value).sort();
const expected = [...fields].sort();
if (actual.length !== expected.length || actual.some((field, index) => field !== expected[index])) {
throw new Error(`${label} contains missing or unknown fields.`);
}
return value;
}
export function assertToken(value, label) {
if (typeof value !== 'string' || !safeToken.test(value)) throw new Error(`${label} is invalid.`);
return value;
}
export function assertTimestamp(value, label) {
if (typeof value !== 'string' || !timestamp.test(value) || Number.isNaN(Date.parse(value))) throw new Error(`${label} is invalid.`);
return value;
}
export function assertDigest(value, label) {
if (!isSha256Hex(value)) throw new Error(`${label} is invalid.`);
return value;
}
export async function readCanonicalFile(path, label) {
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} must be a regular file.`);
return parseCanonicalJson(await readFile(path));
}
export async function loadVerifier(modulePath) {
if (!isAbsolute(modulePath)) throw new Error('Verifier module path must be absolute.');
const info = await lstat(modulePath);
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Verifier module must be a regular file.');
const module = await import(/* @vite-ignore */ pathToFileURL(await realpath(modulePath)).href);
if (typeof module.verifySignedReleaseEnvelope !== 'function') throw new Error('Verifier module does not expose verifySignedReleaseEnvelope.');
return module.verifySignedReleaseEnvelope;
}
export function validateProtectedState(value) {
const state = exactObject(value, ['latestLedgerHeadHash', 'latestLedgerRevision', 'currentManifestDigest', 'publisherFence'], 'protected state');
assertDigest(state.latestLedgerHeadHash, 'protected ledger head');
if (!Number.isSafeInteger(state.latestLedgerRevision) || state.latestLedgerRevision < 0 ||
!Number.isSafeInteger(state.publisherFence) || state.publisherFence < 0 ||
(state.currentManifestDigest !== null && !isSha256Hex(state.currentManifestDigest))) throw new Error('Protected state is invalid.');
return state;
}
export function validateAdmission(value, protectedState, now) {
const admission = exactObject(value, [
'schema', 'manifestDigest', 'manifestPayload', 'expiresAt', 'predecessorManifestDigest', 'latestLedgerHeadHash',
'latestLedgerRevision', 'expectedFence', 'signatureVerified',
], 'verified release admission');
if (admission.schema !== 'portfolio-verified-release-admission/v1' || admission.signatureVerified !== true) {
throw new Error('Release signature was not verified.');
}
assertDigest(admission.manifestDigest, 'manifest digest');
assertTimestamp(now, 'trusted time');
assertTimestamp(admission.expiresAt, 'admission expiry');
if (Date.parse(now) >= Date.parse(admission.expiresAt)) throw new Error('Release admission is expired.');
if (admission.predecessorManifestDigest !== null) assertDigest(admission.predecessorManifestDigest, 'predecessor manifest digest');
const state = validateProtectedState(protectedState);
if (admission.latestLedgerHeadHash !== state.latestLedgerHeadHash || admission.latestLedgerRevision !== state.latestLedgerRevision) {
throw new Error('Release admission is stale relative to the protected ledger.');
}
if (admission.predecessorManifestDigest !== state.currentManifestDigest || admission.expectedFence !== state.publisherFence + 1) {
throw new Error('Release admission predecessor or fence is stale.');
}
const manifest = validateReleaseManifest(admission.manifestPayload, { now });
if (manifest.expiresAt !== admission.expiresAt || manifest.currentManifestDigest !== admission.predecessorManifestDigest ||
manifest.predecessorManifestDigest !== admission.predecessorManifestDigest) throw new Error('Admission does not bind the embedded release manifest.');
return { admission, manifest };
}
export async function verifySignedRelease({ signedManifestPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState, now }) {
const [signedRelease, trustPolicy] = await Promise.all([
readCanonicalFile(signedManifestPath, 'signed release'), readCanonicalFile(trustPolicyPath, 'trust policy'),
]);
const verifier = verifySignedReleaseEnvelope ?? await loadVerifier(verifierModulePath);
if (typeof verifier !== 'function') throw new Error('A release signature verifier is required.');
const result = await verifier({ signedRelease, trustPolicy, now, protectedState });
const verified = validateAdmission(result, protectedState, now);
return { ...verified, signedRelease, signedReleaseBytes: canonicalValueBytes(signedRelease) };
}
export async function enumerateRegularFiles(root) {
const rootPath = await realpath(root);
const output = [];
async function visit(directory, prefix = '') {
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
for (const entry of entries) {
const path = join(directory, entry.name);
const publicPath = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
if (entry.isSymbolicLink()) throw new Error(`Symlink is forbidden in release closure: ${publicPath}`);
if (entry.isDirectory()) await visit(path, publicPath);
else if (entry.isFile()) output.push({ path, publicPath });
else throw new Error(`Non-regular release input is forbidden: ${publicPath}`);
}
}
await visit(rootPath);
return output;
}
function splitTarPath(path) {
const bytes = encoder.encode(path);
if (bytes.length <= 100) return { name: path, prefix: '' };
const segments = path.split('/');
for (let index = segments.length - 1; index > 0; index -= 1) {
const prefix = segments.slice(0, index).join('/');
const name = segments.slice(index).join('/');
if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100) return { name, prefix };
}
throw new Error(`Release path is too long for deterministic ustar: ${path}`);
}
function writeString(target, offset, length, value) {
const bytes = encoder.encode(value);
if (bytes.length > length) throw new Error('Tar header value exceeds its field.');
target.set(bytes, offset);
}
function writeOctal(target, offset, length, value) {
const text = value.toString(8).padStart(length - 1, '0');
if (text.length !== length - 1) throw new Error('Tar numeric field overflow.');
writeString(target, offset, length, `${text}\0`);
}
function tarHeader(path, size) {
const header = new Uint8Array(512);
const { name, prefix } = splitTarPath(path);
writeString(header, 0, 100, name);
writeOctal(header, 100, 8, 0o444);
writeOctal(header, 108, 8, 0);
writeOctal(header, 116, 8, 0);
writeOctal(header, 124, 12, size);
writeOctal(header, 136, 12, 0);
header.fill(0x20, 148, 156);
writeString(header, 156, 1, '0');
writeString(header, 257, 6, 'ustar\0');
writeString(header, 263, 2, '00');
writeString(header, 345, 155, prefix);
const checksum = header.reduce((sum, byte) => sum + byte, 0);
const checksumText = checksum.toString(8).padStart(6, '0');
writeString(header, 148, 8, `${checksumText}\0 `);
return header;
}
export function createDeterministicTar(files) {
const ordered = [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)));
const seen = new Set();
const chunks = [];
for (const file of ordered) {
validatePublicPath(file.path);
if (seen.has(file.path)) throw new Error(`Duplicate release archive path: ${file.path}`);
seen.add(file.path);
const bytes = new Uint8Array(file.bytes);
chunks.push(tarHeader(file.path, bytes.length), bytes);
const remainder = bytes.length % 512;
if (remainder !== 0) chunks.push(new Uint8Array(512 - remainder));
}
chunks.push(new Uint8Array(1024));
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
}
function readString(bytes, start, length) {
const end = bytes.indexOf(0, start);
return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(start, end === -1 || end > start + length ? start + length : end));
}
function readOctal(bytes, start, length, label) {
const text = readString(bytes, start, length).trim();
if (!/^[0-7]+$/u.test(text)) throw new Error(`Invalid tar ${label}.`);
const value = Number.parseInt(text, 8);
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid tar ${label}.`);
return value;
}
export function parseDeterministicTar(source) {
const bytes = new Uint8Array(source);
const files = [];
const seen = new Set();
let offset = 0;
let zeroBlocks = 0;
while (offset + 512 <= bytes.length) {
const header = bytes.subarray(offset, offset + 512);
offset += 512;
if (header.every((byte) => byte === 0)) {
zeroBlocks += 1;
if (zeroBlocks === 2) break;
continue;
}
if (zeroBlocks !== 0) throw new Error('Malformed tar end marker.');
const expectedChecksum = readOctal(header, 148, 8, 'checksum');
const checksumHeader = new Uint8Array(header);
checksumHeader.fill(0x20, 148, 156);
if (checksumHeader.reduce((sum, byte) => sum + byte, 0) !== expectedChecksum) throw new Error('Tar checksum mismatch.');
if (readString(header, 257, 6) !== 'ustar' || String.fromCharCode(header[156] ?? 0) !== '0') throw new Error('Only deterministic regular ustar entries are accepted.');
const name = readString(header, 0, 100);
const prefix = readString(header, 345, 155);
const path = prefix === '' ? name : `${prefix}/${name}`;
validatePublicPath(path);
if (seen.has(path)) throw new Error(`Duplicate archive entry: ${path}`);
seen.add(path);
const size = readOctal(header, 124, 12, 'size');
if (offset + size > bytes.length) throw new Error('Truncated tar entry.');
files.push({ path, bytes: bytes.slice(offset, offset + size) });
offset += Math.ceil(size / 512) * 512;
}
if (zeroBlocks !== 2 || offset !== bytes.length) throw new Error('Tar archive has trailing or missing data.');
return files;
}
export function packageIndexFor({ admission, manifest, signedBytes, sbomBytes }) {
const artifacts = [...manifest.artifacts].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)));
return {
schema: 'portfolio-release-package/v1',
buildId: manifest.buildId,
releaseId: `${manifest.buildId}-${admission.manifestDigest}`,
manifestDigest: admission.manifestDigest,
predecessorManifestDigest: admission.predecessorManifestDigest,
expiresAt: admission.expiresAt,
expectedFence: admission.expectedFence,
latestLedgerHeadHash: admission.latestLedgerHeadHash,
latestLedgerRevision: admission.latestLedgerRevision,
signedManifest: { path: '.release/signed-release-manifest.json', sha256: byteDigest(signedBytes), bytes: signedBytes.length },
sbom: { path: '.release/sbom.cdx.json', sha256: byteDigest(sbomBytes), bytes: sbomBytes.length },
artifacts,
};
}
export function validatePackageFiles(files) {
const byPath = new Map(files.map((file) => [file.path, file]));
const indexFile = byPath.get('.release/package-index.json');
if (indexFile === undefined) throw new Error('Release package index is missing.');
const index = exactObject(parseCanonicalJson(indexFile.bytes), [
'schema', 'buildId', 'releaseId', 'manifestDigest', 'predecessorManifestDigest', 'expiresAt', 'expectedFence',
'latestLedgerHeadHash', 'latestLedgerRevision', 'signedManifest', 'sbom', 'artifacts',
], 'release package index');
if (index.schema !== 'portfolio-release-package/v1') throw new Error('Release package schema is invalid.');
assertToken(index.buildId, 'package buildId');
assertToken(index.releaseId, 'package releaseId');
assertDigest(index.manifestDigest, 'package manifest digest');
assertTimestamp(index.expiresAt, 'package expiry');
if (!Number.isSafeInteger(index.expectedFence) || index.expectedFence < 1) throw new Error('Package fence is invalid.');
const metadata = [index.signedManifest, index.sbom];
for (const record of metadata) {
exactObject(record, ['path', 'sha256', 'bytes'], 'package metadata record');
const file = byPath.get(record.path);
if (file === undefined || byteDigest(file.bytes) !== record.sha256 || file.bytes.length !== record.bytes) throw new Error('Package metadata hash or size mismatch.');
}
if (!Array.isArray(index.artifacts) || index.artifacts.length === 0) throw new Error('Package artifact closure is empty.');
const expectedPaths = new Set(['.release/package-index.json', '.release/signed-release-manifest.json', '.release/sbom.cdx.json']);
for (const artifact of index.artifacts) {
exactObject(artifact, ['path', 'sha256', 'bytes', 'encoding'], 'package artifact');
if (typeof artifact.path !== 'string' || artifact.path.startsWith(reservedPrefix)) throw new Error('Package artifact uses a reserved path.');
const file = byPath.get(artifact.path);
if (file === undefined || byteDigest(file.bytes) !== artifact.sha256 || file.bytes.length !== artifact.bytes) throw new Error(`Artifact hash or size mismatch: ${artifact.path}`);
expectedPaths.add(artifact.path);
}
if (expectedPaths.size !== byPath.size || [...byPath.keys()].some((path) => !expectedPaths.has(path))) throw new Error('Release package files do not exactly match the signed closure.');
return { index, byPath };
}
export async function writeArchiveFiles(files, targetRoot) {
await mkdir(targetRoot, { recursive: false, mode: 0o700 });
const root = resolve(targetRoot);
for (const file of files) {
const destination = resolve(root, file.path);
if (relative(root, destination).startsWith(`..${sep}`) || destination === root) throw new Error('Archive entry escapes staging root.');
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
await writeFile(destination, file.bytes, { flag: 'wx', mode: 0o600 });
}
}
export async function makeImmutable(root, uid, gid) {
if (!Number.isSafeInteger(uid) || uid < 0 || !Number.isSafeInteger(gid) || gid < 0) throw new Error('Release owner UID/GID is invalid.');
async function visit(path) {
const info = await lstat(path);
if (info.isSymbolicLink()) throw new Error('Symlink found in immutable release.');
if (info.uid !== uid || info.gid !== gid) throw new Error(`Release ownership mismatch at ${path}.`);
if (info.isDirectory()) {
for (const entry of await readdir(path)) await visit(join(path, entry));
await chmod(path, 0o555);
} else if (info.isFile()) await chmod(path, 0o444);
else throw new Error('Immutable release contains a non-file entry.');
}
await visit(root);
}
export async function assertReleaseRoot(rootPath) {
if (typeof rootPath !== 'string' || !isAbsolute(rootPath)) throw new Error('Release root must be an explicit absolute path.');
const normalized = resolve(rootPath);
if (normalized === '/' || normalized.split('/').filter(Boolean).length < 2) throw new Error('Broad release root is forbidden.');
const info = await lstat(normalized);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Release root must be a real dedicated directory.');
const marker = await readFile(join(normalized, '.portfolio-release-root'), 'utf8').catch((error) => {
if (error?.code === 'ENOENT') throw new Error('Release root sentinel is missing or invalid.');
throw error;
});
if (marker !== 'portfolio-release-root/v1\n') throw new Error('Release root sentinel is missing or invalid.');
return normalized;
}
export async function atomicCanonicalWrite(path, value, mode = 0o600) {
const temporary = `${path}.next-${process.pid}`;
const handle = await open(temporary, 'wx', mode);
try {
await handle.writeFile(canonicalValueBytes(value));
await handle.sync();
} finally {
await handle.close();
}
await rename(temporary, path);
}
export async function safeRemoveStage(root, stageName) {
assertToken(stageName, 'stage name');
const staging = join(root, 'staging');
const target = join(staging, stageName);
const rel = relative(staging, target);
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('Stage cleanup escaped its root.');
await rm(target, { recursive: true, force: true });
}
export function digestBytes(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}

View File

@@ -0,0 +1,34 @@
export interface TransactionInput {
root: string;
archivePath: string;
transactionId: string;
verifierModulePath: string;
trustPolicyPath: string;
uid: number;
gid: number;
now?: string;
clock?: () => string;
verifySignedReleaseEnvelope?: (input: any) => any;
smokeCheck?: (input: { releaseDir: string; manifestDigest: string }) => Promise<Uint8Array>;
smokeCommand?: string;
expectedCurrentManifestDigest?: string | null;
expectedFence?: number;
crashPoint?: 'before-swap' | 'after-swap';
targetReleaseId?: string;
}
export interface ControlState {
schema: 'portfolio-release-control-state/v1';
currentManifestDigest: string | null;
currentReleaseId: string | null;
publisherFence: number;
latestLedgerHeadHash: string;
latestLedgerRevision: number;
activeTransaction: null | Record<string, unknown>;
}
export function readControlState(root: string): Promise<ControlState>;
export function prepareRelease(input: TransactionInput): Promise<Record<string, unknown>>;
export function activateRelease(input: TransactionInput): Promise<Record<string, any>>;
export function rollbackRelease(input: TransactionInput): Promise<Record<string, any>>;
export function cleanupOrphanStages(root: string): Promise<string[]>;

View File

@@ -0,0 +1,299 @@
import { spawn } from 'node:child_process';
import { chmod, lstat, mkdir, open, readFile, readlink, readdir, rename, symlink, unlink } from 'node:fs/promises';
import { isAbsolute, join, resolve } from 'node:path';
import { byteDigest, canonicalJsonDigest, canonicalValueBytes, parseCanonicalJson } from '../../../src/security/canonical-digest.ts';
import {
assertDigest, assertReleaseRoot, assertTimestamp, assertToken, atomicCanonicalWrite, exactObject, makeImmutable,
safeRemoveStage, verifySignedRelease, writeArchiveFiles,
} from './release-common.mjs';
import { verifyStagedRelease } from '../verify-staged-release.mjs';
const stateFields = ['schema', 'currentManifestDigest', 'currentReleaseId', 'publisherFence', 'latestLedgerHeadHash', 'latestLedgerRevision', 'activeTransaction'];
const transactionFields = ['transactionId', 'manifestDigest', 'releaseId', 'expectedFence', 'phase', 'readinessReceiptDigest'];
const readinessFields = ['schema', 'transactionId', 'manifestDigest', 'releaseId', 'predecessorManifestDigest', 'expectedFence', 'latestLedgerHeadHash', 'latestLedgerRevision', 'expiresAt', 'packageDigest', 'smokeEvidenceDigest', 'createdAt'];
function nowFrom(input) {
const value = typeof input === 'function' ? input() : input ?? new Date().toISOString();
return assertTimestamp(value, 'trusted current time');
}
function normalizeExpectedDigest(value) {
if (value === null || value === 'none') return null;
return assertDigest(value, 'expected current manifest digest');
}
export function validateControlState(value) {
const state = exactObject(value, stateFields, 'release control state');
if (state.schema !== 'portfolio-release-control-state/v1') throw new Error('Release control state schema is invalid.');
if (state.currentManifestDigest !== null) assertDigest(state.currentManifestDigest, 'current manifest digest');
if (state.currentReleaseId !== null) assertToken(state.currentReleaseId, 'current release ID');
if (!Number.isSafeInteger(state.publisherFence) || state.publisherFence < 0 || !Number.isSafeInteger(state.latestLedgerRevision) || state.latestLedgerRevision < 0) throw new Error('Release control counters are invalid.');
assertDigest(state.latestLedgerHeadHash, 'live ledger head');
if ((state.currentManifestDigest === null) !== (state.currentReleaseId === null)) throw new Error('Current release pointer state is incoherent.');
if (state.activeTransaction !== null) {
const tx = exactObject(state.activeTransaction, transactionFields, 'active release transaction');
assertToken(tx.transactionId, 'transaction ID'); assertDigest(tx.manifestDigest, 'transaction manifest digest'); assertToken(tx.releaseId, 'transaction release ID');
if (!Number.isSafeInteger(tx.expectedFence) || tx.expectedFence !== state.publisherFence + 1 || !['preparing', 'ready', 'committing'].includes(tx.phase)) throw new Error('Active transaction fence or phase is invalid.');
if (tx.readinessReceiptDigest !== null) assertDigest(tx.readinessReceiptDigest, 'readiness receipt digest');
}
return state;
}
export async function readControlState(root) {
return validateControlState(parseCanonicalJson(await readFile(join(root, 'control/state.json'))));
}
async function withExclusiveLock(root, callback) {
const lockPath = join(root, 'control', 'transaction.lock');
let handle;
for (let attempt = 0; attempt < 200; attempt += 1) {
try {
handle = await open(lockPath, 'wx', 0o600);
await handle.writeFile(`${process.pid}\n`);
await handle.sync();
break;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
const owner = await readFile(lockPath, 'utf8').catch(() => '');
if (!/^\d+\n$/u.test(owner)) throw new Error('Release transaction lock is malformed; protected recovery is required.');
try { process.kill(Number(owner.trim()), 0); } catch (probeError) {
if (probeError?.code !== 'ESRCH') throw probeError;
await unlink(lockPath).catch((unlinkError) => { if (unlinkError?.code !== 'ENOENT') throw unlinkError; });
continue;
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 5));
}
}
if (handle === undefined) throw new Error('Could not obtain the exclusive release transaction lock.');
try { return await callback(); } finally { await handle.close(); await unlink(lockPath).catch(() => undefined); }
}
async function runSmokeCommand(command, releaseDir, manifestDigest) {
if (typeof command !== 'string' || !isAbsolute(command)) throw new Error('Smoke command must be an absolute executable path.');
const info = await lstat(command);
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o111) === 0) throw new Error('Smoke command is not an executable regular file.');
return await new Promise((resolvePromise, reject) => {
const child = spawn(command, ['--release-dir', releaseDir, '--manifest-digest', manifestDigest], { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
const stdout = []; const stderr = [];
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Staged smoke check timed out.')); }, 15_000);
child.stdout.on('data', (chunk) => stdout.push(chunk)); child.stderr.on('data', (chunk) => stderr.push(chunk));
child.once('error', (error) => { clearTimeout(timer); reject(error); });
child.once('close', (code) => {
clearTimeout(timer);
if (code !== 0) reject(new Error(`Staged smoke check failed: ${Buffer.concat(stderr).toString('utf8').slice(0, 200)}`));
else resolvePromise(Buffer.concat(stdout));
});
});
}
async function prepareUnlocked(input) {
const root = await assertReleaseRoot(input.root);
const transactionId = assertToken(input.transactionId, 'transaction ID');
const statePath = join(root, 'control/state.json');
const state = await readControlState(root);
if (state.activeTransaction !== null) throw new Error('Another release transaction is already active.');
const firstNow = nowFrom(input.clock ?? input.now);
const verified = await verifyStagedRelease({
archivePath: resolve(input.archivePath), verifierModulePath: input.verifierModulePath,
verifySignedReleaseEnvelope: input.verifySignedReleaseEnvelope, trustPolicyPath: input.trustPolicyPath,
protectedState: {
latestLedgerHeadHash: state.latestLedgerHeadHash, latestLedgerRevision: state.latestLedgerRevision,
currentManifestDigest: state.currentManifestDigest, publisherFence: state.publisherFence,
}, now: firstNow,
});
const { index, admission, files } = verified;
if (input.rollbackTargetReleaseId !== undefined) {
const targetId = assertToken(input.rollbackTargetReleaseId, 'rollback target release ID');
const targetIndex = parseCanonicalJson(await readFile(join(root, 'releases', targetId, '.release/package-index.json')));
if (targetIndex.manifestDigest === index.manifestDigest) throw new Error('Rollback must use a newly signed manifest, not replay an old one.');
const left = canonicalValueBytes(targetIndex.artifacts);
const right = canonicalValueBytes(index.artifacts);
if (!Buffer.from(left).equals(Buffer.from(right))) throw new Error('Rollback package does not bind the selected immutable release artifact closure.');
}
const activeTransaction = {
transactionId, manifestDigest: admission.manifestDigest, releaseId: index.releaseId, expectedFence: admission.expectedFence,
phase: 'preparing', readinessReceiptDigest: null,
};
await atomicCanonicalWrite(statePath, { ...state, activeTransaction });
const partialName = `${transactionId}.partial`;
const partialDir = join(root, 'staging', partialName);
const releaseDir = join(root, 'releases', index.releaseId);
try {
await mkdir(join(root, 'staging'), { recursive: true, mode: 0o750 });
await mkdir(join(root, 'releases'), { recursive: true, mode: 0o750 });
await mkdir(join(root, 'receipts', 'readiness'), { recursive: true, mode: 0o750 });
await safeRemoveStage(root, partialName);
await writeArchiveFiles(files.map((file) => file.path.startsWith('.release/')
? file
: { ...file, path: `public/${file.path}` }), partialDir);
await makeImmutable(partialDir, input.uid, input.gid);
const publicDir = join(partialDir, 'public');
const smokeBytes = input.smokeCheck !== undefined
? await input.smokeCheck({ releaseDir: publicDir, manifestDigest: admission.manifestDigest })
: await runSmokeCommand(input.smokeCommand, publicDir, admission.manifestDigest);
const secondNow = nowFrom(input.clock ?? input.now);
if (Date.parse(secondNow) >= Date.parse(admission.expiresAt)) throw new Error('Release expired while staging or smoke-checking.');
const stateAfterSmoke = await readControlState(root);
if (stateAfterSmoke.latestLedgerHeadHash !== admission.latestLedgerHeadHash || stateAfterSmoke.latestLedgerRevision !== admission.latestLedgerRevision ||
stateAfterSmoke.currentManifestDigest !== admission.predecessorManifestDigest || stateAfterSmoke.publisherFence + 1 !== admission.expectedFence) {
throw new Error('Protected state changed while staging the release.');
}
await chmod(partialDir, 0o755);
await rename(partialDir, releaseDir).catch((error) => {
if (error?.code === 'EEXIST') throw new Error('Immutable release ID already exists.');
throw error;
});
await chmod(releaseDir, 0o555);
const receipt = {
schema: 'portfolio-release-readiness-receipt/v1', transactionId, manifestDigest: admission.manifestDigest,
releaseId: index.releaseId, predecessorManifestDigest: admission.predecessorManifestDigest, expectedFence: admission.expectedFence,
latestLedgerHeadHash: admission.latestLedgerHeadHash, latestLedgerRevision: admission.latestLedgerRevision,
expiresAt: admission.expiresAt, packageDigest: byteDigest(await readFile(input.archivePath)),
smokeEvidenceDigest: byteDigest(smokeBytes instanceof Uint8Array ? smokeBytes : Buffer.from(String(smokeBytes))), createdAt: secondNow,
};
const receiptDigest = canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt);
const receiptPath = join(root, 'receipts', 'readiness', `${transactionId}.json`);
await atomicCanonicalWrite(receiptPath, receipt, 0o440);
await chmod(receiptPath, 0o440);
await atomicCanonicalWrite(statePath, { ...stateAfterSmoke, activeTransaction: { ...activeTransaction, phase: 'ready', readinessReceiptDigest: receiptDigest } });
return { receipt, receiptDigest, releaseDir };
} catch (error) {
await makeWritable(partialDir).catch(() => undefined);
await safeRemoveStage(root, partialName).catch(() => undefined);
const latest = await readControlState(root).catch(() => null);
if (latest?.activeTransaction?.transactionId === transactionId && latest.activeTransaction.phase !== 'committing') {
await atomicCanonicalWrite(statePath, { ...latest, activeTransaction: null }).catch(() => undefined);
}
throw error;
}
}
export async function prepareRelease(input) {
const root = await assertReleaseRoot(input.root);
return input.lock === false ? prepareUnlocked({ ...input, root }) : withExclusiveLock(root, () => prepareUnlocked({ ...input, root }));
}
function validateReadiness(value) {
const receipt = exactObject(value, readinessFields, 'readiness receipt');
if (receipt.schema !== 'portfolio-release-readiness-receipt/v1') throw new Error('Readiness receipt schema is invalid.');
assertToken(receipt.transactionId, 'readiness transaction ID'); assertDigest(receipt.manifestDigest, 'readiness manifest digest');
assertToken(receipt.releaseId, 'readiness release ID'); assertTimestamp(receipt.expiresAt, 'readiness expiry');
assertDigest(receipt.latestLedgerHeadHash, 'readiness ledger head'); assertDigest(receipt.packageDigest, 'readiness package digest'); assertDigest(receipt.smokeEvidenceDigest, 'smoke evidence digest');
return receipt;
}
async function signedManifestFromRelease(root, releaseId) {
return join(root, 'releases', releaseId, '.release/signed-release-manifest.json');
}
async function currentTarget(root) {
try { return await readlink(join(root, 'current')); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
}
async function finalizeActivation(root, state, tx, receipt, committedAt, recovered) {
const newState = { ...state, currentManifestDigest: tx.manifestDigest, currentReleaseId: tx.releaseId, publisherFence: tx.expectedFence, activeTransaction: null };
await mkdir(join(root, 'receipts', 'activation'), { recursive: true, mode: 0o750 });
const activationReceipt = {
schema: 'portfolio-release-activation-receipt/v1', transactionId: tx.transactionId, predecessorManifestDigest: receipt.predecessorManifestDigest,
manifestDigest: tx.manifestDigest, releaseId: tx.releaseId, fence: tx.expectedFence,
readinessReceiptDigest: canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt), committedAt, recoveredAfterPointerSwap: recovered,
};
const path = join(root, 'receipts', 'activation', `${tx.transactionId}.json`);
await atomicCanonicalWrite(path, activationReceipt, 0o440);
await chmod(path, 0o440);
await atomicCanonicalWrite(join(root, 'control/state.json'), newState);
return activationReceipt;
}
async function activateUnlocked(input) {
const root = await assertReleaseRoot(input.root);
const transactionId = assertToken(input.transactionId, 'transaction ID');
const expectedCurrent = normalizeExpectedDigest(input.expectedCurrentManifestDigest);
if (!Number.isSafeInteger(input.expectedFence) || input.expectedFence < 1) throw new Error('Expected fence is invalid.');
let state = await readControlState(root);
const tx = state.activeTransaction;
if (tx === null || tx.transactionId !== transactionId || !['ready', 'committing'].includes(tx.phase)) throw new Error('Named transaction is not ready for activation.');
const receipt = validateReadiness(parseCanonicalJson(await readFile(join(root, 'receipts', 'readiness', `${transactionId}.json`))));
if (tx.readinessReceiptDigest !== canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt)) throw new Error('Readiness receipt digest does not match the protected transaction.');
const target = `releases/${tx.releaseId}/public`;
const actualCurrent = await currentTarget(root);
if (tx.phase === 'committing' && actualCurrent === target) {
return finalizeActivation(root, state, tx, receipt, nowFrom(input.clock ?? input.now), true);
}
if (state.currentManifestDigest !== expectedCurrent || tx.expectedFence !== input.expectedFence || state.publisherFence + 1 !== input.expectedFence ||
receipt.predecessorManifestDigest !== expectedCurrent || receipt.expectedFence !== input.expectedFence) throw new Error('Activation predecessor or fence is stale.');
const activationNow = nowFrom(input.clock ?? input.now);
if (Date.parse(activationNow) >= Date.parse(receipt.expiresAt)) throw new Error('Release expired before activation.');
const protectedState = {
latestLedgerHeadHash: state.latestLedgerHeadHash, latestLedgerRevision: state.latestLedgerRevision,
currentManifestDigest: state.currentManifestDigest, publisherFence: state.publisherFence,
};
const verified = await verifySignedRelease({
signedManifestPath: await signedManifestFromRelease(root, tx.releaseId), verifierModulePath: input.verifierModulePath,
verifySignedReleaseEnvelope: input.verifySignedReleaseEnvelope, trustPolicyPath: input.trustPolicyPath,
protectedState, now: activationNow,
});
if (verified.admission.manifestDigest !== tx.manifestDigest || verified.admission.expectedFence !== tx.expectedFence ||
verified.admission.latestLedgerHeadHash !== receipt.latestLedgerHeadHash || verified.admission.latestLedgerRevision !== receipt.latestLedgerRevision) {
throw new Error('Activation signature admission does not match readiness.');
}
state = await readControlState(root);
if (state.currentManifestDigest !== expectedCurrent || state.publisherFence + 1 !== input.expectedFence || state.activeTransaction?.transactionId !== transactionId) {
throw new Error('Protected state changed before activation commit.');
}
await atomicCanonicalWrite(join(root, 'control/state.json'), { ...state, activeTransaction: { ...tx, phase: 'committing' } });
const nextPath = join(root, 'current.next');
const nextInfo = await lstat(nextPath).catch((error) => error?.code === 'ENOENT' ? null : Promise.reject(error));
if (nextInfo !== null) {
if (!nextInfo.isSymbolicLink()) throw new Error('current.next is not a symlink and will not be overwritten.');
await unlink(nextPath);
}
await symlink(target, nextPath);
if (input.crashPoint === 'before-swap') throw new Error('Injected crash before pointer swap.');
await rename(nextPath, join(root, 'current'));
if (input.crashPoint === 'after-swap') throw new Error('Injected crash after pointer swap.');
return finalizeActivation(root, state, tx, receipt, activationNow, false);
}
export async function activateRelease(input) {
const root = await assertReleaseRoot(input.root);
return input.lock === false ? activateUnlocked({ ...input, root }) : withExclusiveLock(root, () => activateUnlocked({ ...input, root }));
}
export async function rollbackRelease(input) {
const root = await assertReleaseRoot(input.root);
return withExclusiveLock(root, async () => {
await prepareUnlocked({ ...input, root, rollbackTargetReleaseId: input.targetReleaseId, lock: false });
return activateUnlocked({ ...input, root, lock: false });
});
}
async function makeWritable(path) {
const info = await lstat(path);
if (info.isDirectory()) {
await chmod(path, 0o700);
for (const entry of await readdir(path)) await makeWritable(join(path, entry));
} else if (info.isFile()) await chmod(path, 0o600);
else throw new Error('Orphan staging contains a non-file entry.');
}
export async function cleanupOrphanStages(rootPath) {
const root = await assertReleaseRoot(rootPath);
const staging = join(root, 'staging');
await mkdir(staging, { recursive: true, mode: 0o750 });
const state = await readControlState(root);
const activePrefix = state.activeTransaction === null ? null : `${state.activeTransaction.transactionId}.`;
const removed = [];
for (const entry of await readdir(staging, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.partial$/u.test(entry.name)) {
throw new Error('Unexpected staging entry blocks orphan cleanup.');
}
if (activePrefix !== null && entry.name.startsWith(activePrefix)) continue;
const path = join(staging, entry.name);
await makeWritable(path);
await safeRemoveStage(root, entry.name);
removed.push(entry.name);
}
return removed;
}

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { cleanupOrphanStages, prepareRelease } from './lib/release-transaction.mjs';
async function main() {
const args = process.argv.slice(2);
if (args.length === 2 && args[1] === '--cleanup-orphans') {
const removed = await cleanupOrphanStages(resolve(args[0]));
process.stdout.write(`${JSON.stringify({ removed })}\n`); return;
}
if (args.length !== 9) throw new Error('Usage: prepare-release.mjs <root> <archive> <transaction-id> <verifier-module> <trust-policy> <smoke-command> <uid> <gid> <trusted-now>');
const [root, archivePath, transactionId, verifierModulePath, trustPolicyPath, smokeCommand, uid, gid, now] = args;
const result = await prepareRelease({ root: resolve(root), archivePath: resolve(archivePath), transactionId,
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), smokeCommand: resolve(smokeCommand),
uid: Number(uid), gid: Number(gid), now });
process.stdout.write(`${JSON.stringify({ manifestDigest: result.receipt.manifestDigest, releaseId: result.receipt.releaseId })}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { process.stderr.write(`Release preparation failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
if [[ $# -lt 1 ]]; then
echo 'Usage: prepare-release.sh <dedicated-root> ...' >&2
exit 64
fi
release_root=$1
if [[ "$release_root" != /* || "$release_root" == / || ! -f "$release_root/.portfolio-release-root" ]]; then
echo 'Release root must be an explicit dedicated directory with a valid sentinel.' >&2
exit 65
fi
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
exec node "$script_dir/prepare-release.mjs" "$@"

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { rollbackRelease } from './lib/release-transaction.mjs';
async function main() {
const args = process.argv.slice(2);
if (args.length !== 12) throw new Error('Usage: rollback-release.mjs <root> <target-release-id> <new-release-archive> <transaction-id> <verifier-module> <trust-policy> <smoke-command> <uid> <gid> <expected-current> <expected-fence> <trusted-now>');
const [root, targetReleaseId, archivePath, transactionId, verifierModulePath, trustPolicyPath, smokeCommand, uid, gid, expectedCurrentManifestDigest, expectedFence, now] = args;
const receipt = await rollbackRelease({ root: resolve(root), targetReleaseId, archivePath: resolve(archivePath), transactionId,
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), smokeCommand: resolve(smokeCommand),
uid: Number(uid), gid: Number(gid), expectedCurrentManifestDigest, expectedFence: Number(expectedFence), now });
process.stdout.write(`${JSON.stringify(receipt)}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { process.stderr.write(`Release rollback failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
if [[ $# -lt 1 ]]; then
echo 'Usage: rollback-release.sh <dedicated-root> ...' >&2
exit 64
fi
release_root=$1
if [[ "$release_root" != /* || "$release_root" == / || ! -f "$release_root/.portfolio-release-root" ]]; then
echo 'Release root must be an explicit dedicated directory with a valid sentinel.' >&2
exit 65
fi
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
exec node "$script_dir/rollback-release.mjs" "$@"

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env node
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { canonicalValueBytes } from '../../src/security/canonical-digest.ts';
import { parseDeterministicTar, validatePackageFiles, verifySignedRelease } from './lib/release-common.mjs';
export async function verifyStagedRelease({ archivePath, verifierModulePath, verifySignedReleaseEnvelope, trustPolicyPath, protectedState, now }) {
const files = parseDeterministicTar(await readFile(archivePath));
const { index, byPath } = validatePackageFiles(files);
const temporaryRoot = await mkdtemp(join(tmpdir(), 'portfolio-release-verify-'));
const signedPath = join(temporaryRoot, 'signed-release.json');
const { writeFile, rm } = await import('node:fs/promises');
await writeFile(signedPath, byPath.get('.release/signed-release-manifest.json').bytes, { flag: 'wx', mode: 0o400 });
try {
const verified = await verifySignedRelease({ signedManifestPath: signedPath, verifierModulePath, verifySignedReleaseEnvelope, trustPolicyPath, protectedState, now });
if (verified.admission.manifestDigest !== index.manifestDigest || verified.manifest.buildId !== index.buildId ||
`${verified.manifest.buildId}-${verified.admission.manifestDigest}` !== index.releaseId ||
verified.admission.expectedFence !== index.expectedFence || verified.admission.expiresAt !== index.expiresAt) {
throw new Error('Package index does not bind the verified signed release admission.');
}
return { files, index, ...verified };
} finally {
await rm(temporaryRoot, { recursive: true, force: true });
}
}
async function main() {
const [archivePath, verifierModulePath, trustPolicyPath, protectedStateJson, now] = process.argv.slice(2);
if (!archivePath || !verifierModulePath || !trustPolicyPath || !protectedStateJson || !now || process.argv.length !== 7) throw new Error('Usage: verify-staged-release.mjs <archive> <verifier-module> <trust-policy> <protected-state-json> <trusted-now>');
const result = await verifyStagedRelease({ archivePath: resolve(archivePath), verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), protectedState: JSON.parse(protectedStateJson), now });
process.stdout.write(Buffer.from(canonicalValueBytes(result.admission))); process.stdout.write('\n');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => { process.stderr.write(`Staged release verification failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
}