import { mkdir, 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 { currentModulePackageHost, type ModulePackageHost, ModulePackageVerificationError, type ModulePackageVerificationErrorCode, 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.2.0", platformVersion: "0.2.0", } 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 { 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 executionScratch(): Promise { const directory = path.join(await scratch(), "execution"); await mkdir(directory); return directory; } async function rejection( options: PackageFixtureOptions, code: ModulePackageVerificationErrorCode, extra: { readonly expectedSha256?: string } = {}, ): Promise { 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] }; } function abiPackage(overrides: PackageFixtureOptions = {}): PackageFixtureOptions { return { manifest: (manifest) => ({ ...manifest, database: { abi: 1 } }), schemaSource: `export default { moduleId: "example", tables: [{ name: "example_records", foreignKeys: [] }] };\n`, serverSource: 'export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };\n', ...overrides, }; } 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: 9, moduleId: "example", target: "any", version: "0.1.0", }); }); it("uses an explicit execution scratch for pure JavaScript and cleans concurrent runs", async () => { const { archivePath } = await archiveFile(); const executionScratchDirectory = await executionScratch(); const runs = Array.from({ length: 4 }, () => verifyModulePackage({ archivePath, executionScratchDirectory, platform }), ); await expect(Promise.all(runs)).resolves.toHaveLength(4); expect(await readdir(executionScratchDirectory)).toEqual([]); }); it("cleans its execution scratch after a backend load failure", async () => { const { archivePath } = await archiveFile({ serverSource: 'import "dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0" };\n', }); const executionScratchDirectory = await executionScratch(); await expect( verifyModulePackage({ archivePath, executionScratchDirectory, platform }), ).rejects.toMatchObject({ code: "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" }); expect(await readdir(executionScratchDirectory)).toEqual([]); }); it("reports a missing execution scratch as a verifier environment error", async () => { const { archivePath } = await archiveFile(); const executionScratchDirectory = path.join(await scratch(), "missing"); await expect( verifyModulePackage({ archivePath, executionScratchDirectory, platform }), ).rejects.toMatchObject({ code: "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE", message: "The verifier execution scratch directory is missing, not writable, or mounted noexec.", }); await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" }); }); it("rejects an unsafe archive before consulting the execution scratch", async () => { const { archivePath } = await archiveFile( withEntry({ path: "example/../attacker-chosen", data: "x" }), ); const executionScratchDirectory = path.join(await scratch(), "missing"); await expect( verifyModulePackage({ archivePath, executionScratchDirectory, platform }), ).rejects.toMatchObject({ code: "MODULE_PACKAGE_UNSAFE_PATH" }); await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" }); }); 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("LICENSE"); 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.each([ ["a missing license declaration", undefined], ["an empty license declaration", ""], ["UNLICENSED", "UNLICENSED"], ["a malformed SPDX expression", "MIT OR"], ])("fails package metadata with %s", async (_label, license) => { await rejection( { metadata: (metadata) => { const changed: Record = { ...metadata, license }; if (license === undefined) { delete changed.license; } return changed; }, }, "MODULE_PACKAGE_METADATA_INVALID", ); }); it("fails closed on pre-ADR-0051 metadata", async () => { await rejection( { metadata: (metadata) => { const old: Record = { ...metadata }; delete old.license; return old; }, }, "MODULE_PACKAGE_METADATA_INVALID", ); }); it("requires LICENSE at the canonical path in both payload and inventory", async () => { await rejection( { tamper: (files) => void files.delete("LICENSE") }, "MODULE_PACKAGE_FILE_MISSING", ); await rejection( { metadata: (metadata) => ({ ...metadata, files: (metadata.files as { path: string }[]).filter((file) => file.path !== "LICENSE"), }), }, "MODULE_PACKAGE_METADATA_INVALID", ); await rejection( withEntry({ path: "example/licenses/LICENSE", data: "attacker-chosen" }), "MODULE_PACKAGE_FILE_UNLISTED", ); await rejection( withEntry({ path: "example/COPYING", data: "attacker-chosen" }), "MODULE_PACKAGE_FILE_UNLISTED", ); }); it("checks the listed LICENSE bytes, size, and digest", async () => { await rejection( { tamper: (files) => files.set("LICENSE", Buffer.from("attacker-chosen")) }, "MODULE_PACKAGE_FILE_MISMATCH", ); await rejection( { metadata: (metadata) => ({ ...metadata, files: (metadata.files as { path: string; size: number; sha256: string }[]).map((file) => file.path === "LICENSE" ? { ...file, size: file.size + 1 } : file, ), }), }, "MODULE_PACKAGE_FILE_MISMATCH", ); }); it("rejects a duplicate LICENSE archive entry", async () => { await rejection( withEntry({ path: "example/LICENSE", data: "attacker-chosen" }), "MODULE_PACKAGE_LAYOUT_INVALID", ); }); 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("verifies ABI-v1 plain schema metadata without comparing Drizzle with Core", async () => { const { archivePath } = await archiveFile(abiPackage()); await expect( verifyModulePackage({ archivePath, platform: { ...platform, databaseRuntime: { "drizzle-orm": "99.0.0" } }, }), ).resolves.toMatchObject({ moduleId: "example" }); }); it("rejects transitional database runtime metadata from an ABI-v1 package", async () => { await rejection( abiPackage({ metadata: (metadata) => ({ ...metadata, transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } }, }), }), "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED", ); }); it("rejects an unsupported database ABI and malformed ABI-v1 schema metadata", async () => { await rejection( abiPackage({ manifest: (manifest) => ({ ...manifest, database: { abi: 2 } }) }), "MODULE_PACKAGE_MANIFEST_INVALID", ); const schemaError = await rejection( abiPackage({ schemaSource: 'export default { moduleId: "example", tables: { records: "attacker-chosen" } };\n', }), "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED", ); expect(schemaError.message).not.toContain("attacker-chosen"); }); it("fails a target this platform cannot run", async () => { await rejection( { metadata: (metadata) => ({ ...metadata, target: "linux-arm64-musl", runtime: { libcVersion: ">=1.2.0", 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), 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", }); }); describe("native targets (ADR 0049)", () => { /** The first bytes of a 64-bit little-endian ELF object for `machine` (62 is x86-64). The * verifier reads only the identification and machine fields. */ function elfHeader(machine = 62): Buffer { const header = Buffer.alloc(64); Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(header); header.writeUInt16LE(3, 16); header.writeUInt16LE(machine, 18); return header; } const linuxHost = { arch: "x64", libc: { family: "glibc", version: "2.41" }, nodeApi: 10, nodeAbi: "137", platform: "linux", } as const; function nativePackage( overrides: { readonly files?: Record; readonly target?: string; readonly runtime?: Record; } = {}, ): PackageFixtureOptions { return { files: overrides.files ?? { "node_modules/native-thing/index.js": "module.exports = {};\n", "node_modules/native-thing/lib/native-thing.node": elfHeader(), "node_modules/native-thing/package.json": '{"name":"native-thing","version":"1.0.0"}\n', }, metadata: (metadata) => ({ ...metadata, runtime: overrides.runtime ?? { libcVersion: ">=2.28.0", node: ">=20", nodeApi: 9, }, target: overrides.target ?? "linux-x64-glibc", }), }; } async function verifyOn( options: PackageFixtureOptions, host: ModulePackageHost, ): Promise { const { archivePath } = await archiveFile(options); return verifyModulePackage({ archivePath, platform: { ...platform, host } }); } async function refusedOn( options: PackageFixtureOptions, host: ModulePackageHost, code: ModulePackageVerificationErrorCode, ): Promise { await expect(verifyOn(options, host)).rejects.toMatchObject({ code }); } it("accepts linux-x64-glibc on a matching host, native node_modules and addon included", async () => { await expect(verifyOn(nativePackage(), linuxHost)).resolves.toMatchObject({ moduleId: "example", target: "linux-x64-glibc", }); }); it("refuses another operating system, CPU, or C library family", async () => { await refusedOn( nativePackage(), { ...linuxHost, platform: "darwin" }, "MODULE_PACKAGE_TARGET_INCOMPATIBLE", ); await refusedOn( nativePackage(), { ...linuxHost, arch: "arm64" }, "MODULE_PACKAGE_TARGET_INCOMPATIBLE", ); // musl reports no glibc version, so the host cannot be proven glibc. const { libc: _libc, ...musl } = linuxHost; await refusedOn(nativePackage(), musl, "MODULE_PACKAGE_TARGET_INCOMPATIBLE"); }); it("refuses a target this platform does not implement", async () => { await refusedOn( nativePackage({ target: "linux-arm64-glibc" }), { ...linuxHost, arch: "arm64" }, "MODULE_PACKAGE_TARGET_INCOMPATIBLE", ); }); it("refuses a C library older than the package needs", async () => { await refusedOn( nativePackage(), { ...linuxHost, libc: { family: "glibc", version: "2.17" } }, "MODULE_PACKAGE_LIBC_INCOMPATIBLE", ); await expect( verifyOn(nativePackage(), { ...linuxHost, libc: { family: "glibc", version: "2.28" } }), ).resolves.toMatchObject({ target: "linux-x64-glibc" }); }); it("refuses a Node-API version or Node.js ABI the host does not provide", async () => { await refusedOn( nativePackage(), { ...linuxHost, nodeApi: 8 }, "MODULE_PACKAGE_NODE_API_INCOMPATIBLE", ); const { nodeApi: _nodeApi, ...unknownApi } = linuxHost; await refusedOn(nativePackage(), unknownApi, "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"); await refusedOn( nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20", nodeAbi: "115" } }), linuxHost, "MODULE_PACKAGE_NODE_API_INCOMPATIBLE", ); }); it("refuses a native file built for another machine, or an addon that is not one", async () => { await refusedOn( nativePackage({ files: { "node_modules/native-thing/lib/native-thing.node": elfHeader(183) }, }), linuxHost, "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE", ); await refusedOn( nativePackage({ files: { "node_modules/native-thing/lib/libthing.so": elfHeader(183) }, }), linuxHost, "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE", ); await refusedOn( nativePackage({ files: { "node_modules/native-thing/lib/native-thing.node": "not an ELF object" }, }), linuxHost, "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE", ); }); it("requires a native package to state its Node.js interface and, on Linux, its C library", async () => { await rejection( nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20" } }), "MODULE_PACKAGE_METADATA_INVALID", ); await rejection( nativePackage({ runtime: { node: ">=20", nodeApi: 9 } }), "MODULE_PACKAGE_METADATA_INVALID", ); }); it("keeps any-target packages free of node_modules, addons, and ELF objects", async () => { await rejection( { files: { "node_modules/thing/index.js": "module.exports = {};\n" } }, "MODULE_PACKAGE_NATIVE_FILE", ); await rejection({ files: { "dist/thing.node": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE"); await rejection({ files: { "dist/libthing.so": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE"); }); it("establishes this host's facts from Node.js alone", () => { const host = currentModulePackageHost(); expect(host.platform).toBe(process.platform); expect(host.arch).toBe(process.arch); expect(host.nodeApi).toBe(Number(process.versions.napi)); if (process.platform === "linux") { const report = process.report.getReport() as { header?: { glibcVersionRuntime?: string } }; expect(host.libc?.version).toBe(report.header?.glibcVersionRuntime); } }); }); 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", }); }); });