Files
rea/tests/boundary/il2cpp/il2cppMetadataProvider.test.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

578 lines
21 KiB
TypeScript

import { setImmediate } from "node:timers/promises";
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisResourceConstraintError,
} from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { Il2cppMetadataProvider } from "../../../src/il2cpp/Il2cppMetadataProvider.js";
import {
createElfFixture,
createIl2cppBinaryFixture,
} from "../../fixtures/il2cpp/binary.js";
import { createIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
afterEach(() => {
vi.restoreAllMocks();
});
/** One-shot inspections keep the real reader's cleanup at one test boundary. */
const inspectMetadata = async (path: string, limit?: number) => {
const provider = new Il2cppMetadataProvider(limit);
try {
return await provider.inspect({ path });
} finally {
await provider.close();
}
};
/** Version 29 file whose methods all share one long name. */
const sharedNameMetadata = (
nameBytes: number | Buffer,
methods: number,
fill = 0x41,
) => {
const encoded = Buffer.isBuffer(nameBytes)
? nameBytes
: Buffer.alloc(nameBytes, fill);
const name = Buffer.concat([encoded, Buffer.from([0])]);
const records = Buffer.alloc(methods * 32);
for (let index = 0; index < methods; index += 1)
records.writeInt32LE(-1, index * 32 + 4);
const header = Buffer.alloc(256);
header.writeUInt32LE(0xfab11baf, 0);
header.writeInt32LE(29, 4);
let cursor = 256;
for (let section = 0; section < 31; section += 1) {
// strings is section 2 and methods is section 5.
const content = section === 2 ? name : section === 5 ? records : undefined;
header.writeInt32LE(cursor, 8 + section * 8);
header.writeInt32LE(content?.length ?? 0, 12 + section * 8);
cursor += content?.length ?? 0;
}
return Buffer.concat([header, name, records]);
};
/** Fixed-width metadata whose literals share one span; no target code is involved. */
const sharedLiteralMetadata = (literalBytes: Buffer, count: number) => {
const header = Buffer.alloc(256),
records = Buffer.alloc(count * 8);
header.writeUInt32LE(0xfab11baf, 0);
header.writeInt32LE(29, 4);
for (let i = 0; i < count; i++)
records.writeUInt32LE(literalBytes.length, i * 8);
let cursor = 256;
for (let section = 0; section < 31; section++) {
const size =
section === 0 ? records.length : section === 1 ? literalBytes.length : 0;
header.writeInt32LE(cursor, 8 + section * 8);
header.writeInt32LE(size, 12 + section * 8);
cursor += size;
}
return Buffer.concat([header, records, literalBytes]);
};
it("bounds repeated literal output during decoding and reuses one span instead of allocating every repetition", async () => {
const root = await createTestTempDirectory("rea-il2cpp-literals-");
const path = join(root, "global-metadata.dat");
await writeFile(path, sharedLiteralMetadata(Buffer.alloc(4096, 65), 20));
const decode = vi.spyOn(TextDecoder.prototype, "decode");
const result = await inspectMetadata(path, 12_000);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expanded literals accepted");
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
validated_section_row_counts: { string_literals: 20 },
},
});
expect(
decode.mock.calls.filter(([bytes]) => bytes?.byteLength === 4096),
).toHaveLength(1);
});
it("refuses raw literal expansion before decoding and admits invalid UTF-8 only when base64 fits", async () => {
const root = await createTestTempDirectory("rea-il2cpp-literal-forms-");
const path = join(root, "global-metadata.dat");
const decode = vi.spyOn(TextDecoder.prototype, "decode");
for (const [bytes, limit, expectedDecodes] of [
[Buffer.alloc(4096, 65), 1024, 0],
[Buffer.alloc(2048, 255), 2400, 1],
] as const) {
decode.mockClear();
await writeFile(path, sharedLiteralMetadata(bytes, 1));
const result = await inspectMetadata(path, limit);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(
decode.mock.calls.filter(([value]) => value?.byteLength === bytes.length),
).toHaveLength(expectedDecodes);
}
});
it("counts an unclaimed parameter table before allocating any member rows", async () => {
const root = await createTestTempDirectory("rea-il2cpp-member-counts-");
const path = join(root, "global-metadata.dat");
const fixture = createIl2cppMetadataFixture({
wide: { fillerTypes: 0, genericContainers: 0, parameters: 2000 },
});
const bytes = Buffer.from(fixture.bytes);
bytes.writeInt32LE(0x7fffffff, fixture.section("parameters").offset);
await writeFile(path, bytes);
const result = await inspectMetadata(path, 4096);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
});
it.each([
{
name: "shared-name row lower bound",
bytes: () => sharedNameMetadata(65_536, 1_100),
limit: undefined,
},
{
name: "JSON escaping",
bytes: () => sharedNameMetadata(4096, 1, 0x01),
limit: 8192,
},
{
name: "UTF-8 expansion",
bytes: () => sharedNameMetadata(Buffer.from("雪".repeat(4096)), 1),
limit: 8192,
},
])(
"refuses $name before allocating serialized text",
async ({ bytes, limit }) => {
const root = await createTestTempDirectory("rea-il2cpp-text-budget-");
const path = join(root, "global-metadata.dat");
await writeFile(path, bytes());
const stringify = vi.spyOn(JSON, "stringify");
const result = await inspectMetadata(path, limit);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(
stringify.mock.calls.some(
([value]) =>
(typeof value === "string" && value.length >= 4096) ||
(typeof value === "object" && value !== null && "methods" in value),
),
).toBe(false);
},
);
it("admits a report whose escaped names fit the budget exactly", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-fit-");
const path = join(root, "global-metadata.dat");
await writeFile(path, sharedNameMetadata(4096, 1, 0x01));
const unbounded = new Il2cppMetadataProvider();
let bytes: number;
try {
const result = await unbounded.inspect({ path });
if (!result.ok) throw result.error;
expect(result.value.methods[0]?.name).toBe("".repeat(4096));
bytes = Buffer.byteLength(JSON.stringify(result.value));
} finally {
await unbounded.close();
}
const exact = new Il2cppMetadataProvider(bytes);
try {
expect((await exact.inspect({ path })).ok).toBe(true);
} finally {
await exact.close();
}
});
it("keeps observed source facts with a complete-report refusal", async () => {
const root = await createTestTempDirectory("rea-il2cpp-report-evidence-");
const path = join(root, "global-metadata.dat");
const bytes = createIl2cppMetadataFixture().bytes;
await writeFile(path, bytes);
const result = await inspectMetadata(path, 256);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected report allowance refusal");
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(result.error.partialObservation).toMatchObject({
operation: "observe_il2cpp_metadata_source",
normalized_result: {
artifact: {
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
inventory_status: "not-returned-as-complete",
},
});
const projected = projectAnalysisError(result.error);
expect(projected.retryable).toBe(false);
expect(projected.code).toBe("resource_constraint");
expect(projected.remediation.action).toContain(
"No complete metadata inventory is retained",
);
expect(projected.remediation.action).toContain(
"Source facts already observed stay with this refusal",
);
expect(projected.remediation.action).not.toMatch(
/does not leave a completed report or Evidence record/u,
);
expect(projected.remediation.action).not.toMatch(/worker memory/u);
});
it("refuses from row counts before decoding any row", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-counts-");
const path = join(root, "global-metadata.dat");
// A broken method name would fail as invalid-string if rows were decoded.
const fixture = createIl2cppMetadataFixture();
const bytes = Buffer.from(fixture.bytes);
bytes.writeInt32LE(0x7fff, fixture.record("methods", 0));
await writeFile(path, bytes);
const result = await inspectMetadata(path, 256);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
});
it.each([
{ requested: "cancel during metadata decoding" },
new AnalysisResourceConstraintError(
"caller-selected-reason",
"memory",
"Caller cancellation reason",
{},
),
])(
"accepts cancellation during decoded batches with exact reason %j",
async (reason) => {
const root = await createTestTempDirectory("rea-il2cpp-provider-cancel-");
const path = join(root, "global-metadata.dat");
const bytes = sharedNameMetadata(1, 100_000);
await writeFile(path, bytes);
const controller = new AbortController();
let batches = 0;
const provider = new Il2cppMetadataProvider(undefined, async () => {
await setImmediate();
if (++batches === 3) controller.abort(reason);
});
try {
const result = await provider.inspect(
{ path },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(batches).toBe(3);
expect(result.error.partialObservation).toMatchObject({
operation: "observe_il2cpp_metadata_source",
normalized_result: {
artifact: {
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
inventory_status: "not-returned-as-complete",
validated_section_row_counts: {
images: 0,
types: 0,
methods: 100_000,
},
},
});
const replacement = createIl2cppMetadataFixture();
await writeFile(path, replacement.bytes);
const next = await provider.inspect({ path });
if (!next.ok) throw next.error;
expect(next.value.artifact.sha256).toBe(
createHash("sha256").update(replacement.bytes).digest("hex"),
);
expect(next.value.methods).toHaveLength(5);
} finally {
await provider.close();
}
},
);
it("bounds concurrent snapshots and retains active decoding until close completes", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-close-");
const path = join(root, "global-metadata.dat");
await writeFile(path, createIl2cppMetadataFixture().bytes);
let markEntered!: () => void;
const entered = new Promise<void>((resolve) => {
markEntered = resolve;
});
let resume!: () => void;
const release = new Promise<void>((resolve) => {
resume = resolve;
});
const provider = new Il2cppMetadataProvider(undefined, () => {
markEntered();
return release;
});
const active = provider.inspect({ path });
await entered;
let closed = false;
const closing = provider.close().then(() => {
closed = true;
});
try {
const refused = await provider.inspect({ path });
expect(refused.ok).toBe(false);
if (!refused.ok) {
expect(refused.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect(projectAnalysisError(refused.error)).toMatchObject({
code: "capability_unavailable",
retryable: false,
details: { reason: "IL2CPP metadata reader is closed" },
});
expect(
projectAnalysisError(refused.error).remediation.action,
).not.toMatch(/retry/i);
}
await setImmediate();
expect(closed).toBe(false);
} finally {
resume();
await closing;
}
expect((await active).ok).toBe(true);
expect(closed).toBe(true);
const afterClose = await provider.inspect({ path });
expect(afterClose.ok).toBe(false);
if (!afterClose.ok) {
expect(afterClose.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect(projectAnalysisError(afterClose.error).retryable).toBe(false);
}
});
it("keeps occupied capacity retryable while the reader remains open", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-occupied-");
const path = join(root, "global-metadata.dat");
await writeFile(path, createIl2cppMetadataFixture().bytes);
let markEntered!: () => void;
const entered = new Promise<void>((resolve) => {
markEntered = resolve;
});
let resume!: () => void;
const release = new Promise<void>((resolve) => {
resume = resolve;
});
const provider = new Il2cppMetadataProvider(undefined, () => {
markEntered();
return release;
});
const active = provider.inspect({ path });
await entered;
try {
const refused = await provider.inspect({ path });
expect(refused.ok).toBe(false);
if (!refused.ok) {
expect(refused.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(projectAnalysisError(refused.error)).toMatchObject({
retryable: true,
details: {
resource: "capacity",
reported_limits: { maximum_concurrent_inspections: 1 },
},
remediation: {
action: expect.stringContaining("active IL2CPP metadata inspection"),
},
});
}
} finally {
resume();
await active;
await provider.close();
}
});
it.each([false, true])(
"keeps an independent failure with shared cancellation reason: %s",
async (sharedReason) => {
const root = await createTestTempDirectory("rea-il2cpp-failure-cancel-");
const path = join(root, "global-metadata.dat");
await writeFile(path, createIl2cppMetadataFixture().bytes);
const controller = new AbortController();
const failure = Object.assign(
new Error("independent scheduler I/O failure"),
{ code: "EIO" },
);
const provider = new Il2cppMetadataProvider(undefined, () => {
controller.abort(sharedReason ? failure : "separate caller cancellation");
return Promise.reject(failure);
});
try {
const result = await provider.inspect(
{ path },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the independent failure");
expect(result.error).not.toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(failure);
} finally {
await provider.close();
}
},
);
it("retains the exact empty source observation without claiming a decoded inventory", async () => {
const root = await createTestTempDirectory("rea-il2cpp-empty-");
const path = join(root, "empty.dat");
await writeFile(path, Buffer.alloc(0));
const result = await inspectMetadata(path);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unsupported empty metadata");
expect(result.error.message).toContain("unrecognized-magic");
expect(result.error.partialObservation).toMatchObject({
operation: "observe_il2cpp_metadata_source",
normalized_result: {
artifact: {
path,
bytes: 0,
sha256: createHash("sha256").digest("hex"),
},
inventory_status: "not-returned-as-complete",
},
});
});
it("retains the exact source when recognized magic has no complete version", async () => {
const root = await createTestTempDirectory("rea-il2cpp-short-prefix-");
const path = join(root, "global-metadata.dat");
const bytes = Buffer.alloc(7);
bytes.writeUInt32LE(0xfab11baf, 0);
await writeFile(path, bytes);
const result = await inspectMetadata(path);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected truncated metadata prefix");
expect(result.error).toMatchObject({
_tag: "AnalysisInputError",
issues: [
{
path: ["path"],
reason: "invalid_format",
message: expect.stringMatching(
/truncated-header:.*metadata version is unknown/u,
),
},
],
});
expect(result.error.partialObservation).toMatchObject({
operation: "observe_il2cpp_metadata_source",
normalized_result: {
artifact: {
path,
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
},
inventory_status: "not-returned-as-complete",
},
});
});
it("keeps the metadata source observation when the selected binary does not link", async () => {
const root = await createTestTempDirectory("rea-il2cpp-binary-failure-");
const path = join(root, "global-metadata.dat");
const binaryPath = join(root, "GameAssembly.dll");
const fixture = createIl2cppMetadataFixture();
await writeFile(path, fixture.bytes);
await writeFile(binaryPath, Buffer.from("not a native image"));
const provider = new Il2cppMetadataProvider();
try {
const result = await provider.inspect({ path, binary_path: binaryPath });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the binary to be refused");
expect(result.error.partialObservation).toMatchObject({
operation: "observe_il2cpp_metadata_source",
normalized_result: {
artifact: {
path,
sha256: createHash("sha256").update(fixture.bytes).digest("hex"),
bytes: fixture.bytes.length,
},
inventory_status: "not-returned-as-complete",
},
});
} finally {
await provider.close();
}
});
it("accepts cancellation between binary linking batches and keeps the metadata source", async () => {
const root = await createTestTempDirectory("rea-il2cpp-link-cancel-");
const path = join(root, "global-metadata.dat");
const binaryPath = join(root, "libil2cpp.so");
const fixture = createIl2cppMetadataFixture();
await writeFile(path, fixture.bytes);
await writeFile(binaryPath, createElfFixture(createIl2cppBinaryFixture()));
// Yields taken by the metadata decode alone; later ones belong to linking.
let decodeYields = 0;
const counting = new Il2cppMetadataProvider(undefined, async () => {
decodeYields++;
});
try {
expect((await counting.inspect({ path })).ok).toBe(true);
} finally {
await counting.close();
}
const controller = new AbortController();
const reason = new Error("stop linking");
let yields = 0;
const provider = new Il2cppMetadataProvider(undefined, async () => {
if (++yields === decodeYields + 2) controller.abort(reason);
});
try {
const result = await provider.inspect(
{ path, binary_path: binaryPath },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(yields).toBe(decodeYields + 2);
expect(result.error.partialObservation).toMatchObject({
operation: "observe_il2cpp_metadata_source",
normalized_result: { artifact: { path } },
});
} finally {
await provider.close();
}
});
it("retains both observed file identities and the selected architecture when native linking fails", async () => {
const root = await createTestTempDirectory("rea-il2cpp-failed-pair-");
const path = join(root, "global-metadata.dat"),
binary_path = join(root, "GameAssembly.dylib");
const bytes = createIl2cppBinaryFixture({ codeRegistration: false });
await writeFile(path, createIl2cppMetadataFixture().bytes);
await writeFile(binary_path, bytes);
const provider = new Il2cppMetadataProvider();
try {
const result = await provider.inspect({ path, binary_path });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Unresolved registration accepted");
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
artifact: { path },
selected_binary: {
path: binary_path,
identity_status: "observed",
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
},
});
} finally {
await provider.close();
}
});