fix: support native package verifier scratch
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

This commit is contained in:
ekklesia
2026-10-09 18:05:04 -05:00
parent c5a6cdaf6a
commit 4be046c8d0
12 changed files with 330 additions and 13 deletions
@@ -1,6 +1,6 @@
import { fork } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
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";
@@ -73,7 +73,8 @@ export type ModulePackageVerificationErrorCode =
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
@@ -118,6 +119,8 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
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.",
@@ -157,6 +160,12 @@ export interface VerifyModulePackageOptions {
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;
}
@@ -414,12 +423,66 @@ function probeEntrypointPath(): string {
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;
@@ -445,7 +508,7 @@ async function loadEntrypoints(
}),
],
{
cwd: tmpdir(),
cwd: executionDirectory,
env: {},
execArgv: [],
serialization: "json",
@@ -514,7 +577,7 @@ export async function verifyModulePackage(
const manifest = checkIdentity(archive, metadata, files);
checkCompatibility(metadata, manifest, files, options.platform);
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
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");
@@ -550,7 +613,7 @@ export async function verifyModulePackage(
// 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);
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
try {
await discoverModuleSchemas(discoveryOptions);
} catch (error) {