fix(native): require observed symbol origin for named identity

This commit is contained in:
morluto committed 2026-10-24 04:14:17 +08:00
1 parent 98ddd04433
commit 62f7b5e4cd
23 files changed
+395 -107

No files matched your search

+23 -10
View File
@@ -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<String> limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {}
+2
View File
@@ -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,
+9 -8
View File
@@ -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
+8 -5
View File
@@ -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
+7
View File
@@ -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.
@@ -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.",
]),
}),
]),
);
});
@@ -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 () => {
@@ -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,
+45 -8
View File
@@ -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: [],
+10 -4
View File
@@ -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(
@@ -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 = <T>(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;
+15 -9
View File
@@ -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(
+7
View File
@@ -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(),
});
+2
View File
@@ -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,
})
+36 -19
View File
@@ -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;
}
}
@@ -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
+2 -9
View File
@@ -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({
+26
View File
@@ -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<typeof nativeSymbolMetadataSchema>;
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";
+2
View File
@@ -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),
+1
View File
@@ -71,6 +71,7 @@ const unknownBody = {
const identity = (fn: IdaFunction) => ({
address: fn.address,
name: fn.name,
symbol: null,
classification: null,
body: unknownBody,
});
@@ -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";
@@ -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<typeof procedure>[];
readonly callers?: readonly ReturnType<typeof procedure>[];
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",
);
},
);
+22
View File
@@ -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",