import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { currentModulePackageHost, verifyModulePackage } from "@ekklesia/core/modules"; import { afterEach, describe, expect, it } from "vitest"; import { ModulePackError, packModule } from "../src/index.js"; import { contentDigest } from "../src/supplements.js"; import { cleanupFixtures, nativeFixtureModule, SOURCE_REVISION, scratchDirectory, } from "./support/fixture-module.js"; import { fileEntries, readTarGz } from "./support/tar.js"; /** * The linux-x64-glibc target end to end, with a real native dependency: the checked-in proof module * uses sharp, whose libvips binary needs Ekklesia's reviewed license supplement (ADR 0049, ADR 0052). * Packed from unrelated temporary copies, verified by Core's own verifier, extracted elsewhere, and * run in a separate process with an empty environment. */ const execFileAsync = promisify(execFile); const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = path.resolve(packageRoot, "..", ".."); const LIBVIPS_DIGEST = "ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59"; const CLOSURE = [ "@img/colour", "@img/sharp-libvips-linux-x64", "@img/sharp-linux-x64", "detect-libc", "semver", "sharp", ]; const host = currentModulePackageHost(); const onTargetHost = host.platform === "linux" && host.arch === "x64" && host.libc?.family === "glibc"; afterEach(cleanupFixtures); async function packNative(target = "linux-x64-glibc") { const moduleDirectory = await nativeFixtureModule(); const outDirectory = await scratchDirectory("ekklesia-native-out-"); const result = await packModule({ licenseFile: path.join(repositoryRoot, "LICENSE"), moduleDirectory, nodeRange: ">=24.11.0 <25", outDirectory, sourceRevision: SOURCE_REVISION, target, }); return { archive: await readFile(result.archivePath), moduleDirectory, result }; } function packagesIn(files: ReadonlyMap): string[] { const names = new Set(); for (const filePath of files.keys()) { const match = /^native-proof\/node_modules\/((?:@[^/]+\/)?[^/]+)\//u.exec(filePath); if (match?.[1]) { names.add(match[1]); } } return [...names].sort(); } describe.runIf(onTargetHost)("the linux-x64-glibc target, proven with sharp", () => { it("packages the native runtime closure as real files, and nothing else", { timeout: 120_000, }, async () => { const { archive, moduleDirectory, result } = await packNative(); expect(path.basename(result.archivePath)).toBe("native-proof-0.1.0-linux-x64-glibc.tar.gz"); expect(result.target).toBe("linux-x64-glibc"); const entries = readTarGz(archive); // Only directories and regular files: no symbolic or hard link of any kind. expect(new Set(entries.map((entry) => entry.typeflag))).toEqual(new Set(["0", "5"])); for (const entry of entries) { expect(entry.mode).toBe(entry.typeflag === "5" ? 0o755 : 0o644); } const files = fileEntries(archive); // Discovered, not listed: exactly sharp's closure for this target. expect(packagesIn(files)).toEqual(CLOSURE); // No package for another platform, and no development dependency. expect( [...files.keys()].some((name) => /sharp-(?:darwin|win32|linuxmusl|linux-arm|wasm)/u.test(name), ), ).toBe(false); expect( [...files.keys()].some((name) => /@types|@emnapi|node_modules\/.*\/node_modules\//u.test(name), ), ).toBe(false); // Declarations and source maps never ship. expect([...files.keys()].some((name) => /\.(?:d\.[cm]?ts|map)$/u.test(name))).toBe(false); const metadata = JSON.parse( files.get("native-proof/ekklesia.package.json")?.toString("utf8") ?? "{}", ); expect(metadata.target).toBe("linux-x64-glibc"); expect(metadata.license).toBe("AGPL-3.0-or-later"); // Read from the binaries: the addon declares Node-API 9; libvips needs GLIBC_2.28. expect(metadata.runtime).toEqual({ libcVersion: ">=2.28.0", node: ">=24.11.0 <25", nodeApi: 9, }); expect(metadata.transitional).toBeUndefined(); expect(files.get("native-proof/LICENSE")).toEqual( await readFile(path.join(repositoryRoot, "LICENSE")), ); // No source checkout, workspace, or temporary path in anything the package says about itself. for (const [filePath, data] of files) { if (/\.(?:js|cjs|mjs|json|md)$|THIRD_PARTY_NOTICES$/u.test(filePath)) { const text = data.toString("utf8"); expect(text, filePath).not.toContain(repositoryRoot); expect(text, filePath).not.toContain(moduleDirectory); expect(text, filePath).not.toContain(tmpdir()); } } }); it("credits every redistributed package, and merges the reviewed libvips supplement after the package's own material", { timeout: 120_000, }, async () => { const { archive } = await packNative(); const notices = fileEntries(archive).get("native-proof/THIRD_PARTY_NOTICES")?.toString("utf8") ?? ""; for (const name of CLOSURE) { const at = notices.indexOf(`\n${name} `); expect(at, name).toBeGreaterThan(0); expect(notices.slice(at, at + 400), name).toMatch(/Used in: (?:backend, )?node_modules/u); } const libvips = notices.indexOf("\n@img/sharp-libvips-linux-x64 1.3.4\n"); const nextEntry = notices.indexOf("\n@img/sharp-linux-x64 0.35.5\n"); const section = notices.slice(libvips, nextEntry); // The declared licence is the package's own, unchanged. expect(section).toContain("License: LGPL-3.0-or-later\n"); expect(section).toContain("[Reviewed license supplement]"); expect(section).toContain(LIBVIPS_DIGEST); expect(section).toContain("not legal review"); expect(section).toContain("[libvips 8.18.7: LICENSE]"); expect(section).toContain("[@img/sharp-libvips-linux-x64 1.3.4: GNU LGPL version 3]"); // Other packages carry their own shipped licence text, with no supplement. const sharp = notices.slice(notices.indexOf("\nsharp 0.35.5\n")); expect(sharp).toContain("[LICENSE]"); expect(sharp).not.toContain("[Reviewed license supplement]"); }); it("is byte-identical when packed from two unrelated copies", { timeout: 180_000 }, async () => { const first = await packNative(); const second = await packNative(); expect(first.moduleDirectory).not.toBe(second.moduleDirectory); expect(first.result.sha256).toBe(second.result.sha256); expect(first.archive.equals(second.archive)).toBe(true); const digest = (archive: Buffer) => createHash("sha256") .update(fileEntries(archive).get("native-proof/THIRD_PARTY_NOTICES") ?? Buffer.alloc(0)) .digest("hex"); expect(digest(first.archive)).toBe(digest(second.archive)); }); it("verifies, extracts elsewhere, and runs sharp from the package alone", { timeout: 180_000, }, async () => { const { result } = await packNative(); const extractTo = path.join( await scratchDirectory("ekklesia-native-installed-"), "native-proof", ); await expect( verifyModulePackage({ archivePath: result.archivePath, expectedSha256: result.sha256, extractTo, platform: { databaseRuntime: { "drizzle-orm": "0.45.2" }, moduleSdkVersion: "0.2.0", platformVersion: "0.2.0", }, }), ).resolves.toMatchObject({ moduleId: "native-proof", target: "linux-x64-glibc" }); // A separate process with an empty environment. Node records every module it resolves, and the // process's own memory map shows every shared object it loaded. const script = ` import { registerHooks } from "node:module"; import { readFileSync } from "node:fs"; const resolved = []; registerHooks({ resolve(specifier, context, next) { const r = next(specifier, context); resolved.push(r.url); return r; } }); const module = await import(${JSON.stringify(`file://${extractTo}/server/index.js`)}); const proof = await module.proof(); const mapped = readFileSync("/proc/self/maps", "utf8").split("\\n").map((line) => line.split(/\\s+/u)[5]).filter((file) => file?.startsWith("/")); process.stdout.write(JSON.stringify({ mapped: [...new Set(mapped)], proof, resolved })); `; const { stdout } = await execFileAsync( process.execPath, ["--input-type=module", "-e", script], { cwd: tmpdir(), env: {}, }, ); const outcome = JSON.parse(stdout) as { mapped: string[]; proof: unknown; resolved: string[]; }; expect(outcome.proof).toEqual({ pixels: [255, 0, 0, 255, 0, 0, 255, 0, 0, 255, 0, 0], png: { format: "png", height: 3, width: 5 }, resized: { channels: 3, height: 2, width: 2 }, }); const fileUrls = outcome.resolved.filter((url) => url.startsWith("file:")); expect(fileUrls.length).toBeGreaterThan(0); for (const url of fileUrls) { expect(url.startsWith(`file://${extractTo}/`), url).toBe(true); } const native = outcome.mapped.filter((file) => /\.(?:node|so)(?:\.|$)/u.test(file)); expect(native).toContain( `${extractTo}/node_modules/@img/sharp-linux-x64/lib/sharp-linux-x64-0.35.5.node`, ); expect(native).toContain( `${extractTo}/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.18.7`, ); // Everything else is the host's own system libraries; nothing from the workspace. for (const file of native) { expect(file.startsWith(extractTo) || /^\/(?:usr\/)?lib/u.test(file), file).toBe(true); expect(file).not.toContain(repositoryRoot); } }); it("records the libvips digest the reviewed supplement pins", { timeout: 60_000 }, async () => { const { archive } = await packNative(); const files = new Map(); for (const [filePath, data] of fileEntries(archive)) { const prefix = "native-proof/node_modules/@img/sharp-libvips-linux-x64/"; if (filePath.startsWith(prefix)) { files.set(filePath.slice(prefix.length), data); } } expect(contentDigest(files)).toBe(LIBVIPS_DIGEST); }); it("still refuses sharp in an any-target package, exactly as before", { timeout: 60_000, }, async () => { // Unchanged any-target behaviour: sharp's per-platform loader leaves imports the bundle cannot // contain, so the any target refuses it before it would ever ship. const refusal = packNative("any"); await expect(refusal).rejects.toBeInstanceOf(ModulePackError); await expect(refusal).rejects.toMatchObject({ code: "MODULE_PACK_EXTERNAL_DEPENDENCY" }); }); });