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 { 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 { 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), 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", }); }); });