import { createHash } from "node:crypto"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { gunzipSync, gzipSync } from "node:zlib"; import { afterEach, describe, expect, it } from "vitest"; import { acquireCorrespondingSources, buildCorrespondingSource, CorrespondingSourceError, type CorrespondingSourceErrorCode, hasSourceObligations, loadSourceRecipes, verifyRedistribution, } from "../src/corresponding-source.js"; import { contentDigest } from "../src/supplements.js"; import { deterministicTarGz, readTarGz } from "../src/tar.js"; import { cleanupFixtures, scratchDirectory } from "./support/fixture-module.js"; /** * Corresponding-source artifacts and the public-distribution gate (ADR 0055), on small synthetic * inputs: a fake LGPL native package, its reviewed supplement, its source recipe, and a source * cache. The real libvips proof is `corresponding-source-native.test.ts`. */ afterEach(cleanupFixtures); function sha256(data: string | Uint8Array): string { return createHash("sha256").update(data).digest("hex"); } const PACKAGE_FILE = "example-0.1.0-linux-x64-glibc.tar.gz"; const SOURCE_FILE = "example-0.1.0-linux-x64-glibc-corresponding-source.tar.gz"; const METADATA_FILE = "example-0.1.0-linux-x64-glibc.redistribution.json"; const TOP = "example-0.1.0-linux-x64-glibc-corresponding-source"; const SOURCES: Readonly> = { "sources/fake-build-1.0.tar.gz": "build definitions for libfake\n", "sources/fakelib-2.0.tar.gz": "fakelib 2.0 source\n", "patches/fakelib-fix.patch": "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n", "sources/helperlib-1.1.tar.gz": "helperlib 1.1 source\n", "sources/codegen-0.3.crate": "codegen 0.3 build-only source\n", }; interface FixtureOptions { readonly version?: string; readonly libraryBytes?: string; readonly license?: string; readonly notices?: string; readonly recipe?: (recipe: Record) => void; readonly withRecipe?: boolean; } interface Fixture { readonly root: string; readonly packagePath: string; readonly cache: string; readonly recipesDirectory: string; readonly supplementsDirectory: string; } function noticeEntry(name: string, version: string, licence: string, uses: string): string { const rule = "-".repeat(80); return `${rule}\n${name} ${version}\nLicense: ${licence}\nUsed in: ${uses}\n${rule}\n\n[LICENSE]\n\ntext\n`; } /** Writes a fake native package into a Module Package archive, its supplement, recipe, and cache. */ async function fixture(options: FixtureOptions = {}): Promise { const version = options.version ?? "1.0.0"; const license = options.license ?? "LGPL-2.1-or-later"; const root = await scratchDirectory("ekklesia-corresponding-"); const nativeFiles = new Map([ ["lib/libfake.so.1", Buffer.from(options.libraryBytes ?? `\u007fELF fake library ${version}`)], ["package.json", Buffer.from(JSON.stringify({ license, name: "fake-native", version }))], ]); const digest = contentDigest(nativeFiles); const packageFiles = new Map([ [ "ekklesia.package.json", Buffer.from( JSON.stringify({ module: { id: "example", version: "0.1.0" }, packageFormat: 1, target: "linux-x64-glibc", }), ), ], [ "THIRD_PARTY_NOTICES", Buffer.from(options.notices ?? noticeEntry("fake-native", version, license, "node_modules")), ], ...[...nativeFiles].map(([file, data]) => [`node_modules/fake-native/${file}`, data] as const), ]); const packageDirectory = path.join(root, "package"); await mkdir(packageDirectory); const packagePath = path.join(packageDirectory, PACKAGE_FILE); await writeFile(packagePath, deterministicTarGz("example", packageFiles)); // The reviewed supplement for exactly these package bytes. const supplementsDirectory = path.join(root, "supplements"); const supplementDirectory = path.join(supplementsDirectory, "fake-native", version); await mkdir(path.join(supplementDirectory, "texts"), { recursive: true }); const material = async (content: string) => { await writeFile(path.join(supplementDirectory, "texts", `${sha256(content)}.txt`), content); return `texts/${sha256(content)}.txt`; }; const supplement = { supplementFormat: 1, package: { name: "fake-native", version, license, contentDigest: digest }, statement: "Covers the libraries built into lib/libfake.so.1.", components: [ { name: "fakelib", version: "2.0", license: "LGPL-2.1-or-later", source: "https://example.org/fakelib-2.0.tar.gz", material: [ { title: "COPYING", text: await material("GNU LESSER GENERAL PUBLIC LICENSE (fake)\n") }, ], }, { name: "helperlib", version: "1.1", license: "MIT", source: "https://example.org/helperlib-1.1.tar.gz", material: [{ title: "LICENSE", text: await material("MIT License (fake)\n") }], }, ], }; const supplementJson = `${JSON.stringify(supplement, null, 2)}\n`; await writeFile(path.join(supplementDirectory, "supplement.json"), supplementJson); await writeFile(path.join(supplementDirectory, "PROVENANCE.md"), "# How this was assembled\n"); // The recipe, and a cache holding the recorded bytes. const recipesDirectory = path.join(root, "recipes"); const cache = path.join(root, "cache"); await mkdir(path.join(cache, "sha256"), { recursive: true }); const file = async (relative: string) => { const content = SOURCES[relative] ?? ""; await writeFile(path.join(cache, "sha256", sha256(content)), content); return { file: relative, origin: `https://example.org/${path.basename(relative)}`, sha256: sha256(content), size: Buffer.byteLength(content), }; }; const building = "# Rebuilding libfake\n\nRun `make` in fake-build.\n"; const recipe: Record = { recipeFormat: 1, package: { name: "fake-native", version, license, contentDigest: digest, target: "linux-x64-glibc", binaries: ["lib/libfake.so.1"], }, supplement: { version, sha256: sha256(supplementJson) }, documents: [ { path: "BUILDING.md", sha256: sha256(building), size: Buffer.byteLength(building) }, ], components: [ { name: "fake-build", version: "1.0", role: "build-definition", license: "MIT", relationship: "The build definitions.", licenseMaterial: [], sources: [await file("sources/fake-build-1.0.tar.gz")], patches: [], }, { name: "fakelib", version: "2.0", role: "compiled", license: "LGPL-2.1-or-later", relationship: "Compiled into lib/libfake.so.1.", licenseMaterial: [{ name: "fakelib", version: "2.0" }], sources: [await file("sources/fakelib-2.0.tar.gz")], patches: [await file("patches/fakelib-fix.patch")], }, { name: "helperlib", version: "1.1", role: "compiled", license: "MIT", relationship: "Compiled into lib/libfake.so.1.", licenseMaterial: [{ name: "helperlib", version: "1.1" }], sources: [await file("sources/helperlib-1.1.tar.gz")], patches: [], }, { name: "codegen", version: "0.3", role: "build-input", license: "MIT", relationship: "Needed only to build.", licenseMaterial: [], sources: [await file("sources/codegen-0.3.crate")], patches: [], }, ], exclusions: [{ name: "testlib", reason: "Used only by tests." }], }; options.recipe?.(recipe); if (options.withRecipe !== false) { const recipeDirectory = path.join(recipesDirectory, "fake-native", version); await mkdir(recipeDirectory, { recursive: true }); await writeFile( path.join(recipeDirectory, "recipe.json"), `${JSON.stringify(recipe, null, 2)}\n`, ); await writeFile(path.join(recipeDirectory, "BUILDING.md"), building); } return { cache, packagePath, recipesDirectory, root, supplementsDirectory }; } async function build(fx: Fixture, out = path.join(fx.root, "out")) { return buildCorrespondingSource({ cacheDirectory: fx.cache, outDirectory: out, packageArchivePath: fx.packagePath, recipesDirectory: fx.recipesDirectory, supplementsDirectory: fx.supplementsDirectory, }); } function verify(fx: Fixture, source?: string, metadata?: string) { return verifyRedistribution({ packageArchivePath: fx.packagePath, recipesDirectory: fx.recipesDirectory, supplementsDirectory: fx.supplementsDirectory, ...(source === undefined ? {} : { sourceArchivePath: source }), ...(metadata === undefined ? {} : { metadataPath: metadata }), }); } async function rejects(promise: Promise, code: CorrespondingSourceErrorCode) { const error = await promise.then( () => undefined, (caught: unknown) => caught, ); expect(error).toBeInstanceOf(CorrespondingSourceError); expect((error as CorrespondingSourceError).code).toBe(code); return error as CorrespondingSourceError; } /** A built release, ready to tamper with: its source archive's files, and a way to write a changed * archive (and metadata naming it) where the gate will look. */ async function release() { const fx = await fixture(); const out = path.join(fx.root, "out"); await build(fx, out); const sourcePath = path.join(out, SOURCE_FILE); const metadataPath = path.join(out, METADATA_FILE); const files = new Map(); for (const entry of readTarGz(await readFile(sourcePath))) { if (!entry.directory) files.set(entry.path.slice(TOP.length + 1), entry.data); } /** Writes `changed` as the archive and keeps the metadata consistent with its bytes, so only the * change itself can fail the gate. */ const write = async (changed: Map, archive?: Buffer) => { const bytes = archive ?? deterministicTarGz(TOP, changed); await writeFile(sourcePath, bytes); const metadata = JSON.parse(await readFile(metadataPath, "utf8")); const digest = sha256(bytes); metadata.correspondingSource.sha256 = digest; metadata.correspondingSource.size = bytes.length; metadata.correspondingSource.manifestSha256 = sha256( changed.get("corresponding-source.json") ?? "", ); metadata.notice = metadata.notice.replace(/SHA-256 [0-9a-f]{64}/u, `SHA-256 ${digest}`); await writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); }; /** Rewrites the manifest's file list to match `changed`, as a careful forger would. */ const relist = (changed: Map) => { const manifest = JSON.parse(changed.get("corresponding-source.json")?.toString("utf8") ?? "{}"); manifest.files = [...changed] .filter(([file]) => file !== "corresponding-source.json") .map(([file, data]) => ({ path: file, size: data.length, sha256: sha256(data) })) .sort((left: { path: string }, right: { path: string }) => (left.path < right.path ? -1 : 1)); changed.set("corresponding-source.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)); return manifest; }; return { files, fx, metadataPath, out, relist, sourcePath, write }; } describe("building the corresponding-source artifact", () => { it("writes a deterministic archive, its digest, the redistribution metadata, and the release notice", async () => { const fx = await fixture(); const first = await build(fx, path.join(fx.root, "out-a")); // A second build into an unrelated directory, from a moved cache and package copy. const second = await build( fx, path.join(await scratchDirectory("ekklesia-elsewhere-"), "out-b"), ); expect(first.required).toBe(true); expect(first.sourceSha256).toBe(second.sourceSha256); expect(await readFile(first.sourceArchivePath ?? "")).toEqual( await readFile(second.sourceArchivePath ?? ""), ); expect(await readFile(`${first.sourceArchivePath}.sha256`, "utf8")).toBe( `${first.sourceSha256} ${SOURCE_FILE}\n`, ); const entries = readTarGz(await readFile(first.sourceArchivePath ?? "")); const files = entries .filter((entry) => !entry.directory) .map((entry) => entry.path.slice(TOP.length + 1)); expect(files).toEqual([ "README.md", "SHA256SUMS", "corresponding-source.json", "dependencies/fake-native/1.0.0/BUILDING.md", "dependencies/fake-native/1.0.0/PROVENANCE.md", "dependencies/fake-native/1.0.0/licenses/supplement.json", expect.stringMatching( /^dependencies\/fake-native\/1\.0\.0\/licenses\/texts\/[0-9a-f]{64}\.txt$/u, ), expect.stringMatching( /^dependencies\/fake-native\/1\.0\.0\/licenses\/texts\/[0-9a-f]{64}\.txt$/u, ), "dependencies/fake-native/1.0.0/patches/fakelib-fix.patch", "dependencies/fake-native/1.0.0/recipe.json", "dependencies/fake-native/1.0.0/sources/codegen-0.3.crate", "dependencies/fake-native/1.0.0/sources/fake-build-1.0.tar.gz", "dependencies/fake-native/1.0.0/sources/fakelib-2.0.tar.gz", "dependencies/fake-native/1.0.0/sources/helperlib-1.1.tar.gz", ]); // The actual bytes, not references to them. const patch = entries.find((entry) => entry.path.endsWith("patches/fakelib-fix.patch")); expect(patch?.data.toString("utf8")).toBe(SOURCES["patches/fakelib-fix.patch"]); const manifest = JSON.parse( entries .find((entry) => entry.path.endsWith("/corresponding-source.json")) ?.data.toString("utf8") ?? "{}", ); expect(manifest.module).toEqual({ id: "example", target: "linux-x64-glibc", version: "0.1.0" }); expect(manifest.dependencies[0]).toMatchObject({ binaries: ["lib/libfake.so.1"], exclusions: [{ name: "testlib", reason: "Used only by tests." }], name: "fake-native", version: "1.0.0", }); expect( manifest.dependencies[0].components.map((component: { role: string }) => component.role), ).toEqual(["build-definition", "compiled", "compiled", "build-input"]); const metadata = JSON.parse(await readFile(first.metadataPath ?? "", "utf8")); expect(metadata).toMatchObject({ correspondingSource: { file: SOURCE_FILE, sha256: first.sourceSha256, manifestSha256: first.manifestSha256, }, covered: [{ name: "fake-native", version: "1.0.0" }], package: { file: PACKAGE_FILE, sha256: sha256(await readFile(fx.packagePath)) }, }); const notice = await readFile(first.noticePath ?? "", "utf8"); expect(notice).toBe(metadata.notice); expect(notice).toContain( "This release includes third-party software covered by licenses including the GNU LGPL", ); expect(notice).toContain( "Corresponding source and build/relink materials for the covered native dependencies", ); expect(notice).toContain(SOURCE_FILE); expect(notice).toContain(`SHA-256 ${first.sourceSha256}`); // Nothing machine-specific in what the build generated. for (const generated of ["README.md", "SHA256SUMS", "corresponding-source.json"]) { const content = entries.find((entry) => entry.path === `${TOP}/${generated}`)?.data.toString("utf8") ?? ""; expect(content).not.toContain(fx.root); expect(content).not.toContain(fx.cache); } expect(JSON.stringify(metadata)).not.toContain(fx.root); }); it("is approved by the gate exactly as built", async () => { const { fx, metadataPath, sourcePath } = await release(); await expect(verify(fx, sourcePath, metadataPath)).resolves.toMatchObject({ approved: true, correspondingSource: { file: SOURCE_FILE }, covered: [{ name: "fake-native", version: "1.0.0" }], }); }); it("refuses to build from cached bytes that differ from the recipe, and writes nothing", async () => { const fx = await fixture(); await writeFile( path.join(fx.cache, "sha256", sha256(SOURCES["patches/fakelib-fix.patch"] ?? "")), "changed patch\n", ); await rejects(build(fx), "CORRESPONDING_SOURCE_SOURCE_MISMATCH"); await rm(path.join(fx.cache, "sha256", sha256(SOURCES["sources/fakelib-2.0.tar.gz"] ?? ""))); await expect(readFile(path.join(fx.root, "out", SOURCE_FILE))).rejects.toThrow(); }); it("refuses a missing cached file, and never overwrites an existing release", async () => { const fx = await fixture(); await rm(path.join(fx.cache, "sha256", sha256(SOURCES["sources/helperlib-1.1.tar.gz"] ?? ""))); await rejects(build(fx), "CORRESPONDING_SOURCE_SOURCE_MISSING"); const fresh = await fixture(); await build(fresh); await rejects(build(fresh), "CORRESPONDING_SOURCE_OUTPUT_EXISTS"); }); it("builds nothing for a package with no covered native code, and the gate approves it without an artifact", async () => { const fx = await fixture({ license: "MIT", withRecipe: false }); // The fixture's supplement records an LGPL component, so remove it: a package whose own files and // licence carry no source obligation needs no corresponding source. await rm(fx.supplementsDirectory, { force: true, recursive: true }); await expect(build(fx)).resolves.toEqual({ required: false }); await expect(verify(fx)).resolves.toMatchObject({ approved: true, covered: [] }); }); it("treats a dependency as covered when its reviewed supplement records a copyleft component", async () => { // Declared MIT, but the supplement says an LGPL library is built into it. const fx = await fixture({ license: "MIT", withRecipe: false }); // The supplement's declared licence must match the package's for the supplement to apply. const supplementPath = path.join( fx.supplementsDirectory, "fake-native", "1.0.0", "supplement.json", ); const supplement = JSON.parse(await readFile(supplementPath, "utf8")); expect(supplement.package.license).toBe("MIT"); await rejects(build(fx), "CORRESPONDING_SOURCE_RECIPE_MISSING"); }); }); describe("the public-distribution gate fails closed", () => { it("without the corresponding-source archive, or without the redistribution metadata", async () => { const { fx, metadataPath, sourcePath } = await release(); await rejects(verify(fx), "CORRESPONDING_SOURCE_ARTIFACT_MISSING"); await rm(sourcePath); await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_ARTIFACT_MISSING"); const second = await release(); await rejects(verify(second.fx, second.sourcePath), "CORRESPONDING_SOURCE_METADATA_MISMATCH"); }); it("when the metadata names a different source digest, or the archive's bytes are corrupted", async () => { const { fx, metadataPath, sourcePath } = await release(); const metadata = JSON.parse(await readFile(metadataPath, "utf8")); metadata.correspondingSource.sha256 = "0".repeat(64); await writeFile(metadataPath, JSON.stringify(metadata)); await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_METADATA_MISMATCH"); const corrupt = await release(); const bytes = await readFile(corrupt.sourcePath); const middle = Math.floor(bytes.length / 2); bytes[middle] = (bytes[middle] ?? 0) ^ 0xff; await writeFile(corrupt.sourcePath, bytes); await rejects( verify(corrupt.fx, corrupt.sourcePath, corrupt.metadataPath), "CORRESPONDING_SOURCE_ARTIFACT_INVALID", ); }); it("when the release notice does not name the exact artifact", async () => { const { fx, metadataPath, sourcePath } = await release(); const metadata = JSON.parse(await readFile(metadataPath, "utf8")); metadata.notice = metadata.notice.replace(SOURCE_FILE, "some-other-source.tar.gz"); await writeFile(metadataPath, JSON.stringify(metadata)); await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_METADATA_MISMATCH"); }); it("when the native package's bytes are not those the recipe was prepared for", async () => { const built = await release(); // The same name and version, different library bytes: a different content digest. const other = await fixture({ libraryBytes: "\u007fELF a different build" }); await writeFile(built.fx.packagePath, await readFile(other.packagePath)); await rejects( verify(built.fx, built.sourcePath, built.metadataPath), "CORRESPONDING_SOURCE_RECIPE_MISMATCH", ); }); it("when a source component's version differs from the recipe", async () => { const { files, fx, metadataPath, relist, sourcePath, write } = await release(); const manifest = JSON.parse(files.get("corresponding-source.json")?.toString("utf8") ?? "{}"); manifest.dependencies[0].components[1].version = "2.1"; files.set("corresponding-source.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)); relist(files); await write(files); const error = await rejects( verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_MANIFEST_MISMATCH", ); expect(error.subject).toBe("fake-native@1.0.0"); }); it("when a required source component is missing", async () => { const { files, fx, metadataPath, relist, sourcePath, write } = await release(); const manifest = JSON.parse(files.get("corresponding-source.json")?.toString("utf8") ?? "{}"); manifest.dependencies[0].components.splice(2, 1); files.set("corresponding-source.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)); files.delete("dependencies/fake-native/1.0.0/sources/helperlib-1.1.tar.gz"); relist(files); await write(files); await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_MANIFEST_MISMATCH"); }); it("when a patch's bytes changed, even with the file list rewritten to match", async () => { const { files, fx, metadataPath, relist, sourcePath, write } = await release(); files.set( "dependencies/fake-native/1.0.0/patches/fakelib-fix.patch", Buffer.from("a different patch\n"), ); relist(files); await write(files); await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_MANIFEST_MISMATCH"); }); it("when the manifest records a file the archive does not hold", async () => { const { files, fx, metadataPath, sourcePath, write } = await release(); files.delete("dependencies/fake-native/1.0.0/sources/fakelib-2.0.tar.gz"); await write(files); const error = await rejects( verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_MANIFEST_MISMATCH", ); expect(error.subject).toBe("dependencies/fake-native/1.0.0/sources/fakelib-2.0.tar.gz"); }); it("when the archive holds source material its manifest does not record", async () => { const { files, fx, metadataPath, sourcePath, write } = await release(); files.set( "dependencies/fake-native/1.0.0/sources/unreviewed.tar.gz", Buffer.from("not recorded\n"), ); await write(files); const error = await rejects( verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_UNRECORDED_FILE", ); expect(error.subject).toBe("dependencies/fake-native/1.0.0/sources/unreviewed.tar.gz"); }); it("when a generated file names a machine-specific path", async () => { const { files, fx, metadataPath, relist, sourcePath, write } = await release(); files.set("README.md", Buffer.from("Built from /home/builder/cache/sha256.\n")); relist(files); await write(files); await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_LOCAL_PATH"); }); it("when the archive's entries are not in the deterministic order", async () => { const { files, fx, metadataPath, sourcePath, write } = await release(); // The same entries and bytes, with the last two file entries swapped. const raw = gunzipSync(await readFile(sourcePath)); const chunks: Buffer[] = []; let offset = 0; while (offset < raw.length && !raw.subarray(offset, offset + 512).every((byte) => byte === 0)) { const size = Number.parseInt( raw .subarray(offset + 124, offset + 136) .toString("latin1") .replace(/\0.*$/u, "") .trim() || "0", 8, ); const length = 512 + Math.ceil(size / 512) * 512; chunks.push(raw.subarray(offset, offset + length)); offset += length; } const last = chunks.length - 1; [chunks[last - 1], chunks[last]] = [chunks[last] as Buffer, chunks[last - 1] as Buffer]; await write(files, gzipSync(Buffer.concat([...chunks, Buffer.alloc(1024)]), { level: 9 })); await rejects(verify(fx, sourcePath, metadataPath), "CORRESPONDING_SOURCE_ARTIFACT_INVALID"); }); it("when the artifact was built for another version of the native dependency", async () => { // The 1.3.3-for-1.3.4 case: an artifact for fake-native 1.0.0 beside a package with 1.0.1. const old = await release(); const current = await fixture({ version: "1.0.1" }); await mkdir(path.join(current.recipesDirectory, "fake-native", "1.0.0"), { recursive: true }); for (const file of ["recipe.json", "BUILDING.md"]) { await writeFile( path.join(current.recipesDirectory, "fake-native", "1.0.0", file), await readFile(path.join(old.fx.recipesDirectory, "fake-native", "1.0.0", file)), ); } await rejects( verify(current, old.sourcePath, old.metadataPath), "CORRESPONDING_SOURCE_MANIFEST_MISMATCH", ); }); it("when the package bundles copyleft code, which this mechanism does not cover", async () => { const notices = noticeEntry("fake-native", "1.0.0", "LGPL-2.1-or-later", "node_modules") + noticeEntry("bundled-lgpl", "3.0.0", "LGPL-3.0-only", "backend"); const fx = await fixture({ notices }); await rejects(build(fx), "CORRESPONDING_SOURCE_BUNDLED_COPYLEFT"); await rejects(verify(fx), "CORRESPONDING_SOURCE_BUNDLED_COPYLEFT"); }); it("when a covered dependency has no recipe, or its recipe was prepared against another supplement", async () => { await rejects( build(await fixture({ withRecipe: false })), "CORRESPONDING_SOURCE_RECIPE_MISSING", ); const fx = await fixture({ recipe: (recipe) => { (recipe.supplement as { sha256: string }).sha256 = "f".repeat(64); }, }); await rejects(build(fx), "CORRESPONDING_SOURCE_RECIPE_MISMATCH"); const unknownMaterial = await fixture({ recipe: (recipe) => { const components = recipe.components as { licenseMaterial: { name: string; version: string }[]; }[]; (components[1] as { licenseMaterial: unknown[] }).licenseMaterial = [ { name: "fakelib", version: "9.9" }, ]; }, }); await rejects(build(unknownMaterial), "CORRESPONDING_SOURCE_RECIPE_MISMATCH"); }); }); describe("source recipes are reviewed inputs", () => { async function loadWith(change: (recipe: Record) => void) { const fx = await fixture({ recipe: change }); return loadSourceRecipes(fx.recipesDirectory); } it("refuses an absolute or climbing source path, a non-https origin, or a compiled component without licence material", async () => { const component = (recipe: Record, index: number) => (recipe.components as Record[])[index] as Record; const first = (recipe: Record) => (component(recipe, 1).sources as Record[])[0] as Record; for (const change of [ (recipe: Record) => { first(recipe).file = "/home/builder/fakelib-2.0.tar.gz"; }, (recipe: Record) => { first(recipe).file = "sources/../../fakelib-2.0.tar.gz"; }, (recipe: Record) => { first(recipe).origin = "http://example.org/fakelib-2.0.tar.gz"; }, (recipe: Record) => { component(recipe, 1).licenseMaterial = []; }, (recipe: Record) => { component(recipe, 2).version = "2.0"; component(recipe, 2).name = "fakelib"; }, ]) { await rejects(loadWith(change), "CORRESPONDING_SOURCE_RECIPE_INVALID"); } }); it("refuses a document whose bytes changed, and an unlisted file beside the recipe", async () => { const fx = await fixture(); const directory = path.join(fx.recipesDirectory, "fake-native", "1.0.0"); await writeFile(path.join(directory, "BUILDING.md"), "edited without review\n"); await rejects(loadSourceRecipes(fx.recipesDirectory), "CORRESPONDING_SOURCE_RECIPE_INVALID"); const second = await fixture(); await writeFile( path.join(second.recipesDirectory, "fake-native", "1.0.0", "notes.txt"), "stray\n", ); await rejects( loadSourceRecipes(second.recipesDirectory), "CORRESPONDING_SOURCE_RECIPE_INVALID", ); }); it("recognizes licences with source obligations, by SPDX identifier or by upstream name", () => { for (const licence of [ "LGPL-3.0-or-later", "MPL-2.0", "GPL-2.0-only", "Mozilla Public License 1.1", "MIT AND LGPL-2.1-or-later", "EPL-2.0", ]) { expect(hasSourceObligations(licence), licence).toBe(true); } for (const licence of [ "MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Zlib", "MIT OR Apache-2.0", ]) { expect(hasSourceObligations(licence), licence).toBe(false); } }); }); describe("acquisition is separate, verified, and the only network step", () => { it("fetches each recorded file once, verifies it, and refuses a response that differs", async () => { const fx = await fixture(); const fetched: string[] = []; const served = new Map( Object.entries(SOURCES).map(([file, content]) => [ `https://example.org/${path.basename(file)}`, content, ]), ); const empty = path.join(fx.root, "fresh-cache"); const result = await acquireCorrespondingSources({ cacheDirectory: empty, fetch: async (url) => { fetched.push(url); return Buffer.from(served.get(url) ?? ""); }, packageArchivePath: fx.packagePath, recipesDirectory: fx.recipesDirectory, supplementsDirectory: fx.supplementsDirectory, }); expect(result).toMatchObject({ fetched: 5, files: 5 }); // A second run finds everything verified and fetches nothing. const again = await acquireCorrespondingSources({ cacheDirectory: empty, fetch: async () => { throw new Error("no network expected"); }, recipes: [{ name: "fake-native", version: "1.0.0" }], recipesDirectory: fx.recipesDirectory, }); expect(again).toMatchObject({ fetched: 0, files: 5 }); // The built artifact from the acquired cache is identical to one built from the original cache. const fromAcquired = await buildCorrespondingSource({ cacheDirectory: empty, outDirectory: path.join(fx.root, "acquired-out"), packageArchivePath: fx.packagePath, recipesDirectory: fx.recipesDirectory, supplementsDirectory: fx.supplementsDirectory, }); expect(fromAcquired.sourceSha256).toBe((await build(fx)).sourceSha256); // A transient upstream failure is retried; only the recorded bytes are ever stored. const flaky = path.join(fx.root, "flaky-cache"); const attempts = new Map(); await expect( acquireCorrespondingSources({ cacheDirectory: flaky, fetch: async (url) => { const count = (attempts.get(url) ?? 0) + 1; attempts.set(url, count); if (count === 1) { throw new Error("connection reset"); } return Buffer.from(served.get(url) ?? ""); }, recipes: [{ name: "fake-native", version: "1.0.0" }], recipesDirectory: fx.recipesDirectory, }), ).resolves.toMatchObject({ fetched: 5 }); const tampered = path.join(fx.root, "tampered-cache"); await rejects( acquireCorrespondingSources({ cacheDirectory: tampered, fetch: async (url) => Buffer.from(`${served.get(url) ?? ""}upstream changed\n`), recipes: [{ name: "fake-native", version: "1.0.0" }], recipesDirectory: fx.recipesDirectory, }), "CORRESPONDING_SOURCE_ACQUISITION_FAILED", ); const { readdir } = await import("node:fs/promises"); expect(await readdir(path.join(tampered, "sha256"))).toEqual([]); }); });