CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
A separately versioned module is now distributed as one deterministic .tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by Core before anything is installed. This slice implements the portable `any` target, with the module template as the end-to-end proof. ADR 0049 is accepted. @ekklesia/contracts: the Module Package v1 contract. The strict ekklesia.package.json schema (packageFormat 1, identity, target, runtime, provenance, incorporated Ekklesia packages, strict transitional facts, an exact byte-wise-sorted file list with size and SHA-256), target names, and the generated runtime package.json. @ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir> --target any --out <dir> --source-revision <commit>`. - Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle included; only Node built-ins remain imports. Any warning, unresolved import, external dependency, or native or platform-specific package fails. - Copies browser artifacts as built, after proving them current, and the migrations; generates package.json and ekklesia.package.json; never ships src, tests, declarations, source maps, or workspace ranges. - THIRD_PARTY_NOTICES: every third-party package traced from the bundles' own inputs, credited once with its declared licence and every licence, copying, copyright, and notice file it ships. A missing licence fails; a missing text fails unless its standard text is complete without copyright lines (Apache-2.0, vendored, needed by drizzle-orm). - Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime 0, 0755/0644, no links. Byte-identical across runs and machines. - Writes only the archive and its .sha256; never deploys, links, or installs, so the workspace's install state is untouched. Core: verifyModulePackage and `verify-module-package.js <archive> [--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe or escaping paths, and a second top-level directory; checks metadata, every file, identity, target, Node.js range, SDK, and the transitional Drizzle agreement; runs the platform's module and migration discovery; loads the backend once in a forked process with an empty environment; only then extracts, with normalized modes, into a new directory. Errors are sanitized. @ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs and whether the built file is current. The nested stylesheet build now works from the module directory, so artifacts no longer depend on the caller's working directory. The split deploy smoke builds the package with the canonical packager (scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm deploy packaging), verifies it with the same generic image, installs the verified tree, removes it, and reinstalls the exact same archive. Co-Authored-By: Claude Opus 5.5 <[email protected]>
204 lines
7.4 KiB
TypeScript
204 lines
7.4 KiB
TypeScript
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<string, "directory" | "file">();
|
|
/** Every path that is an ancestor of some entry, so a file can never claim one. */
|
|
const ancestors = new Set<string>();
|
|
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 });
|
|
}
|