feat: native linux-x64-glibc module packages and reviewed license supplements (ADR 0049, ADR 0052)
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
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f53506b3f0
commit
44d385cd7f
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"manifestVersion": 8,
|
||||
"id": "native-proof",
|
||||
"name": "Native proof",
|
||||
"description": "A minimal native module: its backend uses sharp, so it packages for linux-x64-glibc.",
|
||||
"version": "0.1.0",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
},
|
||||
"database": {
|
||||
"abi": 1
|
||||
},
|
||||
"capabilities": [],
|
||||
"permissions": [],
|
||||
"entrypoints": {
|
||||
"server": "./server/index.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@ekklesia-fixtures/native-proof",
|
||||
"version": "0.1.0",
|
||||
"description": "The packager's native-target proof module. Not a workspace package: its tests link sharp and the Module SDK in.",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ekklesia/module-sdk": "workspace:*",
|
||||
"sharp": "0.35.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// The packager's native-target proof module (ADR 0049). Its backend uses sharp, a native dependency,
|
||||
// so it packages only for a native target. It is plain JavaScript and needs no build step.
|
||||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||||
import sharp from "sharp";
|
||||
|
||||
/** A deterministic image operation: a 4×4 solid red image resized to 2×2, returned as raw pixels,
|
||||
* and the same image encoded as PNG and decoded again. */
|
||||
export async function proof() {
|
||||
const resized = await sharp({
|
||||
create: { background: { b: 0, g: 0, r: 255 }, channels: 3, height: 4, width: 4 },
|
||||
})
|
||||
.resize(2, 2)
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const png = await sharp({
|
||||
create: { background: { b: 255, g: 0, r: 0 }, channels: 3, height: 3, width: 5 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const decoded = await sharp(png).metadata();
|
||||
return {
|
||||
pixels: [...resized.data],
|
||||
png: { format: decoded.format, height: decoded.height, width: decoded.width },
|
||||
resized: {
|
||||
channels: resized.info.channels,
|
||||
height: resized.info.height,
|
||||
width: resized.info.width,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineModuleRuntime({
|
||||
database: { abi: 1 },
|
||||
id: "native-proof",
|
||||
version: "0.1.0",
|
||||
register() {},
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { readFile, symlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { describeElf, ElfFormatError } from "../src/elf.js";
|
||||
import { type ModulePackErrorCode, packModule } from "../src/index.js";
|
||||
import {
|
||||
cleanupFixtures,
|
||||
type FakePackage,
|
||||
type FixtureModuleOptions,
|
||||
fixtureModule,
|
||||
installedPackageFile,
|
||||
SOURCE_REVISION,
|
||||
scratchDirectory,
|
||||
} from "./support/fixture-module.js";
|
||||
import { fileEntries, readTarGz } from "./support/tar.js";
|
||||
|
||||
/**
|
||||
* The native closure rules (ADR 0049), with fake packages built around the real sharp addon's bytes,
|
||||
* so each rule can be broken on its own: which packages are copied, which are left out, and what
|
||||
* fails closed.
|
||||
*/
|
||||
|
||||
afterEach(cleanupFixtures);
|
||||
|
||||
const MIT = "MIT License\n\nCopyright (c) 2026 Example Author\n\nPermission is hereby granted.\n";
|
||||
|
||||
async function sharpAddon(): Promise<Buffer> {
|
||||
const sharp = await installedPackageFile("sharp");
|
||||
return readFile(
|
||||
path.join(path.dirname(sharp), "@img", "sharp-linux-x64", "lib", "sharp-linux-x64-0.35.4.node"),
|
||||
);
|
||||
}
|
||||
|
||||
function wrapper(extra: Record<string, unknown> = {}): FakePackage {
|
||||
return {
|
||||
files: { "index.js": "export default 1;\n", LICENSE: MIT, "index.d.ts": "export {};\n" },
|
||||
name: "fake-native",
|
||||
packageJson: {
|
||||
dependencies: { "fake-helper": "1.0.0" },
|
||||
devDependencies: { "fake-devtool": "1.0.0" },
|
||||
optionalDependencies: {
|
||||
"fake-native-darwin-arm64": "1.0.0",
|
||||
"fake-native-linux-x64-gnu": "1.0.0",
|
||||
"fake-native-linux-x64-musl": "1.0.0",
|
||||
},
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function platformPackages(addon: Uint8Array): Promise<FakePackage[]> {
|
||||
return [
|
||||
{
|
||||
files: { "lib/fake.node": addon, LICENSE: MIT, "index.js": "module.exports = 1;\n" },
|
||||
name: "fake-native-linux-x64-gnu",
|
||||
packageJson: { cpu: ["x64"], libc: ["glibc"], os: ["linux"], type: "commonjs" },
|
||||
},
|
||||
{
|
||||
files: { "lib/fake.node": addon, LICENSE: MIT },
|
||||
name: "fake-native-darwin-arm64",
|
||||
packageJson: { cpu: ["arm64"], os: ["darwin"] },
|
||||
},
|
||||
{
|
||||
files: { "lib/fake.node": addon, LICENSE: MIT },
|
||||
name: "fake-native-linux-x64-musl",
|
||||
packageJson: { cpu: ["x64"], libc: ["musl"], os: ["linux"] },
|
||||
},
|
||||
{ files: { "index.js": "export default 2;\n", LICENSE: MIT }, name: "fake-helper" },
|
||||
{ files: { "index.js": "export default 3;\n", LICENSE: MIT }, name: "fake-devtool" },
|
||||
];
|
||||
}
|
||||
|
||||
const importsFakeNative =
|
||||
'import value from "fake-native";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n';
|
||||
|
||||
async function packNative(options: FixtureModuleOptions, target = "linux-x64-glibc") {
|
||||
const moduleDirectory = await fixtureModule(options);
|
||||
const outDirectory = await scratchDirectory("ekklesia-native-closure-out-");
|
||||
const result = await packModule({
|
||||
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
||||
moduleDirectory,
|
||||
nodeRange: ">=24.11.0 <25",
|
||||
outDirectory,
|
||||
sourceRevision: SOURCE_REVISION,
|
||||
target,
|
||||
});
|
||||
return { archive: await readFile(result.archivePath), result };
|
||||
}
|
||||
|
||||
async function refusal(
|
||||
options: FixtureModuleOptions,
|
||||
code: ModulePackErrorCode,
|
||||
target = "linux-x64-glibc",
|
||||
): Promise<void> {
|
||||
await expect(packNative(options, target)).rejects.toMatchObject({ code });
|
||||
}
|
||||
|
||||
describe("the native closure", () => {
|
||||
it("copies the native package, its production dependencies, and only the matching platform package", async () => {
|
||||
const { archive, result } = await packNative({
|
||||
fakePackages: [wrapper(), ...(await platformPackages(await sharpAddon()))],
|
||||
serverSource: importsFakeNative,
|
||||
});
|
||||
const files = [...fileEntries(archive).keys()].map((name) => name.replace(/^example\//u, ""));
|
||||
const copied = new Set(
|
||||
files.filter((name) => name.startsWith("node_modules/")).map((name) => name.split("/")[1]),
|
||||
);
|
||||
expect([...copied].sort()).toEqual(["fake-helper", "fake-native", "fake-native-linux-x64-gnu"]);
|
||||
// Every copied file is a real file; declarations never ship.
|
||||
expect(
|
||||
readTarGz(archive).every((entry) => entry.typeflag === "0" || entry.typeflag === "5"),
|
||||
).toBe(true);
|
||||
expect(files).not.toContain("node_modules/fake-native/index.d.ts");
|
||||
expect(files).toContain("node_modules/fake-native-linux-x64-gnu/lib/fake.node");
|
||||
|
||||
const metadata = JSON.parse(
|
||||
fileEntries(archive).get("example/ekklesia.package.json")?.toString("utf8") ?? "{}",
|
||||
);
|
||||
// The sharp addon alone: Node-API 9, and GLIBC_2.17 is the newest symbol version it needs.
|
||||
expect(metadata.runtime).toEqual({
|
||||
libcVersion: ">=2.17.0",
|
||||
node: ">=24.11.0 <25",
|
||||
nodeApi: 9,
|
||||
});
|
||||
expect(result.target).toBe("linux-x64-glibc");
|
||||
const notices = fileEntries(archive).get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "";
|
||||
expect(notices).toContain(
|
||||
"fake-native-linux-x64-gnu 1.0.0\nLicense: MIT\nUsed in: node_modules",
|
||||
);
|
||||
expect(notices).not.toContain("fake-devtool");
|
||||
expect(notices).not.toContain("fake-native-darwin-arm64");
|
||||
});
|
||||
|
||||
it("refuses a required dependency that is not installed", async () => {
|
||||
const packages = (await platformPackages(await sharpAddon())).filter(
|
||||
(fake) => fake.name !== "fake-helper",
|
||||
);
|
||||
await refusal(
|
||||
{ fakePackages: [wrapper(), ...packages], serverSource: importsFakeNative },
|
||||
"MODULE_PACK_NATIVE_DEPENDENCY_MISSING",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a required dependency built for another platform", async () => {
|
||||
await refusal(
|
||||
{
|
||||
fakePackages: [
|
||||
wrapper({ dependencies: { "fake-helper": "1.0.0", "fake-required-darwin": "1.0.0" } }),
|
||||
...(await platformPackages(await sharpAddon())),
|
||||
{
|
||||
files: { "index.js": "export default 4;\n", LICENSE: MIT },
|
||||
name: "fake-required-darwin",
|
||||
packageJson: { os: ["darwin"] },
|
||||
},
|
||||
],
|
||||
serverSource: importsFakeNative,
|
||||
},
|
||||
"MODULE_PACK_NATIVE_TARGET_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses two versions of one package in the flat layout", async () => {
|
||||
const addon = await sharpAddon();
|
||||
const packages = await platformPackages(addon);
|
||||
const platform = packages.find((fake) => fake.name === "fake-native-linux-x64-gnu");
|
||||
const conflicting: FakePackage = {
|
||||
...(platform as FakePackage),
|
||||
files: {
|
||||
...(platform?.files ?? {}),
|
||||
"node_modules/fake-helper/package.json": JSON.stringify({
|
||||
license: "MIT",
|
||||
name: "fake-helper",
|
||||
version: "2.0.0",
|
||||
}),
|
||||
"node_modules/fake-helper/LICENSE": MIT,
|
||||
},
|
||||
packageJson: { ...(platform?.packageJson ?? {}), dependencies: { "fake-helper": "2.0.0" } },
|
||||
};
|
||||
await refusal(
|
||||
{
|
||||
fakePackages: [wrapper(), conflicting, ...packages.filter((fake) => fake !== platform)],
|
||||
serverSource: importsFakeNative,
|
||||
},
|
||||
"MODULE_PACK_NATIVE_LAYOUT_CONFLICT",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an addon built for another machine, or one that is not an ELF object", async () => {
|
||||
const foreign = Buffer.from(await sharpAddon());
|
||||
foreign.writeUInt16LE(183, 18); // EM_AARCH64
|
||||
await refusal(
|
||||
{
|
||||
fakePackages: [wrapper(), ...(await platformPackages(foreign))],
|
||||
serverSource: importsFakeNative,
|
||||
},
|
||||
"MODULE_PACK_NATIVE_TARGET_MISMATCH",
|
||||
);
|
||||
await refusal(
|
||||
{
|
||||
fakePackages: [wrapper(), ...(await platformPackages(Buffer.from("not an addon")))],
|
||||
serverSource: importsFakeNative,
|
||||
},
|
||||
"MODULE_PACK_NATIVE_TARGET_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a link inside a native package", async () => {
|
||||
const moduleDirectory = await fixtureModule({
|
||||
fakePackages: [wrapper(), ...(await platformPackages(await sharpAddon()))],
|
||||
serverSource: importsFakeNative,
|
||||
});
|
||||
await symlink(
|
||||
"/etc/hostname",
|
||||
path.join(moduleDirectory, "node_modules", "fake-native-linux-x64-gnu", "escape"),
|
||||
);
|
||||
await expect(
|
||||
packModule({
|
||||
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
||||
moduleDirectory,
|
||||
nodeRange: ">=24.11.0 <25",
|
||||
outDirectory: await scratchDirectory(),
|
||||
sourceRevision: SOURCE_REVISION,
|
||||
target: "linux-x64-glibc",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACK_NATIVE_PACKAGE_INVALID" });
|
||||
});
|
||||
|
||||
it("refuses a native dependency that does not support the package's Node.js range", async () => {
|
||||
await refusal(
|
||||
{
|
||||
fakePackages: [
|
||||
wrapper({ engines: { node: ">=99" } }),
|
||||
...(await platformPackages(await sharpAddon())),
|
||||
],
|
||||
serverSource: importsFakeNative,
|
||||
},
|
||||
"MODULE_PACK_NATIVE_NODE_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a native target for a module with no native addon", async () => {
|
||||
await refusal({}, "MODULE_PACK_NATIVE_TARGET_UNNEEDED");
|
||||
});
|
||||
|
||||
it("refuses targets it does not produce", async () => {
|
||||
for (const target of ["linux-arm64-glibc", "linux-x64-musl", "darwin-arm64", "win32-x64"]) {
|
||||
await refusal({}, "MODULE_PACK_TARGET_UNSUPPORTED", target);
|
||||
}
|
||||
});
|
||||
|
||||
it("still refuses the same native package in an any-target package", async () => {
|
||||
await refusal(
|
||||
{
|
||||
fakePackages: [wrapper(), ...(await platformPackages(await sharpAddon()))],
|
||||
serverSource: importsFakeNative,
|
||||
},
|
||||
"MODULE_PACK_NATIVE_DEPENDENCY",
|
||||
"any",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading an ELF object", () => {
|
||||
it("reads the machine, needed libraries, symbol versions, and declared Node-API version", async () => {
|
||||
const description = describeElf(await sharpAddon());
|
||||
expect(description.machine).toBe(62);
|
||||
expect(description.needed).toContain("libvips-cpp.so.8.18.6");
|
||||
expect(description.exported.has("napi_register_module_v1")).toBe(true);
|
||||
expect(description.nodeApiModuleVersion).toBe(9);
|
||||
expect(description.requiredVersions).toContain("GLIBC_2.17");
|
||||
});
|
||||
|
||||
it("refuses what it cannot read", () => {
|
||||
expect(() => describeElf(Buffer.from("\u007fELF"))).toThrow(ElfFormatError);
|
||||
const thirtyTwoBit = Buffer.alloc(64);
|
||||
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1, 1]).copy(thirtyTwoBit);
|
||||
expect(() => describeElf(thirtyTwoBit)).toThrow(ElfFormatError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -349,8 +349,9 @@ export default { database: { abi: 1 }, id: "example", version: "0.1.0", register
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses any target but any in this release", async () => {
|
||||
await packRejection({}, "MODULE_PACK_TARGET_UNSUPPORTED", { target: "linux-x64-glibc" });
|
||||
it("refuses targets it does not produce, and a native target a pure-JavaScript module does not need", async () => {
|
||||
await packRejection({}, "MODULE_PACK_TARGET_UNSUPPORTED", { target: "linux-arm64-glibc" });
|
||||
await packRejection({}, "MODULE_PACK_NATIVE_TARGET_UNNEEDED", { target: "linux-x64-glibc" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, cp, mkdir, readdir, readFile, realpath, utimes, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { packModule } from "../src/index.js";
|
||||
import { NATIVE_TARGETS, nativeClosure } from "../src/native.js";
|
||||
import {
|
||||
CONTENT_DIGEST_ALGORITHM,
|
||||
contentDigest,
|
||||
DEFAULT_SUPPLEMENTS_DIRECTORY,
|
||||
loadLicenseSupplements,
|
||||
SupplementError,
|
||||
} from "../src/supplements.js";
|
||||
import {
|
||||
cleanupFixtures,
|
||||
fixtureModule,
|
||||
installedPackageFile,
|
||||
SOURCE_REVISION,
|
||||
scratchDirectory,
|
||||
} from "./support/fixture-module.js";
|
||||
import { fileEntries } from "./support/tar.js";
|
||||
|
||||
/**
|
||||
* Reviewed license supplements (ADR 0052): the content digest they are matched by, the loader that
|
||||
* refuses anything unreviewed, how notices merge them, and the real libvips supplement.
|
||||
*/
|
||||
|
||||
afterEach(cleanupFixtures);
|
||||
|
||||
function sha256(data: string | Uint8Array): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
describe("the content digest", () => {
|
||||
const files = new Map<string, Uint8Array>([
|
||||
["package.json", Buffer.from('{"name":"x"}\n')],
|
||||
["lib/a.js", Buffer.from("a\n")],
|
||||
]);
|
||||
|
||||
it("is the documented algorithm: sorted paths, NUL-separated size and SHA-256, one line each", () => {
|
||||
const manifest =
|
||||
`lib/a.js\u0000${2}\u0000${sha256("a\n")}\n` +
|
||||
`package.json\u0000${13}\u0000${sha256('{"name":"x"}\n')}\n`;
|
||||
expect(contentDigest(files)).toBe(`${CONTENT_DIGEST_ALGORITHM}:sha256:${sha256(manifest)}`);
|
||||
});
|
||||
|
||||
it("does not depend on order or on how a path was written, only on paths and bytes", () => {
|
||||
const reordered = new Map([...files].reverse());
|
||||
const dotted = new Map([...files].map(([key, value]) => [`./${key}`, value]));
|
||||
expect(contentDigest(reordered)).toBe(contentDigest(files));
|
||||
expect(contentDigest(dotted)).toBe(contentDigest(files));
|
||||
expect(contentDigest(new Map([...files, ["lib/a.js", Buffer.from("b\n")]]))).not.toBe(
|
||||
contentDigest(files),
|
||||
);
|
||||
expect(
|
||||
contentDigest(new Map([["lib/b.js", Buffer.from("a\n")], ...[...files].slice(0, 1)])),
|
||||
).not.toBe(contentDigest(files));
|
||||
expect(() => contentDigest(new Map([["../escape", Buffer.from("")]]))).toThrow();
|
||||
});
|
||||
|
||||
it("ignores timestamps, permissions, and where a package is installed", async () => {
|
||||
const sharp = await installedPackageFile("sharp");
|
||||
const original = path.join(path.dirname(sharp), "detect-libc");
|
||||
const copy = path.join(
|
||||
await scratchDirectory("ekklesia-digest-copy-"),
|
||||
"node_modules",
|
||||
"detect-libc",
|
||||
);
|
||||
await cp(original, copy, { recursive: true });
|
||||
await utimes(path.join(copy, "package.json"), new Date(0), new Date(0));
|
||||
await chmod(path.join(copy, "package.json"), 0o600);
|
||||
const target = NATIVE_TARGETS.get("linux-x64-glibc");
|
||||
if (target === undefined) throw new Error("target");
|
||||
const [fromStore] = await nativeClosure([{ name: "detect-libc", root: original }], target);
|
||||
const [fromCopy] = await nativeClosure([{ name: "detect-libc", root: copy }], target);
|
||||
expect(fromStore && fromCopy && contentDigest(fromCopy.files)).toBe(
|
||||
fromStore && contentDigest(fromStore.files),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function writeSupplement(
|
||||
root: string,
|
||||
name: string,
|
||||
version: string,
|
||||
packageFields: Record<string, unknown>,
|
||||
material = "Copyright (c) 2026 Reviewed Author\n\nPermission is hereby granted.\n",
|
||||
): Promise<string> {
|
||||
const directory = path.join(root, ...name.split("/"), version);
|
||||
await mkdir(path.join(directory, "texts"), { recursive: true });
|
||||
const digest = sha256(material);
|
||||
await writeFile(path.join(directory, "texts", `${digest}.txt`), material);
|
||||
await writeFile(
|
||||
path.join(directory, "supplement.json"),
|
||||
JSON.stringify({
|
||||
components: [
|
||||
{
|
||||
license: "MIT",
|
||||
material: [{ text: `texts/${digest}.txt`, title: "LICENSE (from upstream)" }],
|
||||
name: "fake-textless-upstream",
|
||||
source: "https://example.invalid/fake-textless-1.0.0.tar.gz (sha256 0)",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
package: { license: "MIT", name, version, ...packageFields },
|
||||
statement: "Reviewed test material for a package that ships no licence file.",
|
||||
supplementFormat: 1,
|
||||
}),
|
||||
);
|
||||
return directory;
|
||||
}
|
||||
|
||||
describe("loading supplements", () => {
|
||||
it("loads nothing from a directory that does not exist", async () => {
|
||||
const missing = path.join(await scratchDirectory(), "none");
|
||||
expect((await loadLicenseSupplements(missing)).size).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a text whose bytes do not match its name, an unlisted file, or a mislabelled directory", async () => {
|
||||
const digest = `${CONTENT_DIGEST_ALGORITHM}:sha256:${"0".repeat(64)}`;
|
||||
const tampered = await scratchDirectory();
|
||||
const directory = await writeSupplement(tampered, "fake-textless", "1.0.0", {
|
||||
contentDigest: digest,
|
||||
});
|
||||
const textFile = path.join(
|
||||
directory,
|
||||
"texts",
|
||||
(await readdir(path.join(directory, "texts")))[0] ?? "",
|
||||
);
|
||||
await writeFile(textFile, "changed after review\n");
|
||||
await expect(loadLicenseSupplements(tampered)).rejects.toBeInstanceOf(SupplementError);
|
||||
|
||||
const unlisted = await scratchDirectory();
|
||||
const second = await writeSupplement(unlisted, "fake-textless", "1.0.0", {
|
||||
contentDigest: digest,
|
||||
});
|
||||
await writeFile(path.join(second, "texts", "extra.txt"), "never reviewed\n");
|
||||
await expect(loadLicenseSupplements(unlisted)).rejects.toBeInstanceOf(SupplementError);
|
||||
|
||||
const mislabelled = await scratchDirectory();
|
||||
await writeSupplement(mislabelled, "fake-textless", "1.0.0", {
|
||||
contentDigest: digest,
|
||||
version: "2.0.0",
|
||||
});
|
||||
await expect(loadLicenseSupplements(mislabelled)).rejects.toBeInstanceOf(SupplementError);
|
||||
|
||||
const unknownField = await scratchDirectory();
|
||||
await writeSupplement(unknownField, "fake-textless", "1.0.0", {
|
||||
contentDigest: digest,
|
||||
overrideLicense: "Apache-2.0",
|
||||
});
|
||||
await expect(loadLicenseSupplements(unknownField)).rejects.toBeInstanceOf(SupplementError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("merging a supplement into THIRD_PARTY_NOTICES", () => {
|
||||
const textless = {
|
||||
files: { "index.js": "export default 1;\n" },
|
||||
name: "fake-textless",
|
||||
};
|
||||
// The bundled package's redistributed files are the ones the bundle took from it.
|
||||
const digest = contentDigest(new Map([["index.js", Buffer.from("export default 1;\n")]]));
|
||||
const serverSource =
|
||||
'import value from "fake-textless";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n';
|
||||
|
||||
async function packWith(supplements: string) {
|
||||
const moduleDirectory = await fixtureModule({ fakePackages: [textless], serverSource });
|
||||
const result = await packModule({
|
||||
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
||||
moduleDirectory,
|
||||
nodeRange: ">=24.11.0 <25",
|
||||
outDirectory: await scratchDirectory(),
|
||||
sourceRevision: SOURCE_REVISION,
|
||||
supplementsDirectory: supplements,
|
||||
target: "any",
|
||||
});
|
||||
return (
|
||||
fileEntries(await readFile(result.archivePath))
|
||||
.get("example/THIRD_PARTY_NOTICES")
|
||||
?.toString("utf8") ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
it("still fails a package that omits its licence text when no supplement names it", async () => {
|
||||
await expect(packWith(await scratchDirectory())).rejects.toMatchObject({
|
||||
code: "MODULE_PACK_LICENCE_TEXT_MISSING",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds an exact supplement's material after the package's own, labelled, and keeps the declared licence", async () => {
|
||||
const supplements = await scratchDirectory();
|
||||
await writeSupplement(supplements, "fake-textless", "1.0.0", { contentDigest: digest });
|
||||
const notices = await packWith(supplements);
|
||||
const entry = notices.slice(notices.indexOf("\nfake-textless 1.0.0\n"));
|
||||
expect(entry).toContain("License: MIT\n");
|
||||
expect(entry).toContain("[Reviewed license supplement]");
|
||||
expect(entry).toContain(digest);
|
||||
expect(entry).toContain("[fake-textless-upstream 1.0.0: LICENSE (from upstream)]");
|
||||
expect(entry).toContain("Copyright (c) 2026 Reviewed Author");
|
||||
expect(entry).toContain("Reviewed test material for a package that ships no licence file.");
|
||||
// Deterministic: the same inputs give the same notices.
|
||||
expect(await packWith(supplements)).toBe(notices);
|
||||
});
|
||||
|
||||
it("fails closed on a supplement for another version, another content digest, or another licence", async () => {
|
||||
for (const fields of [
|
||||
{ contentDigest: digest, version: "0.9.0" },
|
||||
{ contentDigest: `${CONTENT_DIGEST_ALGORITHM}:sha256:${"f".repeat(64)}` },
|
||||
{ contentDigest: digest, license: "Apache-2.0" },
|
||||
]) {
|
||||
const supplements = await scratchDirectory();
|
||||
await writeSupplement(supplements, "fake-textless", fields.version ?? "1.0.0", fields);
|
||||
await expect(packWith(supplements)).rejects.toMatchObject({
|
||||
code: "MODULE_PACK_LICENCE_SUPPLEMENT_MISMATCH",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to package at all when a shipped supplement is malformed", async () => {
|
||||
const supplements = await scratchDirectory();
|
||||
const directory = await writeSupplement(supplements, "fake-textless", "1.0.0", {
|
||||
contentDigest: digest,
|
||||
});
|
||||
await writeFile(path.join(directory, "supplement.json"), "{");
|
||||
await expect(packWith(supplements)).rejects.toMatchObject({
|
||||
code: "MODULE_PACK_LICENCE_SUPPLEMENT_INVALID",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reviewed libvips supplement", () => {
|
||||
it("loads intact, and pins exactly the installed @img/[email protected]", async () => {
|
||||
const supplements = await loadLicenseSupplements(DEFAULT_SUPPLEMENTS_DIRECTORY);
|
||||
expect([...supplements.keys()]).toEqual(["@img/sharp-libvips-linux-x64"]);
|
||||
const [libvips] = supplements.get("@img/sharp-libvips-linux-x64") ?? [];
|
||||
expect(libvips).toMatchObject({ license: "LGPL-3.0-or-later", version: "1.3.3" });
|
||||
|
||||
const sharp = await realpath(await installedPackageFile("sharp"));
|
||||
const target = NATIVE_TARGETS.get("linux-x64-glibc");
|
||||
if (target === undefined) throw new Error("target");
|
||||
const closure = await nativeClosure([{ name: "sharp", root: sharp }], target);
|
||||
const installed = closure.find((entry) => entry.name === "@img/sharp-libvips-linux-x64");
|
||||
expect(installed && contentDigest(installed.files)).toBe(libvips?.contentDigest);
|
||||
|
||||
// Every library built into the binary is covered, with material from a recorded source, and the
|
||||
// open determination is stated rather than resolved.
|
||||
const names = new Set(libvips?.components.map((component) => component.name));
|
||||
for (const library of [
|
||||
"libvips",
|
||||
"glib",
|
||||
"gvdb",
|
||||
"cairo",
|
||||
"pango",
|
||||
"librsvg",
|
||||
"mozjpeg",
|
||||
"zlib-ng",
|
||||
]) {
|
||||
expect(names.has(library), library).toBe(true);
|
||||
}
|
||||
for (const component of libvips?.components ?? []) {
|
||||
expect(component.material.length, component.name).toBeGreaterThan(0);
|
||||
expect(component.source, component.name).toMatch(/https:\/\//u);
|
||||
}
|
||||
expect(libvips?.statement).toContain("open determination");
|
||||
expect(names.has("proxy-libintl")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { cp, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -223,3 +223,38 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
|
||||
}
|
||||
|
||||
export const SOURCE_REVISION = "86ae907f59458508e3277666cdd214f5bd7c1078";
|
||||
|
||||
const nativeFixtureSource = path.join(packageRoot, "tests", "fixtures", "native-module");
|
||||
|
||||
/**
|
||||
* The checked-in native proof module (`tests/fixtures/native-module`), copied to a fresh, unrelated
|
||||
* temporary directory with `node_modules` linking the real sharp and Module SDK this package's
|
||||
* workspace installed, as a module's own install would provide them.
|
||||
*/
|
||||
export async function nativeFixtureModule(): Promise<string> {
|
||||
// Inside this package, beside its own node_modules, as a module checkout sits beside its own: a
|
||||
// bundle's module keys are paths relative to the module, so a copy in the system temporary
|
||||
// directory would bundle paths that climb out of it and into this checkout.
|
||||
const fixtures = path.join(packageRoot, ".fixtures");
|
||||
await mkdir(fixtures, { recursive: true });
|
||||
const root = await realpath(await mkdtemp(path.join(fixtures, "native-")));
|
||||
temporaryDirectories.push(root);
|
||||
const moduleDirectory = path.join(root, "native-proof");
|
||||
await cp(nativeFixtureSource, moduleDirectory, { recursive: true });
|
||||
const modules = path.join(moduleDirectory, "node_modules");
|
||||
await mkdir(path.join(modules, "@ekklesia"), { recursive: true });
|
||||
await symlink(
|
||||
await realpath(path.join(packageRoot, "node_modules", "sharp")),
|
||||
path.join(modules, "sharp"),
|
||||
);
|
||||
await symlink(
|
||||
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")),
|
||||
path.join(modules, "@ekklesia", "module-sdk"),
|
||||
);
|
||||
return moduleDirectory;
|
||||
}
|
||||
|
||||
/** The workspace's real installed package, for tests that build fake native packages from it. */
|
||||
export async function installedPackageFile(...segments: string[]): Promise<string> {
|
||||
return realpath(path.join(packageRoot, "node_modules", ...segments));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user