mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(native): separate value-trace page omission from traversal truncation (#8309)
trace_native_values set one truncated flag for two different facts: the returned page omits retained nodes, and a budget stopped or narrowed the traversal. A last page of a complete trace and a node- or edge-capped trace both reported truncated: true with next_offset null and no unknown naming the cap, so a caller could not tell whether to follow pages, raise a budget, or treat the examined graph as exhausted. Results now carry page_truncated for page omission and a traversal object that is complete, or truncated with the observed reasons in schema order: max_depth, max_functions, max_call_sites, max_nodes, max_edges, node_payload_bytes, provider_pcode_truncated and ambiguous_call_candidates. truncated keeps its previous meaning as the union of both. A new budget owner admits nodes and edges and records every cause together with a located unknown, only after a budget rejects work; an exactly filled budget is no longer truncation. Depth and ambiguous-candidate limits are resolved after traversal, so a destination analyzed or queued through a shallower path is no longer reported as depth-limited. Once an edge is rejected, remaining call sites in that procedure are not resolved. Fixes #8283 Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c287a73276
commit
73ff6ca0c8
8 files changed
+1108
-192
No files matched your search
@@ -422,6 +422,21 @@ establish canonical function identity. A successful seed without a native def-us
|
||||
model still returns its procedure and an explicit coverage unknown. A new trace
|
||||
can retry failed selectors.
|
||||
|
||||
Paging and traversal completeness are separate facts. `page_truncated` means the
|
||||
page omits retained nodes, and the edges they source, before `offset` or after
|
||||
`next_offset`. Every page repeats `traversal`: `complete`, or `truncated` with
|
||||
`reasons` naming each observed cause: the `max_depth`, `max_functions`,
|
||||
`max_call_sites`, `max_nodes` or `max_edges` budget, the fixed
|
||||
`node_payload_bytes` allowance, `provider_pcode_truncated`, or
|
||||
`ambiguous_call_candidates`. Each cause also has a located unknown. A budget is
|
||||
reported only after it rejects work, so an exactly filled budget is not
|
||||
truncation; a depth or candidate limit is reported only when no other path
|
||||
analyzes or queues that destination. `total_nodes` and `total_edges` count
|
||||
retained rows. `truncated` remains true for either kind of omission. Failed
|
||||
analyses and unresolved calls stay in `unknowns` and do not truncate the
|
||||
traversal. Each page reruns analysis; pages are not pinned to one snapshot
|
||||
across session edits.
|
||||
|
||||
The raw Java `is_dead` flag can remain set after Ghidra decodes a live block;
|
||||
`block_membership` reports actual syntax-tree membership separately.
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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 { ok } from "../../domain/result.js";
|
||||
|
||||
/** Simulated provider seam for value traces; not a real decompiler observation. */
|
||||
export const valueTraceSubject = {
|
||||
path: "/fixture",
|
||||
sha256: "a".repeat(64),
|
||||
format: "mach-o" as const,
|
||||
architecture: "arm64" as const,
|
||||
};
|
||||
export const valueTraceProvider = {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1",
|
||||
};
|
||||
export const pcodeScalar = {
|
||||
kind: "constant",
|
||||
size_bytes: 4,
|
||||
location: null,
|
||||
constant_hex: "3",
|
||||
};
|
||||
|
||||
/** A live one-input, one-output operation; a CALL passes one argument. */
|
||||
export const pcodeOperation = (
|
||||
id: string,
|
||||
address: string,
|
||||
opcode: string,
|
||||
) => ({
|
||||
id,
|
||||
address,
|
||||
sequence: 0,
|
||||
opcode,
|
||||
is_dead: true,
|
||||
block_membership: "member",
|
||||
inputs: [pcodeScalar, pcodeScalar],
|
||||
output: pcodeScalar,
|
||||
});
|
||||
|
||||
export const nativeFlowDossier = (
|
||||
base: ReturnType<typeof functionDossierSchema.parse>,
|
||||
facts: {
|
||||
address: string;
|
||||
name: string;
|
||||
operations: ReturnType<typeof pcodeOperation>[];
|
||||
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: [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
interface StaticProcedure {
|
||||
readonly operations: ReturnType<typeof pcodeOperation>[];
|
||||
readonly parameters?: { ordinal: number; name: string; data_type: string }[];
|
||||
readonly def_use?: { definition: string; use: string; input_index: number }[];
|
||||
readonly pcodeTruncated?: boolean;
|
||||
}
|
||||
|
||||
interface GraphOptions {
|
||||
readonly sessionSubject?: boolean;
|
||||
}
|
||||
|
||||
/** A static call graph whose call sites resolve to the listed destinations. */
|
||||
export const staticValueGraph = (
|
||||
procedures: Readonly<Record<string, StaticProcedure>>,
|
||||
calls: Readonly<
|
||||
Record<
|
||||
string,
|
||||
readonly { destination: string; status: "direct" | "candidate" }[]
|
||||
>
|
||||
>,
|
||||
options: GraphOptions = {},
|
||||
) => {
|
||||
// A production session binds an omitted subject to its opened target.
|
||||
const subject =
|
||||
options.sessionSubject === true ? {} : { subject: valueTraceSubject };
|
||||
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const analyzed: string[] = [];
|
||||
const resolved: string[] = [];
|
||||
const port: AnalysisOperationPort = {
|
||||
execute: async (operation, parameters) => {
|
||||
if (operation === "resolve_native_call_targets") {
|
||||
const address = String(parameters.address);
|
||||
resolved.push(address);
|
||||
const targets = calls[address] ?? [];
|
||||
const ambiguous = targets.some(({ status }) => status === "candidate");
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
call_site: address,
|
||||
procedure: ambiguous ? null : (targets[0]?.destination ?? null),
|
||||
status: ambiguous
|
||||
? "ambiguous"
|
||||
: targets.length === 0
|
||||
? "unresolved"
|
||||
: "direct",
|
||||
mechanism: ambiguous ? "computed" : "direct",
|
||||
targets: targets.map(({ destination, status }) => ({
|
||||
address: destination,
|
||||
procedure: destination,
|
||||
status,
|
||||
basis: "provider-reference",
|
||||
references: [],
|
||||
})),
|
||||
limitations: [],
|
||||
},
|
||||
valueTraceProvider,
|
||||
subject,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (operation !== "analyze_function")
|
||||
throw new Error(`Unexpected native trace operation: ${operation}`);
|
||||
const address = String(parameters.procedure);
|
||||
analyzed.push(address);
|
||||
const facts = procedures[address];
|
||||
if (facts === undefined) throw new Error(`Unexpected ${address}`);
|
||||
const dossier = functionDossierSchema.parse(
|
||||
nativeFlowDossier(base, {
|
||||
address,
|
||||
name: address,
|
||||
operations: facts.operations,
|
||||
parameters: facts.parameters ?? [],
|
||||
}),
|
||||
);
|
||||
if (!dossier.native_value_flow?.available)
|
||||
throw new Error("Invalid fixture");
|
||||
dossier.native_value_flow.def_use = facts.def_use ?? [];
|
||||
if (facts.pcodeTruncated === true) {
|
||||
dossier.native_value_flow.truncated = true;
|
||||
dossier.native_value_flow.omitted_operations_lower_bound = 1;
|
||||
}
|
||||
return ok(createAnalysisExecution(dossier, valueTraceProvider, subject));
|
||||
},
|
||||
};
|
||||
return { port, analyzed, resolved };
|
||||
};
|
||||
|
||||
const copies = (address: string, count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
...pcodeOperation(`op-${String(index)}`, address, "COPY"),
|
||||
sequence: index,
|
||||
}));
|
||||
const parameter = [{ ordinal: 0, name: "value", data_type: "/int" }];
|
||||
|
||||
/**
|
||||
* 0x1000 calls 0x2000 and 0x3000, and 0x2000 also calls 0x3000. With
|
||||
* max_depth 1, each one-argument call binds to its callee's single parameter:
|
||||
* 11 nodes and 9 edges, reached in breadth-first order.
|
||||
*/
|
||||
export const valueTraceDiamond = (options: GraphOptions = {}) =>
|
||||
staticValueGraph(
|
||||
{
|
||||
"0x1000": {
|
||||
operations: [
|
||||
pcodeOperation("call-b", "0x1004", "CALL"),
|
||||
pcodeOperation("call-c", "0x1008", "CALL"),
|
||||
...copies("0x1000", 2),
|
||||
],
|
||||
def_use: [{ definition: "op-0", use: "op-1", input_index: 0 }],
|
||||
},
|
||||
"0x2000": {
|
||||
operations: [
|
||||
pcodeOperation("call-c", "0x2004", "CALL"),
|
||||
pcodeOperation("x", "0x2000", "COPY"),
|
||||
],
|
||||
parameters: parameter,
|
||||
},
|
||||
"0x3000": {
|
||||
operations: copies("0x3000", 3),
|
||||
parameters: parameter,
|
||||
def_use: [
|
||||
{ definition: "op-0", use: "op-1", input_index: 0 },
|
||||
{ definition: "op-1", use: "op-2", input_index: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"0x1004": [{ destination: "0x2000", status: "direct" }],
|
||||
"0x1008": [{ destination: "0x3000", status: "direct" }],
|
||||
"0x2004": [{ destination: "0x3000", status: "direct" }],
|
||||
},
|
||||
options,
|
||||
);
|
||||
@@ -17,30 +17,15 @@ import {
|
||||
} 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,
|
||||
});
|
||||
import {
|
||||
nativeFlowDossier,
|
||||
pcodeOperation as node,
|
||||
pcodeScalar as scalar,
|
||||
staticValueGraph,
|
||||
valueTraceDiamond,
|
||||
valueTraceProvider as provider,
|
||||
valueTraceSubject as subject,
|
||||
} from "./NativeValueTrace.fixture.js";
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: async (operation, parameters) => {
|
||||
if (operation === "resolve_native_call_targets")
|
||||
@@ -247,7 +232,8 @@ describe("native value parameter-use node membership", () => {
|
||||
kind: "parameter",
|
||||
}),
|
||||
],
|
||||
truncated: true,
|
||||
page_truncated: true,
|
||||
traversal: { status: "complete" },
|
||||
});
|
||||
expect(includedResult.edges).toHaveLength(parameterUseCount);
|
||||
|
||||
@@ -265,8 +251,16 @@ describe("native value parameter-use node membership", () => {
|
||||
total_edges: 0,
|
||||
nodes: [expect.objectContaining({ kind: "operation" })],
|
||||
edges: [],
|
||||
truncated: true,
|
||||
page_truncated: true,
|
||||
traversal: { status: "truncated", reasons: ["max_nodes"] },
|
||||
});
|
||||
expect(omittedResult.unknowns).toEqual([
|
||||
{
|
||||
procedure: procedureAddress,
|
||||
address: null,
|
||||
reason: "Node budget reached",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([3, 4])(
|
||||
@@ -336,46 +330,6 @@ describe("native value parameter-use node membership", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -521,7 +475,12 @@ describe("native value failed-callee traversal", () => {
|
||||
.filter(({ kind }) => kind === "call")
|
||||
.map(({ source, target }) => ({ source, target })),
|
||||
).toEqual(expectedCalls);
|
||||
expect(trace.truncated).toBe(false);
|
||||
// A failed callee is an explicit unknown, not a traversal budget stop.
|
||||
expect(trace).toMatchObject({
|
||||
page_truncated: false,
|
||||
traversal: { status: "complete" },
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
const capped = await traceNativeValues(port, {
|
||||
procedure: "0x1000",
|
||||
@@ -533,7 +492,16 @@ describe("native value failed-callee traversal", () => {
|
||||
expect(
|
||||
cappedTrace.edges.map(({ source, target }) => ({ source, target })),
|
||||
).toEqual(expectedCalls.slice(0, 2));
|
||||
expect(cappedTrace.truncated).toBe(true);
|
||||
expect(cappedTrace).toMatchObject({
|
||||
page_truncated: false,
|
||||
traversal: { status: "truncated", reasons: ["max_edges"] },
|
||||
truncated: true,
|
||||
});
|
||||
expect(cappedTrace.unknowns).toContainEqual({
|
||||
procedure: "0x1000",
|
||||
address: null,
|
||||
reason: "Edge budget reached",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -647,7 +615,7 @@ describe("native value call-binding fan-out", () => {
|
||||
expect(completeResult).toMatchObject({
|
||||
total_nodes: callCount + 2,
|
||||
total_edges: callCount * 3,
|
||||
truncated: false,
|
||||
traversal: { status: "complete" },
|
||||
unknowns: [],
|
||||
});
|
||||
expect(edgeShape(completeResult.edges)).toEqual(expectedEdges);
|
||||
@@ -661,7 +629,8 @@ describe("native value call-binding fan-out", () => {
|
||||
expect(cappedResult).toMatchObject({
|
||||
total_nodes: callCount + 2,
|
||||
total_edges: callCount * 3 - 1,
|
||||
truncated: true,
|
||||
traversal: { status: "truncated", reasons: ["max_edges"] },
|
||||
unknowns: [budgetUnknown(callerAddress, "Edge budget reached")],
|
||||
});
|
||||
expect(edgeShape(cappedResult.edges)).toEqual(
|
||||
expectedEdges.slice(0, callCount * 3 - 1),
|
||||
@@ -676,12 +645,15 @@ describe("native value call-binding fan-out", () => {
|
||||
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`,
|
||||
})),
|
||||
traversal: { status: "truncated", reasons: ["max_nodes"] },
|
||||
unknowns: [
|
||||
budgetUnknown(calleeAddress, "Node budget reached"),
|
||||
...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"),
|
||||
@@ -698,6 +670,8 @@ describe("bounded native value dependency composition", () => {
|
||||
expect(bounded.ok).toBe(true);
|
||||
if (bounded.ok)
|
||||
expect(bounded.value).toMatchObject({
|
||||
page_truncated: false,
|
||||
traversal: { status: "truncated", reasons: ["max_functions"] },
|
||||
truncated: true,
|
||||
decompilations: 1,
|
||||
});
|
||||
@@ -707,7 +681,12 @@ describe("bounded native value dependency composition", () => {
|
||||
});
|
||||
expect(page.ok).toBe(true);
|
||||
if (page.ok)
|
||||
expect(page.value).toMatchObject({ total_nodes: 3, next_offset: 1 });
|
||||
expect(page.value).toMatchObject({
|
||||
total_nodes: 3,
|
||||
next_offset: 1,
|
||||
page_truncated: true,
|
||||
traversal: { status: "complete" },
|
||||
});
|
||||
const ambiguous = await traceNativeValues(
|
||||
{
|
||||
execute: async (operation, parameters, options) => {
|
||||
@@ -744,6 +723,10 @@ describe("bounded native value dependency composition", () => {
|
||||
expect(ambiguous.value).toMatchObject({
|
||||
decompilations: 1,
|
||||
edges: [expect.objectContaining({ status: "candidate" })],
|
||||
traversal: {
|
||||
status: "truncated",
|
||||
reasons: ["ambiguous_call_candidates"],
|
||||
},
|
||||
});
|
||||
});
|
||||
it("propagates cancellation and rejects cross-target dependencies", async () => {
|
||||
@@ -851,11 +834,19 @@ describe("native value node byte budgets", () => {
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(nativeValueTraceSchema.parse(result.value)).toMatchObject({
|
||||
traversal: { status: "truncated", reasons: ["node_payload_bytes"] },
|
||||
truncated: true,
|
||||
total_nodes: 0,
|
||||
total_edges: 0,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
unknowns: [
|
||||
{
|
||||
procedure: "0x1000",
|
||||
address: null,
|
||||
reason: "Node payload budget reached",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -889,6 +880,7 @@ describe("native value node byte budgets", () => {
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeValueTraceSchema.parse(result.value);
|
||||
expect(trace).toMatchObject({
|
||||
traversal: { status: "truncated", reasons: ["node_payload_bytes"] },
|
||||
truncated: true,
|
||||
total_nodes: 2,
|
||||
total_edges: 0,
|
||||
@@ -929,6 +921,7 @@ describe("native value node byte budgets", () => {
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeValueTraceSchema.parse(result.value);
|
||||
expect(trace).toMatchObject({
|
||||
traversal: { status: "truncated", reasons: ["node_payload_bytes"] },
|
||||
truncated: true,
|
||||
total_nodes: 2,
|
||||
total_edges: 1,
|
||||
@@ -974,7 +967,10 @@ describe("native value byte-bound call bindings", () => {
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeValueTraceSchema.parse(result.value);
|
||||
expect(trace.truncated).toBe(true);
|
||||
expect(trace).toMatchObject({
|
||||
traversal: { status: "truncated", reasons: ["node_payload_bytes"] },
|
||||
truncated: true,
|
||||
});
|
||||
expect(trace.nodes.map(({ kind }) => kind)).toEqual([
|
||||
"operation",
|
||||
"operation",
|
||||
@@ -1041,3 +1037,293 @@ describe("native value validation failure provenance", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const budgetUnknown = (procedure: string, reason: string) => ({
|
||||
procedure,
|
||||
address: null,
|
||||
reason,
|
||||
});
|
||||
|
||||
const traced = async (
|
||||
port: AnalysisOperationPort,
|
||||
request: Record<string, unknown>,
|
||||
) => {
|
||||
const result = await traceNativeValues(port, request);
|
||||
if (!result.ok) throw result.error;
|
||||
return nativeValueTraceSchema.parse(result.value);
|
||||
};
|
||||
|
||||
const omittedBindings = (callee: string, callers: readonly string[]) =>
|
||||
callers.map((procedure) =>
|
||||
budgetUnknown(
|
||||
procedure,
|
||||
`Parameter bindings for ${callee} are unavailable or omitted`,
|
||||
),
|
||||
);
|
||||
const edgeKey = (edge: { source: string; target: string; kind: string }) =>
|
||||
`${edge.kind} ${edge.source} ${edge.target}`;
|
||||
|
||||
describe("native value trace page omission", () => {
|
||||
it("assembles every page of a complete trace without reporting traversal truncation", async () => {
|
||||
const { port } = valueTraceDiamond();
|
||||
const request = { procedure: "0x1000", max_depth: 1 };
|
||||
const complete = await traced(port, request);
|
||||
expect(complete).toMatchObject({
|
||||
total_nodes: 11,
|
||||
total_edges: 9,
|
||||
next_offset: null,
|
||||
page_truncated: false,
|
||||
traversal: { status: "complete" },
|
||||
truncated: false,
|
||||
unknowns: [],
|
||||
});
|
||||
const pages: (typeof complete)[] = [];
|
||||
for (let offset: number | null = 0; offset !== null;) {
|
||||
const page = await traced(port, { ...request, offset, limit: 2 });
|
||||
pages.push(page);
|
||||
offset = page.next_offset;
|
||||
}
|
||||
expect(pages).toHaveLength(6);
|
||||
for (const page of pages)
|
||||
expect(page).toMatchObject({
|
||||
total_nodes: 11,
|
||||
total_edges: 9,
|
||||
page_truncated: true,
|
||||
traversal: { status: "complete" },
|
||||
truncated: true,
|
||||
unknowns: [],
|
||||
});
|
||||
expect(pages.at(-1)).toMatchObject({ next_offset: null, nodes: [{}] });
|
||||
expect(pages.flatMap(({ nodes }) => nodes)).toEqual(complete.nodes);
|
||||
expect(pages.flatMap(({ edges }) => edges.map(edgeKey)).sort()).toEqual(
|
||||
complete.edges.map(edgeKey).sort(),
|
||||
);
|
||||
|
||||
expect(await traced(port, { ...request, limit: 11 })).toMatchObject({
|
||||
next_offset: null,
|
||||
page_truncated: false,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a page beyond the retained nodes separate from traversal truncation", async () => {
|
||||
const { port } = valueTraceDiamond();
|
||||
expect(
|
||||
await traced(port, { procedure: "0x1000", max_depth: 1, offset: 100 }),
|
||||
).toMatchObject({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
total_nodes: 11,
|
||||
next_offset: null,
|
||||
page_truncated: true,
|
||||
traversal: { status: "complete" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report an exactly filled node or edge budget as truncation", async () => {
|
||||
const { port } = valueTraceDiamond();
|
||||
expect(
|
||||
await traced(port, {
|
||||
procedure: "0x1000",
|
||||
max_depth: 1,
|
||||
max_nodes: 11,
|
||||
max_edges: 9,
|
||||
}),
|
||||
).toMatchObject({
|
||||
total_nodes: 11,
|
||||
total_edges: 9,
|
||||
traversal: { status: "complete" },
|
||||
truncated: false,
|
||||
unknowns: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native value traversal budgets", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "node",
|
||||
budgets: { max_nodes: 9 },
|
||||
reasons: ["max_nodes"],
|
||||
unknowns: [
|
||||
{ procedure: "0x3000", address: null, reason: "Node budget reached" },
|
||||
...omittedBindings("0x3000", ["0x1000", "0x2000"]),
|
||||
],
|
||||
nodes: 9,
|
||||
},
|
||||
{
|
||||
label: "edge",
|
||||
budgets: { max_edges: 2 },
|
||||
reasons: ["max_edges"],
|
||||
unknowns: [
|
||||
{ procedure: "0x1000", address: null, reason: "Edge budget reached" },
|
||||
...omittedBindings("0x2000", ["0x1000"]),
|
||||
],
|
||||
nodes: 4,
|
||||
},
|
||||
{
|
||||
label: "node and edge",
|
||||
budgets: { max_nodes: 9, max_edges: 4 },
|
||||
reasons: ["max_nodes", "max_edges"],
|
||||
unknowns: [
|
||||
{ procedure: "0x3000", address: null, reason: "Node budget reached" },
|
||||
{ procedure: "0x3000", address: null, reason: "Edge budget reached" },
|
||||
...omittedBindings("0x3000", ["0x1000", "0x2000"]),
|
||||
],
|
||||
nodes: 9,
|
||||
},
|
||||
])(
|
||||
"names the observed $label budget on every page, including the last",
|
||||
async ({ budgets, reasons, unknowns, nodes }) => {
|
||||
const { port } = valueTraceDiamond();
|
||||
const request = { procedure: "0x1000", max_depth: 1, ...budgets };
|
||||
const unpaged = await traced(port, request);
|
||||
expect(unpaged).toMatchObject({
|
||||
total_nodes: nodes,
|
||||
next_offset: null,
|
||||
page_truncated: false,
|
||||
traversal: { status: "truncated", reasons },
|
||||
truncated: true,
|
||||
unknowns,
|
||||
});
|
||||
const last = await traced(port, {
|
||||
...request,
|
||||
offset: nodes - 1,
|
||||
limit: 3,
|
||||
});
|
||||
expect(last).toMatchObject({
|
||||
nodes: [{ id: unpaged.nodes.at(-1)?.id }],
|
||||
next_offset: null,
|
||||
page_truncated: true,
|
||||
traversal: { status: "truncated", reasons },
|
||||
truncated: true,
|
||||
unknowns,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("stops resolving call sites once the edge budget rejects a call", async () => {
|
||||
const { port, resolved, analyzed } = valueTraceDiamond();
|
||||
const trace = await traced(port, {
|
||||
procedure: "0x1000",
|
||||
max_depth: 1,
|
||||
max_edges: 1,
|
||||
});
|
||||
expect(trace).toMatchObject({
|
||||
traversal: { status: "truncated", reasons: ["max_edges"] },
|
||||
unknowns: [
|
||||
{ procedure: "0x1000", address: null, reason: "Edge budget reached" },
|
||||
],
|
||||
});
|
||||
expect(trace.edges.map(({ kind }) => kind)).toEqual(["def-use"]);
|
||||
expect(resolved).toEqual(["0x1004"]);
|
||||
expect(analyzed).toEqual(["0x1000"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native value traversal limits and policies", () => {
|
||||
it("reports a depth limit only when no shallower path analyzes the destination", async () => {
|
||||
const shared = valueTraceDiamond();
|
||||
expect(
|
||||
await traced(shared.port, { procedure: "0x1000", max_depth: 1 }),
|
||||
).toMatchObject({ traversal: { status: "complete" }, unknowns: [] });
|
||||
// 0x2000 reaches 0x3000 at the depth limit before the seed's own call does.
|
||||
expect(shared.analyzed).toEqual(["0x1000", "0x2000", "0x3000"]);
|
||||
|
||||
expect(
|
||||
await traced(valueTraceDiamond().port, {
|
||||
procedure: "0x1000",
|
||||
max_depth: 0,
|
||||
}),
|
||||
).toMatchObject({
|
||||
traversal: { status: "truncated", reasons: ["max_depth"] },
|
||||
unknowns: [
|
||||
{
|
||||
procedure: "0x1000",
|
||||
address: "0x1004",
|
||||
reason: "Call depth budget reached",
|
||||
},
|
||||
{
|
||||
procedure: "0x1000",
|
||||
address: "0x1008",
|
||||
reason: "Call depth budget reached",
|
||||
},
|
||||
...omittedBindings("0x2000", ["0x1000"]),
|
||||
...omittedBindings("0x3000", ["0x1000"]),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("attributes a queued destination to the decompilation budget, not depth", async () => {
|
||||
expect(
|
||||
await traced(valueTraceDiamond().port, {
|
||||
procedure: "0x1000",
|
||||
max_depth: 1,
|
||||
max_functions: 2,
|
||||
}),
|
||||
).toMatchObject({
|
||||
traversal: { status: "truncated", reasons: ["max_functions"] },
|
||||
unknowns: [
|
||||
{
|
||||
procedure: "0x3000",
|
||||
address: null,
|
||||
reason: "decompilation budget reached",
|
||||
},
|
||||
...omittedBindings("0x3000", ["0x1000", "0x2000"]),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("names call-site, provider p-code and ambiguous-candidate limits", async () => {
|
||||
expect(
|
||||
await traced(valueTraceDiamond().port, {
|
||||
procedure: "0x1000",
|
||||
max_depth: 1,
|
||||
max_call_sites: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
traversal: { status: "truncated", reasons: ["max_call_sites"] },
|
||||
unknowns: [
|
||||
{
|
||||
procedure: "0x1000",
|
||||
address: "0x1008",
|
||||
reason: "Call-site resolution budget reached",
|
||||
},
|
||||
{
|
||||
procedure: "0x2000",
|
||||
address: "0x2004",
|
||||
reason: "Call-site resolution budget reached",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const limited = staticValueGraph(
|
||||
{
|
||||
"0x1000": {
|
||||
operations: [node("call", "0x1004", "CALL")],
|
||||
pcodeTruncated: true,
|
||||
},
|
||||
},
|
||||
{ "0x1004": [{ destination: "0x2000", status: "candidate" }] },
|
||||
);
|
||||
expect(await traced(limited.port, { procedure: "0x1000" })).toMatchObject({
|
||||
traversal: {
|
||||
status: "truncated",
|
||||
reasons: ["provider_pcode_truncated", "ambiguous_call_candidates"],
|
||||
},
|
||||
unknowns: [
|
||||
{
|
||||
procedure: "0x1000",
|
||||
address: null,
|
||||
reason: "Provider p-code was truncated",
|
||||
},
|
||||
{
|
||||
procedure: "0x1000",
|
||||
address: "0x1004",
|
||||
reason: "Ambiguous call candidates were not traversed",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(limited.analyzed).toEqual(["0x1000"]);
|
||||
});
|
||||
});
|
||||
@@ -12,13 +12,11 @@ import {
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "../EnhancedToolTypes.js";
|
||||
import { jsonParts } from "../../domain/jsonSerialization.js";
|
||||
import {
|
||||
renderSourceLimitations,
|
||||
type SourceLimitation,
|
||||
} from "../SourceLimitations.js";
|
||||
|
||||
const NODE_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
||||
import { createValueTraceBudget } from "./NativeValueTraceBudget.js";
|
||||
|
||||
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
|
||||
export const traceNativeValues = async (
|
||||
@@ -27,8 +25,6 @@ export const traceNativeValues = async (
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult => {
|
||||
const input = nativeValueTraceInputSchema.parse(parameters);
|
||||
const nodes: typeof nativeValueTraceSchema._output.nodes = [];
|
||||
const edges: typeof nativeValueTraceSchema._output.edges = [];
|
||||
const procedures: typeof nativeValueTraceSchema._output.procedures = [];
|
||||
const unknowns: typeof nativeValueTraceSchema._output.unknowns = [];
|
||||
const sourceLimitations: SourceLimitation[] = [];
|
||||
@@ -43,34 +39,19 @@ export const traceNativeValues = async (
|
||||
const pending = [{ procedure: input.procedure, depth: 0 }];
|
||||
const attempted = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
// Calls not followed because of depth or candidate policy. They narrow the
|
||||
// traversal only if no other path analyzes or queues the destination.
|
||||
const unexpanded: {
|
||||
caller: string;
|
||||
address: string;
|
||||
destination: string;
|
||||
reason: "max_depth" | "ambiguous_call_candidates";
|
||||
}[] = [];
|
||||
const budget = createValueTraceBudget(input, unknowns);
|
||||
const { nodes, edges } = budget;
|
||||
let targetSha256: string | undefined;
|
||||
let truncated = false;
|
||||
let decompilations = 0;
|
||||
let callSites = 0;
|
||||
let bytes = 0;
|
||||
let nodeBudgetReached = false;
|
||||
const retainNode = (value: (typeof nodes)[number]): boolean => {
|
||||
if (nodeBudgetReached) return false;
|
||||
if (nodes.length >= input.max_nodes) {
|
||||
truncated = true;
|
||||
nodeBudgetReached = true;
|
||||
return false;
|
||||
}
|
||||
let size = 0;
|
||||
// Reuse bounded JSON encoding to count escapes and UTF-8 expansion
|
||||
// before allocating a complete serialized node that might not fit.
|
||||
for (const part of jsonParts(value)) {
|
||||
size += Buffer.byteLength(part);
|
||||
if (size > NODE_PAYLOAD_BYTES - bytes) {
|
||||
truncated = true;
|
||||
nodeBudgetReached = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bytes += size;
|
||||
nodes.push(value);
|
||||
return true;
|
||||
};
|
||||
while (pending.length > 0) {
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
@@ -86,12 +67,14 @@ export const traceNativeValues = async (
|
||||
)
|
||||
continue;
|
||||
if (decompilations >= input.max_functions) {
|
||||
truncated = true;
|
||||
unknowns.push({
|
||||
budget.narrow("max_functions", {
|
||||
procedure: next.procedure,
|
||||
address: null,
|
||||
reason: "decompilation budget reached",
|
||||
});
|
||||
// Keep the unanalyzed selector queued so later accounting attributes
|
||||
// it to this budget rather than to a depth limit.
|
||||
pending.unshift(next);
|
||||
break;
|
||||
}
|
||||
// An exact selector is attempted once per trace, even on failure. Only
|
||||
@@ -163,14 +146,12 @@ export const traceNativeValues = async (
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (flow.truncated) {
|
||||
truncated = true;
|
||||
unknowns.push({
|
||||
if (flow.truncated)
|
||||
budget.narrow("provider_pcode_truncated", {
|
||||
procedure: address,
|
||||
address: null,
|
||||
reason: "Provider p-code was truncated",
|
||||
});
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
// Keep generated parameter identity independent of which operations fit
|
||||
// the budget, while preserving every provider-supplied operation ID.
|
||||
@@ -191,7 +172,7 @@ export const traceNativeValues = async (
|
||||
kind: "operation" as const,
|
||||
parameter: null,
|
||||
};
|
||||
if (!retainNode(node)) break;
|
||||
if (!budget.retainNode(node)) break;
|
||||
ids.add(operation.id);
|
||||
}
|
||||
for (const parameter of flow.parameters) {
|
||||
@@ -201,7 +182,7 @@ export const traceNativeValues = async (
|
||||
localId = `${parameterId}#${suffix}`;
|
||||
const id = qualified(localId);
|
||||
if (
|
||||
!retainNode({
|
||||
!budget.retainNode({
|
||||
id,
|
||||
procedure: address,
|
||||
evidence_id: evidenceId,
|
||||
@@ -216,33 +197,37 @@ export const traceNativeValues = async (
|
||||
for (const use of flow.parameter_uses) {
|
||||
const source = parameterIds.get(use.ordinal);
|
||||
if (!ids.has(use.use) || source === undefined) continue;
|
||||
if (edges.length >= input.max_edges) {
|
||||
truncated = true;
|
||||
if (
|
||||
!budget.retainEdge(
|
||||
{
|
||||
source,
|
||||
target: qualified(use.use),
|
||||
input_index: use.input_index,
|
||||
kind: "parameter-use",
|
||||
status: "derived",
|
||||
evidence_id: evidenceId,
|
||||
},
|
||||
address,
|
||||
)
|
||||
)
|
||||
break;
|
||||
}
|
||||
edges.push({
|
||||
source,
|
||||
target: qualified(use.use),
|
||||
input_index: use.input_index,
|
||||
kind: "parameter-use",
|
||||
status: "derived",
|
||||
evidence_id: evidenceId,
|
||||
});
|
||||
}
|
||||
for (const edge of flow.def_use) {
|
||||
if (!ids.has(edge.definition) || !ids.has(edge.use)) continue;
|
||||
if (edges.length >= input.max_edges) {
|
||||
truncated = true;
|
||||
if (
|
||||
!budget.retainEdge(
|
||||
{
|
||||
source: qualified(edge.definition),
|
||||
target: qualified(edge.use),
|
||||
input_index: edge.input_index,
|
||||
kind: "def-use",
|
||||
status: "derived",
|
||||
evidence_id: evidenceId,
|
||||
},
|
||||
address,
|
||||
)
|
||||
)
|
||||
break;
|
||||
}
|
||||
edges.push({
|
||||
source: qualified(edge.definition),
|
||||
target: qualified(edge.use),
|
||||
input_index: edge.input_index,
|
||||
kind: "def-use",
|
||||
status: "derived",
|
||||
evidence_id: evidenceId,
|
||||
});
|
||||
}
|
||||
for (const operation of flow.operations) {
|
||||
if (
|
||||
@@ -250,9 +235,10 @@ export const traceNativeValues = async (
|
||||
!["CALL", "CALLIND"].includes(operation.opcode)
|
||||
)
|
||||
continue;
|
||||
// A full edge budget cannot retain this call or expand its destination.
|
||||
if (budget.edgesExhausted()) break;
|
||||
if (callSites >= input.max_call_sites) {
|
||||
truncated = true;
|
||||
unknowns.push({
|
||||
budget.narrow("max_call_sites", {
|
||||
procedure: address,
|
||||
address: operation.address,
|
||||
reason: "Call-site resolution budget reached",
|
||||
@@ -305,19 +291,21 @@ export const traceNativeValues = async (
|
||||
reason: `Call target ${targets.data.status}`,
|
||||
});
|
||||
for (const target of targets.data.targets) {
|
||||
if (edges.length >= input.max_edges) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const destination = target.procedure ?? target.address;
|
||||
edges.push({
|
||||
source: qualified(operation.id),
|
||||
target: destination,
|
||||
input_index: null,
|
||||
kind: "call",
|
||||
status: target.status,
|
||||
evidence_id: evidenceId,
|
||||
});
|
||||
if (
|
||||
!budget.retainEdge(
|
||||
{
|
||||
source: qualified(operation.id),
|
||||
target: destination,
|
||||
input_index: null,
|
||||
kind: "call",
|
||||
status: target.status,
|
||||
evidence_id: evidenceId,
|
||||
},
|
||||
address,
|
||||
)
|
||||
)
|
||||
break;
|
||||
if (target.status !== "candidate")
|
||||
callBindings.push({
|
||||
caller: address,
|
||||
@@ -329,27 +317,20 @@ export const traceNativeValues = async (
|
||||
});
|
||||
if (next.depth < input.max_depth && target.status !== "candidate")
|
||||
pending.push({ procedure: destination, depth: next.depth + 1 });
|
||||
else if (!visited.has(destination)) {
|
||||
truncated = true;
|
||||
unknowns.push({
|
||||
procedure: address,
|
||||
else
|
||||
unexpanded.push({
|
||||
caller: address,
|
||||
address: operation.address,
|
||||
destination,
|
||||
reason:
|
||||
target.status === "candidate"
|
||||
? "Ambiguous call candidates were not traversed"
|
||||
: "Call depth budget reached",
|
||||
? "ambiguous_call_candidates"
|
||||
: "max_depth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
truncated &&
|
||||
(nodeBudgetReached ||
|
||||
nodes.length >= input.max_nodes ||
|
||||
edges.length >= input.max_edges ||
|
||||
bytes >= NODE_PAYLOAD_BYTES)
|
||||
)
|
||||
break;
|
||||
// Remaining work is attributed to the rejecting budget recorded above.
|
||||
if (budget.exhausted()) break;
|
||||
}
|
||||
if (targetSha256 === undefined)
|
||||
return err(
|
||||
@@ -358,6 +339,26 @@ export const traceNativeValues = async (
|
||||
"No seed function could be analyzed",
|
||||
),
|
||||
);
|
||||
// A destination queued at a lower depth is omitted by the budget that ended
|
||||
// traversal, and one already attempted was not narrowed by this call.
|
||||
const queued = new Set(pending.map(({ procedure }) => procedure));
|
||||
for (const skipped of unexpanded) {
|
||||
const { destination } = skipped;
|
||||
if (
|
||||
visited.has(destination) ||
|
||||
attempted.has(destination) ||
|
||||
queued.has(destination)
|
||||
)
|
||||
continue;
|
||||
budget.narrow(skipped.reason, {
|
||||
procedure: skipped.caller,
|
||||
address: skipped.address,
|
||||
reason:
|
||||
skipped.reason === "ambiguous_call_candidates"
|
||||
? "Ambiguous call candidates were not traversed"
|
||||
: "Call depth budget reached",
|
||||
});
|
||||
}
|
||||
const parametersByProcedure = new Map<string, typeof nodes>();
|
||||
const returnsByProcedure = new Map<string, typeof nodes>();
|
||||
for (const node of nodes) {
|
||||
@@ -392,37 +393,45 @@ export const traceNativeValues = async (
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (edges.length >= input.max_edges) {
|
||||
truncated = true;
|
||||
if (
|
||||
!budget.retainEdge(
|
||||
{
|
||||
source: binding.call,
|
||||
target: node.id,
|
||||
input_index: ordinal + 1,
|
||||
kind: "argument-binding",
|
||||
status: "derived",
|
||||
evidence_id: binding.evidence_id,
|
||||
},
|
||||
binding.caller,
|
||||
)
|
||||
)
|
||||
break;
|
||||
}
|
||||
edges.push({
|
||||
source: binding.call,
|
||||
target: node.id,
|
||||
input_index: ordinal + 1,
|
||||
kind: "argument-binding",
|
||||
status: "derived",
|
||||
evidence_id: binding.evidence_id,
|
||||
});
|
||||
}
|
||||
if (binding.output)
|
||||
for (const node of returnsByProcedure.get(binding.callee) ?? []) {
|
||||
if (edges.length >= input.max_edges) {
|
||||
truncated = true;
|
||||
if (
|
||||
!budget.retainEdge(
|
||||
{
|
||||
source: node.id,
|
||||
target: binding.call,
|
||||
input_index: 1,
|
||||
kind: "return-binding",
|
||||
status: "derived",
|
||||
evidence_id: node.evidence_id,
|
||||
},
|
||||
binding.caller,
|
||||
)
|
||||
)
|
||||
break;
|
||||
}
|
||||
edges.push({
|
||||
source: node.id,
|
||||
target: binding.call,
|
||||
input_index: 1,
|
||||
kind: "return-binding",
|
||||
status: "derived",
|
||||
evidence_id: node.evidence_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const page = nodes.slice(input.offset, input.offset + input.limit);
|
||||
const pageIds = new Set(page.map(({ id }) => id));
|
||||
// Every edge source is a retained node, so a page holding every node holds
|
||||
// every edge; traversal truncation is independent of the requested page.
|
||||
const pageTruncated = page.length !== nodes.length;
|
||||
const traversal = budget.traversal();
|
||||
const result = nativeValueTraceSchema.parse({
|
||||
seed: input.procedure,
|
||||
target_sha256: targetSha256,
|
||||
@@ -437,11 +446,13 @@ export const traceNativeValues = async (
|
||||
input.offset + page.length < nodes.length
|
||||
? input.offset + page.length
|
||||
: null,
|
||||
truncated: truncated || page.length !== nodes.length,
|
||||
page_truncated: pageTruncated,
|
||||
traversal,
|
||||
truncated: pageTruncated || traversal.status === "truncated",
|
||||
limitations: [
|
||||
"Static decompiler def-use graphs cover recovered block members; when membership is unavailable they exclude operations marked dead. Logical CALL argument positions bind to recovered HighParam ordinals and RETURN values bind to recovered CALL outputs; these are decompiler-derived dependencies, not original source or runtime observations. Missing parameters, variadic arguments and alias dependencies remain unknown.",
|
||||
"LOAD and STORE are memory operations; object fields, persistent state and RNG semantics are not inferred from names or pseudocode.",
|
||||
"Traversal caps depth, decompilations, call-site resolutions, nodes, edges and node payloads (8388608 bytes, 8 MiB). This release has no runtime override for the 8388608-byte node-payload allowance. Complete serialized output is bounded to 33554432 bytes (32 MiB) and that output allowance has no runtime override. Pagination reruns analysis and returns edges whose source is on the node page; endpoints outside the page retain stable identities.",
|
||||
"Traversal caps depth, decompilations, call-site resolutions, nodes, edges and node payloads (8388608 bytes, 8 MiB); traversal.reasons names each cap or policy this trace reached. This release has no runtime override for the 8388608-byte node-payload allowance. Complete serialized output is bounded to 33554432 bytes (32 MiB) and that output allowance has no runtime override. Pagination reruns analysis and returns edges whose source is on the node page; endpoints outside the page retain stable identities.",
|
||||
...renderSourceLimitations(sourceLimitations),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { z } from "zod";
|
||||
import { jsonParts } from "../../domain/jsonSerialization.js";
|
||||
import {
|
||||
nativeValueTraceTruncationReasonSchema,
|
||||
type nativeValueTraceSchema,
|
||||
type NativeValueTraceTruncationReason,
|
||||
} from "../../domain/native/nativeValueTrace.js";
|
||||
|
||||
type Trace = z.output<typeof nativeValueTraceSchema>;
|
||||
type TraceNode = Trace["nodes"][number];
|
||||
type TraceEdge = Trace["edges"][number];
|
||||
type TraceUnknown = Trace["unknowns"][number];
|
||||
|
||||
/** Retained node JSON allowance; this release has no runtime override. */
|
||||
const NODE_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const rowBudgetReasons = {
|
||||
max_nodes: "Node budget reached",
|
||||
node_payload_bytes: "Node payload budget reached",
|
||||
max_edges: "Edge budget reached",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Own row admission and every observed cause of a narrowed traversal. Each
|
||||
* cause is recorded with a located unknown, and a row budget is recorded only
|
||||
* after it rejects a row, so an exactly filled budget is not truncation.
|
||||
*/
|
||||
export const createValueTraceBudget = (
|
||||
limits: { readonly max_nodes: number; readonly max_edges: number },
|
||||
unknowns: TraceUnknown[],
|
||||
) => {
|
||||
const nodes: TraceNode[] = [];
|
||||
const edges: TraceEdge[] = [];
|
||||
const causes = new Set<NativeValueTraceTruncationReason>();
|
||||
let bytes = 0;
|
||||
let nodesExhausted = false;
|
||||
let edgesExhausted = false;
|
||||
const narrow = (
|
||||
reason: NativeValueTraceTruncationReason,
|
||||
unknown: TraceUnknown,
|
||||
): false => {
|
||||
causes.add(reason);
|
||||
unknowns.push(unknown);
|
||||
return false;
|
||||
};
|
||||
const reject = (procedure: string, reason: keyof typeof rowBudgetReasons) =>
|
||||
narrow(reason, {
|
||||
procedure,
|
||||
address: null,
|
||||
reason: rowBudgetReasons[reason],
|
||||
});
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
narrow,
|
||||
retainNode(value: TraceNode): boolean {
|
||||
if (nodesExhausted) return false;
|
||||
if (nodes.length >= limits.max_nodes) {
|
||||
nodesExhausted = true;
|
||||
return reject(value.procedure, "max_nodes");
|
||||
}
|
||||
let size = 0;
|
||||
// Reuse bounded JSON encoding to count escapes and UTF-8 expansion
|
||||
// before allocating a complete serialized node that might not fit.
|
||||
for (const part of jsonParts(value)) {
|
||||
size += Buffer.byteLength(part);
|
||||
if (size > NODE_PAYLOAD_BYTES - bytes) {
|
||||
nodesExhausted = true;
|
||||
return reject(value.procedure, "node_payload_bytes");
|
||||
}
|
||||
}
|
||||
bytes += size;
|
||||
nodes.push(value);
|
||||
return true;
|
||||
},
|
||||
retainEdge(value: TraceEdge, procedure: string): boolean {
|
||||
if (edgesExhausted) return false;
|
||||
if (edges.length >= limits.max_edges) {
|
||||
edgesExhausted = true;
|
||||
return reject(procedure, "max_edges");
|
||||
}
|
||||
edges.push(value);
|
||||
return true;
|
||||
},
|
||||
/** A rejected edge means no later call edge or expansion can be retained. */
|
||||
edgesExhausted: () => edgesExhausted,
|
||||
/** Traversal stops after the procedure in which a row budget rejected work. */
|
||||
exhausted: () => nodesExhausted || edgesExhausted,
|
||||
traversal: (): Trace["traversal"] => {
|
||||
const reasons = nativeValueTraceTruncationReasonSchema.options.filter(
|
||||
(reason) => causes.has(reason),
|
||||
);
|
||||
return reasons.length === 0
|
||||
? { status: "complete" }
|
||||
: { status: "truncated", reasons };
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -12,6 +12,38 @@ export const nativeValueTraceInputSchema = z.strictObject({
|
||||
offset: z.int().min(0).default(0),
|
||||
limit: z.int().min(1).max(5_000).default(1_000),
|
||||
});
|
||||
|
||||
/**
|
||||
* Observed causes of a narrowed traversal. Budget codes name the input field a
|
||||
* caller can raise; the remaining codes are fixed or provider-owned limits.
|
||||
*/
|
||||
export const nativeValueTraceTruncationReasonSchema = z.enum([
|
||||
"max_depth",
|
||||
"max_functions",
|
||||
"max_call_sites",
|
||||
"max_nodes",
|
||||
"max_edges",
|
||||
"node_payload_bytes",
|
||||
"provider_pcode_truncated",
|
||||
"ambiguous_call_candidates",
|
||||
]);
|
||||
export type NativeValueTraceTruncationReason = z.output<
|
||||
typeof nativeValueTraceTruncationReasonSchema
|
||||
>;
|
||||
|
||||
/** Traversal completeness under the requested budgets, independent of paging. */
|
||||
const nativeValueTraceTraversalSchema = z
|
||||
.discriminatedUnion("status", [
|
||||
z.strictObject({ status: z.literal("complete") }),
|
||||
z.strictObject({
|
||||
status: z.literal("truncated"),
|
||||
reasons: z.array(nativeValueTraceTruncationReasonSchema).min(1),
|
||||
}),
|
||||
])
|
||||
.describe(
|
||||
"complete: no trace budget, fixed node-payload allowance, provider p-code limit or ambiguous-candidate policy stopped or narrowed the traversal; failed analyses and unresolved calls remain in unknowns. truncated: reasons lists each observed cause in schema order.",
|
||||
);
|
||||
|
||||
/** Def-use observations and call relationships retain separate authority. */
|
||||
export const nativeValueTraceSchema = z.strictObject({
|
||||
seed: z.string(),
|
||||
@@ -68,6 +100,16 @@ export const nativeValueTraceSchema = z.strictObject({
|
||||
total_edges: z.int().nonnegative(),
|
||||
decompilations: z.int().nonnegative(),
|
||||
next_offset: z.int().nonnegative().nullable(),
|
||||
truncated: z.boolean(),
|
||||
page_truncated: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"This page omits retained nodes, and the edges they source, before offset or after next_offset.",
|
||||
),
|
||||
traversal: nativeValueTraceTraversalSchema,
|
||||
truncated: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"page_truncated or a truncated traversal. Read those fields to distinguish page omission from traversal truncation.",
|
||||
),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Cli } from "incur";
|
||||
import pino from "pino";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisProvider,
|
||||
} from "../../../src/application/AnalysisProvider.js";
|
||||
import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js";
|
||||
import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js";
|
||||
import {
|
||||
valueTraceDiamond,
|
||||
valueTraceProvider,
|
||||
} from "../../../src/application/native/NativeValueTrace.fixture.js";
|
||||
import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const effects = {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
};
|
||||
|
||||
/** One-shot CLI analysis over the shared trace with a simulated provider. */
|
||||
const traceCli = async () => {
|
||||
const graph = valueTraceDiamond({ sessionSubject: true });
|
||||
const directory = await createTestTempDirectory("rea-value-trace-cli-");
|
||||
const path = join(directory, "fixture.hop");
|
||||
await writeFile(path, "synthetic analysis database");
|
||||
const provider: AnalysisProvider = {
|
||||
identity: () => valueTraceProvider,
|
||||
capabilities: () =>
|
||||
(["analyze_function", "resolve_native_call_targets"] as const).map(
|
||||
(operation) => ({
|
||||
provider: valueTraceProvider,
|
||||
operation,
|
||||
available: true,
|
||||
reason: null,
|
||||
cachePolicy: "live",
|
||||
limitations: [],
|
||||
effects,
|
||||
}),
|
||||
),
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({
|
||||
profile: createAnalysisProfile(valueTraceProvider, { fixture: true }),
|
||||
}),
|
||||
createClient: () => ({
|
||||
execute: async (operation, parameters, options) =>
|
||||
operation === "health"
|
||||
? ok(createAnalysisExecution(null, valueTraceProvider))
|
||||
: graph.port.execute(operation, parameters, options),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
};
|
||||
const createSession = () => createTestBinarySession(provider);
|
||||
const dependencies: DirectAnalysisDependencies = {
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: createSession,
|
||||
createNativeDispatchBinarySession: createSession,
|
||||
createArtifactBinarySession: createSession,
|
||||
createNativeBinarySession: createSession,
|
||||
createManagedBinarySession: createSession,
|
||||
};
|
||||
const cli = Cli.create("rea-value-trace-cli-test", { sync: false });
|
||||
registerCoreNativeCommands(
|
||||
cli,
|
||||
pino({ enabled: false }),
|
||||
(target, tool, arguments_, options) =>
|
||||
runDirectAnalysis(dependencies, target, tool, arguments_, options),
|
||||
);
|
||||
return async (options: readonly string[]) => {
|
||||
let stdout = "";
|
||||
await cli.serve(
|
||||
["trace-native-values", path, "0x1000", "--max-depth", "1", ...options],
|
||||
{
|
||||
env: {},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
exit: () => undefined,
|
||||
},
|
||||
);
|
||||
return nativeValueTraceSchema.parse(
|
||||
parseEvidence(JSON.parse(stdout)).normalized_result,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
it("reports the CLI last page and its observed node budget separately", async () => {
|
||||
const trace = await traceCli();
|
||||
expect(
|
||||
await trace(["--offset", "10", "--limit", "3", "--json"]),
|
||||
).toMatchObject({
|
||||
nodes: [{ id: "0x3000/parameter:0" }],
|
||||
total_nodes: 11,
|
||||
next_offset: null,
|
||||
page_truncated: true,
|
||||
traversal: { status: "complete" },
|
||||
truncated: true,
|
||||
unknowns: [],
|
||||
});
|
||||
const capped = await trace([
|
||||
"--max-nodes",
|
||||
"9",
|
||||
"--offset",
|
||||
"8",
|
||||
"--limit",
|
||||
"3",
|
||||
"--json",
|
||||
]);
|
||||
expect(capped).toMatchObject({
|
||||
nodes: [{ id: "0x3000/op-1" }],
|
||||
total_nodes: 9,
|
||||
next_offset: null,
|
||||
page_truncated: true,
|
||||
traversal: { status: "truncated", reasons: ["max_nodes"] },
|
||||
truncated: true,
|
||||
});
|
||||
expect(capped.unknowns).toContainEqual({
|
||||
procedure: "0x3000",
|
||||
address: null,
|
||||
reason: "Node budget reached",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { valueTraceDiamond } from "../../../src/application/native/NativeValueTrace.fixture.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js";
|
||||
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
closeEnhancedToolResources,
|
||||
resources,
|
||||
} from "./enhancedToolsHarness.js";
|
||||
|
||||
afterEach(closeEnhancedToolResources);
|
||||
|
||||
it.each(["full", "compact"] as const)(
|
||||
"separates page omission from named traversal budgets through the advertised %s MCP contract",
|
||||
async (profile) => {
|
||||
const server = createServer(
|
||||
{ kind: "fixed", analysis: valueTraceDiamond().port },
|
||||
{ environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } },
|
||||
);
|
||||
const client = new Client({ name: "value-trace-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const { toolDefinition, ajv, outputSchema } =
|
||||
await advertisedMcpToolSchemas(client, "trace_native_values");
|
||||
if (profile === "full")
|
||||
for (const field of [
|
||||
"page_truncated",
|
||||
"traversal",
|
||||
"max_nodes",
|
||||
"max_edges",
|
||||
])
|
||||
expect(JSON.stringify(outputSchema)).toContain(`"${field}"`);
|
||||
const trace = async (arguments_: Record<string, unknown>) => {
|
||||
const reply = await client.callTool(
|
||||
{ name: "trace_native_values", arguments: arguments_ },
|
||||
{ toolDefinition },
|
||||
);
|
||||
expect(reply.isError).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, reply.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
return nativeValueTraceSchema.parse(
|
||||
parseEvidence(reply.structuredContent).normalized_result,
|
||||
);
|
||||
};
|
||||
const request = { procedure: "0x1000", max_depth: 1 };
|
||||
|
||||
const complete = await trace(request);
|
||||
expect(complete).toMatchObject({
|
||||
total_nodes: 11,
|
||||
next_offset: null,
|
||||
page_truncated: false,
|
||||
traversal: { status: "complete" },
|
||||
truncated: false,
|
||||
});
|
||||
const pages = [];
|
||||
for (let offset: number | null = 0; offset !== null;) {
|
||||
const page = await trace({ ...request, offset, limit: 4 });
|
||||
expect(page).toMatchObject({
|
||||
page_truncated: true,
|
||||
traversal: { status: "complete" },
|
||||
});
|
||||
pages.push(page);
|
||||
offset = page.next_offset;
|
||||
}
|
||||
expect(pages.map(({ nodes }) => nodes.length)).toEqual([4, 4, 3]);
|
||||
expect(pages.flatMap(({ nodes }) => nodes.map(({ id }) => id))).toEqual(
|
||||
complete.nodes.map(({ id }) => id),
|
||||
);
|
||||
|
||||
for (const { budgets, offset, reason, unknown } of [
|
||||
{
|
||||
budgets: { max_nodes: 9 },
|
||||
offset: 8,
|
||||
reason: "max_nodes",
|
||||
unknown: "Node budget reached",
|
||||
},
|
||||
{
|
||||
budgets: { max_edges: 2 },
|
||||
offset: 3,
|
||||
reason: "max_edges",
|
||||
unknown: "Edge budget reached",
|
||||
},
|
||||
] as const) {
|
||||
const last = await trace({ ...request, ...budgets, offset, limit: 3 });
|
||||
expect(last).toMatchObject({
|
||||
nodes: [{}],
|
||||
next_offset: null,
|
||||
page_truncated: true,
|
||||
traversal: { status: "truncated", reasons: [reason] },
|
||||
truncated: true,
|
||||
});
|
||||
expect(last.unknowns.map(({ reason }) => reason)).toContain(unknown);
|
||||
}
|
||||
// Raising the named budget to the retained graph size recovers completeness.
|
||||
expect(
|
||||
await trace({ ...request, max_nodes: 11, max_edges: 9 }),
|
||||
).toMatchObject({
|
||||
traversal: { status: "complete" },
|
||||
truncated: false,
|
||||
unknowns: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user