Files
rea/src/application/native/NativeValueTrace.test.ts
T

869 lines
27 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/hopperValues.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { nativeValueTraceSchema } from "../../domain/native/nativeValueTrace.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.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 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 },
),
);
},
};
// 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")).toHaveLength(3);
expect(trace.truncated).toBe(false);
}
});
});
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" },
});
});
});
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",
}),
);
});
});