import { fork } from "node:child_process"; import { createHash } from "node:crypto"; import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { isVersionCompatible, type ModuleManifest, safeParseModuleManifest, } from "@ekklesia/contracts"; import { MODULE_PACKAGE_ANY_TARGET, MODULE_PACKAGE_FORMAT, MODULE_PACKAGE_METADATA_FILENAME, type ModulePackageMetadata, safeParseModulePackageMetadata, safeParseModulePackageRuntimePackage, } from "@ekklesia/contracts/module-package"; import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js"; import { currentModulePackageHost, MODULE_PACKAGE_NATIVE_TARGETS, type ModulePackageHost, } from "./module-package-host.js"; import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js"; import { type ModulePackageArchive, ModulePackageArchiveError, type ModulePackageArchiveErrorCode, readModulePackageArchive, } from "./module-package-archive.js"; import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-discovery.js"; /** * Verifies an Ekklesia Module Package v1 archive (ADR 0049) against the platform it runs on, without * ever installing it. * * Order matters: everything that can be decided from bytes is decided before anything is written, * and nothing is executed until the package has proven itself well-formed. The archive is read and * checked for safety in memory; its metadata, every payload digest, and every identity statement * are checked next; only then is it written to a fresh temporary directory, validated by the same * discovery the platform uses, and its backend loaded once in a separate, short-lived process with * an empty environment. A caller that asks for extraction gets the verified tree written to a new * directory only after all of that succeeded. * * Errors carry a stable code and a fixed message. Paths, archive entry names, file contents, module * output, and exception messages never appear in them: an archive is untrusted input, and none of * it is ever echoed. */ export type ModulePackageVerificationErrorCode = | ModulePackageArchiveErrorCode | "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE" | "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED" | "MODULE_PACKAGE_DIGEST_MISMATCH" | "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" | "MODULE_PACKAGE_EXTRACTION_FAILED" | "MODULE_PACKAGE_FILE_MISMATCH" | "MODULE_PACKAGE_FILE_MISSING" | "MODULE_PACKAGE_FILE_UNLISTED" | "MODULE_PACKAGE_FORMAT_UNSUPPORTED" | "MODULE_PACKAGE_IDENTITY_MISMATCH" | "MODULE_PACKAGE_LIBC_INCOMPATIBLE" | "MODULE_PACKAGE_MANIFEST_INVALID" | "MODULE_PACKAGE_METADATA_INVALID" | "MODULE_PACKAGE_METADATA_MISSING" | "MODULE_PACKAGE_MODULE_INVALID" | "MODULE_PACKAGE_NATIVE_FILE" | "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE" | "MODULE_PACKAGE_NODE_API_INCOMPATIBLE" | "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE" | "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID" | "MODULE_PACKAGE_SDK_INCOMPATIBLE" | "MODULE_PACKAGE_TARGET_INCOMPATIBLE" | "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED" | "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE"; const messages: Readonly> = Object.freeze({ MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE: "The package's bundled database runtime does not match this platform's (a transitional constraint).", MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED: "A Module Database ABI package must not carry a transitional database runtime.", MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.", MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED: "A backend entrypoint could not be loaded from the package alone.", MODULE_PACKAGE_EXTRACTION_FAILED: "The verified package could not be extracted to a new directory.", MODULE_PACKAGE_FILE_MISMATCH: "A payload file does not match its listed size or SHA-256.", MODULE_PACKAGE_FILE_MISSING: "A file listed in the package metadata is missing from the archive.", MODULE_PACKAGE_FILE_UNLISTED: "The archive contains a file the package metadata does not list.", MODULE_PACKAGE_FORMAT_UNSUPPORTED: "The package format is not one this platform implements.", MODULE_PACKAGE_IDENTITY_MISMATCH: "The package metadata, runtime package.json, and manifest disagree about the module's identity.", MODULE_PACKAGE_LAYOUT_INVALID: "The archive must contain exactly one top-level module directory, with each path once.", MODULE_PACKAGE_LIBC_INCOMPATIBLE: "The package needs a newer C library than this host provides, or one it cannot establish.", MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.", MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.", MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.", MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.", MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.", MODULE_PACKAGE_NATIVE_FILE: "An any-target package contains a native file or a node_modules directory.", MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE: "A native file in the package is not built for the package's target.", MODULE_PACKAGE_NODE_API_INCOMPATIBLE: "The package's addons need a Node-API version or Node.js ABI this host does not provide.", MODULE_PACKAGE_RUNTIME_INCOMPATIBLE: "The package needs a Node.js version this host does not run.", MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID: "The package's runtime package.json is not the generated runtime shape.", MODULE_PACKAGE_SDK_INCOMPATIBLE: "The Module SDK bundled into the package is outside the manifest's own moduleSdk range.", MODULE_PACKAGE_SPECIAL_ENTRY: "The archive contains an entry that is neither a directory nor a regular file.", MODULE_PACKAGE_TARGET_INCOMPATIBLE: "The package's target is not one this platform can run.", MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.", MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED: "The package metadata contains transitional facts this platform does not understand.", MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE: "The verifier execution scratch directory is missing, not writable, or mounted noexec.", MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.", MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.", MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.", }); export class ModulePackageVerificationError extends Error { readonly code: ModulePackageVerificationErrorCode; /** Stable discovery issue codes, when module validation failed; never their messages. */ readonly issueCodes: readonly string[]; constructor(code: ModulePackageVerificationErrorCode, issueCodes: readonly string[] = []) { super(messages[code]); this.name = "ModulePackageVerificationError"; this.code = code; this.issueCodes = Object.freeze([...new Set(issueCodes)].sort()); this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } /** What this platform is, for the checks that compare a package against it. */ export interface ModulePackagePlatform { readonly platformVersion: string; readonly moduleSdkVersion: string; /** The transitional database runtime facts this platform requires a package to agree with. */ readonly databaseRuntime: Readonly>; /** Defaults to the running process's Node.js version. */ readonly nodeVersion?: string; /** Defaults to this process's own host (`currentModulePackageHost`). */ readonly host?: ModulePackageHost; } export interface VerifyModulePackageOptions { readonly archivePath: string; /** When given, the archive's SHA-256 must equal it (lowercase hexadecimal). */ readonly expectedSha256?: string; readonly platform: ModulePackagePlatform; /** A directory that must not exist yet; the verified module directory is written there. */ readonly extractTo?: string; /** * Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`. * A hardened caller whose system temporary directory is `noexec` must provide a dedicated * executable scratch mount; each verification creates and removes its own child directory. */ readonly executionScratchDirectory?: string; readonly loadTimeoutMs?: number; } export interface ModulePackageVerificationResult { readonly archiveSha256: string; /** Payload files, the metadata file not included. */ readonly fileCount: number; readonly moduleId: string; readonly target: string; readonly version: string; } function fail(code: ModulePackageVerificationErrorCode, issueCodes?: readonly string[]): never { throw new ModulePackageVerificationError(code, issueCodes); } function sha256(data: Uint8Array): string { return createHash("sha256").update(data).digest("hex"); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseJson(data: Buffer | undefined): unknown { if (data === undefined) { return undefined; } try { return JSON.parse(data.toString("utf8")) as unknown; } catch { return undefined; } } /** The transitional facts this verifier understands, and nothing else (ADR 0049). */ const UNDERSTOOD_TRANSITIONAL: Readonly> = Object.freeze({ databaseRuntime: Object.freeze(["drizzle-orm"]), }); function checkTransitional(raw: Record): void { const transitional = raw.transitional; if (transitional === undefined) { return; } if (!isRecord(transitional)) { fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"); } for (const [key, value] of Object.entries(transitional)) { const understood = UNDERSTOOD_TRANSITIONAL[key]; if (!understood || !isRecord(value)) { fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"); } if (Object.keys(value).some((name) => !understood.includes(name))) { fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"); } } } function readMetadata(files: ReadonlyMap): ModulePackageMetadata { const raw = parseJson(files.get(MODULE_PACKAGE_METADATA_FILENAME)); if (!files.has(MODULE_PACKAGE_METADATA_FILENAME)) { fail("MODULE_PACKAGE_METADATA_MISSING"); } if (!isRecord(raw)) { fail("MODULE_PACKAGE_METADATA_INVALID"); } if (raw.packageFormat !== MODULE_PACKAGE_FORMAT) { fail("MODULE_PACKAGE_FORMAT_UNSUPPORTED"); } checkTransitional(raw); const parsed = safeParseModulePackageMetadata(raw); if (!parsed.success) { fail("MODULE_PACKAGE_METADATA_INVALID"); } return parsed.data; } function checkPayload(metadata: ModulePackageMetadata, files: ReadonlyMap): void { const listed = new Set(metadata.files.map((file) => file.path)); for (const filePath of files.keys()) { if (filePath !== MODULE_PACKAGE_METADATA_FILENAME && !listed.has(filePath)) { fail("MODULE_PACKAGE_FILE_UNLISTED"); } } for (const file of metadata.files) { const data = files.get(file.path); if (data === undefined) { fail("MODULE_PACKAGE_FILE_MISSING"); } if (data.length !== file.size || sha256(data) !== file.sha256) { fail("MODULE_PACKAGE_FILE_MISMATCH"); } } } function checkIdentity( archive: ModulePackageArchive, metadata: ModulePackageMetadata, files: ReadonlyMap, ): ModuleManifest { const runtimePackage = safeParseModulePackageRuntimePackage(parseJson(files.get("package.json"))); if (!runtimePackage.success) { fail("MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"); } const manifest = safeParseModuleManifest(parseJson(files.get(MODULE_MANIFEST_FILENAME))); if (!manifest.success) { fail("MODULE_PACKAGE_MANIFEST_INVALID"); } if ( archive.topLevel !== metadata.module.id || manifest.data.id !== metadata.module.id || manifest.data.version !== metadata.module.version || runtimePackage.data.version !== metadata.module.version ) { fail("MODULE_PACKAGE_IDENTITY_MISMATCH"); } return manifest.data; } function checkCompatibility( metadata: ModulePackageMetadata, manifest: ModuleManifest, files: ReadonlyMap, platform: ModulePackagePlatform, ): void { if (metadata.target === MODULE_PACKAGE_ANY_TARGET) { // An any-target package bundles everything: no addon, no ELF object, no node_modules. for (const [filePath, data] of files) { if ( filePath.toLowerCase().endsWith(".node") || filePath.startsWith("node_modules/") || isElf(data) ) { fail("MODULE_PACKAGE_NATIVE_FILE"); } } } else { checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost()); } if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) { fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"); } const sdk = metadata.incorporates["@ekklesia/module-sdk"]; if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) { fail("MODULE_PACKAGE_SDK_INCOMPATIBLE"); } if (manifest.database?.abi === 1) { if (metadata.transitional?.databaseRuntime !== undefined) { fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"); } return; } // Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own // Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their // bundled ORM is a private implementation detail and is never compared with Core's. for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) { if (platform.databaseRuntime[runtime] !== version) { fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"); } } } function isElf(data: Uint8Array): boolean { return ( data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46 ); } /** `2.41` → `2.41.0`, for comparison against a semantic-version range. */ function asSemver(version: string): string { const parts = version.split("."); return [...parts, "0", "0"].slice(0, 3).join("."); } /** * A native target (ADR 0049): accepted only for a target this platform implements, on a host that * provably matches it. Platform, CPU, C library family and minimum version, and the Node-API version * or ABI the addons need are all checked; a fact the host cannot establish fails closed. Every ELF * object in the package must be built for the target's machine, and every `.node` addon must be one. */ function checkNativeTarget( metadata: ModulePackageMetadata, files: ReadonlyMap, host: ModulePackageHost, ): void { const target = MODULE_PACKAGE_NATIVE_TARGETS.get(metadata.target); if ( target === undefined || host.platform !== target.platform || host.arch !== target.arch || host.libc?.family !== target.libc ) { fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE"); } const required = metadata.runtime.libcVersion; if ( required === undefined || host.libc === undefined || !isVersionCompatible(asSemver(host.libc.version), required) ) { fail("MODULE_PACKAGE_LIBC_INCOMPATIBLE"); } const { nodeAbi, nodeApi } = metadata.runtime; if ( (nodeApi === undefined && nodeAbi === undefined) || (nodeApi !== undefined && (host.nodeApi === undefined || host.nodeApi < nodeApi)) || (nodeAbi !== undefined && host.nodeAbi !== nodeAbi) ) { fail("MODULE_PACKAGE_NODE_API_INCOMPATIBLE"); } for (const [filePath, data] of files) { const addon = filePath.toLowerCase().endsWith(".node"); if (!isElf(data)) { if (addon) { fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"); } continue; } // ELF64, little-endian, for the target's machine (e_machine at offset 18). if ( data.length < 20 || data[4] !== 2 || data[5] !== 1 || data.readUInt16LE(18) !== target.elfMachine ) { fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"); } } } /** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist. * Safe by construction: every path was validated as relative and non-escaping, and only directories * and regular files exist. Ownership is the writing process's, never the archive's. */ async function writeTree(root: string, archive: ModulePackageArchive): Promise { await mkdir(root, { mode: 0o755 }); await chmod(root, 0o755); for (const entry of archive.entries) { const target = path.join(root, ...entry.path.split("/")); if (entry.type === "directory") { await mkdir(target, { mode: 0o755, recursive: true }); await chmod(target, 0o755); } else { await mkdir(path.dirname(target), { mode: 0o755, recursive: true }); await writeFile(target, entry.data, { flag: "wx", mode: 0o644 }); await chmod(target, 0o644); } } } function probeEntrypointPath(): string { // The probe sits beside this file: compiled `.js` in an installation, source `.ts` under a test // runner, which Node runs directly. Either way it is a separate process. const self = fileURLToPath(import.meta.url); return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`); } function decodedMountPath(value: string): string { return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)), ); } /** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A * verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather * than invoked as files. On systems without mountinfo, the load remains the final authority. */ async function isNoExecMount(directory: string): Promise { if (process.platform !== "linux") return false; let mountinfo: string; let canonicalDirectory: string; try { [mountinfo, canonicalDirectory] = await Promise.all([ readFile("/proc/self/mountinfo", "utf8"), realpath(directory), ]); } catch { return false; } const mounts = mountinfo .split("\n") .map((line) => line.split(" ")) .filter((fields) => fields.length > 6 && fields.includes("-")) .map((fields) => ({ mountPoint: decodedMountPath(fields[4] ?? ""), options: (fields[5] ?? "").split(","), })) .filter( ({ mountPoint }) => canonicalDirectory === mountPoint || canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`), ) .sort((left, right) => right.mountPoint.length - left.mountPoint.length); return mounts[0]?.options.includes("noexec") ?? false; } async function createExecutionScratch(baseDirectory: string): Promise { if (!path.isAbsolute(baseDirectory)) { fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE"); } try { if (await isNoExecMount(baseDirectory)) { fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE"); } return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-")); } catch (error) { if (error instanceof ModulePackageVerificationError) throw error; fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE"); } } /** Loads every backend entrypoint in a short-lived child with an empty environment, and reports * only whether each loaded and exported a definition naming this module. */ async function loadEntrypoints( manifest: Pick, entrypoints: Readonly>, timeoutMs: number, executionDirectory: string, ): Promise { const outcome = await new Promise<"ok" | "failed">((resolve) => { let settled = false; let child: ReturnType | undefined; let timer: ReturnType | undefined; const finish = (value: "ok" | "failed"): void => { if (!settled) { settled = true; clearTimeout(timer); child?.kill("SIGKILL"); resolve(value); } }; try { child = fork( probeEntrypointPath(), [ JSON.stringify({ ...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}), entrypoints, moduleId: manifest.id, version: manifest.version, }), ], { cwd: executionDirectory, env: {}, execArgv: [], serialization: "json", stdio: ["ignore", "ignore", "ignore", "ipc"], }, ); } catch { finish("failed"); return; } timer = setTimeout(() => finish("failed"), timeoutMs); child.on("message", (message: unknown) => { const failures = isRecord(message) ? message.failures : undefined; finish(Array.isArray(failures) && failures.length === 0 ? "ok" : "failed"); }); child.on("error", () => finish("failed")); child.on("exit", () => finish("failed")); }); if (outcome !== "ok") { fail("MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"); } } function discoveryIssueCodes(error: unknown): readonly string[] { if ( error instanceof ModuleDiscoveryError || error instanceof ModuleMigrationDiscoveryError || error instanceof ModuleSchemaDiscoveryError ) { return error.issues.map((issue) => issue.code); } return []; } export async function verifyModulePackage( options: VerifyModulePackageOptions, ): Promise { let compressed: Buffer; try { compressed = await readFile(options.archivePath); } catch { fail("MODULE_PACKAGE_UNREADABLE"); } const archiveSha256 = sha256(compressed); if (options.expectedSha256 !== undefined && options.expectedSha256 !== archiveSha256) { fail("MODULE_PACKAGE_DIGEST_MISMATCH"); } let archive: ModulePackageArchive; try { archive = readModulePackageArchive(compressed); } catch (error) { if (error instanceof ModulePackageArchiveError) { fail(error.code); } fail("MODULE_PACKAGE_UNREADABLE"); } const files = new Map( archive.entries .filter((entry) => entry.type === "file") .map((entry) => [entry.path, entry.data] as const), ); const metadata = readMetadata(files); checkPayload(metadata, files); const manifest = checkIdentity(archive, metadata, files); checkCompatibility(metadata, manifest, files, options.platform); const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir()); try { // The candidate alone, in a scratch inventory of its own: never module storage. const modulesDirectory = path.join(scratch, "candidates"); await mkdir(modulesDirectory, { mode: 0o755 }); await writeTree(path.join(modulesDirectory, metadata.module.id), archive); const discoveryOptions = { moduleSdkVersion: options.platform.moduleSdkVersion, modulesDirectory, modulesDirectoryRequired: true, platformVersion: options.platform.platformVersion, } as const; let entrypoints: Record; try { // The same static validation the platform runs, against this package alone. A module that // requires another module is validated with its dependencies when it is installed. const [discovered] = await discoverModules(discoveryOptions); if (discovered === undefined) { fail("MODULE_PACKAGE_MODULE_INVALID"); } await discoverModuleMigrations(discoveryOptions); entrypoints = { server: discovered.entrypoints.server, ...(discovered.entrypoints.schema ? { schema: discovered.entrypoints.schema } : {}), ...(discovered.entrypoints.jobs ? { jobs: discovered.entrypoints.jobs } : {}), }; } catch (error) { if (error instanceof ModulePackageVerificationError) { throw error; } fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error)); } // Executed only now, and first in isolation: a schema is imported in this process below only // after the probe proved it loads on its own. await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch); try { await discoverModuleSchemas(discoveryOptions); } catch (error) { fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error)); } if (options.extractTo !== undefined) { const extractTo = options.extractTo; let created = false; try { await mkdir(extractTo, { mode: 0o755 }); created = true; await rm(extractTo, { recursive: true }); await writeTree(extractTo, archive); } catch { // Never leave a partial module directory behind; never remove one this call did not create. if (created) { await rm(extractTo, { force: true, recursive: true }).catch(() => undefined); } fail("MODULE_PACKAGE_EXTRACTION_FAILED"); } } } finally { await rm(scratch, { force: true, recursive: true }); } return Object.freeze({ archiveSha256, fileCount: metadata.files.length, moduleId: metadata.module.id, target: metadata.target, version: metadata.module.version, }); }