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]>
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { chmod, cp, mkdir, readdir, readFile, realpath, utimes, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { packModule } from "../src/index.js";
|
|
import { NATIVE_TARGETS, nativeClosure } from "../src/native.js";
|
|
import {
|
|
CONTENT_DIGEST_ALGORITHM,
|
|
contentDigest,
|
|
DEFAULT_SUPPLEMENTS_DIRECTORY,
|
|
loadLicenseSupplements,
|
|
SupplementError,
|
|
} from "../src/supplements.js";
|
|
import {
|
|
cleanupFixtures,
|
|
fixtureModule,
|
|
installedPackageFile,
|
|
SOURCE_REVISION,
|
|
scratchDirectory,
|
|
} from "./support/fixture-module.js";
|
|
import { fileEntries } from "./support/tar.js";
|
|
|
|
/**
|
|
* Reviewed license supplements (ADR 0052): the content digest they are matched by, the loader that
|
|
* refuses anything unreviewed, how notices merge them, and the real libvips supplement.
|
|
*/
|
|
|
|
afterEach(cleanupFixtures);
|
|
|
|
function sha256(data: string | Uint8Array): string {
|
|
return createHash("sha256").update(data).digest("hex");
|
|
}
|
|
|
|
describe("the content digest", () => {
|
|
const files = new Map<string, Uint8Array>([
|
|
["package.json", Buffer.from('{"name":"x"}\n')],
|
|
["lib/a.js", Buffer.from("a\n")],
|
|
]);
|
|
|
|
it("is the documented algorithm: sorted paths, NUL-separated size and SHA-256, one line each", () => {
|
|
const manifest =
|
|
`lib/a.js\u0000${2}\u0000${sha256("a\n")}\n` +
|
|
`package.json\u0000${13}\u0000${sha256('{"name":"x"}\n')}\n`;
|
|
expect(contentDigest(files)).toBe(`${CONTENT_DIGEST_ALGORITHM}:sha256:${sha256(manifest)}`);
|
|
});
|
|
|
|
it("does not depend on order or on how a path was written, only on paths and bytes", () => {
|
|
const reordered = new Map([...files].reverse());
|
|
const dotted = new Map([...files].map(([key, value]) => [`./${key}`, value]));
|
|
expect(contentDigest(reordered)).toBe(contentDigest(files));
|
|
expect(contentDigest(dotted)).toBe(contentDigest(files));
|
|
expect(contentDigest(new Map([...files, ["lib/a.js", Buffer.from("b\n")]]))).not.toBe(
|
|
contentDigest(files),
|
|
);
|
|
expect(
|
|
contentDigest(new Map([["lib/b.js", Buffer.from("a\n")], ...[...files].slice(0, 1)])),
|
|
).not.toBe(contentDigest(files));
|
|
expect(() => contentDigest(new Map([["../escape", Buffer.from("")]]))).toThrow();
|
|
});
|
|
|
|
it("ignores timestamps, permissions, and where a package is installed", async () => {
|
|
const sharp = await installedPackageFile("sharp");
|
|
const original = path.join(path.dirname(sharp), "detect-libc");
|
|
const copy = path.join(
|
|
await scratchDirectory("ekklesia-digest-copy-"),
|
|
"node_modules",
|
|
"detect-libc",
|
|
);
|
|
await cp(original, copy, { recursive: true });
|
|
await utimes(path.join(copy, "package.json"), new Date(0), new Date(0));
|
|
await chmod(path.join(copy, "package.json"), 0o600);
|
|
const target = NATIVE_TARGETS.get("linux-x64-glibc");
|
|
if (target === undefined) throw new Error("target");
|
|
const [fromStore] = await nativeClosure([{ name: "detect-libc", root: original }], target);
|
|
const [fromCopy] = await nativeClosure([{ name: "detect-libc", root: copy }], target);
|
|
expect(fromStore && fromCopy && contentDigest(fromCopy.files)).toBe(
|
|
fromStore && contentDigest(fromStore.files),
|
|
);
|
|
});
|
|
});
|
|
|
|
async function writeSupplement(
|
|
root: string,
|
|
name: string,
|
|
version: string,
|
|
packageFields: Record<string, unknown>,
|
|
material = "Copyright (c) 2026 Reviewed Author\n\nPermission is hereby granted.\n",
|
|
): Promise<string> {
|
|
const directory = path.join(root, ...name.split("/"), version);
|
|
await mkdir(path.join(directory, "texts"), { recursive: true });
|
|
const digest = sha256(material);
|
|
await writeFile(path.join(directory, "texts", `${digest}.txt`), material);
|
|
await writeFile(
|
|
path.join(directory, "supplement.json"),
|
|
JSON.stringify({
|
|
components: [
|
|
{
|
|
license: "MIT",
|
|
material: [{ text: `texts/${digest}.txt`, title: "LICENSE (from upstream)" }],
|
|
name: "fake-textless-upstream",
|
|
source: "https://example.invalid/fake-textless-1.0.0.tar.gz (sha256 0)",
|
|
version: "1.0.0",
|
|
},
|
|
],
|
|
package: { license: "MIT", name, version, ...packageFields },
|
|
statement: "Reviewed test material for a package that ships no licence file.",
|
|
supplementFormat: 1,
|
|
}),
|
|
);
|
|
return directory;
|
|
}
|
|
|
|
describe("loading supplements", () => {
|
|
it("loads nothing from a directory that does not exist", async () => {
|
|
const missing = path.join(await scratchDirectory(), "none");
|
|
expect((await loadLicenseSupplements(missing)).size).toBe(0);
|
|
});
|
|
|
|
it("refuses a text whose bytes do not match its name, an unlisted file, or a mislabelled directory", async () => {
|
|
const digest = `${CONTENT_DIGEST_ALGORITHM}:sha256:${"0".repeat(64)}`;
|
|
const tampered = await scratchDirectory();
|
|
const directory = await writeSupplement(tampered, "fake-textless", "1.0.0", {
|
|
contentDigest: digest,
|
|
});
|
|
const textFile = path.join(
|
|
directory,
|
|
"texts",
|
|
(await readdir(path.join(directory, "texts")))[0] ?? "",
|
|
);
|
|
await writeFile(textFile, "changed after review\n");
|
|
await expect(loadLicenseSupplements(tampered)).rejects.toBeInstanceOf(SupplementError);
|
|
|
|
const unlisted = await scratchDirectory();
|
|
const second = await writeSupplement(unlisted, "fake-textless", "1.0.0", {
|
|
contentDigest: digest,
|
|
});
|
|
await writeFile(path.join(second, "texts", "extra.txt"), "never reviewed\n");
|
|
await expect(loadLicenseSupplements(unlisted)).rejects.toBeInstanceOf(SupplementError);
|
|
|
|
const mislabelled = await scratchDirectory();
|
|
await writeSupplement(mislabelled, "fake-textless", "1.0.0", {
|
|
contentDigest: digest,
|
|
version: "2.0.0",
|
|
});
|
|
await expect(loadLicenseSupplements(mislabelled)).rejects.toBeInstanceOf(SupplementError);
|
|
|
|
const unknownField = await scratchDirectory();
|
|
await writeSupplement(unknownField, "fake-textless", "1.0.0", {
|
|
contentDigest: digest,
|
|
overrideLicense: "Apache-2.0",
|
|
});
|
|
await expect(loadLicenseSupplements(unknownField)).rejects.toBeInstanceOf(SupplementError);
|
|
});
|
|
});
|
|
|
|
describe("merging a supplement into THIRD_PARTY_NOTICES", () => {
|
|
const textless = {
|
|
files: { "index.js": "export default 1;\n" },
|
|
name: "fake-textless",
|
|
};
|
|
// The bundled package's redistributed files are the ones the bundle took from it.
|
|
const digest = contentDigest(new Map([["index.js", Buffer.from("export default 1;\n")]]));
|
|
const serverSource =
|
|
'import value from "fake-textless";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n';
|
|
|
|
async function packWith(supplements: string) {
|
|
const moduleDirectory = await fixtureModule({ fakePackages: [textless], serverSource });
|
|
const result = await packModule({
|
|
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
|
moduleDirectory,
|
|
nodeRange: ">=24.11.0 <25",
|
|
outDirectory: await scratchDirectory(),
|
|
sourceRevision: SOURCE_REVISION,
|
|
supplementsDirectory: supplements,
|
|
target: "any",
|
|
});
|
|
return (
|
|
fileEntries(await readFile(result.archivePath))
|
|
.get("example/THIRD_PARTY_NOTICES")
|
|
?.toString("utf8") ?? ""
|
|
);
|
|
}
|
|
|
|
it("still fails a package that omits its licence text when no supplement names it", async () => {
|
|
await expect(packWith(await scratchDirectory())).rejects.toMatchObject({
|
|
code: "MODULE_PACK_LICENCE_TEXT_MISSING",
|
|
});
|
|
});
|
|
|
|
it("adds an exact supplement's material after the package's own, labelled, and keeps the declared licence", async () => {
|
|
const supplements = await scratchDirectory();
|
|
await writeSupplement(supplements, "fake-textless", "1.0.0", { contentDigest: digest });
|
|
const notices = await packWith(supplements);
|
|
const entry = notices.slice(notices.indexOf("\nfake-textless 1.0.0\n"));
|
|
expect(entry).toContain("License: MIT\n");
|
|
expect(entry).toContain("[Reviewed license supplement]");
|
|
expect(entry).toContain(digest);
|
|
expect(entry).toContain("[fake-textless-upstream 1.0.0: LICENSE (from upstream)]");
|
|
expect(entry).toContain("Copyright (c) 2026 Reviewed Author");
|
|
expect(entry).toContain("Reviewed test material for a package that ships no licence file.");
|
|
// Deterministic: the same inputs give the same notices.
|
|
expect(await packWith(supplements)).toBe(notices);
|
|
});
|
|
|
|
it("fails closed on a supplement for another version, another content digest, or another licence", async () => {
|
|
for (const fields of [
|
|
{ contentDigest: digest, version: "0.9.0" },
|
|
{ contentDigest: `${CONTENT_DIGEST_ALGORITHM}:sha256:${"f".repeat(64)}` },
|
|
{ contentDigest: digest, license: "Apache-2.0" },
|
|
]) {
|
|
const supplements = await scratchDirectory();
|
|
await writeSupplement(supplements, "fake-textless", fields.version ?? "1.0.0", fields);
|
|
await expect(packWith(supplements)).rejects.toMatchObject({
|
|
code: "MODULE_PACK_LICENCE_SUPPLEMENT_MISMATCH",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("refuses to package at all when a shipped supplement is malformed", async () => {
|
|
const supplements = await scratchDirectory();
|
|
const directory = await writeSupplement(supplements, "fake-textless", "1.0.0", {
|
|
contentDigest: digest,
|
|
});
|
|
await writeFile(path.join(directory, "supplement.json"), "{");
|
|
await expect(packWith(supplements)).rejects.toMatchObject({
|
|
code: "MODULE_PACK_LICENCE_SUPPLEMENT_INVALID",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("the reviewed libvips supplement", () => {
|
|
it("loads intact, and pins exactly the installed @img/[email protected]", async () => {
|
|
const supplements = await loadLicenseSupplements(DEFAULT_SUPPLEMENTS_DIRECTORY);
|
|
expect([...supplements.keys()]).toEqual(["@img/sharp-libvips-linux-x64"]);
|
|
const [libvips] = supplements.get("@img/sharp-libvips-linux-x64") ?? [];
|
|
expect(libvips).toMatchObject({ license: "LGPL-3.0-or-later", version: "1.3.3" });
|
|
|
|
const sharp = await realpath(await installedPackageFile("sharp"));
|
|
const target = NATIVE_TARGETS.get("linux-x64-glibc");
|
|
if (target === undefined) throw new Error("target");
|
|
const closure = await nativeClosure([{ name: "sharp", root: sharp }], target);
|
|
const installed = closure.find((entry) => entry.name === "@img/sharp-libvips-linux-x64");
|
|
expect(installed && contentDigest(installed.files)).toBe(libvips?.contentDigest);
|
|
|
|
// Every library built into the binary is covered, with material from a recorded source, and the
|
|
// open determination is stated rather than resolved.
|
|
const names = new Set(libvips?.components.map((component) => component.name));
|
|
for (const library of [
|
|
"libvips",
|
|
"glib",
|
|
"gvdb",
|
|
"cairo",
|
|
"pango",
|
|
"librsvg",
|
|
"mozjpeg",
|
|
"zlib-ng",
|
|
]) {
|
|
expect(names.has(library), library).toBe(true);
|
|
}
|
|
for (const component of libvips?.components ?? []) {
|
|
expect(component.material.length, component.name).toBeGreaterThan(0);
|
|
expect(component.source, component.name).toMatch(/https:\/\//u);
|
|
}
|
|
expect(libvips?.statement).toContain("open determination");
|
|
expect(names.has("proxy-libintl")).toBe(false);
|
|
});
|
|
});
|