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

@@ -3,11 +3,17 @@ export interface BuildReleaseInput {
signedManifestPath: string;
sbomPath: string;
outputPath: string;
verifierModulePath: string;
trustPolicyPath: string;
authorityRoot: string;
protectedState: Record<string, unknown>;
}
export interface BuildReleaseTestInput extends Omit<
BuildReleaseInput,
"authorityRoot"
> {
trustPolicyPath: string;
now: string;
verifySignedReleaseEnvelope?: (input: any) => any;
verifySignedReleaseEnvelope: (input: any) => unknown;
}
export function buildReleasePackage(input: BuildReleaseInput): Promise<{
@@ -15,3 +21,6 @@ export function buildReleasePackage(input: BuildReleaseInput): Promise<{
manifestDigest: string;
archivePath: string;
}>;
export function buildReleasePackageForTest(
input: BuildReleaseTestInput,
): ReturnType<typeof buildReleasePackage>;

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;
});
}

View File

@@ -1,15 +1,28 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { activateRelease } from './lib/release-transaction.mjs';
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 });
if (args.length !== 4)
throw new Error(
"Usage: activate-release.mjs <root> <transaction-id> <expected-current|none> <expected-fence>",
);
const [root, transactionId, expectedCurrentManifestDigest, expectedFence] =
args;
const receipt = await activateRelease({
root: resolve(root),
transactionId,
expectedCurrentManifestDigest,
expectedFence: Number(expectedFence),
});
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; });
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

@@ -1,34 +1,79 @@
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';
export interface ProtectedControlState {
schema: "portfolio-release-control-state/v1";
currentManifestDigest: string | null;
currentReleaseId: string | null;
publisherFence: number;
latestLedgerHeadHash: string;
latestLedgerRevision: number;
releaseTrustPolicyDigest: string;
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 interface ProductionPrepareInput {
root: string;
archivePath: string;
transactionId: string;
smokeCommand: string;
}
export interface ProductionActivateInput {
root: string;
transactionId: string;
expectedCurrentManifestDigest: string | null;
expectedFence: number;
}
export interface ProductionRollbackInput
extends ProductionPrepareInput, ProductionActivateInput {
targetReleaseId: string;
}
export interface TestTransactionInput {
root: string;
archivePath: string;
transactionId: string;
testOnlyVerification: {
trustPolicyPath: string;
verifySignedReleaseEnvelope: (input: any) => any;
};
testClock?: () => string;
smokeCheck?: (input: {
releaseDir: string;
manifestDigest: string;
}) => Promise<Uint8Array>;
smokeCommand?: string;
expectedCurrentManifestDigest?: string | null;
expectedFence?: number;
crashPoint?: "before-swap" | "after-swap";
testPreparationCrashPoint?:
| "after-intent"
| "after-extract"
| "after-immutable"
| "after-release-rename"
| "after-receipt";
testBeforeCommit?: () => Promise<void>;
testBeforePointerSwap?: () => Promise<void>;
targetReleaseId?: string;
lock?: boolean;
}
export function readControlState(root: string): Promise<ProtectedControlState>;
export function prepareRelease(
input: ProductionPrepareInput,
): Promise<Record<string, unknown>>;
export function prepareReleaseForTest(
input: TestTransactionInput,
): Promise<Record<string, unknown>>;
export function activateRelease(
input: ProductionActivateInput,
): Promise<Record<string, any>>;
export function activateReleaseForTest(
input: TestTransactionInput,
): Promise<Record<string, any>>;
export function rollbackRelease(
input: ProductionRollbackInput,
): Promise<Record<string, any>>;
export function rollbackReleaseForTest(
input: TestTransactionInput,
): Promise<Record<string, any>>;
export function cleanupOrphanStages(root: string): Promise<string[]>;

View File

@@ -1,87 +1,260 @@
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 { spawn } from "node:child_process";
import {
assertDigest, assertReleaseRoot, assertTimestamp, assertToken, atomicCanonicalWrite, exactObject, makeImmutable,
safeRemoveStage, verifySignedRelease, writeArchiveFiles,
} from './release-common.mjs';
import { verifyStagedRelease } from '../verify-staged-release.mjs';
chmod,
lstat,
mkdir,
open,
readFile,
readlink,
readdir,
rename,
rm,
symlink,
unlink,
} from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
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'];
import {
byteDigest,
canonicalJsonDigest,
canonicalValueBytes,
parseCanonicalJson,
} from "../../../src/security/canonical-digest.ts";
import {
assertDigest,
assertReleaseRoot,
assertTimestamp,
assertToken,
atomicCanonicalWrite,
exactObject,
makeImmutable,
safeRemoveStage,
verifySignedRelease,
writeArchiveFiles,
verifySignedReleaseForTest,
} from "./release-common.mjs";
import {
verifyStagedRelease,
verifyStagedReleaseForTest,
} from "../verify-staged-release.mjs";
const stateFields = [
"schema",
"currentManifestDigest",
"currentReleaseId",
"publisherFence",
"latestLedgerHeadHash",
"latestLedgerRevision",
"releaseTrustPolicyDigest",
"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');
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');
if (value === null || value === "none") return null;
return assertDigest(value, "expected current manifest digest");
}
function protectedStateFrom(state) {
return {
latestLedgerHeadHash: state.latestLedgerHeadHash,
latestLedgerRevision: state.latestLedgerRevision,
currentManifestDigest: state.currentManifestDigest,
publisherFence: state.publisherFence,
releaseTrustPolicyDigest: state.releaseTrustPolicyDigest,
};
}
async function syncDirectory(path) {
const handle = await open(path, "r");
try {
await handle.sync();
} finally {
await handle.close();
}
}
async function durableCanonicalWrite(path, value, mode = 0o600) {
await atomicCanonicalWrite(path, value, mode);
await syncDirectory(dirname(path));
}
async function recoverInterruptedPreparation(root, state) {
const tx = state.activeTransaction;
if (tx === null || tx.phase !== "preparing") return state;
const target = `releases/${tx.releaseId}/public`;
if ((await currentTarget(root)) === target)
throw new Error(
"Preparing transaction unexpectedly controls the public pointer; privileged recovery is required.",
);
const partialDir = join(root, "staging", `${tx.transactionId}.partial`);
const releaseDir = join(root, "releases", tx.releaseId);
for (const path of [partialDir, releaseDir]) {
if (
await lstat(path).catch((error) =>
error?.code === "ENOENT" ? null : Promise.reject(error),
)
) {
await makeWritable(path);
await rm(path, { recursive: true, force: true });
}
}
await unlink(
join(root, "receipts", "readiness", `${tx.transactionId}.json`),
).catch((error) => {
if (error?.code !== "ENOENT") throw error;
});
const recovered = { ...state, activeTransaction: null };
await durableCanonicalWrite(join(root, "control/state.json"), recovered);
return recovered;
}
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.');
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");
assertDigest(state.releaseTrustPolicyDigest, "release trust-policy digest");
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');
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'))));
return validateControlState(
parseCanonicalJson(await readFile(join(root, "control/state.json"))),
);
}
async function withExclusiveLock(root, callback) {
const lockPath = join(root, 'control', 'transaction.lock');
const lockPath = join(root, "control", "transaction.lock");
let handle;
for (let attempt = 0; attempt < 200; attempt += 1) {
try {
handle = await open(lockPath, 'wx', 0o600);
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; });
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); }
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.');
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.');
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) => {
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);
if (code !== 0) reject(new Error(`Staged smoke check failed: ${Buffer.concat(stderr).toString('utf8').slice(0, 200)}`));
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));
});
});
@@ -89,182 +262,500 @@ async function runSmokeCommand(command, releaseDir, manifestDigest) {
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 transactionId = assertToken(input.transactionId, "transaction ID");
const statePath = join(root, "control/state.json");
let state = await recoverInterruptedPreparation(
root,
await readControlState(root),
);
if (state.activeTransaction !== null)
throw new Error("Another release transaction is already active.");
const firstNow = nowFrom(input.testClock);
const verificationInput = {
archivePath: resolve(input.archivePath),
protectedState: protectedStateFrom(state),
};
const verified = input.testOnlyVerification
? await verifyStagedReleaseForTest({
...verificationInput,
...input.testOnlyVerification,
now: firstNow,
})
: await verifyStagedRelease({ ...verificationInput, authorityRoot: root });
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 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.');
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,
transactionId,
manifestDigest: admission.manifestDigest,
releaseId: index.releaseId,
expectedFence: admission.expectedFence,
phase: "preparing",
readinessReceiptDigest: null,
};
await atomicCanonicalWrite(statePath, { ...state, activeTransaction });
await durableCanonicalWrite(statePath, { ...state, activeTransaction });
if (input.testPreparationCrashPoint === "after-intent")
throw Object.assign(
new Error("Injected preparation process death after intent."),
{ simulatedProcessDeath: true },
);
const partialName = `${transactionId}.partial`;
const partialDir = join(root, 'staging', partialName);
const releaseDir = join(root, 'releases', index.releaseId);
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 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.');
await writeArchiveFiles(
files.map((file) =>
file.path.startsWith(".release/")
? file
: { ...file, path: `public/${file.path}` },
),
partialDir,
);
if (input.testPreparationCrashPoint === "after-extract")
throw Object.assign(
new Error("Injected preparation process death after extract."),
{ simulatedProcessDeath: true },
);
const owner = await lstat(root);
await makeImmutable(partialDir, owner.uid, owner.gid);
if (input.testPreparationCrashPoint === "after-immutable")
throw Object.assign(
new Error("Injected preparation process death after immutable."),
{ simulatedProcessDeath: true },
);
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.testClock);
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.');
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.');
if (error?.code === "EEXIST")
throw new Error("Immutable release ID already exists.");
throw error;
});
await chmod(releaseDir, 0o555);
await syncDirectory(join(root, "releases"));
if (input.testPreparationCrashPoint === "after-release-rename")
throw Object.assign(
new Error("Injected preparation process death after release rename."),
{ simulatedProcessDeath: true },
);
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,
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);
const receiptDigest = canonicalJsonDigest(
"portfolio-release-readiness-receipt/v1",
receipt,
);
const receiptPath = join(
root,
"receipts",
"readiness",
`${transactionId}.json`,
);
await durableCanonicalWrite(receiptPath, receipt, 0o440);
await chmod(receiptPath, 0o440);
await atomicCanonicalWrite(statePath, { ...stateAfterSmoke, activeTransaction: { ...activeTransaction, phase: 'ready', readinessReceiptDigest: receiptDigest } });
if (input.testPreparationCrashPoint === "after-receipt")
throw Object.assign(
new Error("Injected preparation process death after receipt."),
{ simulatedProcessDeath: true },
);
await durableCanonicalWrite(statePath, {
...stateAfterSmoke,
activeTransaction: {
...activeTransaction,
phase: "ready",
readinessReceiptDigest: receiptDigest,
},
});
return { receipt, receiptDigest, releaseDir };
} catch (error) {
if (error?.simulatedProcessDeath === true) throw error;
await makeWritable(partialDir).catch(() => undefined);
await safeRemoveStage(root, partialName).catch(() => undefined);
if (
(await currentTarget(root).catch(() => null)) !==
`releases/${index.releaseId}/public`
) {
await makeWritable(releaseDir).catch(() => undefined);
await rm(releaseDir, { recursive: true, force: true }).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);
if (
latest?.activeTransaction?.transactionId === transactionId &&
latest.activeTransaction.phase !== "committing"
) {
await durableCanonicalWrite(statePath, {
...latest,
activeTransaction: null,
}).catch(() => undefined);
}
throw error;
}
}
export async function prepareRelease(input) {
exactObject(
input,
["root", "archivePath", "transactionId", "smokeCommand"],
"production preparation request",
);
const root = await assertReleaseRoot(input.root);
return input.lock === false ? prepareUnlocked({ ...input, root }) : withExclusiveLock(root, () => prepareUnlocked({ ...input, root }));
const archive = archiveFromInbox(root, input.archivePath);
return withExclusiveLock(root, () =>
prepareUnlocked({ ...input, root, archivePath: archive }),
);
}
export async function prepareReleaseForTest(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');
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");
if (
!Number.isSafeInteger(receipt.latestLedgerRevision) ||
receipt.latestLedgerRevision < 0 ||
!Number.isSafeInteger(receipt.expectedFence) ||
receipt.expectedFence < 1
)
throw new Error("Readiness receipt counters are invalid.");
if (receipt.predecessorManifestDigest !== null)
assertDigest(
receipt.predecessorManifestDigest,
"readiness predecessor manifest digest",
);
return receipt;
}
async function signedManifestFromRelease(root, releaseId) {
return join(root, 'releases', releaseId, '.release/signed-release-manifest.json');
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; }
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,
async function finalizeActivation(
root,
state,
tx,
receipt,
committedAt,
recovered,
) {
const newState = {
...state,
currentManifestDigest: tx.manifestDigest,
currentReleaseId: tx.releaseId,
publisherFence: tx.expectedFence,
activeTransaction: null,
};
const path = join(root, 'receipts', 'activation', `${tx.transactionId}.json`);
await atomicCanonicalWrite(path, activationReceipt, 0o440);
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 durableCanonicalWrite(path, activationReceipt, 0o440);
await chmod(path, 0o440);
await atomicCanonicalWrite(join(root, 'control/state.json'), newState);
await durableCanonicalWrite(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.');
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.');
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 predecessorTarget =
state.currentReleaseId === null
? null
: `releases/${state.currentReleaseId}/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.");
if (
(tx.phase === "ready" && actualCurrent !== predecessorTarget) ||
(tx.phase === "committing" &&
actualCurrent !== predecessorTarget &&
actualCurrent !== target)
) {
throw new Error(
"Public pointer does not match protected activation state.",
);
}
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 activationNow = nowFrom(input.testClock);
if (Date.parse(activationNow) >= Date.parse(receipt.expiresAt))
throw new Error("Release expired before activation.");
const verificationInput = {
signedManifestPath: await signedManifestFromRelease(root, tx.releaseId),
protectedState: protectedStateFrom(state),
};
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.');
const verified = input.testOnlyVerification
? await verifySignedReleaseForTest({
...verificationInput,
...input.testOnlyVerification,
now: activationNow,
})
: await verifySignedRelease({ ...verificationInput, authorityRoot: root });
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.");
}
if (input.testBeforeCommit !== undefined) await input.testBeforeCommit();
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.');
if (
state.currentManifestDigest !== expectedCurrent ||
state.publisherFence + 1 !== input.expectedFence ||
state.activeTransaction?.transactionId !== transactionId ||
state.latestLedgerHeadHash !== receipt.latestLedgerHeadHash ||
state.latestLedgerRevision !== receipt.latestLedgerRevision ||
state.releaseTrustPolicyDigest !==
verificationInput.protectedState.releaseTrustPolicyDigest
) {
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 (tx.phase === "committing" && actualCurrent === target) {
return finalizeActivation(root, state, tx, receipt, activationNow, true);
}
await durableCanonicalWrite(join(root, "control/state.json"), {
...state,
activeTransaction: { ...tx, phase: "committing" },
});
if (input.testBeforePointerSwap !== undefined)
await input.testBeforePointerSwap();
const finalState = await readControlState(root);
if (
finalState.activeTransaction?.transactionId !== transactionId ||
finalState.activeTransaction.phase !== "committing" ||
finalState.latestLedgerHeadHash !== receipt.latestLedgerHeadHash ||
finalState.latestLedgerRevision !== receipt.latestLedgerRevision ||
finalState.releaseTrustPolicyDigest !==
verificationInput.protectedState.releaseTrustPolicyDigest ||
finalState.currentManifestDigest !== expectedCurrent ||
finalState.publisherFence + 1 !== input.expectedFence ||
(await currentTarget(root)) !== predecessorTarget
) {
throw new Error("Protected authority changed before pointer publication.");
}
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.');
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);
if (input.crashPoint === "before-swap")
throw new Error("Injected crash before pointer swap.");
await rename(nextPath, join(root, "current"));
await syncDirectory(root);
if (input.crashPoint === "after-swap")
throw new Error("Injected crash after pointer swap.");
return finalizeActivation(
root,
finalState,
tx,
receipt,
activationNow,
false,
);
}
export async function activateRelease(input) {
exactObject(
input,
["root", "transactionId", "expectedCurrentManifestDigest", "expectedFence"],
"production activation request",
);
const root = await assertReleaseRoot(input.root);
return input.lock === false ? activateUnlocked({ ...input, root }) : withExclusiveLock(root, () => activateUnlocked({ ...input, root }));
return withExclusiveLock(root, () => activateUnlocked({ ...input, root }));
}
export async function activateReleaseForTest(input) {
const root = await assertReleaseRoot(input.root);
return input.lock === false
? activateUnlocked({ ...input, root })
: withExclusiveLock(root, () => activateUnlocked({ ...input, root }));
}
export async function rollbackRelease(input) {
exactObject(
input,
[
"root",
"targetReleaseId",
"archivePath",
"transactionId",
"smokeCommand",
"expectedCurrentManifestDigest",
"expectedFence",
],
"production rollback request",
);
const root = await assertReleaseRoot(input.root);
const archivePath = archiveFromInbox(root, input.archivePath);
return withExclusiveLock(root, async () => {
await prepareUnlocked({
...input,
root,
archivePath,
rollbackTargetReleaseId: input.targetReleaseId,
lock: false,
});
return activateUnlocked({ ...input, root, lock: false });
});
}
export async function rollbackReleaseForTest(input) {
const root = await assertReleaseRoot(input.root);
return withExclusiveLock(root, async () => {
await prepareUnlocked({ ...input, root, rollbackTargetReleaseId: input.targetReleaseId, lock: false });
await prepareUnlocked({
...input,
root,
rollbackTargetReleaseId: input.targetReleaseId,
lock: false,
});
return activateUnlocked({ ...input, root, lock: false });
});
}
@@ -273,21 +764,38 @@ 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));
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.');
else throw new Error("Orphan staging contains a non-file entry.");
}
function archiveFromInbox(root, archivePath) {
const archive = resolve(archivePath);
const inbox = join(root, "inbox");
const rel = relative(inbox, archive);
if (rel === "" || rel.startsWith(`..${sep}`) || isAbsolute(rel))
throw new Error("Production archive must come from the upload-only inbox.");
return archive;
}
export async function cleanupOrphanStages(rootPath) {
const root = await assertReleaseRoot(rootPath);
const staging = join(root, 'staging');
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 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 (
!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);

View File

@@ -1,19 +1,37 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { cleanupOrphanStages, prepareRelease } from './lib/release-transaction.mjs';
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') {
if (args.length === 2 && args[1] === "--cleanup-orphans") {
const removed = await cleanupOrphanStages(resolve(args[0]));
process.stdout.write(`${JSON.stringify({ removed })}\n`); return;
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 (args.length !== 4)
throw new Error(
"Usage: prepare-release.mjs <root> <inbox-archive> <transaction-id> <smoke-command>",
);
const [root, archivePath, transactionId, smokeCommand] = args;
const result = await prepareRelease({
root: resolve(root),
archivePath: resolve(archivePath),
transactionId,
smokeCommand: resolve(smokeCommand),
});
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; });
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

@@ -1,15 +1,38 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { rollbackRelease } from './lib/release-transaction.mjs';
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 });
if (args.length !== 7)
throw new Error(
"Usage: rollback-release.mjs <root> <target-release-id> <inbox-archive> <transaction-id> <smoke-command> <expected-current> <expected-fence>",
);
const [
root,
targetReleaseId,
archivePath,
transactionId,
smokeCommand,
expectedCurrentManifestDigest,
expectedFence,
] = args;
const receipt = await rollbackRelease({
root: resolve(root),
targetReleaseId,
archivePath: resolve(archivePath),
transactionId,
smokeCommand: resolve(smokeCommand),
expectedCurrentManifestDigest,
expectedFence: Number(expectedFence),
});
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; });
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

@@ -1,25 +1,44 @@
#!/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 { 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';
import { canonicalValueBytes } from "../../src/security/canonical-digest.ts";
import {
parseDeterministicTar,
validatePackageFiles,
verifySignedRelease,
verifySignedReleaseForTest,
} from "./lib/release-common.mjs";
export async function verifyStagedRelease({ archivePath, verifierModulePath, verifySignedReleaseEnvelope, trustPolicyPath, protectedState, now }) {
async function verifyStagedReleaseWith(input, verify) {
const { archivePath } = input;
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 });
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.');
const verified = await verify({ ...input, signedManifestPath: signedPath });
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 {
@@ -27,13 +46,57 @@ export async function verifyStagedRelease({ archivePath, verifierModulePath, ver
}
}
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');
export async function verifyStagedRelease(input) {
return verifyStagedReleaseWith(input, (request) =>
verifySignedRelease({
signedManifestPath: request.signedManifestPath,
authorityRoot: request.authorityRoot,
protectedState: request.protectedState,
}),
);
}
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; });
export async function verifyStagedReleaseForTest(input) {
return verifyStagedReleaseWith(input, (request) =>
verifySignedReleaseForTest({
signedManifestPath: request.signedManifestPath,
trustPolicyPath: request.trustPolicyPath,
verifySignedReleaseEnvelope: request.verifySignedReleaseEnvelope,
protectedState: request.protectedState,
now: request.now,
}),
);
}
async function main() {
const [archivePath, authorityRoot, protectedStateJson] =
process.argv.slice(2);
if (
!archivePath ||
!authorityRoot ||
!protectedStateJson ||
process.argv.length !== 5
)
throw new Error(
"Usage: verify-staged-release.mjs <archive> <authority-root> <protected-state-json>",
);
const result = await verifyStagedRelease({
archivePath: resolve(archivePath),
authorityRoot: resolve(authorityRoot),
protectedState: JSON.parse(protectedStateJson),
});
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;
});
}