CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / deploy-smoke (split) (pull_request) Successful in 1m28s
CI / package-publication (pull_request) Successful in 50s
sharp 0.35.4 carries a production-relevant librsvg advisory (GHSA-wq5f-xc86-pv6w). Its fix, sharp
0.35.5, loads @img/sharp-libvips-linux-x64 1.3.4, which ships no licence material either. This adds
the reviewed supplement for that exact package (ADR 0052) and moves the packager's native proof to
sharp 0.35.5, so a later 0.2.1 release can package it on linux-x64-glibc. Nothing in module-pack's
code changes: supplement selection is already by exact name, version, and content digest.
- Supplement: license-supplements/@img/sharp-libvips-linux-x64/1.3.4/
- content digest ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59;
- 190 components and 146 hashed texts;
- assembled with the same method as 1.3.3's, from sharp-libvips v1.3.4's own inputs. That method
reproduces the 1.3.3 supplement byte for byte.
- PROVENANCE.md records:
- the 27 upstream archives and five build-time patches, by sha256;
- the librsvg 2.63.2 crate reproduction (cargo 1.99.0; Cargo.lock 5723e14c…);
- rustc 75a75c3e…;
- every change from 1.3.3.
- Notable: sharp-libvips v1.3.4 now states cairo is used under MPL 1.1, not MPL 2.0. cairo ships
that text itself, so the standard MPL 2.0 text is no longer added for cairo; selectors still
carries it.
- No legal conclusion: the LGPL and MPL source-availability determination stays open, as for 1.3.3.
- The 1.3.3 supplement is unchanged, and still proven: module-pack keeps sharp 0.35.4 as a test-only
devDependency alias (`sharp-0.35.4`). Each supplement is pinned against its real installed
libvips, and each matches only its own.
- Native proof fixture: sharp 0.35.4 → 0.35.5. The closure has the same shape: sharp, @img/colour,
detect-libc, semver, and @img/sharp-linux-x64 0.35.5 with @img/sharp-libvips-linux-x64 1.3.4.
The runtime read from the binaries is unchanged: Node-API 9, glibc >= 2.28, x86-64.
- Docs: the module guide's note on available supplements.
No version bump, no publication, no tag.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
263 lines
11 KiB
TypeScript
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: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, 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.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<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" });
|
|
});
|
|
});
|