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

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:
ekklesia
2026-10-07 20:16:53 -05:00
co-authored by Claude Opus 5.5
parent f53506b3f0
commit 44d385cd7f
173 changed files with 20638 additions and 53 deletions
+1
View File
@@ -9,6 +9,7 @@ export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js";
export * from "./migration-discovery.js";
export * from "./modules-directory.js";
export * from "./module-package-host.js";
export * from "./module-package-verification.js";
export * from "./permissions.js";
export * from "./realtime.js";
+59
View File
@@ -0,0 +1,59 @@
/**
* The facts about this host that decide whether a native Module Package v1 can run on it
* (ADR 0049): operating system, CPU, C library family and version, and Node-API version.
*
* Everything comes from Node.js itself (`process.platform`, `process.arch`, `process.versions`, and
* the diagnostic report's `glibcVersionRuntime`), never from running a system command. A fact Node
* cannot report is left undefined, and every check that needs it fails closed.
*/
export interface ModulePackageHost {
readonly platform: string;
readonly arch: string;
/** The C library on Linux: glibc with its runtime version, or undefined when it cannot be proven
* to be glibc (musl reports none). */
readonly libc?: { readonly family: "glibc"; readonly version: string };
/** `process.versions.napi`: the highest Node-API version this Node.js provides. */
readonly nodeApi?: number;
/** `process.versions.modules`: the ABI an ABI-specific addon must have been built for. */
readonly nodeAbi?: string;
}
function glibcVersion(): string | undefined {
try {
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })
?.header;
const version = header?.glibcVersionRuntime;
return typeof version === "string" && /^\d+\.\d+(?:\.\d+)?$/u.test(version)
? version
: undefined;
} catch {
return undefined;
}
}
export function currentModulePackageHost(): ModulePackageHost {
const libc = process.platform === "linux" ? glibcVersion() : undefined;
const nodeApi = Number(process.versions.napi);
return Object.freeze({
arch: process.arch,
...(libc === undefined
? {}
: { libc: Object.freeze({ family: "glibc" as const, version: libc }) }),
...(Number.isInteger(nodeApi) && nodeApi > 0 ? { nodeApi } : {}),
...(typeof process.versions.modules === "string" ? { nodeAbi: process.versions.modules } : {}),
platform: process.platform,
});
}
/** The native targets this platform can verify, with what each requires of a host. */
export const MODULE_PACKAGE_NATIVE_TARGETS: ReadonlyMap<
string,
{
readonly platform: string;
readonly arch: string;
readonly libc: "glibc";
readonly elfMachine: number;
}
> = new Map([
["linux-x64-glibc", { arch: "x64", elfMachine: 62, libc: "glibc", platform: "linux" }],
]);
@@ -18,6 +18,11 @@ import {
safeParseModulePackageRuntimePackage,
} from "@ekklesia/contracts/module-package";
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
import {
currentModulePackageHost,
MODULE_PACKAGE_NATIVE_TARGETS,
type ModulePackageHost,
} from "./module-package-host.js";
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
import {
type ModulePackageArchive,
@@ -56,11 +61,14 @@ export type ModulePackageVerificationErrorCode =
| "MODULE_PACKAGE_FILE_UNLISTED"
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
| "MODULE_PACKAGE_LIBC_INCOMPATIBLE"
| "MODULE_PACKAGE_MANIFEST_INVALID"
| "MODULE_PACKAGE_METADATA_INVALID"
| "MODULE_PACKAGE_METADATA_MISSING"
| "MODULE_PACKAGE_MODULE_INVALID"
| "MODULE_PACKAGE_NATIVE_FILE"
| "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"
| "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
@@ -85,12 +93,19 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
MODULE_PACKAGE_LAYOUT_INVALID:
"The archive must contain exactly one top-level module directory, with each path once.",
MODULE_PACKAGE_LIBC_INCOMPATIBLE:
"The package needs a newer C library than this host provides, or one it cannot establish.",
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
MODULE_PACKAGE_NATIVE_FILE: "An any-target package contains a native file.",
MODULE_PACKAGE_NATIVE_FILE:
"An any-target package contains a native file or a node_modules directory.",
MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE:
"A native file in the package is not built for the package's target.",
MODULE_PACKAGE_NODE_API_INCOMPATIBLE:
"The package's addons need a Node-API version or Node.js ABI this host does not provide.",
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
"The package needs a Node.js version this host does not run.",
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
@@ -131,6 +146,8 @@ export interface ModulePackagePlatform {
readonly databaseRuntime: Readonly<Record<string, string>>;
/** Defaults to the running process's Node.js version. */
readonly nodeVersion?: string;
/** Defaults to this process's own host (`currentModulePackageHost`). */
readonly host?: ModulePackageHost;
}
export interface VerifyModulePackageOptions {
@@ -266,12 +283,19 @@ function checkCompatibility(
files: ReadonlyMap<string, Buffer>,
platform: ModulePackagePlatform,
): void {
// This verifier implements the portable target only. Native targets are a later slice.
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
}
if ([...files.keys()].some((filePath) => filePath.toLowerCase().endsWith(".node"))) {
fail("MODULE_PACKAGE_NATIVE_FILE");
if (metadata.target === MODULE_PACKAGE_ANY_TARGET) {
// An any-target package bundles everything: no addon, no ELF object, no node_modules.
for (const [filePath, data] of files) {
if (
filePath.toLowerCase().endsWith(".node") ||
filePath.startsWith("node_modules/") ||
isElf(data)
) {
fail("MODULE_PACKAGE_NATIVE_FILE");
}
}
} else {
checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost());
}
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
@@ -296,6 +320,74 @@ function checkCompatibility(
}
}
function isElf(data: Uint8Array): boolean {
return (
data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46
);
}
/** `2.41` → `2.41.0`, for comparison against a semantic-version range. */
function asSemver(version: string): string {
const parts = version.split(".");
return [...parts, "0", "0"].slice(0, 3).join(".");
}
/**
* A native target (ADR 0049): accepted only for a target this platform implements, on a host that
* provably matches it. Platform, CPU, C library family and minimum version, and the Node-API version
* or ABI the addons need are all checked; a fact the host cannot establish fails closed. Every ELF
* object in the package must be built for the target's machine, and every `.node` addon must be one.
*/
function checkNativeTarget(
metadata: ModulePackageMetadata,
files: ReadonlyMap<string, Buffer>,
host: ModulePackageHost,
): void {
const target = MODULE_PACKAGE_NATIVE_TARGETS.get(metadata.target);
if (
target === undefined ||
host.platform !== target.platform ||
host.arch !== target.arch ||
host.libc?.family !== target.libc
) {
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
}
const required = metadata.runtime.libcVersion;
if (
required === undefined ||
host.libc === undefined ||
!isVersionCompatible(asSemver(host.libc.version), required)
) {
fail("MODULE_PACKAGE_LIBC_INCOMPATIBLE");
}
const { nodeAbi, nodeApi } = metadata.runtime;
if (
(nodeApi === undefined && nodeAbi === undefined) ||
(nodeApi !== undefined && (host.nodeApi === undefined || host.nodeApi < nodeApi)) ||
(nodeAbi !== undefined && host.nodeAbi !== nodeAbi)
) {
fail("MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
}
for (const [filePath, data] of files) {
const addon = filePath.toLowerCase().endsWith(".node");
if (!isElf(data)) {
if (addon) {
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
}
continue;
}
// ELF64, little-endian, for the target's machine (e_machine at offset 18).
if (
data.length < 20 ||
data[4] !== 2 ||
data[5] !== 1 ||
data.readUInt16LE(18) !== target.elfMachine
) {
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
}
}
}
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
* Safe by construction: every path was validated as relative and non-escaping, and only directories
* and regular files exist. Ownership is the writing process's, never the archive's. */