Files
Ekklesia/packages/module-pack/src/elf.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

189 lines
6.9 KiB
TypeScript

/**
* Just enough of the ELF format to describe a native file the packager is about to copy (ADR 0049):
* which machine it was built for, which shared libraries and C library symbol versions it needs, and
* which Node.js addon interface it exports. Read from the file's own bytes, never by running it or
* shelling out to a system tool.
*
* Only 64-bit little-endian ELF is understood, because that is every target this packager produces.
* Anything else that starts with the ELF magic is reported as unsupported rather than guessed at.
*/
export class ElfFormatError extends Error {
constructor() {
super("The file is not a 64-bit little-endian ELF object this packager can read.");
this.name = "ElfFormatError";
}
}
/** `EM_X86_64` in the ELF machine field. */
export const ELF_MACHINE_X86_64 = 62;
export interface ElfDescription {
readonly machine: number;
/** `DT_NEEDED` entries: the shared libraries the file links against. */
readonly needed: readonly string[];
/** Symbol version names the file requires, for example `GLIBC_2.28`. */
readonly requiredVersions: readonly string[];
/** Names of the dynamic symbols the file itself defines. */
readonly exported: ReadonlySet<string>;
/** The value a Node-API addon's `node_api_module_get_api_version_v1` returns, when it exports
* that function and its body is the plain constant return every compiler emits for it.
* `undefined` when the function is absent; `"unrecognized"` when its body is something else. */
readonly nodeApiModuleVersion: number | "unrecognized" | undefined;
}
const SHT_NOBITS = 8;
const SHT_DYNSYM = 11;
const SHT_DYNAMIC = 6;
const SHT_GNU_VERNEED = 0x6ffffffe;
const DT_NULL = 0;
const DT_NEEDED = 1;
export function isElf(data: Uint8Array): boolean {
return (
data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46
);
}
interface Section {
readonly type: number;
readonly address: number;
readonly offset: number;
readonly size: number;
readonly link: number;
readonly entrySize: number;
}
export function describeElf(input: Uint8Array): ElfDescription {
const data = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
if (!isElf(data) || data.length < 64 || data[4] !== 2 || data[5] !== 1) {
throw new ElfFormatError();
}
const u16 = (offset: number): number => {
if (offset < 0 || offset + 2 > data.length) throw new ElfFormatError();
return data.readUInt16LE(offset);
};
const u32 = (offset: number): number => {
if (offset < 0 || offset + 4 > data.length) throw new ElfFormatError();
return data.readUInt32LE(offset);
};
const u64 = (offset: number): number => {
if (offset < 0 || offset + 8 > data.length) throw new ElfFormatError();
const value = data.readBigUInt64LE(offset);
if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new ElfFormatError();
return Number(value);
};
const string = (tableOffset: number, tableSize: number, index: number): string => {
if (index >= tableSize) throw new ElfFormatError();
const start = tableOffset + index;
const end = data.indexOf(0, start);
if (end === -1 || end > tableOffset + tableSize) throw new ElfFormatError();
return data.toString("latin1", start, end);
};
const machine = u16(18);
const sectionOffset = u64(40);
const sectionEntrySize = u16(58);
const sectionCount = u16(60);
if (sectionEntrySize < 64 || sectionCount === 0) {
throw new ElfFormatError();
}
const sections: Section[] = [];
for (let index = 0; index < sectionCount; index += 1) {
const base = sectionOffset + index * sectionEntrySize;
sections.push({
address: u64(base + 16),
entrySize: u64(base + 56),
link: u32(base + 40),
offset: u64(base + 24),
size: u64(base + 32),
type: u32(base + 4),
});
}
const linkedStrings = (section: Section): { offset: number; size: number } => {
const table = sections[section.link];
if (table === undefined || table.offset + table.size > data.length) throw new ElfFormatError();
return { offset: table.offset, size: table.size };
};
const needed: string[] = [];
const requiredVersions: string[] = [];
const exported = new Set<string>();
let apiVersionAddress: number | undefined;
for (const section of sections) {
if (section.type === SHT_DYNAMIC) {
const strings = linkedStrings(section);
for (let entry = section.offset; entry + 16 <= section.offset + section.size; entry += 16) {
const tag = u64(entry);
if (tag === DT_NULL) break;
if (tag === DT_NEEDED) needed.push(string(strings.offset, strings.size, u64(entry + 8)));
}
} else if (section.type === SHT_DYNSYM) {
const strings = linkedStrings(section);
const size = section.entrySize === 0 ? 24 : section.entrySize;
for (let entry = section.offset; entry + 24 <= section.offset + section.size; entry += size) {
const nameIndex = u32(entry);
const sectionIndex = u16(entry + 6);
if (nameIndex === 0 || sectionIndex === 0) continue;
const name = string(strings.offset, strings.size, nameIndex);
exported.add(name);
if (name === "node_api_module_get_api_version_v1") {
apiVersionAddress = u64(entry + 8);
}
}
} else if (section.type === SHT_GNU_VERNEED) {
const strings = linkedStrings(section);
let need = section.offset;
for (let guard = 0; guard < 4096; guard += 1) {
const count = u16(need + 2);
let aux = need + u32(need + 8);
for (let index = 0; index < count; index += 1) {
requiredVersions.push(string(strings.offset, strings.size, u32(aux + 8)));
const next = u32(aux + 12);
if (next === 0) break;
aux += next;
}
const next = u32(need + 12);
if (next === 0) break;
need += next;
}
}
}
let nodeApiModuleVersion: number | "unrecognized" | undefined;
if (apiVersionAddress !== undefined) {
const text = sections.find(
(section) =>
section.type !== SHT_NOBITS &&
section.address <= apiVersionAddress &&
apiVersionAddress < section.address + section.size,
);
nodeApiModuleVersion = "unrecognized";
if (text !== undefined) {
let offset = text.offset + (apiVersionAddress - text.address);
// An optional `endbr64` (Intel CET), then `mov eax, imm32; ret`: what every compiler emits for
// a function that returns the NAPI_VERSION constant. Anything else is not guessed at.
if (
data[offset] === 0xf3 &&
data[offset + 1] === 0x0f &&
data[offset + 2] === 0x1e &&
data[offset + 3] === 0xfa
) {
offset += 4;
}
if (data[offset] === 0xb8 && data[offset + 5] === 0xc3) {
nodeApiModuleVersion = u32(offset + 1);
}
}
}
return Object.freeze({
exported,
machine,
needed: Object.freeze(needed),
nodeApiModuleVersion,
requiredVersions: Object.freeze(requiredVersions),
});
}