Files
Ekklesia/core/src/cli/verify-module-package.ts
T
ekklesia 4be046c8d0
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / package-release (pull_request) Successful in 36s
CI / image-build (pull_request) Successful in 52s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
CI / package-publication (pull_request) Successful in 51s
fix: support native package verifier scratch
2026-10-09 18:05:04 -05:00

189 lines
7.2 KiB
TypeScript

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 <archive>
* [--sha256 <hex> | --sha256-file <file>] the archive's expected SHA-256
* [--extract-to <new directory>] 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 <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
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<string, string>();
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();
}
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
if (configured === undefined) return undefined;
if (configured === "" || !path.isAbsolute(configured)) {
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
}
return configured;
}
async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
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<ModulePackagePlatform> {
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<string, unknown> | undefined;
return {
databaseRuntime: { "drizzle-orm": requiredString(dependencies?.["drizzle-orm"]) },
moduleSdkVersion: requiredString(sdk.version),
platformVersion: requiredString(root.version),
};
}
async function main(): Promise<void> {
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
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,
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
...(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;
});
}