CI / verify (pull_request) Successful in 1m6s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
Implements ADR 0049's deferred native target and ADR 0052's reviewed license supplements. The proof case is a real module using sharp. packageFormat stays 1, and `any` is unchanged. Packager (@ekklesia/module-pack): - --target linux-x64-glibc. Native or platform-specific packages, found by the same generic rule that refuses them in `any`, stay outside the bundle. - Each native package's production closure is walked through its own dependencies, installed optionalDependencies that match the target, and required peers. It is copied as real files flat under node_modules/<name>, with no links, dev dependencies, declarations, source maps, or other platforms' packages. Missing or foreign-platform required dependencies, version conflicts, links, foreign machines, and Node.js range conflicts fail closed. - runtime.nodeApi, nodeAbi, and libcVersion are read from the binaries by a small ELF reader: the addon's node_api_module_get_api_version_v1 and the newest GLIBC_ symbol version needed. No shelling out. A native target with no addon is refused. - ADR 0052 supplements: package-provided material stays primary. An exact match on name, version, and ekklesia-content-v1 digest (sorted normalized paths with each file's size and SHA-256; no metadata) adds labelled material after the package's own. Any mismatch, a declared-licence conflict, or a malformed or unreviewed supplement file fails. Missing material with no supplement still fails. Supplement for @img/[email protected]: - 184 components and 143 unique texts, assembled from the exact upstream sources sharp-libvips v1.3.3 builds linux-x64 from: 27 C/C++ libraries, glib's gvdb, librsvg's Rust crates (reproduced with cargo tree), the Rust standard library at the rustc commit recorded in the binary, plus the LGPL-3.0, GPL-3.0, and MPL-2.0 texts. Each source is recorded with its SHA-256 in PROVENANCE.md. - It pins digest ekklesia-content-v1:sha256:50647b0b…, which is identical from the installed copy and from the registry tarball. - LGPL/MPL source-availability determinations stay open, as ADR 0052 states. Nothing claims they are met. Verifier (Core): - linux-x64-glibc is accepted only on a host whose platform, arch, glibc family and version, and Node-API version (from Node itself) match. - Every ELF file must be x86-64; every .node must be an ELF object. - `any` packages now also refuse node_modules/ and ELF files. - Contract: native metadata must state nodeApi or nodeAbi, and Linux targets libcVersion (ADR 0049). Proof: - A checked-in AGPL native fixture packs byte-identically from unrelated copies (8.86 MB) and verifies with Core's verifier. - Extracted elsewhere, it runs a sharp resize and PNG round trip in an empty-environment process. Every resolution stays in the package, and only the package's addon and libvips plus host system libraries load. Signage is untouched. Co-Authored-By: Claude Opus 5.5 <[email protected]>
697 lines
23 KiB
TypeScript
697 lines
23 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 {
|
|
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.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: { 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<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",
|
|
});
|
|
});
|
|
|
|
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<string, Uint8Array | string>;
|
|
readonly target?: string;
|
|
readonly runtime?: Record<string, unknown>;
|
|
} = {},
|
|
): 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<unknown> {
|
|
const { archivePath } = await archiveFile(options);
|
|
return verifyModulePackage({ archivePath, platform: { ...platform, host } });
|
|
}
|
|
|
|
async function refusedOn(
|
|
options: PackageFixtureOptions,
|
|
host: ModulePackageHost,
|
|
code: ModulePackageVerificationErrorCode,
|
|
): Promise<void> {
|
|
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",
|
|
});
|
|
});
|
|
});
|