From 60b55be7e7ef666ec071b6bf9099f55c6aa75b45 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:40:22 +0800 Subject: [PATCH] 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. --- src/native/exports/NativeExportParser.test.ts | 26 +++++++++++- src/native/exports/NativeExportParser.ts | 40 +++++++++++++------ .../boundary/filesystem/nativeExports.test.ts | 32 +++++++++++++++ .../binaryDiagnostics/nativeExports.ts | 23 +++++++++++ 4 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts index 2ce0c3dd9a..72e7c1b37f 100644 --- a/src/native/exports/NativeExportParser.test.ts +++ b/src/native/exports/NativeExportParser.test.ts @@ -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", () => { diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index 2927d214aa..c1313d0f75 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -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(); const namedFunctions = new Set(); 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; 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, diff --git a/tests/boundary/filesystem/nativeExports.test.ts b/tests/boundary/filesystem/nativeExports.test.ts index 4355ceb1f7..7afeba3a24 100644 --- a/tests/boundary/filesystem/nativeExports.test.ts +++ b/tests/boundary/filesystem/nativeExports.test.ts @@ -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(); diff --git a/tests/fixtures/binaryDiagnostics/nativeExports.ts b/tests/fixtures/binaryDiagnostics/nativeExports.ts index 16667a958e..12d02547bc 100644 --- a/tests/fixtures/binaryDiagnostics/nativeExports.ts +++ b/tests/fixtures/binaryDiagnostics/nativeExports.ts @@ -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;