Move shared function, dossier, inventory, reference and output semantics from the legacy Hopper module to native domain owners. Migrate provider, adapter, test and verifier consumers directly.
992 lines
31 KiB
TypeScript
992 lines
31 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { traceNativeValues } from "./NativeValueTrace.js";
|
|
import {
|
|
createAnalysisExecution,
|
|
type AnalysisOperationPort,
|
|
} from "../AnalysisProvider.js";
|
|
import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
|
|
import { functionDossierSchema } from "../../domain/native/functionDossier.js";
|
|
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
|
import { nativeValueTraceSchema } from "../../domain/native/nativeValueTrace.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisOutputError,
|
|
AnalysisInputError,
|
|
AnalysisResourceConstraintError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import { NoBinaryOpenError } from "../../domain/configurationErrors.js";
|
|
import { ok, err } from "../../domain/result.js";
|
|
|
|
const subject = {
|
|
path: "/fixture",
|
|
sha256: "a".repeat(64),
|
|
format: "mach-o" as const,
|
|
architecture: "arm64" as const,
|
|
};
|
|
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
|
const scalar = {
|
|
kind: "constant",
|
|
size_bytes: 4,
|
|
location: null,
|
|
constant_hex: "3",
|
|
};
|
|
const node = (id: string, address: string, opcode: string) => ({
|
|
id,
|
|
address,
|
|
sequence: 0,
|
|
opcode,
|
|
is_dead: true,
|
|
block_membership: "member",
|
|
inputs: [scalar, scalar],
|
|
output: scalar,
|
|
});
|
|
const analysis: AnalysisOperationPort = {
|
|
execute: async (operation, parameters) => {
|
|
if (operation === "resolve_native_call_targets")
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
call_site: "0x1000",
|
|
procedure: "0x1000",
|
|
status: "direct",
|
|
mechanism: "direct",
|
|
targets: [
|
|
{
|
|
address: "0x2000",
|
|
procedure: "0x2000",
|
|
status: "direct",
|
|
basis: "provider-reference",
|
|
references: [],
|
|
},
|
|
],
|
|
limitations: [],
|
|
},
|
|
provider,
|
|
{ subject },
|
|
),
|
|
);
|
|
const callee = parameters.procedure === "0x2000";
|
|
return ok(
|
|
createAnalysisExecution(
|
|
jsonValueSchema.parse({
|
|
...functionDossierSchema.parse(ghidraFunctionDossier()),
|
|
procedure: {
|
|
address: callee ? "0x2000" : "0x1000",
|
|
name: callee ? "callee" : "caller",
|
|
classification: null,
|
|
body: {
|
|
available: false,
|
|
reason:
|
|
"The provider did not report complete function body ranges.",
|
|
},
|
|
signature: null,
|
|
locals: [],
|
|
},
|
|
native_value_flow: {
|
|
available: true,
|
|
provenance: "ghidra-high-pcode",
|
|
operations: callee
|
|
? [node("return", "0x2000", "RETURN")]
|
|
: [node("call", "0x1000", "CALL")],
|
|
def_use: [],
|
|
effects: [],
|
|
parameters: callee
|
|
? [{ ordinal: 0, name: "value", data_type: "/int" }]
|
|
: [],
|
|
parameter_uses: callee
|
|
? [{ ordinal: 0, use: "return", input_index: 1 }]
|
|
: [],
|
|
truncated: false,
|
|
omitted_operations_lower_bound: 0,
|
|
known_omitted_inputs: 0,
|
|
known_omitted_edges: 0,
|
|
limitations: [],
|
|
},
|
|
}),
|
|
provider,
|
|
{ subject },
|
|
),
|
|
);
|
|
},
|
|
};
|
|
describe("native value trace seed admission", () => {
|
|
it("preserves the exact abort reason before attempting a seed", async () => {
|
|
const reason = { request: "cancel before native trace" };
|
|
const attempted: unknown[] = [];
|
|
const result = await traceNativeValues(
|
|
{
|
|
execute: async (operation, parameters) => {
|
|
attempted.push([operation, parameters]);
|
|
return analysis.execute(operation, parameters);
|
|
},
|
|
},
|
|
{ procedure: "0x1000" },
|
|
AbortSignal.abort(reason),
|
|
);
|
|
if (result.ok) throw new Error("Expected pre-seed cancellation");
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.cause).toBe(reason);
|
|
expect(attempted).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
new AnalysisInputError("analyze_function", {
|
|
userMessage: "Unknown selector: missing",
|
|
}),
|
|
new NoBinaryOpenError(),
|
|
new AnalysisResourceConstraintError(
|
|
"analyze_function",
|
|
"capacity",
|
|
"Provider is busy",
|
|
null,
|
|
{ retryable: true },
|
|
),
|
|
new AnalysisCancelledError("analyze_function"),
|
|
])("preserves the original seed failure object: $_tag", async (failure) => {
|
|
const attempted: unknown[] = [];
|
|
const result = await traceNativeValues(
|
|
{
|
|
execute: async (operation, parameters) => {
|
|
attempted.push([operation, parameters]);
|
|
return err(failure);
|
|
},
|
|
},
|
|
{ procedure: "missing" },
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected seed failure");
|
|
expect(result.error).toBe(failure);
|
|
expect(attempted).toEqual([["analyze_function", { procedure: "missing" }]]);
|
|
});
|
|
|
|
it("retains a successful seed without def-use as a useful unknown trace", async () => {
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const result = await traceNativeValues(
|
|
{
|
|
execute: async () =>
|
|
ok(
|
|
createAnalysisExecution(
|
|
{ ...base, native_value_flow: null },
|
|
provider,
|
|
{ subject },
|
|
),
|
|
),
|
|
},
|
|
{ procedure: base.procedure.address },
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(nativeValueTraceSchema.parse(result.value)).toMatchObject({
|
|
target_sha256: subject.sha256,
|
|
decompilations: 1,
|
|
procedures: [
|
|
expect.objectContaining({ address: base.procedure.address }),
|
|
],
|
|
unknowns: [
|
|
expect.objectContaining({
|
|
reason: "Provider has no native def-use model",
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("native value parameter-use node membership", () => {
|
|
it("retains only parameter uses whose parameter nodes fit the node budget", async () => {
|
|
const operationCount = 3_000;
|
|
const parameterUseCount = 12_000;
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const procedureAddress = base.procedure.address;
|
|
const largeFlow = {
|
|
available: true as const,
|
|
provenance: "ghidra-high-pcode" as const,
|
|
operations: Array.from({ length: operationCount }, (_, index) => ({
|
|
...node(`operation-${String(index)}`, procedureAddress, "MULTIEQUAL"),
|
|
inputs: [scalar, scalar, scalar, scalar],
|
|
})),
|
|
def_use: [],
|
|
effects: [],
|
|
parameters: [{ ordinal: 0, name: "value", data_type: "/int" }],
|
|
parameter_uses: Array.from({ length: parameterUseCount }, (_, index) => ({
|
|
ordinal: 0,
|
|
use: `operation-${String(index % operationCount)}`,
|
|
input_index: Math.floor(index / operationCount),
|
|
})),
|
|
truncated: false,
|
|
omitted_operations_lower_bound: 0,
|
|
known_omitted_inputs: 0,
|
|
known_omitted_edges: 0,
|
|
limitations: [],
|
|
};
|
|
const largeDossier = {
|
|
...base,
|
|
native_value_flow: largeFlow,
|
|
};
|
|
const largeAnalysis: AnalysisOperationPort = {
|
|
execute: async () =>
|
|
ok(createAnalysisExecution(largeDossier, provider, { subject })),
|
|
};
|
|
|
|
const includedParameter = await traceNativeValues(largeAnalysis, {
|
|
procedure: procedureAddress,
|
|
max_nodes: 20_000,
|
|
max_edges: 40_000,
|
|
offset: operationCount,
|
|
limit: 1,
|
|
});
|
|
if (!includedParameter.ok) throw includedParameter.error;
|
|
const includedResult = nativeValueTraceSchema.parse(
|
|
includedParameter.value,
|
|
);
|
|
expect(includedResult).toMatchObject({
|
|
total_nodes: operationCount + 1,
|
|
total_edges: parameterUseCount,
|
|
nodes: [
|
|
expect.objectContaining({
|
|
id: `${procedureAddress}/parameter:0`,
|
|
kind: "parameter",
|
|
}),
|
|
],
|
|
truncated: true,
|
|
});
|
|
expect(includedResult.edges).toHaveLength(parameterUseCount);
|
|
|
|
const omittedParameter = await traceNativeValues(largeAnalysis, {
|
|
procedure: procedureAddress,
|
|
max_nodes: operationCount,
|
|
max_edges: 40_000,
|
|
offset: operationCount - 1,
|
|
limit: 1,
|
|
});
|
|
if (!omittedParameter.ok) throw omittedParameter.error;
|
|
const omittedResult = nativeValueTraceSchema.parse(omittedParameter.value);
|
|
expect(omittedResult).toMatchObject({
|
|
total_nodes: operationCount,
|
|
total_edges: 0,
|
|
nodes: [expect.objectContaining({ kind: "operation" })],
|
|
edges: [],
|
|
truncated: true,
|
|
});
|
|
});
|
|
|
|
it.each([3, 4])(
|
|
"keeps parameter identity separate from colliding operation IDs with max_nodes=%i",
|
|
async (max_nodes) => {
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const address = base.procedure.address;
|
|
const execution = createAnalysisExecution(
|
|
{
|
|
...base,
|
|
native_value_flow: {
|
|
available: true,
|
|
provenance: "ghidra-high-pcode",
|
|
operations: [
|
|
node("parameter:0", address, "COPY"),
|
|
node("parameter:0#1", address, "COPY"),
|
|
node("use", address, "COPY"),
|
|
],
|
|
def_use: [],
|
|
effects: [],
|
|
parameters: [{ ordinal: 0, name: "value", data_type: "/int" }],
|
|
parameter_uses: [{ ordinal: 0, use: "use", input_index: 1 }],
|
|
truncated: false,
|
|
omitted_operations_lower_bound: 0,
|
|
known_omitted_inputs: 0,
|
|
known_omitted_edges: 0,
|
|
limitations: [],
|
|
},
|
|
},
|
|
provider,
|
|
{ subject },
|
|
);
|
|
const result = await traceNativeValues(
|
|
{ execute: async () => ok(execution) },
|
|
{ procedure: address, max_nodes },
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
const parsedResult = nativeValueTraceSchema.parse(result.value);
|
|
expect(parsedResult).toMatchObject({
|
|
total_nodes: max_nodes,
|
|
total_edges: max_nodes === 3 ? 0 : 1,
|
|
edges:
|
|
max_nodes === 3
|
|
? []
|
|
: [
|
|
{
|
|
source: `${address}/parameter:0#2`,
|
|
target: `${address}/use`,
|
|
kind: "parameter-use",
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
parsedResult.nodes
|
|
.filter(({ kind }) => kind === "operation")
|
|
.map(({ id }) => id),
|
|
).toEqual([
|
|
`${address}/parameter:0`,
|
|
`${address}/parameter:0#1`,
|
|
`${address}/use`,
|
|
]);
|
|
expect(new Set(parsedResult.nodes.map(({ id }) => id)).size).toBe(
|
|
max_nodes,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
const nativeFlowDossier = (
|
|
base: ReturnType<typeof functionDossierSchema.parse>,
|
|
facts: {
|
|
address: string;
|
|
name: string;
|
|
operations: ReturnType<typeof node>[];
|
|
parameters: { ordinal: number; name: string; data_type: string }[];
|
|
},
|
|
) => {
|
|
const { address, name, operations, parameters } = facts;
|
|
return jsonValueSchema.parse({
|
|
...base,
|
|
procedure: {
|
|
address,
|
|
name,
|
|
classification: null,
|
|
body: {
|
|
available: false,
|
|
reason: "The provider did not report complete function body ranges.",
|
|
},
|
|
signature: null,
|
|
locals: [],
|
|
},
|
|
native_value_flow: {
|
|
available: true,
|
|
provenance: "ghidra-high-pcode",
|
|
operations,
|
|
def_use: [],
|
|
effects: [],
|
|
parameters,
|
|
parameter_uses: [],
|
|
truncated: false,
|
|
omitted_operations_lower_bound: 0,
|
|
known_omitted_inputs: 0,
|
|
known_omitted_edges: 0,
|
|
limitations: [],
|
|
},
|
|
});
|
|
};
|
|
|
|
const callBindingAnalysis = (input: {
|
|
readonly callerAddress: string;
|
|
readonly calleeAddress: string;
|
|
readonly caller: ReturnType<typeof jsonValueSchema.parse>;
|
|
readonly callee: ReturnType<typeof jsonValueSchema.parse>;
|
|
readonly targetLimitations?: readonly string[];
|
|
}): AnalysisOperationPort => ({
|
|
execute: async (operation, parameters) => {
|
|
if (operation === "resolve_native_call_targets")
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
call_site: String(parameters.address),
|
|
procedure: input.calleeAddress,
|
|
status: "direct",
|
|
mechanism: "direct",
|
|
targets: [
|
|
{
|
|
address: input.calleeAddress,
|
|
procedure: input.calleeAddress,
|
|
status: "direct",
|
|
basis: "provider-reference",
|
|
references: [],
|
|
},
|
|
],
|
|
limitations: [...(input.targetLimitations ?? [])],
|
|
},
|
|
provider,
|
|
{ subject },
|
|
),
|
|
);
|
|
if (operation !== "analyze_function")
|
|
throw new Error(`Unexpected native trace operation: ${operation}`);
|
|
if (
|
|
parameters.procedure !== input.callerAddress &&
|
|
parameters.procedure !== input.calleeAddress
|
|
)
|
|
throw new Error(`Unexpected procedure: ${String(parameters.procedure)}`);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
parameters.procedure === input.callerAddress
|
|
? input.caller
|
|
: input.callee,
|
|
provider,
|
|
{ subject },
|
|
),
|
|
);
|
|
},
|
|
});
|
|
|
|
describe("native value failed-callee traversal", () => {
|
|
it("attempts a repeated failed selector once and still analyzes a later callee", async () => {
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const caller = nativeFlowDossier(base, {
|
|
address: "0x1000",
|
|
name: "caller",
|
|
operations: [
|
|
node("bad-1", "0x1100", "CALL"),
|
|
node("bad-2", "0x1104", "CALL"),
|
|
node("good", "0x1108", "CALL"),
|
|
],
|
|
parameters: [],
|
|
});
|
|
const good = nativeFlowDossier(base, {
|
|
address: "0x3000",
|
|
name: "good",
|
|
operations: [],
|
|
parameters: [],
|
|
});
|
|
const attempts: string[] = [];
|
|
const port: AnalysisOperationPort = {
|
|
execute: async (operation, parameters) => {
|
|
if (operation === "resolve_native_call_targets") {
|
|
const destination =
|
|
parameters.address === "0x1108" ? "0x3000" : "0x2000";
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
call_site: String(parameters.address),
|
|
procedure: "0x1000",
|
|
status: "direct",
|
|
mechanism: "direct",
|
|
targets: [
|
|
{
|
|
address: destination,
|
|
procedure: destination,
|
|
status: "direct",
|
|
basis: "provider-reference",
|
|
references: [],
|
|
},
|
|
],
|
|
limitations: [],
|
|
},
|
|
provider,
|
|
{ subject },
|
|
),
|
|
);
|
|
}
|
|
expect(operation).toBe("analyze_function");
|
|
const selector = String(parameters.procedure);
|
|
attempts.push(selector);
|
|
if (selector === "0x2000")
|
|
return err(
|
|
new AnalysisOutputError("analyze_function", "Callee unavailable"),
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
selector === "0x1000" ? caller : good,
|
|
provider,
|
|
{ subject },
|
|
),
|
|
);
|
|
},
|
|
};
|
|
const expectedCalls = [
|
|
{ source: "0x1000/bad-1", target: "0x2000" },
|
|
{ source: "0x1000/bad-2", target: "0x2000" },
|
|
{ source: "0x1000/good", target: "0x3000" },
|
|
];
|
|
// A new invocation retries the failed selector: failures are not cached
|
|
// across traces, and successful procedure identity remains separate.
|
|
for (let run = 0; run < 2; run++) {
|
|
attempts.length = 0;
|
|
const result = await traceNativeValues(port, {
|
|
procedure: "0x1000",
|
|
max_functions: 3,
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
const trace = nativeValueTraceSchema.parse(result.value);
|
|
expect(attempts).toEqual(["0x1000", "0x2000", "0x3000"]);
|
|
expect(trace.decompilations).toBe(3);
|
|
expect(trace.procedures.map(({ address }) => address)).toEqual([
|
|
"0x1000",
|
|
"0x3000",
|
|
]);
|
|
expect(trace.unknowns).toContainEqual({
|
|
procedure: "0x2000",
|
|
address: null,
|
|
reason: expect.stringContaining("Callee unavailable"),
|
|
});
|
|
expect(
|
|
trace.edges
|
|
.filter(({ kind }) => kind === "call")
|
|
.map(({ source, target }) => ({ source, target })),
|
|
).toEqual(expectedCalls);
|
|
expect(trace.truncated).toBe(false);
|
|
}
|
|
const capped = await traceNativeValues(port, {
|
|
procedure: "0x1000",
|
|
max_functions: 3,
|
|
max_edges: 2,
|
|
});
|
|
if (!capped.ok) throw capped.error;
|
|
const cappedTrace = nativeValueTraceSchema.parse(capped.value);
|
|
expect(
|
|
cappedTrace.edges.map(({ source, target }) => ({ source, target })),
|
|
).toEqual(expectedCalls.slice(0, 2));
|
|
expect(cappedTrace.truncated).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("native value call-binding fan-out", () => {
|
|
it("retains limitations embedded in resolved call-target results", async () => {
|
|
const callerAddress = "0x1000";
|
|
const calleeAddress = "0x2000";
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const caller = nativeFlowDossier(base, {
|
|
address: callerAddress,
|
|
name: "caller",
|
|
operations: [node("call", callerAddress, "CALL")],
|
|
parameters: [],
|
|
});
|
|
const callee = nativeFlowDossier(base, {
|
|
address: calleeAddress,
|
|
name: "callee",
|
|
operations: [],
|
|
parameters: [],
|
|
});
|
|
const caveat = "Only statically resolved call sites are included.";
|
|
const result = await traceNativeValues(
|
|
callBindingAnalysis({
|
|
callerAddress,
|
|
calleeAddress,
|
|
caller,
|
|
callee,
|
|
targetLimitations: [caveat],
|
|
}),
|
|
{ procedure: callerAddress, max_functions: 2 },
|
|
);
|
|
|
|
if (!result.ok) throw result.error;
|
|
expect(nativeValueTraceSchema.parse(result.value).limitations).toContain(
|
|
`Fixture (fixture 1) resolve_native_call_targets: ${caveat}`,
|
|
);
|
|
});
|
|
|
|
it("preserves binding order and edge-budget behavior for repeated callees", async () => {
|
|
const callCount = 128;
|
|
const callerAddress = "0x1000";
|
|
const calleeAddress = "0x2000";
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const caller = nativeFlowDossier(base, {
|
|
address: callerAddress,
|
|
name: "caller",
|
|
operations: Array.from({ length: callCount }, (_, index) => ({
|
|
...node(
|
|
`call-${index}`,
|
|
`0x${(0x1000 + index * 4).toString(16)}`,
|
|
"CALL",
|
|
),
|
|
sequence: index,
|
|
inputs: [scalar, scalar],
|
|
})),
|
|
parameters: [],
|
|
});
|
|
const callee = nativeFlowDossier(base, {
|
|
address: calleeAddress,
|
|
name: "callee",
|
|
operations: [node("return", calleeAddress, "RETURN")],
|
|
parameters: [{ ordinal: 0, name: "value", data_type: "/int" }],
|
|
});
|
|
const fanoutAnalysis = callBindingAnalysis({
|
|
callerAddress,
|
|
calleeAddress,
|
|
caller,
|
|
callee,
|
|
});
|
|
const expectedEdges = [
|
|
...Array.from({ length: callCount }, (_, index) => ({
|
|
source: `${callerAddress}/call-${index}`,
|
|
target: calleeAddress,
|
|
input_index: null,
|
|
kind: "call",
|
|
})),
|
|
...Array.from({ length: callCount }, (_, index) => [
|
|
{
|
|
source: `${callerAddress}/call-${index}`,
|
|
target: `${calleeAddress}/parameter:0`,
|
|
input_index: 1,
|
|
kind: "argument-binding",
|
|
},
|
|
{
|
|
source: `${calleeAddress}/return`,
|
|
target: `${callerAddress}/call-${index}`,
|
|
input_index: 1,
|
|
kind: "return-binding",
|
|
},
|
|
]).flat(),
|
|
];
|
|
const request = {
|
|
procedure: callerAddress,
|
|
max_functions: 2,
|
|
max_call_sites: callCount,
|
|
max_nodes: callCount + 2,
|
|
max_edges: callCount * 3,
|
|
limit: callCount + 2,
|
|
};
|
|
|
|
const complete = await traceNativeValues(fanoutAnalysis, request);
|
|
if (!complete.ok) throw complete.error;
|
|
const completeResult = nativeValueTraceSchema.parse(complete.value);
|
|
const edgeShape = (edges: typeof completeResult.edges) =>
|
|
edges.map(({ source, target, input_index, kind }) => ({
|
|
source,
|
|
target,
|
|
input_index,
|
|
kind,
|
|
}));
|
|
expect(completeResult).toMatchObject({
|
|
total_nodes: callCount + 2,
|
|
total_edges: callCount * 3,
|
|
truncated: false,
|
|
unknowns: [],
|
|
});
|
|
expect(edgeShape(completeResult.edges)).toEqual(expectedEdges);
|
|
|
|
const capped = await traceNativeValues(fanoutAnalysis, {
|
|
...request,
|
|
max_edges: callCount * 3 - 1,
|
|
});
|
|
if (!capped.ok) throw capped.error;
|
|
const cappedResult = nativeValueTraceSchema.parse(capped.value);
|
|
expect(cappedResult).toMatchObject({
|
|
total_nodes: callCount + 2,
|
|
total_edges: callCount * 3 - 1,
|
|
truncated: true,
|
|
});
|
|
expect(edgeShape(cappedResult.edges)).toEqual(
|
|
expectedEdges.slice(0, callCount * 3 - 1),
|
|
);
|
|
|
|
const nodeCapped = await traceNativeValues(fanoutAnalysis, {
|
|
...request,
|
|
max_nodes: callCount + 1,
|
|
});
|
|
if (!nodeCapped.ok) throw nodeCapped.error;
|
|
const nodeCappedResult = nativeValueTraceSchema.parse(nodeCapped.value);
|
|
expect(nodeCappedResult).toMatchObject({
|
|
total_nodes: callCount + 1,
|
|
total_edges: callCount * 2,
|
|
truncated: true,
|
|
unknowns: Array.from({ length: callCount }, () => ({
|
|
procedure: callerAddress,
|
|
address: null,
|
|
reason: `Parameter bindings for ${calleeAddress} are unavailable or omitted`,
|
|
})),
|
|
});
|
|
expect(edgeShape(nodeCappedResult.edges)).toEqual(
|
|
expectedEdges.filter(({ kind }) => kind !== "argument-binding"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("bounded native value dependency composition", () => {
|
|
it("reports work truncation and stable pagination without traversing ambiguous calls", async () => {
|
|
const bounded = await traceNativeValues(analysis, {
|
|
procedure: "0x1000",
|
|
max_functions: 1,
|
|
});
|
|
expect(bounded.ok).toBe(true);
|
|
if (bounded.ok)
|
|
expect(bounded.value).toMatchObject({
|
|
truncated: true,
|
|
decompilations: 1,
|
|
});
|
|
const page = await traceNativeValues(analysis, {
|
|
procedure: "0x1000",
|
|
limit: 1,
|
|
});
|
|
expect(page.ok).toBe(true);
|
|
if (page.ok)
|
|
expect(page.value).toMatchObject({ total_nodes: 3, next_offset: 1 });
|
|
const ambiguous = await traceNativeValues(
|
|
{
|
|
execute: async (operation, parameters, options) => {
|
|
if (operation !== "resolve_native_call_targets")
|
|
return analysis.execute(operation, parameters, options);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
call_site: "0x1000",
|
|
procedure: "0x1000",
|
|
status: "ambiguous",
|
|
mechanism: "computed",
|
|
targets: [
|
|
{
|
|
address: "0x2000",
|
|
procedure: "0x2000",
|
|
status: "candidate",
|
|
basis: "provider-reference",
|
|
references: [],
|
|
},
|
|
],
|
|
limitations: [],
|
|
},
|
|
provider,
|
|
{ subject },
|
|
),
|
|
);
|
|
},
|
|
},
|
|
{ procedure: "0x1000" },
|
|
);
|
|
expect(ambiguous.ok).toBe(true);
|
|
if (ambiguous.ok)
|
|
expect(ambiguous.value).toMatchObject({
|
|
decompilations: 1,
|
|
edges: [expect.objectContaining({ status: "candidate" })],
|
|
});
|
|
});
|
|
it("propagates cancellation and rejects cross-target dependencies", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
expect(
|
|
await traceNativeValues(
|
|
analysis,
|
|
{ procedure: "0x1000" },
|
|
controller.signal,
|
|
),
|
|
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
|
const cancelled = await traceNativeValues(
|
|
{
|
|
execute: async () =>
|
|
err(new AnalysisCancelledError("analyze_function")),
|
|
},
|
|
{ procedure: "0x1000" },
|
|
);
|
|
expect(cancelled.ok).toBe(false);
|
|
const mismatch = await traceNativeValues(
|
|
{
|
|
execute: async (operation, parameters, options) => {
|
|
const value = await analysis.execute(operation, parameters, options);
|
|
return value.ok && parameters.procedure === "0x2000"
|
|
? ok({
|
|
...value.value,
|
|
subject: { ...subject, sha256: "b".repeat(64) },
|
|
})
|
|
: value;
|
|
},
|
|
},
|
|
{ procedure: "0x1000" },
|
|
);
|
|
expect(mismatch).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisOutputError" },
|
|
});
|
|
});
|
|
|
|
it("returns typed cancellation when the caller aborts with the final provider reply", async () => {
|
|
const controller = new AbortController();
|
|
const reason = { request: "stop trace" };
|
|
const result = await traceNativeValues(
|
|
{
|
|
execute: async (operation, parameters, options) => {
|
|
const value = await analysis.execute(operation, parameters, options);
|
|
if (parameters.procedure === "0x2000") controller.abort(reason);
|
|
return value;
|
|
},
|
|
},
|
|
{ procedure: "0x1000" },
|
|
controller.signal,
|
|
);
|
|
if (result.ok) throw new Error("Expected cancellation");
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.cause).toBe(reason);
|
|
});
|
|
});
|
|
|
|
describe("native value trace selector origin", () => {
|
|
it("marks only the initial procedure as caller-supplied", async () => {
|
|
const analyzed: { procedure: unknown; origin: unknown }[] = [];
|
|
|
|
const result = await traceNativeValues(
|
|
{
|
|
execute: async (operation, parameters, options) => {
|
|
if (operation === "analyze_function")
|
|
analyzed.push({
|
|
procedure: parameters.procedure,
|
|
origin: options?.selectorOrigin,
|
|
});
|
|
return analysis.execute(operation, parameters, options);
|
|
},
|
|
},
|
|
{ procedure: "0x1000" },
|
|
);
|
|
|
|
expect(result.ok, "the trace completes").toBe(true);
|
|
expect(
|
|
analyzed,
|
|
"the caller's procedure is unmarked and the discovered callee is provider-derived",
|
|
).toEqual([
|
|
{ procedure: "0x1000", origin: undefined },
|
|
{ procedure: "0x2000", origin: "provider" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("native value node byte budgets", () => {
|
|
it("stops admitting parameters after an operation exceeds the byte budget", async () => {
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const value = nativeFlowDossier(base, {
|
|
address: "0x1000",
|
|
name: "caller",
|
|
operations: [node("use", "0x1000", "a".repeat(9 * 1024 * 1024))],
|
|
parameters: [{ ordinal: 0, name: "value", data_type: "/int" }],
|
|
});
|
|
const result = await traceNativeValues(
|
|
{
|
|
execute: async () =>
|
|
ok(createAnalysisExecution(value, provider, { subject })),
|
|
},
|
|
{ procedure: "0x1000" },
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(nativeValueTraceSchema.parse(result.value)).toMatchObject({
|
|
truncated: true,
|
|
total_nodes: 0,
|
|
total_edges: 0,
|
|
nodes: [],
|
|
edges: [],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ label: "plain text", name: "a".repeat(9 * 1024 * 1024) },
|
|
{ label: "JSON escapes", name: "\0".repeat(2 * 1024 * 1024) },
|
|
])("rejects an oversized parameter encoded with $label", async ({ name }) => {
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const value = nativeFlowDossier(base, {
|
|
address: "0x1000",
|
|
name: "caller",
|
|
operations: [
|
|
node("parameter:0", "0x1000", "COPY"),
|
|
node("use", "0x1000", "MULTIEQUAL"),
|
|
],
|
|
parameters: [{ ordinal: 0, name, data_type: "/int" }],
|
|
});
|
|
const parsed = functionDossierSchema.parse(value);
|
|
if (!parsed.native_value_flow?.available)
|
|
throw new Error("Invalid fixture");
|
|
parsed.native_value_flow.parameter_uses = [
|
|
{ ordinal: 0, use: "use", input_index: 0 },
|
|
];
|
|
const result = await traceNativeValues(
|
|
{
|
|
execute: async () =>
|
|
ok(createAnalysisExecution(parsed, provider, { subject })),
|
|
},
|
|
{ procedure: "0x1000" },
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
const trace = nativeValueTraceSchema.parse(result.value);
|
|
expect(trace).toMatchObject({
|
|
truncated: true,
|
|
total_nodes: 2,
|
|
total_edges: 0,
|
|
nodes: [
|
|
expect.objectContaining({ kind: "operation" }),
|
|
expect.objectContaining({ kind: "operation" }),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("shares the byte budget across operation and parameter nodes", async () => {
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const value = nativeFlowDossier(base, {
|
|
address: "0x1000",
|
|
name: "caller",
|
|
operations: [node("use", "0x1000", "MULTIEQUAL")],
|
|
parameters: [0, 1].map((ordinal) => ({
|
|
ordinal,
|
|
name: "a".repeat(5 * 1024 * 1024),
|
|
data_type: "/int",
|
|
})),
|
|
});
|
|
const parsed = functionDossierSchema.parse(value);
|
|
if (!parsed.native_value_flow?.available)
|
|
throw new Error("Invalid fixture");
|
|
parsed.native_value_flow.parameter_uses = [0, 1].map((ordinal) => ({
|
|
ordinal,
|
|
use: "use",
|
|
input_index: ordinal,
|
|
}));
|
|
const result = await traceNativeValues(
|
|
{
|
|
execute: async () =>
|
|
ok(createAnalysisExecution(parsed, provider, { subject })),
|
|
},
|
|
{ procedure: "0x1000" },
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
const trace = nativeValueTraceSchema.parse(result.value);
|
|
expect(trace).toMatchObject({
|
|
truncated: true,
|
|
total_nodes: 2,
|
|
total_edges: 1,
|
|
});
|
|
expect(trace.nodes.map(({ id }) => id)).toEqual([
|
|
"0x1000/use",
|
|
"0x1000/parameter:0",
|
|
]);
|
|
expect(trace.edges[0]?.source).toBe("0x1000/parameter:0");
|
|
const retainedBytes = trace.nodes.reduce(
|
|
(total, value) => total + Buffer.byteLength(JSON.stringify(value)),
|
|
0,
|
|
);
|
|
expect(retainedBytes).toBeLessThanOrEqual(8 * 1024 * 1024);
|
|
});
|
|
});
|
|
|
|
describe("native value byte-bound call bindings", () => {
|
|
it("omits argument bindings to rejected callee parameter nodes", async () => {
|
|
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
|
const caller = nativeFlowDossier(base, {
|
|
address: "0x1000",
|
|
name: "caller",
|
|
operations: [node("call", "0x1000", "CALL")],
|
|
parameters: [],
|
|
});
|
|
const callee = nativeFlowDossier(base, {
|
|
address: "0x2000",
|
|
name: "callee",
|
|
operations: [node("return", "0x2000", "RETURN")],
|
|
parameters: [
|
|
{ ordinal: 0, name: "a".repeat(9 * 1024 * 1024), data_type: "/int" },
|
|
],
|
|
});
|
|
const result = await traceNativeValues(
|
|
callBindingAnalysis({
|
|
callerAddress: "0x1000",
|
|
calleeAddress: "0x2000",
|
|
caller,
|
|
callee,
|
|
}),
|
|
{ procedure: "0x1000" },
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
const trace = nativeValueTraceSchema.parse(result.value);
|
|
expect(trace.truncated).toBe(true);
|
|
expect(trace.nodes.map(({ kind }) => kind)).toEqual([
|
|
"operation",
|
|
"operation",
|
|
]);
|
|
expect(trace.edges.map(({ kind }) => kind)).toEqual([
|
|
"call",
|
|
"return-binding",
|
|
]);
|
|
expect(trace.unknowns).toContainEqual(
|
|
expect.objectContaining({
|
|
reason: "Parameter bindings for 0x2000 are unavailable or omitted",
|
|
}),
|
|
);
|
|
});
|
|
});
|