diff --git a/src/ghidra/GhidraPeProfile.test.ts b/src/ghidra/GhidraPeProfile.test.ts index 561aad701..4509d3ef7 100644 --- a/src/ghidra/GhidraPeProfile.test.ts +++ b/src/ghidra/GhidraPeProfile.test.ts @@ -1,95 +1,145 @@ -import { describe, expect, it } from "vitest"; +import { expect, it } from "vitest"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import { ghidraPeCodeViewLimitations } from "./GhidraPeProfile.js"; +import { ghidraPePdbLimitations } from "./GhidraPeProfile.js"; +import { + ghidraPdbLimitations, + type GhidraSessionInfo, +} from "./GhidraSessionValues.js"; -describe("ghidraPeCodeViewLimitations", () => { - it("returns empty limitations for non-PE targets", () => { - const elfTarget: BinaryTarget = { - path: "/test/file", - sha256: "abc", - kind: "executable", - format: "elf", - architecture: "x86_64", - availableArchitectures: ["x86_64"], +const target: BinaryTarget = { + path: "/fixture/public.exe", + sha256: "a".repeat(64), + kind: "executable", + format: "pe", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + executableRole: "application", + managed: false, + codeView: { + pdbPath: "public.pdb", + pdbFileName: "public.pdb", + guid: "01234567-89ab-cdef-0123-456789abcdef", + age: 10, + }, +}; +const absentAttributes = { + file: null, + guid: null, + age: null, + signature: null, + version: null, + loaded: null, +}; + +it.each([undefined, absentAttributes])( + "retains declared identity and unknown load state when reported attributes are %j", + (pdb) => { + const caveats = ghidraPePdbLimitations(target, pdb); + expect(caveats).toHaveLength(1); + expect(caveats[0]).toContain( + 'PDB "public.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age (decimal) 10)', + ); + expect(caveats[0]).toContain("load state unknown"); + expect(caveats[0]).not.toContain("were not applied"); + expect(caveats[0]).not.toContain("names are analysis-generated"); + }, +); + +it.each([true, false, null])( + "uses reported PDB observations once with loaded = %s", + (loaded) => { + const pdb = { + file: "reported.pdb", + guid: "reported-guid", + age: "a", + signature: null, + version: "RSDS", + loaded, }; - expect(ghidraPeCodeViewLimitations(elfTarget)).toEqual([]); - }); + const caveats = ghidraPePdbLimitations(target, pdb); + expect(caveats).toEqual(ghidraPdbLimitations(pdb)); + expect(caveats).toHaveLength(1); + expect(caveats.join(" ")).not.toContain("public.pdb"); + }, +); - it("does not claim CodeView symbols were unapplied when the session loaded a PDB", () => { - const peTarget: BinaryTarget = { - path: "/test/file.exe", - sha256: "abc", - kind: "executable", - architecture: "x86_64", - availableArchitectures: ["x86_64"], - executableRole: "application", - managed: false, - format: "pe", +it("retains explicit failure to load without asserting all names are generated", () => { + const caveats = ghidraPePdbLimitations(target, { + ...absentAttributes, + loaded: false, + }); + expect(caveats[0]).toContain("reports not loaded"); + expect(caveats[0]).not.toContain("names are analysis-generated"); +}); + +it("retains a loaded-state attestation caveat even when reference attributes are absent", () => { + const caveats = ghidraPePdbLimitations(target, { + ...absentAttributes, + loaded: true, + }); + expect(caveats).toEqual( + ghidraPdbLimitations({ ...absentAttributes, loaded: true }), + ); + expect(caveats[0]).toContain("not an independent PDB identity"); + expect(caveats[0]).not.toContain("reports not loaded"); +}); + +it("preserves omitted-attribute resource coverage with declared fallback identity", () => { + const pdb: GhidraSessionInfo["pdb"] = { + ...absentAttributes, + resource_limit: { omitted_attributes: ["file"], utf16_budget: 2048 }, + }; + const caveats = ghidraPePdbLimitations(target, pdb); + expect(caveats).toHaveLength(2); + expect(caveats[0]).toContain("omitted"); + expect(caveats[1]).toContain("public.pdb"); +}); + +it("reports NB10 declared age without inventing a GUID", () => { + const caveats = ghidraPePdbLimitations( + { + ...target, codeView: { - pdbPath: "C:\\symbols.pdb", - pdbFileName: "symbols.pdb", - guid: "12345678-1234-1234-1234-123456789abc", - age: 1, - }, - }; - expect(ghidraPeCodeViewLimitations(peTarget, true)).toEqual([]); - }); - - it("returns empty limitations for PE target without codeView", () => { - const peTarget: BinaryTarget = { - path: "/test/file.exe", - sha256: "def", - kind: "executable", - format: "pe", - architecture: "x86_64", - availableArchitectures: ["x86_64"], - executableRole: "application", - managed: false, - }; - expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([]); - }); - - it("returns limitation with GUID and age for RSDS CodeView records", () => { - const peTarget: BinaryTarget = { - path: "/test/file.exe", - sha256: "def", - kind: "executable", - format: "pe", - architecture: "x86_64", - availableArchitectures: ["x86_64"], - executableRole: "application", - managed: false, - codeView: { - pdbPath: "C:\\build\\app.pdb", - pdbFileName: "app.pdb", - guid: "01234567-89ab-cdef-0123-456789abcdef", - age: 1, - }, - }; - expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([ - 'The target references debug symbols "app.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age 1) that were not applied; procedure names are analysis-generated.', - ]); - }); - - it("returns limitation without GUID for NB10 CodeView records", () => { - const peTarget: BinaryTarget = { - path: "/test/file.exe", - sha256: "def", - kind: "executable", - format: "pe", - architecture: "x86", - availableArchitectures: ["x86"], - executableRole: "application", - managed: false, - codeView: { - pdbPath: "D:\\test\\legacy.pdb", + pdbPath: "legacy.pdb", pdbFileName: "legacy.pdb", guid: null, age: 2, }, - }; - expect(ghidraPeCodeViewLimitations(peTarget)).toEqual([ - 'The target references debug symbols "legacy.pdb" (age 2) that were not applied; procedure names are analysis-generated.', - ]); - }); + }, + undefined, + ); + expect(caveats[0]).toContain('PDB "legacy.pdb" (age (decimal) 2)'); + expect(caveats[0]).not.toContain("GUID"); +}); + +it("retains missing-report uncertainty without a declared CodeView record", () => { + const { codeView: _, ...withoutReference } = target; + expect(ghidraPePdbLimitations(withoutReference, undefined)).toEqual( + ghidraPdbLimitations(undefined), + ); +}); + +it("adds no PDB warning for a non-PE target", () => { + const elf: BinaryTarget = { + path: "/fixture/program", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }; + expect(ghidraPePdbLimitations(elf, undefined)).toEqual([]); +}); + +it("bounds an escaped inline locator while preserving its declared source value", () => { + const name = '"'.repeat(5000) + ".pdb"; + const selected = { + ...target, + codeView: { ...target.codeView!, pdbFileName: name }, + }; + const caveats = ghidraPePdbLimitations(selected, undefined).join(" "); + expect(Buffer.byteLength(caveats)).toBeLessThan(1024); + expect(caveats).toContain("locator omitted"); + expect(caveats).toContain("01234567-89ab-cdef-0123-456789abcdef"); + expect(selected.codeView.pdbFileName).toBe(name); }); diff --git a/src/ghidra/GhidraPeProfile.ts b/src/ghidra/GhidraPeProfile.ts index 217b3256f..08ed11d81 100644 --- a/src/ghidra/GhidraPeProfile.ts +++ b/src/ghidra/GhidraPeProfile.ts @@ -1,24 +1,41 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + ghidraPdbLimitations, + type GhidraSessionInfo, +} from "./GhidraSessionValues.js"; -/** - * Report unapplied PE companion PDB symbols when CodeView debug information is present. - * - * Ghidra does not automatically locate or apply sidecar PDBs in REA's isolated - * runtime directories without companion admission. When the PE target header - * references debug symbols, surface this limitation so callers understand that - * procedure names remain analysis-generated. - */ -export const ghidraPeCodeViewLimitations = ( +/** Use reported Program facts first, retaining declared PE facts as a fallback. */ +export const ghidraPePdbLimitations = ( target: BinaryTarget, - pdbLoaded = false, + pdb: GhidraSessionInfo["pdb"], ): readonly string[] => { - if (target.format !== "pe" || target.codeView === undefined || pdbLoaded) { - return []; - } + if (target.format !== "pe") return []; + const reported = ghidraPdbLimitations(pdb); + if ( + target.codeView === undefined || + pdb?.loaded === true || + (pdb !== undefined && + [pdb.file, pdb.guid, pdb.age, pdb.signature, pdb.version].some( + (attribute) => attribute !== null, + )) + ) + return reported; const { pdbFileName, guid, age } = target.codeView; - return [ - guid !== null - ? `The target references debug symbols "${pdbFileName}" (GUID ${guid}, age ${age}) that were not applied; procedure names are analysis-generated.` - : `The target references debug symbols "${pdbFileName}" (age ${age}) that were not applied; procedure names are analysis-generated.`, - ]; + // Include JSON escaping in the inline budget before allocating that representation. + const locator = + pdbFileName.length * 6 + 2 <= 1024 + ? JSON.stringify(pdbFileName) + : "(declared locator omitted from this inline caveat: 1024-byte resource budget)"; + const identity = + guid === null + ? `age (decimal) ${age}` + : `GUID ${guid}, age (decimal) ${age}`; + const state = + pdb === undefined + ? "Ghidra did not report PDB attributes; load state unknown" + : pdb.loaded === false + ? "Ghidra reports not loaded" + : "Ghidra reports load state unknown"; + const declared = `The PE CodeView record references PDB ${locator} (${identity}); ${state}. Original PDB names and types may be missing. Use list_procedures and address-based analysis to inspect recovered functions. REA does not admit a caller-selected PDB companion.`; + return pdb === undefined ? [declared] : [...reported, declared]; }; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 018116b82..82901f07a 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -830,36 +830,60 @@ describe("Ghidra result projection", () => { }); describe("Ghidra PE CodeView debug symbol limitations", () => { - it("projects unapplied companion PDB limitation for PE target with CodeView record", async () => { - const ghidra = provider(installationHost(), () => ({ - start: () => Promise.resolve(ok(sessionInfo())), - callTool: () => Promise.resolve(ok(null)), - close: () => Promise.resolve(ok(null)), - })); - const target = { - ...peTarget("x86_64"), - codeView: { - pdbPath: "C:\\projects\\app\\build\\test.pdb", - pdbFileName: "test.pdb", - guid: "01234567-89ab-cdef-0123-456789abcdef", - age: 1, - }, - }; - const profile = await ghidra.resolveAnalysisProfile(target); - if (!profile.ok || profile.value.profile === null) - throw new Error("expected PE profile"); - const client = ghidra.createClient(target, profile.value.profile); - const health = await client.execute("health", {}); - expect(health).toMatchObject({ - ok: true, - value: { - limitations: expect.arrayContaining([ - 'The target references debug symbols "test.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age 1) that were not applied; procedure names are analysis-generated.', - ]), - }, - }); - await client.close(); - }); + it.each([ + ["absent report", undefined, "load state unknown"], + ["unknown load", null, "load state unknown"], + ["not loaded", false, "reports not loaded"], + ["loaded", true, "reports a loaded PDB"], + ] as const)( + "preserves %s with one PDB caveat for a declared CodeView record", + async (_, loaded, expected) => { + const ghidra = provider(installationHost(), () => ({ + start: () => + Promise.resolve( + ok({ + ...sessionInfo(), + ...(loaded === undefined + ? {} + : { + pdb: { + file: "test.pdb", + guid: "01234567-89ab-cdef-0123-456789abcdef", + age: "1", + signature: null, + version: "RSDS", + loaded, + }, + }), + }), + ), + callTool: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(ok(null)), + })); + const target = { + ...peTarget("x86_64"), + codeView: { + pdbPath: "C:\\projects\\app\\build\\test.pdb", + pdbFileName: "test.pdb", + guid: "01234567-89ab-cdef-0123-456789abcdef", + age: 1, + }, + }; + const profile = await ghidra.resolveAnalysisProfile(target); + if (!profile.ok || profile.value.profile === null) + throw new Error("expected PE profile"); + const client = ghidra.createClient(target, profile.value.profile); + const health = await client.execute("health", {}); + if (!health.ok) throw health.error; + const pdbCaveats = health.value.limitations.filter((limit) => + limit.includes("PDB"), + ); + expect(pdbCaveats).toHaveLength(1); + expect(pdbCaveats[0]).toContain(expected); + expect(pdbCaveats[0]).not.toContain("names are analysis-generated"); + await client.close(); + }, + ); }); describe("Ghidra relationship coverage", () => { diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index dd95849c8..9fbcd74c7 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -69,7 +69,7 @@ import { isGhidraMipsSoftFloatTarget, } from "./GhidraMipsProfile.js"; import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; -import { ghidraPeCodeViewLimitations } from "./GhidraPeProfile.js"; +import { ghidraPePdbLimitations } from "./GhidraPeProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; @@ -89,10 +89,7 @@ import { limitationsFor, } from "./GhidraProviderCapabilities.js"; import type { GhidraSessionError } from "./GhidraSessionError.js"; -import { - ghidraPdbLimitations, - type GhidraSessionInfo, -} from "./GhidraSessionValues.js"; +import type { GhidraSessionInfo } from "./GhidraSessionValues.js"; import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js"; import { ghidraExtensionSchema, @@ -531,7 +528,6 @@ export const createGhidraProviderClient = (input: { // Replaced by the reported outcome once the session handshake is checked. const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)]; const pdbLimitations: string[] = []; - let pdbLoaded = false; // Replaced by the reported storage outcome once the handshake is checked. const softFloatLimitations: string[] = [ ...ghidraMipsSoftFloatLimitations(softFloat), @@ -657,11 +653,10 @@ export const createGhidraProviderClient = (input: { info: GhidraSessionInfo, ): Promise => { if (target.format === "pe") { - pdbLoaded = info.pdb?.loaded === true; pdbLimitations.splice( 0, pdbLimitations.length, - ...ghidraPdbLimitations(info.pdb), + ...ghidraPePdbLimitations(target, info.pdb), ); } const softFloatReason = ghidraMipsSoftFloatFailure( @@ -716,7 +711,6 @@ export const createGhidraProviderClient = (input: { ...ghidraExtensionLimitations(extensions), ...seedLimitations, ...pdbLimitations, - ...ghidraPeCodeViewLimitations(target, pdbLoaded), ...(releaseLimitation === undefined ? [] : [releaseLimitation]), ]; const nativeAotOwner =