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]>
208 lines
7.2 KiB
TypeScript
208 lines
7.2 KiB
TypeScript
import { gunzipSync, gzipSync } from "node:zlib";
|
|
|
|
/**
|
|
* The deterministic archive encoding of Ekklesia Module Package v1 (ADR 0049).
|
|
*
|
|
* POSIX ustar, written by hand rather than by a general tar library, so every byte is decided here:
|
|
* - one top-level directory, then every directory and regular file in byte-wise path order, which
|
|
* puts every directory before what it contains;
|
|
* - uid and gid 0, empty user and group names, modification time 0, device fields 0;
|
|
* - directories 0755 and files 0644; no links, no special files, no extended or long-name headers;
|
|
* - exactly two zero blocks at the end;
|
|
* - gzip at a fixed level, with Node's header: no file name and a modification time of 0.
|
|
*
|
|
* The same packager version and the same input bytes therefore always produce the same archive.
|
|
*/
|
|
|
|
export class TarPathError extends Error {
|
|
readonly path: string;
|
|
|
|
constructor(entryPath: string) {
|
|
super("A path does not fit a portable ustar header.");
|
|
this.name = "TarPathError";
|
|
this.path = entryPath;
|
|
}
|
|
}
|
|
|
|
const BLOCK = 512;
|
|
const GZIP_LEVEL = 9;
|
|
|
|
function octal(value: number, width: number): string {
|
|
return `${value.toString(8).padStart(width - 1, "0")}\u0000`;
|
|
}
|
|
|
|
/** Splits a path into ustar's 155-byte prefix and 100-byte name, at a `/`. */
|
|
function splitPath(entryPath: string): { readonly name: string; readonly prefix: string } {
|
|
if (entryPath.length <= 100) {
|
|
return { name: entryPath, prefix: "" };
|
|
}
|
|
for (let index = entryPath.length - 1; index > 0; index -= 1) {
|
|
if (entryPath[index] === "/") {
|
|
const prefix = entryPath.slice(0, index);
|
|
const name = entryPath.slice(index + 1);
|
|
if (prefix.length <= 155 && name.length > 0 && name.length <= 100) {
|
|
return { name, prefix };
|
|
}
|
|
}
|
|
}
|
|
throw new TarPathError(entryPath);
|
|
}
|
|
|
|
function header(entryPath: string, directory: boolean, size: number): Buffer {
|
|
const block = Buffer.alloc(BLOCK);
|
|
const { name, prefix } = splitPath(directory ? `${entryPath}/` : entryPath);
|
|
block.write(name, 0, 100, "latin1");
|
|
block.write(octal(directory ? 0o755 : 0o644, 8), 100, "latin1");
|
|
block.write(octal(0, 8), 108, "latin1");
|
|
block.write(octal(0, 8), 116, "latin1");
|
|
block.write(octal(size, 12), 124, "latin1");
|
|
block.write(octal(0, 12), 136, "latin1");
|
|
block.write(" ", 148, "latin1");
|
|
block.write(directory ? "5" : "0", 156, "latin1");
|
|
block.write("ustar\u000000", 257, "latin1");
|
|
block.write(octal(0, 8), 329, "latin1");
|
|
block.write(octal(0, 8), 337, "latin1");
|
|
block.write(prefix, 345, 155, "latin1");
|
|
let checksum = 0;
|
|
for (const byte of block) {
|
|
checksum += byte;
|
|
}
|
|
block.write(`${checksum.toString(8).padStart(6, "0")}\u0000 `, 148, "latin1");
|
|
return block;
|
|
}
|
|
|
|
function compareBytes(left: string, right: string): number {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
/**
|
|
* Archives `files` (paths relative to the module directory, printable ASCII) under `topLevel`.
|
|
* Directories are implied by the file paths.
|
|
*/
|
|
export function deterministicTarGz(topLevel: string, files: ReadonlyMap<string, Buffer>): Buffer {
|
|
const directories = new Set<string>([topLevel]);
|
|
for (const filePath of files.keys()) {
|
|
const segments = filePath.split("/");
|
|
for (let index = 1; index < segments.length; index += 1) {
|
|
directories.add(`${topLevel}/${segments.slice(0, index).join("/")}`);
|
|
}
|
|
}
|
|
const entries: { readonly path: string; readonly data?: Buffer }[] = [
|
|
...[...directories].map((directory) => ({ path: directory })),
|
|
...[...files].map(([filePath, data]) => ({ data, path: `${topLevel}/${filePath}` })),
|
|
].sort((left, right) => compareBytes(left.path, right.path));
|
|
|
|
const parts: Buffer[] = [];
|
|
for (const entry of entries) {
|
|
if (entry.data === undefined) {
|
|
parts.push(header(entry.path, true, 0));
|
|
} else {
|
|
parts.push(header(entry.path, false, entry.data.length), entry.data);
|
|
const padding = (BLOCK - (entry.data.length % BLOCK)) % BLOCK;
|
|
if (padding > 0) {
|
|
parts.push(Buffer.alloc(padding));
|
|
}
|
|
}
|
|
}
|
|
parts.push(Buffer.alloc(BLOCK * 2));
|
|
return gzipSync(Buffer.concat(parts), { level: GZIP_LEVEL });
|
|
}
|
|
|
|
/** One entry of a ustar archive, as read back. */
|
|
export interface TarEntry {
|
|
readonly path: string;
|
|
readonly directory: boolean;
|
|
readonly data: Buffer;
|
|
}
|
|
|
|
export class TarFormatError extends Error {
|
|
constructor() {
|
|
super("An archive is not in Ekklesia's deterministic ustar encoding.");
|
|
this.name = "TarFormatError";
|
|
}
|
|
}
|
|
|
|
function field(block: Buffer, offset: number, length: number): string {
|
|
const raw = block.subarray(offset, offset + length);
|
|
const end = raw.indexOf(0);
|
|
return raw.subarray(0, end === -1 ? length : end).toString("latin1");
|
|
}
|
|
|
|
/**
|
|
* Reads an archive written by `deterministicTarGz` back into its entries, refusing anything that
|
|
* encoding never produces: a link or special file, another header type, a bad checksum, or data
|
|
* after the end blocks. Whether the archive is byte-for-byte canonical is checked separately, by
|
|
* writing the entries again (`isCanonicalTarGz`).
|
|
*/
|
|
export function readTarGz(archive: Buffer): readonly TarEntry[] {
|
|
let raw: Buffer;
|
|
try {
|
|
raw = gunzipSync(archive);
|
|
} catch {
|
|
throw new TarFormatError();
|
|
}
|
|
const entries: TarEntry[] = [];
|
|
let offset = 0;
|
|
for (;;) {
|
|
if (offset + BLOCK > raw.length) {
|
|
throw new TarFormatError();
|
|
}
|
|
const block = raw.subarray(offset, offset + BLOCK);
|
|
if (block.every((byte) => byte === 0)) {
|
|
break;
|
|
}
|
|
const stored = Number.parseInt(field(block, 148, 8).trim() || "x", 8);
|
|
let checksum = 0;
|
|
for (let index = 0; index < BLOCK; index += 1) {
|
|
checksum += index >= 148 && index < 156 ? 32 : (block[index] ?? 0);
|
|
}
|
|
const typeflag = field(block, 156, 1);
|
|
const size = Number.parseInt(field(block, 124, 12) || "x", 8);
|
|
if (
|
|
stored !== checksum ||
|
|
(typeflag !== "0" && typeflag !== "5") ||
|
|
!Number.isSafeInteger(size)
|
|
) {
|
|
throw new TarFormatError();
|
|
}
|
|
const name = field(block, 0, 100);
|
|
const prefix = field(block, 345, 155);
|
|
const joined = prefix === "" ? name : `${prefix}/${name}`;
|
|
const directory = typeflag === "5";
|
|
const start = offset + BLOCK;
|
|
if (start + size > raw.length) {
|
|
throw new TarFormatError();
|
|
}
|
|
entries.push({
|
|
data: Buffer.from(raw.subarray(start, start + size)),
|
|
directory,
|
|
path: directory ? joined.replace(/\/$/u, "") : joined,
|
|
});
|
|
offset = start + Math.ceil(size / BLOCK) * BLOCK;
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/** Whether `archive` is exactly what `deterministicTarGz` writes for its own entries: the same
|
|
* order, headers, and padding. Compared uncompressed, so a different zlib cannot matter. */
|
|
export function isCanonicalTarGz(archive: Buffer, entries: readonly TarEntry[]): boolean {
|
|
const topLevel = entries[0];
|
|
if (topLevel === undefined || !topLevel.directory || topLevel.path.includes("/")) {
|
|
return false;
|
|
}
|
|
const files = new Map<string, Buffer>();
|
|
for (const entry of entries) {
|
|
if (!entry.directory) {
|
|
if (!entry.path.startsWith(`${topLevel.path}/`)) {
|
|
return false;
|
|
}
|
|
files.set(entry.path.slice(topLevel.path.length + 1), entry.data);
|
|
}
|
|
}
|
|
try {
|
|
return gunzipSync(deterministicTarGz(topLevel.path, files)).equals(gunzipSync(archive));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|