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
651 lines
25 KiB
TypeScript
651 lines
25 KiB
TypeScript
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<Record<ModulePackageVerificationErrorCode, string>> = 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<Record<string, string>>;
|
|
/** 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<string, unknown> {
|
|
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<Record<string, readonly string[]>> = Object.freeze({
|
|
databaseRuntime: Object.freeze(["drizzle-orm"]),
|
|
});
|
|
|
|
function checkTransitional(raw: Record<string, unknown>): 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<string, Buffer>): 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<string, Buffer>): 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<string, Buffer>,
|
|
): 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<string, Buffer>,
|
|
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<string, Buffer>,
|
|
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<void> {
|
|
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<boolean> {
|
|
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<string> {
|
|
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<ModuleManifest, "database" | "id" | "version">,
|
|
entrypoints: Readonly<Record<string, string>>,
|
|
timeoutMs: number,
|
|
executionDirectory: string,
|
|
): Promise<void> {
|
|
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
|
let settled = false;
|
|
let child: ReturnType<typeof fork> | undefined;
|
|
let timer: ReturnType<typeof setTimeout> | 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<ModulePackageVerificationResult> {
|
|
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<string, string>;
|
|
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,
|
|
});
|
|
}
|