fix(native): preserve provider limitations through call traces
This commit is contained in:
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 () => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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<
|
||||
|
||||
Reference in new issue
Block a user