feat: native linux-x64-glibc module packages and reviewed license supplements (ADR 0049, ADR 0052)
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f53506b3f0
commit
44d385cd7f
@@ -3,6 +3,8 @@ 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,
|
||||
@@ -354,7 +356,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
target: "linux-arm64-musl",
|
||||
runtime: { node: ">=20", nodeApi: 9 },
|
||||
runtime: { libcVersion: ">=1.2.0", node: ">=20", nodeApi: 9 },
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||
@@ -493,6 +495,179 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface PackageFixtureOptions {
|
||||
readonly tamper?: (files: Map<string, Buffer>) => void;
|
||||
/** Entries appended after the package's own, under any path. */
|
||||
readonly extraEntries?: readonly FixtureEntry[];
|
||||
/** Extra payload files, listed in the metadata like every other file. */
|
||||
readonly files?: Readonly<Record<string, Uint8Array | string>>;
|
||||
}
|
||||
|
||||
export interface PackageFixture {
|
||||
@@ -163,6 +165,9 @@ export async function modulePackageFixture(
|
||||
);
|
||||
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
||||
files.set("LICENSE", Buffer.from("Example module license text.\n"));
|
||||
for (const [filePath, data] of Object.entries(options.files ?? {})) {
|
||||
files.set(filePath, Buffer.from(data));
|
||||
}
|
||||
|
||||
const listed = [...files.entries()]
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
|
||||
Reference in New Issue
Block a user