import { createHash } from "node:crypto"; import { lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { get as httpsGet } from "node:https"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { contentDigest, DEFAULT_SUPPLEMENTS_DIRECTORY, type LicenseSupplement, loadLicenseSupplements, SupplementError, } from "./supplements.js"; import { deterministicTarGz, isCanonicalTarGz, readTarGz, TarFormatError } from "./tar.js"; /** * Corresponding-source artifacts for native packages that redistribute copyleft code (ADR 0055). * * A Module Package whose native dependencies include code under the GNU LGPL, the MPL, or a similar * licence with source obligations may be distributed publicly only beside an artifact carrying the * exact source, patches, and rebuild and relink instructions for those dependencies. This file is * the whole mechanism, and it knows no particular library: * * - **Recipes** are reviewed data, checked in beside the packager at * `corresponding-sources///`: `recipe.json` and the documents it lists. A * recipe names one exact native package (name, version, content digest), the reviewed supplement * it was prepared with, and every source file and patch by origin, size, and SHA-256. * - **Acquisition** (`acquireCorrespondingSources`) is the only step that uses the network. It * downloads each recorded file once, verifies it, and stores it in a cache keyed by SHA-256. * - **Construction** (`buildCorrespondingSource`) is offline. Given a built Module Package, it finds * the covered native dependencies, verifies every byte it will use against the recipe, and writes * the deterministic source archive, the redistribution metadata linking it to the package by * digest, and the release notice. Nothing is written until everything has verified. * - **The gate** (`verifyRedistribution`) approves a package for public distribution only when all of * that holds for the exact files presented, and fails closed otherwise. * * Packaging for development and testing is unaffected: `packModule` never consults any of this. * * This is Ekklesia's project compliance policy. Nothing here is a legal determination, and no output * claims to be one. */ export const RECIPE_FORMAT = 1; export const CORRESPONDING_SOURCE_FORMAT = 1; export const REDISTRIBUTION_FORMAT = 1; export const DEFAULT_RECIPES_DIRECTORY = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", "corresponding-sources", ); export type CorrespondingSourceErrorCode = | "CORRESPONDING_SOURCE_ACQUISITION_FAILED" | "CORRESPONDING_SOURCE_ARTIFACT_INVALID" | "CORRESPONDING_SOURCE_ARTIFACT_MISSING" | "CORRESPONDING_SOURCE_BUNDLED_COPYLEFT" | "CORRESPONDING_SOURCE_LOCAL_PATH" | "CORRESPONDING_SOURCE_MANIFEST_MISMATCH" | "CORRESPONDING_SOURCE_METADATA_MISMATCH" | "CORRESPONDING_SOURCE_OUTPUT_EXISTS" | "CORRESPONDING_SOURCE_PACKAGE_INVALID" | "CORRESPONDING_SOURCE_RECIPE_INVALID" | "CORRESPONDING_SOURCE_RECIPE_MISMATCH" | "CORRESPONDING_SOURCE_RECIPE_MISSING" | "CORRESPONDING_SOURCE_SOURCE_MISMATCH" | "CORRESPONDING_SOURCE_SOURCE_MISSING" | "CORRESPONDING_SOURCE_SUPPLEMENT_MISMATCH" | "CORRESPONDING_SOURCE_UNRECORDED_FILE"; const MESSAGES: Readonly> = Object.freeze({ CORRESPONDING_SOURCE_ACQUISITION_FAILED: "A recorded source file could not be acquired with its recorded size and SHA-256.", CORRESPONDING_SOURCE_ARTIFACT_INVALID: "The corresponding-source archive is not in Ekklesia's deterministic encoding.", CORRESPONDING_SOURCE_ARTIFACT_MISSING: "The package redistributes covered native code, but no corresponding-source archive was given.", CORRESPONDING_SOURCE_BUNDLED_COPYLEFT: "The package bundles code under a licence with source obligations, which this mechanism does not cover.", CORRESPONDING_SOURCE_LOCAL_PATH: "A generated file names a machine-specific path.", CORRESPONDING_SOURCE_MANIFEST_MISMATCH: "The corresponding-source manifest does not match the recipe, the package, or the archive's own files.", CORRESPONDING_SOURCE_METADATA_MISMATCH: "The redistribution metadata or release notice does not identify these exact artifacts.", CORRESPONDING_SOURCE_OUTPUT_EXISTS: "A corresponding-source output already exists.", CORRESPONDING_SOURCE_PACKAGE_INVALID: "The Module Package cannot be read.", CORRESPONDING_SOURCE_RECIPE_INVALID: "A corresponding-source recipe is malformed or does not match its reviewed files.", CORRESPONDING_SOURCE_RECIPE_MISMATCH: "The recipe for a covered native dependency was prepared for different package bytes or a different supplement.", CORRESPONDING_SOURCE_RECIPE_MISSING: "A covered native dependency has no corresponding-source recipe.", CORRESPONDING_SOURCE_SOURCE_MISMATCH: "A cached source file does not have its recorded size and SHA-256.", CORRESPONDING_SOURCE_SOURCE_MISSING: "A recorded source file is not in the source cache.", CORRESPONDING_SOURCE_SUPPLEMENT_MISMATCH: "A covered native dependency has no reviewed supplement matching it exactly.", CORRESPONDING_SOURCE_UNRECORDED_FILE: "The corresponding-source archive holds a file its manifest does not record.", }); export class CorrespondingSourceError extends Error { readonly code: CorrespondingSourceErrorCode; /** What failed, by package, component, or archive-relative path; never a local path. */ readonly subject: string; constructor(code: CorrespondingSourceErrorCode, subject = "") { super(MESSAGES[code]); this.name = "CorrespondingSourceError"; this.code = code; this.subject = subject; } } // ------------------------------------------------------------------------------------------------- // Recipes // ------------------------------------------------------------------------------------------------- export type SourceComponentRole = "build-definition" | "build-input" | "compiled"; const ROLES: readonly SourceComponentRole[] = ["build-definition", "build-input", "compiled"]; export interface RecipeFile { /** Relative to the dependency's directory in the artifact: `sources/…` or `patches/…`. */ readonly file: string; /** Where the bytes were obtained from. Acquisition only; construction never reads it. */ readonly origin: string; readonly sha256: string; readonly size: number; } export interface SourceComponent { readonly name: string; readonly version: string; readonly role: SourceComponentRole; readonly license: string; readonly relationship: string; /** The supplement components carrying this component's licence material. */ readonly licenseMaterial: readonly { readonly name: string; readonly version: string }[]; /** For source that ships inside another component's archive, such as a bundled subproject. */ readonly includedIn?: { readonly component: string; readonly path: string }; readonly sources: readonly RecipeFile[]; readonly patches: readonly RecipeFile[]; } export interface SourceRecipe { readonly package: { readonly name: string; readonly version: string; readonly license: string; readonly contentDigest: string; readonly target: string; readonly binaries: readonly string[]; }; readonly supplement: { readonly version: string; readonly sha256: string }; readonly documents: readonly { readonly path: string; readonly sha256: string; readonly size: number; }[]; readonly components: readonly SourceComponent[]; readonly exclusions: readonly { readonly name: string; readonly reason: string }[]; } /** A loaded recipe, with its reviewed files' exact bytes. */ export interface LoadedRecipe { readonly recipe: SourceRecipe; readonly recipeBytes: Buffer; readonly recipeSha256: string; readonly documents: ReadonlyMap; } const SHA256 = /^[0-9a-f]{64}$/u; const DIGEST = /^ekklesia-content-v1:sha256:[0-9a-f]{64}$/u; function sha256(data: Uint8Array): string { return createHash("sha256").update(data).digest("hex"); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** A non-empty string with no control character other than tab and line feed. */ function text(value: unknown): value is string { if (typeof value !== "string" || value.trim() === "") { return false; } for (const character of value) { const code = character.charCodeAt(0); if ((code < 0x20 && code !== 0x09 && code !== 0x0a) || code === 0x7f) { return false; } } return true; } function exactKeys( value: Record, required: readonly string[], optional: readonly string[] = [], ) { return ( required.every((key) => key in value) && Object.keys(value).every((key) => required.includes(key) || optional.includes(key)) ); } /** A relative, normalized, portable path: `/` separators, printable ASCII, no `.` or `..` segment. */ function portablePath(value: unknown): value is string { return ( typeof value === "string" && /^[\x21-\x7e]+$/u.test(value) && !value.startsWith("/") && !value.includes("\\") && value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..") ); } function compareText(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } function parseRecipeFile(value: unknown): RecipeFile | undefined { if ( !isRecord(value) || !exactKeys(value, ["file", "origin", "sha256", "size"]) || !portablePath(value.file) || !/^(?:sources|patches)\//u.test(value.file) || typeof value.origin !== "string" || !/^https:\/\/\S+$/u.test(value.origin) || typeof value.sha256 !== "string" || !SHA256.test(value.sha256) || typeof value.size !== "number" || !Number.isSafeInteger(value.size) || value.size < 0 ) { return undefined; } return { file: value.file, origin: value.origin, sha256: value.sha256, size: value.size }; } function parseComponent(value: unknown): SourceComponent | undefined { if ( !isRecord(value) || !exactKeys( value, [ "name", "version", "role", "license", "relationship", "licenseMaterial", "sources", "patches", ], ["includedIn"], ) || !text(value.name) || !text(value.version) || !ROLES.includes(value.role as SourceComponentRole) || !text(value.license) || !text(value.relationship) || !Array.isArray(value.licenseMaterial) || !Array.isArray(value.sources) || !Array.isArray(value.patches) ) { return undefined; } const licenseMaterial: { name: string; version: string }[] = []; for (const material of value.licenseMaterial) { if ( !isRecord(material) || !exactKeys(material, ["name", "version"]) || !text(material.name) || !text(material.version) ) { return undefined; } licenseMaterial.push({ name: material.name, version: material.version }); } const sources = value.sources.map(parseRecipeFile); const patches = value.patches.map(parseRecipeFile); if (sources.some((file) => file === undefined) || patches.some((file) => file === undefined)) { return undefined; } let includedIn: SourceComponent["includedIn"]; if (value.includedIn !== undefined) { const inside = value.includedIn; if ( !isRecord(inside) || !exactKeys(inside, ["component", "path"]) || !text(inside.component) || !portablePath(inside.path) ) { return undefined; } includedIn = { component: inside.component, path: inside.path }; } const role = value.role as SourceComponentRole; // A compiled component carries its own source (or names the archive it ships in) and reviewed // licence material. Build inputs carry their licence files inside their own source. if ((sources.length === 0) !== (includedIn !== undefined)) { return undefined; } if (role === "compiled" && licenseMaterial.length === 0) { return undefined; } return { ...(includedIn === undefined ? {} : { includedIn }), license: value.license, licenseMaterial, name: value.name, patches: patches as RecipeFile[], relationship: value.relationship, role, sources: sources as RecipeFile[], version: value.version, }; } /** Validates a recipe's structure strictly. Exported for tests and tools; loading also checks files. */ export function parseSourceRecipe(raw: unknown): SourceRecipe | undefined { if ( !isRecord(raw) || !exactKeys(raw, [ "recipeFormat", "package", "supplement", "documents", "components", "exclusions", ]) || raw.recipeFormat !== RECIPE_FORMAT || !isRecord(raw.package) || !exactKeys(raw.package, [ "name", "version", "license", "contentDigest", "target", "binaries", ]) || !isRecord(raw.supplement) || !exactKeys(raw.supplement, ["version", "sha256"]) || !Array.isArray(raw.documents) || !Array.isArray(raw.components) || !Array.isArray(raw.exclusions) ) { return undefined; } const pkg = raw.package; if ( !text(pkg.name) || !text(pkg.version) || !text(pkg.license) || typeof pkg.contentDigest !== "string" || !DIGEST.test(pkg.contentDigest) || !text(pkg.target) || !Array.isArray(pkg.binaries) || pkg.binaries.length === 0 || !pkg.binaries.every(portablePath) || !text(raw.supplement.version) || typeof raw.supplement.sha256 !== "string" || !SHA256.test(raw.supplement.sha256) ) { return undefined; } const documents: { path: string; sha256: string; size: number }[] = []; for (const document of raw.documents) { if ( !isRecord(document) || !exactKeys(document, ["path", "sha256", "size"]) || !portablePath(document.path) || /^(?:sources|patches|licenses)\//u.test(document.path) || ["PROVENANCE.md", "recipe.json"].includes(document.path) || typeof document.sha256 !== "string" || !SHA256.test(document.sha256) || typeof document.size !== "number" || !Number.isSafeInteger(document.size) ) { return undefined; } documents.push({ path: document.path, sha256: document.sha256, size: document.size }); } const components = raw.components.map(parseComponent); if (components.length === 0 || components.some((component) => component === undefined)) { return undefined; } const parsed = components as SourceComponent[]; // Every source and patch has its own path, and every component its own name and version. const files = parsed.flatMap((component) => [...component.sources, ...component.patches].map((file) => file.file), ); const identities = parsed.map((component) => `${component.name}\u0000${component.version}`); if (new Set(files).size !== files.length || new Set(identities).size !== identities.length) { return undefined; } for (const component of parsed) { const parent = component.includedIn?.component; if ( parent !== undefined && !parsed.some((other) => other.name === parent && other.sources.length > 0) ) { return undefined; } } const exclusions: { name: string; reason: string }[] = []; for (const exclusion of raw.exclusions) { if ( !isRecord(exclusion) || !exactKeys(exclusion, ["name", "reason"]) || !text(exclusion.name) || !text(exclusion.reason) ) { return undefined; } exclusions.push({ name: exclusion.name, reason: exclusion.reason }); } return { components: parsed, documents, exclusions, package: { binaries: pkg.binaries as string[], contentDigest: pkg.contentDigest, license: pkg.license, name: pkg.name, target: pkg.target, version: pkg.version, }, supplement: { sha256: raw.supplement.sha256, version: raw.supplement.version }, }; } async function listFiles(root: string, relative = ""): Promise { const directory = relative === "" ? root : path.join(root, relative); const found: string[] = []; for (const entry of await readdir(directory, { withFileTypes: true })) { const child = relative === "" ? entry.name : `${relative}/${entry.name}`; const stats = await lstat(path.join(directory, entry.name)); if (stats.isDirectory()) { found.push(...(await listFiles(root, child))); } else if (stats.isFile()) { found.push(child); } else { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_RECIPE_INVALID", child); } } return found; } /** * Loads every recipe under `directory`, indexed by package name. Like supplements, a recipe is a * reviewed input: each document must match its recorded size and SHA-256, the directory must be * `//` for the recipe's own package, and nothing unlisted may sit beside it. Any * malformed recipe fails the whole load. */ export async function loadSourceRecipes( directory: string = DEFAULT_RECIPES_DIRECTORY, ): Promise> { const index = new Map(); let files: string[]; try { files = await listFiles(directory); } catch (error) { if (isRecord(error) && error.code === "ENOENT") { return index; } throw error; } const roots = files .filter((file) => file.endsWith("/recipe.json")) .map((file) => file.slice(0, -"/recipe.json".length)) .sort(compareText); for (const label of roots) { const recipeBytes = await readFile(path.join(directory, ...label.split("/"), "recipe.json")); let raw: unknown; try { raw = JSON.parse(recipeBytes.toString("utf8")); } catch { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_RECIPE_INVALID", label); } const recipe = parseSourceRecipe(raw); if (recipe === undefined || label !== `${recipe.package.name}/${recipe.package.version}`) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_RECIPE_INVALID", label); } const documents = new Map(); for (const document of recipe.documents) { let bytes: Buffer; try { bytes = await readFile( path.join(directory, ...label.split("/"), ...document.path.split("/")), ); } catch { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_RECIPE_INVALID", `${label}/${document.path}`, ); } if (bytes.length !== document.size || sha256(bytes) !== document.sha256) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_RECIPE_INVALID", `${label}/${document.path}`, ); } documents.set(document.path, bytes); } const allowed = new Set(["recipe.json", ...recipe.documents.map((document) => document.path)]); for (const file of files.filter((candidate) => candidate.startsWith(`${label}/`))) { if (!allowed.has(file.slice(label.length + 1))) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_RECIPE_INVALID", file); } } const list = index.get(recipe.package.name) ?? []; list.push({ documents, recipe, recipeBytes, recipeSha256: sha256(recipeBytes) }); index.set(recipe.package.name, list); } for (const file of files) { if (!roots.some((root) => file.startsWith(`${root}/`))) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_RECIPE_INVALID", file); } } return index; } // ------------------------------------------------------------------------------------------------- // What a Module Package redistributes // ------------------------------------------------------------------------------------------------- /** Licences whose terms carry source or similar obligations. Matched against SPDX identifiers and * against licence names as upstream projects state them. Deliberately broad: a false positive asks * for a source artifact; a false negative would approve what it should not. */ const COPYLEFT = /\b(?:A?GPL|LGPL|MPL|EPL|EUPL|CDDL|OSL|CPL)(?:[-\s]|\b)|General Public License|Mozilla Public License|Eclipse Public License|Common Development and Distribution License|European Union Public Licen[cs]e/iu; export function hasSourceObligations(licence: string): boolean { return COPYLEFT.test(licence); } export interface NativeDependency { readonly name: string; readonly version: string; readonly license: string; readonly contentDigest: string; } export interface PackageInventory { readonly fileName: string; readonly sha256: string; readonly module: { readonly id: string; readonly version: string }; readonly target: string; readonly native: readonly NativeDependency[]; /** Third-party code bundled into an artifact (not copied as files) under a copyleft licence. */ readonly bundledCopyleft: readonly string[]; } const RULE = "-".repeat(80); const NOTICE_ENTRY = new RegExp( `^${RULE}\\n(\\S+) (\\S+)\\nLicense: (.+)\\nUsed in: (.+)\\n${RULE}$`, "gmu", ); /** Reads what a built Module Package redistributes, from the archive alone. */ export function inventoryModulePackage(fileName: string, archive: Buffer): PackageInventory { let entries: ReturnType; try { entries = readTarGz(archive); } catch { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_PACKAGE_INVALID", fileName); } const top = entries[0]?.path; if (top === undefined || top.includes("/")) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_PACKAGE_INVALID", fileName); } const files = new Map(); for (const entry of entries) { if (!entry.directory && entry.path.startsWith(`${top}/`)) { files.set(entry.path.slice(top.length + 1), entry.data); } } let metadata: unknown; try { metadata = JSON.parse(files.get("ekklesia.package.json")?.toString("utf8") ?? ""); } catch { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_PACKAGE_INVALID", fileName); } if ( !isRecord(metadata) || !isRecord(metadata.module) || !text(metadata.module.id) || !text(metadata.module.version) || !text(metadata.target) ) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_PACKAGE_INVALID", fileName); } const roots = new Set(); for (const filePath of files.keys()) { const match = /^node_modules\/((?:@[^/]+\/)?[^/]+)\/package\.json$/u.exec(filePath); if (match?.[1]) { roots.add(match[1]); } } const native: NativeDependency[] = []; for (const root of [...roots].sort(compareText)) { const prefix = `node_modules/${root}/`; const own = new Map(); for (const [filePath, data] of files) { if (filePath.startsWith(prefix)) { own.set(filePath.slice(prefix.length), data); } } let manifest: unknown; try { manifest = JSON.parse(own.get("package.json")?.toString("utf8") ?? ""); } catch { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_PACKAGE_INVALID", root); } if (!isRecord(manifest) || manifest.name !== root || !text(manifest.version)) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_PACKAGE_INVALID", root); } native.push({ contentDigest: contentDigest(own), license: typeof manifest.license === "string" ? manifest.license : "", name: root, version: manifest.version, }); } const bundledCopyleft: string[] = []; const notices = files.get("THIRD_PARTY_NOTICES")?.toString("utf8") ?? ""; const copied = new Set(native.map((dependency) => dependency.name)); for (const match of notices.matchAll(NOTICE_ENTRY)) { const [, name, version, licence, uses] = match; // A package copied into node_modules is credited "backend, node_modules" when the backend // imports it; it is redistributed as files, so it is a native dependency, not bundled code. const bundled = !copied.has(name ?? "") && (uses ?? "").split(", ").some((use) => use === "backend" || use === "browser"); if (bundled && hasSourceObligations(licence ?? "")) { bundledCopyleft.push(`${name} ${version}`); } } return { bundledCopyleft, fileName, module: { id: metadata.module.id, version: metadata.module.version }, native, sha256: sha256(archive), target: metadata.target, }; } /** A native dependency is covered when it, or any component a reviewed supplement records inside it, * is under a licence with source obligations. */ function isCovered( dependency: NativeDependency, supplements: ReadonlyMap, ): boolean { if (hasSourceObligations(dependency.license)) { return true; } const supplement = supplements .get(dependency.name) ?.find( (candidate) => candidate.version === dependency.version && candidate.contentDigest === dependency.contentDigest, ); return ( supplement?.components.some((component) => hasSourceObligations(component.license)) ?? false ); } function familiesOf(licences: readonly string[]): string[] { const families = new Set(); for (const licence of licences) { if (/\bLGPL|Lesser General Public/iu.test(licence)) families.add("GNU LGPL"); if (/\bAGPL|Affero General Public/iu.test(licence)) families.add("GNU AGPL"); const plain = licence .replace(/\b[LA]GPL[\w.+-]*/giu, "") .replace(/(?:Lesser|Affero) General Public Licen[cs]e/giu, ""); if (/\bGPL\b|General Public Licen[cs]e/iu.test(plain)) families.add("GNU GPL"); if (/\bMPL\b|Mozilla Public Licen[cs]e/iu.test(licence)) families.add("MPL"); if (/\bEPL\b|Eclipse Public Licen[cs]e/iu.test(licence)) families.add("EPL"); if (/\bCDDL\b|Common Development and Distribution/iu.test(licence)) families.add("CDDL"); if (/\bEUPL\b|European Union Public/iu.test(licence)) families.add("EUPL"); } return [...families].sort(compareText); } // ------------------------------------------------------------------------------------------------- // Matching a package to its reviewed inputs // ------------------------------------------------------------------------------------------------- export interface CoveredDependency extends NativeDependency { readonly recipe: LoadedRecipe; readonly supplement: LicenseSupplement; readonly supplementFiles: ReadonlyMap; readonly supplementSha256: string; /** Licence families present, from the declared licence and the supplement's components. */ readonly families: readonly string[]; } export interface ReviewedInputs { readonly recipesDirectory?: string; readonly supplementsDirectory?: string; } async function coveredDependencies( inventory: PackageInventory, inputs: ReviewedInputs, ): Promise { if (inventory.bundledCopyleft.length > 0) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_BUNDLED_COPYLEFT", inventory.bundledCopyleft.join(", "), ); } const supplementsDirectory = inputs.supplementsDirectory ?? DEFAULT_SUPPLEMENTS_DIRECTORY; let supplements: Awaited>; try { supplements = await loadLicenseSupplements(supplementsDirectory); } catch (error) { if (error instanceof SupplementError) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_SUPPLEMENT_MISMATCH", error.supplement, ); } throw error; } const recipes = await loadSourceRecipes(inputs.recipesDirectory ?? DEFAULT_RECIPES_DIRECTORY); const covered: CoveredDependency[] = []; for (const dependency of inventory.native) { if (!isCovered(dependency, supplements)) { continue; } const subject = `${dependency.name}@${dependency.version}`; const candidates = recipes.get(dependency.name) ?? []; if (candidates.length === 0) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_RECIPE_MISSING", subject); } const recipe = candidates.find( (candidate) => candidate.recipe.package.version === dependency.version && candidate.recipe.package.contentDigest === dependency.contentDigest, ); if ( recipe === undefined || recipe.recipe.package.license !== dependency.license || recipe.recipe.package.target !== inventory.target ) { throw new CorrespondingSourceError( candidates.some((candidate) => candidate.recipe.package.version === dependency.version) ? "CORRESPONDING_SOURCE_RECIPE_MISMATCH" : "CORRESPONDING_SOURCE_RECIPE_MISSING", subject, ); } const supplement = supplements .get(dependency.name) ?.find( (candidate) => candidate.version === recipe.recipe.supplement.version && candidate.contentDigest === dependency.contentDigest, ); if (supplement === undefined) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_SUPPLEMENT_MISMATCH", subject); } const supplementDirectory = path.join( supplementsDirectory, ...dependency.name.split("/"), supplement.version, ); const supplementFiles = new Map(); for (const file of await listFiles(supplementDirectory)) { supplementFiles.set(file, await readFile(path.join(supplementDirectory, ...file.split("/")))); } const supplementJson = supplementFiles.get("supplement.json"); if ( supplementJson === undefined || sha256(supplementJson) !== recipe.recipe.supplement.sha256 ) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_RECIPE_MISMATCH", subject); } const recorded = new Set( supplement.components.map((component) => `${component.name}\u0000${component.version}`), ); for (const component of recipe.recipe.components) { for (const material of component.licenseMaterial) { if (!recorded.has(`${material.name}\u0000${material.version}`)) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_RECIPE_MISMATCH", `${subject}: ${component.name} ${component.version}`, ); } } } covered.push({ ...dependency, families: familiesOf([ dependency.license, ...supplement.components.map((component) => component.license), ]), recipe, supplement, supplementFiles, supplementSha256: sha256(supplementJson), }); } return covered; } // ------------------------------------------------------------------------------------------------- // The verified source cache, and acquisition // ------------------------------------------------------------------------------------------------- /** Where a recorded file lives in a source cache: `/sha256/`. The name is never trusted: * every read re-hashes the bytes. */ export function cachedSourcePath(cacheDirectory: string, digest: string): string { return path.join(cacheDirectory, "sha256", digest); } async function readCached( cacheDirectory: string, file: RecipeFile, subject: string, ): Promise { let bytes: Buffer; try { bytes = await readFile(cachedSourcePath(cacheDirectory, file.sha256)); } catch { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_SOURCE_MISSING", `${subject}/${file.file}`, ); } if (bytes.length !== file.size || sha256(bytes) !== file.sha256) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_SOURCE_MISMATCH", `${subject}/${file.file}`, ); } return bytes; } const ACQUISITION_ATTEMPTS = 3; export type SourceFetcher = (url: string) => Promise; /** * Downloads one recorded file over HTTPS, exactly as served: explicit headers, redirects followed, * and no content decoding. The platform `fetch` adds request headers some source hosts refuse, and * may transparently decode a body; neither is acceptable for bytes that are about to be hashed. */ function defaultFetch(url: string, redirects = 0): Promise { return new Promise((resolve, reject) => { if (!url.startsWith("https://")) { reject(new Error("only https sources are acquired")); return; } const request = httpsGet( url, { headers: { accept: "*/*", "user-agent": "ekklesia-corresponding-source" }, timeout: 120_000, }, (response) => { const status = response.statusCode ?? 0; const location = response.headers.location; if (status >= 300 && status < 400 && location !== undefined && redirects < 10) { response.resume(); resolve(defaultFetch(new URL(location, url).href, redirects + 1)); return; } if (status !== 200) { response.resume(); reject(new Error(`HTTP ${status}`)); return; } const chunks: Buffer[] = []; response.on("data", (chunk: Buffer) => chunks.push(chunk)); response.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks)))); response.on("error", reject); }, ); request.on("timeout", () => request.destroy(new Error("timed out"))); request.on("error", reject); }); } export interface AcquireOptions extends ReviewedInputs { readonly cacheDirectory: string; /** The built Module Package whose covered native dependencies' sources are needed. */ readonly packageArchivePath?: string; /** Or recipes named directly, to prepare a cache before any package is built. */ readonly recipes?: readonly { readonly name: string; readonly version: string }[]; readonly fetch?: SourceFetcher; } export interface AcquireResult { readonly files: number; readonly fetched: number; readonly bytes: number; } /** * The preparation step, and the only one that uses the network. Every source file and patch the * recipes record is downloaded from its recorded origin unless the cache already holds it, and is * stored only after its size and SHA-256 match the recipe. A response that differs fails; nothing is * ever trusted because of where it came from. */ export async function acquireCorrespondingSources(options: AcquireOptions): Promise { const recipes: { readonly recipe: LoadedRecipe; readonly subject: string }[] = []; if (options.packageArchivePath !== undefined) { const archive = await readFile(options.packageArchivePath); const inventory = inventoryModulePackage(path.basename(options.packageArchivePath), archive); for (const dependency of await coveredDependencies(inventory, options)) { recipes.push({ recipe: dependency.recipe, subject: `${dependency.name}@${dependency.version}`, }); } } if (options.recipes !== undefined) { const loaded = await loadSourceRecipes(options.recipesDirectory ?? DEFAULT_RECIPES_DIRECTORY); for (const wanted of options.recipes) { const recipe = loaded .get(wanted.name) ?.find((candidate) => candidate.recipe.package.version === wanted.version); if (recipe === undefined) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_RECIPE_MISSING", `${wanted.name}@${wanted.version}`, ); } recipes.push({ recipe, subject: `${wanted.name}@${wanted.version}` }); } } const fetcher = options.fetch ?? defaultFetch; const wanted = new Map(); for (const { recipe, subject } of recipes) { for (const component of recipe.recipe.components) { for (const file of [...component.sources, ...component.patches]) { wanted.set(file.sha256, { file, subject }); } } } await mkdir(path.join(options.cacheDirectory, "sha256"), { recursive: true }); let fetched = 0; let bytes = 0; for (const [digest, { file, subject }] of [...wanted].sort(([left], [right]) => compareText(left, right), )) { try { await readCached(options.cacheDirectory, file, subject); bytes += file.size; continue; } catch { // Missing or wrong: acquire it again. } // Upstream hosts fail transiently (rate limits, resets). A few attempts are made, and only bytes // with the recorded size and SHA-256 are ever accepted. let data: Uint8Array | undefined; for (let attempt = 1; attempt <= ACQUISITION_ATTEMPTS && data === undefined; attempt += 1) { try { const candidate = await fetcher(file.origin); if (candidate.length === file.size && sha256(candidate) === digest) { data = candidate; } } catch { // Retried below, then refused. } } if (data === undefined) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_ACQUISITION_FAILED", `${subject}/${file.file}`, ); } const target = cachedSourcePath(options.cacheDirectory, digest); const partial = `${target}.partial-${process.pid}`; await writeFile(partial, data); await rename(partial, target); fetched += 1; bytes += data.length; } return { bytes, fetched, files: wanted.size }; } // ------------------------------------------------------------------------------------------------- // The corresponding-source archive // ------------------------------------------------------------------------------------------------- export interface ManifestFile { readonly path: string; readonly size: number; readonly sha256: string; } function dependencyDirectory(dependency: { readonly name: string; readonly version: string; }): string { return `dependencies/${dependency.name}/${dependency.version}`; } function json(value: unknown): Buffer { return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"); } function manifestComponents(dependency: CoveredDependency) { const base = dependencyDirectory(dependency); const asFile = (file: RecipeFile) => ({ origin: file.origin, path: `${base}/${file.file}`, sha256: file.sha256, size: file.size, }); return dependency.recipe.recipe.components.map((component) => ({ name: component.name, version: component.version, role: component.role, license: component.license, relationship: component.relationship, licenseMaterial: component.licenseMaterial, ...(component.includedIn === undefined ? {} : { includedIn: { component: component.includedIn.component, path: component.includedIn.path, }, }), sources: component.sources.map(asFile), patches: component.patches.map(asFile), })); } function readme( inventory: PackageInventory, covered: readonly CoveredDependency[], sourceFile: string, ): string { const lines = [ `# Corresponding source for ${inventory.fileName}`, "", `This archive accompanies the Ekklesia Module Package \`${inventory.fileName}\`: module`, `\`${inventory.module.id}\` ${inventory.module.version}, target \`${inventory.target}\`. It carries the source,`, "patches, build definitions, and rebuild and relink instructions for the native dependencies that", "package redistributes under licences with source obligations, as Ekklesia's redistribution policy", "requires (Ekklesia ADR 0055). It is project compliance material, not a legal opinion.", "", "## Covered native dependencies", "", ]; for (const dependency of covered) { lines.push( `- **${dependency.name} ${dependency.version}**, declared \`${dependency.license}\`, with components under`, ` ${dependency.families.join(" and ")}. Content digest \`${dependency.contentDigest}\`.`, ` Everything for it is in \`${dependencyDirectory(dependency)}/\`. Start with \`BUILDING.md\` there.`, ); } lines.push( "", "## Layout", "", "- `corresponding-source.json`: the machine-readable manifest. It names the package and each covered", " dependency by digest, every component with its role, licence, and relationship to the binary,", " and every file in this archive with its size and SHA-256.", "- `SHA256SUMS`: the same digests, for `sha256sum -c SHA256SUMS`.", "- `dependencies///`, for each covered dependency:", " - `BUILDING.md`: how the binary was built, how to rebuild it, and how to use a rebuilt library;", " - `recipe.json`: the reviewed source recipe this archive was assembled from;", " - `PROVENANCE.md`: how the source and licence material were established;", " - `licenses/`: the reviewed licence supplement, also in the package's `THIRD_PARTY_NOTICES`;", " - `sources/` and `patches/`: the exact source archives, source packages, and patches;", " - any build helpers the recipe lists.", "", "## Verifying", "", "```sh", `tar -xzf ${sourceFile}`, `cd ${sourceFile.replace(/\.tar\.gz$/u, "")}`, "sha256sum -c SHA256SUMS", "```", "", "The release this archive belongs to records the archive's own SHA-256 beside the package's.", "", ); return lines.join("\n"); } /** Directory prefixes that only ever name one machine's files. They are assembled from segments so * that this module's own built text never contains such a path: the release builder rejects a * package whose files name the build machine's temporary or home directory. */ const LOCAL_ROOTS = ["home", "Users", "root", "tmp", "var/folders"].map( (segment) => `/${segment}/`, ); const PATH_BOUNDARY = new Set(["", " ", "\t", "\n", '"', "'", "`", "(", "="]); function leaksLocalPath(content: string): boolean { for (const root of LOCAL_ROOTS) { for (let at = content.indexOf(root); at !== -1; at = content.indexOf(root, at + 1)) { if (PATH_BOUNDARY.has(at === 0 ? "" : (content[at - 1] ?? ""))) { return true; } } } if (/(?:^|[\s"'`(=])[A-Za-z]:\\/u.test(content)) { return true; } return [process.cwd(), tmpdir(), homedir()] .filter((candidate) => candidate.length > 1) .some((candidate) => content.includes(candidate)); } export interface BuildCorrespondingSourceOptions extends ReviewedInputs { readonly packageArchivePath: string; readonly cacheDirectory: string; readonly outDirectory: string; } export interface CorrespondingSourceRelease { /** False when the package redistributes no covered native code: nothing is needed or written. */ readonly required: boolean; readonly sourceArchivePath?: string; readonly sourceSha256?: string; readonly sourceBytes?: number; readonly manifestSha256?: string; readonly metadataPath?: string; readonly noticePath?: string; } function baseName(packageFile: string): string { if (!packageFile.endsWith(".tar.gz")) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_PACKAGE_INVALID", packageFile); } return packageFile.slice(0, -".tar.gz".length); } /** * The release notice displayed beside a binary release, naming its exact corresponding-source * artifact. Exported so release tooling and tests produce identical text. */ export function releaseNotice( covered: readonly Pick[], sourceFile: string, sourceSha256: string, ): string { const families = familiesOf(covered.flatMap((dependency) => dependency.families)); const lines = [ `This release includes third-party software covered by licenses including the ${families.join(" and ")}:`, "", ...covered.map( (dependency) => ` ${dependency.name} ${dependency.version} (declared ${dependency.license}; components under ${dependency.families.join(" and ")})`, ), "", "Corresponding source and build/relink materials for the covered native dependencies distributed", "with this release are available in the accompanying corresponding-source artifact:", "", ` ${sourceFile}`, ` SHA-256 ${sourceSha256}`, "", "The binary package's own THIRD_PARTY_NOTICES carries the licence and notice material for", "everything it includes.", "", ]; return lines.join("\n"); } /** * Builds the corresponding-source archive for a built Module Package, offline, from the reviewed * recipes and a verified source cache, and writes it beside the package's other release artifacts: * * - `-corresponding-source.tar.gz` and its `.sha256`; * - `.redistribution.json`, linking package, dependencies, and source by digest; * - `.source-notice.txt`, the release notice. * * Every byte is verified before anything is written, and nothing existing is overwritten. */ export async function buildCorrespondingSource( options: BuildCorrespondingSourceOptions, ): Promise { const packageFile = path.basename(options.packageArchivePath); const base = baseName(packageFile); let archive: Buffer; try { archive = await readFile(options.packageArchivePath); } catch { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_PACKAGE_INVALID", packageFile); } const inventory = inventoryModulePackage(packageFile, archive); const covered = await coveredDependencies(inventory, options); if (covered.length === 0) { return { required: false }; } const files = new Map(); for (const dependency of covered) { const directory = dependencyDirectory(dependency); const subject = `${dependency.name}@${dependency.version}`; files.set(`${directory}/recipe.json`, dependency.recipe.recipeBytes); for (const [documentPath, bytes] of dependency.recipe.documents) { files.set(`${directory}/${documentPath}`, bytes); } for (const [file, bytes] of dependency.supplementFiles) { files.set( file === "PROVENANCE.md" ? `${directory}/PROVENANCE.md` : `${directory}/licenses/${file}`, bytes, ); } for (const component of dependency.recipe.recipe.components) { for (const file of [...component.sources, ...component.patches]) { files.set( `${directory}/${file.file}`, await readCached(options.cacheDirectory, file, subject), ); } } } const sourceFile = `${base}-corresponding-source.tar.gz`; files.set("README.md", Buffer.from(readme(inventory, covered, sourceFile), "utf8")); const listed = [...files] .map(([filePath, data]) => ({ path: filePath, sha256: sha256(data), size: data.length })) .sort((left, right) => compareText(left.path, right.path)); files.set( "SHA256SUMS", Buffer.from(listed.map((file) => `${file.sha256} ${file.path}\n`).join(""), "utf8"), ); const manifest = { correspondingSourceFormat: CORRESPONDING_SOURCE_FORMAT, module: { id: inventory.module.id, version: inventory.module.version, target: inventory.target, }, dependencies: covered.map((dependency) => ({ name: dependency.name, version: dependency.version, license: dependency.license, contentDigest: dependency.contentDigest, directory: dependencyDirectory(dependency), recipe: { sha256: dependency.recipe.recipeSha256 }, supplement: { version: dependency.supplement.version, sha256: dependency.supplementSha256 }, binaries: dependency.recipe.recipe.package.binaries, components: manifestComponents(dependency), exclusions: dependency.recipe.recipe.exclusions, })), files: [...files] .map(([filePath, data]) => ({ path: filePath, size: data.length, sha256: sha256(data) })) .sort((left, right) => compareText(left.path, right.path)), }; const manifestBytes = json(manifest); files.set("corresponding-source.json", manifestBytes); for (const generated of ["README.md", "SHA256SUMS", "corresponding-source.json"]) { if (leaksLocalPath(files.get(generated)?.toString("utf8") ?? "")) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_LOCAL_PATH", generated); } } const sourceArchive = deterministicTarGz(`${base}-corresponding-source`, files); const sourceSha256 = sha256(sourceArchive); const notice = releaseNotice(covered, sourceFile, sourceSha256); const metadata = { redistributionFormat: REDISTRIBUTION_FORMAT, policy: "Ekklesia ADR 0055: native redistributable packages carry corresponding-source artifacts", package: { file: packageFile, sha256: inventory.sha256, module: inventory.module, target: inventory.target, }, covered: covered.map((dependency) => ({ name: dependency.name, version: dependency.version, license: dependency.license, contentDigest: dependency.contentDigest, supplement: { version: dependency.supplement.version, sha256: dependency.supplementSha256 }, recipe: { sha256: dependency.recipe.recipeSha256 }, })), correspondingSource: { file: sourceFile, sha256: sourceSha256, size: sourceArchive.length, manifestSha256: sha256(manifestBytes), }, notice, }; const outputs: readonly [string, Buffer][] = [ [sourceFile, sourceArchive], [`${sourceFile}.sha256`, Buffer.from(`${sourceSha256} ${sourceFile}\n`, "utf8")], [`${base}.redistribution.json`, json(metadata)], [`${base}.source-notice.txt`, Buffer.from(notice, "utf8")], ]; await mkdir(options.outDirectory, { recursive: true }); for (const [name] of outputs) { try { await lstat(path.join(options.outDirectory, name)); throw new CorrespondingSourceError("CORRESPONDING_SOURCE_OUTPUT_EXISTS", name); } catch (error) { if (error instanceof CorrespondingSourceError) { throw error; } } } const staging = path.join(options.outDirectory, `.corresponding-source-${process.pid}`); await rm(staging, { force: true, recursive: true }); await mkdir(staging); try { for (const [name, data] of outputs) { await writeFile(path.join(staging, name), data); } for (const [name] of outputs) { await rename(path.join(staging, name), path.join(options.outDirectory, name)); } } finally { await rm(staging, { force: true, recursive: true }); } return { manifestSha256: metadata.correspondingSource.manifestSha256, metadataPath: path.join(options.outDirectory, `${base}.redistribution.json`), noticePath: path.join(options.outDirectory, `${base}.source-notice.txt`), required: true, sourceArchivePath: path.join(options.outDirectory, sourceFile), sourceBytes: sourceArchive.length, sourceSha256, }; } // ------------------------------------------------------------------------------------------------- // The public-distribution gate // ------------------------------------------------------------------------------------------------- export interface VerifyRedistributionOptions extends ReviewedInputs { readonly packageArchivePath: string; /** Required when the package redistributes covered native code. */ readonly sourceArchivePath?: string; /** `.redistribution.json`. Required when a source archive is required. */ readonly metadataPath?: string; } export interface RedistributionApproval { readonly approved: true; readonly package: { readonly file: string; readonly sha256: string }; readonly covered: readonly { readonly name: string; readonly version: string; readonly contentDigest: string; }[]; readonly correspondingSource?: { readonly file: string; readonly sha256: string; readonly manifestSha256: string; }; } function deepEqual(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } /** * Approves a Module Package for public distribution under ADR 0055, or throws. Approval needs every * covered native dependency to have its reviewed supplement and recipe, and the corresponding-source * archive and redistribution metadata to identify exactly these files by digest. The archive must be * canonical, its manifest must match the recipes and its own contents, and the release notice must * name it. A package with no covered native code is approved without any of them. */ export async function verifyRedistribution( options: VerifyRedistributionOptions, ): Promise { const packageFile = path.basename(options.packageArchivePath); const base = baseName(packageFile); let archive: Buffer; try { archive = await readFile(options.packageArchivePath); } catch { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_PACKAGE_INVALID", packageFile); } const inventory = inventoryModulePackage(packageFile, archive); const covered = await coveredDependencies(inventory, options); const approval = { approved: true as const, covered: covered.map((dependency) => ({ contentDigest: dependency.contentDigest, name: dependency.name, version: dependency.version, })), package: { file: packageFile, sha256: inventory.sha256 }, }; if (covered.length === 0) { return approval; } const sourceFile = `${base}-corresponding-source.tar.gz`; let source: Buffer; try { if (options.sourceArchivePath === undefined) { throw new Error("missing"); } source = await readFile(options.sourceArchivePath); } catch { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_ARTIFACT_MISSING", sourceFile); } if (path.basename(options.sourceArchivePath) !== sourceFile) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_METADATA_MISMATCH", sourceFile); } // The archive itself: readable, and exactly what the deterministic encoder writes. let entries: ReturnType; try { entries = readTarGz(source); } catch (error) { if (error instanceof TarFormatError) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_ARTIFACT_INVALID", sourceFile); } throw error; } const top = `${base}-corresponding-source`; if (entries[0]?.path !== top || !entries[0].directory || !isCanonicalTarGz(source, entries)) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_ARTIFACT_INVALID", sourceFile); } const files = new Map(); for (const entry of entries) { if (!entry.directory) { files.set(entry.path.slice(top.length + 1), entry.data); } } // The manifest, against the package and the reviewed recipes. const manifestBytes = files.get("corresponding-source.json"); let manifest: unknown; try { manifest = JSON.parse(manifestBytes?.toString("utf8") ?? ""); } catch { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_MANIFEST_MISMATCH", "corresponding-source.json", ); } if ( !isRecord(manifest) || manifest.correspondingSourceFormat !== CORRESPONDING_SOURCE_FORMAT || !deepEqual(manifest.module, { id: inventory.module.id, version: inventory.module.version, target: inventory.target, }) || !Array.isArray(manifest.dependencies) || !Array.isArray(manifest.files) || manifest.dependencies.length !== covered.length ) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_MANIFEST_MISMATCH", "corresponding-source.json", ); } for (const [index, dependency] of covered.entries()) { const recorded = manifest.dependencies[index]; const directory = dependencyDirectory(dependency); const expected = { name: dependency.name, version: dependency.version, license: dependency.license, contentDigest: dependency.contentDigest, directory, recipe: { sha256: dependency.recipe.recipeSha256 }, supplement: { version: dependency.supplement.version, sha256: dependency.supplementSha256 }, binaries: dependency.recipe.recipe.package.binaries, components: manifestComponents(dependency), exclusions: dependency.recipe.recipe.exclusions, }; if (!deepEqual(recorded, expected)) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_MANIFEST_MISMATCH", `${dependency.name}@${dependency.version}`, ); } // The reviewed inputs themselves travel with the source, byte for byte. const reviewed = new Map([ [`${directory}/recipe.json`, dependency.recipe.recipeBytes], ]); for (const [documentPath, bytes] of dependency.recipe.documents) { reviewed.set(`${directory}/${documentPath}`, bytes); } for (const [file, bytes] of dependency.supplementFiles) { reviewed.set( file === "PROVENANCE.md" ? `${directory}/PROVENANCE.md` : `${directory}/licenses/${file}`, bytes, ); } for (const [reviewedPath, bytes] of reviewed) { if (!files.get(reviewedPath)?.equals(bytes)) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_MANIFEST_MISMATCH", reviewedPath); } } } // Every file is recorded, with its exact bytes, and every recorded file is present. const listed = new Map(); for (const entry of manifest.files) { if ( !isRecord(entry) || typeof entry.path !== "string" || typeof entry.size !== "number" || typeof entry.sha256 !== "string" || !portablePath(entry.path) ) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_MANIFEST_MISMATCH", "corresponding-source.json", ); } listed.set(entry.path, { sha256: entry.sha256, size: entry.size }); } for (const filePath of files.keys()) { if (filePath !== "corresponding-source.json" && !listed.has(filePath)) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_UNRECORDED_FILE", filePath); } } for (const [filePath, recorded] of listed) { const data = files.get(filePath); if (data === undefined || data.length !== recorded.size || sha256(data) !== recorded.sha256) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_MANIFEST_MISMATCH", filePath); } } for (const dependency of manifest.dependencies as { components: { sources: ManifestFile[]; patches: ManifestFile[] }[]; }[]) { for (const component of dependency.components) { for (const file of [...component.sources, ...component.patches]) { const recorded = listed.get(file.path); if ( recorded === undefined || recorded.sha256 !== file.sha256 || recorded.size !== file.size ) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_MANIFEST_MISMATCH", file.path); } } } } for (const generated of ["README.md", "SHA256SUMS", "corresponding-source.json"]) { const content = files.get(generated)?.toString("utf8"); if (content === undefined) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_MANIFEST_MISMATCH", generated); } if (leaksLocalPath(content)) { throw new CorrespondingSourceError("CORRESPONDING_SOURCE_LOCAL_PATH", generated); } } // The redistribution metadata and release notice name exactly these files. const sourceSha256 = sha256(source); const manifestSha256 = sha256(manifestBytes ?? Buffer.alloc(0)); let metadata: unknown; try { if (options.metadataPath === undefined) { throw new Error("missing"); } metadata = JSON.parse(await readFile(options.metadataPath, "utf8")); } catch { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_METADATA_MISMATCH", `${base}.redistribution.json`, ); } const expectedMetadata = { redistributionFormat: REDISTRIBUTION_FORMAT, policy: "Ekklesia ADR 0055: native redistributable packages carry corresponding-source artifacts", package: { file: packageFile, sha256: inventory.sha256, module: inventory.module, target: inventory.target, }, covered: covered.map((dependency) => ({ name: dependency.name, version: dependency.version, license: dependency.license, contentDigest: dependency.contentDigest, supplement: { version: dependency.supplement.version, sha256: dependency.supplementSha256 }, recipe: { sha256: dependency.recipe.recipeSha256 }, })), correspondingSource: { file: sourceFile, sha256: sourceSha256, size: source.length, manifestSha256, }, notice: releaseNotice(covered, sourceFile, sourceSha256), }; if (!deepEqual(metadata, expectedMetadata)) { throw new CorrespondingSourceError( "CORRESPONDING_SOURCE_METADATA_MISMATCH", `${base}.redistribution.json`, ); } return { ...approval, correspondingSource: { file: sourceFile, manifestSha256, sha256: sourceSha256 }, }; }