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,188 @@
|
||||
/**
|
||||
* 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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user