From 62f7b5e4cd76cfc34fac9a0def0b68a9e175159b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:07:29 +0800 Subject: [PATCH] fix(native): require observed symbol origin for named identity --- bridge/ghidra/ReaGhidraBridge.java | 33 +++-- bridge/hopper_bridge.py | 2 + docs/mcp-contracts.md | 17 +-- docs/native-function-matching.md | 13 +- docs/native-investigation.md | 7 ++ .../NativeFunctionMatchingService.test.ts | 66 ++++++++-- .../NativeFunctionMatchingViews.test.ts | 2 +- src/contracts/functionComparisonExample.ts | 7 ++ src/domain/functionComparison.test.ts | 53 ++++++-- src/domain/functionComparisonDimensions.ts | 14 ++- src/domain/functionComparisonNormalization.ts | 12 +- src/domain/functionRelationshipComparison.ts | 24 ++-- src/domain/ghidraValues.fixture.ts | 7 ++ src/domain/hopperValues.ts | 2 + src/domain/native/functionInventory.ts | 55 ++++++--- .../native/functionStructuralCandidate.ts | 10 +- src/domain/native/nativeInventory.ts | 11 +- src/domain/native/nativeSymbol.ts | 26 ++++ src/domain/native/procedureInfo.ts | 2 + src/ida/IdaOperationRunner.ts | 1 + .../tools/nativeFunctionMatching.test.ts | 2 +- ...unctionComparisonReferenceFactsMcp.test.ts | 114 ++++++++++++++++-- tests/fixtures/nativeFunctionMatching.ts | 22 ++++ 23 files changed, 395 insertions(+), 107 deletions(-) create mode 100644 src/domain/native/nativeSymbol.ts diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index a980e9aee8..27c3142aa2 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -1097,6 +1097,8 @@ public final class ReaGhidraBridge extends HeadlessScript { Function function = resolveProcedure(requireString(params, "procedure")); JsonObject result = new JsonObject(); result.addProperty("name", procedureName(function)); + Symbol symbol = function.getSymbol(); + result.add("symbol", symbol == null ? JsonNull.INSTANCE : symbolMetadata(symbol)); result.addProperty("entrypoint", canonicalAddress(function.getEntryPoint())); result.addProperty("basicblock_count", basicBlocks(function).size()); JsonObject body = functionBody(function); @@ -2138,13 +2140,8 @@ public final class ReaGhidraBridge extends HeadlessScript { if (!address.isMemoryAddress() && !address.isExternalAddress()) { continue; } - JsonObject facts = new JsonObject(); - facts.addProperty("primary", symbol.isPrimary()); - facts.addProperty("dynamic", symbol.isDynamic()); - facts.addProperty("external", symbol.isExternal()); - facts.addProperty("type", normalizedSymbolType(symbol)); - facts.addProperty("source", normalizedSource(symbol.getSource())); - result.add(new InventoryItem(address, symbol.getName(true), facts)); + JsonObject facts = symbolMetadata(symbol); + result.add(new InventoryItem(address, symbol.getName(true), facts, null)); } result.sort(INVENTORY_ORDER); nameInventory = List.copyOf(result); @@ -2188,7 +2185,8 @@ public final class ReaGhidraBridge extends HeadlessScript { InventoryItem item = new InventoryItem( function.getEntryPoint(), symbol == null ? function.getName() : symbol.getName(true), - facts + facts, + symbol == null ? null : symbolMetadata(symbol) ); destination.add(new FunctionEntry(function, item)); } @@ -2219,7 +2217,7 @@ public final class ReaGhidraBridge extends HeadlessScript { instance.isMissingNullTerminator() ? "missing" : "present_or_not_required" ); facts.addProperty("byte_length", Math.max(0, data.getLength())); - result.add(new InventoryItem(data.getAddress(), value, facts)); + result.add(new InventoryItem(data.getAddress(), value, facts, null)); } result.sort(INVENTORY_ORDER); stringInventory = List.copyOf(result); @@ -4406,6 +4404,9 @@ public final class ReaGhidraBridge extends HeadlessScript { if (factsName != null) { result.add(factsName, item.facts.deepCopy()); } + if (item.symbol != null) { + result.add("symbol", item.symbol.deepCopy()); + } return result; } @@ -4433,6 +4434,8 @@ public final class ReaGhidraBridge extends HeadlessScript { JsonObject result = new JsonObject(); result.addProperty("address", canonicalAddress(function.getEntryPoint())); result.addProperty("name", procedureName(function)); + Symbol symbol = function.getSymbol(); + result.add("symbol", symbol == null ? JsonNull.INSTANCE : symbolMetadata(symbol)); result.add("classification", functionClassification(function)); result.add("body", functionBody(function)); return result; @@ -4661,6 +4664,16 @@ public final class ReaGhidraBridge extends HeadlessScript { return value.isEmpty() ? "unknown" : value; } + private static JsonObject symbolMetadata(Symbol symbol) { + JsonObject facts = new JsonObject(); + facts.addProperty("primary", symbol.isPrimary()); + facts.addProperty("dynamic", symbol.isDynamic()); + facts.addProperty("external", symbol.isExternal()); + facts.addProperty("type", normalizedSymbolType(symbol)); + facts.addProperty("source", normalizedSource(symbol.getSource())); + return facts; + } + private static String normalizedSource(SourceType source) { return source.name().toLowerCase(Locale.ROOT); } @@ -4907,7 +4920,7 @@ public final class ReaGhidraBridge extends HeadlessScript { .thenComparingInt(Reference::getOperandIndex) .thenComparing(Reference::isPrimary); - private record InventoryItem(Address address, String value, JsonObject facts) {} + private record InventoryItem(Address address, String value, JsonObject facts, JsonObject symbol) {} private record AddressNameRequest(String inputAddress, Address address, String name) {} private record FunctionEntry(Function function, InventoryItem item) {} private record FunctionAnalysisObservation(JsonObject dossier, List limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {} diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 13c6c9c554..42aa59fcac 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -400,6 +400,7 @@ def _procedure_identity(procedure): return { "address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), + "symbol": None, "classification": None, "body": { "available": False, @@ -1070,6 +1071,7 @@ def _dispatch(method, params): length = sum(max(0, _basic_block_end(procedure, block) - block.getStartingAddress()) for block in blocks) return { "name": _procedure_name(procedure), + "symbol": None, "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index e9b6ab68ab..472b54b50a 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1276,14 +1276,15 @@ profiles, Ghidra universal-image imports without an exact slice commitment, and older or imported Evidence without a commitment leave coordinate identity unknown. Unparseable coordinates also remain unknown. -Across different artifacts, endpoints use nonempty stable procedure names. -Missing or address-generated names remain unknown. A name observed at multiple -procedure entries across either dossier's call and reference facets retains its -label constraint but cannot establish equal endpoints. Same-container records -without a common coordinate context likewise cannot establish equality from -matching labels. Procedure labels do not carry authoritative symbol-origin -facts, so recognition of generated names remains conservative and cannot prove -whether every provider label was generated or explicitly named. +Across different artifacts, endpoints use nonempty procedure names whose observed +symbol facts establish a named origin. Dynamic/default symbols and unknown origin +remain unknown. Ghidra emits the same source/dynamic symbol facts for inventories +and procedure identities; Hopper, IDA and older imported dossiers without verified +origin facts cannot establish correspondence from display labels. No name-spelling +rule classifies `EntryPoint` or a `FUN_` prefix as generated. A name observed at +multiple procedure entries retains its label constraint but cannot establish equal +endpoints. Same-container records without a common coordinate context likewise +cannot establish equality from matching labels. Pairing preserves duplicate counts and considers alternative compatible pairings; array order never identifies an edge. Compatible records with unknown diff --git a/docs/native-function-matching.md b/docs/native-function-matching.md index 5744398758..8d98fc59c2 100644 --- a/docs/native-function-matching.md +++ b/docs/native-function-matching.md @@ -19,11 +19,14 @@ session references as `left` and `right`: } ``` -Unique exact reported names are paired first. Optional `list_names` Evidence -identifies dynamic/default names to exclude. If origin is unavailable, the -existing automatic-name exclusion rule applies and each function retains that -limitation. Exact names establish name correspondence, not unchanged code or -original-source identity. +Unique exact names with observed symbol origin are paired first. Inventory rows +or optional `list_names` Evidence identify dynamic/default names to exclude. +Unknown or conflicting origin does not establish name correspondence; complete +independent body bytes remain eligible. Exact names establish name correspondence, +not unchanged code or original-source identity. Ghidra publishes its symbol facts; +Hopper, IDA and older imported dossiers retain unknown origin when no such facts +were observed. Display spelling alone, including `EntryPoint` or `FUN_` prefixes, +does not establish whether a name was generated. Authentic Evidence selected for the wrong side, role, target, provider, profile or document returns an input error naming the selected field or observation diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 998e8da57a..79040fca27 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -1038,3 +1038,10 @@ numeric address still selects its containing procedure. Use `resolve_containing_procedure` to request containment directly. IDA and Hopper reject reserved `file-offset:` selectors before name lookup; source-file offset selection belongs to the Ghidra import mapping. + +Native function names carry observed symbol metadata where available. Ghidra uses +its function symbol's source and dynamic flags; Hopper and IDA leave origin +unknown because this adapter has no verified origin API. Comparison and matching +cannot use unknown labels as cross-artifact identity, even when their spelling +looks meaningful. Original labels remain in Evidence. This qualification does not +change address equality in a known common native coordinate context. diff --git a/src/application/native/NativeFunctionMatchingService.test.ts b/src/application/native/NativeFunctionMatchingService.test.ts index ce836a41d4..b231184aee 100644 --- a/src/application/native/NativeFunctionMatchingService.test.ts +++ b/src/application/native/NativeFunctionMatchingService.test.ts @@ -7,7 +7,8 @@ import { createAnalysisProfile } from "../../domain/analysisProfile.js"; import { functionDossierSchema } from "../../domain/hopperValues.js"; import { procedureInfoSchema } from "../../domain/native/procedureInfo.js"; import { - nativeMatchingInventory, + nativeMatchingNamedInventory as nativeMatchingInventory, + nativeMatchingInventory as unqualifiedInventory, nativeMatchingDetails, nativeMatchingTarget, nativeMatchingProvider, @@ -81,10 +82,10 @@ it("retains transitive provenance without treating dependencies as native observ }); it("keeps symbol origin unknown when supplied metadata describes a different same-address alias", async () => { - const left = nativeMatchingInventory("1", [ + const left = unqualifiedInventory("1", [ { address: "0x10", value: "reported_name" }, ]); - const right = nativeMatchingInventory("2", [ + const right = unqualifiedInventory("2", [ { address: "0x20", value: "reported_name" }, ]); const alias = createEvidence( @@ -109,13 +110,14 @@ it("keeps symbol origin unknown when supplied metadata describes a different sam }, ); const { report } = await compare({ left, right, left_observations: [alias] }); - expect(report.summary.matched).toBe(1); - expect(report.matches[0]?.left[0]?.limitations).toContain( - "Symbol origin unavailable; exact-name matching uses the reported name and the existing automatic-name exclusion rule.", - ); - expect(report.matches[0]?.left[0]?.evidence_links).toContain( - alias.evidence_id, + expect(report.summary.matched).toBe(0); + const member = report.matches + .flatMap((match) => match.left) + .find((value) => value.address === "0x10"); + expect(member?.limitations).toContain( + "Symbol origin unavailable; exact-name matching is unavailable. Independent complete body bytes remain eligible.", ); + expect(member?.evidence_links).toContain(alias.evidence_id); }); it("preserves contradictory symbol origins from inventory/name facts or multiple name observations, independently of byte correspondence", async () => { @@ -224,6 +226,13 @@ it.each(["", "ram:"])( { address: "0x500", name: "puts", + symbol: { + primary: true, + dynamic: false, + external: true, + type: "function", + source: "imported", + }, classification: null, body: { available: false, reason: "Synthetic external target" }, }, @@ -269,6 +278,19 @@ it.each(["", "ram:"])( report.matches[0]?.right[0]?.structural_candidate_sha256, ); expect(report.matches[0]?.compare_functions_input).toBeNull(); + const unknownOrigin = dossier("1", "0x10"); + if (unknownOrigin.callees[0] === undefined) + throw new Error("Missing fixture callee"); + unknownOrigin.callees[0].symbol = null; + const unqualified = await compare({ + left, + right, + left_observations: [observe("1", "analyze_function", unknownOrigin)], + right_observations: [observe("2", "analyze_function", r)], + }); + expect(unqualified.report.source_coverage.left_structural_candidates).toBe( + 0, + ); const incomplete = await compare({ left, right, @@ -637,8 +659,8 @@ it("does not use generated symbols and preserves literal named FUN_ symbols when })), }); const { report } = await compare({ - left: nativeMatchingInventory("1", entries), - right: nativeMatchingInventory("2", entries), + left: unqualifiedInventory("1", entries), + right: unqualifiedInventory("2", entries), left_observations: [names("1")], right_observations: [names("2")], }); @@ -1027,3 +1049,25 @@ it.each(["left_observations", "right_observations"] as const)( expect(restored.report.summary.matched).toBe(1); }, ); + +it("does not propose exact-symbol correspondence from unqualified EntryPoint labels", async () => { + const { report } = await compare({ + left: unqualifiedInventory("1", [{ address: "0x10", value: "EntryPoint" }]), + right: unqualifiedInventory("2", [ + { address: "0x20", value: "EntryPoint" }, + ]), + }); + expect(report.summary.matched).toBe(0); + expect( + report.matches.flatMap((match) => [...match.left, ...match.right]), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "EntryPoint", + limitations: expect.arrayContaining([ + "Symbol origin unavailable; exact-name matching is unavailable. Independent complete body bytes remain eligible.", + ]), + }), + ]), + ); +}); diff --git a/src/application/native/NativeFunctionMatchingViews.test.ts b/src/application/native/NativeFunctionMatchingViews.test.ts index cc3eda5030..cfa9def892 100644 --- a/src/application/native/NativeFunctionMatchingViews.test.ts +++ b/src/application/native/NativeFunctionMatchingViews.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "vitest"; import { matchNativeFunctions } from "./NativeFunctionMatchingService.js"; import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; -import { nativeMatchingInventory } from "../../../tests/fixtures/nativeFunctionMatching.js"; +import { nativeMatchingNamedInventory as nativeMatchingInventory } from "../../../tests/fixtures/nativeFunctionMatching.js"; import { analysisViewResultSchema } from "../../domain/analysisView/analysisView.js"; it("pages individual function facts without transferring an entire ambiguous alias group, preserving mandatory identity and source coverage", async () => { diff --git a/src/contracts/functionComparisonExample.ts b/src/contracts/functionComparisonExample.ts index aefc75e1ae..735512c400 100644 --- a/src/contracts/functionComparisonExample.ts +++ b/src/contracts/functionComparisonExample.ts @@ -7,6 +7,13 @@ const dossier = (text: string) => procedure: { address: "0x1000", name: "main", + symbol: { + primary: true, + dynamic: false, + external: false, + type: "function", + source: "imported" as const, + }, classification: null, body: { available: false, diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index c18c95dbaa..ea7e90f4db 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -18,6 +18,13 @@ import type { RelationshipCoverage } from "./relationshipCoverage.js"; const procedure = (address: string, name: string) => ({ address, name, + symbol: { + primary: true, + dynamic: false, + external: false, + type: "function", + source: "imported" as const, + }, classification: null, body: { available: false as const, @@ -168,6 +175,7 @@ const keepsGeneratedNamesAmbiguous = (): void => { ...dossier("return 0;", base), procedure: { ...procedure(base, name), + symbol: null, signature: "int helper(void)", locals: [], }, @@ -265,7 +273,13 @@ describe("function comparison normalized identity", () => { keepsGeneratedNamesAmbiguous, ); - it.each(["sub_deallocate", "sub_deadbeef_handler", "fcn.dispatch"])( + it.each([ + "sub_deallocate", + "sub_deadbeef_handler", + "fcn.dispatch", + "EntryPoint", + "FUN_deadBEEF", + ])( "recognizes the explicit symbol %s without a generated-name prefix false positive", (name) => { const named = (base: "0x1000" | "0x2000") => @@ -292,14 +306,15 @@ describe("function comparison normalized identity", () => { }, ); - it.each(["sub_deadbeef", "fcn.00401000", "FUN_deadBEEF"])( - "keeps %s as an address-derived name", + it.each(["sub_deadbeef", "fcn.00401000", "FUN_deadBEEF", "EntryPoint"])( + "keeps unqualified label %s unknown regardless of spelling", (name) => { const generated = (base: "0x1000" | "0x2000") => functionDossierSchema.parse({ ...dossier("return 0;", base), procedure: { ...procedure(base, name), + symbol: null, name, signature: "int helper(void)", locals: [], @@ -319,21 +334,42 @@ describe("function comparison normalized identity", () => { describe("function comparison address-derived call names", () => { it.each([ - { name: "sub_deallocate", status: "unchanged", diagnostic: undefined }, + { + name: "sub_deallocate", + source: "imported", + status: "unchanged", + diagnostic: undefined, + }, { name: "sub_deadbeef", + source: "default", status: "unknown", diagnostic: "Provider retained an unresolved call target.", }, - { name: "FUN_00401000", status: "unknown", diagnostic: undefined }, - { name: "FUN_initialize", status: "unchanged", diagnostic: undefined }, + { + name: "FUN_00401000", + source: "default", + status: "unknown", + diagnostic: undefined, + }, + { + name: "FUN_initialize", + source: "imported", + status: "unchanged", + diagnostic: undefined, + }, ])( "preserves the calls dimension for callee $name", - ({ name, status, diagnostic }) => { + ({ name, source, status, diagnostic }) => { const calling = (base: "0x1000" | "0x2000") => functionDossierSchema.parse({ ...dossier("return helper();", base), - callees: [procedure(base === "0x1000" ? "0x1010" : "0x2010", name)], + callees: [ + { + ...procedure(base === "0x1000" ? "0x1010" : "0x2010", name), + symbol: { ...procedure(base, name).symbol, source }, + }, + ], }); const result = compareFunctions( functionSnapshotFixture(observe("6", calling("0x1000"))), @@ -996,6 +1032,7 @@ describe("function comparison identity and malformed CFG", () => { ...dossier("return 0;", base), procedure: { ...procedure(base, "sub_1000"), + symbol: null, name: "sub_1000", signature: "int helper(void)", locals: [], diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index 0bb4259389..e3b19d95a0 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -1,3 +1,4 @@ +import { hasStableNativeSymbol } from "./native/nativeSymbol.js"; import { canonicalJson } from "./comparisonSemantics.js"; import { compareFunctionRelationships, @@ -9,7 +10,6 @@ import type { FunctionSnapshot } from "./functionDossierEvidence.js"; import { commentProjection, identityProjection, - hasStableFunctionName, nameAndSwiftProjection, normalizeCfg, referencedStringProjection, @@ -550,8 +550,14 @@ const compareIdentity = ( context: ComparisonContext, ): FunctionDimension => { if ( - !hasStableFunctionName(left.dossier.procedure.name) || - !hasStableFunctionName(right.dossier.procedure.name) + !hasStableNativeSymbol( + left.dossier.procedure.name, + left.dossier.procedure.symbol, + ) || + !hasStableNativeSymbol( + right.dossier.procedure.name, + right.dossier.procedure.symbol, + ) ) return unresolvedDimension({ dimension: "identity", @@ -559,7 +565,7 @@ const compareIdentity = ( leftCount: null, rightCount: null, limitations: [ - "Empty or address-derived function names are not stable cross-version identity.", + "Empty names, generated symbols and unknown symbol origins do not establish cross-version identity.", ], }); return compareValues( diff --git a/src/domain/functionComparisonNormalization.ts b/src/domain/functionComparisonNormalization.ts index b71bf57673..55480f2daf 100644 --- a/src/domain/functionComparisonNormalization.ts +++ b/src/domain/functionComparisonNormalization.ts @@ -1,3 +1,4 @@ +import { hasStableNativeSymbol } from "./native/nativeSymbol.js"; import { canonicalJson } from "./comparisonSemantics.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import { nativeAddressCoordinateInputSchema } from "./native/nativeAddress.js"; @@ -20,7 +21,8 @@ export const functionMatch = ( const leftName = left.dossier.procedure.name; const rightName = right.dossier.procedure.name; const stableNames = - hasStableFunctionName(leftName) && hasStableFunctionName(rightName); + hasStableNativeSymbol(leftName, left.dossier.procedure.symbol) && + hasStableNativeSymbol(rightName, right.dossier.procedure.symbol); if (stableNames && leftName === rightName) return { status: "matched", @@ -224,14 +226,6 @@ export const sorted = (values: readonly T[]): readonly T[] => .sort((left, right) => compareUnicodeCodePoints(left.json, right.json)) .map(({ value }) => value); -/** Recognize address-derived procedure names without excluding named FUN_ symbols. */ -export const isAutoName = (name: string): boolean => - /^(?:sub_[0-9a-f]+|fcn\.[0-9a-f]+|FUN_[0-9a-f]+)$/iu.test(name); - -/** A display label must be nonempty before it can constrain named correspondence. */ -export const hasStableFunctionName = (name: string): boolean => - name.length > 0 && !isAutoName(name); - const parseAddress = (value: string) => { const parsed = nativeAddressCoordinateInputSchema.safeParse(value); return parsed.success ? parsed.data : null; diff --git a/src/domain/functionRelationshipComparison.ts b/src/domain/functionRelationshipComparison.ts index 1b2bfc7eca..4483344bc1 100644 --- a/src/domain/functionRelationshipComparison.ts +++ b/src/domain/functionRelationshipComparison.ts @@ -1,11 +1,11 @@ import { functionCoordinateContextsEqual } from "./native/functionCoordinateContext.js"; import { canonicalJson } from "./comparisonSemantics.js"; import type { FunctionSnapshot } from "./functionDossierEvidence.js"; +import { sorted } from "./functionComparisonNormalization.js"; import { - hasStableFunctionName, - isAutoName, - sorted, -} from "./functionComparisonNormalization.js"; + hasStableNativeSymbol, + nativeSymbolOrigin, +} from "./native/nativeSymbol.js"; import { referenceFactsCompatible, type ReferenceCompatibilityRow, @@ -30,6 +30,7 @@ type Endpoint = | "unparsed_address" | "missing_name" | "generated_name" + | "symbol_origin" | "coordinate_context"; readonly reported_address: string; readonly reported_name: string | null; @@ -115,13 +116,18 @@ const endpoint = ( reported_address: address, reported_name: procedure?.name ?? null, }; - if (procedure === null || !hasStableFunctionName(procedure.name)) + if ( + procedure === null || + !hasStableNativeSymbol(procedure.name, procedure.symbol) + ) return { kind: "unknown", reason: - procedure !== null && isAutoName(procedure.name) - ? "generated_name" - : "missing_name", + procedure === null || procedure.name.length === 0 + ? "missing_name" + : nativeSymbolOrigin(procedure.symbol) === "generated" + ? "generated_name" + : "symbol_origin", reported_address: address, reported_name: procedure?.name ?? null, }; @@ -272,7 +278,7 @@ const compareRelationshipFacts = ( limitations.push( coordinateContext === "common" ? "Relationship endpoint coordinates could not be parsed, so their identity remains unknown." - : "Unnamed relationship endpoints and generated procedure names have no stable identity outside a common executable coordinate context.", + : "Unnamed relationship endpoints, generated symbols and unknown symbol origins have no stable identity outside a common executable coordinate context.", ); if (coordinateContext === "unknown" && (unknownIdentity || ambiguousIdentity)) limitations.push( diff --git a/src/domain/ghidraValues.fixture.ts b/src/domain/ghidraValues.fixture.ts index 4927cf2105..255947a1f4 100644 --- a/src/domain/ghidraValues.fixture.ts +++ b/src/domain/ghidraValues.fixture.ts @@ -19,6 +19,13 @@ export const ghidraFunctionBody = () => ({ export const ghidraFunctionIdentity = () => ({ address: "0x401000", name: "fixture_main", + symbol: { + primary: true, + dynamic: false, + external: false, + type: "function", + source: "imported" as const, + }, classification: ghidraFunctionClassification(), body: ghidraFunctionBody(), }); diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 1ed9d464e9..dd31eda95b 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -1,3 +1,4 @@ +import { nativeSymbolMetadataSchema } from "./native/nativeSymbol.js"; import { z } from "zod"; import { fixedTupleSchema } from "./fixedTupleSchema.js"; import { addressCoordinate } from "./native/addressCoordinate.js"; @@ -204,6 +205,7 @@ export const procedureIdentitySchema = z .object({ address: z.string(), name: z.string(), + symbol: nativeSymbolMetadataSchema.nullable().exactOptional(), classification: procedureClassificationSchema.nullable(), body: functionBodySchema, }) diff --git a/src/domain/native/functionInventory.ts b/src/domain/native/functionInventory.ts index 0f95c2aeaa..a5192a8522 100644 --- a/src/domain/native/functionInventory.ts +++ b/src/domain/native/functionInventory.ts @@ -9,7 +9,7 @@ import { functionBodyEntryAgrees, functionDossierSchema, } from "../hopperValues.js"; -import { isAutoName } from "../functionComparisonNormalization.js"; +import { nativeSymbolOrigin } from "./nativeSymbol.js"; import { addressedValueSchema, nativeInventoryRowsSchemas, @@ -43,7 +43,12 @@ const matchingProcedureInfoSchema = procedureInfoSchema entrypoint: nativeAddressInputSchema, body: matchingBodySchema, }) - .transform(({ entrypoint, body }) => ({ address: entrypoint, body })); + .transform(({ entrypoint, body, name, symbol }) => ({ + address: entrypoint, + body, + name, + symbol, + })); const matchingFunctionDossierSchema = functionDossierSchema.safeExtend({ procedure: functionDossierSchema.shape.procedure.safeExtend({ address: nativeAddressInputSchema, @@ -84,7 +89,11 @@ interface Facts { readonly bytes: FunctionByteCoverage; } -const addBody = (observation: Evidence, details: Facts["details"]): void => { +const addBody = ( + observation: Evidence, + details: Facts["details"], + symbols: Facts["symbols"], +): void => { const dossier = observation.operation === "analyze_function" ? sourcePayload( @@ -96,6 +105,12 @@ const addBody = (observation: Evidence, details: Facts["details"]): void => { dossier?.procedure ?? sourcePayload(matchingProcedureInfoSchema, observation.normalized_result); const address = procedure.address; + if (procedure.symbol != null) + addSymbol( + { address, value: procedure.name, symbol: procedure.symbol }, + observation.evidence_id, + symbols, + ); if (!functionBodyEntryAgrees(procedure.body, address)) throw new EvidenceIntegrityError( "Procedure body entry membership contradicts its address", @@ -125,6 +140,18 @@ const addBody = (observation: Evidence, details: Facts["details"]): void => { } detail.links.push(observation.evidence_id); }; +const addSymbol = ( + symbol: AddressedValue, + evidenceId: string, + symbols: Facts["symbols"], +): void => { + const address = symbol.address; + let group = symbols.get(address); + if (group === undefined) + symbols.set(address, (group = { values: [], links: new Set() })); + group.values.push(symbol); + group.links.add(evidenceId); +}; const addNames = async ( observation: Evidence, symbols: Facts["symbols"], @@ -135,12 +162,7 @@ const addNames = async ( observation.normalized_result, )) { await checkpoint(128 + symbol.value.length * 2); - const address = symbol.address; - let group = symbols.get(address); - if (group === undefined) - symbols.set(address, (group = { values: [], links: new Set() })); - group.values.push(symbol); - group.links.add(observation.evidence_id); + addSymbol(symbol, observation.evidence_id, symbols); } }; const addBytes = async ( @@ -184,18 +206,13 @@ const reportedSymbol = ( ...(known?.values.filter((symbol) => symbol.value === row.value) ?? []), ]; const generated = names.some( - (symbol) => - symbol.symbol?.dynamic === true || symbol.symbol?.source === "default", + (symbol) => nativeSymbolOrigin(symbol.symbol) === "generated", ); const named = names.some( - (symbol) => - symbol.symbol?.dynamic === false && symbol.symbol.source !== "default", + (symbol) => nativeSymbolOrigin(symbol.symbol) === "named", ); return { - value: - row.value.length === 0 || generated || (!named && isAutoName(row.value)) - ? null - : row.value, + value: row.value.length === 0 || generated || !named ? null : row.value, origin: generated && named ? "conflicting" @@ -242,7 +259,7 @@ const functionFact = async ( ); if (symbol.origin === "unknown") limitations.push( - "Symbol origin unavailable; exact-name matching uses the reported name and the existing automatic-name exclusion rule.", + "Symbol origin unavailable; exact-name matching is unavailable. Independent complete body bytes remain eligible.", ); if (symbol.origin === "conflicting") limitations.push( @@ -296,7 +313,7 @@ export const readFunctionInventory = async ( break; case "procedure_info": case "analyze_function": - addBody(observation, facts.details); + addBody(observation, facts.details, facts.symbols); break; } } diff --git a/src/domain/native/functionStructuralCandidate.ts b/src/domain/native/functionStructuralCandidate.ts index e07d8ebbeb..e7d2529316 100644 --- a/src/domain/native/functionStructuralCandidate.ts +++ b/src/domain/native/functionStructuralCandidate.ts @@ -1,10 +1,8 @@ import type { Evidence } from "../evidence.js"; import type { FunctionDossier } from "../hopperValues.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; -import { - hasStableFunctionName, - normalizeCfg, -} from "../functionComparisonNormalization.js"; +import { normalizeCfg } from "../functionComparisonNormalization.js"; +import { hasStableNativeSymbol } from "./nativeSymbol.js"; import { assessFunctionCallCoverage } from "../functionCallCoverage.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; @@ -29,7 +27,9 @@ export const functionStructuralCandidate = ( if ( cfg === null || cfg.length === 0 || - dossier.callees.some((callee) => !hasStableFunctionName(callee.name)) + dossier.callees.some( + (callee) => !hasStableNativeSymbol(callee.name, callee.symbol), + ) ) return null; const calls = dossier.callees diff --git a/src/domain/native/nativeInventory.ts b/src/domain/native/nativeInventory.ts index af16f03210..8e811c784d 100644 --- a/src/domain/native/nativeInventory.ts +++ b/src/domain/native/nativeInventory.ts @@ -1,19 +1,12 @@ import { z } from "zod"; +import { nativeSymbolMetadataSchema } from "./nativeSymbol.js"; import { analysisStringSchema } from "../hopperValues.js"; /** Canonical named native observations; same-address alternatives stay separate. */ export const addressedValueSchema = z.object({ address: z.string(), value: z.string(), - symbol: z - .object({ - primary: z.boolean(), - dynamic: z.boolean(), - external: z.boolean(), - type: z.string(), - source: z.enum(["default", "analysis", "ai", "imported", "user_defined"]), - }) - .exactOptional(), + symbol: nativeSymbolMetadataSchema.exactOptional(), objc_class_label: z.literal("hopper_legacy_class_object").exactOptional(), procedure: z .object({ diff --git a/src/domain/native/nativeSymbol.ts b/src/domain/native/nativeSymbol.ts new file mode 100644 index 0000000000..3bfe09e466 --- /dev/null +++ b/src/domain/native/nativeSymbol.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +/** Native producer facts about the exact reported symbol, independent of display spelling. */ +export const nativeSymbolMetadataSchema = z.object({ + primary: z.boolean(), + dynamic: z.boolean(), + external: z.boolean(), + type: z.string(), + source: z.enum(["default", "analysis", "ai", "imported", "user_defined"]), +}); +export type NativeSymbolMetadata = z.infer; + +export const nativeSymbolOrigin = ( + symbol: NativeSymbolMetadata | null | undefined, +): "generated" | "named" | "unknown" => + symbol == null + ? "unknown" + : symbol.dynamic || symbol.source === "default" + ? "generated" + : "named"; + +/** Unknown origin never makes an arbitrary display label a cross-artifact identity. */ +export const hasStableNativeSymbol = ( + name: string, + symbol: NativeSymbolMetadata | null | undefined, +): boolean => name.length > 0 && nativeSymbolOrigin(symbol) === "named"; diff --git a/src/domain/native/procedureInfo.ts b/src/domain/native/procedureInfo.ts index f06ea4f09a..8baf87143f 100644 --- a/src/domain/native/procedureInfo.ts +++ b/src/domain/native/procedureInfo.ts @@ -1,3 +1,4 @@ +import { nativeSymbolMetadataSchema } from "./nativeSymbol.js"; import { z } from "zod"; import { functionBodySchema, @@ -8,6 +9,7 @@ import { /** Provider-neutral procedure metadata, including exact body ranges. */ export const procedureInfoSchema = z.object({ name: z.string(), + symbol: nativeSymbolMetadataSchema.nullable().exactOptional(), entrypoint: z.string(), basicblock_count: z.number().int().min(0), length: z.number().min(0), diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index 236636b073..9899281a5b 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -71,6 +71,7 @@ const unknownBody = { const identity = (fn: IdaFunction) => ({ address: fn.address, name: fn.name, + symbol: null, classification: null, body: unknownBody, }); diff --git a/tests/acceptance/tools/nativeFunctionMatching.test.ts b/tests/acceptance/tools/nativeFunctionMatching.test.ts index fc2c0bf5fa..3afed436ac 100644 --- a/tests/acceptance/tools/nativeFunctionMatching.test.ts +++ b/tests/acceptance/tools/nativeFunctionMatching.test.ts @@ -6,7 +6,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { - nativeMatchingInventory, + nativeMatchingNamedInventory as nativeMatchingInventory, nativeMatchingDetails, nativeMatchingTarget, } from "../../fixtures/nativeFunctionMatching.js"; diff --git a/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts b/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts index f5c3b90839..5806df7188 100644 --- a/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonReferenceFactsMcp.test.ts @@ -1,3 +1,4 @@ +import type { NativeSymbolMetadata } from "../../../src/domain/native/nativeSymbol.js"; import type { Client } from "@modelcontextprotocol/client"; import { expect, it, onTestFinished } from "vitest"; @@ -44,9 +45,20 @@ const createHarness = async () => { return harness; }; -const procedure = (address: string, name: string) => ({ +const procedure = ( + address: string, + name: string, + symbol: NativeSymbolMetadata | null = { + primary: true, + dynamic: false, + external: false, + type: "function", + source: "imported", + }, +) => ({ address, name, + symbol, classification: null, body: dossier.procedure.body, }); @@ -58,6 +70,7 @@ const reference = ( options: { readonly sourceName?: string; readonly targetName?: string | null; + readonly targetSymbol?: NativeSymbolMetadata | null; readonly sourceEntryAddress?: string; readonly targetEntryAddress?: string; } = {}, @@ -77,6 +90,7 @@ const reference = ( : procedure( options.targetEntryAddress ?? targetAddress, options.targetName ?? "helper", + options.targetSymbol, ), kind: classification === "unknown" @@ -109,11 +123,12 @@ const evidence = ( readonly incoming?: readonly Reference[]; readonly procedureAddress?: string; readonly procedureName?: string; + readonly procedureSymbol?: NativeSymbolMetadata | null; readonly architecture?: "arm64" | "x86_64" | null; readonly format?: "mach-o" | "mach-o-universal"; readonly profile?: AnalysisProfileCommitment | null; - readonly callees?: readonly ReturnType[]; - readonly callers?: readonly ReturnType[]; + readonly callees?: readonly (typeof dossier.callees)[number][]; + readonly callers?: readonly (typeof dossier.callers)[number][]; } = {}, ) => createEvidence( @@ -138,6 +153,10 @@ const evidence = ( ...dossier.procedure, address: options.procedureAddress ?? dossier.procedure.address, name: options.procedureName ?? dossier.procedure.name, + symbol: + options.procedureSymbol === undefined + ? dossier.procedure.symbol + : options.procedureSymbol, }, callees: [...(options.callees ?? [])], callers: [...(options.callers ?? [])], @@ -311,16 +330,24 @@ it("preserves reference identity uncertainty and same-artifact coordinates", asy expectReferences(await compare(client, namedRight, namedLeft), "unchanged"); }); -it("keeps generated and ambiguous procedure names from proving cross-artifact identity", async () => { +it("keeps unqualified and ambiguous procedure names from proving cross-artifact identity", async () => { const { client } = await createHarness(); for (const [index, name] of ["sub_1010", "fcn.1010", "FUN_1010"].entries()) { const left = evidence(`auto-left-${index}`, "1", [ - reference("0x1000", "0x1010", "call", { targetName: name }), + reference("0x1000", "0x1010", "call", { + targetName: name, + targetSymbol: null, + }), ]); const right = evidence( `auto-right-${index}`, "2", - [reference("0x2000", "0x2020", "call", { targetName: name })], + [ + reference("0x2000", "0x2020", "call", { + targetName: name, + targetSymbol: null, + }), + ], { procedureAddress: "0x2000" }, ); expectReferences(await compare(client, left, right), "unknown"); @@ -332,7 +359,12 @@ it("keeps generated and ambiguous procedure names from proving cross-artifact id evidence( `auto-different-${index}`, "3", - [reference("0x2000", "0x2020", "call", { targetName: otherName })], + [ + reference("0x2000", "0x2020", "call", { + targetName: otherName, + targetSymbol: null, + }), + ], { procedureAddress: "0x2000" }, ), ), @@ -370,10 +402,12 @@ it("keeps generated and ambiguous procedure names from proving cross-artifact id client, evidence("generated-function-left", "7", [], { procedureName: "sub_1000", + procedureSymbol: null, }), evidence("generated-function-right", "8", [], { procedureAddress: "0x2000", procedureName: "FUN_2000", + procedureSymbol: null, }), ); expect.soft(generatedFunctions.result.function_match).toMatchObject({ @@ -823,7 +857,7 @@ it("retains caller entries and independent contradictions beside unknown endpoin callees: [ procedure("0x1010", name), procedure("0x1010", name), - procedure("0x1020", "sub_1020"), + procedure("0x1020", "sub_1020", null), ], }); const mixed = await compare( @@ -876,3 +910,67 @@ it("keeps equal coordinates in distinct same-architecture universal slices unkno ); } }); + +it.each([null, "default", "imported"] as const)( + "native comparison uses observed symbol origin %s for EntryPoint labels", + async (source) => { + const harness = await createHarness(); + const edge = (base: "0x1000" | "0x2000", name: string) => { + const value = reference( + base, + base === "0x1000" ? "0x1010" : "0x2010", + "call", + { targetName: name }, + ); + if (value.target_procedure === null) + throw new Error("Missing fixture target"); + value.target_procedure.symbol = + source === null + ? null + : { + primary: true, + dynamic: false, + external: false, + type: "function", + source, + }; + return value; + }; + const leftEdge = edge( + "0x1000", + source === "imported" ? "EntryPoint" : "_main", + ); + const rightEdge = edge("0x2000", "EntryPoint"); + if ( + leftEdge.target_procedure === null || + rightEdge.target_procedure === null + ) + throw new Error("Missing fixture target"); + const compared = await compare( + harness.client, + evidence("origin-left", "1", [leftEdge], { + callees: [leftEdge.target_procedure], + }), + evidence("origin-right", "2", [rightEdge], { + callees: [rightEdge.target_procedure], + }), + ); + const status = source === "imported" ? "unchanged" : "unknown"; + expectReferences(compared, status); + expect( + compared.result.dimensions.find((value) => value.dimension === "calls"), + ).toMatchObject({ status }); + await verifyDimension( + harness.client, + compared.evidence.evidence_id, + source === "imported" ? "pass" : "unknown", + "references", + ); + await verifyDimension( + harness.client, + compared.evidence.evidence_id, + source === "imported" ? "pass" : "unknown", + "calls", + ); + }, +); diff --git a/tests/fixtures/nativeFunctionMatching.ts b/tests/fixtures/nativeFunctionMatching.ts index f20dd77c00..b5969f1fb6 100644 --- a/tests/fixtures/nativeFunctionMatching.ts +++ b/tests/fixtures/nativeFunctionMatching.ts @@ -1,6 +1,7 @@ import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; import { jsonValueSchema } from "../../src/domain/jsonValue.js"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../src/contracts/functionComparisonExample.js"; +import type { NativeSymbolMetadata } from "../../src/domain/native/nativeSymbol.js"; import { functionDossierSchema } from "../../src/domain/hopperValues.js"; /** Explicit synthetic producer facts; they do not qualify any real provider. */ @@ -26,6 +27,27 @@ export const nativeMatchingInventory = ( result: jsonValueSchema.parse(entries), evidenceLinks: [...evidenceLinks], }); +/** Explicit imported-origin fixture for tests that select exact-symbol correspondence. */ +export const nativeMatchingNamedInventory = ( + side: "1" | "2", + entries: readonly { address: string; value: string }[], + evidenceLinks: readonly string[] = [], +) => + createEvidence(nativeMatchingTarget(side), nativeMatchingProvider, { + operation: "list_procedures", + parameters: {}, + result: entries.map((entry) => ({ + ...entry, + symbol: { + primary: true, + dynamic: false, + external: false, + type: "function", + source: "imported", + } satisfies NativeSymbolMetadata, + })), + evidenceLinks: [...evidenceLinks], + }); export const nativeMatchingBody = (address: string, bytes: number) => ({ available: true as const, provenance: "explicit synthetic body",