fix(native): bound PE export names before intermediate retention

Charge PE alias names when storing them, before target interpretation can suppress output rows. Validate unused-slot names as bytes without retaining or decoding strings. Keep row and forwarder accounting separate so names are charged once.

Preserve alias ordering, malformed unused-name issues and ordinal-only suppression. Add production service/provider retention regressions using compact shared-pointer fixtures.

Validation: 43 focused parser, filesystem, CLI and stdio MCP tests; fresh build, typecheck, lint and formatting passed. Three bounded Node 24.22.1 measurement pairs showed median process RSS high-water 154000 to 93168 KiB; timing and sampled family memory have cache/startup/sampling limits. No Windows loader acceptance or OOM reproduction claim.
This commit is contained in:
morluto committed 2026-10-27 00:40:35 +08:00
1 parent 0973649cec
commit 60b55be7e7
4 files changed
+107 -14

No files matched your search

+25 -1
View File
@@ -50,7 +50,7 @@ const peExportNameAcrossSections = (overlapAtRva?: number): Buffer => {
return bytes;
};
describe("PE export name mapping coverage", () => {
describe("PE export name coverage", () => {
it("reads a bounded export name across adjacent section mappings", () => {
const result = parse(peExportNameAcrossSections());
expect(result).toMatchObject({
@@ -82,6 +82,30 @@ describe("PE export name mapping coverage", () => {
expect(result.issues).toMatchObject([{ code: "name_unreadable" }]);
expect(result.coverage.status).toBe("partial");
});
it("validates unreadable names on unused slots without retaining exports", () => {
const result = parse(
peExportImage({
functions: [null, { rva: 0x3000 }],
names: [
{ name: "Unreadable", index: 0 },
{ name: "Unused", index: 0 },
{ name: "Kept", index: 1 },
],
nameRvaOverride: { position: 0, rva: 0x9000 },
}),
);
expect(result.exports.map(({ name }) => name)).toEqual(["Kept"]);
expect(result.issues).toMatchObject([
{ code: "name_unreadable", location: "0x9000" },
]);
expect(result.coverage).toMatchObject({
status: "partial",
examined_entries: 2,
exports: 1,
});
});
});
describe("PE export directories", () => {
+27 -13
View File
@@ -36,13 +36,11 @@ const MAXIMUM_NAME_BYTES = 4096;
/** Text retained across all rows; names can repeat one long string table entry. */
const MAXIMUM_RETAINED_TEXT_BYTES = 16 * 1024 * 1024;
/** Charge every retained row and its text before it is stored. */
/** Charge retained text and rows at their owning storage boundary. */
const createRetention = () => {
let rows = 0;
let text = 0;
return (...strings: readonly (string | null)[]): void => {
rows++;
rowBudget(rows, "Export inventory");
const retainText = (...strings: readonly (string | null)[]): void => {
for (const value of strings) text += value?.length ?? 0;
if (text > MAXIMUM_RETAINED_TEXT_BYTES)
throw new BinaryResourceFailure(
@@ -51,6 +49,14 @@ const createRetention = () => {
`Export names exceed the ${String(MAXIMUM_RETAINED_TEXT_BYTES)}-byte retained-text allowance`,
);
};
return {
text: retainText,
row: (...strings: readonly (string | null)[]): void => {
rows++;
rowBudget(rows, "Export inventory");
retainText(...strings);
},
};
};
const hex = (value: bigint | number) => `0x${value.toString(16)}`;
@@ -115,7 +121,7 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
const mappings = imageFileMappings(image);
const map = (rva: number, size: number): number =>
mappedFileOffset(mappings, base + BigInt(rva), size, image.zero_fills);
const cString = (rva: number): string => {
const cStringBytes = (rva: number): Buffer => {
const value = readMappedCString(
bytes,
image,
@@ -127,7 +133,7 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
"malformed",
`Export name at RVA ${hex(rva)} is empty`,
);
return value.toString("latin1");
return value;
};
const header = map(directory.address, PE_EXPORT_DIRECTORY_BYTES);
@@ -145,7 +151,7 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
const issues: NativeExportIssue[] = [];
let dllName: string | null = null;
try {
dllName = cString(reader.u32(header + 12));
dllName = cStringBytes(reader.u32(header + 12)).toString("latin1");
} catch (cause) {
issues.push({
code: "name_unreadable",
@@ -154,6 +160,7 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
});
}
const retain = createRetention();
const namesByFunction = new Map<number, string[]>();
const namedFunctions = new Set<number>();
for (let index = 0; index < nameCount; index++) {
@@ -169,8 +176,13 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
}
namedFunctions.add(functionIndex);
try {
const nameBytes = cStringBytes(nameRva);
// Validate unused slots too, but retain names only for populated slots.
if (reader.u32(functions + functionIndex * 4) === 0) continue;
const name = nameBytes.toString("latin1");
retain.text(name);
const list = namesByFunction.get(functionIndex) ?? [];
list.push(cString(nameRva));
list.push(name);
namesByFunction.set(functionIndex, list);
} catch (cause) {
issues.push({
@@ -182,7 +194,6 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
}
const forwarderEnd = directory.address + directory.size;
const retain = createRetention();
const exports: PeExport[] = [];
for (let index = 0; index < functionCount; index++) {
const rva = reader.u32(functions + index * 4);
@@ -192,7 +203,10 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
let target: Pick<PeExport, "address" | "forwarder">;
if (rva >= directory.address && rva < forwarderEnd) {
try {
target = { address: null, forwarder: cString(rva) };
target = {
address: null,
forwarder: cStringBytes(rva).toString("latin1"),
};
} catch (cause) {
issues.push({
code: "malformed_entry",
@@ -205,12 +219,12 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
const exportNames = namesByFunction.get(index);
if (exportNames !== undefined)
for (const name of exportNames) {
retain(name, target.forwarder);
retain.row(target.forwarder);
exports.push({ name, ordinal, ...target });
}
// A slot whose only name was unreadable is reported through its issue.
else if (!namedFunctions.has(index)) {
retain(target.forwarder);
retain.row(target.forwarder);
exports.push({ name: null, ordinal, ...target });
}
}
@@ -619,7 +633,7 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
});
continue;
}
retain(
retain.row(
name,
typeof version === "object" ? (version?.name ?? null) : null,
rawNameBase64,
@@ -8,10 +8,42 @@ import { nativeExportsSchema } from "../../../src/domain/native/nativeExports.js
import {
elfExportImage,
peExportImage,
peRepeatedExportNamesImage,
} from "../../fixtures/binaryDiagnostics/nativeExports.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("native export stable filesystem boundary", () => {
it("bounds retained PE aliases before interpreting their target", async () => {
const root = await createTestTempDirectory("rea-native-export-aliases-");
const service = createNativeExportsService();
try {
// A malformed forwarder suppresses all rows, but its names still occupy
// the intermediate inventory and must be bounded before EAT traversal.
for (const target of [0x3000, 0x1080, 0]) {
const path = join(root, `aliases-${String(target)}.dll`);
const bytes = peRepeatedExportNamesImage(target);
expect(bytes.length).toBeLessThan(32_000);
await writeFile(path, bytes);
const result = await service.inspect({ path });
if (target === 0) {
if (!result.ok) throw new Error(JSON.stringify(result.error));
expect(result.value.normalized_result).toMatchObject({
exports: [],
issues: [],
coverage: { status: "complete", examined_entries: 1, exports: 0 },
});
} else {
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisResourceConstraintError" },
});
}
}
} finally {
await service.close();
}
});
it("binds PE and ELF export inventories to the selected file", async () => {
const root = await createTestTempDirectory("rea-native-exports-");
const service = createNativeExportsService();
+23
View File
@@ -108,6 +108,29 @@ export const peExportImage = (options: {
return bytes;
};
/** Compact PE whose name-table aliases all select one shared 4096-byte name. */
export const peRepeatedExportNamesImage = (
targetRva: number,
count = 4100,
): Buffer => {
const names = 0x500;
const ordinals = names + count * 4;
const text = ordinals + count * 2;
const bytes = Buffer.alloc(text + 4097);
peExportImage({ functions: [{ rva: targetRva }] }).copy(bytes);
const section = 152 + 240;
bytes.writeUInt32LE(bytes.length - SECTION_OFFSET, section + 8);
bytes.writeUInt32LE(bytes.length - SECTION_OFFSET, section + 16);
const rva = (offset: number): number => offset - SECTION_OFFSET + SECTION_RVA;
bytes.writeUInt32LE(count, SECTION_OFFSET + 24);
bytes.writeUInt32LE(rva(names), SECTION_OFFSET + 32);
bytes.writeUInt32LE(rva(ordinals), SECTION_OFFSET + 36);
for (let index = 0; index < count; index++)
bytes.writeUInt32LE(rva(text), names + index * 4);
bytes.fill(0x6e, text, text + 4096);
return bytes;
};
/** One `.dynsym` entry; `version` is its raw `.gnu.version` half-word. */
export interface ElfFixtureSymbol {
readonly name: string;