fix(native): preserve provider limitations through call traces

This commit is contained in:
morluto committed 2026-10-15 06:20:20 +08:00
1 parent 01a1b3fda6
commit a004061abc
4 files changed
+193 -30

No files matched your search

@@ -257,6 +257,56 @@ describe("native UI action trace", () => {
}),
);
});
it("carries source-owned reference limitations into trace output without changing coverage", async () => {
const limitation = "SEH handler targets may be outside the function body.";
const run = async (limitations: readonly string[]) => {
const result = await new EnhancedTools(
testAnalysis({
procedure_address: () => execution("procedure_address", "0x1000"),
procedure_references: () =>
ok(
createAnalysisExecution(
{
reference_kinds_available: false,
references: [],
unresolved_calls: [],
},
{ id: "ghidra", name: "Ghidra", version: "fixture" },
{ limitations },
),
),
procedure_callees: (parameters) =>
execution(
"procedure_callees",
parameters.procedure === "0x1000" ? ["0x2000"] : [],
),
}),
).execute("trace_native_ui_action", { action: "0x1000" });
if (!result.ok) throw result.error;
return nativeInvestigationTraceSchema.parse(result.value);
};
const withSourceLimitation = await run([limitation]);
const withoutSourceLimitation = await run([]);
expect(withSourceLimitation.limitations).toContain(
`Ghidra (ghidra fixture) procedure_references: ${limitation}`,
);
expect(
withSourceLimitation.limitations.filter((entry) =>
entry.endsWith(`procedure_references: ${limitation}`),
),
).toHaveLength(1);
expect(withSourceLimitation.coverage).toEqual(
withoutSourceLimitation.coverage,
);
expect(withSourceLimitation.coverage).toContainEqual(
expect.objectContaining({
facet: "static_native_calls",
status: "partial",
}),
);
});
it("accepts an explicit native seed without an authored UI connection", async () => {
const result = await new EnhancedTools(
testAnalysis({
@@ -34,6 +34,12 @@ describe("untyped native call route fallback", () => {
operation === "procedure_references"
? referenceProvider
: fallbackProvider,
{
limitations:
operation === "procedure_references"
? ["Reference-kind metadata is incomplete."]
: ["Callee enumeration is limited to named functions."],
},
),
);
},
@@ -54,6 +60,18 @@ describe("untyped native call route fallback", () => {
resolution: "inferred",
})),
unknowns: [{ ...unknown, provider: referenceProvider }],
limitations: [
{
provider: referenceProvider,
operation: "procedure_references",
limitation: "Reference-kind metadata is incomplete.",
},
{
provider: fallbackProvider,
operation: "procedure_callees",
limitation: "Callee enumeration is limited to named functions.",
},
],
},
});
},
@@ -105,6 +123,7 @@ describe("typed native call routes", () => {
],
},
provider,
{ limitations: ["SEH handler targets may be outside the body."] },
),
),
},
@@ -126,6 +145,13 @@ describe("typed native call routes", () => {
},
],
unknowns: [{ address: "0x1008" }],
limitations: [
{
provider,
operation: "procedure_references",
limitation: "SEH handler targets may be outside the body.",
},
],
},
});
});
@@ -146,12 +172,26 @@ describe("typed native call routes", () => {
});
expect(calls).toBe(1);
const malformed = await readNativeCallRoutes(
{ execute: async () => ok(createAnalysisExecution({}, provider)) },
{
execute: async () =>
ok(
createAnalysisExecution({}, provider, {
limitations: ["Malformed output still has source caveats."],
}),
),
},
"0x1000",
);
expect(malformed).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
limitations: [
{
provider,
operation: "procedure_references",
limitation: "Malformed output still has source caveats.",
},
],
});
});
it("rejects omitted unresolved-call observations instead of claiming none", async () => {
+66 -28
View File
@@ -10,6 +10,19 @@ import {
} from "../../domain/hopperValues.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
export interface NativeCallRouteLimitation {
readonly provider: ProviderIdentity;
readonly operation: "procedure_references" | "procedure_callees";
readonly limitation: string;
}
const limitationsFrom = (
operation: NativeCallRouteLimitation["operation"],
provider: ProviderIdentity,
limitations: readonly string[],
): NativeCallRouteLimitation[] =>
limitations.map((limitation) => ({ provider, operation, limitation }));
const referencesSchema = z.object({
reference_kinds_available: z.boolean(),
references: z.array(
@@ -24,6 +37,33 @@ const referencesSchema = z.object({
z.object({ address: z.string(), reason: z.string() }),
),
});
const typedCallRoutes = (
references: z.infer<typeof referencesSchema>["references"],
) => {
const calls = references.filter(({ kind }) => kind.available && kind.call);
const countBySite = new Map<string, Set<string>>();
for (const call of calls) {
const targets = countBySite.get(call.source_address) ?? new Set<string>();
targets.add(call.target_address);
countBySite.set(call.source_address, targets);
}
return calls.map((call) => ({
address: call.target_procedure?.address ?? call.target_address,
site: call.source_address,
relation:
call.kind.available && (call.kind.computed || call.kind.indirect)
? ("indirect_call" as const)
: ("direct_call" as const),
resolution:
(countBySite.get(call.source_address)?.size ?? 0) > 1
? ("ambiguous" as const)
: call.kind.available && (call.kind.computed || call.kind.indirect)
? ("resolved" as const)
: ("observed" as const),
}));
};
/** Prefer typed static call references, preserving computed targets and targetless sites. */
export const readNativeCallRoutes = async (
analysis: AnalysisOperationPort,
@@ -45,7 +85,13 @@ export const readNativeCallRoutes = async (
reason: string;
provider: ProviderIdentity;
}> = [];
let limitations: NativeCallRouteLimitation[] = [];
if (execution.ok) {
limitations = limitationsFrom(
"procedure_references",
execution.value.provider,
execution.value.limitations,
);
const parsed = referencesSchema.safeParse(execution.value.result);
if (!parsed.success)
return {
@@ -55,6 +101,7 @@ export const readNativeCallRoutes = async (
"Invalid typed call references",
),
unknowns,
limitations,
};
// Missing detailed reference flags do not invalidate observed targetless
// calls. Keep their producing provider even if another provider supplies
@@ -68,48 +115,38 @@ export const readNativeCallRoutes = async (
parsed.data.reference_kinds_available === true ||
(references.length > 0 && references.every(({ kind }) => kind.available))
) {
const calls = references.filter(
({ kind }) => kind.available && kind.call,
);
const countBySite = new Map<string, Set<string>>();
for (const call of calls) {
const targets =
countBySite.get(call.source_address) ?? new Set<string>();
targets.add(call.target_address);
countBySite.set(call.source_address, targets);
}
return {
ok: true as const,
value: {
provider: execution.value.provider,
routes: calls.map((call) => ({
address: call.target_procedure?.address ?? call.target_address,
site: call.source_address,
relation:
call.kind.available && (call.kind.computed || call.kind.indirect)
? ("indirect_call" as const)
: ("direct_call" as const),
resolution:
(countBySite.get(call.source_address)?.size ?? 0) > 1
? ("ambiguous" as const)
: call.kind.available &&
(call.kind.computed || call.kind.indirect)
? ("resolved" as const)
: ("observed" as const),
})),
routes: typedCallRoutes(references),
unknowns,
limitations,
},
};
}
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
return { ok: false as const, error: execution.error, unknowns };
return {
ok: false as const,
error: execution.error,
unknowns,
limitations,
};
const callees = await analysis.execute(
"procedure_callees",
{ procedure },
options,
);
if (!callees.ok)
return { ok: false as const, error: callees.error, unknowns };
return { ok: false as const, error: callees.error, unknowns, limitations };
limitations = [
...limitations,
...limitationsFrom(
"procedure_callees",
callees.value.provider,
callees.value.limitations,
),
];
const parsed = parseRelatedAddresses(callees.value.result, "callees");
return parsed.ok
? {
@@ -123,7 +160,8 @@ export const readNativeCallRoutes = async (
resolution: "inferred" as const,
})),
unknowns,
limitations,
},
}
: { ok: false as const, error: parsed.error, unknowns };
: { ok: false as const, error: parsed.error, unknowns, limitations };
};
+36 -1
View File
@@ -13,7 +13,10 @@ import {
import { nativeDispatchMetadataResultSchema } from "../../domain/native/objcSwiftMetadata.js";
import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { readNativeCallRoutes } from "./NativeCallRoutes.js";
import {
readNativeCallRoutes,
type NativeCallRouteLimitation,
} from "./NativeCallRoutes.js";
import { err, ok } from "../../domain/result.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
@@ -244,6 +247,10 @@ export const traceNativeUiAction = async (
: graph.truncated && trace.reason === null
? "native_trace_output_bound_reached"
: trace.reason,
limitations: [
...trace.limitations,
...renderSourceLimitations(expanded.limitations),
],
}),
),
);
@@ -398,6 +405,7 @@ const addDirectCallees = async (input: {
let examined = 0;
let omitted = 0;
let truncated = false;
const sourceLimitations: NativeCallRouteLimitation[] = [];
while (queueIndex < queue.length) {
const current = queue[queueIndex++];
if (current === undefined || visited.has(current.address)) continue;
@@ -419,6 +427,9 @@ const addDirectCallees = async (input: {
"provider",
);
examined += 1;
sourceLimitations.push(
...(execution.ok ? execution.value.limitations : execution.limitations),
);
if (!execution.ok && execution.error._tag === "AnalysisCancelledError")
return { error: execution.error };
const unknowns = execution.ok
@@ -539,9 +550,33 @@ const addDirectCallees = async (input: {
examined,
omitted,
truncated,
limitations: sourceLimitations,
};
};
const renderSourceLimitations = (
limitations: readonly NativeCallRouteLimitation[],
): string[] => {
const seen = new Set<string>();
const rendered: string[] = [];
for (const { provider, operation, limitation } of limitations) {
const provenance = JSON.stringify([
provider.id,
provider.name,
provider.version,
operation,
limitation,
]);
if (seen.has(provenance)) continue;
seen.add(provenance);
const version = provider.version === null ? "" : ` ${provider.version}`;
rendered.push(
`${provider.name} (${provider.id}${version}) ${operation}: ${limitation}`,
);
}
return rendered;
};
const graphNodeDepths = (
start: string,
nodes: readonly z.infer<