fix(ghidra): preserve PE PDB coverage facts (#2933)

Preserve reported Program PDB identity, explicit omissions and nullable load state, retaining declared CodeView facts when equivalence is unknown. Bound JSON locator expansion and advance PE/workflow replay revisions without discarding historical Evidence.

Main already contains the shared canonical workflow collector. Integrate its unknown-PDB repair and fix the CLI fixture for the new artifact-session dependency. No unrelated pcap repair is included.

Validation: 309 focused tests across 15 files pass (2 Windows-only skips), runtime build and check:fast/pre-push pass. Real Windows Ghidra 12.1.4 PUBLIC/JDK 21.0.12.1 uses the public where.exe/PDB pair; 17 relevant production files match verified ca24903f. MCP cold/warm normalized results match and the backward graph has 3 successful nodes. Inputs are unchanged and no owned Java/Node processes remain. CLI inspection keeps its earlier exact-source scope. NB10 omission uses regression fixtures, without a real NB10 provider claim.

Tests keep 2-core affinity and 512 MiB Node/Java heaps. Retain the 600s analysis and 30s initialization timeout observations alongside the successful test-only extended-budget run; no product-default or cross-revision speedup claim.

Fixes #2259
This commit is contained in:
Ysopwnable
2026-10-14 22:25:05 +08:00
committed by GitHub
parent 6060de329d
commit 0f55f247cd
11 changed files with 685 additions and 169 deletions
+3 -3
View File
@@ -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. */
+1 -1
View File
@@ -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",
+282 -81
View File
@@ -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);
});
+110 -25
View File
@@ -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<GhidraSessionInfo["pdb"]>,
): 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];
};
+98 -38
View File
@@ -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();
+3 -9
View File
@@ -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<AnalysisError | undefined> => {
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 =
+13 -8
View File
@@ -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.`,
];
};
@@ -136,6 +136,7 @@ interface SnapshotProviderOptions {
readonly profile?: ReturnType<typeof createAnalysisProfile>;
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",
@@ -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);
});
@@ -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),
@@ -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);
});