diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index f2b1da0fd1..e7de578be3 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -321,10 +321,12 @@ The complete Evidence identifies the stable artifact snapshot by path, byte count and SHA-256. - PE rows reproduce the export directory: each used export-address-table slot - with its `ordinal`, one row per name (aliases share an ordinal), `address` + with its biased `ordinal` (`OrdinalBase` plus the slot index), one row per + name (aliases share an ordinal), `address` as the preferred-base virtual address, or `forwarder` text when the slot forwards to another DLL. Unused zero-address slots are omitted; - ordinal-only exports have a null `name`. The + ordinal-only exports have a null `name`. The name-ordinal table stores an + unbiased 16-bit EAT index; reported biased ordinals may exceed 16 bits. The directory's DLL name, ordinal base and timestamp are reported, and an image without an export directory is a complete empty inventory. - ELF rows list defined `.dynsym` symbols with global, weak or GNU-unique diff --git a/src/domain/native/nativeExports.ts b/src/domain/native/nativeExports.ts index e258c919ff..3790c65f61 100644 --- a/src/domain/native/nativeExports.ts +++ b/src/domain/native/nativeExports.ts @@ -4,6 +4,9 @@ import { absoluteLocalPathStringSchema } from "../localPath.js"; const uint = z.number().int().min(0).max(0xffffffff); const address = z.string().regex(/^0x[0-9a-f]+$/u); +// PE ordinals are the DWORD OrdinalBase plus an index into the DWORD-sized +// export address table. The last valid index is one less than its entry count. +const MAX_PE_EXPORT_ORDINAL = 0xffffffff + 0xfffffffe; /** Hard ceiling on the caller-selected input read budget. */ export const MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES = 536870912; @@ -33,7 +36,7 @@ export type InspectNativeExportsInput = z.infer< */ const peExportSchema = z.strictObject({ name: z.string().min(1).nullable(), - ordinal: z.number().int().min(0).max(0xffff), + ordinal: z.number().int().min(0).max(MAX_PE_EXPORT_ORDINAL), address: address.nullable(), forwarder: z.string().min(1).nullable(), }); diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts index 3ac9b67615..151b9666bc 100644 --- a/src/native/exports/NativeExportParser.test.ts +++ b/src/native/exports/NativeExportParser.test.ts @@ -115,16 +115,29 @@ describe("PE export directories", () => { expect(result.limitations[0]).toContain("declares no export directory"); }); - it("refuses ordinals beyond the 16-bit ordinal space", () => { - expect(() => - parseNativeExports( + it("preserves biased ordinals beyond WORD and DWORD", () => { + const cases = [ + { ordinalBase: 0x10000, index: 0, ordinal: 0x10000 }, + { ordinalBase: 0xffffffff, index: 0, ordinal: 0xffffffff }, + { + ordinalBase: 0xffffffff, + index: 1, + ordinal: 0x1_0000_0000, + }, + ]; + + for (const { ordinalBase, index, ordinal } of cases) { + const result = parse( peExportImage({ - functions: [{ rva: 0x2000 }], - ordinalBase: 0xffff + 1, + ordinalBase, + functions: Array.from({ length: index + 1 }, (_, row) => + row === index ? { rva: 0x2000 } : null, + ), + names: [{ name: "Named", index }], }), - artifact, - ), - ).toThrow("16-bit ordinals"); + ); + expect(result.exports).toMatchObject([{ name: "Named", ordinal }]); + } }); it("ignores a trailing unused slot after the last 16-bit ordinal", () => { diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index 87870b1c3e..0c35ab9e45 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -188,11 +188,6 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => { // A zero slot is an unused ordinal, not an export. if (rva === 0) continue; const ordinal = ordinalBase + index; - if (ordinal > 0xffff) - throw new BinaryFormatFailure( - "malformed", - "PE exported ordinal exceeds 16-bit ordinals", - ); let target: Pick; if (rva >= directory.virtual_address && rva < forwarderEnd) { try { diff --git a/tests/acceptance/applications/nativeExportsOrdinalSlots.test.ts b/tests/acceptance/applications/nativeExportsOrdinalSlots.test.ts index fd6b6b085f..48dccc8dd9 100644 --- a/tests/acceptance/applications/nativeExportsOrdinalSlots.test.ts +++ b/tests/acceptance/applications/nativeExportsOrdinalSlots.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { expect } from "vitest"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { nativeExportsSchema } from "../../../src/domain/native/nativeExports.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { peExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -45,5 +46,73 @@ cliTest( }, ); }, - 30_000, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves biased PE ordinals above the name-table index width through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-pe-biased-ordinal-"); + const cases = [ + { name: "word-base", base: 0x10000, index: 0, ordinal: 0x10000 }, + { + name: "maximum-dword-base", + base: 0xffffffff, + index: 0, + ordinal: 0xffffffff, + }, + { + name: "sum-above-dword", + base: 0xffffffff, + index: 1, + ordinal: 0x1_0000_0000, + }, + ]; + const mcp = + surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined; + + for (const fixture of cases) { + const path = join(root, `${fixture.name}.dll`); + const functions = Array.from({ length: fixture.index + 1 }, (_, row) => + row === fixture.index ? { rva: 0x1300 } : null, + ); + await writeFile( + path, + peExportImage({ + ordinalBase: fixture.base, + functions, + names: [{ name: "Named", index: fixture.index }], + }), + ); + + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["inspect-native-exports", path, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = toolContract("inspect_native_exports").outputSchema.parse( + response.json, + ); + } else { + const response = await mcp!.call("inspect_native_exports", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + + const evidence = toolContract( + "inspect_native_exports", + ).outputSchema.parse(document); + expect( + nativeExportsSchema.parse(evidence.normalized_result), + ).toMatchObject({ + format: "pe", + coverage: { + status: "complete", + examined_entries: fixture.index + 1, + exports: 1, + }, + exports: [{ name: "Named", ordinal: fixture.ordinal }], + }); + } + }, );