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]>
245 lines
10 KiB
TypeScript
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`;
|
|
}
|