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
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user