import { createHash } from "node:crypto"; import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { gzipSync } from "node:zlib"; import { createMigrationModule } from "./migration-module-fixture.js"; /** * Test-only builders for Ekklesia Module Package v1 archives (ADR 0049). They write ustar archives * by hand so a test can produce exactly what the canonical packager never would: links, device * nodes, traversal, a second top-level directory, a corrupted payload. */ export type FixtureEntryType = "file" | "directory" | "symlink" | "hardlink" | "character" | "fifo"; export interface FixtureEntry { readonly path: string; readonly type?: FixtureEntryType; readonly mode?: number; readonly data?: Uint8Array | string; readonly linkname?: string; } const typeflags: Readonly> = { character: "3", directory: "5", fifo: "6", file: "0", hardlink: "1", symlink: "2", }; function octal(value: number, width: number): string { return `${value.toString(8).padStart(width - 1, "0")}\u0000`; } function header(entry: FixtureEntry, size: number): Buffer { const block = Buffer.alloc(512); const name = entry.type === "directory" && !entry.path.endsWith("/") ? `${entry.path}/` : entry.path; let prefix = ""; let base = name; if (Buffer.byteLength(name) > 100) { const split = name.lastIndexOf("/", name.length - 2); prefix = name.slice(0, split); base = name.slice(split + 1); } block.write(base, 0, 100, "latin1"); block.write(octal(entry.mode ?? (entry.type === "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(typeflags[entry.type ?? "file"], 156, "latin1"); block.write(entry.linkname ?? "", 157, 100, "latin1"); block.write("ustar\u000000", 257, "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; } /** A gzip-compressed ustar archive of exactly these entries, in this order. */ export function tarGz(entries: readonly FixtureEntry[]): Buffer { const parts: Buffer[] = []; for (const entry of entries) { const data = entry.type === undefined || entry.type === "file" ? Buffer.from(entry.data ?? "") : Buffer.alloc(0); parts.push(header(entry, data.length), data, Buffer.alloc((512 - (data.length % 512)) % 512)); } parts.push(Buffer.alloc(1024)); return gzipSync(Buffer.concat(parts), { level: 9 }); } export function sha256(data: Uint8Array | string): string { return createHash("sha256").update(data).digest("hex"); } export interface PackageFixtureOptions { readonly id?: string; readonly version?: string; readonly serverSource?: string; readonly schemaSource?: string; readonly manifest?: (manifest: Record) => Record; readonly runtimePackage?: Record; readonly metadata?: (metadata: Record) => Record; /** Changes the payload after its digests were computed. */ readonly tamper?: (files: Map) => void; /** Entries appended after the package's own, under any path. */ readonly extraEntries?: readonly FixtureEntry[]; } export interface PackageFixture { readonly archive: Buffer; readonly sha256: string; } async function readTree(root: string): Promise> { const files = new Map(); for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) { if (entry.isFile()) { const absolute = path.join(entry.parentPath, entry.name); files.set(path.relative(root, absolute).split(path.sep).join("/"), await readFile(absolute)); } } return files; } /** * A valid any-target package of a minimal module: a server stub with no imports, one migration, a * notices file, the generated runtime package.json, and complete metadata. Every option changes one * thing, so a test can break exactly the rule it is about. */ export async function modulePackageFixture( options: PackageFixtureOptions = {}, ): Promise { const id = options.id ?? "example"; const version = options.version ?? "0.1.0"; const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-fixture-")); let files: Map; try { const created = await createMigrationModule(scratch, id, { ...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }), serverSource: options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`, }); files = await readTree(created.moduleDirectory); } finally { await rm(scratch, { force: true, recursive: true }); } const manifest = JSON.parse( files.get("ekklesia.module.json")?.toString("utf8") ?? "{}", ) as Record; files.set( "ekklesia.module.json", Buffer.from( `${JSON.stringify(options.manifest ? options.manifest(manifest) : { ...manifest, version }, null, 2)}\n`, ), ); files.set( "package.json", Buffer.from( `${JSON.stringify( options.runtimePackage ?? { name: `@test/module-${id}`, version, private: true, type: "module", ekklesia: { layer: "module" }, }, null, 2, )}\n`, ), ); files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n")); const listed = [...files.entries()] .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([filePath, data]) => ({ path: filePath, size: data.length, sha256: sha256(data) })); const baseMetadata: Record = { packageFormat: 1, module: { id, version }, target: "any", runtime: { node: ">=20" }, provenance: { revision: "86ae907f59458508e3277666cdd214f5bd7c1078", packager: { name: "@ekklesia/module-pack", version: "0.1.2" }, }, incorporates: { "@ekklesia/module-sdk": "0.1.2" }, files: listed, }; const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata; options.tamper?.(files); const directories = new Set([id]); for (const filePath of files.keys()) { const segments = filePath.split("/"); for (let index = 1; index < segments.length; index += 1) { directories.add(`${id}/${segments.slice(0, index).join("/")}`); } } const all = new Map(); for (const directory of directories) { all.set(directory, { path: directory, type: "directory" }); } for (const [filePath, data] of files) { all.set(`${id}/${filePath}`, { path: `${id}/${filePath}`, data }); } all.set(`${id}/ekklesia.package.json`, { path: `${id}/ekklesia.package.json`, data: `${JSON.stringify(metadata, null, 2)}\n`, }); const ordered = [...all.values()].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0, ); const archive = tarGz([...ordered, ...(options.extraEntries ?? [])]); return { archive, sha256: sha256(archive) }; }