Files
Ekklesia/packages/module-pack/src/notices.ts
T
ekklesiaandClaude Opus 5.5 44d385cd7f
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
feat: native linux-x64-glibc module packages and reviewed license supplements (ADR 0049, ADR 0052)
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]>
2026-10-07 20:16:53 -05:00

245 lines
10 KiB
TypeScript

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
* package requires, generated from what was actually bundled.
*
* - **Discovery.** Every source file in every bundle (the backend, and each browser artifact) is
* traced to the package it belongs to. Packages installed under `node_modules` are third-party
* software and are credited here. The Ekklesia platform's own packages, and the module itself,
* are not third-party software: the platform packages appear in `ekklesia.package.json` under
* `incorporates`.
* - **Content.** For each third-party package: its name, version, declared licence, where in the
* package it is used, and the complete text of every licence, copying, copyright, and notice file
* it ships at its root, plus the file a `SEE LICENSE IN` declaration names.
* - **Duplicates.** A package bundled into several artifacts is credited once, listing every
* artifact kind it is used in. Different packages are never merged, even when their texts match,
* so each component keeps its own notice.
* - **Gaps fail.** A package that declares no licence, or whose licence cannot be established,
* stops packaging. When a package ships no licence file, the standard text of its declared licence
* is supplied only if that licence's standard text is complete without project-specific lines
* (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_SUPPLEMENT_MISMATCH"
| "MODULE_PACK_LICENCE_TEXT_MISSING"
| "MODULE_PACK_LICENCE_UNKNOWN";
export class NoticeError extends Error {
readonly code: NoticeErrorCode;
readonly component: string;
constructor(code: NoticeErrorCode, component: string) {
super(code);
this.name = "NoticeError";
this.code = code;
this.component = component;
}
}
/** 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
* for a package that ships none is faithful. Checked into this package (`licenses/`). */
const STANDARD_TEXTS: Readonly<Record<string, string>> = Object.freeze({
"Apache-2.0": "Apache-2.0.txt",
});
const LICENCE_FILE =
/^(?:licen[cs]e(?:[-._][a-z0-9.-]+)?|copying(?:[-._][a-z0-9.-]+)?|notice(?:[-._][a-z0-9.-]+)?|copyright(?:[-._][a-z0-9.-]+)?)$/iu;
const RULE = "-".repeat(80);
function licenceOf(bundled: BundledPackage): string | undefined {
const declared = bundled.manifest.license;
if (typeof declared === "string" && declared.trim() !== "") {
return declared.trim();
}
if (typeof declared === "object" && declared !== null && "type" in declared) {
const type = (declared as { type?: unknown }).type;
return typeof type === "string" && type.trim() !== "" ? type.trim() : undefined;
}
return undefined;
}
function normalized(text: string): string {
return `${text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replace(/\s+$/u, "")}\n`;
}
function compare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
async function licenceTexts(
bundled: BundledPackage,
licence: string,
): Promise<readonly { readonly title: string; readonly text: string }[]> {
const names = (await readdir(bundled.root, { withFileTypes: true }))
.filter((entry) => entry.isFile() && LICENCE_FILE.test(entry.name))
.map((entry) => entry.name)
.sort(compare);
const seeFile = /^SEE LICEN[CS]E IN (.+)$/iu.exec(licence)?.[1]?.trim();
if (seeFile && !names.includes(seeFile)) {
const resolved = path.resolve(bundled.root, seeFile);
if (resolved.startsWith(`${bundled.root}${path.sep}`)) {
names.push(seeFile);
}
}
const texts: { title: string; text: string }[] = [];
for (const name of names) {
texts.push({
text: normalized(await readFile(path.join(bundled.root, name), "utf8")),
title: name,
});
}
return texts;
}
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
async function standardText(licence: string): Promise<string | undefined> {
const file = STANDARD_TEXTS[licence];
return file === undefined
? 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) =>
compare(left.bundled.name, right.bundled.name) ||
compare(left.bundled.version, right.bundled.version),
);
const sections: string[] = [];
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 (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);
}
texts = [
...texts,
{
text: `The package ships no licence file. This is the standard text of the licence it declares (${licence}).\n\n${standard}`,
title: `Standard ${licence} text`,
},
];
}
sections.push(
[
RULE,
`${bundled.name} ${bundled.version}`,
`License: ${licence}`,
`Used in: ${[...uses].sort(compare).join(", ")}`,
RULE,
"",
...texts.flatMap((text) => [`[${text.title}]`, "", text.text]),
...(supplement === undefined ? [] : supplementSection(supplement)),
].join("\n"),
);
}
const header = [
"THIRD-PARTY SOFTWARE NOTICES",
"",
"This module package includes the third-party software listed below. Each entry names the",
"component, its version, its declared licence, where in the package it is used, and the licence",
"and notice text that came with it.",
"",
"The Ekklesia platform's own packages are not third-party software. They are listed in",
'ekklesia.package.json under "incorporates".',
"",
ordered.length === 0
? "This module package includes no third-party software."
: `${ordered.length} ${ordered.length === 1 ? "component" : "components"}.`,
"",
].join("\n");
return `${[header, ...sections].join("\n").replace(/\n+$/u, "")}\n`;
}