Files
Ekklesia/packages/module-pack/tests/native-target.test.ts
T
ekklesiaandClaude Opus 5.5 44d385cd7f
CI / verify (pull_request) Successful in 1m6s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
feat: native linux-x64-glibc module packages and reviewed license supplements (ADR 0049, ADR 0052)
Implements ADR 0049's deferred native target and ADR 0052's reviewed
license supplements. The proof case is a real module using sharp.
packageFormat stays 1, and `any` is unchanged.

Packager (@ekklesia/module-pack):
- --target linux-x64-glibc. Native or platform-specific packages, found by
  the same generic rule that refuses them in `any`, stay outside the bundle.
- Each native package's production closure is walked through its own
  dependencies, installed optionalDependencies that match the target, and
  required peers. It is copied as real files flat under node_modules/<name>,
  with no links, dev dependencies, declarations, source maps, or other
  platforms' packages. Missing or foreign-platform required dependencies,
  version conflicts, links, foreign machines, and Node.js range conflicts
  fail closed.
- runtime.nodeApi, nodeAbi, and libcVersion are read from the binaries by a
  small ELF reader: the addon's node_api_module_get_api_version_v1 and the
  newest GLIBC_ symbol version needed. No shelling out. A native target with
  no addon is refused.
- ADR 0052 supplements: package-provided material stays primary. An exact
  match on name, version, and ekklesia-content-v1 digest (sorted normalized
  paths with each file's size and SHA-256; no metadata) adds labelled
  material after the package's own. Any mismatch, a declared-licence
  conflict, or a malformed or unreviewed supplement file fails. Missing
  material with no supplement still fails.

Supplement for @img/[email protected]:
- 184 components and 143 unique texts, assembled from the exact upstream
  sources sharp-libvips v1.3.3 builds linux-x64 from: 27 C/C++ libraries,
  glib's gvdb, librsvg's Rust crates (reproduced with cargo tree), the Rust
  standard library at the rustc commit recorded in the binary, plus the
  LGPL-3.0, GPL-3.0, and MPL-2.0 texts. Each source is recorded with its
  SHA-256 in PROVENANCE.md.
- It pins digest ekklesia-content-v1:sha256:50647b0b…, which is identical
  from the installed copy and from the registry tarball.
- LGPL/MPL source-availability determinations stay open, as ADR 0052
  states. Nothing claims they are met.

Verifier (Core):
- linux-x64-glibc is accepted only on a host whose platform, arch, glibc
  family and version, and Node-API version (from Node itself) match.
- Every ELF file must be x86-64; every .node must be an ELF object.
- `any` packages now also refuse node_modules/ and ELF files.
- Contract: native metadata must state nodeApi or nodeAbi, and Linux targets
  libcVersion (ADR 0049).

Proof:
- A checked-in AGPL native fixture packs byte-identically from unrelated
  copies (8.86 MB) and verifies with Core's verifier.
- Extracted elsewhere, it runs a sharp resize and PNG round trip in an
  empty-environment process. Every resolution stays in the package, and
  only the package's addon and libvips plus host system libraries load.

Signage is untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 20:16:53 -05:00

263 lines
11 KiB
TypeScript

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:50647b0b1e0b99943f24e4a1580650c0d8fe7744779b0f16271dcd2d59a7678c";
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, Buffer>): string[] {
const names = new Set<string>();
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.3\n");
const nextEntry = notices.indexOf("\n@img/sharp-linux-x64 0.35.4\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.6: LICENSE]");
expect(section).toContain("[@img/sharp-libvips-linux-x64 1.3.3: GNU LGPL version 3]");
// Other packages carry their own shipped licence text, with no supplement.
const sharp = notices.slice(notices.indexOf("\nsharp 0.35.4\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.1.2",
platformVersion: "0.1.2",
},
}),
).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.4.node`,
);
expect(native).toContain(
`${extractTo}/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.18.6`,
);
// 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<string, Buffer>();
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" });
});
});