mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
0973649cec
commit
60b55be7e7
4 files changed
+107
-14
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user