feat(il2cpp): link native methods and export Ghidra seeds

Link IL2CPP metadata names to native addresses and types through shared CLI/MCP workflows, with bounded queries and atomic Ghidra seed export.

Preserve the original implementation by 8rulerstar and follow-up correctness, resource, provenance, and CI dependency repairs. Verified against real Windows/Android artifacts, independent references, actual Ghidra, and focused CLI/MCP tests.

Fixes #5112.
This commit is contained in:
JANG BYUNGKUN authored and GitHub committed 2026-10-20 21:56:54 +08:00
1 parent e50a271c72
commit 52d1a72af0
43 files changed
+5060 -368

No files matched your search

@@ -8,6 +8,10 @@ import {
AnalysisResourceConstraintError,
} from "../../../src/domain/analysisErrorCore.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";
@@ -15,6 +19,16 @@ 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,
@@ -66,23 +80,18 @@ it("bounds repeated literal output during decoding and reuses one span instead o
const path = join(root, "global-metadata.dat");
await writeFile(path, sharedLiteralMetadata(Buffer.alloc(4096, 65), 20));
const decode = vi.spyOn(TextDecoder.prototype, "decode");
const provider = new Il2cppMetadataProvider(12_000);
try {
const result = await provider.inspect({ path });
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);
} finally {
await provider.close();
}
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 () => {
@@ -95,20 +104,13 @@ it("refuses raw literal expansion before decoding and admits invalid UTF-8 only
] as const) {
decode.mockClear();
await writeFile(path, sharedLiteralMetadata(bytes, 1));
const provider = new Il2cppMetadataProvider(limit);
try {
const result = await provider.inspect({ path });
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);
} finally {
await provider.close();
}
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);
}
});
@@ -121,61 +123,48 @@ it("counts an unclaimed parameter table before allocating any member rows", asyn
const bytes = Buffer.from(fixture.bytes);
bytes.writeInt32LE(0x7fffffff, fixture.section("parameters").offset);
await writeFile(path, bytes);
const provider = new Il2cppMetadataProvider(4096);
try {
const result = await provider.inspect({ path });
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
} finally {
await provider.close();
}
const result = await inspectMetadata(path, 4096);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
});
it("refuses an oversized report from its row lower bound before serializing it", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-budget-");
const path = join(root, "global-metadata.dat");
// Shared names make about 100 KiB of input expand beyond 64 MiB of JSON.
await writeFile(path, sharedNameMetadata(65_536, 1_100));
const stringify = vi.spyOn(JSON, "stringify");
const provider = new Il2cppMetadataProvider();
try {
const result = await provider.inspect({ path });
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 === "object" && value !== null && "methods" in value,
(typeof value === "string" && value.length >= 4096) ||
(typeof value === "object" && value !== null && "methods" in value),
),
).toBe(false);
} finally {
await provider.close();
}
});
it("counts escaped name characters toward the budget without escaping the name", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-escape-");
const path = join(root, "global-metadata.dat");
// 4 KiB of U+0001 is 4 KiB raw but 24 KiB once escaped as .
await writeFile(path, sharedNameMetadata(4096, 1, 0x01));
const stringify = vi.spyOn(JSON, "stringify");
const provider = new Il2cppMetadataProvider(8192);
try {
const result = await provider.inspect({ path });
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,
),
).toBe(false);
} finally {
await provider.close();
}
});
},
);
it("admits a report whose escaped names fit the budget exactly", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-fit-");
@@ -207,15 +196,10 @@ it("refuses from row counts before decoding any row", async () => {
const bytes = Buffer.from(fixture.bytes);
bytes.writeInt32LE(0x7fff, fixture.record("methods", 0));
await writeFile(path, bytes);
const provider = new Il2cppMetadataProvider(256);
try {
const result = await provider.inspect({ path });
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
} finally {
await provider.close();
}
const result = await inspectMetadata(path, 256);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
});
it.each([
@@ -317,28 +301,6 @@ it("bounds concurrent snapshots and retains active decoding until close complete
expect((await provider.inspect({ path })).ok).toBe(false);
});
it("refuses UTF-8 expansion before serializing the complete report", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-unicode-");
const path = join(root, "global-metadata.dat");
await writeFile(path, sharedNameMetadata(Buffer.from("雪".repeat(4096)), 1));
const stringify = vi.spyOn(JSON, "stringify");
const provider = new Il2cppMetadataProvider(8192);
try {
const result = await provider.inspect({ path });
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(
stringify.mock.calls.some(
([value]) =>
typeof value === "object" && value !== null && "methods" in value,
),
).toBe(false);
} finally {
await provider.close();
}
});
it.each([false, true])(
"keeps an independent failure with shared cancellation reason: %s",
async (sharedReason) => {
@@ -373,19 +335,42 @@ it("retains the exact empty source observation without claiming a decoded invent
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("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 });
const result = await provider.inspect({ path, binary_path: binaryPath });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unsupported empty metadata");
expect(result.error.message).toContain("unrecognized-magic");
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,
bytes: 0,
sha256: createHash("sha256").digest("hex"),
sha256: createHash("sha256").update(fixture.bytes).digest("hex"),
bytes: fixture.bytes.length,
},
inventory_status: "not-returned-as-complete",
},
@@ -394,3 +379,72 @@ it("retains the exact empty source observation without claiming a decoded invent
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();
}
});