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

@@ -4,12 +4,14 @@ This runbook describes the provider-neutral release transaction. It is not autho
## Release-root contract
Provision one dedicated absolute directory such as `<release-root>`. Never use `/`, `/var`, `/srv`, a home directory, or an existing web root directly. The directory must be owned by the narrowly scoped deployment account and contain:
Provision one dedicated absolute directory such as `<release-root>`. Never use `/`, `/var`, `/srv`, a home directory, or an existing web root directly. The directory must be owned by a narrowly scoped activation service account and contain:
```text
<release-root>/
.portfolio-release-root exact bytes: portfolio-release-root/v1\n
.control/release-trust.json canonical audited signing trust policy
control/state.json canonical JSON, owner read/write only
inbox/ upload identity write-only handoff boundary
```
The initial state is the canonical form of:
@@ -22,11 +24,12 @@ The initial state is the canonical form of:
"latestLedgerHeadHash": "<protected-live-ledger-head>",
"latestLedgerRevision": 0,
"publisherFence": 0,
"releaseTrustPolicyDigest": "<sha256-of-protected-release-trust-policy>",
"schema": "portfolio-release-control-state/v1"
}
```
Provisioning must write that state through the protected control plane, not by copying an example with placeholder values. The deployment account needs write access only to this dedicated root. Nginx needs read/traverse access to `current`; it does not need permission to write releases or control state. `current` points to `releases/<release-id>/public`, while signed metadata and the SBOM remain in the immutable sibling `.release` directory and are therefore outside the served document root. Signing keys, the approval ledger, trust policy, server credentials, and private source remain outside the release package and public root.
Provisioning must write that state through the protected control plane, not by copying an example with placeholder values. The upload identity may create package files only in `inbox`; it must not own or be able to write the release root, `.control`, `control`, `releases`, receipts, or `current`. The activation service owns those protected paths and derives immutable release ownership from the provisioned root itself—UID/GID are never supplied by a release caller. Nginx needs read/traverse access to `current`; it does not need write permission. `current` points to `releases/<release-id>/public`, while signed metadata and the SBOM remain in the immutable sibling `.release` directory and are outside the served document root. Signing keys, the approval ledger, server credentials, and private source remain outside the release package and public root.
The scripts reject non-absolute roots, broad roots, missing sentinels, symlinked verifier modules, unknown canonical fields, and non-canonical JSON.
@@ -39,14 +42,12 @@ node scripts/build-release.mjs \
--dist <exact-dist-directory> \
--signed-manifest <signed-release-envelope.json> \
--sbom <sbom.cdx.json> \
--verifier-module <absolute-path-to-audited-sign-release.mjs> \
--trust-policy <external-canonical-trust-policy.json> \
--authority-root <release-root> \
--protected-state '<canonical-protected-state-json>' \
--now <trusted-rfc3339-time> \
--output <new-release.tar>
```
The builder verifies the real Ed25519 envelope through `verifySignedReleaseEnvelope`, validates the typed admission, requires the directory to equal the manifest closure exactly, and checks every byte count and SHA-256 hash. Its ustar output has sorted entries, fixed time/ownership/modes, no links, and exactly these metadata paths:
The production builder loads the single audited verifier and the digest-bound trust policy from the protected authority root. It does not accept verifier code, a policy path, or a caller-provided clock. It validates the typed admission, requires the directory to equal the manifest closure exactly, and checks every byte count and SHA-256 hash. Its ustar output has sorted entries, fixed time/ownership/modes, no links, and exactly these metadata paths:
```text
.release/package-index.json
@@ -68,13 +69,12 @@ The executable must expose that directory only through a non-public, authenticat
## Prepare without changing visibility
Use the deployment account's numeric UID/GID. Preparation verifies signature, exact predecessor, live ledger, fence, hashes, and time; extracts without using the system tar extractor; makes all release files `0444` and directories `0555`; runs the internal smoke; rechecks time and protected state; and writes an immutable readiness receipt. It does not create or change `current`.
Place the completed package in `<release-root>/inbox` through the upload identity. Preparation verifies signature, exact predecessor, live ledger, trust-policy digest, fence, hashes, and system time; extracts without using the system tar extractor; makes all release files `0444` and directories `0555` under activation-service ownership; runs the internal smoke; rechecks protected state; and writes a durable readiness receipt. It does not create or change `current`.
```sh
scripts/deploy/prepare-release.sh \
<release-root> <new-release.tar> <transaction-id> \
<absolute-verifier-module> <external-trust-policy.json> <absolute-smoke-command> \
<deployment-uid> <deployment-gid> <trusted-rfc3339-time>
<release-root> <release-root>/inbox/<new-release.tar> <transaction-id> \
<absolute-smoke-command>
```
Inspect the resulting release ID and readiness receipt before activation. A preparation failure leaves the existing `current` pointer untouched.
@@ -85,11 +85,10 @@ Supply the exact predecessor digest (`none` only for the first release) and the
```sh
scripts/deploy/activate-release.sh \
<release-root> <transaction-id> <expected-current-digest-or-none> <expected-next-fence> \
<absolute-verifier-module> <external-trust-policy.json> <trusted-rfc3339-time>
<release-root> <transaction-id> <expected-current-digest-or-none> <expected-next-fence>
```
Activation obtains an exclusive transaction lock, re-verifies the signed envelope against current protected state, rechecks receipt/ledger/predecessor/fence/expiry, writes a durable committing intent, creates the relative `current.next` symlink, and renames it atomically over `current`. Nothing else changes public visibility.
Activation obtains the activation-service transaction lock, re-verifies the signed envelope against current protected state, and immediately before publication rereads the exact receipt, ledger head/revision, trust-policy digest, predecessor, pointer, fence, and expiry. It writes a durable committing intent, creates the relative `current.next` symlink, and renames it atomically over `current`. Every protected control-plane writer must honor the same service lock/CAS protocol; direct state or pointer writes are forbidden.
- A failure before the rename leaves the predecessor visible.
- A crash after the rename is recovered by repeating the same command: the script observes the new pointer and finalizes the receipt/state without another visibility change.
@@ -104,16 +103,15 @@ Rollback does not replay the selected release's old manifest. The signing pipeli
```sh
scripts/deploy/rollback-release.sh \
<release-root> <selected-old-release-id> <newly-signed-rollback-release.tar> <transaction-id> \
<absolute-verifier-module> <external-trust-policy.json> <absolute-smoke-command> \
<deployment-uid> <deployment-gid> <current-manifest-digest> <expected-next-fence> <trusted-rfc3339-time>
<release-root> <selected-old-release-id> <release-root>/inbox/<newly-signed-rollback-release.tar> <transaction-id> \
<absolute-smoke-command> <current-manifest-digest> <expected-next-fence>
```
The script verifies that the rollback package uses a fresh manifest digest and exactly matches the selected old artifact closure, then performs the same readiness and atomic pointer transaction. Record both activation and rollback receipts in the release record.
## Orphan staging recovery
Only hidden directories named `<safe-transaction-id>.partial` beneath the validated dedicated root may be cleaned. Active transaction staging is retained. After protected-state recovery has confirmed that no transaction can resume:
On startup or retry, a durable `preparing` intent is recovered idempotently: the activation service proves that its target is not public, removes only that transaction's partial/release/receipt artifacts, clears the intent, and starts verification again. If the public pointer unexpectedly names a preparing release, automatic cleanup stops for privileged investigation. Only hidden directories named `<safe-transaction-id>.partial` beneath the validated dedicated root may otherwise be cleaned. Active transaction staging is retained. After protected-state recovery has confirmed that no transaction can resume:
```sh
scripts/deploy/prepare-release.sh <release-root> --cleanup-orphans

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/')
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.');
: { ...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;
});
}

View File

@@ -1,141 +1,394 @@
import { lstat, mkdtemp, mkdir, readFile, readlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import {
lstat,
mkdtemp,
mkdir,
readFile,
readlink,
rename,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { buildReleasePackage } from '../../scripts/build-release.mjs';
import { activateRelease, cleanupOrphanStages, prepareRelease, readControlState, rollbackRelease } from '../../scripts/deploy/lib/release-transaction.mjs';
import { createReleaseFixture, initializeReleaseRoot } from './release-test-fixture.ts';
import { canonicalValueBytes } from "../../src/security/canonical-digest.ts";
import {
buildReleasePackage,
buildReleasePackageForTest,
} from "../../scripts/build-release.mjs";
import {
activateRelease,
activateReleaseForTest,
cleanupOrphanStages,
prepareRelease,
prepareReleaseForTest,
readControlState,
rollbackReleaseForTest,
} from "../../scripts/deploy/lib/release-transaction.mjs";
import {
createReleaseFixture,
initializeReleaseRoot,
} from "./release-test-fixture.ts";
type Fixture = Awaited<ReturnType<typeof createReleaseFixture>>;
function buildInput(fixture: Fixture, outputPath: string) {
return {
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
verifierModulePath: fixture.verifierModulePath, verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
trustPolicyPath: fixture.trustPolicyPath, protectedState: fixture.protectedState,
now: '2026-09-02T01:00:00Z', outputPath,
distDir: fixture.distDir,
signedManifestPath: fixture.signedManifestPath,
sbomPath: fixture.sbomPath,
trustPolicyPath: fixture.trustPolicyPath,
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
protectedState: fixture.protectedState,
now: "2026-09-02T01:00:00Z",
outputPath,
};
}
function transactionInput(root: string, archivePath: string, transactionId: string, fixture: Fixture) {
function transactionInput(
root: string,
archivePath: string,
transactionId: string,
fixture: Fixture,
) {
return {
root, archivePath, transactionId, verifierModulePath: fixture.verifierModulePath,
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope, trustPolicyPath: fixture.trustPolicyPath,
uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0, now: '2026-09-02T01:00:00Z',
root,
archivePath,
transactionId,
testOnlyVerification: {
trustPolicyPath: fixture.trustPolicyPath,
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
},
testClock: () => "2026-09-02T01:00:00Z",
smokeCheck: async ({ releaseDir }: { releaseDir: string }) => {
await readFile(join(releaseDir, 'index.html'));
return Buffer.from('internal-nginx-smoke-ok\n');
await readFile(join(releaseDir, "index.html"));
return Buffer.from("internal-nginx-smoke-ok\n");
},
};
}
describe('fenced atomic Nginx release transaction', () => {
test('prepares immutably without changing current, then activates with one atomic pointer swap', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-atomic-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
await prepareRelease(transactionInput(root, archive, 'tx-a', fixture));
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await activateRelease({ ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
const target = await readlink(join(root, 'current'));
async function setup(prefix: string) {
const temp = await mkdtemp(join(tmpdir(), prefix));
const fixture = await createReleaseFixture(join(temp, "fixture"));
const root = join(temp, "release-root");
await initializeReleaseRoot(
root,
fixture.protectedState,
fixture.trustPolicy,
);
const archive = join(temp, "release.tar");
await buildReleasePackageForTest(buildInput(fixture, archive));
return { temp, fixture, root, archive };
}
describe("fenced atomic Nginx release transaction", () => {
test("prepares immutably without changing current, then activates with one atomic pointer swap", async () => {
const { fixture, root, archive } = await setup("portfolio-atomic-");
const input = transactionInput(root, archive, "tx-a", fixture);
await prepareReleaseForTest(input);
await expect(lstat(join(root, "current"))).rejects.toThrow();
await activateReleaseForTest({
...input,
expectedCurrentManifestDigest: null,
expectedFence: 1,
});
const target = await readlink(join(root, "current"));
expect(target).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
expect((await lstat(join(root, target, 'index.html'))).mode & 0o777).toBe(0o444);
expect((await lstat(join(root, target, "index.html"))).mode & 0o777).toBe(
0o444,
);
expect((await lstat(join(root, target))).mode & 0o777).toBe(0o555);
expect((await lstat(join(root, target, "index.html"))).uid).toBe(
(await lstat(root)).uid,
);
});
test('rejects stale predecessor/fence, expiry crossing, and concurrent activation without moving the old pointer', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-fence-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
const input = transactionInput(root, archive, 'tx-a', fixture);
await prepareRelease(input);
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 2, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('fence');
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: fixture.manifestDigest, expectedFence: 1, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('predecessor');
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: fixture.expiresAt })).rejects.toThrow('expired');
test("rejects stale predecessor/fence, expiry crossing, and concurrent activation without moving the old pointer", async () => {
const { fixture, root, archive } = await setup("portfolio-fence-");
const input = transactionInput(root, archive, "tx-a", fixture);
await prepareReleaseForTest(input);
await expect(
activateReleaseForTest({
...input,
expectedCurrentManifestDigest: null,
expectedFence: 2,
}),
).rejects.toThrow("fence");
await expect(
activateReleaseForTest({
...input,
expectedCurrentManifestDigest: fixture.manifestDigest,
expectedFence: 1,
}),
).rejects.toThrow("predecessor");
await expect(
activateReleaseForTest({
...input,
expectedCurrentManifestDigest: null,
expectedFence: 1,
testClock: () => fixture.expiresAt,
}),
).rejects.toThrow("expired");
const attempts = await Promise.allSettled([
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
activateReleaseForTest({
...input,
expectedCurrentManifestDigest: null,
expectedFence: 1,
}),
activateReleaseForTest({
...input,
expectedCurrentManifestDigest: null,
expectedFence: 1,
}),
]);
expect(attempts.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(attempts.filter((result) => result.status === 'rejected')).toHaveLength(1);
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
expect(
attempts.filter((result) => result.status === "fulfilled"),
).toHaveLength(1);
expect(
attempts.filter((result) => result.status === "rejected"),
).toHaveLength(1);
});
test('rechecks trusted time after smoke so an expiry crossing never becomes ready', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-expiry-crossing-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
const times = ['2026-09-02T01:00:00Z', fixture.expiresAt];
await expect(prepareRelease({ ...transactionInput(root, archive, 'tx-a', fixture), clock: () => times.shift() ?? fixture.expiresAt })).rejects.toThrow('expired while staging');
test("rechecks trusted time after smoke so an expiry crossing never becomes ready", async () => {
const { fixture, root, archive } = await setup(
"portfolio-expiry-crossing-",
);
const times = ["2026-09-02T01:00:00Z", fixture.expiresAt];
await expect(
prepareReleaseForTest({
...transactionInput(root, archive, "tx-a", fixture),
testClock: () => times.shift() ?? fixture.expiresAt,
}),
).rejects.toThrow("expired while staging");
expect((await readControlState(root)).activeTransaction).toBeNull();
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await expect(lstat(join(root, "current"))).rejects.toThrow();
});
test('a crash before swap leaves the predecessor visible and retry after a post-swap crash converges on the committed release', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-crash-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
const input = { ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' };
await prepareRelease(input);
await expect(activateRelease({ ...input, crashPoint: 'before-swap' })).rejects.toThrow('before pointer');
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await expect(activateRelease({ ...input, crashPoint: 'after-swap' })).rejects.toThrow('after pointer');
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
const recovered = await activateRelease(input);
test.each([
"after-intent",
"after-extract",
"after-immutable",
"after-release-rename",
"after-receipt",
] as const)(
"recovers an interrupted preparation at %s and safely retries",
async (testPreparationCrashPoint) => {
const { fixture, root, archive } = await setup(
`portfolio-prepare-crash-${testPreparationCrashPoint}-`,
);
const input = transactionInput(root, archive, "tx-a", fixture);
await expect(
prepareReleaseForTest({ ...input, testPreparationCrashPoint }),
).rejects.toThrow("process death");
expect((await readControlState(root)).activeTransaction?.phase).toBe(
"preparing",
);
await prepareReleaseForTest(input);
expect((await readControlState(root)).activeTransaction?.phase).toBe(
"ready",
);
await expect(lstat(join(root, "current"))).rejects.toThrow();
},
);
test("a crash before swap leaves the predecessor visible and retry after a post-swap crash converges", async () => {
const { fixture, root, archive } = await setup(
"portfolio-activation-crash-",
);
const input = {
...transactionInput(root, archive, "tx-a", fixture),
expectedCurrentManifestDigest: null,
expectedFence: 1,
};
await prepareReleaseForTest(input);
await expect(
activateReleaseForTest({ ...input, crashPoint: "before-swap" }),
).rejects.toThrow("before pointer");
await expect(lstat(join(root, "current"))).rejects.toThrow();
await expect(
activateReleaseForTest({ ...input, crashPoint: "after-swap" }),
).rejects.toThrow("after pointer");
expect(await readlink(join(root, "current"))).toBe(
`releases/release-a-${fixture.manifestDigest}/public`,
);
const recovered = await activateReleaseForTest(input);
expect(recovered.recoveredAfterPointerSwap).toBe(true);
expect((await readControlState(root)).currentManifestDigest).toBe(fixture.manifestDigest);
expect((await readControlState(root)).currentManifestDigest).toBe(
fixture.manifestDigest,
);
});
test('rollback requires a fresh signed manifest and never replays the selected old manifest', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-rollback-'));
const fixtureA = await createReleaseFixture(join(temp, 'fixture-a'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archiveA = join(temp, 'a.tar'); await buildReleasePackage(buildInput(fixtureA, archiveA));
const inputA = transactionInput(root, archiveA, 'tx-a', fixtureA);
await prepareRelease(inputA);
await activateRelease({ ...inputA, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
const releaseA = (await readlink(join(root, 'current'))).split('/')[1] as string;
test("a protected ledger revocation racing final publication is preserved and blocks activation", async () => {
const { fixture, root, archive } = await setup(
"portfolio-revocation-race-",
);
const input = transactionInput(root, archive, "tx-a", fixture);
await prepareReleaseForTest(input);
await expect(
activateReleaseForTest({
...input,
expectedCurrentManifestDigest: null,
expectedFence: 1,
testBeforePointerSwap: async () => {
const state = await readControlState(root);
await writeFile(
join(root, "control/state.next"),
canonicalValueBytes({
...state,
latestLedgerHeadHash: "a".repeat(64),
latestLedgerRevision: state.latestLedgerRevision + 1,
}),
);
await rename(
join(root, "control/state.next"),
join(root, "control/state.json"),
);
},
}),
).rejects.toThrow("authority changed");
expect((await readControlState(root)).latestLedgerRevision).toBe(
fixture.protectedState.latestLedgerRevision + 1,
);
await expect(lstat(join(root, "current"))).rejects.toThrow();
});
const fixtureB = await createReleaseFixture(join(temp, 'fixture-b'));
fixtureB.protectedState.currentManifestDigest = fixtureA.manifestDigest; fixtureB.protectedState.publisherFence = 1;
await writeFile(join(fixtureB.distDir, 'index.html'), '<!doctype html><title>Portfolio B</title>\n');
const signedB = await fixtureB.signFor({ buildId: 'release-b', distDir: fixtureB.distDir, predecessorManifestDigest: fixtureA.manifestDigest, publisherFence: 1, outputPath: fixtureB.signedManifestPath });
const archiveB = join(temp, 'b.tar'); await buildReleasePackage({ ...buildInput(fixtureB, archiveB), now: '2026-09-02T02:00:00Z' });
const inputB = { ...transactionInput(root, archiveB, 'tx-b', fixtureB), now: '2026-09-02T02:00:00Z' };
await prepareRelease(inputB);
await activateRelease({ ...inputB, expectedCurrentManifestDigest: fixtureA.manifestDigest, expectedFence: 2, now: '2026-09-02T02:01:00Z' });
test("rollback requires a fresh signed manifest that binds the selected old artifact closure", async () => {
const { temp, fixture, root, archive } = await setup("portfolio-rollback-");
const inputA = transactionInput(root, archive, "tx-a", fixture);
await prepareReleaseForTest(inputA);
await activateReleaseForTest({
...inputA,
expectedCurrentManifestDigest: null,
expectedFence: 1,
});
const releaseA = (await readlink(join(root, "current"))).split(
"/",
)[1] as string;
await expect(rollbackRelease({ ...inputA, targetReleaseId: releaseA, transactionId: 'tx-replay', expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' })).rejects.toThrow();
fixture.protectedState.currentManifestDigest = fixture.manifestDigest;
fixture.protectedState.publisherFence = 1;
await writeFile(
join(fixture.distDir, "index.html"),
"<!doctype html><title>Portfolio B</title>\n",
);
const signedB = await fixture.signFor({
buildId: "release-b",
distDir: fixture.distDir,
predecessorManifestDigest: fixture.manifestDigest,
publisherFence: 1,
outputPath: fixture.signedManifestPath,
});
const archiveB = join(temp, "b.tar");
await buildReleasePackageForTest({
...buildInput(fixture, archiveB),
now: "2026-09-02T02:00:00Z",
});
const inputB = {
...transactionInput(root, archiveB, "tx-b", fixture),
testClock: () => "2026-09-02T02:00:00Z",
};
await prepareReleaseForTest(inputB);
await activateReleaseForTest({
...inputB,
expectedCurrentManifestDigest: fixture.manifestDigest,
expectedFence: 2,
});
fixtureA.protectedState.currentManifestDigest = signedB.manifestDigest; fixtureA.protectedState.publisherFence = 2;
const freshSigned = join(temp, 'rollback-signed.json');
const fresh = await fixtureA.signFor({ buildId: 'rollback-a', distDir: fixtureA.distDir, predecessorManifestDigest: signedB.manifestDigest, publisherFence: 2, outputPath: freshSigned });
fixtureA.signedManifestPath = freshSigned;
const rollbackArchive = join(temp, 'rollback.tar'); await buildReleasePackage({ ...buildInput(fixtureA, rollbackArchive), now: '2026-09-02T03:00:00Z' });
const rollback = await rollbackRelease({ ...transactionInput(root, rollbackArchive, 'tx-rollback', fixtureA), targetReleaseId: releaseA,
expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' });
await expect(
rollbackReleaseForTest({
...inputA,
targetReleaseId: releaseA,
transactionId: "tx-replay",
expectedCurrentManifestDigest: signedB.manifestDigest,
expectedFence: 3,
}),
).rejects.toThrow();
fixture.protectedState.currentManifestDigest = signedB.manifestDigest;
fixture.protectedState.publisherFence = 2;
await writeFile(
join(fixture.distDir, "index.html"),
"<!doctype html><title>Portfolio</title>\n",
);
const freshSigned = join(temp, "rollback-signed.json");
const fresh = await fixture.signFor({
buildId: "rollback-a",
distDir: fixture.distDir,
predecessorManifestDigest: signedB.manifestDigest,
publisherFence: 2,
outputPath: freshSigned,
});
fixture.signedManifestPath = freshSigned;
const rollbackArchive = join(temp, "rollback.tar");
await buildReleasePackageForTest({
...buildInput(fixture, rollbackArchive),
now: "2026-09-02T03:00:00Z",
});
const rollback = await rollbackReleaseForTest({
...transactionInput(root, rollbackArchive, "tx-rollback", fixture),
targetReleaseId: releaseA,
expectedCurrentManifestDigest: signedB.manifestDigest,
expectedFence: 3,
testClock: () => "2026-09-02T03:00:00Z",
});
expect(rollback.manifestDigest).toBe(fresh.manifestDigest);
expect(await readlink(join(root, 'current'))).toContain('rollback-a-');
expect(await readlink(join(root, "current"))).toContain("rollback-a-");
});
test('orphan cleanup and all mutations remain confined to a validated explicit release root', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-cleanup-'));
const outside = join(temp, 'outside.txt'); await writeFile(outside, 'keep');
const invalidRoot = join(temp, 'not-a-release-root'); await mkdir(join(invalidRoot, 'staging', 'orphan.partial'), { recursive: true });
await expect(cleanupOrphanStages(invalidRoot)).rejects.toThrow('sentinel');
expect(await readFile(outside, 'utf8')).toBe('keep');
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
await mkdir(join(root, 'staging', 'orphan.partial'), { recursive: true });
await writeFile(join(root, 'staging', 'orphan.partial', 'marker'), 'orphan');
expect(await cleanupOrphanStages(root)).toEqual(['orphan.partial']);
await expect(lstat(join(root, 'staging', 'orphan.partial'))).rejects.toThrow();
expect(await readFile(outside, 'utf8')).toBe('keep');
await expect(cleanupOrphanStages('/')).rejects.toThrow('Broad');
test("production APIs reject caller authority fields and archives outside the upload-only inbox", async () => {
const { fixture, root, archive } = await setup(
"portfolio-production-authority-",
);
await expect(
prepareRelease({
...transactionInput(root, archive, "tx-a", fixture),
smokeCommand: "/bin/true",
} as any),
).rejects.toThrow("unknown");
await expect(
prepareRelease({
root,
archivePath: archive,
transactionId: "tx-a",
smokeCommand: "/bin/true",
}),
).rejects.toThrow("upload-only inbox");
await expect(
activateRelease({
root,
transactionId: "tx-a",
expectedCurrentManifestDigest: null,
expectedFence: 1,
now: "2026-09-02T01:00:00Z",
} as any),
).rejects.toThrow("unknown");
await expect(
buildReleasePackage(buildInput(fixture, join(root, "bad.tar")) as any),
).rejects.toThrow("rejects caller");
});
test("orphan cleanup and all mutations remain confined to a validated explicit release root", async () => {
const temp = await mkdtemp(join(tmpdir(), "portfolio-cleanup-"));
const outside = join(temp, "outside.txt");
await writeFile(outside, "keep");
const invalidRoot = join(temp, "not-a-release-root");
await mkdir(join(invalidRoot, "staging", "orphan.partial"), {
recursive: true,
});
await expect(cleanupOrphanStages(invalidRoot)).rejects.toThrow("sentinel");
expect(await readFile(outside, "utf8")).toBe("keep");
const root = join(temp, "release-root");
await initializeReleaseRoot(root);
await mkdir(join(root, "staging", "orphan.partial"), { recursive: true });
await writeFile(
join(root, "staging", "orphan.partial", "marker"),
"orphan",
);
expect(await cleanupOrphanStages(root)).toEqual(["orphan.partial"]);
await expect(
lstat(join(root, "staging", "orphan.partial")),
).rejects.toThrow();
expect(await readFile(outside, "utf8")).toBe("keep");
await expect(cleanupOrphanStages("/")).rejects.toThrow("Broad");
});
});

View File

@@ -1,51 +1,96 @@
import { mkdtemp, readFile, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import { mkdtemp, readFile, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { byteDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts';
import { buildReleasePackage } from '../../scripts/build-release.mjs';
import { parseDeterministicTar } from '../../scripts/deploy/lib/release-common.mjs';
import { createReleaseFixture } from './release-test-fixture.ts';
import {
byteDigest,
canonicalValueBytes,
} from "../../src/security/canonical-digest.ts";
import {
buildReleasePackage,
buildReleasePackageForTest,
} from "../../scripts/build-release.mjs";
import { parseDeterministicTar } from "../../scripts/deploy/lib/release-common.mjs";
import { createReleaseFixture } from "./release-test-fixture.ts";
function buildInput(fixture: Awaited<ReturnType<typeof createReleaseFixture>>, outputPath: string) {
function buildInput(
fixture: Awaited<ReturnType<typeof createReleaseFixture>>,
outputPath: string,
) {
return {
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
verifierModulePath: fixture.verifierModulePath, trustPolicyPath: fixture.trustPolicyPath,
distDir: fixture.distDir,
signedManifestPath: fixture.signedManifestPath,
sbomPath: fixture.sbomPath,
trustPolicyPath: fixture.trustPolicyPath,
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
protectedState: fixture.protectedState, now: '2026-09-02T01:00:00Z', outputPath,
protectedState: fixture.protectedState,
now: "2026-09-02T01:00:00Z",
outputPath,
};
}
describe('deterministic exact-manifest release package', () => {
test('builds byte-identical archives containing only the signed closure and release metadata', async () => {
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-'));
describe("deterministic exact-manifest release package", () => {
test("builds byte-identical archives containing only the signed closure and release metadata", async () => {
const root = await mkdtemp(join(tmpdir(), "portfolio-package-"));
const fixture = await createReleaseFixture(root);
const first = join(root, 'release-one.tar');
const second = join(root, 'release-two.tar');
await buildReleasePackage(buildInput(fixture, first));
await buildReleasePackage(buildInput(fixture, second));
expect(byteDigest(await readFile(first))).toBe(byteDigest(await readFile(second)));
expect(parseDeterministicTar(await readFile(first)).map((file) => file.path)).toEqual([
'.release/package-index.json', '.release/sbom.cdx.json', '.release/signed-release-manifest.json',
'assets/app.js', 'index.html',
const first = join(root, "release-one.tar");
const second = join(root, "release-two.tar");
await buildReleasePackageForTest(buildInput(fixture, first));
await buildReleasePackageForTest(buildInput(fixture, second));
expect(byteDigest(await readFile(first))).toBe(
byteDigest(await readFile(second)),
);
expect(
parseDeterministicTar(await readFile(first)).map((file) => file.path),
).toEqual([
".release/package-index.json",
".release/sbom.cdx.json",
".release/signed-release-manifest.json",
"assets/app.js",
"index.html",
]);
});
test('fails closed on extra, substituted, or invalidly signed inputs', async () => {
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-invalid-'));
test("fails closed on extra, substituted, or invalidly signed inputs", async () => {
const root = await mkdtemp(join(tmpdir(), "portfolio-package-invalid-"));
const fixture = await createReleaseFixture(root);
await writeFile(join(fixture.distDir, 'extra.txt'), 'not in manifest');
await expect(buildReleasePackage(buildInput(fixture, join(root, 'extra.tar')))).rejects.toThrow('exactly match');
await writeFile(join(fixture.distDir, "extra.txt"), "not in manifest");
await expect(
buildReleasePackageForTest(buildInput(fixture, join(root, "extra.tar"))),
).rejects.toThrow("exactly match");
await unlink(join(fixture.distDir, 'extra.txt'));
await writeFile(join(fixture.distDir, 'index.html'), 'substituted');
await expect(buildReleasePackage(buildInput(fixture, join(root, 'substituted.tar')))).rejects.toThrow('hash');
await unlink(join(fixture.distDir, "extra.txt"));
await writeFile(join(fixture.distDir, "index.html"), "substituted");
await expect(
buildReleasePackageForTest(
buildInput(fixture, join(root, "substituted.tar")),
),
).rejects.toThrow("hash");
await writeFile(join(fixture.distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
const envelope = JSON.parse(await readFile(fixture.signedManifestPath, 'utf8'));
envelope.signature.value = `${envelope.signature.value[0] === 'A' ? 'B' : 'A'}${envelope.signature.value.slice(1)}`;
await writeFile(
join(fixture.distDir, "index.html"),
"<!doctype html><title>Portfolio</title>\n",
);
const envelope = JSON.parse(
await readFile(fixture.signedManifestPath, "utf8"),
);
envelope.signature.value = `${envelope.signature.value[0] === "A" ? "B" : "A"}${envelope.signature.value.slice(1)}`;
await writeFile(fixture.signedManifestPath, canonicalValueBytes(envelope));
await expect(buildReleasePackage(buildInput(fixture, join(root, 'invalid.tar')))).rejects.toThrow('signature');
await expect(
buildReleasePackageForTest(
buildInput(fixture, join(root, "invalid.tar")),
),
).rejects.toThrow("signature");
});
test("production package requests reject caller-selected verifier, policy, and clock fields", async () => {
const root = await mkdtemp(join(tmpdir(), "portfolio-package-authority-"));
const fixture = await createReleaseFixture(root);
await expect(
buildReleasePackage(
buildInput(fixture, join(root, "release.tar")) as any,
),
).rejects.toThrow("rejects caller");
});
});

View File

@@ -1,9 +1,13 @@
import { generateKeyPairSync, sign, verify } from 'node:crypto';
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { generateKeyPairSync, sign, verify } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { canonicalJsonDigest, canonicalValueBytes, byteDigest } from '../../src/security/canonical-digest.ts';
import { computeProductionInputRoot } from '../../src/security/production-input-root.ts';
import {
canonicalJsonDigest,
canonicalValueBytes,
byteDigest,
} from "../../src/security/canonical-digest.ts";
import { computeProductionInputRoot } from "../../src/security/production-input-root.ts";
const digest = (character: string) => character.repeat(64);
@@ -12,13 +16,19 @@ export interface ReleaseFixture {
signedManifestPath: string;
sbomPath: string;
trustPolicyPath: string;
verifierModulePath: string;
verifySignedReleaseEnvelope(input: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }): any;
trustPolicy: Record<string, unknown>;
verifySignedReleaseEnvelope(input: {
signedRelease: any;
trustPolicy: any;
now: string;
protectedState: any;
}): any;
protectedState: {
latestLedgerHeadHash: string;
latestLedgerRevision: number;
currentManifestDigest: string | null;
publisherFence: number;
releaseTrustPolicyDigest: string;
};
manifestDigest: string;
buildId: string;
@@ -34,84 +44,107 @@ export interface ReleaseFixture {
}): Promise<{ manifestDigest: string; signedManifestPath: string }>;
}
export async function createReleaseFixture(root: string): Promise<ReleaseFixture> {
const distDir = join(root, 'dist');
await mkdir(join(distDir, 'assets'), { recursive: true });
await writeFile(join(distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
await writeFile(join(distDir, 'assets/app.js'), 'console.log("portfolio");\n');
const sbomPath = join(root, 'sbom.cdx.json');
export async function createReleaseFixture(
root: string,
): Promise<ReleaseFixture> {
const distDir = join(root, "dist");
await mkdir(join(distDir, "assets"), { recursive: true });
await writeFile(
join(distDir, "index.html"),
"<!doctype html><title>Portfolio</title>\n",
);
await writeFile(
join(distDir, "assets/app.js"),
'console.log("portfolio");\n',
);
const sbomPath = join(root, "sbom.cdx.json");
await writeFile(sbomPath, '{"bomFormat":"CycloneDX","specVersion":"1.6"}\n');
const keys = generateKeyPairSync('ed25519');
const publicKeyPem = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString();
const trustPolicyPath = join(root, 'release-trust.json');
await writeFile(trustPolicyPath, canonicalValueBytes({
keyId: 'release-test-key', issuerEpoch: 7, scope: 'portfolio-release-signing/v1', publicKeyPem,
}));
const keys = generateKeyPairSync("ed25519");
const publicKeyPem = keys.publicKey
.export({ type: "spki", format: "pem" })
.toString();
const trustPolicyPath = join(root, "release-trust.json");
const trustPolicy = {
keyId: "release-test-key",
issuerEpoch: 7,
scope: "portfolio-release-signing/v1",
publicKeyPem,
};
await writeFile(trustPolicyPath, canonicalValueBytes(trustPolicy));
const verifierModulePath = join(root, 'real-release-verifier.mjs');
await writeFile(verifierModulePath, `
import { createHash, verify } from 'node:crypto';
const protectedState = {
latestLedgerHeadHash: digest("9"),
latestLedgerRevision: 11,
currentManifestDigest: null,
publisherFence: 0,
releaseTrustPolicyDigest: canonicalJsonDigest(
"portfolio-trust-policy/v1",
trustPolicy,
),
};
function canonical(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
}
function digest(schema, value) {
return createHash('sha256').update(canonical({ schema, value })).digest('hex');
}
function fail(message) { throw new Error(message); }
export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, protectedState }) {
if (signedRelease?.schema !== 'portfolio-signed-release-manifest/v1') fail('bad envelope schema');
if (signedRelease.signature?.algorithm !== 'Ed25519' || signedRelease.signature?.encoding !== 'base64url-nopad') fail('bad signature metadata');
if (signedRelease.signature?.keyId !== trustPolicy.keyId || signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch || signedRelease.signature?.scope !== trustPolicy.scope) fail('untrusted signer');
const signature = Buffer.from(signedRelease.signature.value, 'base64url');
if (signature.length !== 64 || !verify(null, Buffer.from(canonical(signedRelease.payload)), trustPolicy.publicKeyPem, signature)) fail('invalid release signature');
const manifestDigest = digest('portfolio-candidate-release-manifest/v1', signedRelease.payload);
if (manifestDigest !== signedRelease.payloadDigest) fail('candidate digest mismatch');
const verifySignedReleaseEnvelope = ({
signedRelease,
trustPolicy,
now,
protectedState: state,
}: {
signedRelease: any;
trustPolicy: any;
now: string;
protectedState: any;
}) => {
if (signedRelease?.schema !== "portfolio-signed-release-manifest/v1")
throw new Error("bad envelope schema");
if (
signedRelease.signature?.keyId !== trustPolicy.keyId ||
signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch ||
signedRelease.signature?.scope !== trustPolicy.scope
)
throw new Error("untrusted signer");
const signature = Buffer.from(signedRelease.signature.value, "base64url");
if (
signature.length !== 64 ||
!verify(
null,
canonicalValueBytes(signedRelease.payload),
trustPolicy.publicKeyPem,
signature,
)
)
throw new Error("invalid release signature");
const manifestDigest = canonicalJsonDigest(
"portfolio-candidate-release-manifest/v1",
signedRelease.payload,
);
if (manifestDigest !== signedRelease.payloadDigest)
throw new Error("candidate digest mismatch");
const candidate = signedRelease.payload;
if (candidate.latestLedgerHeadHash !== protectedState.latestLedgerHeadHash || candidate.latestLedgerRevision !== protectedState.latestLedgerRevision) fail('stale ledger');
if (candidate.releaseManifest.currentManifestDigest !== protectedState.currentManifestDigest) fail('stale predecessor');
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) fail('expired release');
if (
candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
candidate.latestLedgerRevision !== state.latestLedgerRevision
)
throw new Error("stale ledger");
if (
candidate.releaseManifest.currentManifestDigest !==
state.currentManifestDigest
)
throw new Error("stale predecessor");
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt))
throw new Error("expired release");
return {
schema: 'portfolio-verified-release-admission/v1',
schema: "portfolio-verified-release-admission/v1",
manifestDigest,
manifestPayload: candidate.releaseManifest,
expiresAt: candidate.releaseManifest.expiresAt,
predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
predecessorManifestDigest:
candidate.releaseManifest.currentManifestDigest,
latestLedgerHeadHash: candidate.latestLedgerHeadHash,
latestLedgerRevision: candidate.latestLedgerRevision,
expectedFence: protectedState.publisherFence + 1,
expectedFence: state.publisherFence + 1,
signatureVerified: true,
};
}
`);
await chmod(verifierModulePath, 0o500);
const protectedState = {
latestLedgerHeadHash: digest('9'), latestLedgerRevision: 11, currentManifestDigest: null, publisherFence: 0,
};
const verifySignedReleaseEnvelope = ({ signedRelease, trustPolicy, now, protectedState: state }: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }) => {
if (signedRelease?.schema !== 'portfolio-signed-release-manifest/v1') throw new Error('bad envelope schema');
if (signedRelease.signature?.keyId !== trustPolicy.keyId || signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch ||
signedRelease.signature?.scope !== trustPolicy.scope) throw new Error('untrusted signer');
const signature = Buffer.from(signedRelease.signature.value, 'base64url');
if (signature.length !== 64 || !verify(null, canonicalValueBytes(signedRelease.payload), trustPolicy.publicKeyPem, signature)) throw new Error('invalid release signature');
const manifestDigest = canonicalJsonDigest('portfolio-candidate-release-manifest/v1', signedRelease.payload);
if (manifestDigest !== signedRelease.payloadDigest) throw new Error('candidate digest mismatch');
const candidate = signedRelease.payload;
if (candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash || candidate.latestLedgerRevision !== state.latestLedgerRevision) throw new Error('stale ledger');
if (candidate.releaseManifest.currentManifestDigest !== state.currentManifestDigest) throw new Error('stale predecessor');
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) throw new Error('expired release');
return {
schema: 'portfolio-verified-release-admission/v1', manifestDigest, manifestPayload: candidate.releaseManifest,
expiresAt: candidate.releaseManifest.expiresAt, predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
latestLedgerHeadHash: candidate.latestLedgerHeadHash, latestLedgerRevision: candidate.latestLedgerRevision,
expectedFence: state.publisherFence + 1, signatureVerified: true,
};
};
const signFor = async (input: {
@@ -123,19 +156,29 @@ export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, p
expiresAt?: string;
outputPath: string;
}) => {
const artifactPaths = ['assets/app.js', 'index.html'];
const artifacts = await Promise.all(artifactPaths.map(async (path) => {
const artifactPaths = ["assets/app.js", "index.html"];
const artifacts = await Promise.all(
artifactPaths.map(async (path) => {
const bytes = await readFile(join(input.distDir, path));
return { path, sha256: byteDigest(bytes), bytes: bytes.length, encoding: 'identity' as const };
}));
const inputDigests = {
PortfolioContent: digest('a'), PublicAssetManifest: digest('b'), PreparedAssetSet: digest('c'), SiteConfig: digest('d'),
return {
path,
sha256: byteDigest(bytes),
bytes: bytes.length,
encoding: "identity" as const,
};
const issuedAt = input.issuedAt ?? '2026-09-02T00:00:00Z';
const expiresAt = input.expiresAt ?? '2026-09-03T00:00:00Z';
}),
);
const inputDigests = {
PortfolioContent: digest("a"),
PublicAssetManifest: digest("b"),
PreparedAssetSet: digest("c"),
SiteConfig: digest("d"),
};
const issuedAt = input.issuedAt ?? "2026-09-02T00:00:00Z";
const expiresAt = input.expiresAt ?? "2026-09-03T00:00:00Z";
const releaseManifest = {
schema: 'portfolio-release-manifest/v1' as const,
launchMode: 'private-source-initial-launch' as const,
schema: "portfolio-release-manifest/v1" as const,
launchMode: "private-source-initial-launch" as const,
buildId: input.buildId,
inputDigests,
productionInputRoot: computeProductionInputRoot({
@@ -146,52 +189,115 @@ export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, p
}),
artifacts,
deployFiles: artifactPaths,
sourceCommit: digest('e'), sourceTree: digest('f'), approvedRecordRoot: digest('1'), approvedAssetRoot: digest('2'),
approvalProofSetDigest: digest('3'), approvalProjectionAttestationDigest: digest('4'), scanEvidenceDigest: digest('5'),
policyVersions: { approval: '1', privacy: '1', release: '1' }, toolVersions: { node: '22.22.1' },
sourceCommit: digest("e"),
sourceTree: digest("f"),
approvedRecordRoot: digest("1"),
approvedAssetRoot: digest("2"),
approvalProofSetDigest: digest("3"),
approvalProjectionAttestationDigest: digest("4"),
scanEvidenceDigest: digest("5"),
policyVersions: { approval: "1", privacy: "1", release: "1" },
toolVersions: { node: "22.22.1" },
predecessorManifestDigest: input.predecessorManifestDigest,
currentManifestDigest: input.predecessorManifestDigest,
issuedAt, expiresAt, minimumBoundExpiry: expiresAt,
issuedAt,
expiresAt,
minimumBoundExpiry: expiresAt,
};
const candidate = {
schema: 'portfolio-candidate-release-manifest/v1',
schema: "portfolio-candidate-release-manifest/v1",
releaseManifest,
latestLedgerHeadHash: protectedState.latestLedgerHeadHash,
latestLedgerRevision: protectedState.latestLedgerRevision,
sourceClosureDigest: digest('6'), publicRefMap: [], lfsOids: [], releaseArchives: [],
buildProvenanceDigest: digest('7'), privacyPolicyDigest: digest('8'), toolImageDigests: [digest('0')],
sourceClosureDigest: digest("6"),
publicObjectClosureDigest: digest("b"),
publicRefMap: {},
lfsOids: [],
releaseArchives: [],
buildProvenanceDigest: digest("7"),
privacyPolicyDigest: digest("8"),
toolImageDigests: [digest("0")],
};
const payloadDigest = canonicalJsonDigest('portfolio-candidate-release-manifest/v1', candidate);
const signature = sign(null, canonicalValueBytes(candidate), keys.privateKey).toString('base64url');
const payloadDigest = canonicalJsonDigest(
"portfolio-candidate-release-manifest/v1",
candidate,
);
const signature = sign(
null,
canonicalValueBytes(candidate),
keys.privateKey,
).toString("base64url");
const signedRelease = {
schema: 'portfolio-signed-release-manifest/v1', payload: candidate, payloadDigest,
schema: "portfolio-signed-release-manifest/v1",
payload: candidate,
payloadDigest,
signature: {
algorithm: 'Ed25519', encoding: 'base64url-nopad', keyId: 'release-test-key', issuerEpoch: 7,
scope: 'portfolio-release-signing/v1', value: signature,
algorithm: "Ed25519",
encoding: "base64url-nopad",
keyId: "release-test-key",
issuerEpoch: 7,
scope: "portfolio-release-signing/v1",
value: signature,
},
};
await writeFile(input.outputPath, canonicalValueBytes(signedRelease));
return { manifestDigest: payloadDigest, signedManifestPath: input.outputPath };
return {
manifestDigest: payloadDigest,
signedManifestPath: input.outputPath,
};
};
const signedManifestPath = join(root, 'signed-release.json');
const initial = await signFor({ buildId: 'release-a', distDir, predecessorManifestDigest: null, publisherFence: 0, outputPath: signedManifestPath });
const signedManifestPath = join(root, "signed-release.json");
const initial = await signFor({
buildId: "release-a",
distDir,
predecessorManifestDigest: null,
publisherFence: 0,
outputPath: signedManifestPath,
});
return {
distDir, signedManifestPath, sbomPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState,
manifestDigest: initial.manifestDigest, buildId: 'release-a', expiresAt: '2026-09-03T00:00:00Z', signFor,
distDir,
signedManifestPath,
sbomPath,
trustPolicyPath,
trustPolicy,
verifySignedReleaseEnvelope,
protectedState,
manifestDigest: initial.manifestDigest,
buildId: "release-a",
expiresAt: "2026-09-03T00:00:00Z",
signFor,
};
}
export async function initializeReleaseRoot(root: string, state?: Partial<ReleaseFixture['protectedState']>): Promise<void> {
await mkdir(join(root, 'control'), { recursive: true });
await writeFile(join(root, '.portfolio-release-root'), 'portfolio-release-root/v1\n');
await writeFile(join(root, 'control/state.json'), canonicalValueBytes({
schema: 'portfolio-release-control-state/v1',
export async function initializeReleaseRoot(
root: string,
state?: Partial<ReleaseFixture["protectedState"]>,
trustPolicy?: Record<string, unknown>,
): Promise<void> {
await mkdir(join(root, "control"), { recursive: true });
await mkdir(join(root, ".control"), { recursive: true });
await mkdir(join(root, "inbox"), { recursive: true });
if (trustPolicy !== undefined)
await writeFile(
join(root, ".control", "release-trust.json"),
canonicalValueBytes(trustPolicy),
);
await writeFile(
join(root, ".portfolio-release-root"),
"portfolio-release-root/v1\n",
);
await writeFile(
join(root, "control/state.json"),
canonicalValueBytes({
schema: "portfolio-release-control-state/v1",
currentManifestDigest: state?.currentManifestDigest ?? null,
currentReleaseId: null,
publisherFence: state?.publisherFence ?? 0,
latestLedgerHeadHash: state?.latestLedgerHeadHash ?? digest('9'),
latestLedgerHeadHash: state?.latestLedgerHeadHash ?? digest("9"),
latestLedgerRevision: state?.latestLedgerRevision ?? 11,
releaseTrustPolicyDigest: state?.releaseTrustPolicyDigest ?? digest("8"),
activeTransaction: null,
}));
}),
);
}