mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
087813fb88
* fix(cancellation): preserve caller abort reasons through late finalization Caller aborts that arrived during final provider, cleanup, or protocol handling were dropped or replaced by I/O, cleanup, or success results. Each affected path now samples the caller signal after its owner has settled and returns AnalysisCancelledError carrying the exact signal.reason, keeping any completed partial Evidence or cleanup state. - Web script export: keep the exact reason for an interrupted output write, and honor a cancellation fact recorded before a retried capture close succeeds. - Native value tracing and Hopper regex search: check the signal after the final provider reply or regex worker settles. - Apple asset catalog: stop assetutil with SIGTERM so cancellation settles only after the helper exits. - CDP observation: end an armed window on a fatal protocol failure. - ADB bugreport: prefer cancellation over the staging-removal output error when the caller aborted. - JavaScript recovery: check the signal after provider success. - export-web-scripts CLI: run under command cancellation and forward the signal into the export workflow. - Objdump and Rizin CLI: report cancellation after cleanup completes. - Binary session: keep cancellation primary when previous-client retirement fails after abort. Fixes morluto/rea#6042 Fixes morluto/rea#6043 Fixes morluto/rea#6048 Fixes morluto/rea#6049 Fixes morluto/rea#6057 Fixes morluto/rea#6060 Fixes morluto/rea#6078 Fixes morluto/rea#6080 Fixes morluto/rea#6108 Fixes morluto/rea#6111 Fixes morluto/rea#6115 * fix(cancellation): keep caller reasons through export rollback and commit - Rollback failure after a caller abort now returns AnalysisCancelledError with the exact signal.reason, attaching the rollback failure as cleanup and retaining any published result as partial observation. - A capture close retry that succeeds after abort returns cancellation instead of the stale cleanup diagnostic. - An abort that lands during the final output commit returns cancellation carrying the completed manifest as partial observation. - Apple assetutil stop test: abort and await the run in finally so a timed-out wait does not leave the child running, and skip on Windows where the JavaScript SIGTERM handler cannot run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5 * fix(cancellation): bind export cancellations to cleanup and caller reasons - A rollback-failure cancellation is bound to the retained cleanup lease, so a later successful close clears cleanup_incomplete instead of reporting a residual directory that no longer exists. - mapExportFailure wraps the caller's own AnalysisCancelledError reason under export_web_scripts rather than returning it as the export's error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Copilot-Session: 35b247e9-c793-4b8f-979a-807c27112cf5
457 lines
16 KiB
TypeScript
457 lines
16 KiB
TypeScript
import { functionDossierSchema } from "../../domain/hopperValues.js";
|
|
import { nativeCallTargetsSchema } from "../../domain/native/nativeInstruction.js";
|
|
import {
|
|
nativeValueTraceInputSchema,
|
|
nativeValueTraceSchema,
|
|
} from "../../domain/native/nativeValueTrace.js";
|
|
import { createEvidence } from "../../domain/evidence.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisOutputError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
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;
|
|
|
|
/** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */
|
|
export const traceNativeValues = async (
|
|
analysis: AnalysisOperationPort,
|
|
parameters: unknown,
|
|
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[] = [];
|
|
const callBindings: {
|
|
caller: string;
|
|
callee: string;
|
|
call: string;
|
|
argument_count: number;
|
|
output: boolean;
|
|
evidence_id: string;
|
|
}[] = [];
|
|
const pending = [{ procedure: input.procedure, depth: 0 }];
|
|
const attempted = new Set<string>();
|
|
const visited = new Set<string>();
|
|
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(new AnalysisCancelledError("trace_native_values"));
|
|
const next = pending.shift();
|
|
if (
|
|
next === undefined ||
|
|
attempted.has(next.procedure) ||
|
|
visited.has(next.procedure)
|
|
)
|
|
continue;
|
|
if (decompilations >= input.max_functions) {
|
|
truncated = true;
|
|
unknowns.push({
|
|
procedure: next.procedure,
|
|
address: null,
|
|
reason: "decompilation budget reached",
|
|
});
|
|
break;
|
|
}
|
|
// An exact selector is attempted once per trace, even on failure. Only
|
|
// successful dossiers establish canonical procedure identity in visited.
|
|
attempted.add(next.procedure);
|
|
const execution = await analysis.execute(
|
|
"analyze_function",
|
|
{ procedure: next.procedure },
|
|
{
|
|
...(signal === undefined ? {} : { signal }),
|
|
...(next.depth > 0 ? { selectorOrigin: "provider" as const } : {}),
|
|
},
|
|
);
|
|
decompilations++;
|
|
if (!execution.ok) {
|
|
if (execution.error._tag === "AnalysisCancelledError")
|
|
return err(execution.error);
|
|
unknowns.push({
|
|
procedure: next.procedure,
|
|
address: null,
|
|
reason: execution.error.message,
|
|
});
|
|
continue;
|
|
}
|
|
const parsed = functionDossierSchema.safeParse(execution.value.result);
|
|
if (!parsed.success || execution.value.subject == null)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"trace_native_values",
|
|
"Function dossier lacks validated facts or artifact identity",
|
|
),
|
|
);
|
|
const dossier = parsed.data;
|
|
const address = dossier.procedure.address;
|
|
if (visited.has(address)) continue;
|
|
visited.add(next.procedure);
|
|
visited.add(address);
|
|
targetSha256 ??= execution.value.subject.sha256;
|
|
if (execution.value.subject.sha256 !== targetSha256)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"trace_native_values",
|
|
"Cross-function target digest mismatch",
|
|
),
|
|
);
|
|
const evidence = createEvidence(
|
|
execution.value.subject,
|
|
execution.value.provider,
|
|
{
|
|
operation: "analyze_function",
|
|
parameters: { procedure: next.procedure },
|
|
result: execution.value.result,
|
|
rawResult: execution.value.rawResult,
|
|
limitations: execution.value.limitations,
|
|
locations: execution.value.locations,
|
|
...(execution.value.analysisProfile === undefined
|
|
? {}
|
|
: { analysisProfile: execution.value.analysisProfile }),
|
|
},
|
|
);
|
|
const evidenceId = evidence.evidence_id;
|
|
procedures.push({
|
|
address,
|
|
depth: next.depth,
|
|
evidence_id: evidenceId,
|
|
provider: execution.value.provider.id,
|
|
analysis_profile_digest: execution.value.analysisProfile?.digest ?? null,
|
|
});
|
|
const flow = dossier.native_value_flow;
|
|
if (flow === null || !flow.available) {
|
|
unknowns.push({
|
|
procedure: address,
|
|
address: null,
|
|
reason: flow?.reason ?? "Provider has no native def-use model",
|
|
});
|
|
continue;
|
|
}
|
|
if (flow.truncated) {
|
|
truncated = true;
|
|
unknowns.push({
|
|
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.
|
|
const operationIds = new Set(flow.operations.map(({ id }) => id));
|
|
const parameterIds = new Map<number, string>();
|
|
const qualified = (id: string) => `${address}/${id}`;
|
|
for (const operation of flow.operations) {
|
|
if (
|
|
operation.block_membership === "detached" ||
|
|
(operation.block_membership === "unavailable" && operation.is_dead)
|
|
)
|
|
continue;
|
|
const node = {
|
|
id: qualified(operation.id),
|
|
procedure: address,
|
|
evidence_id: evidenceId,
|
|
operation,
|
|
kind: "operation" as const,
|
|
parameter: null,
|
|
};
|
|
if (!retainNode(node)) break;
|
|
ids.add(operation.id);
|
|
}
|
|
for (const parameter of flow.parameters) {
|
|
const parameterId = `parameter:${parameter.ordinal}`;
|
|
let localId = parameterId;
|
|
for (let suffix = 1; operationIds.has(localId); suffix++)
|
|
localId = `${parameterId}#${suffix}`;
|
|
const id = qualified(localId);
|
|
if (
|
|
!retainNode({
|
|
id,
|
|
procedure: address,
|
|
evidence_id: evidenceId,
|
|
kind: "parameter",
|
|
operation: null,
|
|
parameter,
|
|
})
|
|
)
|
|
break;
|
|
parameterIds.set(parameter.ordinal, id);
|
|
}
|
|
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;
|
|
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;
|
|
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 (
|
|
!ids.has(operation.id) ||
|
|
!["CALL", "CALLIND"].includes(operation.opcode)
|
|
)
|
|
continue;
|
|
if (callSites >= input.max_call_sites) {
|
|
truncated = true;
|
|
unknowns.push({
|
|
procedure: address,
|
|
address: operation.address,
|
|
reason: "Call-site resolution budget reached",
|
|
});
|
|
break;
|
|
}
|
|
callSites++;
|
|
const resolved = await analysis.execute(
|
|
"resolve_native_call_targets",
|
|
{ address: operation.address },
|
|
signal === undefined ? {} : { signal },
|
|
);
|
|
if (!resolved.ok) {
|
|
if (resolved.error._tag === "AnalysisCancelledError")
|
|
return err(resolved.error);
|
|
unknowns.push({
|
|
procedure: address,
|
|
address: operation.address,
|
|
reason: resolved.error.message,
|
|
});
|
|
continue;
|
|
}
|
|
const targets = nativeCallTargetsSchema.safeParse(resolved.value.result);
|
|
if (!targets.success)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"resolve_native_call_targets",
|
|
"Invalid static call resolution",
|
|
),
|
|
);
|
|
sourceLimitations.push(
|
|
...targets.data.limitations.map((limitation) => ({
|
|
provider: resolved.value.provider,
|
|
operation: "resolve_native_call_targets" as const,
|
|
limitation,
|
|
})),
|
|
);
|
|
if (targets.data.targets.length === 0)
|
|
unknowns.push({
|
|
procedure: address,
|
|
address: operation.address,
|
|
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 (target.status !== "candidate")
|
|
callBindings.push({
|
|
caller: address,
|
|
callee: destination,
|
|
call: qualified(operation.id),
|
|
argument_count: Math.max(0, operation.inputs.length - 1),
|
|
output: operation.output !== null,
|
|
evidence_id: evidenceId,
|
|
});
|
|
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,
|
|
address: operation.address,
|
|
reason:
|
|
target.status === "candidate"
|
|
? "Ambiguous call candidates were not traversed"
|
|
: "Call depth budget reached",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
truncated &&
|
|
(nodeBudgetReached ||
|
|
nodes.length >= input.max_nodes ||
|
|
edges.length >= input.max_edges ||
|
|
bytes >= NODE_PAYLOAD_BYTES)
|
|
)
|
|
break;
|
|
}
|
|
if (targetSha256 === undefined)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"trace_native_values",
|
|
"No seed function could be analyzed",
|
|
),
|
|
);
|
|
const parametersByProcedure = new Map<string, typeof nodes>();
|
|
const returnsByProcedure = new Map<string, typeof nodes>();
|
|
for (const node of nodes) {
|
|
if (node.kind === "parameter") {
|
|
const parameters = parametersByProcedure.get(node.procedure) ?? [];
|
|
parameters.push(node);
|
|
parametersByProcedure.set(node.procedure, parameters);
|
|
} else if (
|
|
node.operation?.opcode === "RETURN" &&
|
|
node.operation.inputs.length > 1
|
|
) {
|
|
const returns = returnsByProcedure.get(node.procedure) ?? [];
|
|
returns.push(node);
|
|
returnsByProcedure.set(node.procedure, returns);
|
|
}
|
|
}
|
|
for (const binding of callBindings) {
|
|
const parameters = parametersByProcedure.get(binding.callee) ?? [];
|
|
if (parameters.length === 0)
|
|
unknowns.push({
|
|
procedure: binding.caller,
|
|
address: null,
|
|
reason: `Parameter bindings for ${binding.callee} are unavailable or omitted`,
|
|
});
|
|
for (const node of parameters) {
|
|
const ordinal = node.parameter?.ordinal;
|
|
if (ordinal === undefined || ordinal >= binding.argument_count) {
|
|
unknowns.push({
|
|
procedure: binding.caller,
|
|
address: null,
|
|
reason: `Argument ${ordinal ?? "unknown"} for ${binding.callee} was not recovered`,
|
|
});
|
|
continue;
|
|
}
|
|
if (edges.length >= input.max_edges) {
|
|
truncated = true;
|
|
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;
|
|
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));
|
|
const result = nativeValueTraceSchema.parse({
|
|
seed: input.procedure,
|
|
target_sha256: targetSha256,
|
|
nodes: page,
|
|
edges: edges.filter(({ source }) => pageIds.has(source)),
|
|
procedures,
|
|
unknowns,
|
|
total_nodes: nodes.length,
|
|
total_edges: edges.length,
|
|
decompilations,
|
|
next_offset:
|
|
input.offset + page.length < nodes.length
|
|
? input.offset + page.length
|
|
: null,
|
|
truncated: truncated || page.length !== nodes.length,
|
|
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.",
|
|
...renderSourceLimitations(sourceLimitations),
|
|
],
|
|
});
|
|
if (Buffer.byteLength(JSON.stringify(result)) > 32 * 1024 * 1024)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"trace_native_values",
|
|
"Graph output exceeds 33554432 bytes (32 MiB). This release has no runtime override. Lower max_nodes, max_edges, or the page limit.",
|
|
),
|
|
);
|
|
if (signal?.aborted)
|
|
return err(
|
|
new AnalysisCancelledError("trace_native_values", {
|
|
cause: signal.reason,
|
|
}),
|
|
);
|
|
return ok(result);
|
|
};
|