CI / verify (pull_request) Successful in 1m3s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 36s
CI / package-publication (pull_request) Canceled after 0s
CI / deploy-smoke (same-origin) (pull_request) Canceled after 0s
CI / deploy-smoke (split) (pull_request) Canceled after 0s
CI / image-build (pull_request) Canceled after 30s
Ekklesia may publicly distribute a native Module Package containing LGPL or MPL components only beside a version-specific corresponding-source artifact. The artifact carries the exact source, patches, build definitions, and rebuild and relink instructions for those components. ADR 0055 (Accepted) records the policy: project compliance policy, not legal certification. It decides the item ADR 0052 deferred, and extends ADR 0049's distributable-release gate. Module Package v1, packageFormat 1, and both libvips supplements are unchanged. The mechanism is generic and lives in @ekklesia/module-pack: - Source recipes: reviewed data under corresponding-sources/<package>/<version>/. recipe.json names the exact package (name, version, content digest) and the exact supplement it was prepared with, and lists every component with its role, licence, licence material, and relationship to the binary. Every source file and patch is recorded by origin, size, and SHA-256; documents are hashed too, and every exclusion has a reason. Loading is strict and refuses anything unlisted. - Acquisition (the only network step) fetches each recorded file once into a cache keyed by SHA-256. It uses node:https with exact headers and no decoding, retries transient failures, and stores only bytes that match. - Construction is offline. From a built package it finds the covered native dependencies (by declared licence, or by a supplement component's licence), verifies every byte, and writes four sibling artifacts: the deterministic <package>-corresponding-source.tar.gz and its .sha256; <package>.redistribution.json, linking package, dependencies, supplement, recipe, and source by digest; and <package>.source-notice.txt, the release notice. - The public-distribution gate (verifyRedistribution, `ekklesia-corresponding-source verify`) approves only when the recipe, supplement, canonical archive, manifest, and metadata identify exactly these files. It refuses bundled copyleft code, and approves packages with no covered native code without an artifact. packModule is untouched. First complete proof: @img/[email protected] (sharp 0.35.5), from the reviewed supplement and provenance. - 383 components and 387 source files, about 208 MB: the sharp-libvips v1.3.4 build definitions, the 27 library archives, the five build-time patches, the 353 crates librsvg's resolved Cargo.lock records, and rust-src for nightly-2026-09-27 (rustc 75a75c3e0), which vendors std's dependencies. - BUILDING.md and two helpers point sharp-libvips's unchanged build logic at those files. - Followed in a Rocky Linux 8 container with networking disabled, they rebuilt libvips-cpp.so.8.18.7, byte-identically across two builds. Swapped into a verified, extracted native package, the rebuilt library was the one loaded, and sharp ran. - The native proof's source artifact, native-proof-0.1.0-linux-x64-glibc-corresponding-source.tar.gz, is 207242785 bytes, sha256 cc405492032b04a5391ab84e64f0e5e50f78e571ef7818204782c25a03eb50b7. It is byte-identical from unrelated directories and from a cache acquired fresh from upstream. Tests: - corresponding-source.test.ts runs in CI: synthetic inputs, every negative case, acquisition with retries and refusal, and determinism. - corresponding-source-native.test.ts checks the real recipe everywhere. With a verified cache (`pnpm test:corresponding-source`, which acquires once) it builds the real artifact twice, gates it, fails it closed, and runs sharp from the package. Docs: the module guide's distribution section, the README and architecture notes, and an ADR 0052 cross-reference. No Signage change, version bump, publication, or tag. Co-Authored-By: Claude Opus 5.5 <[email protected]>
775 lines
32 KiB
TypeScript
775 lines
32 KiB
TypeScript
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<Record<string, string>> = {
|
|
"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<string, unknown>) => 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<Fixture> {
|
|
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<string, Buffer>([
|
|
["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<string, Buffer>([
|
|
[
|
|
"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<string, unknown> = {
|
|
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<unknown>, 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<string, Buffer>();
|
|
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<string, Buffer>, 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<string, Buffer>) => {
|
|
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("[email protected]");
|
|
});
|
|
|
|
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<string, unknown>) => 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<string, unknown>, index: number) =>
|
|
(recipe.components as Record<string, unknown>[])[index] as Record<string, unknown>;
|
|
const first = (recipe: Record<string, unknown>) =>
|
|
(component(recipe, 1).sources as Record<string, unknown>[])[0] as Record<string, unknown>;
|
|
for (const change of [
|
|
(recipe: Record<string, unknown>) => {
|
|
first(recipe).file = "/home/builder/fakelib-2.0.tar.gz";
|
|
},
|
|
(recipe: Record<string, unknown>) => {
|
|
first(recipe).file = "sources/../../fakelib-2.0.tar.gz";
|
|
},
|
|
(recipe: Record<string, unknown>) => {
|
|
first(recipe).origin = "http://example.org/fakelib-2.0.tar.gz";
|
|
},
|
|
(recipe: Record<string, unknown>) => {
|
|
component(recipe, 1).licenseMaterial = [];
|
|
},
|
|
(recipe: Record<string, unknown>) => {
|
|
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<string, number>();
|
|
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([]);
|
|
});
|
|
});
|