fix: close release provenance and privacy gaps

This commit is contained in:
2026-09-02 11:25:52 +03:00
parent 7da2b72933
commit 79e5d04395
17 changed files with 2271 additions and 704 deletions

View File

@@ -5,3 +5,17 @@ export interface ArchiveFile {
export function createDeterministicTar(files: readonly ArchiveFile[]): Buffer;
export function parseDeterministicTar(source: Uint8Array): ArchiveFile[];
export function verifySignedRelease(input: {
signedManifestPath: string;
authorityRoot: string;
protectedState: Record<string, unknown>;
}): Promise<Record<string, unknown>>;
export function verifySignedReleaseForTest(input: {
signedManifestPath: string;
trustPolicyPath: string;
verifySignedReleaseEnvelope: (input: Record<string, unknown>) => unknown;
protectedState: Record<string, unknown>;
now: string;
}): Promise<Record<string, unknown>>;

View File

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