Files
ekklesiaandClaude Opus 5.5 afaf3a853c
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / deploy-smoke (split) (pull_request) Successful in 1m28s
CI / package-publication (pull_request) Successful in 50s
feat: reviewed libvips 1.3.4 supplement, and the native proof on sharp 0.35.5
sharp 0.35.4 carries a production-relevant librsvg advisory (GHSA-wq5f-xc86-pv6w). Its fix, sharp
0.35.5, loads @img/sharp-libvips-linux-x64 1.3.4, which ships no licence material either. This adds
the reviewed supplement for that exact package (ADR 0052) and moves the packager's native proof to
sharp 0.35.5, so a later 0.2.1 release can package it on linux-x64-glibc. Nothing in module-pack's
code changes: supplement selection is already by exact name, version, and content digest.

- Supplement: license-supplements/@img/sharp-libvips-linux-x64/1.3.4/
  - content digest ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59;
  - 190 components and 146 hashed texts;
  - assembled with the same method as 1.3.3's, from sharp-libvips v1.3.4's own inputs. That method
    reproduces the 1.3.3 supplement byte for byte.
  - PROVENANCE.md records:
    - the 27 upstream archives and five build-time patches, by sha256;
    - the librsvg 2.63.2 crate reproduction (cargo 1.99.0; Cargo.lock 5723e14c…);
    - rustc 75a75c3e…;
    - every change from 1.3.3.
  - Notable: sharp-libvips v1.3.4 now states cairo is used under MPL 1.1, not MPL 2.0. cairo ships
    that text itself, so the standard MPL 2.0 text is no longer added for cairo; selectors still
    carries it.
  - No legal conclusion: the LGPL and MPL source-availability determination stays open, as for 1.3.3.
- The 1.3.3 supplement is unchanged, and still proven: module-pack keeps sharp 0.35.4 as a test-only
  devDependency alias (`sharp-0.35.4`). Each supplement is pinned against its real installed
  libvips, and each matches only its own.
- Native proof fixture: sharp 0.35.4 → 0.35.5. The closure has the same shape: sharp, @img/colour,
  detect-libc, semver, and @img/sharp-linux-x64 0.35.5 with @img/sharp-libvips-linux-x64 1.3.4.
  The runtime read from the binaries is unchanged: Node-API 9, glibc >= 2.28, x86-64.
- Docs: the module guide's note on available supplements.

No version bump, no publication, no tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 21:34:35 -05:00

343 lines
14 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 supplements", () => {
// Each reviewed supplement against the real package it names, as installed: 1.3.4 with sharp
// 0.35.5 (the native proof's), and 1.3.3 with sharp 0.35.4, installed for this test alone as
// `sharp-0.35.4`. A package built with either sharp matches exactly the supplement reviewed for its
// libvips, and never the other.
const INSTALLED = [
{ libvips: "8.18.6", sharp: "sharp-0.35.4", version: "1.3.3" },
{ libvips: "8.18.7", sharp: "sharp", version: "1.3.4" },
] as const;
async function installedLibvips(sharp: string) {
const root = 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 }], target);
const installed = closure.find((entry) => entry.name === "@img/sharp-libvips-linux-x64");
if (installed === undefined) throw new Error("libvips is not in sharp's closure");
return installed;
}
it("loads both intact, one per reviewed version, with distinct content digests", 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.map((supplement) => supplement.version)).toEqual(["1.3.3", "1.3.4"]);
expect(new Set(libvips.map((supplement) => supplement.contentDigest)).size).toBe(2);
for (const supplement of libvips) {
expect(supplement.license).toBe("LGPL-3.0-or-later");
}
});
it.each(
INSTALLED,
)("pins exactly the installed @img/sharp-libvips-linux-x64@$version, and only it", async ({
libvips: library,
sharp,
version,
}) => {
const supplements = await loadLicenseSupplements(DEFAULT_SUPPLEMENTS_DIRECTORY);
const installed = await installedLibvips(sharp);
expect(installed.version).toBe(version);
const digest = contentDigest(installed.files);
// The packager's selection: the same name, the same version, and the same content digest.
const matching = (supplements.get(installed.name) ?? []).filter(
(supplement) =>
supplement.version === installed.version && supplement.contentDigest === digest,
);
expect(matching.map((supplement) => supplement.version)).toEqual([version]);
const [libvips] = matching;
// 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 name of [
"libvips",
"glib",
"gvdb",
"cairo",
"pango",
"librsvg",
"mozjpeg",
"zlib-ng",
]) {
expect(names.has(name), name).toBe(true);
}
expect(libvips?.components.find((component) => component.name === "libvips")?.version).toBe(
library,
);
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(libvips?.statement).toContain(`lib/libvips-cpp.so.${library}`);
expect(names.has("proxy-libintl")).toBe(false);
});
it("records each version's own upstream statement for cairo, and adds a standard text only where none ships", async () => {
const supplements = await loadLicenseSupplements(DEFAULT_SUPPLEMENTS_DIRECTORY);
const byVersion = new Map(
(supplements.get("@img/sharp-libvips-linux-x64") ?? []).map((supplement) => [
supplement.version,
supplement,
]),
);
const cairo = (version: string) =>
byVersion.get(version)?.components.find((component) => component.name === "cairo");
// sharp-libvips v1.3.3 stated MPL 2.0, which cairo does not ship, so its standard text was added.
expect(cairo("1.3.3")?.license).toBe("Mozilla Public License 2.0");
expect(cairo("1.3.3")?.material.map((material) => material.title)).toContain(
"MPL-2.0 (the version sharp-libvips states cairo is used under; standard text from https://www.mozilla.org/MPL/2.0/)",
);
// v1.3.4 states MPL 1.1, whose text cairo ships itself; nothing is added.
expect(cairo("1.3.4")?.license).toBe("Mozilla Public License 1.1");
expect(cairo("1.3.4")?.material.map((material) => material.title)).toEqual([
"COPYING",
"COPYING-LGPL-2.1",
"COPYING-MPL-1.1",
]);
// selectors ships no licence file in either, and carries the standard MPL 2.0 text in both.
for (const version of ["1.3.3", "1.3.4"]) {
const selectors = byVersion
.get(version)
?.components.find((component) => component.name === "selectors");
expect(selectors?.license, version).toBe("MPL-2.0");
expect(
selectors?.material.map((material) => material.title),
version,
).toEqual(["MPL-2.0 (standard text; the crate ships no licence file)"]);
}
});
});