Files
Ekklesia/core/tests/support/module-package-fixture.ts
T
ekklesia 70854d234c
CI / verify (pull_request) Successful in 1m1s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
feat: transition module packages to database ABI v1
2026-10-07 18:00:04 -05:00

208 lines
7.3 KiB
TypeScript

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<Record<FixtureEntryType, string>> = {
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<string, unknown>) => Record<string, unknown>;
readonly runtimePackage?: Record<string, unknown>;
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
/** Changes the payload after its digests were computed. */
readonly tamper?: (files: Map<string, Buffer>) => 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<Map<string, Buffer>> {
const files = new Map<string, Buffer>();
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<PackageFixture> {
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<string, Buffer>;
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<string, unknown>;
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<string, unknown> = {
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<string>([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<string, FixtureEntry>();
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) };
}