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): Buffer { const directories = new Set([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(); 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; } }