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([ ["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, material = "Copyright (c) 2026 Reviewed Author\n\nPermission is hereby granted.\n", ): Promise { 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)"]); } }); });