Files
rea/src/native/elf/ElfNoteParser.ts
T
morluto 5f702dd6cc fix(parsers): preserve format boundaries and query occurrence identity
Normalize parsed facts at their owning boundaries across PE exports and certificates, ELF section names, GPU bundles, IL2CPP generic types, dyld slice selection, source maps, environment replay, and JavaScript mapped arguments. Preserve partial facts and explicit unsupported cases; clarify the corresponding documentation.

Validation: 831 affected tests passed with one existing skip across 23 files; build, typecheck/lint, documentation, formatting, and 2133-file test discovery passed. Format controls are synthetic; real Unity, GPU, and ARM-device execution is unverified.

Fixes #6709
Fixes #6688
Fixes #6719
Fixes #6718
Fixes #6704
Fixes #6703
Fixes #6692
Fixes #6723
Fixes #6687
Fixes #6726
Fixes #6696
Fixes #6691
Fixes #6727
2026-10-23 22:01:16 +08:00

492 lines
15 KiB
TypeScript

import { createHash } from "node:crypto";
import { inflateSync } from "node:zlib";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { readElfFixedHeader } from "../../domain/elfFixedHeader.js";
import {
elfNoteAlignment as noteAlignment,
elfNoteLayout,
} from "../../domain/native/elfNoteLayout.js";
import type {
ElfNotes,
InspectElfNotesInput,
} from "../../domain/native/elfNotes.js";
import {
elfObjectTypeName,
gnuNoteTypeName,
} from "../../domain/native/elfNotes.js";
const PT_NOTE = 4;
const SHT_NOTE = 7;
const SHT_STRTAB = 3;
const SHF_COMPRESSED = 0x800;
const ELFCOMPRESS_ZLIB = 1;
const HEX_DESCRIPTOR_LIMIT = 64;
interface TextBudget {
limit: number;
used: number;
hashed: number;
scanned: number;
readonly hashes: Map<string, string>;
readonly scans: Map<string, number>;
}
const fail = (message: string): never => {
throw new ArtifactReaderFailure("format", message);
};
const chargeText = (
budget: TextBudget,
bytes: number,
field: "used" | "hashed" | "scanned" = "used",
): void => {
if (
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
bytes > budget.limit - budget[field]
)
throw new ArtifactReaderFailure(
"limit",
`ELF note text exceeds the ${String(budget.limit)}-byte budget.`,
{ boundary: "memory" },
);
budget[field] += bytes;
};
const sha256 = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
const GNU_OS_NAMES: Readonly<Record<number, string>> = {
0: "linux",
1: "hurd",
2: "solaris",
3: "freebsd",
4: "netbsd",
5: "syllable",
6: "nacl",
};
const LIMITATIONS = [
"This workflow does not execute the image, resolve runtime notes, or verify GNU build-id uniqueness against package catalogs.",
"PT_NOTE and SHT_NOTE are examined independently; the same file range may appear twice when both tables declare it.",
] as const;
const ABSENT_LIMITATIONS = [
"No PT_NOTE program header or SHT_NOTE section was declared. That is not proof the image never carried notes in another container or overlay.",
"This workflow does not execute the image, resolve runtime notes, or verify GNU build-id uniqueness against package catalogs.",
] as const;
class ElfReader {
constructor(
readonly bytes: Buffer,
readonly little: boolean,
readonly bits: 32 | 64,
) {}
u16(offset: number): number {
this.require(offset, 2);
return this.little
? this.bytes.readUInt16LE(offset)
: this.bytes.readUInt16BE(offset);
}
u32(offset: number): number {
this.require(offset, 4);
return this.little
? this.bytes.readUInt32LE(offset)
: this.bytes.readUInt32BE(offset);
}
word(offset: number): number {
const width = this.bits === 64 ? 8 : 4;
this.require(offset, width);
if (this.bits === 32) return this.u32(offset);
const value = this.little
? this.bytes.readBigUInt64LE(offset)
: this.bytes.readBigUInt64BE(offset);
if (value > BigInt(Number.MAX_SAFE_INTEGER))
fail("ELF offset or size exceeds a safe integer.");
return Number(value);
}
require(offset: number, length: number): void {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset > this.bytes.length - length
)
fail(
`ELF range ${String(offset)} + ${String(length)} leaves the artifact.`,
);
}
slice(offset: number, length: number): Buffer {
this.require(offset, length);
return this.bytes.subarray(offset, offset + length);
}
cString(table: Buffer, offset: number, text: TextBudget): string | null {
if (offset < 0 || offset >= table.length) return null;
const affordable = text.limit - text.used;
const available = table.length - offset;
const scanned = Math.min(available, affordable + 1);
const end = table.subarray(offset, offset + scanned).indexOf(0);
if (end < 0) {
chargeText(text, scanned);
return null;
}
chargeText(text, end);
return table.subarray(offset, offset + end).toString("latin1");
}
}
interface ParsedNote {
readonly location: { offset: number; bytes: number };
readonly namesz: number;
readonly descsz: number;
readonly type: number;
readonly owner: string;
readonly owner_bytes_hex: string;
readonly descriptor: Buffer;
readonly descriptor_offset: number;
}
const parseNoteStream = (
reader: ElfReader,
offset: number,
size: number,
alignment: number,
maxEntries: number,
examined: { count: number },
text: TextBudget,
): ParsedNote[] => {
// Alignment padding is not read when a note's aligned size equals the
// declared table. Require the whole table first so a short file is invalid
// input instead of a provider result the service rejects.
reader.require(offset, size);
const notes: ParsedNote[] = [];
let at = 0;
while (at + 12 <= size) {
examined.count += 1;
if (examined.count > maxEntries)
throw new ArtifactReaderFailure(
"limit",
`ELF note tables exceed the ${String(maxEntries)}-entry budget.`,
{ boundary: "capacity" },
);
const header = offset + at;
const namesz = reader.u32(header);
const descsz = reader.u32(header + 4);
const type = reader.u32(header + 8);
// Align offsets from the start of this note. Independently rounding
// namesz/descsz disagrees with binutils when alignment is 8 because the
// 12-byte note header is not 8-aligned (GNU property namesz=4 then starts
// its descriptor at 16, not 20).
const { descriptor: descRel, next: nextRel } = elfNoteLayout(
namesz,
descsz,
alignment,
);
const next = at + nextRel;
if (next > size) fail("ELF note entry exceeds its table.");
if (next <= at) fail("ELF note alignment did not advance.");
const nameBytes = reader.slice(header + 12, namesz);
const descriptor = reader.slice(header + descRel, descsz);
const ownerEnd = nameBytes.indexOf(0);
const ownerBytes = ownerEnd < 0 ? namesz : ownerEnd;
chargeText(text, ownerBytes + namesz * 2);
notes.push({
location: { offset: header, bytes: next - at },
namesz,
descsz,
type,
owner:
ownerEnd < 0
? nameBytes.toString("latin1")
: nameBytes.subarray(0, ownerEnd).toString("latin1"),
owner_bytes_hex: nameBytes.toString("hex"),
descriptor,
descriptor_offset: header + descRel,
});
at = next;
}
if (
at < size &&
reader.slice(offset + at, size - at).some((byte) => byte !== 0)
)
fail("ELF note table has trailing non-zero bytes.");
return notes;
};
const decodeGnu = (
owner: string,
type: number,
descriptor: Buffer,
little: boolean,
text: TextBudget,
): ElfNotes["entries"][number]["gnu"] => {
if (owner !== "GNU") return null;
if (type === 3 && descriptor.length > 0) {
chargeText(text, descriptor.length * 2);
return {
build_id_hex: descriptor.toString("hex"),
abi_tag: null,
gold_version: null,
};
}
if (type === 1 && descriptor.length === 16) {
const read = (offset: number) =>
little
? descriptor.readUInt32LE(offset)
: descriptor.readUInt32BE(offset);
const os = read(0);
return {
build_id_hex: null,
abi_tag: {
os,
os_name: GNU_OS_NAMES[os] ?? null,
major: read(4),
minor: read(8),
subminor: read(12),
},
gold_version: null,
};
}
if (type === 4) {
const range = `${String(descriptor.byteOffset)}:${String(descriptor.length)}`;
let end = text.scans.get(range);
if (end === undefined) {
chargeText(text, descriptor.length, "scanned");
end = descriptor.length;
while (end > 0 && descriptor[end - 1] === 0) end -= 1;
text.scans.set(range, end);
}
chargeText(text, end);
return {
build_id_hex: null,
abi_tag: null,
gold_version: descriptor.toString("latin1", 0, end),
};
}
return {
build_id_hex: null,
abi_tag: null,
gold_version: null,
};
};
/** Decode PT_NOTE and SHT_NOTE tables from one in-memory ELF image. */
export const parseElfNotes = (
bytes: Buffer,
artifact: ElfNotes["artifact"],
input: InspectElfNotesInput,
): ElfNotes => {
const fixed = readElfFixedHeader(bytes);
if (!fixed.ok) return fail(fixed.error);
const header = fixed.value;
const reader = new ElfReader(
bytes,
header.byteOrder === "little",
header.elfClass,
);
const wide = header.elfClass === 64;
const type = reader.u16(16);
const phoff = reader.word(wide ? 32 : 28);
const shoff = reader.word(wide ? 40 : 32);
const phentsize = reader.u16(wide ? 54 : 42);
const phnum = reader.u16(wide ? 56 : 44);
const shentsize = reader.u16(wide ? 58 : 46);
const shnum = reader.u16(wide ? 60 : 48);
const shstrndx = reader.u16(wide ? 62 : 50);
if (
phnum === 0xffff ||
shnum === 0xffff ||
shstrndx === 0xffff ||
(shoff !== 0 && shnum === 0)
)
return fail("ELF extended numbering is unsupported.");
if (phnum !== 0) {
if (phentsize < (wide ? 56 : 32))
return fail("ELF program header width is invalid.");
reader.require(phoff, phnum * phentsize);
}
if (shnum !== 0) {
if (shentsize < (wide ? 64 : 40))
return fail("ELF section header width is invalid.");
reader.require(shoff, shnum * shentsize);
}
const text: TextBudget = {
limit: Math.min(bytes.length, input.max_file_bytes),
used: 0,
hashed: 0,
scanned: 0,
hashes: new Map(),
scans: new Map(),
};
let shstrtab: Buffer | null = null;
let sectionNameLimitation: string | undefined;
if (shnum !== 0 && shstrndx !== 0) {
if (shstrndx >= shnum)
return fail("ELF section name index is out of range.");
const namesHeader = shoff + shstrndx * shentsize;
if (reader.u32(namesHeader + 4) !== SHT_STRTAB)
return fail("ELF section name table is not a string table.");
const strOff = reader.word(namesHeader + (wide ? 24 : 16));
const strSize = reader.word(namesHeader + (wide ? 32 : 20));
shstrtab = reader.slice(strOff, strSize);
if ((reader.word(namesHeader + 8) & SHF_COMPRESSED) !== 0) {
const compressionHeaderBytes = wide ? 24 : 12;
if (strSize < compressionHeaderBytes)
return fail("ELF compressed section name table header is truncated.");
const compressionType = reader.u32(strOff);
if (compressionType !== ELFCOMPRESS_ZLIB) {
shstrtab = null;
sectionNameLimitation = `ELF section names are unavailable because compression type ${String(compressionType)} is unsupported.`;
} else {
const uncompressedSize = reader.word(strOff + (wide ? 8 : 4));
if (uncompressedSize > input.max_file_bytes - bytes.length)
throw new ArtifactReaderFailure(
"limit",
`ELF source and compressed section names exceed the ${String(input.max_file_bytes)}-byte memory budget.`,
{ boundary: "memory" },
);
try {
shstrtab = inflateSync(shstrtab.subarray(compressionHeaderBytes), {
maxOutputLength: Math.max(1, uncompressedSize),
});
} catch (cause: unknown) {
return fail(
`ELF compressed section name table could not be decompressed: ${cause instanceof Error ? cause.message : "invalid zlib data"}.`,
);
}
if (shstrtab.length !== uncompressedSize)
return fail(
"ELF compressed section name table size is inconsistent.",
);
text.limit = bytes.length + shstrtab.length;
}
}
}
const tables: ElfNotes["tables"] = [];
const parsed: ReturnType<typeof toEntry>[] = [];
const examined = { count: 0 };
if (phnum !== 0) {
if (phoff < header.headerSize)
return fail("ELF program header table overlaps the native header.");
for (let index = 0; index < phnum; index += 1) {
const at = phoff + index * phentsize;
if (reader.u32(at) !== PT_NOTE) continue;
const fileOffset = reader.word(at + (wide ? 8 : 4));
const fileSize = reader.word(at + (wide ? 32 : 16));
const alignment = noteAlignment(reader.word(at + (wide ? 48 : 28)));
tables.push({
kind: "program",
index,
name: null,
location: { offset: fileOffset, bytes: fileSize },
alignment,
});
for (const note of parseNoteStream(
reader,
fileOffset,
fileSize,
alignment,
input.max_entries,
examined,
text,
))
parsed.push(toEntry(note, "program", index, reader.little, text));
}
}
if (shnum !== 0) {
if (shoff < header.headerSize)
return fail("ELF section header table overlaps the native header.");
for (let index = 0; index < shnum; index += 1) {
const at = shoff + index * shentsize;
if (reader.u32(at + 4) !== SHT_NOTE) continue;
const nameOffset = reader.u32(at);
const fileOffset = reader.word(at + (wide ? 24 : 16));
const fileSize = reader.word(at + (wide ? 32 : 20));
const alignment = noteAlignment(reader.word(at + (wide ? 48 : 32)));
tables.push({
kind: "section",
index,
name:
shstrtab === null ? null : reader.cString(shstrtab, nameOffset, text),
location: { offset: fileOffset, bytes: fileSize },
alignment,
});
for (const note of parseNoteStream(
reader,
fileOffset,
fileSize,
alignment,
input.max_entries,
examined,
text,
))
parsed.push(toEntry(note, "section", index, reader.little, text));
}
}
const absent = tables.length === 0;
return {
artifact,
format: wide ? "elf64" : "elf32",
byte_order: reader.little ? "little" : "big",
machine: header.machine,
type,
type_name: elfObjectTypeName(type),
tables,
entries: parsed.map((entry, index) => ({ ...entry, index })),
coverage: {
status: "complete",
examined_tables: tables.length,
examined_entries: examined.count,
},
limitations: [
...(absent ? ABSENT_LIMITATIONS : LIMITATIONS),
...(sectionNameLimitation === undefined ? [] : [sectionNameLimitation]),
],
};
};
const toEntry = (
note: ParsedNote,
tableKind: "program" | "section",
tableIndex: number,
little: boolean,
text: TextBudget,
): Omit<ElfNotes["entries"][number], "index"> => {
const gnuOwner = note.owner === "GNU";
const range = `${String(note.descriptor_offset)}:${String(note.descsz)}`;
let digest = text.hashes.get(range);
if (digest === undefined) {
chargeText(text, note.descriptor.length, "hashed");
digest = sha256(note.descriptor);
text.hashes.set(range, digest);
}
return {
table_kind: tableKind,
table_index: tableIndex,
location: note.location,
namesz: note.namesz,
descsz: note.descsz,
type: note.type,
type_name: gnuOwner ? gnuNoteTypeName(note.type) : "unknown",
owner: note.owner,
owner_bytes_hex: note.owner_bytes_hex,
descriptor: {
location: { offset: note.descriptor_offset, bytes: note.descsz },
bytes: note.descsz,
sha256: digest,
hex:
note.descsz <= HEX_DESCRIPTOR_LIMIT
? note.descriptor.toString("hex")
: null,
},
gnu: decodeGnu(note.owner, note.type, note.descriptor, little, text),
};
};