Files
rea/src/il2cpp/Il2cppGhidraSeeds.ts
T
morluto 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

230 lines
7.5 KiB
TypeScript

import {
MAX_GHIDRA_SEED_BYTES,
MAX_GHIDRA_SEED_ENTRIES,
MAX_GHIDRA_SEED_NAME_LENGTH,
} from "../ghidra/GhidraAnalysisSeeds.js";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
const OPERATION = "inspect_il2cpp_metadata";
const capacity = (
message: string,
allowance: string,
kind: "bytes" | "count",
) =>
new AnalysisResourceConstraintError(
OPERATION,
"capacity",
message,
{
maximum_seed_bytes: MAX_GHIDRA_SEED_BYTES,
maximum_seed_entries: MAX_GHIDRA_SEED_ENTRIES,
maximum_seed_name_characters: MAX_GHIDRA_SEED_NAME_LENGTH,
},
fixedAllowanceOptions(allowance, kind),
);
import type { Il2cppMetadata } from "../domain/il2cpp/il2cppMetadata.js";
/** A linked metadata report reduced to Ghidra function seeds. */
export interface Il2cppGhidraSeeds {
/** `address<TAB>function<TAB>name` lines in the REA_GHIDRA_SEED_FILE format. */
readonly text: string;
readonly functions: number;
/** Methods whose code address another method already seeded (identical code folding). */
readonly folded: number;
/** Each folded address with the name its function seed kept and the names it shares. */
readonly folded_groups: readonly {
readonly address: string;
readonly kept: string;
readonly also: readonly string[];
}[];
/** Methods without native code: abstract, interface, or generic definitions. */
readonly without_address: number;
readonly no_body: number;
readonly unknown: number;
}
/**
* Where Ghidra's default loaders place the binary: an ELF linked at 0 goes to
* 0x100000, while Mach-O and PE images keep their link base.
*/
export const ghidraDefaultBase = (
binary: NonNullable<Il2cppMetadata["binary"]>,
): bigint => {
const linked = BigInt(binary.image_base);
return binary.format === "elf" && linked === 0n ? 0x100000n : linked;
};
/** `Namespace.Outer.Inner` for a type, following declaring_type to the outermost type. */
const typeName = (report: Il2cppMetadata, index: number): string => {
const parts: string[] = [];
let namespace = "";
const seen = new Set<number>();
let length = 0;
for (let at = index; at !== -1;) {
if (seen.has(at))
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["types", at],
reason: "invalid_format",
message: "Cyclic type ancestry cannot name a seed.",
},
]);
seen.add(at);
const type = report.types[at];
if (type === undefined)
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["types", at],
reason: "invalid_format",
message: "Seed type owner is unavailable.",
},
]);
length += type.name.length + 1;
if (length > MAX_GHIDRA_SEED_NAME_LENGTH)
throw capacity(
"Qualified seed type name exceeds the Ghidra name limit.",
"The IL2CPP Ghidra seed-name allowance",
"count",
);
parts.unshift(type.name);
namespace = type.namespace;
if (length + namespace.length > MAX_GHIDRA_SEED_NAME_LENGTH)
throw capacity(
"Qualified seed type name exceeds the Ghidra name limit.",
"The IL2CPP Ghidra seed-name allowance",
"count",
);
at = type.declaring_type;
}
return namespace === "" ? parts.join(".") : `${namespace}.${parts.join(".")}`;
};
/**
* Name every linked method `Type$$Method`, the Il2CppDumper convention, at
* `base + rva`. Whitespace becomes `_` because seed names cannot contain it.
* The first method at an address names its function, since a seed file holds
* one function seed per address. The other methods at that address are listed
* in `#` comment lines after the seeds, so a reader of the file or the
* decompilation can see that the kept name is one of several.
*/
export const il2cppGhidraSeeds = (
report: Il2cppMetadata,
base: bigint,
): Il2cppGhidraSeeds => {
if (report.binary === null)
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["binary_path"],
reason: "invalid_value",
message: "Ghidra seeds need a report linked with binary_path.",
},
]);
const seeded = new Map<bigint, { kept: string; also: string[] }>();
const lines: string[] = [];
let folded = 0;
let withoutAddress = 0,
noBody = 0,
unknown = 0,
bytes = 0;
const typeNames = new Map<number, string>();
const reserve = (length: number) => {
if (length > MAX_GHIDRA_SEED_BYTES - bytes)
throw capacity(
"Seed file exceeds Ghidra byte admission.",
"The IL2CPP Ghidra seed-file allowance",
"bytes",
);
bytes += length;
};
const emit = (text: string) => {
reserve(Buffer.byteLength(text));
lines.push(text);
};
report.methods.forEach((method, index) => {
const rva = report.method_addresses[index]?.rva ?? null;
if (rva === null) {
withoutAddress++;
if (report.method_addresses[index]?.state === "no-body") noBody++;
else unknown++;
return;
}
const address = base + BigInt(rva);
if (address < 0n || address > 0xffffffffffffffffn)
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["seed_base"],
reason: "invalid_value",
message: "Rebased seed address exceeds 64 bits.",
},
]);
let ownerName = typeNames.get(method.declaring_type);
if (ownerName === undefined) {
ownerName = typeName(report, method.declaring_type);
typeNames.set(method.declaring_type, ownerName);
}
if (ownerName.length + 2 + method.name.length > MAX_GHIDRA_SEED_NAME_LENGTH)
throw capacity(
"Qualified method seed name exceeds Ghidra character admission.",
"The IL2CPP Ghidra seed-name allowance",
"count",
);
const name = `${ownerName}$$${method.name}`.replace(/\s/gu, "_");
if (name.includes("\0"))
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["methods", index, "name"],
reason: "invalid_format",
message: "Seed names cannot contain NUL.",
},
]);
const group = seeded.get(address);
if (group !== undefined) {
folded++;
if (group.also.length === 0)
reserve(
Buffer.byteLength(
`# folded 0x${address.toString(16)} ${group.kept} also\n`,
),
);
reserve(Buffer.byteLength(` ${name}`));
group.also.push(name);
return;
}
seeded.set(address, { kept: name, also: [] });
if (seeded.size > MAX_GHIDRA_SEED_ENTRIES)
throw capacity(
"Seed function count exceeds Ghidra entry admission.",
"The IL2CPP Ghidra seed-entry allowance",
"count",
);
emit(`0x${address.toString(16)}\tfunction\t${name}\n`);
});
const foldedGroups = [...seeded]
.filter(([, group]) => group.also.length > 0)
.map(([address, group]) => ({
address: `0x${address.toString(16)}`,
kept: group.kept,
also: group.also,
}));
for (const group of foldedGroups) {
lines.push(`# folded ${group.address} ${group.kept} also`);
for (const name of group.also) lines.push(` ${name}`);
lines.push("\n");
}
emit(
`# metadata_sha256 ${report.artifact.sha256}\n# binary_sha256 ${report.binary.artifact.sha256}\n# link_base ${report.binary.image_base}\n# seed_base 0x${base.toString(16)}\n`,
);
return {
text: lines.join(""),
functions: seeded.size,
folded,
folded_groups: foldedGroups,
without_address: withoutAddress,
no_body: noBody,
unknown,
};
};