Files
tech-log-frontend/scripts/lib/ci-candidate-archive.ts
T

664 lines
24 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import {
lstat,
mkdir,
mkdtemp,
open,
readFile,
readdir,
rename,
rm,
unlink,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
distSha256,
releaseCandidateManifestSchema,
type ReleaseCandidateManifest,
} from "./release-candidate.ts";
import { supplyChainDigest } from "./supply-chain.ts";
import {
assertSafePublishLeaf,
ensureSafePublishDirectory,
} from "./ci-gate-log.ts";
const MAX_ARCHIVE_BYTES = 268_435_456;
const MAX_CANDIDATE_FILES = 4_096;
const MAX_ARCHIVE_MEMBERS = 8_192;
const MAX_MEMBER_PATH_BYTES = 1_024;
const TAR_EXECUTABLE = "/usr/bin/tar";
const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" });
export type CapturedCandidateArchive = Readonly<{
bytes: Buffer;
archiveSha256: string;
}>;
export async function captureCiCandidateArchive(input: Readonly<{
archivePath: string;
expectedSha256: string;
}>): Promise<CapturedCandidateArchive> {
if (!/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
throw new TypeError("expected candidate archive SHA-256 is invalid");
}
const absolute = path.resolve(input.archivePath);
const before = await lstat(absolute);
if (!before.isFile() || before.isSymbolicLink()) {
throw new TypeError("candidate archive must be a regular non-symlink file");
}
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
}
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
let bytes: Buffer;
try {
assertSameIdentity(before, await handle.stat());
bytes = await readCapturedArchive(handle, before.size);
assertSameIdentity(before, await handle.stat());
} finally {
await handle.close();
}
const archiveSha256 = createHash("sha256").update(bytes).digest("hex");
if (archiveSha256 !== input.expectedSha256) {
throw new Error("candidate archive SHA-256 mismatch");
}
return Object.freeze({ bytes, archiveSha256 });
}
export async function withVerifiedCapturedCandidate<T>(input: Readonly<{
captured: CapturedCandidateArchive;
verify: (view: Readonly<{
extractionRoot: string;
manifest: ReleaseCandidateManifest;
}>) => Promise<T>;
}>): Promise<T> {
let result: T | undefined;
await verifyCapturedCiCandidateArchive(
input.captured.bytes,
input.captured.archiveSha256,
{
verifyExtracted: async (extractionRoot, manifest) => {
result = await input.verify({ extractionRoot, manifest });
},
},
);
return result as T;
}
export async function verifyCiCandidateArchive(
input: Readonly<{
archivePath: string;
expectedSha256?: string;
extractTo?: string;
repositoryRoot?: string;
}>,
dependencies: Readonly<{ afterArchiveRead?: () => Promise<void> }> = {},
): Promise<Readonly<{
archiveSha256: string;
memberCount: number;
manifest: ReleaseCandidateManifest;
}>> {
if (input.expectedSha256 && !/^[a-f0-9]{64}$/u.test(input.expectedSha256)) {
throw new TypeError("expected candidate archive SHA-256 is invalid");
}
const absolute = path.resolve(input.archivePath);
const before = await lstat(absolute);
if (!before.isFile() || before.isSymbolicLink()) {
throw new TypeError("candidate archive must be a regular non-symlink file");
}
if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) {
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
}
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
let archive: Buffer;
try {
assertSameIdentity(before, await handle.stat());
archive = await readCapturedArchive(handle, before.size);
assertSameIdentity(before, await handle.stat());
} finally {
await handle.close();
}
if (archive.byteLength !== before.size) {
throw new Error("candidate archive changed size during capture");
}
await dependencies.afterArchiveRead?.();
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
if (input.expectedSha256 && archiveSha256 !== input.expectedSha256) {
throw new Error("candidate archive SHA-256 mismatch");
}
const extractionTarget = input.extractTo ? path.resolve(input.extractTo) : undefined;
let extractionRoot: string;
let extractionParentIdentity: Awaited<ReturnType<typeof ensureSafePublishDirectory>> | undefined;
if (extractionTarget) {
if (!input.repositoryRoot) {
throw new TypeError("repositoryRoot is required when publishing an extracted candidate");
}
const repositoryRoot = path.resolve(input.repositoryRoot);
extractionParentIdentity = await ensureSafePublishDirectory(
repositoryRoot,
path.dirname(extractionTarget),
);
await assertSafePublishLeaf(extractionTarget, input.extractTo);
extractionRoot = await mkdtemp(
path.join(path.dirname(extractionTarget), `.${path.basename(extractionTarget)}.verified-`),
);
} else {
extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-candidate-archive-"));
}
let published = false;
try {
const captured = await materializeCapturedArchive(archive);
try {
const preflightManifest = preflightArchiveHandle(captured.handle);
extractArchiveHandle(captured.handle, extractionRoot);
const verified = await verifyExtractedTree(extractionRoot, preflightManifest);
if (extractionTarget) {
const repositoryRoot = path.resolve(input.repositoryRoot!);
const currentParentIdentity = await ensureSafePublishDirectory(
repositoryRoot,
path.dirname(extractionTarget),
);
if (
!extractionParentIdentity ||
extractionParentIdentity.dev <= 0 ||
extractionParentIdentity.ino <= 0 ||
currentParentIdentity.dev !== extractionParentIdentity.dev ||
currentParentIdentity.ino !== extractionParentIdentity.ino
) {
throw new Error("verified extraction parent identity changed");
}
await assertSafePublishLeaf(extractionTarget, input.extractTo);
if (await pathExists(extractionTarget)) {
throw new Error(`verified extraction target already exists: ${input.extractTo}`);
}
await rename(extractionRoot, extractionTarget);
published = true;
}
return Object.freeze({
archiveSha256,
memberCount: verified.memberCount,
manifest: verified.manifest,
});
} finally {
await captured.handle.close();
await rm(captured.root, { recursive: true, force: true });
}
} finally {
if (!published) await rm(extractionRoot, { recursive: true, force: true });
}
}
export async function verifyCapturedCiCandidateArchive(
archive: Buffer,
expectedSha256: string,
dependencies: Readonly<{
verifyExtracted?: (
extractionRoot: string,
manifest: ReleaseCandidateManifest,
) => Promise<void>;
}> = {},
): Promise<Readonly<{
archiveSha256: string;
memberCount: number;
manifest: ReleaseCandidateManifest;
}>> {
if (archive.byteLength <= 0 || archive.byteLength > MAX_ARCHIVE_BYTES) {
throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`);
}
if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) {
throw new TypeError("expected candidate archive SHA-256 is invalid");
}
const archiveSha256 = createHash("sha256").update(archive).digest("hex");
if (archiveSha256 !== expectedSha256) {
throw new Error("candidate archive SHA-256 mismatch");
}
const captured = await materializeCapturedArchive(archive);
const extractionRoot = await mkdtemp(path.join(tmpdir(), "ci-captured-candidate-"));
try {
const manifest = preflightArchiveHandle(captured.handle);
extractArchiveHandle(captured.handle, extractionRoot);
const verified = await verifyExtractedTree(extractionRoot, manifest);
await dependencies.verifyExtracted?.(extractionRoot, verified.manifest);
return Object.freeze({
archiveSha256,
memberCount: verified.memberCount,
manifest: verified.manifest,
});
} finally {
await rm(extractionRoot, { recursive: true, force: true });
await captured.handle.close();
await rm(captured.root, { recursive: true, force: true });
}
}
function preflightArchiveHandle(archiveHandle: FileHandle): ReleaseCandidateManifest {
const listed = spawnSync(
TAR_EXECUTABLE,
["--list", "--verbose", "--numeric-owner", "--full-time", "--gzip", "--file", "/proc/self/fd/3"],
{
encoding: "utf8",
maxBuffer: 16_777_216,
timeout: 10_000,
env: TAR_ENVIRONMENT,
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
},
);
if (listed.status !== 0 || listed.signal || listed.error) {
throw new Error(
`candidate archive listing failed: ${listed.stderr || listed.error?.message || listed.signal}`,
);
}
const seen = new Set<string>();
const regularMembers = new Set<string>();
const directoryMembers = new Set<string>();
let totalBytes = 0;
const lines = listed.stdout.split(/\r?\n/u).filter(Boolean);
if (lines.length === 0 || lines.length > MAX_ARCHIVE_MEMBERS) {
throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`);
}
for (const line of lines) {
const match = /^(?<mode>.{10})\s+\d+\/\d+\s+(?<bytes>\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:\s+[+-]\d{4})?\s+(?<path>.+)$/u.exec(line);
if (!match?.groups) throw new Error(`candidate archive listing is unparseable: ${line}`);
const member = match.groups.path!.endsWith("/")
? match.groups.path!.slice(0, -1)
: match.groups.path!;
assertSafeMemberPath(member);
if (seen.has(member)) throw new Error(`candidate archive duplicate member: ${member}`);
seen.add(member);
const mode = match.groups.mode!;
if (!mode.startsWith("-") && !mode.startsWith("d")) {
throw new Error(`candidate archive contains non-regular member: ${member}`);
}
if (mode.startsWith("-")) {
const memberBytes = Number(match.groups.bytes);
if (
member === RELEASE_CANDIDATE_MANIFEST_PATH &&
memberBytes > 8_388_608
) {
throw new RangeError("candidate manifest exceeds 8388608 bytes");
}
totalBytes += memberBytes;
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_ARCHIVE_BYTES) {
throw new RangeError("candidate archive expanded bytes exceed the bound");
}
regularMembers.add(member);
} else {
directoryMembers.add(member);
}
}
const manifest = readManifestFromArchive(archiveHandle);
validateManifestSemantics(manifest);
const expectedFiles = new Set([
...manifest.files.map(({ path: member }) => member),
RELEASE_CANDIDATE_MANIFEST_PATH,
]);
for (const member of expectedFiles) assertSafeMemberPath(member);
const expectedDirectories = new Set(
directoryAncestors([...expectedFiles]).filter(
(member) => member === "dist" || member.startsWith("dist/"),
),
);
if (
JSON.stringify([...regularMembers].sort(asciiCompare)) !==
JSON.stringify([...expectedFiles].sort(asciiCompare)) ||
JSON.stringify([...directoryMembers].sort(asciiCompare)) !==
JSON.stringify([...expectedDirectories].sort(asciiCompare))
) {
throw new Error("candidate archive exact member set drift before extraction");
}
return manifest;
}
function extractArchiveHandle(archiveHandle: FileHandle, extractionRoot: string): void {
const extracted = spawnSync(
TAR_EXECUTABLE,
[
"--extract",
"--gzip",
"--file",
"/proc/self/fd/3",
"--directory",
extractionRoot,
"--no-same-owner",
"--no-same-permissions",
],
{
encoding: "utf8",
maxBuffer: 1_048_576,
timeout: 30_000,
env: TAR_ENVIRONMENT,
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
},
);
if (extracted.status !== 0 || extracted.signal || extracted.error) {
throw new Error(
`candidate archive isolated extraction failed: ${extracted.stderr || extracted.error?.message || extracted.signal}`,
);
}
}
function validateManifestSemantics(manifest: ReleaseCandidateManifest): void {
if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) {
throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`);
}
const canonicalFiles = [...manifest.files].sort((left, right) =>
asciiCompare(left.path, right.path),
);
if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) {
throw new Error("candidate manifest files are not in canonical ASCII order");
}
const expectedFiles = new Map<string, Readonly<{ bytes: number; sha256: string }>>();
let declaredBytes = 0;
for (const file of manifest.files) {
assertSafeMemberPath(file.path);
if (expectedFiles.has(file.path)) {
throw new Error(`candidate manifest duplicate file: ${file.path}`);
}
declaredBytes += file.bytes;
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) {
throw new RangeError("candidate manifest declared bytes exceed the archive bound");
}
expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 });
}
const evidencePaths = [...expectedFiles.keys()]
.filter((member) => !member.startsWith("dist/"))
.sort(asciiCompare);
if (
JSON.stringify(evidencePaths) !==
JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare))
) {
throw new Error("candidate manifest evidence member set drift");
}
const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/"));
if (distFiles.length === 0) throw new Error("candidate manifest has no dist files");
const lockfile = expectedFiles.get("pnpm-lock.yaml");
if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) {
throw new Error("candidate manifest lockfile digest summary mismatch");
}
if (
distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !==
manifest.distSha256
) {
throw new Error("candidate manifest dist digest summary mismatch");
}
if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) {
throw new Error("candidate manifest bundle digest summary mismatch");
}
}
async function verifyExtractedTree(
extractionRoot: string,
preflightManifest: ReleaseCandidateManifest,
): Promise<Readonly<{ memberCount: number; manifest: ReleaseCandidateManifest }>> {
const entries = await walkExtractedTree(extractionRoot);
if (entries.length === 0 || entries.length > MAX_ARCHIVE_MEMBERS) {
throw new RangeError(`candidate archive member count is outside 1..${MAX_ARCHIVE_MEMBERS}`);
}
const manifest = releaseCandidateManifestSchema.parse(
JSON.parse(
await readFile(path.join(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
) as unknown,
);
if (JSON.stringify(manifest) !== JSON.stringify(preflightManifest)) {
throw new Error("candidate manifest changed between preflight and extraction");
}
if (manifest.files.length === 0 || manifest.files.length > MAX_CANDIDATE_FILES) {
throw new RangeError(`candidate manifest exceeds ${MAX_CANDIDATE_FILES} files`);
}
const canonicalFiles = [...manifest.files].sort((left, right) =>
asciiCompare(left.path, right.path),
);
if (JSON.stringify(manifest.files) !== JSON.stringify(canonicalFiles)) {
throw new Error("candidate manifest files are not in canonical ASCII order");
}
const expectedFiles = new Map<string, Readonly<{ bytes: number; sha256: string }>>();
let declaredBytes = 0;
for (const file of manifest.files) {
assertSafeMemberPath(file.path);
if (expectedFiles.has(file.path)) throw new Error(`candidate manifest duplicate file: ${file.path}`);
declaredBytes += file.bytes;
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ARCHIVE_BYTES) {
throw new RangeError("candidate manifest declared bytes exceed the archive bound");
}
expectedFiles.set(file.path, { bytes: file.bytes, sha256: file.sha256 });
}
const evidencePaths = [...expectedFiles.keys()]
.filter((member) => !member.startsWith("dist/"))
.sort(asciiCompare);
if (
JSON.stringify(evidencePaths) !==
JSON.stringify([...RELEASE_CANDIDATE_EVIDENCE_PATHS].sort(asciiCompare))
) {
throw new Error("candidate manifest evidence member set drift");
}
const distFiles = manifest.files.filter(({ path: member }) => member.startsWith("dist/"));
if (distFiles.length === 0) throw new Error("candidate manifest has no dist files");
const lockfile = expectedFiles.get("pnpm-lock.yaml");
if (!lockfile || lockfile.sha256 !== manifest.lockfileSha256) {
throw new Error("candidate manifest lockfile digest summary mismatch");
}
if (
distSha256(distFiles.map((file) => ({ ...file, gzipBytes: 0 }))) !==
manifest.distSha256
) {
throw new Error("candidate manifest dist digest summary mismatch");
}
if (supplyChainDigest(manifest.files) !== manifest.bundleSha256) {
throw new Error("candidate manifest bundle digest summary mismatch");
}
const expectedFilePaths = new Set([
...expectedFiles.keys(),
RELEASE_CANDIDATE_MANIFEST_PATH,
]);
const expectedDirectories = new Set(directoryAncestors([...expectedFilePaths]));
for (const entry of entries) {
assertSafeMemberPath(entry.path);
if (entry.type === "directory") {
if (!expectedDirectories.has(entry.path)) {
throw new Error(`candidate archive contains unexpected directory: ${entry.path}`);
}
} else if (!expectedFilePaths.has(entry.path)) {
throw new Error(`candidate archive contains unexpected file: ${entry.path}`);
}
}
const actualFiles = new Set(
entries.filter(({ type }) => type === "file").map(({ path: member }) => member),
);
for (const expected of expectedFilePaths) {
if (!actualFiles.has(expected)) throw new Error(`candidate archive is missing file: ${expected}`);
}
for (const [member, expected] of expectedFiles) {
const bytes = await readFile(path.join(extractionRoot, member));
if (bytes.byteLength !== expected.bytes) {
throw new Error(`candidate archive member size mismatch: ${member}`);
}
if (createHash("sha256").update(bytes).digest("hex") !== expected.sha256) {
throw new Error(`candidate archive member digest mismatch: ${member}`);
}
}
return Object.freeze({ memberCount: entries.length, manifest });
}
async function walkExtractedTree(
root: string,
relativeDirectory = "",
): Promise<ReadonlyArray<Readonly<{ path: string; type: "file" | "directory" }>>> {
const children = await readdir(path.join(root, relativeDirectory), {
withFileTypes: true,
});
const entries: Array<Readonly<{ path: string; type: "file" | "directory" }>> = [];
for (const child of children.sort((left, right) => asciiCompare(left.name, right.name))) {
const relative = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name;
assertSafeMemberPath(relative);
const metadata = await lstat(path.join(root, relative));
if (metadata.isSymbolicLink()) {
throw new Error(`candidate archive contains non-regular member: ${relative}`);
}
if (metadata.isDirectory() && child.isDirectory()) {
entries.push(Object.freeze({ path: relative, type: "directory" }));
entries.push(...(await walkExtractedTree(root, relative)));
} else if (metadata.isFile() && child.isFile()) {
if (metadata.nlink !== 1) {
throw new Error(`candidate archive contains hard-linked member: ${relative}`);
}
entries.push(Object.freeze({ path: relative, type: "file" }));
} else {
throw new Error(`candidate archive contains non-regular member: ${relative}`);
}
if (entries.length > MAX_ARCHIVE_MEMBERS) {
throw new RangeError(`candidate archive exceeds ${MAX_ARCHIVE_MEMBERS} members`);
}
}
return entries;
}
function assertSameIdentity(
before: Awaited<ReturnType<typeof lstat>>,
after: Awaited<ReturnType<typeof lstat>>,
): void {
if (
!after.isFile() ||
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size
) {
throw new Error("candidate archive file identity changed");
}
}
async function readCapturedArchive(
handle: FileHandle,
expectedSize: number,
): Promise<Buffer> {
const captured = Buffer.allocUnsafe(expectedSize + 1);
let offset = 0;
while (offset < captured.byteLength) {
const { bytesRead } = await handle.read(
captured,
offset,
captured.byteLength - offset,
offset,
);
if (bytesRead === 0) break;
offset += bytesRead;
}
if (offset !== expectedSize) {
throw new Error("candidate archive changed size during bounded capture");
}
return captured.subarray(0, offset);
}
function readManifestFromArchive(archiveHandle: FileHandle): ReleaseCandidateManifest {
const extracted = spawnSync(
TAR_EXECUTABLE,
[
"--extract",
"--gzip",
"--to-stdout",
"--file",
"/proc/self/fd/3",
"--",
RELEASE_CANDIDATE_MANIFEST_PATH,
],
{
maxBuffer: 8_388_609,
timeout: 10_000,
env: TAR_ENVIRONMENT,
stdio: ["ignore", "pipe", "pipe", archiveHandle.fd],
},
);
if (extracted.status !== 0 || extracted.signal || extracted.error) {
throw new Error(
`candidate manifest preflight failed: ${String(extracted.stderr) || extracted.error?.message || extracted.signal}`,
);
}
const bytes = Buffer.from(extracted.stdout);
if (bytes.byteLength === 0 || bytes.byteLength > 8_388_608) {
throw new RangeError("candidate manifest preflight size is outside 1..8388608");
}
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return releaseCandidateManifestSchema.parse(JSON.parse(source) as unknown);
}
async function materializeCapturedArchive(
archive: Buffer,
): Promise<Readonly<{ root: string; handle: FileHandle }>> {
const root = await mkdtemp(path.join(tmpdir(), "ci-captured-archive-"));
const file = path.join(root, "candidate.tar.gz");
let handle: FileHandle | undefined;
try {
handle = await open(
file,
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
await handle.writeFile(archive);
await handle.sync();
await unlink(file);
return Object.freeze({ root, handle });
} catch (error) {
if (handle) await handle.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
throw error;
}
}
function assertSafeMemberPath(member: string): void {
if (
!member ||
member.startsWith("-") ||
Buffer.byteLength(member, "utf8") > MAX_MEMBER_PATH_BYTES ||
member.includes("\\") ||
[...member].some((character) => {
const codePoint = character.codePointAt(0)!;
return codePoint <= 0x1f || codePoint === 0x7f;
}) ||
path.posix.isAbsolute(member) ||
path.posix.normalize(member) !== member ||
member === ".." ||
member.startsWith("../") ||
member.includes("/../")
) {
throw new TypeError(`candidate archive contains unsafe member path: ${member}`);
}
}
function directoryAncestors(files: readonly string[]): string[] {
const directories = new Set<string>();
for (const file of files) {
let directory = path.posix.dirname(file);
while (directory !== ".") {
directories.add(directory);
directory = path.posix.dirname(directory);
}
}
return [...directories];
}
function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
async function pathExists(target: string): Promise<boolean> {
try {
await lstat(target);
return true;
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return false;
throw error;
}
}