Files
rea/src/application/wasm/WasmInterfaceComparisonService.ts
T
morlutoandCursor Agent ef59174c1e fix(errors): name fixed allowances in resource-constraint recovery (#5633)
* fix(errors): name fixed allowances in resource-constraint recovery

Fixed parser, count, report, input, and capture refusals inherited
host-memory, wait-and-retry, or retained-Evidence advice. Producers now
attach allowance-specific recovery, and deterministic capacity caps are
not retryable. IL2CPP reader occupancy stays retryable capacity.

Refs #5556, #5405, #5456, #5459, #5460, #5504, #5505, #5506, #5537, #5628, #5630, #5631, #5632

* fix(il2cpp): keep a closing metadata reader non-retryable

close() stops admission before the active inspection finishes. A second
inspect in that window was projected as retryable capacity. Closing and
closed readers now report capability_unavailable. Occupied readers that
remain open stay retryable.

* fix(errors): align fixed-cap recovery with retained facts

IL2CPP report refusals keep observed source facts instead of denying Evidence. Command-output caps use the capture allowance. Ghidra seed recovery names the limit that was exceeded.

* fix(errors): call byte caps a byte allowance

WASM representation limits and the IL2CPP Ghidra seed-file cap are byte budgets. Their recovery no longer says the exceeded allowance is a fixed count.

* test(ida): keep unresolved procedure lookups non-retryable

Main now treats an unchanged invalid request as non-retryable. The IDA lookup tests follow that projection.

* fix(errors): qualify capture recovery for live output

A fixed capture allowance still rejects the same output. Live captures such as adb logcat can change between requests, so recovery no longer says every unchanged request fails again.

* fix(adb): keep a truncated live capture retryable

Device output can shrink between calls, so an adb capture that exceeds the allowance stays retryable. Deterministic capture and version-output limits stay non-retryable.

* fix(adb): keep truncated mutations non-retryable

Only live observations opt into retrying a capture that exceeded the allowance. Push, install, uninstall, and other mutations stay non-retryable.

* fix(errors): keep live capture recovery off deterministic limits

Artifact-derived capture allowances again say the unchanged request fails. Only volatile adb observations use the live-output sentence.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-21 03:45:34 +08:00

256 lines
8.6 KiB
TypeScript

import {
WASM_INTERFACE_REPRESENTATION_BYTES,
wasmInterfaceEntryRepresentationBytes,
} from "../../domain/wasm/wasmInterface.js";
import { setImmediate } from "node:timers/promises";
import {
compareWasmInterfacesInputSchema,
wasmInterfaceComparisonSchema,
} from "../../contracts/wasm/wasmComparisonContracts.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../domain/evidence.js";
import {
wasmArtifactSchema,
type WasmArtifact,
} from "../../domain/wasm/wasmArtifact.js";
import type { WasmInterfaceEntry } from "../../domain/wasm/wasmInterface.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import { ARTIFACT_COMPARISON_PROVIDER } from "../InvestigationProviders.js";
import {
resolvePairedEvidenceRequest,
type EvidenceLookup,
} from "../EvidenceInputResolver.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { z } from "zod";
const OPERATION = "compare_wasm_interfaces";
const parseInspection = (
raw: unknown,
): { evidence: Evidence; result: WasmArtifact } => {
const evidence = parseEvidence(raw);
if (evidence.operation !== "inspect_wasm_artifact")
throw new TypeError("Expected complete inspect_wasm_artifact Evidence");
const result = wasmArtifactSchema.parse(evidence.normalized_result);
if (
evidence.subject?.digest.sha256 !== result.artifact.sha256 ||
evidence.subject.local_path !== result.artifact.path ||
evidence.parameters.path !== result.artifact.path
)
throw new TypeError(
"WASM Evidence subject/selection does not match normalized artifact identity",
);
return { evidence, result };
};
/** Compare only producer-owned typed facts; no raw WAT or original file is reread. */
export const compareWasmInterfaces = async (
rawInput: unknown,
options?: ExecutionOptions,
lookup?: EvidenceLookup,
): Promise<Result<Evidence, AnalysisError>> => {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
const parsed = compareWasmInterfacesInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput),
);
const resolved = resolvePairedEvidenceRequest(parsed.data, lookup);
if (!resolved.ok) return resolved;
try {
await setImmediate();
options?.signal?.throwIfAborted();
const left = parseInspection(resolved.value.left);
const right = parseInspection(resolved.value.right);
const scope = parsed.data.scope;
const selected = (entry: WasmInterfaceEntry): boolean =>
scope === "both" ||
entry.direction === (scope === "imports" ? "import" : "export");
const groups = new Map<
string,
{
left: WasmInterfaceEntry[];
right: WasmInterfaceEntry[];
entry: WasmInterfaceEntry;
}
>();
let work = 0;
for (const [side, inspection] of [
["left", left],
["right", right],
] as const) {
for (const entry of inspection.result.interface?.entries ?? []) {
if (++work % 256 === 0) {
await setImmediate();
options?.signal?.throwIfAborted();
}
if (!selected(entry)) continue;
const key = JSON.stringify([entry.direction, entry.module, entry.name]);
let group = groups.get(key);
if (group === undefined) {
group = { left: [], right: [], entry };
groups.set(key, group);
}
group[side].push(entry);
}
}
const knownInventory = (inspection: typeof left): boolean =>
inspection.result.interface?.state === "complete";
const changes: z.infer<typeof wasmInterfaceComparisonSchema>["changes"] =
[];
const summary = {
added: 0,
removed: 0,
changed: 0,
unchanged: 0,
unknown: 0,
};
let remaining = WASM_INTERFACE_REPRESENTATION_BYTES;
for (const group of groups.values()) {
remaining -=
512 +
group.left.reduce(
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
0,
) +
group.right.reduce(
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
0,
);
if (remaining < 0)
throw new AnalysisResourceConstraintError(
OPERATION,
"capacity",
"WASM comparison exceeds typed interface representation capacity",
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
fixedAllowanceOptions(
"The WASM interface comparison inventory",
"bytes",
),
);
if (++work % 256 === 0) {
await setImmediate();
options?.signal?.throwIfAborted();
}
let state: keyof typeof summary;
let reason: string | null = null;
const l = group.left[0];
const r = group.right[0];
if (group.left.length > 1 || group.right.length > 1) {
state = "unknown";
reason =
"Duplicate interface names are ambiguous; all occurrences retained";
} else if (!knownInventory(left) || !knownInventory(right)) {
state = "unknown";
reason =
"Incomplete interface inventories cannot establish per-key uniqueness or absence";
} else if (
[l, r].some(
(entry) =>
entry !== undefined &&
(entry.signature === null ||
entry.issue !== null ||
entry.kind === "unknown"),
)
) {
state = "unknown";
reason =
"Signature/type semantics were not admitted for every present entry";
} else if (l === undefined) state = "added";
else if (r === undefined) state = "removed";
else if (l.kind !== r.kind) state = "changed";
else
state =
JSON.stringify(l.signature) === JSON.stringify(r.signature)
? "unchanged"
: "changed";
summary[state]++;
changes.push({
direction: group.entry.direction,
module: group.entry.module,
name: group.entry.name,
state,
left: group.left,
right: group.right,
reason,
});
}
const compareText = (a: string, b: string): number =>
a < b ? -1 : a > b ? 1 : 0;
changes.sort(
(a, b) =>
compareText(a.direction, b.direction) ||
compareText(a.module ?? "", b.module ?? "") ||
compareText(a.name, b.name),
);
const coverage =
left.result.interface === undefined &&
right.result.interface === undefined
? "unknown"
: !knownInventory(left) || !knownInventory(right) || summary.unknown > 0
? "partial"
: "complete-within-inputs";
const limitations = [
"Exact declared interface equality is not runtime/ABI compatibility, successful linking or original-source identity.",
"Typed facts come from the selected validated WABT decoding; unsupported signature semantics and duplicate names remain unknown.",
];
const report = wasmInterfaceComparisonSchema.parse({
left: {
evidence_id: left.evidence.evidence_id,
artifact: left.result.artifact,
},
right: {
evidence_id: right.evidence.evidence_id,
artifact: right.result.artifact,
},
scope,
coverage,
changes,
summary,
limitations,
});
await setImmediate();
options?.signal?.throwIfAborted();
return ok(
createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
operation: OPERATION,
predicateType: "rea.wasm-interface-comparison",
parameters: {
left_evidence_id: left.evidence.evidence_id,
right_evidence_id: right.evidence.evidence_id,
scope,
},
result: report,
confidence: "derived",
authority: "analyst-inference",
limitations,
evidenceLinks: [left.evidence.evidence_id, right.evidence.evidence_id],
}),
);
} catch (cause: unknown) {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
if (cause instanceof AnalysisError) return err(cause);
return err(
new AnalysisInputError(OPERATION, { cause }, [
{
path: [],
reason: "invalid_value",
message: cause instanceof Error ? cause.message : String(cause),
},
]),
);
}
};