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]>
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
import { readFile, symlink } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { describeElf, ElfFormatError } from "../src/elf.js";
|
|
import { type ModulePackErrorCode, packModule } from "../src/index.js";
|
|
import {
|
|
cleanupFixtures,
|
|
type FakePackage,
|
|
type FixtureModuleOptions,
|
|
fixtureModule,
|
|
installedPackageFile,
|
|
SOURCE_REVISION,
|
|
scratchDirectory,
|
|
} from "./support/fixture-module.js";
|
|
import { fileEntries, readTarGz } from "./support/tar.js";
|
|
|
|
/**
|
|
* The native closure rules (ADR 0049), with fake packages built around the real sharp addon's bytes,
|
|
* so each rule can be broken on its own: which packages are copied, which are left out, and what
|
|
* fails closed.
|
|
*/
|
|
|
|
afterEach(cleanupFixtures);
|
|
|
|
const MIT = "MIT License\n\nCopyright (c) 2026 Example Author\n\nPermission is hereby granted.\n";
|
|
|
|
async function sharpAddon(): Promise<Buffer> {
|
|
const sharp = await installedPackageFile("sharp");
|
|
return readFile(
|
|
path.join(path.dirname(sharp), "@img", "sharp-linux-x64", "lib", "sharp-linux-x64-0.35.4.node"),
|
|
);
|
|
}
|
|
|
|
function wrapper(extra: Record<string, unknown> = {}): FakePackage {
|
|
return {
|
|
files: { "index.js": "export default 1;\n", LICENSE: MIT, "index.d.ts": "export {};\n" },
|
|
name: "fake-native",
|
|
packageJson: {
|
|
dependencies: { "fake-helper": "1.0.0" },
|
|
devDependencies: { "fake-devtool": "1.0.0" },
|
|
optionalDependencies: {
|
|
"fake-native-darwin-arm64": "1.0.0",
|
|
"fake-native-linux-x64-gnu": "1.0.0",
|
|
"fake-native-linux-x64-musl": "1.0.0",
|
|
},
|
|
...extra,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function platformPackages(addon: Uint8Array): Promise<FakePackage[]> {
|
|
return [
|
|
{
|
|
files: { "lib/fake.node": addon, LICENSE: MIT, "index.js": "module.exports = 1;\n" },
|
|
name: "fake-native-linux-x64-gnu",
|
|
packageJson: { cpu: ["x64"], libc: ["glibc"], os: ["linux"], type: "commonjs" },
|
|
},
|
|
{
|
|
files: { "lib/fake.node": addon, LICENSE: MIT },
|
|
name: "fake-native-darwin-arm64",
|
|
packageJson: { cpu: ["arm64"], os: ["darwin"] },
|
|
},
|
|
{
|
|
files: { "lib/fake.node": addon, LICENSE: MIT },
|
|
name: "fake-native-linux-x64-musl",
|
|
packageJson: { cpu: ["x64"], libc: ["musl"], os: ["linux"] },
|
|
},
|
|
{ files: { "index.js": "export default 2;\n", LICENSE: MIT }, name: "fake-helper" },
|
|
{ files: { "index.js": "export default 3;\n", LICENSE: MIT }, name: "fake-devtool" },
|
|
];
|
|
}
|
|
|
|
const importsFakeNative =
|
|
'import value from "fake-native";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n';
|
|
|
|
async function packNative(options: FixtureModuleOptions, target = "linux-x64-glibc") {
|
|
const moduleDirectory = await fixtureModule(options);
|
|
const outDirectory = await scratchDirectory("ekklesia-native-closure-out-");
|
|
const result = await packModule({
|
|
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
|
moduleDirectory,
|
|
nodeRange: ">=24.11.0 <25",
|
|
outDirectory,
|
|
sourceRevision: SOURCE_REVISION,
|
|
target,
|
|
});
|
|
return { archive: await readFile(result.archivePath), result };
|
|
}
|
|
|
|
async function refusal(
|
|
options: FixtureModuleOptions,
|
|
code: ModulePackErrorCode,
|
|
target = "linux-x64-glibc",
|
|
): Promise<void> {
|
|
await expect(packNative(options, target)).rejects.toMatchObject({ code });
|
|
}
|
|
|
|
describe("the native closure", () => {
|
|
it("copies the native package, its production dependencies, and only the matching platform package", async () => {
|
|
const { archive, result } = await packNative({
|
|
fakePackages: [wrapper(), ...(await platformPackages(await sharpAddon()))],
|
|
serverSource: importsFakeNative,
|
|
});
|
|
const files = [...fileEntries(archive).keys()].map((name) => name.replace(/^example\//u, ""));
|
|
const copied = new Set(
|
|
files.filter((name) => name.startsWith("node_modules/")).map((name) => name.split("/")[1]),
|
|
);
|
|
expect([...copied].sort()).toEqual(["fake-helper", "fake-native", "fake-native-linux-x64-gnu"]);
|
|
// Every copied file is a real file; declarations never ship.
|
|
expect(
|
|
readTarGz(archive).every((entry) => entry.typeflag === "0" || entry.typeflag === "5"),
|
|
).toBe(true);
|
|
expect(files).not.toContain("node_modules/fake-native/index.d.ts");
|
|
expect(files).toContain("node_modules/fake-native-linux-x64-gnu/lib/fake.node");
|
|
|
|
const metadata = JSON.parse(
|
|
fileEntries(archive).get("example/ekklesia.package.json")?.toString("utf8") ?? "{}",
|
|
);
|
|
// The sharp addon alone: Node-API 9, and GLIBC_2.17 is the newest symbol version it needs.
|
|
expect(metadata.runtime).toEqual({
|
|
libcVersion: ">=2.17.0",
|
|
node: ">=24.11.0 <25",
|
|
nodeApi: 9,
|
|
});
|
|
expect(result.target).toBe("linux-x64-glibc");
|
|
const notices = fileEntries(archive).get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "";
|
|
expect(notices).toContain(
|
|
"fake-native-linux-x64-gnu 1.0.0\nLicense: MIT\nUsed in: node_modules",
|
|
);
|
|
expect(notices).not.toContain("fake-devtool");
|
|
expect(notices).not.toContain("fake-native-darwin-arm64");
|
|
});
|
|
|
|
it("refuses a required dependency that is not installed", async () => {
|
|
const packages = (await platformPackages(await sharpAddon())).filter(
|
|
(fake) => fake.name !== "fake-helper",
|
|
);
|
|
await refusal(
|
|
{ fakePackages: [wrapper(), ...packages], serverSource: importsFakeNative },
|
|
"MODULE_PACK_NATIVE_DEPENDENCY_MISSING",
|
|
);
|
|
});
|
|
|
|
it("refuses a required dependency built for another platform", async () => {
|
|
await refusal(
|
|
{
|
|
fakePackages: [
|
|
wrapper({ dependencies: { "fake-helper": "1.0.0", "fake-required-darwin": "1.0.0" } }),
|
|
...(await platformPackages(await sharpAddon())),
|
|
{
|
|
files: { "index.js": "export default 4;\n", LICENSE: MIT },
|
|
name: "fake-required-darwin",
|
|
packageJson: { os: ["darwin"] },
|
|
},
|
|
],
|
|
serverSource: importsFakeNative,
|
|
},
|
|
"MODULE_PACK_NATIVE_TARGET_MISMATCH",
|
|
);
|
|
});
|
|
|
|
it("refuses two versions of one package in the flat layout", async () => {
|
|
const addon = await sharpAddon();
|
|
const packages = await platformPackages(addon);
|
|
const platform = packages.find((fake) => fake.name === "fake-native-linux-x64-gnu");
|
|
const conflicting: FakePackage = {
|
|
...(platform as FakePackage),
|
|
files: {
|
|
...(platform?.files ?? {}),
|
|
"node_modules/fake-helper/package.json": JSON.stringify({
|
|
license: "MIT",
|
|
name: "fake-helper",
|
|
version: "2.0.0",
|
|
}),
|
|
"node_modules/fake-helper/LICENSE": MIT,
|
|
},
|
|
packageJson: { ...(platform?.packageJson ?? {}), dependencies: { "fake-helper": "2.0.0" } },
|
|
};
|
|
await refusal(
|
|
{
|
|
fakePackages: [wrapper(), conflicting, ...packages.filter((fake) => fake !== platform)],
|
|
serverSource: importsFakeNative,
|
|
},
|
|
"MODULE_PACK_NATIVE_LAYOUT_CONFLICT",
|
|
);
|
|
});
|
|
|
|
it("refuses an addon built for another machine, or one that is not an ELF object", async () => {
|
|
const foreign = Buffer.from(await sharpAddon());
|
|
foreign.writeUInt16LE(183, 18); // EM_AARCH64
|
|
await refusal(
|
|
{
|
|
fakePackages: [wrapper(), ...(await platformPackages(foreign))],
|
|
serverSource: importsFakeNative,
|
|
},
|
|
"MODULE_PACK_NATIVE_TARGET_MISMATCH",
|
|
);
|
|
await refusal(
|
|
{
|
|
fakePackages: [wrapper(), ...(await platformPackages(Buffer.from("not an addon")))],
|
|
serverSource: importsFakeNative,
|
|
},
|
|
"MODULE_PACK_NATIVE_TARGET_MISMATCH",
|
|
);
|
|
});
|
|
|
|
it("refuses a link inside a native package", async () => {
|
|
const moduleDirectory = await fixtureModule({
|
|
fakePackages: [wrapper(), ...(await platformPackages(await sharpAddon()))],
|
|
serverSource: importsFakeNative,
|
|
});
|
|
await symlink(
|
|
"/etc/hostname",
|
|
path.join(moduleDirectory, "node_modules", "fake-native-linux-x64-gnu", "escape"),
|
|
);
|
|
await expect(
|
|
packModule({
|
|
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
|
moduleDirectory,
|
|
nodeRange: ">=24.11.0 <25",
|
|
outDirectory: await scratchDirectory(),
|
|
sourceRevision: SOURCE_REVISION,
|
|
target: "linux-x64-glibc",
|
|
}),
|
|
).rejects.toMatchObject({ code: "MODULE_PACK_NATIVE_PACKAGE_INVALID" });
|
|
});
|
|
|
|
it("refuses a native dependency that does not support the package's Node.js range", async () => {
|
|
await refusal(
|
|
{
|
|
fakePackages: [
|
|
wrapper({ engines: { node: ">=99" } }),
|
|
...(await platformPackages(await sharpAddon())),
|
|
],
|
|
serverSource: importsFakeNative,
|
|
},
|
|
"MODULE_PACK_NATIVE_NODE_INCOMPATIBLE",
|
|
);
|
|
});
|
|
|
|
it("refuses a native target for a module with no native addon", async () => {
|
|
await refusal({}, "MODULE_PACK_NATIVE_TARGET_UNNEEDED");
|
|
});
|
|
|
|
it("refuses targets it does not produce", async () => {
|
|
for (const target of ["linux-arm64-glibc", "linux-x64-musl", "darwin-arm64", "win32-x64"]) {
|
|
await refusal({}, "MODULE_PACK_TARGET_UNSUPPORTED", target);
|
|
}
|
|
});
|
|
|
|
it("still refuses the same native package in an any-target package", async () => {
|
|
await refusal(
|
|
{
|
|
fakePackages: [wrapper(), ...(await platformPackages(await sharpAddon()))],
|
|
serverSource: importsFakeNative,
|
|
},
|
|
"MODULE_PACK_NATIVE_DEPENDENCY",
|
|
"any",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("reading an ELF object", () => {
|
|
it("reads the machine, needed libraries, symbol versions, and declared Node-API version", async () => {
|
|
const description = describeElf(await sharpAddon());
|
|
expect(description.machine).toBe(62);
|
|
expect(description.needed).toContain("libvips-cpp.so.8.18.6");
|
|
expect(description.exported.has("napi_register_module_v1")).toBe(true);
|
|
expect(description.nodeApiModuleVersion).toBe(9);
|
|
expect(description.requiredVersions).toContain("GLIBC_2.17");
|
|
});
|
|
|
|
it("refuses what it cannot read", () => {
|
|
expect(() => describeElf(Buffer.from("\u007fELF"))).toThrow(ElfFormatError);
|
|
const thirtyTwoBit = Buffer.alloc(64);
|
|
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1, 1]).copy(thirtyTwoBit);
|
|
expect(() => describeElf(thirtyTwoBit)).toThrow(ElfFormatError);
|
|
});
|
|
});
|