diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 5a193ec99..f731b3c62 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -218,7 +218,7 @@ export const resolveGhidraAnalysisProfile = async ( ].sort(), import_mode: "ephemeral-source-immutable", ...(target.format === "pe" - ? { pdb_observations: "program-info-v3" } + ? { pdb_observations: "program-info-v4" } : {}), annotation_policy: "atomic-function-entry-metadata-v1", load_image_observations: "source-mappings-entry-context-v2", diff --git a/src/ghidra/GhidraPeProfile.test.ts b/src/ghidra/GhidraPeProfile.test.ts index 36822377f..4bf441690 100644 --- a/src/ghidra/GhidraPeProfile.test.ts +++ b/src/ghidra/GhidraPeProfile.test.ts @@ -70,9 +70,9 @@ it.each([true, false, null])( "uses reported PDB observations once with loaded = %s", (loaded) => { const pdb = { - file: "reported.pdb", - guid: "reported-guid", - age: "a", + file: "public.pdb", + guid: "{01234567-89AB-CDEF-0123-456789ABCDEF}", + age: "0X000A", signature: null, version: "RSDS", loaded, @@ -80,10 +80,43 @@ it.each([true, false, null])( const caveats = ghidraPePdbLimitations(target, pdb); expect(caveats).toEqual(ghidraPdbLimitations(pdb)); expect(caveats).toHaveLength(1); - expect(caveats.join(" ")).not.toContain("public.pdb"); + expect(caveats.join(" ")).not.toContain('PDB "public.pdb"'); }, ); +it.each([ + { file: "different.pdb", guid: "0123456789abcdef0123456789abcdef", age: "a" }, + { file: "public.pdb", guid: "1123456789abcdef0123456789abcdef", age: "a" }, + { file: "public.pdb", guid: "0123456789abcdef0123456789abcdef", age: "10" }, + { file: "public.pdb", guid: "unrecognized-guid", age: "a" }, + { file: "public.pdb", guid: "0123456789abcdef0123456789abcdef", age: "ten" }, +])("retains non-equivalent declared PDB identity for %j", (identity) => { + for (const loaded of [false, null, true]) { + const pdb = { ...absentAttributes, ...identity, version: "RSDS", loaded }; + const caveats = ghidraPePdbLimitations(target, pdb); + expect(caveats[0]).toBe(ghidraPdbLimitations(pdb)[0]); + expect(caveats[0]).toContain(identity.file); + expect(caveats[0]).toContain(identity.guid); + expect(caveats[0]).toContain(`age (hex) ${identity.age}`); + expect(caveats).toHaveLength(2); + expect(caveats[1]).toContain('PDB "public.pdb"'); + expect(caveats[1]).toContain("01234567-89ab-cdef-0123-456789abcdef"); + expect(caveats[1]).toContain("age (decimal) 10"); + expect(caveats[1]).toContain("equivalence is not established"); + expect(pdb).toMatchObject({ ...identity, loaded }); + } +}); + +it.each([ + "0123456789ABCDEF0123456789ABCDEF", + "01234567-89ab-cdef-0123-456789abcdef", +])("avoids duplicate identity for normalized matching GUID %s", (guid) => { + const pdb = { ...absentAttributes, file: "public.pdb", guid, age: "a" }; + expect(ghidraPePdbLimitations(target, pdb)).toEqual( + ghidraPdbLimitations(pdb), + ); +}); + it("retains explicit failure to load without asserting all names are generated", () => { const caveats = ghidraPePdbLimitations(target, { ...absentAttributes, diff --git a/src/ghidra/GhidraPeProfile.ts b/src/ghidra/GhidraPeProfile.ts index 482eabd6e..300978c70 100644 --- a/src/ghidra/GhidraPeProfile.ts +++ b/src/ghidra/GhidraPeProfile.ts @@ -1,4 +1,7 @@ -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { + BinaryTarget, + PeCodeViewRecord, +} from "../domain/binaryTargetTypes.js"; import { ghidraPdbLimitations, type GhidraSessionInfo, @@ -6,6 +9,35 @@ import { const PDB_LOCATOR_INLINE_BYTE_BUDGET = 1024; +/** Establish equivalence only for the declared fields and recognized formats. */ +const reportedPdbMatchesDeclaration = ( + declared: PeCodeViewRecord, + reported: NonNullable, +): boolean => { + if ( + (reported.file !== declared.pdbPath && + reported.file !== declared.pdbFileName) || + reported.age === null || + !/^(?:0x)?[0-9a-f]+$/iu.test(reported.age) || + Number.parseInt(reported.age, 16) !== declared.age + ) + return false; + if (declared.guid === null) return reported.guid === null; + if (reported.guid === null) return false; + let guid = reported.guid; + if (guid.startsWith("{") && guid.endsWith("}")) guid = guid.slice(1, -1); + if ( + !/^(?:[0-9a-f]{32}|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/iu.test( + guid, + ) + ) + return false; + return ( + guid.replaceAll("-", "").toLowerCase() === + declared.guid.replaceAll("-", "").toLowerCase() + ); +}; + /** Measure JSON quotes, escapes and UTF-8 without allocating the encoded string. */ const pdbLocatorFitsInlineBudget = (value: string): boolean => { if (value.length * 6 + 2 <= PDB_LOCATOR_INLINE_BYTE_BUDGET) return true; @@ -47,10 +79,7 @@ export const ghidraPePdbLimitations = ( const reported = ghidraPdbLimitations(pdb); if ( target.codeView === undefined || - (pdb !== undefined && - pdb.file !== null && - pdb.age !== null && - (target.codeView.guid === null || pdb.guid !== null)) + (pdb !== undefined && reportedPdbMatchesDeclaration(target.codeView, pdb)) ) return reported; const { pdbFileName, guid, age } = target.codeView; @@ -70,6 +99,11 @@ export const ghidraPePdbLimitations = ( : pdb.loaded === true ? "Ghidra reports a loaded PDB; this declared identity is not an independent matching-PDB attestation" : "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.`; + const comparison = + pdb !== undefined && + [pdb.file, pdb.guid, pdb.age].some((value) => value !== null) + ? " Reported and declared PDB observations are retained separately; equivalence is not established." + : ""; + const declared = `The PE CodeView record references PDB ${locator} (${identity}); ${state}.${comparison} 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 c27fb19b7..99e6a330e 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -85,7 +85,7 @@ const createElfClient = async (ghidra: GhidraProvider) => { return ghidra.createClient(target, resolved.value.profile); }; -it.each(["program-info-v1", "program-info-v2"])( +it.each(["program-info-v1", "program-info-v2", "program-info-v3"])( "retains historical PE PDB Evidence for %s without replaying its old projection", async (pdbObservations) => { const target = peTarget("x86_64"); diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index e370892f3..5d7bda0d2 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -121,21 +121,26 @@ export const ghidraPdbLimitations = ( limitations.push( `PDB attributes ${pdb.resource_limit.omitted_attributes.join(", ")} were omitted because the handshake's ${pdb.resource_limit.utf16_budget}-UTF-16-unit PDB attribute budget was exhausted. Omitted values remain unknown; they were not truncated or reported as absent.`, ); + const hasReference = [ + pdb.file, + pdb.guid, + pdb.age, + pdb.signature, + pdb.version, + ].some((value) => value !== null); + const reference = hasReference + ? `The Ghidra Program references PDB ${pdb.file ?? "(file unknown)"} (GUID ${pdb.guid ?? "unknown"}, age (hex) ${pdb.age ?? "unknown"}, signature ${pdb.signature ?? "unknown"}, version ${pdb.version ?? "unknown"}); ` + : ""; if (pdb.loaded === true) return [ ...limitations, - "Ghidra reports a loaded PDB; this is not an independent PDB identity or complete symbol/type-coverage attestation. Original names and types may still be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions.", + `${reference}Ghidra reports a loaded PDB; this is not an independent PDB identity or complete symbol/type-coverage attestation. Original names and types may still be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions.`, ]; - if ( - [pdb.file, pdb.guid, pdb.age, pdb.signature, pdb.version].every( - (value) => value === null, - ) - ) - return limitations; + if (!hasReference) return limitations; const state = pdb.loaded === false ? "not loaded" : "load state unknown"; return [ ...limitations, - `The target references PDB ${pdb.file ?? "(file unknown)"} (GUID ${pdb.guid ?? "unknown"}, age (hex) ${pdb.age ?? "unknown"}, signature ${pdb.signature ?? "unknown"}, version ${pdb.version ?? "unknown"}); Ghidra reports ${state}. Original names and types may be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions. REA does not admit a caller-selected PDB companion.`, + `${reference}Ghidra reports ${state}. Original names and types may be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions. REA does not admit a caller-selected PDB companion.`, ]; };