feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s

A separately versioned module is now distributed as one deterministic
.tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by
Core before anything is installed. This slice implements the portable `any`
target, with the module template as the end-to-end proof. ADR 0049 is
accepted.

@ekklesia/contracts: the Module Package v1 contract. The strict
ekklesia.package.json schema (packageFormat 1, identity, target, runtime,
provenance, incorporated Ekklesia packages, strict transitional facts, an
exact byte-wise-sorted file list with size and SHA-256), target names, and the
generated runtime package.json.

@ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir>
--target any --out <dir> --source-revision <commit>`.
- Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle
  included; only Node built-ins remain imports. Any warning, unresolved
  import, external dependency, or native or platform-specific package fails.
- Copies browser artifacts as built, after proving them current, and the
  migrations; generates package.json and ekklesia.package.json; never ships
  src, tests, declarations, source maps, or workspace ranges.
- THIRD_PARTY_NOTICES: every third-party package traced from the bundles'
  own inputs, credited once with its declared licence and every licence,
  copying, copyright, and notice file it ships. A missing licence fails; a
  missing text fails unless its standard text is complete without copyright
  lines (Apache-2.0, vendored, needed by drizzle-orm).
- Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime
  0, 0755/0644, no links. Byte-identical across runs and machines.
- Writes only the archive and its .sha256; never deploys, links, or installs,
  so the workspace's install state is untouched.

Core: verifyModulePackage and `verify-module-package.js <archive>
[--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in
memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe
or escaping paths, and a second top-level directory; checks metadata, every
file, identity, target, Node.js range, SDK, and the transitional Drizzle
agreement; runs the platform's module and migration discovery; loads the
backend once in a forked process with an empty environment; only then
extracts, with normalized modes, into a new directory. Errors are sanitized.

@ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs
and whether the built file is current. The nested stylesheet build now works
from the module directory, so artifacts no longer depend on the caller's
working directory.

The split deploy smoke builds the package with the canonical packager
(scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm
deploy packaging), verifies it with the same generic image, installs the
verified tree, removes it, and reinstalls the exact same archive.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 12:44:54 -05:00
co-authored by Claude Opus 5.5
parent 45d6506c10
commit 35ef31df9f
45 changed files with 4876 additions and 151 deletions
+175
View File
@@ -0,0 +1,175 @@
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 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();
}
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));
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,
...(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;
});
}
+1
View File
@@ -8,6 +8,7 @@ export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js";
export * from "./migration-discovery.js";
export * from "./modules-directory.js";
export * from "./module-package-verification.js";
export * from "./permissions.js";
export * from "./realtime.js";
export * from "./runtime.js";
+203
View File
@@ -0,0 +1,203 @@
import { gunzipSync } from "node:zlib";
/**
* Reads an Ekklesia Module Package v1 archive (ADR 0049) entirely in memory and enforces the
* archive-safety rules before anything is written anywhere.
*
* Deliberately a strict reader rather than a general tar implementation: v1 packages hold only
* POSIX ustar directories and regular files, so every other entry type is refused instead of
* interpreted. Nothing here touches the filesystem; extraction happens only after the caller has
* verified the whole package.
*/
export type ModulePackageArchiveErrorCode =
| "MODULE_PACKAGE_LAYOUT_INVALID"
| "MODULE_PACKAGE_LINK"
| "MODULE_PACKAGE_SPECIAL_ENTRY"
| "MODULE_PACKAGE_TOO_LARGE"
| "MODULE_PACKAGE_UNREADABLE"
| "MODULE_PACKAGE_UNSAFE_MODE"
| "MODULE_PACKAGE_UNSAFE_PATH";
export class ModulePackageArchiveError extends Error {
readonly code: ModulePackageArchiveErrorCode;
constructor(code: ModulePackageArchiveErrorCode) {
super(code);
this.name = "ModulePackageArchiveError";
this.code = code;
}
}
/** Generous for a module, small enough that a hostile archive cannot exhaust the host. */
export const MODULE_PACKAGE_MAX_COMPRESSED_BYTES = 256 * 1024 * 1024;
export const MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024;
export const MODULE_PACKAGE_MAX_ENTRIES = 100_000;
export interface ModulePackageArchiveEntry {
/** Relative to the top-level module directory; empty for that directory itself. */
readonly path: string;
readonly type: "directory" | "file";
readonly data: Buffer;
}
export interface ModulePackageArchive {
/** The single top-level directory's name. Validated as a module id by the caller. */
readonly topLevel: string;
/** Every entry except the top-level directory itself, in archive order. */
readonly entries: readonly ModulePackageArchiveEntry[];
}
const BLOCK = 512;
function field(block: Buffer, offset: number, length: number): string {
const raw = block.subarray(offset, offset + length);
const end = raw.indexOf(0);
return raw.subarray(0, end === -1 ? length : end).toString("latin1");
}
function octalField(block: Buffer, offset: number, length: number): number {
const text = field(block, offset, length).trim();
if (!/^[0-7]+$/u.test(text)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
const value = Number.parseInt(text, 8);
if (!Number.isSafeInteger(value)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
return value;
}
function headerChecksumValid(block: Buffer): boolean {
const declared = octalField(block, 148, 8);
let sum = 0;
for (let index = 0; index < BLOCK; index += 1) {
sum += index >= 148 && index < 156 ? 0x20 : (block[index] ?? 0);
}
return sum === declared;
}
/** Printable ASCII, no backslash, relative, and no empty, `.`, or `..` segment. */
function safeSegments(value: string): readonly string[] {
if (
value.length === 0 ||
!/^[\x20-\x7e]+$/u.test(value) ||
value.includes("\\") ||
value.startsWith("/")
) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH");
}
const segments = value.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH");
}
return segments;
}
export function readModulePackageArchive(compressed: Buffer): ModulePackageArchive {
if (compressed.length > MODULE_PACKAGE_MAX_COMPRESSED_BYTES) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE");
}
let raw: Buffer;
try {
raw = gunzipSync(compressed, { maxOutputLength: MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES });
} catch (error) {
throw new ModulePackageArchiveError(
error instanceof RangeError ? "MODULE_PACKAGE_TOO_LARGE" : "MODULE_PACKAGE_UNREADABLE",
);
}
const entries: ModulePackageArchiveEntry[] = [];
const kinds = new Map<string, "directory" | "file">();
/** Every path that is an ancestor of some entry, so a file can never claim one. */
const ancestors = new Set<string>();
let topLevel: string | undefined;
let offset = 0;
let ended = false;
while (offset + BLOCK <= raw.length) {
const block = raw.subarray(offset, offset + BLOCK);
if (block.every((byte) => byte === 0)) {
ended = true;
break;
}
if (field(block, 257, 6) !== "ustar" || field(block, 263, 2) !== "00") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
if (!headerChecksumValid(block)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
if (entries.length + 1 > MODULE_PACKAGE_MAX_ENTRIES) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE");
}
const typeflag = field(block, 156, 1);
if (typeflag === "1" || typeflag === "2") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LINK");
}
const type =
typeflag === "5" ? "directory" : typeflag === "0" || typeflag === "" ? "file" : undefined;
if (type === undefined) {
// Devices, FIFOs, contiguous files, and every extended or long-name header: v1 packages hold
// directories and regular files, so nothing else is interpreted.
throw new ModulePackageArchiveError("MODULE_PACKAGE_SPECIAL_ENTRY");
}
const mode = octalField(block, 100, 8);
if ((mode & 0o7000) !== 0) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_MODE");
}
const name = field(block, 0, 100);
const prefix = field(block, 345, 155);
let fullPath = prefix === "" ? name : `${prefix}/${name}`;
if (type === "directory" && fullPath.endsWith("/")) {
fullPath = fullPath.slice(0, -1);
}
const segments = safeSegments(fullPath);
const top = segments[0] ?? "";
if (topLevel === undefined) {
topLevel = top;
} else if (top !== topLevel) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
const size = type === "file" ? octalField(block, 124, 12) : 0;
const dataStart = offset + BLOCK;
const dataEnd = dataStart + size;
if (dataEnd > raw.length) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
const relative = segments.slice(1).join("/");
if (relative === "") {
if (type !== "directory") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
} else {
if (kinds.has(relative)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
// No entry may sit below a file, and no file may claim a path something already sits below.
// Linear in the total number of path segments, whatever order the entries arrive in.
if (type === "file" && ancestors.has(relative)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
for (let index = 1; index < segments.length - 1; index += 1) {
const ancestor = segments.slice(1, index + 1).join("/");
if (kinds.get(ancestor) === "file") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
ancestors.add(ancestor);
}
kinds.set(relative, type);
entries.push({ data: Buffer.from(raw.subarray(dataStart, dataEnd)), path: relative, type });
}
offset = dataStart + Math.ceil(size / BLOCK) * BLOCK;
}
if (!ended || topLevel === undefined) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
return Object.freeze({ entries: Object.freeze(entries), topLevel });
}
+89
View File
@@ -0,0 +1,89 @@
// The isolated, short-lived validation process of Module Package v1 verification (ADR 0049).
//
// The verifier forks this file with an empty environment and one JSON argument naming a candidate
// module's backend entrypoints. It imports each one, so a package whose code cannot load, or whose
// bundle still reaches for a dependency it does not contain, is refused before it is ever installed.
// Whatever the module's top-level code does happens here, in a process with no database URL, no
// secret, and no platform state, and that is discarded as soon as it reports.
//
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
// It carries fixed role and reason names only: never an error message, a path, or module output.
//
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
// in tests as well as the compiled file in the image.
import { pathToFileURL } from "node:url";
import { parseModuleRuntimeDefinition, parseModuleSchemaDefinition } from "@ekklesia/contracts";
interface ProbeRequest {
readonly moduleId: string;
readonly version: string;
readonly entrypoints: Readonly<Record<string, string>>;
}
interface ProbeFailure {
readonly role: string;
readonly reason: "export" | "identity" | "load";
}
function defaultExport(namespace: unknown): unknown {
return typeof namespace === "object" && namespace !== null && "default" in namespace
? (namespace as { default: unknown }).default
: undefined;
}
async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
const failures: ProbeFailure[] = [];
for (const role of Object.keys(request.entrypoints).sort()) {
const entrypoint = request.entrypoints[role];
if (entrypoint === undefined) {
continue;
}
let namespace: unknown;
try {
namespace = await import(pathToFileURL(entrypoint).href);
} catch {
failures.push({ reason: "load", role });
continue;
}
try {
if (role === "server") {
const definition = parseModuleRuntimeDefinition(defaultExport(namespace));
if (definition.id !== request.moduleId || definition.version !== request.version) {
failures.push({ reason: "identity", role });
}
} else if (role === "schema") {
const definition = parseModuleSchemaDefinition(defaultExport(namespace));
if (definition.moduleId !== request.moduleId) {
failures.push({ reason: "identity", role });
}
}
} catch {
failures.push({ reason: "export", role });
}
}
return failures;
}
function report(message: unknown): void {
if (typeof process.send !== "function") {
process.exitCode = 2;
return;
}
process.send(message, () => {
// A module may leave timers or handles behind; the probe's work is done either way.
process.exit(0);
});
}
let request: ProbeRequest | undefined;
try {
request = JSON.parse(process.argv[2] ?? "") as ProbeRequest;
} catch {
report({ failures: [{ reason: "load", role: "request" }] });
}
if (request !== undefined) {
probe(request).then(
(failures) => report({ failures }),
() => report({ failures: [{ reason: "load", role: "probe" }] }),
);
}
@@ -0,0 +1,479 @@
import { fork } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
isVersionCompatible,
MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_METADATA_FILENAME,
type ModuleManifest,
type ModulePackageMetadata,
safeParseModuleManifest,
safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage,
} from "@ekklesia/contracts";
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.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_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_MANIFEST_INVALID"
| "MODULE_PACKAGE_METADATA_INVALID"
| "MODULE_PACKAGE_METADATA_MISSING"
| "MODULE_PACKAGE_MODULE_INVALID"
| "MODULE_PACKAGE_NATIVE_FILE"
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
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_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_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.",
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_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;
}
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;
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 {
// This verifier implements the portable target only. Native targets are a later slice.
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
}
if ([...files.keys()].some((filePath) => filePath.toLowerCase().endsWith(".node"))) {
fail("MODULE_PACKAGE_NATIVE_FILE");
}
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");
}
// Transitional (ADR 0049): today a module's Drizzle copy must be interchangeable with the
// platform's, because Core queries the module's tables with its own Drizzle instance. This is an
// implementation constraint pending a Module Database ABI, not that ABI.
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
if (platform.databaseRuntime[runtime] !== version) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_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)}`);
}
/** 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(
moduleId: string,
version: string,
entrypoints: Readonly<Record<string, string>>,
timeoutMs: number,
): 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({ entrypoints, moduleId, version })], {
cwd: tmpdir(),
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 mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
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(
metadata.module.id,
metadata.module.version,
entrypoints,
options.loadTimeoutMs ?? 30_000,
);
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,
});
}
@@ -0,0 +1,387 @@
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
type ModulePackageVerificationErrorCode,
ModulePackageVerificationError,
verifyModulePackage,
} from "../src/modules/index.js";
import {
type FixtureEntry,
modulePackageFixture,
type PackageFixtureOptions,
sha256,
} from "./support/module-package-fixture.js";
const platform = {
databaseRuntime: { "drizzle-orm": "0.45.2" },
moduleSdkVersion: "0.1.2",
platformVersion: "0.1.2",
} as const;
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
async function scratch(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-"));
temporaryDirectories.push(directory);
return directory;
}
async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
readonly archivePath: string;
readonly sha256: string;
}> {
const fixture = await modulePackageFixture(options);
const archivePath = path.join(await scratch(), "private-host-name-5e1d.tar.gz");
await writeFile(archivePath, fixture.archive);
return { archivePath, sha256: fixture.sha256 };
}
async function rejection(
options: PackageFixtureOptions,
code: ModulePackageVerificationErrorCode,
extra: { readonly expectedSha256?: string } = {},
): Promise<ModulePackageVerificationError> {
const { archivePath } = await archiveFile(options);
try {
await verifyModulePackage({ archivePath, platform, ...extra });
} catch (error) {
expect(error).toBeInstanceOf(ModulePackageVerificationError);
const verificationError = error as ModulePackageVerificationError;
expect(verificationError.code).toBe(code);
// Sanitized: never the archive's location, a temporary directory, or text from inside it.
const rendered = JSON.stringify({ ...verificationError, message: verificationError.message });
expect(rendered).not.toContain("private-host-name-5e1d");
expect(rendered).not.toContain(tmpdir());
expect(rendered).not.toContain("attacker-chosen");
return verificationError;
}
throw new Error(`Expected ${code}.`);
}
function withEntry(entry: FixtureEntry): PackageFixtureOptions {
return { extraEntries: [entry] };
}
describe("Module Package v1 verification (ADR 0049)", () => {
it("verifies a valid any-target package, its digest, and its module", async () => {
const { archivePath, sha256: digest } = await archiveFile();
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
expect(result).toEqual({
archiveSha256: digest,
fileCount: 8,
moduleId: "example",
target: "any",
version: "0.1.0",
});
});
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
const { archivePath } = await archiveFile();
const target = path.join(await scratch(), "example");
await verifyModulePackage({ archivePath, extractTo: target, platform });
const names = (await readdir(target, { recursive: true })).sort();
expect(names).toContain("ekklesia.package.json");
expect(names).toContain("dist/server/index.js");
expect((await stat(target)).mode & 0o7777).toBe(0o755);
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
const { archivePath: broken } = await archiveFile({ runtimePackage: { name: "x" } });
const refused = path.join(await scratch(), "refused");
await expect(
verifyModulePackage({ archivePath: broken, extractTo: refused, platform }),
).rejects.toBeInstanceOf(ModulePackageVerificationError);
await expect(stat(refused)).rejects.toMatchObject({ code: "ENOENT" });
});
it("refuses to extract over an existing directory", async () => {
const { archivePath } = await archiveFile();
const existing = await scratch();
await expect(
verifyModulePackage({ archivePath, extractTo: existing, platform }),
).rejects.toMatchObject({ code: "MODULE_PACKAGE_EXTRACTION_FAILED" });
});
it("fails a wrong archive digest", async () => {
await rejection({}, "MODULE_PACKAGE_DIGEST_MISMATCH", { expectedSha256: sha256("other") });
});
it("fails a payload whose contents no longer match their digest", async () => {
await rejection(
{
tamper: (files) => {
files.set("dist/server/index.js", Buffer.from("export default {}; // attacker-chosen\n"));
},
},
"MODULE_PACKAGE_FILE_MISMATCH",
);
});
it("fails a listed file missing from the archive", async () => {
await rejection(
{ tamper: (files) => void files.delete("THIRD_PARTY_NOTICES") },
"MODULE_PACKAGE_FILE_MISSING",
);
});
it("fails an archive file that is not listed", async () => {
await rejection(
withEntry({ path: "example/attacker-chosen.js", data: "extra" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
});
it("fails when the manifest and the package disagree about the version", async () => {
await rejection(
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
"MODULE_PACKAGE_IDENTITY_MISMATCH",
);
});
it("fails when the metadata names another module", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, module: { id: "other", version: "0.1.0" } }) },
"MODULE_PACKAGE_IDENTITY_MISMATCH",
);
});
it("fails a runtime package.json that is not the generated shape", async () => {
await rejection(
{
runtimePackage: {
name: "@test/module-example",
version: "0.1.0",
private: true,
type: "module",
ekklesia: { layer: "module" },
dependencies: { "@ekklesia/module-sdk": "workspace:*" },
},
},
"MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID",
);
});
it("fails an unsupported package format", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, packageFormat: 2 }) },
"MODULE_PACKAGE_FORMAT_UNSUPPORTED",
);
});
it("fails transitional metadata it does not understand", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, transitional: { orm: { prisma: "6.0.0" } } }) },
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2", prisma: "6.0.0" } },
}),
},
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
);
});
it("enforces today's database runtime agreement, and accepts it when it holds", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.44.0" } },
}),
},
"MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE",
);
const { archivePath } = await archiveFile({
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
}),
});
await expect(verifyModulePackage({ archivePath, platform })).resolves.toMatchObject({
moduleId: "example",
});
});
it("fails a target this platform cannot run", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
target: "linux-arm64-musl",
runtime: { node: ">=20", nodeApi: 9 },
}),
},
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
});
it("fails a native file inside an any-target package", async () => {
const native = await modulePackageFixture({
tamper: (files) => {
files.set("dist/addon.node", Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
},
metadata: (metadata) => {
const files = [
...(metadata.files as { path: string; size: number; sha256: string }[]),
{
path: "dist/addon.node",
size: 4,
sha256: sha256(Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
},
].sort((left, right) => (left.path < right.path ? -1 : 1));
return { ...metadata, files };
},
});
const archivePath = path.join(await scratch(), "native.tar.gz");
await writeFile(archivePath, native.archive);
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_NATIVE_FILE",
});
});
it("fails a package that needs a newer Node.js", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, runtime: { node: ">=99" } }) },
"MODULE_PACKAGE_RUNTIME_INCOMPATIBLE",
);
});
it("fails an incorporated SDK outside the manifest's own range", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
incorporates: { "@ekklesia/module-sdk": "0.9.0" },
}),
},
"MODULE_PACKAGE_SDK_INCOMPATIBLE",
);
});
it("fails a module whose declared entrypoint is missing", async () => {
await rejection(
{
manifest: (manifest) => ({
...manifest,
version: "0.1.0",
entrypoints: {
...(manifest.entrypoints as Record<string, unknown>),
schema: "./dist/schema/index.js",
},
}),
},
"MODULE_PACKAGE_MODULE_INVALID",
);
});
it("fails a backend that imports a dependency the package does not contain", async () => {
const error = await rejection(
{
serverSource:
'import pad from "ekklesia-dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0", register() { pad(); } };\n',
},
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
expect(error.message).not.toContain("ekklesia-dependency-not-in-this-package");
});
it("fails a backend whose default export names another module", async () => {
await rejection(
{ serverSource: 'export default { id: "other", version: "0.1.0" };\n' },
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
});
it.each([
["an absolute path", { path: "/etc/attacker-chosen", data: "x" }, "MODULE_PACKAGE_UNSAFE_PATH"],
[
"a .. segment",
{ path: "example/../attacker-chosen", data: "x" },
"MODULE_PACKAGE_UNSAFE_PATH",
],
[
"a symbolic link",
{ path: "example/link", type: "symlink", linkname: "/etc/passwd" },
"MODULE_PACKAGE_LINK",
],
[
"a hard link",
{ path: "example/hard", type: "hardlink", linkname: "example/package.json" },
"MODULE_PACKAGE_LINK",
],
[
"a device node",
{ path: "example/device", type: "character" },
"MODULE_PACKAGE_SPECIAL_ENTRY",
],
["a FIFO", { path: "example/fifo", type: "fifo" }, "MODULE_PACKAGE_SPECIAL_ENTRY"],
[
"a setuid file",
{ path: "example/setuid", data: "x", mode: 0o4755 },
"MODULE_PACKAGE_UNSAFE_MODE",
],
[
"a setgid directory",
{ path: "example/setgid", type: "directory", mode: 0o2755 },
"MODULE_PACKAGE_UNSAFE_MODE",
],
[
"a second top-level directory",
{ path: "attacker-chosen/file", data: "x" },
"MODULE_PACKAGE_LAYOUT_INVALID",
],
[
"a duplicate entry",
{ path: "example/package.json", data: "{}" },
"MODULE_PACKAGE_LAYOUT_INVALID",
],
] as const)("fails %s", async (_label, entry, code) => {
await rejection(withEntry(entry as FixtureEntry), code);
});
it("fails a file that is not a gzip-compressed tar archive", async () => {
const archivePath = path.join(await scratch(), "plain.tar.gz");
await writeFile(archivePath, "not an archive");
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_UNREADABLE",
});
});
it("fails an archive without package metadata", async () => {
const fixture = await modulePackageFixture();
const { gunzipSync, gzipSync } = await import("node:zlib");
// Rebuild without the metadata entry by filtering the fixture through a fresh archive.
const archivePath = path.join(await scratch(), "no-metadata.tar.gz");
const raw = gunzipSync(fixture.archive);
const blocks: Buffer[] = [];
for (let offset = 0; offset + 512 <= raw.length; ) {
const headerBlock = raw.subarray(offset, offset + 512);
if (headerBlock.every((byte) => byte === 0)) {
break;
}
const name = headerBlock.subarray(0, 100).toString("latin1").replace(/\0.*$/su, "");
const size = Number.parseInt(headerBlock.subarray(124, 136).toString("latin1"), 8);
const length = 512 + Math.ceil(size / 512) * 512;
if (!name.endsWith("ekklesia.package.json")) {
blocks.push(raw.subarray(offset, offset + length));
}
offset += length;
}
await writeFile(archivePath, gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])));
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_METADATA_MISSING",
});
});
});
@@ -0,0 +1,205 @@
import { createHash } from "node:crypto";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { gzipSync } from "node:zlib";
import { createMigrationModule } from "./migration-module-fixture.js";
/**
* Test-only builders for Ekklesia Module Package v1 archives (ADR 0049). They write ustar archives
* by hand so a test can produce exactly what the canonical packager never would: links, device
* nodes, traversal, a second top-level directory, a corrupted payload.
*/
export type FixtureEntryType = "file" | "directory" | "symlink" | "hardlink" | "character" | "fifo";
export interface FixtureEntry {
readonly path: string;
readonly type?: FixtureEntryType;
readonly mode?: number;
readonly data?: Uint8Array | string;
readonly linkname?: string;
}
const typeflags: Readonly<Record<FixtureEntryType, string>> = {
character: "3",
directory: "5",
fifo: "6",
file: "0",
hardlink: "1",
symlink: "2",
};
function octal(value: number, width: number): string {
return `${value.toString(8).padStart(width - 1, "0")}\u0000`;
}
function header(entry: FixtureEntry, size: number): Buffer {
const block = Buffer.alloc(512);
const name =
entry.type === "directory" && !entry.path.endsWith("/") ? `${entry.path}/` : entry.path;
let prefix = "";
let base = name;
if (Buffer.byteLength(name) > 100) {
const split = name.lastIndexOf("/", name.length - 2);
prefix = name.slice(0, split);
base = name.slice(split + 1);
}
block.write(base, 0, 100, "latin1");
block.write(octal(entry.mode ?? (entry.type === "directory" ? 0o755 : 0o644), 8), 100, "latin1");
block.write(octal(0, 8), 108, "latin1");
block.write(octal(0, 8), 116, "latin1");
block.write(octal(size, 12), 124, "latin1");
block.write(octal(0, 12), 136, "latin1");
block.write(" ", 148, "latin1");
block.write(typeflags[entry.type ?? "file"], 156, "latin1");
block.write(entry.linkname ?? "", 157, 100, "latin1");
block.write("ustar\u000000", 257, "latin1");
block.write(prefix, 345, 155, "latin1");
let checksum = 0;
for (const byte of block) {
checksum += byte;
}
block.write(`${checksum.toString(8).padStart(6, "0")}\u0000 `, 148, "latin1");
return block;
}
/** A gzip-compressed ustar archive of exactly these entries, in this order. */
export function tarGz(entries: readonly FixtureEntry[]): Buffer {
const parts: Buffer[] = [];
for (const entry of entries) {
const data =
entry.type === undefined || entry.type === "file"
? Buffer.from(entry.data ?? "")
: Buffer.alloc(0);
parts.push(header(entry, data.length), data, Buffer.alloc((512 - (data.length % 512)) % 512));
}
parts.push(Buffer.alloc(1024));
return gzipSync(Buffer.concat(parts), { level: 9 });
}
export function sha256(data: Uint8Array | string): string {
return createHash("sha256").update(data).digest("hex");
}
export interface PackageFixtureOptions {
readonly id?: string;
readonly version?: string;
readonly serverSource?: string;
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
readonly runtimePackage?: Record<string, unknown>;
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
/** Changes the payload after its digests were computed. */
readonly tamper?: (files: Map<string, Buffer>) => void;
/** Entries appended after the package's own, under any path. */
readonly extraEntries?: readonly FixtureEntry[];
}
export interface PackageFixture {
readonly archive: Buffer;
readonly sha256: string;
}
async function readTree(root: string): Promise<Map<string, Buffer>> {
const files = new Map<string, Buffer>();
for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) {
if (entry.isFile()) {
const absolute = path.join(entry.parentPath, entry.name);
files.set(path.relative(root, absolute).split(path.sep).join("/"), await readFile(absolute));
}
}
return files;
}
/**
* A valid any-target package of a minimal module: a server stub with no imports, one migration, a
* notices file, the generated runtime package.json, and complete metadata. Every option changes one
* thing, so a test can break exactly the rule it is about.
*/
export async function modulePackageFixture(
options: PackageFixtureOptions = {},
): Promise<PackageFixture> {
const id = options.id ?? "example";
const version = options.version ?? "0.1.0";
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-fixture-"));
let files: Map<string, Buffer>;
try {
const created = await createMigrationModule(scratch, id, {
serverSource:
options.serverSource ??
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
});
files = await readTree(created.moduleDirectory);
} finally {
await rm(scratch, { force: true, recursive: true });
}
const manifest = JSON.parse(
files.get("ekklesia.module.json")?.toString("utf8") ?? "{}",
) as Record<string, unknown>;
files.set(
"ekklesia.module.json",
Buffer.from(
`${JSON.stringify(options.manifest ? options.manifest(manifest) : { ...manifest, version }, null, 2)}\n`,
),
);
files.set(
"package.json",
Buffer.from(
`${JSON.stringify(
options.runtimePackage ?? {
name: `@test/module-${id}`,
version,
private: true,
type: "module",
ekklesia: { layer: "module" },
},
null,
2,
)}\n`,
),
);
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
const listed = [...files.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([filePath, data]) => ({ path: filePath, size: data.length, sha256: sha256(data) }));
const baseMetadata: Record<string, unknown> = {
packageFormat: 1,
module: { id, version },
target: "any",
runtime: { node: ">=20" },
provenance: {
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
},
incorporates: { "@ekklesia/module-sdk": "0.1.2" },
files: listed,
};
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
options.tamper?.(files);
const directories = new Set<string>([id]);
for (const filePath of files.keys()) {
const segments = filePath.split("/");
for (let index = 1; index < segments.length; index += 1) {
directories.add(`${id}/${segments.slice(0, index).join("/")}`);
}
}
const all = new Map<string, FixtureEntry>();
for (const directory of directories) {
all.set(directory, { path: directory, type: "directory" });
}
for (const [filePath, data] of files) {
all.set(`${id}/${filePath}`, { path: `${id}/${filePath}`, data });
}
all.set(`${id}/ekklesia.package.json`, {
path: `${id}/ekklesia.package.json`,
data: `${JSON.stringify(metadata, null, 2)}\n`,
});
const ordered = [...all.values()].sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
const archive = tarGz([...ordered, ...(options.extraEntries ?? [])]);
return { archive, sha256: sha256(archive) };
}
@@ -0,0 +1,75 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
parseSha256File,
parseVerifyModulePackageArguments,
readModulePackagePlatform,
} from "../src/cli/verify-module-package.js";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const digest = "ab".repeat(32);
describe("verify-module-package arguments", () => {
it("takes an archive and, optionally, a digest and an extraction directory", () => {
expect(parseVerifyModulePackageArguments(["example-0.1.0-any.tar.gz"])).toEqual({
archivePath: "example-0.1.0-any.tar.gz",
});
expect(
parseVerifyModulePackageArguments([
"x.tar.gz",
"--sha256",
digest.toUpperCase(),
"--extract-to",
"/staging/example",
]),
).toEqual({ archivePath: "x.tar.gz", expectedSha256: digest, extractTo: "/staging/example" });
expect(
parseVerifyModulePackageArguments(["x.tar.gz", "--sha256-file", "x.tar.gz.sha256"]),
).toEqual({ archivePath: "x.tar.gz", sha256File: "x.tar.gz.sha256" });
});
it.each([
["no archive", []],
["two archives", ["a.tar.gz", "b.tar.gz"]],
["an unknown option", ["a.tar.gz", "--force"]],
["both digest options", ["a.tar.gz", "--sha256", digest, "--sha256-file", "a.sha256"]],
["a malformed digest", ["a.tar.gz", "--sha256", "abc"]],
["an option without its value", ["a.tar.gz", "--extract-to"]],
])("refuses %s", (_label, argv) => {
expect(() => parseVerifyModulePackageArguments(argv)).toThrow();
});
});
describe("verify-module-package digest files", () => {
it("reads the sha256sum format, ignoring the recorded file name", () => {
expect(parseSha256File(`${digest} example-0.1.0-any.tar.gz\n`)).toBe(digest);
expect(parseSha256File(`${digest.toUpperCase()} *renamed.tar.gz\n`)).toBe(digest);
});
it.each([
"",
"not-a-digest x.tar.gz\n",
`${digest}\n${digest} second\n`,
])("refuses %j", (contents) => {
expect(() => parseSha256File(contents)).toThrow();
});
});
describe("verify-module-package platform facts", () => {
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
const core = JSON.parse(
await readFile(path.join(repositoryRoot, "core", "package.json"), "utf8"),
);
const sdk = JSON.parse(
await readFile(path.join(repositoryRoot, "packages", "module-sdk", "package.json"), "utf8"),
);
await expect(readModulePackagePlatform(repositoryRoot)).resolves.toEqual({
databaseRuntime: { "drizzle-orm": core.dependencies["drizzle-orm"] },
moduleSdkVersion: sdk.version,
platformVersion: root.version,
});
});
});