CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
Every Module Package v1 now declares the module's license and carries its text. packageFormat stays 1; pre-ADR-0051 metadata fails closed. - Contracts: ekklesia.package.json requires an SPDX `license` expression, validated with spdx-expression-parse, with UNLICENSED and non-expressions refused. The `files` list must hold exactly one non-empty root LICENSE. The package contracts move to the @ekklesia/contracts/module-package subpath, so the SPDX parser and its data (spdx-exceptions and spdx-license-ids ship no licence files) never reach a module bundle through the SDK's re-export of the contracts root. - Packager: reads the source package.json's string SPDX declaration and never invents one. It requires an explicit --license-file (a non-empty regular file, no symlink, no parent search, no download) and copies its bytes unchanged to LICENSE, listed with size and SHA-256 and covered by the archive digest. THIRD_PARTY_NOTICES stays third-party only, and bundled Ekklesia code stays in `incorporates`. - Verifier: rejects missing or invalid license metadata, and a missing, unlisted, duplicated, or corrupted LICENSE, through the schema and the existing strict inventory. - Template: packaged as AGPL-3.0-or-later with the repository's canonical LICENSE, passed explicitly (Dockerfile, smoke, docs). Packing it twice gives byte-identical archives. Started by Codex; reviewed and completed in Claude Code (empty license-text refusal, schema non-empty LICENSE, symlink and empty-file tests). ADR 0051 is Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
210 lines
7.4 KiB
TypeScript
210 lines
7.4 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"));
|
|
files.set("LICENSE", Buffer.from("Example module license text.\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 },
|
|
license: "MIT",
|
|
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) };
|
|
}
|