import { readFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { type ModulePackagePlatform, ModulePackageVerificationError, verifyModulePackage, } from "../modules/index.js"; /** * `verify-module-package` — verifies an Ekklesia Module Package v1 archive against this platform * (ADR 0049), and never installs it. * * node core/dist/cli/verify-module-package.js * [--sha256 | --sha256-file ] the archive's expected SHA-256 * [--extract-to ] write the verified module directory there * * Run from the platform's root (`/opt/ekklesia` in the image), like every operator command: the * platform's own versions are read from there. `--extract-to` writes only after every check passed, * into a directory that must not exist yet; it is the staging step of an installation, not the * installation itself. */ class VerifyArgumentsError extends Error {} const USAGE = "Usage: verify-module-package [--sha256 | --sha256-file ] [--extract-to ]"; export interface VerifyModulePackageArguments { readonly archivePath: string; readonly expectedSha256?: string; readonly sha256File?: string; readonly extractTo?: string; } function normalizedDigest(value: string): string { const digest = value.trim().toLowerCase(); if (!/^[0-9a-f]{64}$/u.test(digest)) { throw new VerifyArgumentsError("The SHA-256 must be 64 hexadecimal characters."); } return digest; } export function parseVerifyModulePackageArguments( argv: readonly string[], ): VerifyModulePackageArguments { const positional: string[] = []; const options = new Map(); for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] ?? ""; if (argument.startsWith("--")) { if (!["--sha256", "--sha256-file", "--extract-to"].includes(argument)) { throw new VerifyArgumentsError(USAGE); } const value = argv[index + 1]; if (value === undefined || value.startsWith("--") || options.has(argument)) { throw new VerifyArgumentsError(USAGE); } options.set(argument, value); index += 1; } else { positional.push(argument); } } const archivePath = positional[0]; if (positional.length !== 1 || archivePath === undefined || archivePath === "") { throw new VerifyArgumentsError(USAGE); } if (options.has("--sha256") && options.has("--sha256-file")) { throw new VerifyArgumentsError(USAGE); } const sha256 = options.get("--sha256"); const sha256File = options.get("--sha256-file"); const extractTo = options.get("--extract-to"); return { archivePath, ...(sha256 === undefined ? {} : { expectedSha256: normalizedDigest(sha256) }), ...(sha256File === undefined ? {} : { sha256File }), ...(extractTo === undefined ? {} : { extractTo }), }; } /** The `sha256sum` format: one line, the digest, whitespace, and a file name that is never trusted. */ export function parseSha256File(contents: string): string { const lines = contents.split("\n").filter((line) => line.trim() !== ""); const match = lines.length === 1 ? /^([0-9a-fA-F]{64})\s+\*?\S/u.exec(lines[0] ?? "") : null; if (!match?.[1]) { throw new VerifyArgumentsError("The SHA-256 file must hold one sha256sum line."); } return match[1].toLowerCase(); } async function readJson(file: string): Promise> { return JSON.parse(await readFile(file, "utf8")) as Record; } function requiredString(value: unknown): string { if (typeof value !== "string" || value === "") { throw new VerifyArgumentsError("The platform's own package metadata could not be read."); } return value; } /** What this platform is, read from where it is installed. The transitional database runtime is * the Drizzle version Core itself depends on (ADR 0049). */ export async function readModulePackagePlatform( repositoryRoot: string, ): Promise { const [root, sdk, core] = await Promise.all([ readJson(path.join(repositoryRoot, "package.json")), readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")), readJson(path.join(repositoryRoot, "core", "package.json")), ]); const dependencies = core.dependencies as Record | undefined; return { databaseRuntime: { "drizzle-orm": requiredString(dependencies?.["drizzle-orm"]) }, moduleSdkVersion: requiredString(sdk.version), platformVersion: requiredString(root.version), }; } async function main(): Promise { const parsed = parseVerifyModulePackageArguments(process.argv.slice(2)); let expectedSha256 = parsed.expectedSha256; if (parsed.sha256File !== undefined) { let contents: string; try { contents = await readFile(parsed.sha256File, "utf8"); } catch { throw new VerifyArgumentsError("The SHA-256 file could not be read."); } expectedSha256 = parseSha256File(contents); } const platform = await readModulePackagePlatform(process.cwd()).catch(() => { throw new VerifyArgumentsError("The platform's own package metadata could not be read."); }); const result = await verifyModulePackage({ archivePath: parsed.archivePath, platform, ...(expectedSha256 === undefined ? {} : { expectedSha256 }), ...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }), }); console.log( `Module package verified: ${result.moduleId}@${result.version} (target ${result.target}), ${result.fileCount} files, sha256 ${result.archiveSha256}.`, ); if (expectedSha256 === undefined) { console.log("The archive's SHA-256 was not checked: no expected digest was given."); } if (parsed.extractTo !== undefined) { console.log("The verified module directory was extracted. Nothing was installed."); } } function isDirectExecution(): boolean { const entrypoint = process.argv[1]; return ( entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href ); } if (isDirectExecution()) { main().catch((error: unknown) => { if (error instanceof ModulePackageVerificationError) { console.error(`[${error.code}] ${error.message}`); if (error.issueCodes.length > 0) { console.error(`Module validation issues: ${error.issueCodes.join(", ")}`); } } else if (error instanceof VerifyArgumentsError) { console.error(error.message); } else { console.error("Module package verification failed unexpectedly."); } process.exitCode = 1; }); }