fix: recover and fence atomic activation
This commit is contained in:
@@ -4,12 +4,14 @@ This runbook describes the provider-neutral release transaction. It is not autho
|
|||||||
|
|
||||||
## Release-root contract
|
## 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
|
```text
|
||||||
<release-root>/
|
<release-root>/
|
||||||
.portfolio-release-root exact bytes: portfolio-release-root/v1\n
|
.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
|
control/state.json canonical JSON, owner read/write only
|
||||||
|
inbox/ upload identity write-only handoff boundary
|
||||||
```
|
```
|
||||||
|
|
||||||
The initial state is the canonical form of:
|
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>",
|
"latestLedgerHeadHash": "<protected-live-ledger-head>",
|
||||||
"latestLedgerRevision": 0,
|
"latestLedgerRevision": 0,
|
||||||
"publisherFence": 0,
|
"publisherFence": 0,
|
||||||
|
"releaseTrustPolicyDigest": "<sha256-of-protected-release-trust-policy>",
|
||||||
"schema": "portfolio-release-control-state/v1"
|
"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.
|
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> \
|
--dist <exact-dist-directory> \
|
||||||
--signed-manifest <signed-release-envelope.json> \
|
--signed-manifest <signed-release-envelope.json> \
|
||||||
--sbom <sbom.cdx.json> \
|
--sbom <sbom.cdx.json> \
|
||||||
--verifier-module <absolute-path-to-audited-sign-release.mjs> \
|
--authority-root <release-root> \
|
||||||
--trust-policy <external-canonical-trust-policy.json> \
|
|
||||||
--protected-state '<canonical-protected-state-json>' \
|
--protected-state '<canonical-protected-state-json>' \
|
||||||
--now <trusted-rfc3339-time> \
|
|
||||||
--output <new-release.tar>
|
--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
|
```text
|
||||||
.release/package-index.json
|
.release/package-index.json
|
||||||
@@ -68,13 +69,12 @@ The executable must expose that directory only through a non-public, authenticat
|
|||||||
|
|
||||||
## Prepare without changing visibility
|
## 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
|
```sh
|
||||||
scripts/deploy/prepare-release.sh \
|
scripts/deploy/prepare-release.sh \
|
||||||
<release-root> <new-release.tar> <transaction-id> \
|
<release-root> <release-root>/inbox/<new-release.tar> <transaction-id> \
|
||||||
<absolute-verifier-module> <external-trust-policy.json> <absolute-smoke-command> \
|
<absolute-smoke-command>
|
||||||
<deployment-uid> <deployment-gid> <trusted-rfc3339-time>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Inspect the resulting release ID and readiness receipt before activation. A preparation failure leaves the existing `current` pointer untouched.
|
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
|
```sh
|
||||||
scripts/deploy/activate-release.sh \
|
scripts/deploy/activate-release.sh \
|
||||||
<release-root> <transaction-id> <expected-current-digest-or-none> <expected-next-fence> \
|
<release-root> <transaction-id> <expected-current-digest-or-none> <expected-next-fence>
|
||||||
<absolute-verifier-module> <external-trust-policy.json> <trusted-rfc3339-time>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 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.
|
- 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
|
```sh
|
||||||
scripts/deploy/rollback-release.sh \
|
scripts/deploy/rollback-release.sh \
|
||||||
<release-root> <selected-old-release-id> <newly-signed-rollback-release.tar> <transaction-id> \
|
<release-root> <selected-old-release-id> <release-root>/inbox/<newly-signed-rollback-release.tar> <transaction-id> \
|
||||||
<absolute-verifier-module> <external-trust-policy.json> <absolute-smoke-command> \
|
<absolute-smoke-command> <current-manifest-digest> <expected-next-fence>
|
||||||
<deployment-uid> <deployment-gid> <current-manifest-digest> <expected-next-fence> <trusted-rfc3339-time>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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
|
## 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
|
```sh
|
||||||
scripts/deploy/prepare-release.sh <release-root> --cleanup-orphans
|
scripts/deploy/prepare-release.sh <release-root> --cleanup-orphans
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ export interface BuildReleaseInput {
|
|||||||
signedManifestPath: string;
|
signedManifestPath: string;
|
||||||
sbomPath: string;
|
sbomPath: string;
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
verifierModulePath: string;
|
authorityRoot: string;
|
||||||
trustPolicyPath: string;
|
|
||||||
protectedState: Record<string, unknown>;
|
protectedState: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildReleaseTestInput extends Omit<
|
||||||
|
BuildReleaseInput,
|
||||||
|
"authorityRoot"
|
||||||
|
> {
|
||||||
|
trustPolicyPath: string;
|
||||||
now: string;
|
now: string;
|
||||||
verifySignedReleaseEnvelope?: (input: any) => any;
|
verifySignedReleaseEnvelope: (input: any) => unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReleasePackage(input: BuildReleaseInput): Promise<{
|
export function buildReleasePackage(input: BuildReleaseInput): Promise<{
|
||||||
@@ -15,3 +21,6 @@ export function buildReleasePackage(input: BuildReleaseInput): Promise<{
|
|||||||
manifestDigest: string;
|
manifestDigest: string;
|
||||||
archivePath: string;
|
archivePath: string;
|
||||||
}>;
|
}>;
|
||||||
|
export function buildReleasePackageForTest(
|
||||||
|
input: BuildReleaseTestInput,
|
||||||
|
): ReturnType<typeof buildReleasePackage>;
|
||||||
|
|||||||
@@ -1,81 +1,182 @@
|
|||||||
#!/usr/bin/env node
|
#!/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 {
|
import {
|
||||||
createDeterministicTar, enumerateRegularFiles, packageIndexFor, parseDeterministicTar, validatePackageFiles,
|
lstat,
|
||||||
verifySignedRelease, writeArchiveFiles,
|
mkdir,
|
||||||
} from './deploy/lib/release-common.mjs';
|
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) {
|
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 = {};
|
const values = {};
|
||||||
for (let index = 0; index < args.length; index += 2) {
|
for (let index = 0; index < args.length; index += 2) {
|
||||||
const token = args[index];
|
const token = args[index];
|
||||||
const value = args[index + 1];
|
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);
|
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;
|
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;
|
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 {
|
return {
|
||||||
distDir: resolve(values.dist), signedManifestPath: resolve(values['signed-manifest']), sbomPath: resolve(values.sbom),
|
distDir: resolve(values.dist),
|
||||||
outputPath: resolve(values.output), verifierModulePath: resolve(values['verifier-module']), trustPolicyPath: resolve(values['trust-policy']),
|
signedManifestPath: resolve(values["signed-manifest"]),
|
||||||
protectedState, now: values.now,
|
sbomPath: resolve(values.sbom),
|
||||||
|
outputPath: resolve(values.output),
|
||||||
|
authorityRoot: resolve(values["authority-root"]),
|
||||||
|
protectedState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildReleasePackage(input) {
|
async function buildReleasePackageWith(input, verify) {
|
||||||
const outputInfo = await lstat(input.outputPath).catch((error) => error?.code === 'ENOENT' ? null : Promise.reject(error));
|
const outputInfo = await lstat(input.outputPath).catch((error) =>
|
||||||
if (outputInfo !== null) throw new Error('Release archive output already exists.');
|
error?.code === "ENOENT" ? null : Promise.reject(error),
|
||||||
const verified = await verifySignedRelease(input);
|
);
|
||||||
|
if (outputInfo !== null)
|
||||||
|
throw new Error("Release archive output already exists.");
|
||||||
|
const verified = await verify(input);
|
||||||
const { admission, manifest, signedReleaseBytes } = verified;
|
const { admission, manifest, signedReleaseBytes } = verified;
|
||||||
const sourceFiles = await enumerateRegularFiles(input.distDir);
|
const sourceFiles = await enumerateRegularFiles(input.distDir);
|
||||||
const actualPaths = sourceFiles.map((file) => file.publicPath);
|
const actualPaths = sourceFiles.map((file) => file.publicPath);
|
||||||
const expectedPaths = [...manifest.deployFiles].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
const expectedPaths = [...manifest.deployFiles].sort((left, right) =>
|
||||||
if (actualPaths.length !== expectedPaths.length || actualPaths.some((path, index) => path !== expectedPaths[index])) {
|
Buffer.compare(Buffer.from(left), Buffer.from(right)),
|
||||||
throw new Error('dist files must exactly match the signed deploy closure.');
|
);
|
||||||
|
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 = [];
|
const artifactFiles = [];
|
||||||
for (const artifact of manifest.artifacts) {
|
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);
|
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 bytes = await readFile(source);
|
||||||
const digest = (await import('../src/security/canonical-digest.ts')).byteDigest(bytes);
|
const digest = (
|
||||||
if (digest !== artifact.sha256 || bytes.length !== artifact.bytes) throw new Error(`Artifact hash or size mismatch: ${artifact.path}`);
|
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 });
|
artifactFiles.push({ path: artifact.path, bytes });
|
||||||
}
|
}
|
||||||
const sbomBytes = await readFile(input.sbomPath);
|
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([
|
const archiveBytes = createDeterministicTar([
|
||||||
...artifactFiles,
|
...artifactFiles,
|
||||||
{ path: '.release/package-index.json', bytes: canonicalValueBytes(index) },
|
{ path: ".release/package-index.json", bytes: canonicalValueBytes(index) },
|
||||||
{ path: '.release/sbom.cdx.json', bytes: sbomBytes },
|
{ path: ".release/sbom.cdx.json", bytes: sbomBytes },
|
||||||
{ path: '.release/signed-release-manifest.json', bytes: signedReleaseBytes },
|
{
|
||||||
|
path: ".release/signed-release-manifest.json",
|
||||||
|
bytes: signedReleaseBytes,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
const temporary = `${input.outputPath}.partial-${process.pid}`;
|
const temporary = `${input.outputPath}.partial-${process.pid}`;
|
||||||
await mkdir(dirname(input.outputPath), { recursive: true });
|
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}`;
|
const verifyRoot = `${input.outputPath}.verify-${process.pid}`;
|
||||||
try {
|
try {
|
||||||
const parsed = parseDeterministicTar(await readFile(temporary));
|
const parsed = parseDeterministicTar(await readFile(temporary));
|
||||||
validatePackageFiles(parsed);
|
validatePackageFiles(parsed);
|
||||||
await writeArchiveFiles(parsed, verifyRoot);
|
await writeArchiveFiles(parsed, verifyRoot);
|
||||||
const extracted = await enumerateRegularFiles(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);
|
await rename(temporary, input.outputPath);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(verifyRoot, { recursive: true, force: true });
|
await rm(verifyRoot, { recursive: true, force: true });
|
||||||
await rm(temporary, { 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() {
|
async function main() {
|
||||||
@@ -83,6 +184,14 @@ async function main() {
|
|||||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
if (
|
||||||
main().catch((error) => { process.stderr.write(`Release packaging failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from "node:url";
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from "node:path";
|
||||||
import { activateRelease } from './lib/release-transaction.mjs';
|
import { activateRelease } from "./lib/release-transaction.mjs";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
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]');
|
if (args.length !== 4)
|
||||||
const [root, transactionId, expectedCurrentManifestDigest, expectedFence, verifierModulePath, trustPolicyPath, now, crashPoint] = args;
|
throw new Error(
|
||||||
if (crashPoint !== undefined && process.env.PORTFOLIO_DEPLOY_TEST_HOOKS !== '1') throw new Error('Crash hooks are disabled outside the isolated test harness.');
|
"Usage: activate-release.mjs <root> <transaction-id> <expected-current|none> <expected-fence>",
|
||||||
const receipt = await activateRelease({ root: resolve(root), transactionId, expectedCurrentManifestDigest, expectedFence: Number(expectedFence),
|
);
|
||||||
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), now, crashPoint });
|
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`);
|
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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,34 +1,79 @@
|
|||||||
export interface TransactionInput {
|
export interface ProtectedControlState {
|
||||||
root: string;
|
schema: "portfolio-release-control-state/v1";
|
||||||
archivePath: string;
|
|
||||||
transactionId: string;
|
|
||||||
verifierModulePath: string;
|
|
||||||
trustPolicyPath: string;
|
|
||||||
uid: number;
|
|
||||||
gid: number;
|
|
||||||
now?: string;
|
|
||||||
clock?: () => string;
|
|
||||||
verifySignedReleaseEnvelope?: (input: any) => any;
|
|
||||||
smokeCheck?: (input: { releaseDir: string; manifestDigest: string }) => Promise<Uint8Array>;
|
|
||||||
smokeCommand?: string;
|
|
||||||
expectedCurrentManifestDigest?: string | null;
|
|
||||||
expectedFence?: number;
|
|
||||||
crashPoint?: 'before-swap' | 'after-swap';
|
|
||||||
targetReleaseId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControlState {
|
|
||||||
schema: 'portfolio-release-control-state/v1';
|
|
||||||
currentManifestDigest: string | null;
|
currentManifestDigest: string | null;
|
||||||
currentReleaseId: string | null;
|
currentReleaseId: string | null;
|
||||||
publisherFence: number;
|
publisherFence: number;
|
||||||
latestLedgerHeadHash: string;
|
latestLedgerHeadHash: string;
|
||||||
latestLedgerRevision: number;
|
latestLedgerRevision: number;
|
||||||
|
releaseTrustPolicyDigest: string;
|
||||||
activeTransaction: null | Record<string, unknown>;
|
activeTransaction: null | Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readControlState(root: string): Promise<ControlState>;
|
export interface ProductionPrepareInput {
|
||||||
export function prepareRelease(input: TransactionInput): Promise<Record<string, unknown>>;
|
root: string;
|
||||||
export function activateRelease(input: TransactionInput): Promise<Record<string, any>>;
|
archivePath: string;
|
||||||
export function rollbackRelease(input: TransactionInput): Promise<Record<string, any>>;
|
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[]>;
|
export function cleanupOrphanStages(root: string): Promise<string[]>;
|
||||||
|
|||||||
@@ -1,87 +1,260 @@
|
|||||||
import { spawn } from 'node:child_process';
|
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 {
|
import {
|
||||||
assertDigest, assertReleaseRoot, assertTimestamp, assertToken, atomicCanonicalWrite, exactObject, makeImmutable,
|
chmod,
|
||||||
safeRemoveStage, verifySignedRelease, writeArchiveFiles,
|
lstat,
|
||||||
} from './release-common.mjs';
|
mkdir,
|
||||||
import { verifyStagedRelease } from '../verify-staged-release.mjs';
|
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'];
|
import {
|
||||||
const transactionFields = ['transactionId', 'manifestDigest', 'releaseId', 'expectedFence', 'phase', 'readinessReceiptDigest'];
|
byteDigest,
|
||||||
const readinessFields = ['schema', 'transactionId', 'manifestDigest', 'releaseId', 'predecessorManifestDigest', 'expectedFence', 'latestLedgerHeadHash', 'latestLedgerRevision', 'expiresAt', 'packageDigest', 'smokeEvidenceDigest', 'createdAt'];
|
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) {
|
function nowFrom(input) {
|
||||||
const value = typeof input === 'function' ? input() : input ?? new Date().toISOString();
|
const value =
|
||||||
return assertTimestamp(value, 'trusted current time');
|
typeof input === "function" ? input() : (input ?? new Date().toISOString());
|
||||||
|
return assertTimestamp(value, "trusted current time");
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExpectedDigest(value) {
|
function normalizeExpectedDigest(value) {
|
||||||
if (value === null || value === 'none') return null;
|
if (value === null || value === "none") return null;
|
||||||
return assertDigest(value, 'expected current manifest digest');
|
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) {
|
export function validateControlState(value) {
|
||||||
const state = exactObject(value, stateFields, 'release control state');
|
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.schema !== "portfolio-release-control-state/v1")
|
||||||
if (state.currentManifestDigest !== null) assertDigest(state.currentManifestDigest, 'current manifest digest');
|
throw new Error("Release control state schema is invalid.");
|
||||||
if (state.currentReleaseId !== null) assertToken(state.currentReleaseId, 'current release ID');
|
if (state.currentManifestDigest !== null)
|
||||||
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.currentManifestDigest, "current manifest digest");
|
||||||
assertDigest(state.latestLedgerHeadHash, 'live ledger head');
|
if (state.currentReleaseId !== null)
|
||||||
if ((state.currentManifestDigest === null) !== (state.currentReleaseId === null)) throw new Error('Current release pointer state is incoherent.');
|
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) {
|
if (state.activeTransaction !== null) {
|
||||||
const tx = exactObject(state.activeTransaction, transactionFields, 'active release transaction');
|
const tx = exactObject(
|
||||||
assertToken(tx.transactionId, 'transaction ID'); assertDigest(tx.manifestDigest, 'transaction manifest digest'); assertToken(tx.releaseId, 'transaction release ID');
|
state.activeTransaction,
|
||||||
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.');
|
transactionFields,
|
||||||
if (tx.readinessReceiptDigest !== null) assertDigest(tx.readinessReceiptDigest, 'readiness receipt digest');
|
"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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readControlState(root) {
|
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) {
|
async function withExclusiveLock(root, callback) {
|
||||||
const lockPath = join(root, 'control', 'transaction.lock');
|
const lockPath = join(root, "control", "transaction.lock");
|
||||||
let handle;
|
let handle;
|
||||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
handle = await open(lockPath, 'wx', 0o600);
|
handle = await open(lockPath, "wx", 0o600);
|
||||||
await handle.writeFile(`${process.pid}\n`);
|
await handle.writeFile(`${process.pid}\n`);
|
||||||
await handle.sync();
|
await handle.sync();
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code !== 'EEXIST') throw error;
|
if (error?.code !== "EEXIST") throw error;
|
||||||
const owner = await readFile(lockPath, 'utf8').catch(() => '');
|
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.');
|
if (!/^\d+\n$/u.test(owner))
|
||||||
try { process.kill(Number(owner.trim()), 0); } catch (probeError) {
|
throw new Error(
|
||||||
if (probeError?.code !== 'ESRCH') throw probeError;
|
"Release transaction lock is malformed; protected recovery is required.",
|
||||||
await unlink(lockPath).catch((unlinkError) => { if (unlinkError?.code !== 'ENOENT') throw unlinkError; });
|
);
|
||||||
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 5));
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (handle === undefined) throw new Error('Could not obtain the exclusive release transaction lock.');
|
if (handle === undefined)
|
||||||
try { return await callback(); } finally { await handle.close(); await unlink(lockPath).catch(() => 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) {
|
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);
|
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) => {
|
return await new Promise((resolvePromise, reject) => {
|
||||||
const child = spawn(command, ['--release-dir', releaseDir, '--manifest-digest', manifestDigest], { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
const child = spawn(
|
||||||
const stdout = []; const stderr = [];
|
command,
|
||||||
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Staged smoke check timed out.')); }, 15_000);
|
["--release-dir", releaseDir, "--manifest-digest", manifestDigest],
|
||||||
child.stdout.on('data', (chunk) => stdout.push(chunk)); child.stderr.on('data', (chunk) => stderr.push(chunk));
|
{ shell: false, stdio: ["ignore", "pipe", "pipe"] },
|
||||||
child.once('error', (error) => { clearTimeout(timer); reject(error); });
|
);
|
||||||
child.once('close', (code) => {
|
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);
|
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));
|
else resolvePromise(Buffer.concat(stdout));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -89,182 +262,500 @@ async function runSmokeCommand(command, releaseDir, manifestDigest) {
|
|||||||
|
|
||||||
async function prepareUnlocked(input) {
|
async function prepareUnlocked(input) {
|
||||||
const root = await assertReleaseRoot(input.root);
|
const root = await assertReleaseRoot(input.root);
|
||||||
const transactionId = assertToken(input.transactionId, 'transaction ID');
|
const transactionId = assertToken(input.transactionId, "transaction ID");
|
||||||
const statePath = join(root, 'control/state.json');
|
const statePath = join(root, "control/state.json");
|
||||||
const state = await readControlState(root);
|
let state = await recoverInterruptedPreparation(
|
||||||
if (state.activeTransaction !== null) throw new Error('Another release transaction is already active.');
|
root,
|
||||||
const firstNow = nowFrom(input.clock ?? input.now);
|
await readControlState(root),
|
||||||
const verified = await verifyStagedRelease({
|
);
|
||||||
archivePath: resolve(input.archivePath), verifierModulePath: input.verifierModulePath,
|
if (state.activeTransaction !== null)
|
||||||
verifySignedReleaseEnvelope: input.verifySignedReleaseEnvelope, trustPolicyPath: input.trustPolicyPath,
|
throw new Error("Another release transaction is already active.");
|
||||||
protectedState: {
|
const firstNow = nowFrom(input.testClock);
|
||||||
latestLedgerHeadHash: state.latestLedgerHeadHash, latestLedgerRevision: state.latestLedgerRevision,
|
const verificationInput = {
|
||||||
currentManifestDigest: state.currentManifestDigest, publisherFence: state.publisherFence,
|
archivePath: resolve(input.archivePath),
|
||||||
}, now: firstNow,
|
protectedState: protectedStateFrom(state),
|
||||||
});
|
};
|
||||||
|
const verified = input.testOnlyVerification
|
||||||
|
? await verifyStagedReleaseForTest({
|
||||||
|
...verificationInput,
|
||||||
|
...input.testOnlyVerification,
|
||||||
|
now: firstNow,
|
||||||
|
})
|
||||||
|
: await verifyStagedRelease({ ...verificationInput, authorityRoot: root });
|
||||||
const { index, admission, files } = verified;
|
const { index, admission, files } = verified;
|
||||||
if (input.rollbackTargetReleaseId !== undefined) {
|
if (input.rollbackTargetReleaseId !== undefined) {
|
||||||
const targetId = assertToken(input.rollbackTargetReleaseId, 'rollback target release ID');
|
const targetId = assertToken(
|
||||||
const targetIndex = parseCanonicalJson(await readFile(join(root, 'releases', targetId, '.release/package-index.json')));
|
input.rollbackTargetReleaseId,
|
||||||
if (targetIndex.manifestDigest === index.manifestDigest) throw new Error('Rollback must use a newly signed manifest, not replay an old one.');
|
"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 left = canonicalValueBytes(targetIndex.artifacts);
|
||||||
const right = canonicalValueBytes(index.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 = {
|
const activeTransaction = {
|
||||||
transactionId, manifestDigest: admission.manifestDigest, releaseId: index.releaseId, expectedFence: admission.expectedFence,
|
transactionId,
|
||||||
phase: 'preparing', readinessReceiptDigest: null,
|
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 partialName = `${transactionId}.partial`;
|
||||||
const partialDir = join(root, 'staging', partialName);
|
const partialDir = join(root, "staging", partialName);
|
||||||
const releaseDir = join(root, 'releases', index.releaseId);
|
const releaseDir = join(root, "releases", index.releaseId);
|
||||||
try {
|
try {
|
||||||
await mkdir(join(root, 'staging'), { 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, "releases"), { recursive: true, mode: 0o750 });
|
||||||
await mkdir(join(root, 'receipts', 'readiness'), { recursive: true, mode: 0o750 });
|
await mkdir(join(root, "receipts", "readiness"), {
|
||||||
|
recursive: true,
|
||||||
|
mode: 0o750,
|
||||||
|
});
|
||||||
await safeRemoveStage(root, partialName);
|
await safeRemoveStage(root, partialName);
|
||||||
await writeArchiveFiles(files.map((file) => file.path.startsWith('.release/')
|
await writeArchiveFiles(
|
||||||
? file
|
files.map((file) =>
|
||||||
: { ...file, path: `public/${file.path}` }), partialDir);
|
file.path.startsWith(".release/")
|
||||||
await makeImmutable(partialDir, input.uid, input.gid);
|
? file
|
||||||
const publicDir = join(partialDir, 'public');
|
: { ...file, path: `public/${file.path}` },
|
||||||
const smokeBytes = input.smokeCheck !== undefined
|
),
|
||||||
? await input.smokeCheck({ releaseDir: publicDir, manifestDigest: admission.manifestDigest })
|
partialDir,
|
||||||
: await runSmokeCommand(input.smokeCommand, publicDir, admission.manifestDigest);
|
);
|
||||||
const secondNow = nowFrom(input.clock ?? input.now);
|
if (input.testPreparationCrashPoint === "after-extract")
|
||||||
if (Date.parse(secondNow) >= Date.parse(admission.expiresAt)) throw new Error('Release expired while staging or smoke-checking.');
|
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);
|
const stateAfterSmoke = await readControlState(root);
|
||||||
if (stateAfterSmoke.latestLedgerHeadHash !== admission.latestLedgerHeadHash || stateAfterSmoke.latestLedgerRevision !== admission.latestLedgerRevision ||
|
if (
|
||||||
stateAfterSmoke.currentManifestDigest !== admission.predecessorManifestDigest || stateAfterSmoke.publisherFence + 1 !== admission.expectedFence) {
|
stateAfterSmoke.latestLedgerHeadHash !== admission.latestLedgerHeadHash ||
|
||||||
throw new Error('Protected state changed while staging the release.');
|
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 chmod(partialDir, 0o755);
|
||||||
await rename(partialDir, releaseDir).catch((error) => {
|
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;
|
throw error;
|
||||||
});
|
});
|
||||||
await chmod(releaseDir, 0o555);
|
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 = {
|
const receipt = {
|
||||||
schema: 'portfolio-release-readiness-receipt/v1', transactionId, manifestDigest: admission.manifestDigest,
|
schema: "portfolio-release-readiness-receipt/v1",
|
||||||
releaseId: index.releaseId, predecessorManifestDigest: admission.predecessorManifestDigest, expectedFence: admission.expectedFence,
|
transactionId,
|
||||||
latestLedgerHeadHash: admission.latestLedgerHeadHash, latestLedgerRevision: admission.latestLedgerRevision,
|
manifestDigest: admission.manifestDigest,
|
||||||
expiresAt: admission.expiresAt, packageDigest: byteDigest(await readFile(input.archivePath)),
|
releaseId: index.releaseId,
|
||||||
smokeEvidenceDigest: byteDigest(smokeBytes instanceof Uint8Array ? smokeBytes : Buffer.from(String(smokeBytes))), createdAt: secondNow,
|
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 receiptDigest = canonicalJsonDigest(
|
||||||
const receiptPath = join(root, 'receipts', 'readiness', `${transactionId}.json`);
|
"portfolio-release-readiness-receipt/v1",
|
||||||
await atomicCanonicalWrite(receiptPath, receipt, 0o440);
|
receipt,
|
||||||
|
);
|
||||||
|
const receiptPath = join(
|
||||||
|
root,
|
||||||
|
"receipts",
|
||||||
|
"readiness",
|
||||||
|
`${transactionId}.json`,
|
||||||
|
);
|
||||||
|
await durableCanonicalWrite(receiptPath, receipt, 0o440);
|
||||||
await chmod(receiptPath, 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 };
|
return { receipt, receiptDigest, releaseDir };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.simulatedProcessDeath === true) throw error;
|
||||||
await makeWritable(partialDir).catch(() => undefined);
|
await makeWritable(partialDir).catch(() => undefined);
|
||||||
await safeRemoveStage(root, partialName).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);
|
const latest = await readControlState(root).catch(() => null);
|
||||||
if (latest?.activeTransaction?.transactionId === transactionId && latest.activeTransaction.phase !== 'committing') {
|
if (
|
||||||
await atomicCanonicalWrite(statePath, { ...latest, activeTransaction: null }).catch(() => undefined);
|
latest?.activeTransaction?.transactionId === transactionId &&
|
||||||
|
latest.activeTransaction.phase !== "committing"
|
||||||
|
) {
|
||||||
|
await durableCanonicalWrite(statePath, {
|
||||||
|
...latest,
|
||||||
|
activeTransaction: null,
|
||||||
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareRelease(input) {
|
export async function prepareRelease(input) {
|
||||||
|
exactObject(
|
||||||
|
input,
|
||||||
|
["root", "archivePath", "transactionId", "smokeCommand"],
|
||||||
|
"production preparation request",
|
||||||
|
);
|
||||||
const root = await assertReleaseRoot(input.root);
|
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) {
|
function validateReadiness(value) {
|
||||||
const receipt = exactObject(value, readinessFields, 'readiness receipt');
|
const receipt = exactObject(value, readinessFields, "readiness receipt");
|
||||||
if (receipt.schema !== 'portfolio-release-readiness-receipt/v1') throw new Error('Readiness receipt schema is invalid.');
|
if (receipt.schema !== "portfolio-release-readiness-receipt/v1")
|
||||||
assertToken(receipt.transactionId, 'readiness transaction ID'); assertDigest(receipt.manifestDigest, 'readiness manifest digest');
|
throw new Error("Readiness receipt schema is invalid.");
|
||||||
assertToken(receipt.releaseId, 'readiness release ID'); assertTimestamp(receipt.expiresAt, 'readiness expiry');
|
assertToken(receipt.transactionId, "readiness transaction ID");
|
||||||
assertDigest(receipt.latestLedgerHeadHash, 'readiness ledger head'); assertDigest(receipt.packageDigest, 'readiness package digest'); assertDigest(receipt.smokeEvidenceDigest, 'smoke evidence digest');
|
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;
|
return receipt;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signedManifestFromRelease(root, releaseId) {
|
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) {
|
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) {
|
async function finalizeActivation(
|
||||||
const newState = { ...state, currentManifestDigest: tx.manifestDigest, currentReleaseId: tx.releaseId, publisherFence: tx.expectedFence, activeTransaction: null };
|
root,
|
||||||
await mkdir(join(root, 'receipts', 'activation'), { recursive: true, mode: 0o750 });
|
state,
|
||||||
const activationReceipt = {
|
tx,
|
||||||
schema: 'portfolio-release-activation-receipt/v1', transactionId: tx.transactionId, predecessorManifestDigest: receipt.predecessorManifestDigest,
|
receipt,
|
||||||
manifestDigest: tx.manifestDigest, releaseId: tx.releaseId, fence: tx.expectedFence,
|
committedAt,
|
||||||
readinessReceiptDigest: canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt), committedAt, recoveredAfterPointerSwap: recovered,
|
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 mkdir(join(root, "receipts", "activation"), {
|
||||||
await atomicCanonicalWrite(path, activationReceipt, 0o440);
|
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 chmod(path, 0o440);
|
||||||
await atomicCanonicalWrite(join(root, 'control/state.json'), newState);
|
await durableCanonicalWrite(join(root, "control/state.json"), newState);
|
||||||
return activationReceipt;
|
return activationReceipt;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateUnlocked(input) {
|
async function activateUnlocked(input) {
|
||||||
const root = await assertReleaseRoot(input.root);
|
const root = await assertReleaseRoot(input.root);
|
||||||
const transactionId = assertToken(input.transactionId, 'transaction ID');
|
const transactionId = assertToken(input.transactionId, "transaction ID");
|
||||||
const expectedCurrent = normalizeExpectedDigest(input.expectedCurrentManifestDigest);
|
const expectedCurrent = normalizeExpectedDigest(
|
||||||
if (!Number.isSafeInteger(input.expectedFence) || input.expectedFence < 1) throw new Error('Expected fence is invalid.');
|
input.expectedCurrentManifestDigest,
|
||||||
|
);
|
||||||
|
if (!Number.isSafeInteger(input.expectedFence) || input.expectedFence < 1)
|
||||||
|
throw new Error("Expected fence is invalid.");
|
||||||
let state = await readControlState(root);
|
let state = await readControlState(root);
|
||||||
const tx = state.activeTransaction;
|
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.');
|
if (
|
||||||
const receipt = validateReadiness(parseCanonicalJson(await readFile(join(root, 'receipts', 'readiness', `${transactionId}.json`))));
|
tx === null ||
|
||||||
if (tx.readinessReceiptDigest !== canonicalJsonDigest('portfolio-release-readiness-receipt/v1', receipt)) throw new Error('Readiness receipt digest does not match the protected transaction.');
|
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 target = `releases/${tx.releaseId}/public`;
|
||||||
|
const predecessorTarget =
|
||||||
|
state.currentReleaseId === null
|
||||||
|
? null
|
||||||
|
: `releases/${state.currentReleaseId}/public`;
|
||||||
const actualCurrent = await currentTarget(root);
|
const actualCurrent = await currentTarget(root);
|
||||||
if (tx.phase === 'committing' && actualCurrent === target) {
|
if (
|
||||||
return finalizeActivation(root, state, tx, receipt, nowFrom(input.clock ?? input.now), true);
|
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 ||
|
const activationNow = nowFrom(input.testClock);
|
||||||
receipt.predecessorManifestDigest !== expectedCurrent || receipt.expectedFence !== input.expectedFence) throw new Error('Activation predecessor or fence is stale.');
|
if (Date.parse(activationNow) >= Date.parse(receipt.expiresAt))
|
||||||
const activationNow = nowFrom(input.clock ?? input.now);
|
throw new Error("Release expired before activation.");
|
||||||
if (Date.parse(activationNow) >= Date.parse(receipt.expiresAt)) throw new Error('Release expired before activation.');
|
const verificationInput = {
|
||||||
const protectedState = {
|
signedManifestPath: await signedManifestFromRelease(root, tx.releaseId),
|
||||||
latestLedgerHeadHash: state.latestLedgerHeadHash, latestLedgerRevision: state.latestLedgerRevision,
|
protectedState: protectedStateFrom(state),
|
||||||
currentManifestDigest: state.currentManifestDigest, publisherFence: state.publisherFence,
|
|
||||||
};
|
};
|
||||||
const verified = await verifySignedRelease({
|
const verified = input.testOnlyVerification
|
||||||
signedManifestPath: await signedManifestFromRelease(root, tx.releaseId), verifierModulePath: input.verifierModulePath,
|
? await verifySignedReleaseForTest({
|
||||||
verifySignedReleaseEnvelope: input.verifySignedReleaseEnvelope, trustPolicyPath: input.trustPolicyPath,
|
...verificationInput,
|
||||||
protectedState, now: activationNow,
|
...input.testOnlyVerification,
|
||||||
});
|
now: activationNow,
|
||||||
if (verified.admission.manifestDigest !== tx.manifestDigest || verified.admission.expectedFence !== tx.expectedFence ||
|
})
|
||||||
verified.admission.latestLedgerHeadHash !== receipt.latestLedgerHeadHash || verified.admission.latestLedgerRevision !== receipt.latestLedgerRevision) {
|
: await verifySignedRelease({ ...verificationInput, authorityRoot: root });
|
||||||
throw new Error('Activation signature admission does not match readiness.');
|
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);
|
state = await readControlState(root);
|
||||||
if (state.currentManifestDigest !== expectedCurrent || state.publisherFence + 1 !== input.expectedFence || state.activeTransaction?.transactionId !== transactionId) {
|
if (
|
||||||
throw new Error('Protected state changed before activation commit.');
|
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' } });
|
if (tx.phase === "committing" && actualCurrent === target) {
|
||||||
const nextPath = join(root, 'current.next');
|
return finalizeActivation(root, state, tx, receipt, activationNow, true);
|
||||||
const nextInfo = await lstat(nextPath).catch((error) => error?.code === 'ENOENT' ? null : Promise.reject(error));
|
}
|
||||||
|
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 !== 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 unlink(nextPath);
|
||||||
}
|
}
|
||||||
await symlink(target, nextPath);
|
await symlink(target, nextPath);
|
||||||
if (input.crashPoint === 'before-swap') throw new Error('Injected crash before pointer swap.');
|
if (input.crashPoint === "before-swap")
|
||||||
await rename(nextPath, join(root, 'current'));
|
throw new Error("Injected crash before pointer swap.");
|
||||||
if (input.crashPoint === 'after-swap') throw new Error('Injected crash after pointer swap.');
|
await rename(nextPath, join(root, "current"));
|
||||||
return finalizeActivation(root, state, tx, receipt, activationNow, false);
|
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) {
|
export async function activateRelease(input) {
|
||||||
|
exactObject(
|
||||||
|
input,
|
||||||
|
["root", "transactionId", "expectedCurrentManifestDigest", "expectedFence"],
|
||||||
|
"production activation request",
|
||||||
|
);
|
||||||
const root = await assertReleaseRoot(input.root);
|
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) {
|
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);
|
const root = await assertReleaseRoot(input.root);
|
||||||
return withExclusiveLock(root, async () => {
|
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 });
|
return activateUnlocked({ ...input, root, lock: false });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -273,21 +764,38 @@ async function makeWritable(path) {
|
|||||||
const info = await lstat(path);
|
const info = await lstat(path);
|
||||||
if (info.isDirectory()) {
|
if (info.isDirectory()) {
|
||||||
await chmod(path, 0o700);
|
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 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) {
|
export async function cleanupOrphanStages(rootPath) {
|
||||||
const root = await assertReleaseRoot(rootPath);
|
const root = await assertReleaseRoot(rootPath);
|
||||||
const staging = join(root, 'staging');
|
const staging = join(root, "staging");
|
||||||
await mkdir(staging, { recursive: true, mode: 0o750 });
|
await mkdir(staging, { recursive: true, mode: 0o750 });
|
||||||
const state = await readControlState(root);
|
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 = [];
|
const removed = [];
|
||||||
for (const entry of await readdir(staging, { withFileTypes: true })) {
|
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)) {
|
if (
|
||||||
throw new Error('Unexpected staging entry blocks orphan cleanup.');
|
!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;
|
if (activePrefix !== null && entry.name.startsWith(activePrefix)) continue;
|
||||||
const path = join(staging, entry.name);
|
const path = join(staging, entry.name);
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from "node:url";
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from "node:path";
|
||||||
import { cleanupOrphanStages, prepareRelease } from './lib/release-transaction.mjs';
|
import {
|
||||||
|
cleanupOrphanStages,
|
||||||
|
prepareRelease,
|
||||||
|
} from "./lib/release-transaction.mjs";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
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]));
|
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>');
|
if (args.length !== 4)
|
||||||
const [root, archivePath, transactionId, verifierModulePath, trustPolicyPath, smokeCommand, uid, gid, now] = args;
|
throw new Error(
|
||||||
const result = await prepareRelease({ root: resolve(root), archivePath: resolve(archivePath), transactionId,
|
"Usage: prepare-release.mjs <root> <inbox-archive> <transaction-id> <smoke-command>",
|
||||||
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), smokeCommand: resolve(smokeCommand),
|
);
|
||||||
uid: Number(uid), gid: Number(gid), now });
|
const [root, archivePath, transactionId, smokeCommand] = args;
|
||||||
process.stdout.write(`${JSON.stringify({ manifestDigest: result.receipt.manifestDigest, releaseId: result.receipt.releaseId })}\n`);
|
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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from "node:url";
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from "node:path";
|
||||||
import { rollbackRelease } from './lib/release-transaction.mjs';
|
import { rollbackRelease } from "./lib/release-transaction.mjs";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
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>');
|
if (args.length !== 7)
|
||||||
const [root, targetReleaseId, archivePath, transactionId, verifierModulePath, trustPolicyPath, smokeCommand, uid, gid, expectedCurrentManifestDigest, expectedFence, now] = args;
|
throw new Error(
|
||||||
const receipt = await rollbackRelease({ root: resolve(root), targetReleaseId, archivePath: resolve(archivePath), transactionId,
|
"Usage: rollback-release.mjs <root> <target-release-id> <inbox-archive> <transaction-id> <smoke-command> <expected-current> <expected-fence>",
|
||||||
verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), smokeCommand: resolve(smokeCommand),
|
);
|
||||||
uid: Number(uid), gid: Number(gid), expectedCurrentManifestDigest, expectedFence: Number(expectedFence), now });
|
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`);
|
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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,25 +1,44 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { mkdtemp, readFile } from 'node:fs/promises';
|
import { mkdtemp, readFile } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from "node:path";
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
import { canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
import { canonicalValueBytes } from "../../src/security/canonical-digest.ts";
|
||||||
import { parseDeterministicTar, validatePackageFiles, verifySignedRelease } from './lib/release-common.mjs';
|
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 files = parseDeterministicTar(await readFile(archivePath));
|
||||||
const { index, byPath } = validatePackageFiles(files);
|
const { index, byPath } = validatePackageFiles(files);
|
||||||
const temporaryRoot = await mkdtemp(join(tmpdir(), 'portfolio-release-verify-'));
|
const temporaryRoot = await mkdtemp(
|
||||||
const signedPath = join(temporaryRoot, 'signed-release.json');
|
join(tmpdir(), "portfolio-release-verify-"),
|
||||||
const { writeFile, rm } = await import('node:fs/promises');
|
);
|
||||||
await writeFile(signedPath, byPath.get('.release/signed-release-manifest.json').bytes, { flag: 'wx', mode: 0o400 });
|
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 {
|
try {
|
||||||
const verified = await verifySignedRelease({ signedManifestPath: signedPath, verifierModulePath, verifySignedReleaseEnvelope, trustPolicyPath, protectedState, now });
|
const verified = await verify({ ...input, signedManifestPath: signedPath });
|
||||||
if (verified.admission.manifestDigest !== index.manifestDigest || verified.manifest.buildId !== index.buildId ||
|
if (
|
||||||
`${verified.manifest.buildId}-${verified.admission.manifestDigest}` !== index.releaseId ||
|
verified.admission.manifestDigest !== index.manifestDigest ||
|
||||||
verified.admission.expectedFence !== index.expectedFence || verified.admission.expiresAt !== index.expiresAt) {
|
verified.manifest.buildId !== index.buildId ||
|
||||||
throw new Error('Package index does not bind the verified signed release admission.');
|
`${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 };
|
return { files, index, ...verified };
|
||||||
} finally {
|
} finally {
|
||||||
@@ -27,13 +46,57 @@ export async function verifyStagedRelease({ archivePath, verifierModulePath, ver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
export async function verifyStagedRelease(input) {
|
||||||
const [archivePath, verifierModulePath, trustPolicyPath, protectedStateJson, now] = process.argv.slice(2);
|
return verifyStagedReleaseWith(input, (request) =>
|
||||||
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>');
|
verifySignedRelease({
|
||||||
const result = await verifyStagedRelease({ archivePath: resolve(archivePath), verifierModulePath: resolve(verifierModulePath), trustPolicyPath: resolve(trustPolicyPath), protectedState: JSON.parse(protectedStateJson), now });
|
signedManifestPath: request.signedManifestPath,
|
||||||
process.stdout.write(Buffer.from(canonicalValueBytes(result.admission))); process.stdout.write('\n');
|
authorityRoot: request.authorityRoot,
|
||||||
|
protectedState: request.protectedState,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
export async function verifyStagedReleaseForTest(input) {
|
||||||
main().catch((error) => { process.stderr.write(`Staged release verification failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,394 @@
|
|||||||
import { lstat, mkdtemp, mkdir, readFile, readlink, writeFile } from 'node:fs/promises';
|
import {
|
||||||
import { tmpdir } from 'node:os';
|
lstat,
|
||||||
import { join } from 'node:path';
|
mkdtemp,
|
||||||
import { describe, expect, test } from 'vitest';
|
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 { canonicalValueBytes } from "../../src/security/canonical-digest.ts";
|
||||||
import { activateRelease, cleanupOrphanStages, prepareRelease, readControlState, rollbackRelease } from '../../scripts/deploy/lib/release-transaction.mjs';
|
import {
|
||||||
import { createReleaseFixture, initializeReleaseRoot } from './release-test-fixture.ts';
|
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>>;
|
type Fixture = Awaited<ReturnType<typeof createReleaseFixture>>;
|
||||||
|
|
||||||
function buildInput(fixture: Fixture, outputPath: string) {
|
function buildInput(fixture: Fixture, outputPath: string) {
|
||||||
return {
|
return {
|
||||||
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
|
distDir: fixture.distDir,
|
||||||
verifierModulePath: fixture.verifierModulePath, verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
|
signedManifestPath: fixture.signedManifestPath,
|
||||||
trustPolicyPath: fixture.trustPolicyPath, protectedState: fixture.protectedState,
|
sbomPath: fixture.sbomPath,
|
||||||
now: '2026-09-02T01:00:00Z', outputPath,
|
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 {
|
return {
|
||||||
root, archivePath, transactionId, verifierModulePath: fixture.verifierModulePath,
|
root,
|
||||||
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope, trustPolicyPath: fixture.trustPolicyPath,
|
archivePath,
|
||||||
uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0, now: '2026-09-02T01:00:00Z',
|
transactionId,
|
||||||
|
testOnlyVerification: {
|
||||||
|
trustPolicyPath: fixture.trustPolicyPath,
|
||||||
|
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
|
||||||
|
},
|
||||||
|
testClock: () => "2026-09-02T01:00:00Z",
|
||||||
smokeCheck: async ({ releaseDir }: { releaseDir: string }) => {
|
smokeCheck: async ({ releaseDir }: { releaseDir: string }) => {
|
||||||
await readFile(join(releaseDir, 'index.html'));
|
await readFile(join(releaseDir, "index.html"));
|
||||||
return Buffer.from('internal-nginx-smoke-ok\n');
|
return Buffer.from("internal-nginx-smoke-ok\n");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('fenced atomic Nginx release transaction', () => {
|
async function setup(prefix: string) {
|
||||||
test('prepares immutably without changing current, then activates with one atomic pointer swap', async () => {
|
const temp = await mkdtemp(join(tmpdir(), prefix));
|
||||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-atomic-'));
|
const fixture = await createReleaseFixture(join(temp, "fixture"));
|
||||||
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
const root = join(temp, "release-root");
|
||||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
await initializeReleaseRoot(
|
||||||
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
root,
|
||||||
await prepareRelease(transactionInput(root, archive, 'tx-a', fixture));
|
fixture.protectedState,
|
||||||
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
fixture.trustPolicy,
|
||||||
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'));
|
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(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))).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 () => {
|
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, root, archive } = await setup("portfolio-fence-");
|
||||||
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
const input = transactionInput(root, archive, "tx-a", fixture);
|
||||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
await prepareReleaseForTest(input);
|
||||||
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
await expect(
|
||||||
const input = transactionInput(root, archive, 'tx-a', fixture);
|
activateReleaseForTest({
|
||||||
await prepareRelease(input);
|
...input,
|
||||||
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 2, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('fence');
|
expectedCurrentManifestDigest: null,
|
||||||
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
expectedFence: 2,
|
||||||
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');
|
).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([
|
const attempts = await Promise.allSettled([
|
||||||
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
|
activateReleaseForTest({
|
||||||
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
|
...input,
|
||||||
|
expectedCurrentManifestDigest: null,
|
||||||
|
expectedFence: 1,
|
||||||
|
}),
|
||||||
|
activateReleaseForTest({
|
||||||
|
...input,
|
||||||
|
expectedCurrentManifestDigest: null,
|
||||||
|
expectedFence: 1,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
expect(attempts.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
expect(
|
||||||
expect(attempts.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
attempts.filter((result) => result.status === "fulfilled"),
|
||||||
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
|
).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 () => {
|
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, root, archive } = await setup(
|
||||||
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
"portfolio-expiry-crossing-",
|
||||||
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];
|
||||||
const times = ['2026-09-02T01:00:00Z', fixture.expiresAt];
|
await expect(
|
||||||
await expect(prepareRelease({ ...transactionInput(root, archive, 'tx-a', fixture), clock: () => times.shift() ?? fixture.expiresAt })).rejects.toThrow('expired while staging');
|
prepareReleaseForTest({
|
||||||
|
...transactionInput(root, archive, "tx-a", fixture),
|
||||||
|
testClock: () => times.shift() ?? fixture.expiresAt,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("expired while staging");
|
||||||
expect((await readControlState(root)).activeTransaction).toBeNull();
|
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 () => {
|
test.each([
|
||||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-crash-'));
|
"after-intent",
|
||||||
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
"after-extract",
|
||||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
"after-immutable",
|
||||||
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
"after-release-rename",
|
||||||
const input = { ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' };
|
"after-receipt",
|
||||||
await prepareRelease(input);
|
] as const)(
|
||||||
await expect(activateRelease({ ...input, crashPoint: 'before-swap' })).rejects.toThrow('before pointer');
|
"recovers an interrupted preparation at %s and safely retries",
|
||||||
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
async (testPreparationCrashPoint) => {
|
||||||
await expect(activateRelease({ ...input, crashPoint: 'after-swap' })).rejects.toThrow('after pointer');
|
const { fixture, root, archive } = await setup(
|
||||||
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
|
`portfolio-prepare-crash-${testPreparationCrashPoint}-`,
|
||||||
const recovered = await activateRelease(input);
|
);
|
||||||
|
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(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 () => {
|
test("a protected ledger revocation racing final publication is preserved and blocks activation", async () => {
|
||||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-rollback-'));
|
const { fixture, root, archive } = await setup(
|
||||||
const fixtureA = await createReleaseFixture(join(temp, 'fixture-a'));
|
"portfolio-revocation-race-",
|
||||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
);
|
||||||
const archiveA = join(temp, 'a.tar'); await buildReleasePackage(buildInput(fixtureA, archiveA));
|
const input = transactionInput(root, archive, "tx-a", fixture);
|
||||||
const inputA = transactionInput(root, archiveA, 'tx-a', fixtureA);
|
await prepareReleaseForTest(input);
|
||||||
await prepareRelease(inputA);
|
await expect(
|
||||||
await activateRelease({ ...inputA, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
|
activateReleaseForTest({
|
||||||
const releaseA = (await readlink(join(root, 'current'))).split('/')[1] as string;
|
...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'));
|
test("rollback requires a fresh signed manifest that binds the selected old artifact closure", async () => {
|
||||||
fixtureB.protectedState.currentManifestDigest = fixtureA.manifestDigest; fixtureB.protectedState.publisherFence = 1;
|
const { temp, fixture, root, archive } = await setup("portfolio-rollback-");
|
||||||
await writeFile(join(fixtureB.distDir, 'index.html'), '<!doctype html><title>Portfolio B</title>\n');
|
const inputA = transactionInput(root, archive, "tx-a", fixture);
|
||||||
const signedB = await fixtureB.signFor({ buildId: 'release-b', distDir: fixtureB.distDir, predecessorManifestDigest: fixtureA.manifestDigest, publisherFence: 1, outputPath: fixtureB.signedManifestPath });
|
await prepareReleaseForTest(inputA);
|
||||||
const archiveB = join(temp, 'b.tar'); await buildReleasePackage({ ...buildInput(fixtureB, archiveB), now: '2026-09-02T02:00:00Z' });
|
await activateReleaseForTest({
|
||||||
const inputB = { ...transactionInput(root, archiveB, 'tx-b', fixtureB), now: '2026-09-02T02:00:00Z' };
|
...inputA,
|
||||||
await prepareRelease(inputB);
|
expectedCurrentManifestDigest: null,
|
||||||
await activateRelease({ ...inputB, expectedCurrentManifestDigest: fixtureA.manifestDigest, expectedFence: 2, now: '2026-09-02T02:01:00Z' });
|
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;
|
await expect(
|
||||||
const freshSigned = join(temp, 'rollback-signed.json');
|
rollbackReleaseForTest({
|
||||||
const fresh = await fixtureA.signFor({ buildId: 'rollback-a', distDir: fixtureA.distDir, predecessorManifestDigest: signedB.manifestDigest, publisherFence: 2, outputPath: freshSigned });
|
...inputA,
|
||||||
fixtureA.signedManifestPath = freshSigned;
|
targetReleaseId: releaseA,
|
||||||
const rollbackArchive = join(temp, 'rollback.tar'); await buildReleasePackage({ ...buildInput(fixtureA, rollbackArchive), now: '2026-09-02T03:00:00Z' });
|
transactionId: "tx-replay",
|
||||||
const rollback = await rollbackRelease({ ...transactionInput(root, rollbackArchive, 'tx-rollback', fixtureA), targetReleaseId: releaseA,
|
expectedCurrentManifestDigest: signedB.manifestDigest,
|
||||||
expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' });
|
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(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 () => {
|
test("production APIs reject caller authority fields and archives outside the upload-only inbox", async () => {
|
||||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-cleanup-'));
|
const { fixture, root, archive } = await setup(
|
||||||
const outside = join(temp, 'outside.txt'); await writeFile(outside, 'keep');
|
"portfolio-production-authority-",
|
||||||
const invalidRoot = join(temp, 'not-a-release-root'); await mkdir(join(invalidRoot, 'staging', 'orphan.partial'), { recursive: true });
|
);
|
||||||
await expect(cleanupOrphanStages(invalidRoot)).rejects.toThrow('sentinel');
|
await expect(
|
||||||
expect(await readFile(outside, 'utf8')).toBe('keep');
|
prepareRelease({
|
||||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
...transactionInput(root, archive, "tx-a", fixture),
|
||||||
await mkdir(join(root, 'staging', 'orphan.partial'), { recursive: true });
|
smokeCommand: "/bin/true",
|
||||||
await writeFile(join(root, 'staging', 'orphan.partial', 'marker'), 'orphan');
|
} as any),
|
||||||
expect(await cleanupOrphanStages(root)).toEqual(['orphan.partial']);
|
).rejects.toThrow("unknown");
|
||||||
await expect(lstat(join(root, 'staging', 'orphan.partial'))).rejects.toThrow();
|
await expect(
|
||||||
expect(await readFile(outside, 'utf8')).toBe('keep');
|
prepareRelease({
|
||||||
await expect(cleanupOrphanStages('/')).rejects.toThrow('Broad');
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,51 +1,96 @@
|
|||||||
import { mkdtemp, readFile, unlink, writeFile } from 'node:fs/promises';
|
import { mkdtemp, readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import { join } from 'node:path';
|
import { join } from "node:path";
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { byteDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
import {
|
||||||
import { buildReleasePackage } from '../../scripts/build-release.mjs';
|
byteDigest,
|
||||||
import { parseDeterministicTar } from '../../scripts/deploy/lib/release-common.mjs';
|
canonicalValueBytes,
|
||||||
import { createReleaseFixture } from './release-test-fixture.ts';
|
} 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 {
|
return {
|
||||||
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
|
distDir: fixture.distDir,
|
||||||
verifierModulePath: fixture.verifierModulePath, trustPolicyPath: fixture.trustPolicyPath,
|
signedManifestPath: fixture.signedManifestPath,
|
||||||
|
sbomPath: fixture.sbomPath,
|
||||||
|
trustPolicyPath: fixture.trustPolicyPath,
|
||||||
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
|
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', () => {
|
describe("deterministic exact-manifest release package", () => {
|
||||||
test('builds byte-identical archives containing only the signed closure and release metadata', async () => {
|
test("builds byte-identical archives containing only the signed closure and release metadata", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-'));
|
const root = await mkdtemp(join(tmpdir(), "portfolio-package-"));
|
||||||
const fixture = await createReleaseFixture(root);
|
const fixture = await createReleaseFixture(root);
|
||||||
const first = join(root, 'release-one.tar');
|
const first = join(root, "release-one.tar");
|
||||||
const second = join(root, 'release-two.tar');
|
const second = join(root, "release-two.tar");
|
||||||
await buildReleasePackage(buildInput(fixture, first));
|
await buildReleasePackageForTest(buildInput(fixture, first));
|
||||||
await buildReleasePackage(buildInput(fixture, second));
|
await buildReleasePackageForTest(buildInput(fixture, second));
|
||||||
expect(byteDigest(await readFile(first))).toBe(byteDigest(await readFile(second)));
|
expect(byteDigest(await readFile(first))).toBe(
|
||||||
expect(parseDeterministicTar(await readFile(first)).map((file) => file.path)).toEqual([
|
byteDigest(await readFile(second)),
|
||||||
'.release/package-index.json', '.release/sbom.cdx.json', '.release/signed-release-manifest.json',
|
);
|
||||||
'assets/app.js', 'index.html',
|
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 () => {
|
test("fails closed on extra, substituted, or invalidly signed inputs", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-invalid-'));
|
const root = await mkdtemp(join(tmpdir(), "portfolio-package-invalid-"));
|
||||||
const fixture = await createReleaseFixture(root);
|
const fixture = await createReleaseFixture(root);
|
||||||
await writeFile(join(fixture.distDir, 'extra.txt'), 'not in manifest');
|
await writeFile(join(fixture.distDir, "extra.txt"), "not in manifest");
|
||||||
await expect(buildReleasePackage(buildInput(fixture, join(root, 'extra.tar')))).rejects.toThrow('exactly match');
|
await expect(
|
||||||
|
buildReleasePackageForTest(buildInput(fixture, join(root, "extra.tar"))),
|
||||||
|
).rejects.toThrow("exactly match");
|
||||||
|
|
||||||
await unlink(join(fixture.distDir, 'extra.txt'));
|
await unlink(join(fixture.distDir, "extra.txt"));
|
||||||
await writeFile(join(fixture.distDir, 'index.html'), 'substituted');
|
await writeFile(join(fixture.distDir, "index.html"), "substituted");
|
||||||
await expect(buildReleasePackage(buildInput(fixture, join(root, 'substituted.tar')))).rejects.toThrow('hash');
|
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');
|
await writeFile(
|
||||||
const envelope = JSON.parse(await readFile(fixture.signedManifestPath, 'utf8'));
|
join(fixture.distDir, "index.html"),
|
||||||
envelope.signature.value = `${envelope.signature.value[0] === 'A' ? 'B' : 'A'}${envelope.signature.value.slice(1)}`;
|
"<!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 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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { generateKeyPairSync, sign, verify } from 'node:crypto';
|
import { generateKeyPairSync, sign, verify } from "node:crypto";
|
||||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { join } from 'node:path';
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { canonicalJsonDigest, canonicalValueBytes, byteDigest } from '../../src/security/canonical-digest.ts';
|
import {
|
||||||
import { computeProductionInputRoot } from '../../src/security/production-input-root.ts';
|
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);
|
const digest = (character: string) => character.repeat(64);
|
||||||
|
|
||||||
@@ -12,13 +16,19 @@ export interface ReleaseFixture {
|
|||||||
signedManifestPath: string;
|
signedManifestPath: string;
|
||||||
sbomPath: string;
|
sbomPath: string;
|
||||||
trustPolicyPath: string;
|
trustPolicyPath: string;
|
||||||
verifierModulePath: string;
|
trustPolicy: Record<string, unknown>;
|
||||||
verifySignedReleaseEnvelope(input: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }): any;
|
verifySignedReleaseEnvelope(input: {
|
||||||
|
signedRelease: any;
|
||||||
|
trustPolicy: any;
|
||||||
|
now: string;
|
||||||
|
protectedState: any;
|
||||||
|
}): any;
|
||||||
protectedState: {
|
protectedState: {
|
||||||
latestLedgerHeadHash: string;
|
latestLedgerHeadHash: string;
|
||||||
latestLedgerRevision: number;
|
latestLedgerRevision: number;
|
||||||
currentManifestDigest: string | null;
|
currentManifestDigest: string | null;
|
||||||
publisherFence: number;
|
publisherFence: number;
|
||||||
|
releaseTrustPolicyDigest: string;
|
||||||
};
|
};
|
||||||
manifestDigest: string;
|
manifestDigest: string;
|
||||||
buildId: string;
|
buildId: string;
|
||||||
@@ -34,83 +44,106 @@ export interface ReleaseFixture {
|
|||||||
}): Promise<{ manifestDigest: string; signedManifestPath: string }>;
|
}): Promise<{ manifestDigest: string; signedManifestPath: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createReleaseFixture(root: string): Promise<ReleaseFixture> {
|
export async function createReleaseFixture(
|
||||||
const distDir = join(root, 'dist');
|
root: string,
|
||||||
await mkdir(join(distDir, 'assets'), { recursive: true });
|
): Promise<ReleaseFixture> {
|
||||||
await writeFile(join(distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
|
const distDir = join(root, "dist");
|
||||||
await writeFile(join(distDir, 'assets/app.js'), 'console.log("portfolio");\n');
|
await mkdir(join(distDir, "assets"), { recursive: true });
|
||||||
const sbomPath = join(root, 'sbom.cdx.json');
|
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');
|
await writeFile(sbomPath, '{"bomFormat":"CycloneDX","specVersion":"1.6"}\n');
|
||||||
|
|
||||||
const keys = generateKeyPairSync('ed25519');
|
const keys = generateKeyPairSync("ed25519");
|
||||||
const publicKeyPem = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
const publicKeyPem = keys.publicKey
|
||||||
const trustPolicyPath = join(root, 'release-trust.json');
|
.export({ type: "spki", format: "pem" })
|
||||||
await writeFile(trustPolicyPath, canonicalValueBytes({
|
.toString();
|
||||||
keyId: 'release-test-key', issuerEpoch: 7, scope: 'portfolio-release-signing/v1', publicKeyPem,
|
const trustPolicyPath = join(root, "release-trust.json");
|
||||||
}));
|
const trustPolicy = {
|
||||||
|
keyId: "release-test-key",
|
||||||
const verifierModulePath = join(root, 'real-release-verifier.mjs');
|
issuerEpoch: 7,
|
||||||
await writeFile(verifierModulePath, `
|
scope: "portfolio-release-signing/v1",
|
||||||
import { createHash, verify } from 'node:crypto';
|
publicKeyPem,
|
||||||
|
|
||||||
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 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');
|
|
||||||
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: protectedState.publisherFence + 1,
|
|
||||||
signatureVerified: true,
|
|
||||||
};
|
};
|
||||||
}
|
await writeFile(trustPolicyPath, canonicalValueBytes(trustPolicy));
|
||||||
`);
|
|
||||||
await chmod(verifierModulePath, 0o500);
|
|
||||||
|
|
||||||
const protectedState = {
|
const protectedState = {
|
||||||
latestLedgerHeadHash: digest('9'), latestLedgerRevision: 11, currentManifestDigest: null, publisherFence: 0,
|
latestLedgerHeadHash: digest("9"),
|
||||||
|
latestLedgerRevision: 11,
|
||||||
|
currentManifestDigest: null,
|
||||||
|
publisherFence: 0,
|
||||||
|
releaseTrustPolicyDigest: canonicalJsonDigest(
|
||||||
|
"portfolio-trust-policy/v1",
|
||||||
|
trustPolicy,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifySignedReleaseEnvelope = ({ signedRelease, trustPolicy, now, protectedState: state }: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }) => {
|
const verifySignedReleaseEnvelope = ({
|
||||||
if (signedRelease?.schema !== 'portfolio-signed-release-manifest/v1') throw new Error('bad envelope schema');
|
signedRelease,
|
||||||
if (signedRelease.signature?.keyId !== trustPolicy.keyId || signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch ||
|
trustPolicy,
|
||||||
signedRelease.signature?.scope !== trustPolicy.scope) throw new Error('untrusted signer');
|
now,
|
||||||
const signature = Buffer.from(signedRelease.signature.value, 'base64url');
|
protectedState: state,
|
||||||
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);
|
signedRelease: any;
|
||||||
if (manifestDigest !== signedRelease.payloadDigest) throw new Error('candidate digest mismatch');
|
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;
|
const candidate = signedRelease.payload;
|
||||||
if (candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash || candidate.latestLedgerRevision !== state.latestLedgerRevision) throw new Error('stale ledger');
|
if (
|
||||||
if (candidate.releaseManifest.currentManifestDigest !== state.currentManifestDigest) throw new Error('stale predecessor');
|
candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
|
||||||
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) throw new Error('expired release');
|
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 {
|
return {
|
||||||
schema: 'portfolio-verified-release-admission/v1', manifestDigest, manifestPayload: candidate.releaseManifest,
|
schema: "portfolio-verified-release-admission/v1",
|
||||||
expiresAt: candidate.releaseManifest.expiresAt, predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
|
manifestDigest,
|
||||||
latestLedgerHeadHash: candidate.latestLedgerHeadHash, latestLedgerRevision: candidate.latestLedgerRevision,
|
manifestPayload: candidate.releaseManifest,
|
||||||
expectedFence: state.publisherFence + 1, signatureVerified: true,
|
expiresAt: candidate.releaseManifest.expiresAt,
|
||||||
|
predecessorManifestDigest:
|
||||||
|
candidate.releaseManifest.currentManifestDigest,
|
||||||
|
latestLedgerHeadHash: candidate.latestLedgerHeadHash,
|
||||||
|
latestLedgerRevision: candidate.latestLedgerRevision,
|
||||||
|
expectedFence: state.publisherFence + 1,
|
||||||
|
signatureVerified: true,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,19 +156,29 @@ export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, p
|
|||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
}) => {
|
}) => {
|
||||||
const artifactPaths = ['assets/app.js', 'index.html'];
|
const artifactPaths = ["assets/app.js", "index.html"];
|
||||||
const artifacts = await Promise.all(artifactPaths.map(async (path) => {
|
const artifacts = await Promise.all(
|
||||||
const bytes = await readFile(join(input.distDir, path));
|
artifactPaths.map(async (path) => {
|
||||||
return { path, sha256: byteDigest(bytes), bytes: bytes.length, encoding: 'identity' as const };
|
const bytes = await readFile(join(input.distDir, path));
|
||||||
}));
|
return {
|
||||||
|
path,
|
||||||
|
sha256: byteDigest(bytes),
|
||||||
|
bytes: bytes.length,
|
||||||
|
encoding: "identity" as const,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
const inputDigests = {
|
const inputDigests = {
|
||||||
PortfolioContent: digest('a'), PublicAssetManifest: digest('b'), PreparedAssetSet: digest('c'), SiteConfig: digest('d'),
|
PortfolioContent: digest("a"),
|
||||||
|
PublicAssetManifest: digest("b"),
|
||||||
|
PreparedAssetSet: digest("c"),
|
||||||
|
SiteConfig: digest("d"),
|
||||||
};
|
};
|
||||||
const issuedAt = input.issuedAt ?? '2026-09-02T00:00:00Z';
|
const issuedAt = input.issuedAt ?? "2026-09-02T00:00:00Z";
|
||||||
const expiresAt = input.expiresAt ?? '2026-09-03T00:00:00Z';
|
const expiresAt = input.expiresAt ?? "2026-09-03T00:00:00Z";
|
||||||
const releaseManifest = {
|
const releaseManifest = {
|
||||||
schema: 'portfolio-release-manifest/v1' as const,
|
schema: "portfolio-release-manifest/v1" as const,
|
||||||
launchMode: 'private-source-initial-launch' as const,
|
launchMode: "private-source-initial-launch" as const,
|
||||||
buildId: input.buildId,
|
buildId: input.buildId,
|
||||||
inputDigests,
|
inputDigests,
|
||||||
productionInputRoot: computeProductionInputRoot({
|
productionInputRoot: computeProductionInputRoot({
|
||||||
@@ -146,52 +189,115 @@ export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, p
|
|||||||
}),
|
}),
|
||||||
artifacts,
|
artifacts,
|
||||||
deployFiles: artifactPaths,
|
deployFiles: artifactPaths,
|
||||||
sourceCommit: digest('e'), sourceTree: digest('f'), approvedRecordRoot: digest('1'), approvedAssetRoot: digest('2'),
|
sourceCommit: digest("e"),
|
||||||
approvalProofSetDigest: digest('3'), approvalProjectionAttestationDigest: digest('4'), scanEvidenceDigest: digest('5'),
|
sourceTree: digest("f"),
|
||||||
policyVersions: { approval: '1', privacy: '1', release: '1' }, toolVersions: { node: '22.22.1' },
|
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,
|
predecessorManifestDigest: input.predecessorManifestDigest,
|
||||||
currentManifestDigest: input.predecessorManifestDigest,
|
currentManifestDigest: input.predecessorManifestDigest,
|
||||||
issuedAt, expiresAt, minimumBoundExpiry: expiresAt,
|
issuedAt,
|
||||||
|
expiresAt,
|
||||||
|
minimumBoundExpiry: expiresAt,
|
||||||
};
|
};
|
||||||
const candidate = {
|
const candidate = {
|
||||||
schema: 'portfolio-candidate-release-manifest/v1',
|
schema: "portfolio-candidate-release-manifest/v1",
|
||||||
releaseManifest,
|
releaseManifest,
|
||||||
latestLedgerHeadHash: protectedState.latestLedgerHeadHash,
|
latestLedgerHeadHash: protectedState.latestLedgerHeadHash,
|
||||||
latestLedgerRevision: protectedState.latestLedgerRevision,
|
latestLedgerRevision: protectedState.latestLedgerRevision,
|
||||||
sourceClosureDigest: digest('6'), publicRefMap: [], lfsOids: [], releaseArchives: [],
|
sourceClosureDigest: digest("6"),
|
||||||
buildProvenanceDigest: digest('7'), privacyPolicyDigest: digest('8'), toolImageDigests: [digest('0')],
|
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 payloadDigest = canonicalJsonDigest(
|
||||||
const signature = sign(null, canonicalValueBytes(candidate), keys.privateKey).toString('base64url');
|
"portfolio-candidate-release-manifest/v1",
|
||||||
|
candidate,
|
||||||
|
);
|
||||||
|
const signature = sign(
|
||||||
|
null,
|
||||||
|
canonicalValueBytes(candidate),
|
||||||
|
keys.privateKey,
|
||||||
|
).toString("base64url");
|
||||||
const signedRelease = {
|
const signedRelease = {
|
||||||
schema: 'portfolio-signed-release-manifest/v1', payload: candidate, payloadDigest,
|
schema: "portfolio-signed-release-manifest/v1",
|
||||||
|
payload: candidate,
|
||||||
|
payloadDigest,
|
||||||
signature: {
|
signature: {
|
||||||
algorithm: 'Ed25519', encoding: 'base64url-nopad', keyId: 'release-test-key', issuerEpoch: 7,
|
algorithm: "Ed25519",
|
||||||
scope: 'portfolio-release-signing/v1', value: signature,
|
encoding: "base64url-nopad",
|
||||||
|
keyId: "release-test-key",
|
||||||
|
issuerEpoch: 7,
|
||||||
|
scope: "portfolio-release-signing/v1",
|
||||||
|
value: signature,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await writeFile(input.outputPath, canonicalValueBytes(signedRelease));
|
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 signedManifestPath = join(root, "signed-release.json");
|
||||||
const initial = await signFor({ buildId: 'release-a', distDir, predecessorManifestDigest: null, publisherFence: 0, outputPath: signedManifestPath });
|
const initial = await signFor({
|
||||||
|
buildId: "release-a",
|
||||||
|
distDir,
|
||||||
|
predecessorManifestDigest: null,
|
||||||
|
publisherFence: 0,
|
||||||
|
outputPath: signedManifestPath,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
distDir, signedManifestPath, sbomPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState,
|
distDir,
|
||||||
manifestDigest: initial.manifestDigest, buildId: 'release-a', expiresAt: '2026-09-03T00:00:00Z', signFor,
|
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> {
|
export async function initializeReleaseRoot(
|
||||||
await mkdir(join(root, 'control'), { recursive: true });
|
root: string,
|
||||||
await writeFile(join(root, '.portfolio-release-root'), 'portfolio-release-root/v1\n');
|
state?: Partial<ReleaseFixture["protectedState"]>,
|
||||||
await writeFile(join(root, 'control/state.json'), canonicalValueBytes({
|
trustPolicy?: Record<string, unknown>,
|
||||||
schema: 'portfolio-release-control-state/v1',
|
): Promise<void> {
|
||||||
currentManifestDigest: state?.currentManifestDigest ?? null,
|
await mkdir(join(root, "control"), { recursive: true });
|
||||||
currentReleaseId: null,
|
await mkdir(join(root, ".control"), { recursive: true });
|
||||||
publisherFence: state?.publisherFence ?? 0,
|
await mkdir(join(root, "inbox"), { recursive: true });
|
||||||
latestLedgerHeadHash: state?.latestLedgerHeadHash ?? digest('9'),
|
if (trustPolicy !== undefined)
|
||||||
latestLedgerRevision: state?.latestLedgerRevision ?? 11,
|
await writeFile(
|
||||||
activeTransaction: null,
|
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"),
|
||||||
|
latestLedgerRevision: state?.latestLedgerRevision ?? 11,
|
||||||
|
releaseTrustPolicyDigest: state?.releaseTrustPolicyDigest ?? digest("8"),
|
||||||
|
activeTransaction: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user