diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index 31711ecd4..4bf16e4b4 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -795,7 +795,7 @@ it("rejects unsupported parent operations and incompatible collections", () => { it("preserves selected native facet unavailability instead of an exhausted empty page", () => { const parent = nativeParent({ - ...ghidraFunctionDossier(), + ...functionDossierSchema.parse(ghidraFunctionDossier()), facet_availability: { comments: { available: false, reason: "Typed comments unavailable" }, references: { available: false, reason: "Typed references unavailable" }, diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index 507550537..c1ec76ba8 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -188,7 +188,7 @@ export const androidResultSchemas = { source_line: z.null(), dex_offset: z.null(), /** false means transport-bounded instruction evidence; null/omitted is unknown. */ - direct_invokes_exhaustive: z.boolean().nullable().optional(), + direct_invokes_exhaustive: z.boolean().nullable().default(null), /** Raw direct invoke instructions inside this using node, when available. */ direct_invokes: z .array( diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index b71dcac1d..7170f098b 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -128,6 +128,49 @@ const observe = ( }, ); +const rejectsMalformedFacetAvailability = (): void => { + expect( + functionDossierSchema.safeParse({ + ...dossier("return 0;", "0x1000"), + facet_availability: { cfg: { available: false, reason: "" } }, + }).success, + ).toBe(false); + expect( + functionDossierSchema.safeParse({ + ...dossier("return 0;", "0x1000"), + facet_availability: { cfg: undefined }, + }).success, + ).toBe(false); +}; + +const keepsGeneratedNamesAmbiguous = (): void => { + const named = (base: "0x1000" | "0x2000", name: string) => + functionDossierSchema.parse({ + ...dossier("return 0;", base), + procedure: { + ...procedure(base, name), + signature: "int helper(void)", + locals: [], + }, + }); + const generated = compareFunctions( + observe("1", named("0x1000", "sub_1000")), + observe("2", named("0x2000", "sub_2000")), + ); + expect(generated.function_match.status).toBe("ambiguous"); + expect(generated.status).toBe("unknown"); + expect( + generated.dimensions.find(({ dimension }) => dimension === "identity"), + ).toMatchObject({ status: "unknown" }); + + const stable = compareFunctions( + observe("3", named("0x1000", "firstFunction")), + observe("4", named("0x2000", "secondFunction")), + ); + expect(stable.function_match.status).toBe("mismatched"); + expect(stable.status).toBe("changed"); +}; + describe("function comparison normalized identity", () => { it("retains unresolved call details alongside missing facet coverage", () => { const value: Record = { @@ -187,47 +230,14 @@ describe("function comparison normalized identity", () => { ), ).toMatchObject({ status: "unknown", left_count: null, right_count: null }); }); - it("rejects malformed facet availability instead of treating it as absence", () => { - expect( - functionDossierSchema.safeParse({ - ...dossier("return 0;", "0x1000"), - facet_availability: { cfg: { available: false, reason: "" } }, - }).success, - ).toBe(false); - expect( - functionDossierSchema.safeParse({ - ...dossier("return 0;", "0x1000"), - facet_availability: { cfg: undefined }, - }).success, - ).toBe(false); - }); - it("keeps differing generated names ambiguous and stable names mismatched", () => { - const named = (base: "0x1000" | "0x2000", name: string) => - functionDossierSchema.parse({ - ...dossier("return 0;", base), - procedure: { - ...procedure(base, name), - signature: "int helper(void)", - locals: [], - }, - }); - const generated = compareFunctions( - observe("1", named("0x1000", "sub_1000")), - observe("2", named("0x2000", "sub_2000")), - ); - expect(generated.function_match.status).toBe("ambiguous"); - expect(generated.status).toBe("unknown"); - expect( - generated.dimensions.find(({ dimension }) => dimension === "identity"), - ).toMatchObject({ status: "unknown" }); - - const stable = compareFunctions( - observe("3", named("0x1000", "firstFunction")), - observe("4", named("0x2000", "secondFunction")), - ); - expect(stable.function_match.status).toBe("mismatched"); - expect(stable.status).toBe("changed"); - }); + it( + "rejects malformed facet availability instead of treating it as absence", + rejectsMalformedFacetAvailability, + ); + it( + "keeps differing generated names ambiguous and stable names mismatched", + keepsGeneratedNamesAmbiguous, + ); it.each(["sub_deallocate", "sub_deadbeef_handler", "fcn.dispatch"])( "recognizes the explicit symbol %s without a generated-name prefix false positive", diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index d126cfbfd..1a35dd7c7 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -1459,7 +1459,7 @@ it.each(["idb_close", "idb_list", "connection_close"])( }); expect(result.error.cleanupIncomplete).toBe(true); } finally { - producer.beforeCall = undefined; + delete producer.beforeCall; producer.close = originalClose; await client.close(); } diff --git a/tests/composition/idaProvider.test.ts b/tests/composition/idaProvider.test.ts index f6dd1dd0d..1fd17171d 100644 --- a/tests/composition/idaProvider.test.ts +++ b/tests/composition/idaProvider.test.ts @@ -22,6 +22,73 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +const reobservesExternallyMutableState = async (): Promise => { + const { root, target } = await createIdaTarget(); + roots.push(root); + const path = join(root, "registration.json"); + await writeFile(path, JSON.stringify({ command: "fixture" })); + const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); + if (!config.ok) throw config.error; + const producer = new RecordingIdaMcp(target); + const session = createTestBinarySession( + new IdaProvider(config.value, {}, () => producer), + ); + expect((await session.open(target.path)).ok).toBe(true); + const query = { procedure: "main", document: target.path }; + const first = await session.execute("procedure_pseudo_code", query); + expect(first.ok && first.value.result).toContain("42"); + const snapshot = session.exportAnalysisSnapshot(); + if (!snapshot.ok) throw snapshot.error; + expect(snapshot.value.entries).toEqual([]); + const profile = session.analysisProfile(); + if (profile === undefined) throw new Error("Missing profile"); + const cached = createAnalysisExecution( + "stale output", + IDA_PROVIDER_IDENTITY, + { + analysisProfile: profile, + subject: { + path: target.path, + sha256: target.sha256, + format: target.format, + ...(target.architecture === undefined + ? {} + : { architecture: target.architecture }), + }, + }, + ); + snapshot.value.entries.push( + createAnalysisSnapshotEntry({ + target: snapshotTarget(target), + binding: snapshotBinding(profile), + operation: "procedure_pseudo_code", + parameters: query, + execution: cached, + }), + ); + snapshot.value.evidence_bundle = createEvidenceBundle([ + ...snapshot.value.evidence_bundle.records, + createEvidence(target, IDA_PROVIDER_IDENTITY, { + operation: "procedure_pseudo_code", + parameters: query, + result: cached.result, + rawResult: cached.rawResult, + analysisProfile: profile, + }), + ]); + expect(session.importAnalysisSnapshot(snapshot.value).ok).toBe(true); + expect(session.allowsSnapshotReplay("procedure_pseudo_code")).toBe(false); + expect(session.allowsSnapshotReplay("binary_overview")).toBe(false); + producer.pseudocode = "int main(void) { return 99; }"; + const second = await session.execute("procedure_pseudo_code", query); + expect(second.ok && second.value.result).toContain("99"); + expect( + producer.calls.filter(({ name }) => name === "decompile_function"), + ).toHaveLength(2); + await session.close(); + expect(producer.closes).toBe(1); +}; + describe("IDA provider composition", () => { it("discovers without launching and commits adapter semantics without persisting credentials", async () => { const { root, target } = await createIdaTarget(); @@ -59,72 +126,10 @@ describe("IDA provider composition", () => { .find(({ operation }) => operation === "procedure_callers")?.available, ).toBe(false); }); - it("re-observes externally mutable state even when a document is explicit and a snapshot is imported", async () => { - const { root, target } = await createIdaTarget(); - roots.push(root); - const path = join(root, "registration.json"); - await writeFile(path, JSON.stringify({ command: "fixture" })); - const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); - if (!config.ok) throw config.error; - const producer = new RecordingIdaMcp(target); - const session = createTestBinarySession( - new IdaProvider(config.value, {}, () => producer), - ); - expect((await session.open(target.path)).ok).toBe(true); - const query = { procedure: "main", document: target.path }; - const first = await session.execute("procedure_pseudo_code", query); - expect(first.ok && first.value.result).toContain("42"); - const snapshot = session.exportAnalysisSnapshot(); - if (!snapshot.ok) throw snapshot.error; - expect(snapshot.value.entries).toEqual([]); - const profile = session.analysisProfile(); - if (profile === undefined) throw new Error("Missing profile"); - const cached = createAnalysisExecution( - "stale output", - IDA_PROVIDER_IDENTITY, - { - analysisProfile: profile, - subject: { - path: target.path, - sha256: target.sha256, - format: target.format, - ...(target.architecture === undefined - ? {} - : { architecture: target.architecture }), - }, - }, - ); - snapshot.value.entries.push( - createAnalysisSnapshotEntry({ - target: snapshotTarget(target), - binding: snapshotBinding(profile), - operation: "procedure_pseudo_code", - parameters: query, - execution: cached, - }), - ); - snapshot.value.evidence_bundle = createEvidenceBundle([ - ...snapshot.value.evidence_bundle.records, - createEvidence(target, IDA_PROVIDER_IDENTITY, { - operation: "procedure_pseudo_code", - parameters: query, - result: cached.result, - rawResult: cached.rawResult, - analysisProfile: profile, - }), - ]); - expect(session.importAnalysisSnapshot(snapshot.value).ok).toBe(true); - expect(session.allowsSnapshotReplay("procedure_pseudo_code")).toBe(false); - expect(session.allowsSnapshotReplay("binary_overview")).toBe(false); - producer.pseudocode = "int main(void) { return 99; }"; - const second = await session.execute("procedure_pseudo_code", query); - expect(second.ok && second.value.result).toContain("99"); - expect( - producer.calls.filter(({ name }) => name === "decompile_function"), - ).toHaveLength(2); - await session.close(); - expect(producer.closes).toBe(1); - }); + it( + "re-observes externally mutable state even when a document is explicit and a snapshot is imported", + reobservesExternallyMutableState, + ); it("rejects unsupported target kinds and reports incomplete upstream profiles on first analysis", async () => { const { root, target } = await createIdaTarget(); roots.push(root);