CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
Every Module Package v1 now declares the module's license and carries its text. packageFormat stays 1; pre-ADR-0051 metadata fails closed. - Contracts: ekklesia.package.json requires an SPDX `license` expression, validated with spdx-expression-parse, with UNLICENSED and non-expressions refused. The `files` list must hold exactly one non-empty root LICENSE. The package contracts move to the @ekklesia/contracts/module-package subpath, so the SPDX parser and its data (spdx-exceptions and spdx-license-ids ship no licence files) never reach a module bundle through the SDK's re-export of the contracts root. - Packager: reads the source package.json's string SPDX declaration and never invents one. It requires an explicit --license-file (a non-empty regular file, no symlink, no parent search, no download) and copies its bytes unchanged to LICENSE, listed with size and SHA-256 and covered by the archive digest. THIRD_PARTY_NOTICES stays third-party only, and bundled Ekklesia code stays in `incorporates`. - Verifier: rejects missing or invalid license metadata, and a missing, unlisted, duplicated, or corrupted LICENSE, through the schema and the existing strict inventory. - Template: packaged as AGPL-3.0-or-later with the repository's canonical LICENSE, passed explicitly (Dockerfile, smoke, docs). Packing it twice gives byte-identical archives. Started by Codex; reviewed and completed in Claude Code (empty license-text refusal, schema non-empty LICENSE, symlink and empty-file tests). ADR 0051 is Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
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 {
|
|
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.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] };
|
|
}
|
|
|
|
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("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<string, unknown> = { ...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<string, unknown> = { ...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: { 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",
|
|
});
|
|
});
|
|
});
|