import { gunzipSync } from "node:zlib"; /** * Reads an Ekklesia Module Package v1 archive (ADR 0049) entirely in memory and enforces the * archive-safety rules before anything is written anywhere. * * Deliberately a strict reader rather than a general tar implementation: v1 packages hold only * POSIX ustar directories and regular files, so every other entry type is refused instead of * interpreted. Nothing here touches the filesystem; extraction happens only after the caller has * verified the whole package. */ export type ModulePackageArchiveErrorCode = | "MODULE_PACKAGE_LAYOUT_INVALID" | "MODULE_PACKAGE_LINK" | "MODULE_PACKAGE_SPECIAL_ENTRY" | "MODULE_PACKAGE_TOO_LARGE" | "MODULE_PACKAGE_UNREADABLE" | "MODULE_PACKAGE_UNSAFE_MODE" | "MODULE_PACKAGE_UNSAFE_PATH"; export class ModulePackageArchiveError extends Error { readonly code: ModulePackageArchiveErrorCode; constructor(code: ModulePackageArchiveErrorCode) { super(code); this.name = "ModulePackageArchiveError"; this.code = code; } } /** Generous for a module, small enough that a hostile archive cannot exhaust the host. */ export const MODULE_PACKAGE_MAX_COMPRESSED_BYTES = 256 * 1024 * 1024; export const MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024; export const MODULE_PACKAGE_MAX_ENTRIES = 100_000; export interface ModulePackageArchiveEntry { /** Relative to the top-level module directory; empty for that directory itself. */ readonly path: string; readonly type: "directory" | "file"; readonly data: Buffer; } export interface ModulePackageArchive { /** The single top-level directory's name. Validated as a module id by the caller. */ readonly topLevel: string; /** Every entry except the top-level directory itself, in archive order. */ readonly entries: readonly ModulePackageArchiveEntry[]; } const BLOCK = 512; 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"); } function octalField(block: Buffer, offset: number, length: number): number { const text = field(block, offset, length).trim(); if (!/^[0-7]+$/u.test(text)) { throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE"); } const value = Number.parseInt(text, 8); if (!Number.isSafeInteger(value)) { throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE"); } return value; } function headerChecksumValid(block: Buffer): boolean { const declared = octalField(block, 148, 8); let sum = 0; for (let index = 0; index < BLOCK; index += 1) { sum += index >= 148 && index < 156 ? 0x20 : (block[index] ?? 0); } return sum === declared; } /** Printable ASCII, no backslash, relative, and no empty, `.`, or `..` segment. */ function safeSegments(value: string): readonly string[] { if ( value.length === 0 || !/^[\x20-\x7e]+$/u.test(value) || value.includes("\\") || value.startsWith("/") ) { throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH"); } const segments = value.split("/"); if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH"); } return segments; } export function readModulePackageArchive(compressed: Buffer): ModulePackageArchive { if (compressed.length > MODULE_PACKAGE_MAX_COMPRESSED_BYTES) { throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE"); } let raw: Buffer; try { raw = gunzipSync(compressed, { maxOutputLength: MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES }); } catch (error) { throw new ModulePackageArchiveError( error instanceof RangeError ? "MODULE_PACKAGE_TOO_LARGE" : "MODULE_PACKAGE_UNREADABLE", ); } const entries: ModulePackageArchiveEntry[] = []; const kinds = new Map(); /** Every path that is an ancestor of some entry, so a file can never claim one. */ const ancestors = new Set(); let topLevel: string | undefined; let offset = 0; let ended = false; while (offset + BLOCK <= raw.length) { const block = raw.subarray(offset, offset + BLOCK); if (block.every((byte) => byte === 0)) { ended = true; break; } if (field(block, 257, 6) !== "ustar" || field(block, 263, 2) !== "00") { throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE"); } if (!headerChecksumValid(block)) { throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE"); } if (entries.length + 1 > MODULE_PACKAGE_MAX_ENTRIES) { throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE"); } const typeflag = field(block, 156, 1); if (typeflag === "1" || typeflag === "2") { throw new ModulePackageArchiveError("MODULE_PACKAGE_LINK"); } const type = typeflag === "5" ? "directory" : typeflag === "0" || typeflag === "" ? "file" : undefined; if (type === undefined) { // Devices, FIFOs, contiguous files, and every extended or long-name header: v1 packages hold // directories and regular files, so nothing else is interpreted. throw new ModulePackageArchiveError("MODULE_PACKAGE_SPECIAL_ENTRY"); } const mode = octalField(block, 100, 8); if ((mode & 0o7000) !== 0) { throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_MODE"); } const name = field(block, 0, 100); const prefix = field(block, 345, 155); let fullPath = prefix === "" ? name : `${prefix}/${name}`; if (type === "directory" && fullPath.endsWith("/")) { fullPath = fullPath.slice(0, -1); } const segments = safeSegments(fullPath); const top = segments[0] ?? ""; if (topLevel === undefined) { topLevel = top; } else if (top !== topLevel) { throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID"); } const size = type === "file" ? octalField(block, 124, 12) : 0; const dataStart = offset + BLOCK; const dataEnd = dataStart + size; if (dataEnd > raw.length) { throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE"); } const relative = segments.slice(1).join("/"); if (relative === "") { if (type !== "directory") { throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID"); } } else { if (kinds.has(relative)) { throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID"); } // No entry may sit below a file, and no file may claim a path something already sits below. // Linear in the total number of path segments, whatever order the entries arrive in. if (type === "file" && ancestors.has(relative)) { throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID"); } for (let index = 1; index < segments.length - 1; index += 1) { const ancestor = segments.slice(1, index + 1).join("/"); if (kinds.get(ancestor) === "file") { throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID"); } ancestors.add(ancestor); } kinds.set(relative, type); entries.push({ data: Buffer.from(raw.subarray(dataStart, dataEnd)), path: relative, type }); } offset = dataStart + Math.ceil(size / BLOCK) * BLOCK; } if (!ended || topLevel === undefined) { throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE"); } return Object.freeze({ entries: Object.freeze(entries), topLevel }); }