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
+72 -4
View File
@@ -2,6 +2,7 @@ import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { BundledPackage } from "./packages.js";
import { contentDigest, type LicenseSupplement } from "./supplements.js";
/**
* `THIRD_PARTY_NOTICES` (ADR 0049): the licence and notice text that redistributing a module
@@ -24,11 +25,20 @@ import type { BundledPackage } from "./packages.js";
* (Apache-2.0). An MIT, ISC, or BSD package without its file is refused, because its copyright line
* exists only in that file.
*
* - **Reviewed supplements** (ADR 0052). When a reviewed license supplement exists for a package's
* name, it must match the package exactly (version and content digest over the files
* redistributed for it) and must name the licence the package declares, or packaging fails. A
* matching supplement's material follows the package's own, labelled as supplement material; it
* stands in for the licence text the package omits, and never changes the declared licence.
*
* Everything is sorted, and line endings are normalized, so the file is byte-identical for identical
* inputs.
*/
export type NoticeErrorCode = "MODULE_PACK_LICENCE_TEXT_MISSING" | "MODULE_PACK_LICENCE_UNKNOWN";
export type NoticeErrorCode =
| "MODULE_PACK_LICENCE_SUPPLEMENT_MISMATCH"
| "MODULE_PACK_LICENCE_TEXT_MISSING"
| "MODULE_PACK_LICENCE_UNKNOWN";
export class NoticeError extends Error {
readonly code: NoticeErrorCode;
@@ -42,11 +52,16 @@ export class NoticeError extends Error {
}
}
export type NoticeUse = "backend" | "browser";
/** Where in the package a component is used: bundled into the backend or a browser artifact, or
* copied as files under `node_modules` (a native target's runtime dependency). */
export type NoticeUse = "backend" | "browser" | "node_modules";
export interface NoticeComponent {
readonly bundled: BundledPackage;
readonly uses: ReadonlySet<NoticeUse>;
/** The files redistributed for this component, by path relative to its root: what a supplement's
* content digest is computed over. Read only when a supplement names the component. */
readonly redistributedFiles: () => Promise<ReadonlyMap<string, Uint8Array>>;
}
/** Licences whose standard text is complete without any project-specific line, so supplying it
@@ -114,8 +129,47 @@ async function standardText(licence: string): Promise<string | undefined> {
: normalized(await readFile(path.join(packageRoot, "licenses", file), "utf8"));
}
function supplementSection(supplement: LicenseSupplement): string[] {
const lines = [
"[Reviewed license supplement]",
"",
`This material did not come from ${supplement.name} ${supplement.version}. The package omits licence`,
"and notice material that redistribution requires, so Ekklesia's canonical packager adds it from a",
"reviewed license supplement matched to this exact package: its name, its version, and its content",
`digest ${supplement.contentDigest}. The supplement does not change the licence`,
"the package declares. Review of a supplement is project and code review of a build input. It is",
"not legal review, and it does not establish that every redistribution obligation is met.",
"",
normalized(supplement.statement).replace(/\n$/u, ""),
"",
"Components covered:",
"",
];
for (const component of supplement.components) {
lines.push(` ${component.name} ${component.version}: ${component.license}`);
lines.push(` Source: ${component.source}`);
}
lines.push("");
const printed = new Map<string, string>();
for (const component of supplement.components) {
for (const material of component.material) {
const label = `${component.name} ${component.version}: ${material.title}`;
const first = printed.get(material.sha256);
lines.push(`[${label}]`, "");
if (first === undefined) {
printed.set(material.sha256, label);
lines.push(normalized(material.text));
} else {
lines.push(`Identical to the text printed above under [${first}].`, "");
}
}
}
return lines;
}
export async function thirdPartyNotices(
components: ReadonlyMap<string, NoticeComponent>,
supplements: ReadonlyMap<string, readonly LicenseSupplement[]> = new Map(),
): Promise<string> {
const ordered = [...components.values()].sort(
(left, right) =>
@@ -123,15 +177,28 @@ export async function thirdPartyNotices(
compare(left.bundled.version, right.bundled.version),
);
const sections: string[] = [];
for (const { bundled, uses } of ordered) {
for (const { bundled, redistributedFiles, uses } of ordered) {
const component = `${bundled.name}@${bundled.version}`;
const licence = licenceOf(bundled);
if (licence === undefined || /^UNLICEN[CS]ED$/iu.test(licence)) {
throw new NoticeError("MODULE_PACK_LICENCE_UNKNOWN", component);
}
let supplement: LicenseSupplement | undefined;
const candidates = supplements.get(bundled.name);
if (candidates !== undefined && candidates.length > 0) {
// A supplement exists for this name: only an exact match may be used, and anything else
// fails rather than falling back to a nearby version or to no supplement at all.
const digest = contentDigest(await redistributedFiles());
supplement = candidates.find(
(candidate) => candidate.version === bundled.version && candidate.contentDigest === digest,
);
if (supplement === undefined || supplement.license !== licence) {
throw new NoticeError("MODULE_PACK_LICENCE_SUPPLEMENT_MISMATCH", component);
}
}
let texts = await licenceTexts(bundled, licence);
// A NOTICE file alone is not the licence: the licence text must still come from somewhere.
if (!texts.some((text) => !/^notice/iu.test(text.title))) {
if (supplement === undefined && !texts.some((text) => !/^notice/iu.test(text.title))) {
const standard = await standardText(licence);
if (standard === undefined) {
throw new NoticeError("MODULE_PACK_LICENCE_TEXT_MISSING", component);
@@ -153,6 +220,7 @@ export async function thirdPartyNotices(
RULE,
"",
...texts.flatMap((text) => [`[${text.title}]`, "", text.text]),
...(supplement === undefined ? [] : supplementSection(supplement)),
].join("\n"),
);
}