diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index af23463f4a..a4c4d14584 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -19,12 +19,12 @@ export const workflowAnalysisProfile = ( createAnalysisProfile(REA_WORKFLOW_PROVIDER, { upstream_analysis_profile: upstream, ...(operation === "binary_overview" - ? { workflow_revision: 3 } + ? { workflow_revision: 4 } : operation === "batch_decompile" || operation === "get_call_graph" || operation === "trace_feature" - ? { workflow_revision: 2 } - : {}), + ? { workflow_revision: 3 } + : { workflow_revision: 1 }), }); /** Provider identity for deterministic artifact inventories. */ diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index f7a5f711bc..1ba15ff846 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -219,7 +219,7 @@ export const resolveGhidraAnalysisProfile = async ( ].sort(), import_mode: "ephemeral-source-immutable", ...(target.format === "pe" - ? { pdb_observations: "program-info-v1" } + ? { pdb_observations: "program-info-v5" } : {}), 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 38db29bfc6..b8c869564f 100644 --- a/src/ghidra/GhidraPeProfile.test.ts +++ b/src/ghidra/GhidraPeProfile.test.ts @@ -1,96 +1,297 @@ -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(["file", "guid", "age"] as const)( + "retains declared identity when only reported %s is omitted", + (attribute) => { + const pdb: GhidraSessionInfo["pdb"] = { + file: "program.pdb", + guid: "reported-guid", + age: "a", + signature: null, + version: "RSDS", + loaded: null, + resource_limit: { omitted_attributes: [attribute], utf16_budget: 4096 }, + [attribute]: null, }; - expect(ghidraPeCodeViewLimitations(elfTarget)).toEqual([]); - }); + const caveats = ghidraPePdbLimitations(target, pdb).join(" "); + expect(caveats).toContain("omitted"); + expect(caveats).toContain('PDB "public.pdb"'); + expect(caveats).toContain("01234567-89ab-cdef-0123-456789abcdef"); + expect(caveats).toContain("load state unknown"); + }, +); - 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.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: "public.pdb", + guid: "{01234567-89AB-CDEF-0123-456789ABCDEF}", + age: "0X000A", + signature: null, + version: "RSDS", + loaded, + }; + const caveats = ghidraPePdbLimitations(target, pdb); + expect(caveats).toEqual(ghidraPdbLimitations(pdb)); + expect(caveats).toHaveLength(1); + 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, + 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[0]).toEqual( + ghidraPdbLimitations({ ...absentAttributes, loaded: true })[0], + ); + expect(caveats).toHaveLength(2); + expect(caveats[1]).toContain('PDB "public.pdb"'); + 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: 4096 }, + }; + 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, + pdbPath: "legacy.pdb", + pdbFileName: "legacy.pdb", + guid: null, + age: 2, }, - }; - expect(ghidraPeCodeViewLimitations(peTarget, true)).toEqual([]); - expect(ghidraPeCodeViewLimitations(peTarget, undefined)).toEqual([]); - }); + }, + undefined, + ); + expect(caveats[0]).toContain('PDB "legacy.pdb" (age (decimal) 2)'); + expect(caveats[0]).not.toContain("GUID"); +}); - 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, +it.each([false, null, true])( + "keeps an omitted NB10 GUID unknown with loaded = %s", + (loaded) => { + const pdbPath = "a".repeat(4070) + "\\legacy.pdb"; + const legacy = { + ...target, codeView: { - pdbPath: "C:\\build\\app.pdb", - pdbFileName: "app.pdb", - guid: "01234567-89ab-cdef-0123-456789abcdef", - age: 1, - }, - }; - expect(ghidraPeCodeViewLimitations(peTarget, false)).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, pdbFileName: "legacy.pdb", guid: null, age: 2, }, }; - expect(ghidraPeCodeViewLimitations(peTarget, false)).toEqual([ - 'The target references debug symbols "legacy.pdb" (age 2) that were not applied; procedure names are analysis-generated.', + const pdb = { + ...absentAttributes, + file: pdbPath, + age: "2", + signature: "01234567", + version: "NB10", + loaded, + }; + expect(ghidraPePdbLimitations(legacy, pdb)).toEqual( + ghidraPdbLimitations(pdb), + ); + const omitted: GhidraSessionInfo["pdb"] = { + ...pdb, + resource_limit: { omitted_attributes: ["guid"], utf16_budget: 4096 }, + }; + const caveats = ghidraPePdbLimitations(legacy, omitted); + expect(caveats).toEqual([ + ...ghidraPdbLimitations(omitted), + expect.stringContaining('PDB "legacy.pdb" (age (decimal) 2)'), ]); - }); + expect(caveats.at(-1)).toContain("equivalence is not established"); + }, +); + +it.each(["signature", "version"] as const)( + "keeps matching NB10 identity when only %s is omitted", + (attribute) => { + const legacy = { + ...target, + codeView: { + pdbPath: "legacy.pdb", + pdbFileName: "legacy.pdb", + guid: null, + age: 2, + }, + }; + const pdb: GhidraSessionInfo["pdb"] = { + ...absentAttributes, + file: "legacy.pdb", + age: "2", + resource_limit: { omitted_attributes: [attribute], utf16_budget: 4096 }, + }; + expect(ghidraPePdbLimitations(legacy, pdb)).toEqual( + ghidraPdbLimitations(pdb), + ); + }, +); + +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); +}); + +it.each([ + ["long ASCII", "a".repeat(171)], + ["ASCII at the byte budget", "a".repeat(1022)], + ["ASCII beyond the byte budget", "a".repeat(1023)], + ["quotes at the byte budget", '"'.repeat(511)], + ["quotes beyond the byte budget", '"'.repeat(512)], + ["short control escapes", "\n".repeat(511)], + ["short control escapes beyond the byte budget", "\n".repeat(512)], + ["six-byte control escapes", "\u0001".repeat(170)], + ["six-byte control escapes beyond the byte budget", "\u0001".repeat(171)], + ["UTF-8 BMP text", "中".repeat(340)], + ["UTF-8 BMP text beyond the byte budget", "中".repeat(341)], + ["surrogate pairs", "😀".repeat(255)], + ["surrogate pairs beyond the byte budget", "😀".repeat(256)], + ["unpaired high surrogates", "\ud800".repeat(170)], + ["unpaired high surrogates beyond the byte budget", "\ud800".repeat(171)], + ["unpaired low surrogates", "\udfff".repeat(170)], + ["unpaired low surrogates beyond the byte budget", "\udfff".repeat(171)], +])("uses actual JSON UTF-8 size for %s", (_, name) => { + const selected = { + ...target, + codeView: { ...target.codeView!, pdbFileName: name }, + }; + const caveat = ghidraPePdbLimitations(selected, undefined).join(" "); + const encoded = JSON.stringify(name); + if (Buffer.byteLength(encoded) <= 1024) { + expect(caveat).toContain(`PDB ${encoded} (`); + expect(caveat).not.toContain("locator omitted"); + } else { + expect(caveat).toContain("locator omitted"); + expect(caveat).not.toContain(encoded); + } + expect(selected.codeView.pdbFileName).toBe(name); }); diff --git a/src/ghidra/GhidraPeProfile.ts b/src/ghidra/GhidraPeProfile.ts index 46f53040a5..7f60edb912 100644 --- a/src/ghidra/GhidraPeProfile.ts +++ b/src/ghidra/GhidraPeProfile.ts @@ -1,28 +1,113 @@ -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { + BinaryTarget, + PeCodeViewRecord, +} 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 = ( - target: BinaryTarget, - pdbLoaded?: boolean | null, -): readonly string[] => { +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 ( - target.format !== "pe" || - target.codeView === undefined || - pdbLoaded !== false - ) { - return []; - } - 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.`, - ]; + reported.resource_limit?.omitted_attributes.some( + (attribute) => + attribute === "file" || attribute === "guid" || attribute === "age", + ) || + (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; + if (value.length + 2 > PDB_LOCATOR_INLINE_BYTE_BUDGET) return false; + let bytes = 2; + for (let index = 0; index < value.length; index += 1) { + const point = value.codePointAt(index) ?? 0; + if (point > 0xffff) { + bytes += 4; + index += 1; + } else if (point >= 0xd800 && point <= 0xdfff) { + // JSON.stringify escapes unpaired UTF-16 surrogates as six ASCII bytes. + bytes += 6; + } else if (point < 0x20) { + bytes += + point === 8 || + point === 9 || + point === 10 || + point === 12 || + point === 13 + ? 2 + : 6; + } else if (point === 0x22 || point === 0x5c) { + bytes += 2; + } else { + bytes += point < 0x80 ? 1 : point < 0x800 ? 2 : 3; + } + if (bytes > PDB_LOCATOR_INLINE_BYTE_BUDGET) return false; + } + return true; +}; + +/** Use reported Program facts first, retaining declared PE facts as a fallback. */ +export const ghidraPePdbLimitations = ( + target: BinaryTarget, + pdb: GhidraSessionInfo["pdb"], +): readonly string[] => { + if (target.format !== "pe") return []; + const reported = ghidraPdbLimitations(pdb); + if ( + target.codeView === undefined || + (pdb !== undefined && reportedPdbMatchesDeclaration(target.codeView, pdb)) + ) + return reported; + const { pdbFileName, guid, age } = target.codeView; + // Include JSON escaping in the inline budget before allocating that representation. + const locator = pdbLocatorFitsInlineBudget(pdbFileName) + ? JSON.stringify(pdbFileName) + : `(declared locator omitted from this inline caveat: ${PDB_LOCATOR_INLINE_BYTE_BUDGET}-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" + : pdb.loaded === true + ? "Ghidra reports a loaded PDB; this declared identity is not an independent matching-PDB attestation" + : "Ghidra reports load state unknown"; + 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 7bc0dd8b2f..5c585df5b3 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -35,6 +35,11 @@ import { silentLogger } from "../logger.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; +import { createAnalysisProfile } from "../domain/analysisProfile.js"; +import { createAnalysisExecution } from "../application/AnalysisProvider.js"; +import { AnalysisSnapshotCache } from "../application/binary/AnalysisSnapshotCache.js"; +import { createEvidence } from "../domain/evidence.js"; +import { createEvidenceBundle } from "../domain/evidenceBundle.js"; const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; afterEach(() => vi.restoreAllMocks()); @@ -81,6 +86,69 @@ const createElfClient = async (ghidra: GhidraProvider) => { return ghidra.createClient(target, resolved.value.profile); }; +it.each([ + "program-info-v1", + "program-info-v2", + "program-info-v3", + "program-info-v4", +])( + "retains historical PE PDB Evidence for %s without replaying its old projection", + async (pdbObservations) => { + const target = peTarget("x86_64"); + const resolved = await provider().resolveAnalysisProfile(target); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("Expected admitted PE profile"); + const current = resolved.value.profile; + const prior = createAnalysisProfile(current.provider, { + ...current.parameters, + pdb_observations: pdbObservations, + }); + const operation = "search_procedures"; + const parameters = { pattern: "wmain" }; + const limitations = [ + pdbObservations === "program-info-v4" + ? "Legacy projection discarded NB10 declaration when the reported GUID was omitted." + : "Legacy projection collapsed unknown PDB load state.", + ]; + const cache = new AnalysisSnapshotCache(); + cache.record({ + target, + profile: prior, + operation, + parameters, + execution: createAnalysisExecution([], prior.provider, { + subject: target, + analysisProfile: prior, + limitations, + }), + }); + const evidence = createEvidence(target, prior.provider, { + operation, + parameters, + result: [], + analysisProfile: prior, + limitations, + }); + const exported = cache.export( + target, + prior, + createEvidenceBundle([evidence]), + ); + if (!exported.ok) throw exported.error; + const imported = new AnalysisSnapshotCache(); + expect(imported.import(exported.value, undefined, () => ok(0)).ok).toBe( + true, + ); + expect( + imported.lookup(target, current, operation, parameters), + ).toBeUndefined(); + expect( + imported.lookup(target, prior, operation, parameters)?.limitations, + ).toEqual(expect.arrayContaining(limitations)); + expect(exported.value.evidence_bundle.records).toContainEqual(evidence); + }, +); + describe("Ghidra provider", () => { it("commits DOS loader and real-mode language while retaining the CPU family", async () => { const ghidra = provider(); @@ -957,43 +1025,31 @@ describe("Ghidra result projection", () => { describe("Ghidra PE CodeView debug symbol limitations", () => { it.each([ - { - name: "omitted PDB facts", - loaded: undefined, - expectedLimitation: - "Ghidra did not report PDB attributes; debug-symbol coverage is unknown.", - forbiddenLimitation: "that were not applied", - }, - { - name: "an explicit not-loaded report", - loaded: false, - expectedLimitation: - 'The target references debug symbols "test.pdb" (GUID 01234567-89ab-cdef-0123-456789abcdef, age 1) that were not applied; procedure names are analysis-generated.', - forbiddenLimitation: "coverage is unknown", - }, - { - name: "an explicit loaded report", - loaded: true, - expectedLimitation: "Ghidra reports a loaded PDB", - forbiddenLimitation: "that were not applied", - }, - ])( - "preserves CodeView coverage state for $name in session Evidence", - async ({ loaded, expectedLimitation, forbiddenLimitation }) => { - const pdb = { - file: "C:\\projects\\app\\build\\test.pdb", - guid: "01234567-89ab-cdef-0123-456789abcdef", - age: "1", - signature: null, - version: "RSDS", - loaded: loaded ?? null, - }; + ["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 in operation and health Evidence without duplicate caveats", + async (_, loaded, expected) => { const ghidra = provider(installationHost(), () => ({ start: () => Promise.resolve( - ok( - loaded === undefined ? sessionInfo() : { ...sessionInfo(), pdb }, - ), + 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([])), close: () => Promise.resolve(ok(null)), @@ -1001,7 +1057,7 @@ describe("Ghidra PE CodeView debug symbol limitations", () => { const target = { ...peTarget("x86_64"), codeView: { - pdbPath: "C:\\projects\\app\\build\\test.pdb", + pdbPath: "test.pdb", pdbFileName: "test.pdb", guid: "01234567-89ab-cdef-0123-456789abcdef", age: 1, @@ -1019,9 +1075,13 @@ describe("Ghidra PE CodeView debug symbol limitations", () => { const health = await client.execute("health", {}); if (!health.ok) throw health.error; for (const observation of [search.value, health.value]) { - const limitations = observation.limitations.join("\n"); - expect(limitations).toContain(expectedLimitation); - expect(limitations).not.toContain(forbiddenLimitation); + const caveats = observation.limitations.filter((limit) => + limit.includes("PDB"), + ); + expect(caveats).toHaveLength(1); + expect(caveats[0]).toContain(expected); + expect(caveats[0]).not.toContain("names are analysis-generated"); + expect(caveats[0]).not.toContain("that were not applied"); } } finally { await client.close(); diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 738c09363c..da81fca433 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -73,7 +73,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"; @@ -93,10 +93,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, @@ -532,7 +529,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: boolean | null | undefined; // Replaced by the reported storage outcome once the handshake is checked. const softFloatLimitations: string[] = [ ...ghidraMipsSoftFloatLimitations(softFloat), @@ -663,11 +659,10 @@ export const createGhidraProviderClient = (input: { info: GhidraSessionInfo, ): Promise => { if (target.format === "pe") { - pdbLoaded = info.pdb?.loaded; pdbLimitations.splice( 0, pdbLimitations.length, - ...ghidraPdbLimitations(info.pdb), + ...ghidraPePdbLimitations(target, info.pdb), ); } const softFloatReason = ghidraMipsSoftFloatFailure( @@ -722,7 +717,6 @@ export const createGhidraProviderClient = (input: { ...ghidraExtensionLimitations(extensions), ...seedLimitations, ...pdbLimitations, - ...ghidraPeCodeViewLimitations(target, pdbLoaded), ...(releaseLimitation === undefined ? [] : [releaseLimitation]), ]; const nativeAotOwner = diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index e370892f3e..5d7bda0d27 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.`, ]; }; diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 2ba32af0b3..332ccd441f 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -136,6 +136,7 @@ interface SnapshotProviderOptions { readonly profile?: ReturnType; readonly identity?: ProviderIdentity; readonly supportedOperations?: readonly CapabilityDescriptor["operation"][]; + readonly limitations?: readonly string[]; } const makeProvider = ( @@ -209,6 +210,7 @@ const makeProvider = ( createAnalysisExecution(result, profile.provider, { analysisProfile: profile, rawResult: result, + limitations: options.limitations ?? [], }), ); }, @@ -254,11 +256,11 @@ const withAlternateWorkflowProfile = ( snapshot: AnalysisSnapshot, path: string, current: Evidence, -): AnalysisSnapshot => { - const alternateProfile = createAnalysisProfile(REA_WORKFLOW_PROVIDER, { + alternateProfile = createAnalysisProfile(REA_WORKFLOW_PROVIDER, { workflow: "binary_overview", fixture: "alternate-profile", - }); + }), +): AnalysisSnapshot => { if ( typeof current.normalized_result !== "object" || current.normalized_result === null || @@ -467,6 +469,78 @@ const verifyWindowsSnapshotAdmission = async ( ).toHaveLength(1); }; +it.each([ + { tool: "binary_overview", arguments: {}, priorRevision: 3 }, + { + tool: "inspect_native_api", + arguments: { procedure: "0x1000" }, + priorRevision: undefined, + }, +] as const)( + "refreshes pre-coverage $tool Evidence and replays the current revision", + async (scenario) => { + const fixture = await createSnapshotReplayFixture("rea-prior-coverage-"); + const limitation = "Original PDB names may be missing."; + const provider = fixture.createProvider({ + supportedOperations: workflowProviderOperations, + limitations: [limitation], + }); + const dependencies = fixture.dependencies(provider); + const options = { snapshotPath: fixture.snapshotPath }; + const first = parseEvidence( + await runDirectAnalysis( + dependencies, + fixture.path, + scenario.tool, + scenario.arguments, + options, + ), + ); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + const priorProfile = createAnalysisProfile(REA_WORKFLOW_PROVIDER, { + upstream_analysis_profile: saved.value.binding.analysis_profile, + ...(scenario.priorRevision === undefined + ? {} + : { workflow_revision: scenario.priorRevision }), + }); + const prior = withAlternateWorkflowProfile( + saved.value, + fixture.path, + { ...first, limitations: ["Derived by an REA composed workflow."] }, + priorProfile, + ); + const written = await withArtifactResourceScope((resources) => + writeAnalysisSnapshot(prior, fixture.snapshotPath, true, resources), + ); + if (!written.ok) throw written.error; + const refreshed = parseEvidence( + await runDirectAnalysis( + dependencies, + fixture.path, + scenario.tool, + scenario.arguments, + options, + ), + ); + expect(refreshed.limitations).toContain(limitation); + expect(refreshed.analysis_profile?.digest).not.toBe(priorProfile.digest); + expect(refreshed.normalized_result).toEqual(first.normalized_result); + const starts = [...fixture.starts]; + const calls = [...fixture.calls]; + const replayed = await runDirectAnalysis( + dependencies, + fixture.path, + scenario.tool, + scenario.arguments, + options, + ); + expect(replayed).toEqual(refreshed); + expect(fixture.starts).toEqual(starts); + expect(fixture.calls).toEqual(calls); + }, +); + describe("direct analysis composed snapshot replay", () => { it.skipIf(process.platform !== "win32")( "does not replay a CLI snapshot through a Windows junction", diff --git a/tests/boundary/mcp/enhancedTools.limitations.test.ts b/tests/boundary/mcp/enhancedTools.limitations.test.ts new file mode 100644 index 0000000000..bf05bb20c8 --- /dev/null +++ b/tests/boundary/mcp/enhancedTools.limitations.test.ts @@ -0,0 +1,42 @@ +import { afterEach, expect, it } from "vitest"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { ok } from "../../../src/domain/result.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connect, closeEnhancedToolResources } from "./enhancedToolsHarness.js"; + +afterEach(closeEnhancedToolResources); + +it("retains provider coverage caveats in a composed MCP overview without repeated reads", async () => { + const calls: string[] = []; + const limitation = "Companion PDB symbol coverage is unverified."; + const client = await connect({ + execute: async (operation) => { + calls.push(operation); + const value = operation === "current_document" ? "fixture" : []; + return ok( + createAnalysisExecution( + value, + { id: "fixture", name: "Fixture", version: "1" }, + { limitations: [limitation] }, + ), + ); + }, + }); + const response = await client.callTool({ + name: "binary_overview", + arguments: {}, + }); + expect(response.isError).not.toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.limitations).toEqual([ + "Artifact identity is unavailable for this observation.", + "Derived by an REA composed workflow.", + limitation, + ]); + expect(evidence.normalized_result).toMatchObject({ + document: "fixture", + procedure_count: 0, + string_count: 0, + }); + expect(calls.length).toBe(new Set(calls).size); +}); diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index 97ad303414..14894ba7de 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -315,7 +315,7 @@ it("retains missing PDB coverage in empty PE name-search Evidence and snapshot r const evidence = sessionEvidence(harness.session, reply.structuredContent); expect(evidence.normalized_result).toEqual([]); expect(evidence.analysis_profile?.parameters.pdb_observations).toBe( - "program-info-v1", + "program-info-v5", ); const limitation = evidence.limitations.find((value) => value.includes(pdb.file), diff --git a/tests/composition/directAnalysisLimitations.test.ts b/tests/composition/directAnalysisLimitations.test.ts new file mode 100644 index 0000000000..6349d38c4f --- /dev/null +++ b/tests/composition/directAnalysisLimitations.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "vitest"; +import { parseConfig } from "../../src/config/parseConfig.js"; +import { runDirectAnalysis } from "../../src/application/DirectAnalysis.js"; +import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js"; +import { ok } from "../../src/domain/result.js"; +import { + createBinarySessionTargets, + createTestBinarySession, +} from "../fixtures/binarySession.js"; + +it("retains provider coverage caveats in a composed CLI result and closes the session", async () => { + const [path] = await createBinarySessionTargets(); + const limitation = "Companion PDB symbol coverage is unverified."; + let reads = 0; + let closed = 0; + const createSession = () => + createTestBinarySession(() => ({ + execute: async (operation) => { + reads++; + return ok( + createAnalysisExecution( + operation === "current_document" ? "fixture" : [], + { id: "fixture", name: "Fixture", version: "1" }, + { limitations: [limitation] }, + ), + ); + }, + close: async () => { + closed++; + return ok(null); + }, + })); + const output = await runDirectAnalysis( + { + readConfiguration: () => parseConfig({}), + createBinarySession: createSession, + createManagedBinarySession: createSession, + createArtifactBinarySession: createSession, + }, + path, + "binary_overview", + {}, + ); + expect(output).toMatchObject({ + limitations: ["Derived by an REA composed workflow.", limitation], + normalized_result: { + document: "fixture", + segment_count: 0, + procedure_count: 0, + string_count: 0, + }, + }); + expect(reads).toBe(5); + expect(closed).toBe(1); +});