fix: recover and fence atomic activation
This commit is contained in:
@@ -1,141 +1,394 @@
|
||||
import { lstat, mkdtemp, mkdir, readFile, readlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
lstat,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
readlink,
|
||||
rename,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildReleasePackage } from '../../scripts/build-release.mjs';
|
||||
import { activateRelease, cleanupOrphanStages, prepareRelease, readControlState, rollbackRelease } from '../../scripts/deploy/lib/release-transaction.mjs';
|
||||
import { createReleaseFixture, initializeReleaseRoot } from './release-test-fixture.ts';
|
||||
import { canonicalValueBytes } from "../../src/security/canonical-digest.ts";
|
||||
import {
|
||||
buildReleasePackage,
|
||||
buildReleasePackageForTest,
|
||||
} from "../../scripts/build-release.mjs";
|
||||
import {
|
||||
activateRelease,
|
||||
activateReleaseForTest,
|
||||
cleanupOrphanStages,
|
||||
prepareRelease,
|
||||
prepareReleaseForTest,
|
||||
readControlState,
|
||||
rollbackReleaseForTest,
|
||||
} from "../../scripts/deploy/lib/release-transaction.mjs";
|
||||
import {
|
||||
createReleaseFixture,
|
||||
initializeReleaseRoot,
|
||||
} from "./release-test-fixture.ts";
|
||||
|
||||
type Fixture = Awaited<ReturnType<typeof createReleaseFixture>>;
|
||||
|
||||
function buildInput(fixture: Fixture, outputPath: string) {
|
||||
return {
|
||||
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
|
||||
verifierModulePath: fixture.verifierModulePath, verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
|
||||
trustPolicyPath: fixture.trustPolicyPath, protectedState: fixture.protectedState,
|
||||
now: '2026-09-02T01:00:00Z', outputPath,
|
||||
distDir: fixture.distDir,
|
||||
signedManifestPath: fixture.signedManifestPath,
|
||||
sbomPath: fixture.sbomPath,
|
||||
trustPolicyPath: fixture.trustPolicyPath,
|
||||
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
|
||||
protectedState: fixture.protectedState,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
outputPath,
|
||||
};
|
||||
}
|
||||
|
||||
function transactionInput(root: string, archivePath: string, transactionId: string, fixture: Fixture) {
|
||||
function transactionInput(
|
||||
root: string,
|
||||
archivePath: string,
|
||||
transactionId: string,
|
||||
fixture: Fixture,
|
||||
) {
|
||||
return {
|
||||
root, archivePath, transactionId, verifierModulePath: fixture.verifierModulePath,
|
||||
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope, trustPolicyPath: fixture.trustPolicyPath,
|
||||
uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0, now: '2026-09-02T01:00:00Z',
|
||||
root,
|
||||
archivePath,
|
||||
transactionId,
|
||||
testOnlyVerification: {
|
||||
trustPolicyPath: fixture.trustPolicyPath,
|
||||
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
|
||||
},
|
||||
testClock: () => "2026-09-02T01:00:00Z",
|
||||
smokeCheck: async ({ releaseDir }: { releaseDir: string }) => {
|
||||
await readFile(join(releaseDir, 'index.html'));
|
||||
return Buffer.from('internal-nginx-smoke-ok\n');
|
||||
await readFile(join(releaseDir, "index.html"));
|
||||
return Buffer.from("internal-nginx-smoke-ok\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('fenced atomic Nginx release transaction', () => {
|
||||
test('prepares immutably without changing current, then activates with one atomic pointer swap', async () => {
|
||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-atomic-'));
|
||||
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
||||
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
||||
await prepareRelease(transactionInput(root, archive, 'tx-a', fixture));
|
||||
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
||||
await activateRelease({ ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
|
||||
const target = await readlink(join(root, 'current'));
|
||||
async function setup(prefix: string) {
|
||||
const temp = await mkdtemp(join(tmpdir(), prefix));
|
||||
const fixture = await createReleaseFixture(join(temp, "fixture"));
|
||||
const root = join(temp, "release-root");
|
||||
await initializeReleaseRoot(
|
||||
root,
|
||||
fixture.protectedState,
|
||||
fixture.trustPolicy,
|
||||
);
|
||||
const archive = join(temp, "release.tar");
|
||||
await buildReleasePackageForTest(buildInput(fixture, archive));
|
||||
return { temp, fixture, root, archive };
|
||||
}
|
||||
|
||||
describe("fenced atomic Nginx release transaction", () => {
|
||||
test("prepares immutably without changing current, then activates with one atomic pointer swap", async () => {
|
||||
const { fixture, root, archive } = await setup("portfolio-atomic-");
|
||||
const input = transactionInput(root, archive, "tx-a", fixture);
|
||||
await prepareReleaseForTest(input);
|
||||
await expect(lstat(join(root, "current"))).rejects.toThrow();
|
||||
await activateReleaseForTest({
|
||||
...input,
|
||||
expectedCurrentManifestDigest: null,
|
||||
expectedFence: 1,
|
||||
});
|
||||
const target = await readlink(join(root, "current"));
|
||||
expect(target).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
|
||||
expect((await lstat(join(root, target, 'index.html'))).mode & 0o777).toBe(0o444);
|
||||
expect((await lstat(join(root, target, "index.html"))).mode & 0o777).toBe(
|
||||
0o444,
|
||||
);
|
||||
expect((await lstat(join(root, target))).mode & 0o777).toBe(0o555);
|
||||
expect((await lstat(join(root, target, "index.html"))).uid).toBe(
|
||||
(await lstat(root)).uid,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects stale predecessor/fence, expiry crossing, and concurrent activation without moving the old pointer', async () => {
|
||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-fence-'));
|
||||
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
||||
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
||||
const input = transactionInput(root, archive, 'tx-a', fixture);
|
||||
await prepareRelease(input);
|
||||
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 2, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('fence');
|
||||
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
||||
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: fixture.manifestDigest, expectedFence: 1, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('predecessor');
|
||||
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: fixture.expiresAt })).rejects.toThrow('expired');
|
||||
test("rejects stale predecessor/fence, expiry crossing, and concurrent activation without moving the old pointer", async () => {
|
||||
const { fixture, root, archive } = await setup("portfolio-fence-");
|
||||
const input = transactionInput(root, archive, "tx-a", fixture);
|
||||
await prepareReleaseForTest(input);
|
||||
await expect(
|
||||
activateReleaseForTest({
|
||||
...input,
|
||||
expectedCurrentManifestDigest: null,
|
||||
expectedFence: 2,
|
||||
}),
|
||||
).rejects.toThrow("fence");
|
||||
await expect(
|
||||
activateReleaseForTest({
|
||||
...input,
|
||||
expectedCurrentManifestDigest: fixture.manifestDigest,
|
||||
expectedFence: 1,
|
||||
}),
|
||||
).rejects.toThrow("predecessor");
|
||||
await expect(
|
||||
activateReleaseForTest({
|
||||
...input,
|
||||
expectedCurrentManifestDigest: null,
|
||||
expectedFence: 1,
|
||||
testClock: () => fixture.expiresAt,
|
||||
}),
|
||||
).rejects.toThrow("expired");
|
||||
const attempts = await Promise.allSettled([
|
||||
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
|
||||
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
|
||||
activateReleaseForTest({
|
||||
...input,
|
||||
expectedCurrentManifestDigest: null,
|
||||
expectedFence: 1,
|
||||
}),
|
||||
activateReleaseForTest({
|
||||
...input,
|
||||
expectedCurrentManifestDigest: null,
|
||||
expectedFence: 1,
|
||||
}),
|
||||
]);
|
||||
expect(attempts.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
expect(attempts.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
||||
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
|
||||
expect(
|
||||
attempts.filter((result) => result.status === "fulfilled"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
attempts.filter((result) => result.status === "rejected"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rechecks trusted time after smoke so an expiry crossing never becomes ready', async () => {
|
||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-expiry-crossing-'));
|
||||
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
||||
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
||||
const times = ['2026-09-02T01:00:00Z', fixture.expiresAt];
|
||||
await expect(prepareRelease({ ...transactionInput(root, archive, 'tx-a', fixture), clock: () => times.shift() ?? fixture.expiresAt })).rejects.toThrow('expired while staging');
|
||||
test("rechecks trusted time after smoke so an expiry crossing never becomes ready", async () => {
|
||||
const { fixture, root, archive } = await setup(
|
||||
"portfolio-expiry-crossing-",
|
||||
);
|
||||
const times = ["2026-09-02T01:00:00Z", fixture.expiresAt];
|
||||
await expect(
|
||||
prepareReleaseForTest({
|
||||
...transactionInput(root, archive, "tx-a", fixture),
|
||||
testClock: () => times.shift() ?? fixture.expiresAt,
|
||||
}),
|
||||
).rejects.toThrow("expired while staging");
|
||||
expect((await readControlState(root)).activeTransaction).toBeNull();
|
||||
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
||||
await expect(lstat(join(root, "current"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('a crash before swap leaves the predecessor visible and retry after a post-swap crash converges on the committed release', async () => {
|
||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-crash-'));
|
||||
const fixture = await createReleaseFixture(join(temp, 'fixture'));
|
||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
||||
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
|
||||
const input = { ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' };
|
||||
await prepareRelease(input);
|
||||
await expect(activateRelease({ ...input, crashPoint: 'before-swap' })).rejects.toThrow('before pointer');
|
||||
await expect(lstat(join(root, 'current'))).rejects.toThrow();
|
||||
await expect(activateRelease({ ...input, crashPoint: 'after-swap' })).rejects.toThrow('after pointer');
|
||||
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
|
||||
const recovered = await activateRelease(input);
|
||||
test.each([
|
||||
"after-intent",
|
||||
"after-extract",
|
||||
"after-immutable",
|
||||
"after-release-rename",
|
||||
"after-receipt",
|
||||
] as const)(
|
||||
"recovers an interrupted preparation at %s and safely retries",
|
||||
async (testPreparationCrashPoint) => {
|
||||
const { fixture, root, archive } = await setup(
|
||||
`portfolio-prepare-crash-${testPreparationCrashPoint}-`,
|
||||
);
|
||||
const input = transactionInput(root, archive, "tx-a", fixture);
|
||||
await expect(
|
||||
prepareReleaseForTest({ ...input, testPreparationCrashPoint }),
|
||||
).rejects.toThrow("process death");
|
||||
expect((await readControlState(root)).activeTransaction?.phase).toBe(
|
||||
"preparing",
|
||||
);
|
||||
await prepareReleaseForTest(input);
|
||||
expect((await readControlState(root)).activeTransaction?.phase).toBe(
|
||||
"ready",
|
||||
);
|
||||
await expect(lstat(join(root, "current"))).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
test("a crash before swap leaves the predecessor visible and retry after a post-swap crash converges", async () => {
|
||||
const { fixture, root, archive } = await setup(
|
||||
"portfolio-activation-crash-",
|
||||
);
|
||||
const input = {
|
||||
...transactionInput(root, archive, "tx-a", fixture),
|
||||
expectedCurrentManifestDigest: null,
|
||||
expectedFence: 1,
|
||||
};
|
||||
await prepareReleaseForTest(input);
|
||||
await expect(
|
||||
activateReleaseForTest({ ...input, crashPoint: "before-swap" }),
|
||||
).rejects.toThrow("before pointer");
|
||||
await expect(lstat(join(root, "current"))).rejects.toThrow();
|
||||
await expect(
|
||||
activateReleaseForTest({ ...input, crashPoint: "after-swap" }),
|
||||
).rejects.toThrow("after pointer");
|
||||
expect(await readlink(join(root, "current"))).toBe(
|
||||
`releases/release-a-${fixture.manifestDigest}/public`,
|
||||
);
|
||||
const recovered = await activateReleaseForTest(input);
|
||||
expect(recovered.recoveredAfterPointerSwap).toBe(true);
|
||||
expect((await readControlState(root)).currentManifestDigest).toBe(fixture.manifestDigest);
|
||||
expect((await readControlState(root)).currentManifestDigest).toBe(
|
||||
fixture.manifestDigest,
|
||||
);
|
||||
});
|
||||
|
||||
test('rollback requires a fresh signed manifest and never replays the selected old manifest', async () => {
|
||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-rollback-'));
|
||||
const fixtureA = await createReleaseFixture(join(temp, 'fixture-a'));
|
||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
||||
const archiveA = join(temp, 'a.tar'); await buildReleasePackage(buildInput(fixtureA, archiveA));
|
||||
const inputA = transactionInput(root, archiveA, 'tx-a', fixtureA);
|
||||
await prepareRelease(inputA);
|
||||
await activateRelease({ ...inputA, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
|
||||
const releaseA = (await readlink(join(root, 'current'))).split('/')[1] as string;
|
||||
test("a protected ledger revocation racing final publication is preserved and blocks activation", async () => {
|
||||
const { fixture, root, archive } = await setup(
|
||||
"portfolio-revocation-race-",
|
||||
);
|
||||
const input = transactionInput(root, archive, "tx-a", fixture);
|
||||
await prepareReleaseForTest(input);
|
||||
await expect(
|
||||
activateReleaseForTest({
|
||||
...input,
|
||||
expectedCurrentManifestDigest: null,
|
||||
expectedFence: 1,
|
||||
testBeforePointerSwap: async () => {
|
||||
const state = await readControlState(root);
|
||||
await writeFile(
|
||||
join(root, "control/state.next"),
|
||||
canonicalValueBytes({
|
||||
...state,
|
||||
latestLedgerHeadHash: "a".repeat(64),
|
||||
latestLedgerRevision: state.latestLedgerRevision + 1,
|
||||
}),
|
||||
);
|
||||
await rename(
|
||||
join(root, "control/state.next"),
|
||||
join(root, "control/state.json"),
|
||||
);
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("authority changed");
|
||||
expect((await readControlState(root)).latestLedgerRevision).toBe(
|
||||
fixture.protectedState.latestLedgerRevision + 1,
|
||||
);
|
||||
await expect(lstat(join(root, "current"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
const fixtureB = await createReleaseFixture(join(temp, 'fixture-b'));
|
||||
fixtureB.protectedState.currentManifestDigest = fixtureA.manifestDigest; fixtureB.protectedState.publisherFence = 1;
|
||||
await writeFile(join(fixtureB.distDir, 'index.html'), '<!doctype html><title>Portfolio B</title>\n');
|
||||
const signedB = await fixtureB.signFor({ buildId: 'release-b', distDir: fixtureB.distDir, predecessorManifestDigest: fixtureA.manifestDigest, publisherFence: 1, outputPath: fixtureB.signedManifestPath });
|
||||
const archiveB = join(temp, 'b.tar'); await buildReleasePackage({ ...buildInput(fixtureB, archiveB), now: '2026-09-02T02:00:00Z' });
|
||||
const inputB = { ...transactionInput(root, archiveB, 'tx-b', fixtureB), now: '2026-09-02T02:00:00Z' };
|
||||
await prepareRelease(inputB);
|
||||
await activateRelease({ ...inputB, expectedCurrentManifestDigest: fixtureA.manifestDigest, expectedFence: 2, now: '2026-09-02T02:01:00Z' });
|
||||
test("rollback requires a fresh signed manifest that binds the selected old artifact closure", async () => {
|
||||
const { temp, fixture, root, archive } = await setup("portfolio-rollback-");
|
||||
const inputA = transactionInput(root, archive, "tx-a", fixture);
|
||||
await prepareReleaseForTest(inputA);
|
||||
await activateReleaseForTest({
|
||||
...inputA,
|
||||
expectedCurrentManifestDigest: null,
|
||||
expectedFence: 1,
|
||||
});
|
||||
const releaseA = (await readlink(join(root, "current"))).split(
|
||||
"/",
|
||||
)[1] as string;
|
||||
|
||||
await expect(rollbackRelease({ ...inputA, targetReleaseId: releaseA, transactionId: 'tx-replay', expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' })).rejects.toThrow();
|
||||
fixture.protectedState.currentManifestDigest = fixture.manifestDigest;
|
||||
fixture.protectedState.publisherFence = 1;
|
||||
await writeFile(
|
||||
join(fixture.distDir, "index.html"),
|
||||
"<!doctype html><title>Portfolio B</title>\n",
|
||||
);
|
||||
const signedB = await fixture.signFor({
|
||||
buildId: "release-b",
|
||||
distDir: fixture.distDir,
|
||||
predecessorManifestDigest: fixture.manifestDigest,
|
||||
publisherFence: 1,
|
||||
outputPath: fixture.signedManifestPath,
|
||||
});
|
||||
const archiveB = join(temp, "b.tar");
|
||||
await buildReleasePackageForTest({
|
||||
...buildInput(fixture, archiveB),
|
||||
now: "2026-09-02T02:00:00Z",
|
||||
});
|
||||
const inputB = {
|
||||
...transactionInput(root, archiveB, "tx-b", fixture),
|
||||
testClock: () => "2026-09-02T02:00:00Z",
|
||||
};
|
||||
await prepareReleaseForTest(inputB);
|
||||
await activateReleaseForTest({
|
||||
...inputB,
|
||||
expectedCurrentManifestDigest: fixture.manifestDigest,
|
||||
expectedFence: 2,
|
||||
});
|
||||
|
||||
fixtureA.protectedState.currentManifestDigest = signedB.manifestDigest; fixtureA.protectedState.publisherFence = 2;
|
||||
const freshSigned = join(temp, 'rollback-signed.json');
|
||||
const fresh = await fixtureA.signFor({ buildId: 'rollback-a', distDir: fixtureA.distDir, predecessorManifestDigest: signedB.manifestDigest, publisherFence: 2, outputPath: freshSigned });
|
||||
fixtureA.signedManifestPath = freshSigned;
|
||||
const rollbackArchive = join(temp, 'rollback.tar'); await buildReleasePackage({ ...buildInput(fixtureA, rollbackArchive), now: '2026-09-02T03:00:00Z' });
|
||||
const rollback = await rollbackRelease({ ...transactionInput(root, rollbackArchive, 'tx-rollback', fixtureA), targetReleaseId: releaseA,
|
||||
expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' });
|
||||
await expect(
|
||||
rollbackReleaseForTest({
|
||||
...inputA,
|
||||
targetReleaseId: releaseA,
|
||||
transactionId: "tx-replay",
|
||||
expectedCurrentManifestDigest: signedB.manifestDigest,
|
||||
expectedFence: 3,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
fixture.protectedState.currentManifestDigest = signedB.manifestDigest;
|
||||
fixture.protectedState.publisherFence = 2;
|
||||
await writeFile(
|
||||
join(fixture.distDir, "index.html"),
|
||||
"<!doctype html><title>Portfolio</title>\n",
|
||||
);
|
||||
const freshSigned = join(temp, "rollback-signed.json");
|
||||
const fresh = await fixture.signFor({
|
||||
buildId: "rollback-a",
|
||||
distDir: fixture.distDir,
|
||||
predecessorManifestDigest: signedB.manifestDigest,
|
||||
publisherFence: 2,
|
||||
outputPath: freshSigned,
|
||||
});
|
||||
fixture.signedManifestPath = freshSigned;
|
||||
const rollbackArchive = join(temp, "rollback.tar");
|
||||
await buildReleasePackageForTest({
|
||||
...buildInput(fixture, rollbackArchive),
|
||||
now: "2026-09-02T03:00:00Z",
|
||||
});
|
||||
const rollback = await rollbackReleaseForTest({
|
||||
...transactionInput(root, rollbackArchive, "tx-rollback", fixture),
|
||||
targetReleaseId: releaseA,
|
||||
expectedCurrentManifestDigest: signedB.manifestDigest,
|
||||
expectedFence: 3,
|
||||
testClock: () => "2026-09-02T03:00:00Z",
|
||||
});
|
||||
expect(rollback.manifestDigest).toBe(fresh.manifestDigest);
|
||||
expect(await readlink(join(root, 'current'))).toContain('rollback-a-');
|
||||
expect(await readlink(join(root, "current"))).toContain("rollback-a-");
|
||||
});
|
||||
|
||||
test('orphan cleanup and all mutations remain confined to a validated explicit release root', async () => {
|
||||
const temp = await mkdtemp(join(tmpdir(), 'portfolio-cleanup-'));
|
||||
const outside = join(temp, 'outside.txt'); await writeFile(outside, 'keep');
|
||||
const invalidRoot = join(temp, 'not-a-release-root'); await mkdir(join(invalidRoot, 'staging', 'orphan.partial'), { recursive: true });
|
||||
await expect(cleanupOrphanStages(invalidRoot)).rejects.toThrow('sentinel');
|
||||
expect(await readFile(outside, 'utf8')).toBe('keep');
|
||||
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
|
||||
await mkdir(join(root, 'staging', 'orphan.partial'), { recursive: true });
|
||||
await writeFile(join(root, 'staging', 'orphan.partial', 'marker'), 'orphan');
|
||||
expect(await cleanupOrphanStages(root)).toEqual(['orphan.partial']);
|
||||
await expect(lstat(join(root, 'staging', 'orphan.partial'))).rejects.toThrow();
|
||||
expect(await readFile(outside, 'utf8')).toBe('keep');
|
||||
await expect(cleanupOrphanStages('/')).rejects.toThrow('Broad');
|
||||
test("production APIs reject caller authority fields and archives outside the upload-only inbox", async () => {
|
||||
const { fixture, root, archive } = await setup(
|
||||
"portfolio-production-authority-",
|
||||
);
|
||||
await expect(
|
||||
prepareRelease({
|
||||
...transactionInput(root, archive, "tx-a", fixture),
|
||||
smokeCommand: "/bin/true",
|
||||
} as any),
|
||||
).rejects.toThrow("unknown");
|
||||
await expect(
|
||||
prepareRelease({
|
||||
root,
|
||||
archivePath: archive,
|
||||
transactionId: "tx-a",
|
||||
smokeCommand: "/bin/true",
|
||||
}),
|
||||
).rejects.toThrow("upload-only inbox");
|
||||
await expect(
|
||||
activateRelease({
|
||||
root,
|
||||
transactionId: "tx-a",
|
||||
expectedCurrentManifestDigest: null,
|
||||
expectedFence: 1,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
} as any),
|
||||
).rejects.toThrow("unknown");
|
||||
await expect(
|
||||
buildReleasePackage(buildInput(fixture, join(root, "bad.tar")) as any),
|
||||
).rejects.toThrow("rejects caller");
|
||||
});
|
||||
|
||||
test("orphan cleanup and all mutations remain confined to a validated explicit release root", async () => {
|
||||
const temp = await mkdtemp(join(tmpdir(), "portfolio-cleanup-"));
|
||||
const outside = join(temp, "outside.txt");
|
||||
await writeFile(outside, "keep");
|
||||
const invalidRoot = join(temp, "not-a-release-root");
|
||||
await mkdir(join(invalidRoot, "staging", "orphan.partial"), {
|
||||
recursive: true,
|
||||
});
|
||||
await expect(cleanupOrphanStages(invalidRoot)).rejects.toThrow("sentinel");
|
||||
expect(await readFile(outside, "utf8")).toBe("keep");
|
||||
const root = join(temp, "release-root");
|
||||
await initializeReleaseRoot(root);
|
||||
await mkdir(join(root, "staging", "orphan.partial"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "staging", "orphan.partial", "marker"),
|
||||
"orphan",
|
||||
);
|
||||
expect(await cleanupOrphanStages(root)).toEqual(["orphan.partial"]);
|
||||
await expect(
|
||||
lstat(join(root, "staging", "orphan.partial")),
|
||||
).rejects.toThrow();
|
||||
expect(await readFile(outside, "utf8")).toBe("keep");
|
||||
await expect(cleanupOrphanStages("/")).rejects.toThrow("Broad");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,96 @@
|
||||
import { mkdtemp, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { mkdtemp, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { byteDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
||||
import { buildReleasePackage } from '../../scripts/build-release.mjs';
|
||||
import { parseDeterministicTar } from '../../scripts/deploy/lib/release-common.mjs';
|
||||
import { createReleaseFixture } from './release-test-fixture.ts';
|
||||
import {
|
||||
byteDigest,
|
||||
canonicalValueBytes,
|
||||
} from "../../src/security/canonical-digest.ts";
|
||||
import {
|
||||
buildReleasePackage,
|
||||
buildReleasePackageForTest,
|
||||
} from "../../scripts/build-release.mjs";
|
||||
import { parseDeterministicTar } from "../../scripts/deploy/lib/release-common.mjs";
|
||||
import { createReleaseFixture } from "./release-test-fixture.ts";
|
||||
|
||||
function buildInput(fixture: Awaited<ReturnType<typeof createReleaseFixture>>, outputPath: string) {
|
||||
function buildInput(
|
||||
fixture: Awaited<ReturnType<typeof createReleaseFixture>>,
|
||||
outputPath: string,
|
||||
) {
|
||||
return {
|
||||
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
|
||||
verifierModulePath: fixture.verifierModulePath, trustPolicyPath: fixture.trustPolicyPath,
|
||||
distDir: fixture.distDir,
|
||||
signedManifestPath: fixture.signedManifestPath,
|
||||
sbomPath: fixture.sbomPath,
|
||||
trustPolicyPath: fixture.trustPolicyPath,
|
||||
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
|
||||
protectedState: fixture.protectedState, now: '2026-09-02T01:00:00Z', outputPath,
|
||||
protectedState: fixture.protectedState,
|
||||
now: "2026-09-02T01:00:00Z",
|
||||
outputPath,
|
||||
};
|
||||
}
|
||||
|
||||
describe('deterministic exact-manifest release package', () => {
|
||||
test('builds byte-identical archives containing only the signed closure and release metadata', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-'));
|
||||
describe("deterministic exact-manifest release package", () => {
|
||||
test("builds byte-identical archives containing only the signed closure and release metadata", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "portfolio-package-"));
|
||||
const fixture = await createReleaseFixture(root);
|
||||
const first = join(root, 'release-one.tar');
|
||||
const second = join(root, 'release-two.tar');
|
||||
await buildReleasePackage(buildInput(fixture, first));
|
||||
await buildReleasePackage(buildInput(fixture, second));
|
||||
expect(byteDigest(await readFile(first))).toBe(byteDigest(await readFile(second)));
|
||||
expect(parseDeterministicTar(await readFile(first)).map((file) => file.path)).toEqual([
|
||||
'.release/package-index.json', '.release/sbom.cdx.json', '.release/signed-release-manifest.json',
|
||||
'assets/app.js', 'index.html',
|
||||
const first = join(root, "release-one.tar");
|
||||
const second = join(root, "release-two.tar");
|
||||
await buildReleasePackageForTest(buildInput(fixture, first));
|
||||
await buildReleasePackageForTest(buildInput(fixture, second));
|
||||
expect(byteDigest(await readFile(first))).toBe(
|
||||
byteDigest(await readFile(second)),
|
||||
);
|
||||
expect(
|
||||
parseDeterministicTar(await readFile(first)).map((file) => file.path),
|
||||
).toEqual([
|
||||
".release/package-index.json",
|
||||
".release/sbom.cdx.json",
|
||||
".release/signed-release-manifest.json",
|
||||
"assets/app.js",
|
||||
"index.html",
|
||||
]);
|
||||
});
|
||||
|
||||
test('fails closed on extra, substituted, or invalidly signed inputs', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-invalid-'));
|
||||
test("fails closed on extra, substituted, or invalidly signed inputs", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "portfolio-package-invalid-"));
|
||||
const fixture = await createReleaseFixture(root);
|
||||
await writeFile(join(fixture.distDir, 'extra.txt'), 'not in manifest');
|
||||
await expect(buildReleasePackage(buildInput(fixture, join(root, 'extra.tar')))).rejects.toThrow('exactly match');
|
||||
await writeFile(join(fixture.distDir, "extra.txt"), "not in manifest");
|
||||
await expect(
|
||||
buildReleasePackageForTest(buildInput(fixture, join(root, "extra.tar"))),
|
||||
).rejects.toThrow("exactly match");
|
||||
|
||||
await unlink(join(fixture.distDir, 'extra.txt'));
|
||||
await writeFile(join(fixture.distDir, 'index.html'), 'substituted');
|
||||
await expect(buildReleasePackage(buildInput(fixture, join(root, 'substituted.tar')))).rejects.toThrow('hash');
|
||||
await unlink(join(fixture.distDir, "extra.txt"));
|
||||
await writeFile(join(fixture.distDir, "index.html"), "substituted");
|
||||
await expect(
|
||||
buildReleasePackageForTest(
|
||||
buildInput(fixture, join(root, "substituted.tar")),
|
||||
),
|
||||
).rejects.toThrow("hash");
|
||||
|
||||
await writeFile(join(fixture.distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
|
||||
const envelope = JSON.parse(await readFile(fixture.signedManifestPath, 'utf8'));
|
||||
envelope.signature.value = `${envelope.signature.value[0] === 'A' ? 'B' : 'A'}${envelope.signature.value.slice(1)}`;
|
||||
await writeFile(
|
||||
join(fixture.distDir, "index.html"),
|
||||
"<!doctype html><title>Portfolio</title>\n",
|
||||
);
|
||||
const envelope = JSON.parse(
|
||||
await readFile(fixture.signedManifestPath, "utf8"),
|
||||
);
|
||||
envelope.signature.value = `${envelope.signature.value[0] === "A" ? "B" : "A"}${envelope.signature.value.slice(1)}`;
|
||||
await writeFile(fixture.signedManifestPath, canonicalValueBytes(envelope));
|
||||
await expect(buildReleasePackage(buildInput(fixture, join(root, 'invalid.tar')))).rejects.toThrow('signature');
|
||||
await expect(
|
||||
buildReleasePackageForTest(
|
||||
buildInput(fixture, join(root, "invalid.tar")),
|
||||
),
|
||||
).rejects.toThrow("signature");
|
||||
});
|
||||
|
||||
test("production package requests reject caller-selected verifier, policy, and clock fields", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "portfolio-package-authority-"));
|
||||
const fixture = await createReleaseFixture(root);
|
||||
await expect(
|
||||
buildReleasePackage(
|
||||
buildInput(fixture, join(root, "release.tar")) as any,
|
||||
),
|
||||
).rejects.toThrow("rejects caller");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { generateKeyPairSync, sign, verify } from 'node:crypto';
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { generateKeyPairSync, sign, verify } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { canonicalJsonDigest, canonicalValueBytes, byteDigest } from '../../src/security/canonical-digest.ts';
|
||||
import { computeProductionInputRoot } from '../../src/security/production-input-root.ts';
|
||||
import {
|
||||
canonicalJsonDigest,
|
||||
canonicalValueBytes,
|
||||
byteDigest,
|
||||
} from "../../src/security/canonical-digest.ts";
|
||||
import { computeProductionInputRoot } from "../../src/security/production-input-root.ts";
|
||||
|
||||
const digest = (character: string) => character.repeat(64);
|
||||
|
||||
@@ -12,13 +16,19 @@ export interface ReleaseFixture {
|
||||
signedManifestPath: string;
|
||||
sbomPath: string;
|
||||
trustPolicyPath: string;
|
||||
verifierModulePath: string;
|
||||
verifySignedReleaseEnvelope(input: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }): any;
|
||||
trustPolicy: Record<string, unknown>;
|
||||
verifySignedReleaseEnvelope(input: {
|
||||
signedRelease: any;
|
||||
trustPolicy: any;
|
||||
now: string;
|
||||
protectedState: any;
|
||||
}): any;
|
||||
protectedState: {
|
||||
latestLedgerHeadHash: string;
|
||||
latestLedgerRevision: number;
|
||||
currentManifestDigest: string | null;
|
||||
publisherFence: number;
|
||||
releaseTrustPolicyDigest: string;
|
||||
};
|
||||
manifestDigest: string;
|
||||
buildId: string;
|
||||
@@ -34,83 +44,106 @@ export interface ReleaseFixture {
|
||||
}): Promise<{ manifestDigest: string; signedManifestPath: string }>;
|
||||
}
|
||||
|
||||
export async function createReleaseFixture(root: string): Promise<ReleaseFixture> {
|
||||
const distDir = join(root, 'dist');
|
||||
await mkdir(join(distDir, 'assets'), { recursive: true });
|
||||
await writeFile(join(distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
|
||||
await writeFile(join(distDir, 'assets/app.js'), 'console.log("portfolio");\n');
|
||||
const sbomPath = join(root, 'sbom.cdx.json');
|
||||
export async function createReleaseFixture(
|
||||
root: string,
|
||||
): Promise<ReleaseFixture> {
|
||||
const distDir = join(root, "dist");
|
||||
await mkdir(join(distDir, "assets"), { recursive: true });
|
||||
await writeFile(
|
||||
join(distDir, "index.html"),
|
||||
"<!doctype html><title>Portfolio</title>\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(distDir, "assets/app.js"),
|
||||
'console.log("portfolio");\n',
|
||||
);
|
||||
const sbomPath = join(root, "sbom.cdx.json");
|
||||
await writeFile(sbomPath, '{"bomFormat":"CycloneDX","specVersion":"1.6"}\n');
|
||||
|
||||
const keys = generateKeyPairSync('ed25519');
|
||||
const publicKeyPem = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
const trustPolicyPath = join(root, 'release-trust.json');
|
||||
await writeFile(trustPolicyPath, canonicalValueBytes({
|
||||
keyId: 'release-test-key', issuerEpoch: 7, scope: 'portfolio-release-signing/v1', publicKeyPem,
|
||||
}));
|
||||
|
||||
const verifierModulePath = join(root, 'real-release-verifier.mjs');
|
||||
await writeFile(verifierModulePath, `
|
||||
import { createHash, verify } from 'node:crypto';
|
||||
|
||||
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,
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = keys.publicKey
|
||||
.export({ type: "spki", format: "pem" })
|
||||
.toString();
|
||||
const trustPolicyPath = join(root, "release-trust.json");
|
||||
const trustPolicy = {
|
||||
keyId: "release-test-key",
|
||||
issuerEpoch: 7,
|
||||
scope: "portfolio-release-signing/v1",
|
||||
publicKeyPem,
|
||||
};
|
||||
}
|
||||
`);
|
||||
await chmod(verifierModulePath, 0o500);
|
||||
await writeFile(trustPolicyPath, canonicalValueBytes(trustPolicy));
|
||||
|
||||
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 }) => {
|
||||
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 verifySignedReleaseEnvelope = ({
|
||||
signedRelease,
|
||||
trustPolicy,
|
||||
now,
|
||||
protectedState: state,
|
||||
}: {
|
||||
signedRelease: any;
|
||||
trustPolicy: any;
|
||||
now: string;
|
||||
protectedState: any;
|
||||
}) => {
|
||||
if (signedRelease?.schema !== "portfolio-signed-release-manifest/v1")
|
||||
throw new Error("bad envelope schema");
|
||||
if (
|
||||
signedRelease.signature?.keyId !== trustPolicy.keyId ||
|
||||
signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch ||
|
||||
signedRelease.signature?.scope !== trustPolicy.scope
|
||||
)
|
||||
throw new Error("untrusted signer");
|
||||
const signature = Buffer.from(signedRelease.signature.value, "base64url");
|
||||
if (
|
||||
signature.length !== 64 ||
|
||||
!verify(
|
||||
null,
|
||||
canonicalValueBytes(signedRelease.payload),
|
||||
trustPolicy.publicKeyPem,
|
||||
signature,
|
||||
)
|
||||
)
|
||||
throw new Error("invalid release signature");
|
||||
const manifestDigest = canonicalJsonDigest(
|
||||
"portfolio-candidate-release-manifest/v1",
|
||||
signedRelease.payload,
|
||||
);
|
||||
if (manifestDigest !== signedRelease.payloadDigest)
|
||||
throw new Error("candidate digest mismatch");
|
||||
const candidate = signedRelease.payload;
|
||||
if (candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash || candidate.latestLedgerRevision !== state.latestLedgerRevision) throw new Error('stale ledger');
|
||||
if (candidate.releaseManifest.currentManifestDigest !== state.currentManifestDigest) throw new Error('stale predecessor');
|
||||
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) throw new Error('expired release');
|
||||
if (
|
||||
candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash ||
|
||||
candidate.latestLedgerRevision !== state.latestLedgerRevision
|
||||
)
|
||||
throw new Error("stale ledger");
|
||||
if (
|
||||
candidate.releaseManifest.currentManifestDigest !==
|
||||
state.currentManifestDigest
|
||||
)
|
||||
throw new Error("stale predecessor");
|
||||
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt))
|
||||
throw new Error("expired release");
|
||||
return {
|
||||
schema: 'portfolio-verified-release-admission/v1', manifestDigest, manifestPayload: candidate.releaseManifest,
|
||||
expiresAt: candidate.releaseManifest.expiresAt, predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
|
||||
latestLedgerHeadHash: candidate.latestLedgerHeadHash, latestLedgerRevision: candidate.latestLedgerRevision,
|
||||
expectedFence: state.publisherFence + 1, signatureVerified: true,
|
||||
schema: "portfolio-verified-release-admission/v1",
|
||||
manifestDigest,
|
||||
manifestPayload: candidate.releaseManifest,
|
||||
expiresAt: candidate.releaseManifest.expiresAt,
|
||||
predecessorManifestDigest:
|
||||
candidate.releaseManifest.currentManifestDigest,
|
||||
latestLedgerHeadHash: candidate.latestLedgerHeadHash,
|
||||
latestLedgerRevision: candidate.latestLedgerRevision,
|
||||
expectedFence: state.publisherFence + 1,
|
||||
signatureVerified: true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -123,19 +156,29 @@ export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, p
|
||||
expiresAt?: string;
|
||||
outputPath: string;
|
||||
}) => {
|
||||
const artifactPaths = ['assets/app.js', 'index.html'];
|
||||
const artifacts = await Promise.all(artifactPaths.map(async (path) => {
|
||||
const bytes = await readFile(join(input.distDir, path));
|
||||
return { path, sha256: byteDigest(bytes), bytes: bytes.length, encoding: 'identity' as const };
|
||||
}));
|
||||
const artifactPaths = ["assets/app.js", "index.html"];
|
||||
const artifacts = await Promise.all(
|
||||
artifactPaths.map(async (path) => {
|
||||
const bytes = await readFile(join(input.distDir, path));
|
||||
return {
|
||||
path,
|
||||
sha256: byteDigest(bytes),
|
||||
bytes: bytes.length,
|
||||
encoding: "identity" as const,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const inputDigests = {
|
||||
PortfolioContent: digest('a'), PublicAssetManifest: digest('b'), PreparedAssetSet: digest('c'), SiteConfig: digest('d'),
|
||||
PortfolioContent: digest("a"),
|
||||
PublicAssetManifest: digest("b"),
|
||||
PreparedAssetSet: digest("c"),
|
||||
SiteConfig: digest("d"),
|
||||
};
|
||||
const issuedAt = input.issuedAt ?? '2026-09-02T00:00:00Z';
|
||||
const expiresAt = input.expiresAt ?? '2026-09-03T00:00:00Z';
|
||||
const issuedAt = input.issuedAt ?? "2026-09-02T00:00:00Z";
|
||||
const expiresAt = input.expiresAt ?? "2026-09-03T00:00:00Z";
|
||||
const releaseManifest = {
|
||||
schema: 'portfolio-release-manifest/v1' as const,
|
||||
launchMode: 'private-source-initial-launch' as const,
|
||||
schema: "portfolio-release-manifest/v1" as const,
|
||||
launchMode: "private-source-initial-launch" as const,
|
||||
buildId: input.buildId,
|
||||
inputDigests,
|
||||
productionInputRoot: computeProductionInputRoot({
|
||||
@@ -146,52 +189,115 @@ export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, p
|
||||
}),
|
||||
artifacts,
|
||||
deployFiles: artifactPaths,
|
||||
sourceCommit: digest('e'), sourceTree: digest('f'), approvedRecordRoot: digest('1'), approvedAssetRoot: digest('2'),
|
||||
approvalProofSetDigest: digest('3'), approvalProjectionAttestationDigest: digest('4'), scanEvidenceDigest: digest('5'),
|
||||
policyVersions: { approval: '1', privacy: '1', release: '1' }, toolVersions: { node: '22.22.1' },
|
||||
sourceCommit: digest("e"),
|
||||
sourceTree: digest("f"),
|
||||
approvedRecordRoot: digest("1"),
|
||||
approvedAssetRoot: digest("2"),
|
||||
approvalProofSetDigest: digest("3"),
|
||||
approvalProjectionAttestationDigest: digest("4"),
|
||||
scanEvidenceDigest: digest("5"),
|
||||
policyVersions: { approval: "1", privacy: "1", release: "1" },
|
||||
toolVersions: { node: "22.22.1" },
|
||||
predecessorManifestDigest: input.predecessorManifestDigest,
|
||||
currentManifestDigest: input.predecessorManifestDigest,
|
||||
issuedAt, expiresAt, minimumBoundExpiry: expiresAt,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
minimumBoundExpiry: expiresAt,
|
||||
};
|
||||
const candidate = {
|
||||
schema: 'portfolio-candidate-release-manifest/v1',
|
||||
schema: "portfolio-candidate-release-manifest/v1",
|
||||
releaseManifest,
|
||||
latestLedgerHeadHash: protectedState.latestLedgerHeadHash,
|
||||
latestLedgerRevision: protectedState.latestLedgerRevision,
|
||||
sourceClosureDigest: digest('6'), publicRefMap: [], lfsOids: [], releaseArchives: [],
|
||||
buildProvenanceDigest: digest('7'), privacyPolicyDigest: digest('8'), toolImageDigests: [digest('0')],
|
||||
sourceClosureDigest: digest("6"),
|
||||
publicObjectClosureDigest: digest("b"),
|
||||
publicRefMap: {},
|
||||
lfsOids: [],
|
||||
releaseArchives: [],
|
||||
buildProvenanceDigest: digest("7"),
|
||||
privacyPolicyDigest: digest("8"),
|
||||
toolImageDigests: [digest("0")],
|
||||
};
|
||||
const payloadDigest = canonicalJsonDigest('portfolio-candidate-release-manifest/v1', candidate);
|
||||
const signature = sign(null, canonicalValueBytes(candidate), keys.privateKey).toString('base64url');
|
||||
const payloadDigest = canonicalJsonDigest(
|
||||
"portfolio-candidate-release-manifest/v1",
|
||||
candidate,
|
||||
);
|
||||
const signature = sign(
|
||||
null,
|
||||
canonicalValueBytes(candidate),
|
||||
keys.privateKey,
|
||||
).toString("base64url");
|
||||
const signedRelease = {
|
||||
schema: 'portfolio-signed-release-manifest/v1', payload: candidate, payloadDigest,
|
||||
schema: "portfolio-signed-release-manifest/v1",
|
||||
payload: candidate,
|
||||
payloadDigest,
|
||||
signature: {
|
||||
algorithm: 'Ed25519', encoding: 'base64url-nopad', keyId: 'release-test-key', issuerEpoch: 7,
|
||||
scope: 'portfolio-release-signing/v1', value: signature,
|
||||
algorithm: "Ed25519",
|
||||
encoding: "base64url-nopad",
|
||||
keyId: "release-test-key",
|
||||
issuerEpoch: 7,
|
||||
scope: "portfolio-release-signing/v1",
|
||||
value: signature,
|
||||
},
|
||||
};
|
||||
await writeFile(input.outputPath, canonicalValueBytes(signedRelease));
|
||||
return { manifestDigest: payloadDigest, signedManifestPath: input.outputPath };
|
||||
return {
|
||||
manifestDigest: payloadDigest,
|
||||
signedManifestPath: input.outputPath,
|
||||
};
|
||||
};
|
||||
|
||||
const signedManifestPath = join(root, 'signed-release.json');
|
||||
const initial = await signFor({ buildId: 'release-a', distDir, predecessorManifestDigest: null, publisherFence: 0, outputPath: signedManifestPath });
|
||||
const signedManifestPath = join(root, "signed-release.json");
|
||||
const initial = await signFor({
|
||||
buildId: "release-a",
|
||||
distDir,
|
||||
predecessorManifestDigest: null,
|
||||
publisherFence: 0,
|
||||
outputPath: signedManifestPath,
|
||||
});
|
||||
return {
|
||||
distDir, signedManifestPath, sbomPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState,
|
||||
manifestDigest: initial.manifestDigest, buildId: 'release-a', expiresAt: '2026-09-03T00:00:00Z', signFor,
|
||||
distDir,
|
||||
signedManifestPath,
|
||||
sbomPath,
|
||||
trustPolicyPath,
|
||||
trustPolicy,
|
||||
verifySignedReleaseEnvelope,
|
||||
protectedState,
|
||||
manifestDigest: initial.manifestDigest,
|
||||
buildId: "release-a",
|
||||
expiresAt: "2026-09-03T00:00:00Z",
|
||||
signFor,
|
||||
};
|
||||
}
|
||||
|
||||
export async function initializeReleaseRoot(root: string, state?: Partial<ReleaseFixture['protectedState']>): Promise<void> {
|
||||
await mkdir(join(root, 'control'), { recursive: true });
|
||||
await writeFile(join(root, '.portfolio-release-root'), 'portfolio-release-root/v1\n');
|
||||
await writeFile(join(root, 'control/state.json'), canonicalValueBytes({
|
||||
schema: 'portfolio-release-control-state/v1',
|
||||
currentManifestDigest: state?.currentManifestDigest ?? null,
|
||||
currentReleaseId: null,
|
||||
publisherFence: state?.publisherFence ?? 0,
|
||||
latestLedgerHeadHash: state?.latestLedgerHeadHash ?? digest('9'),
|
||||
latestLedgerRevision: state?.latestLedgerRevision ?? 11,
|
||||
activeTransaction: null,
|
||||
}));
|
||||
export async function initializeReleaseRoot(
|
||||
root: string,
|
||||
state?: Partial<ReleaseFixture["protectedState"]>,
|
||||
trustPolicy?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await mkdir(join(root, "control"), { recursive: true });
|
||||
await mkdir(join(root, ".control"), { recursive: true });
|
||||
await mkdir(join(root, "inbox"), { recursive: true });
|
||||
if (trustPolicy !== undefined)
|
||||
await writeFile(
|
||||
join(root, ".control", "release-trust.json"),
|
||||
canonicalValueBytes(trustPolicy),
|
||||
);
|
||||
await writeFile(
|
||||
join(root, ".portfolio-release-root"),
|
||||
"portfolio-release-root/v1\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "control/state.json"),
|
||||
canonicalValueBytes({
|
||||
schema: "portfolio-release-control-state/v1",
|
||||
currentManifestDigest: state?.currentManifestDigest ?? null,
|
||||
currentReleaseId: null,
|
||||
publisherFence: state?.publisherFence ?? 0,
|
||||
latestLedgerHeadHash: state?.latestLedgerHeadHash ?? digest("9"),
|
||||
latestLedgerRevision: state?.latestLedgerRevision ?? 11,
|
||||
releaseTrustPolicyDigest: state?.releaseTrustPolicyDigest ?? digest("8"),
|
||||
activeTransaction: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user