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
189 lines
7.2 KiB
TypeScript
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;
|
|
});
|
|
}
|