Files
reverse-engineering-anything/src/domain/analysisView/il2cppMetadataView.test.ts
T
JANG BYUNGKUN 52d1a72af0 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.
2026-10-20 21:56:54 +08:00

555 lines
16 KiB
TypeScript

import { createEmptyIl2cppMetadataFixture } from "../il2cpp/il2cppMetadata.fixture.js";
import { afterEach, expect, it, vi } from "vitest";
import {
il2cppMetadataParentSchema,
type Il2cppMetadata,
} from "../il2cpp/il2cppMetadata.js";
import { createEvidence } from "../evidence.js";
import { freezeJsonSnapshot } from "../immutableJson.js";
import { jsonObjectSchema } from "../jsonValue.js";
import { projectIl2cppMetadataView } from "./il2cppMetadataView.js";
afterEach(() => {
vi.restoreAllMocks();
});
const report: Il2cppMetadata = {
...createEmptyIl2cppMetadataFixture(),
images: [{ name: "A.dll", type_start: 0, type_count: 2 }],
types: [
{
name: "Outer",
namespace: "N",
method_start: 0,
method_count: 1,
nested_type_start: 0,
nested_type_count: 1,
declaring_type: -1,
},
{
name: "Inner",
namespace: "",
method_start: -1,
method_count: 0,
nested_type_start: -1,
nested_type_count: 0,
declaring_type: 0,
},
],
methods: [{ name: "Run", declaring_type: 0 }],
fields: [{ name: "value", declaring_type: 0 }],
properties: [],
events: [],
parameters: [{ name: "times", method: 0 }],
string_literals: [{ value: "Go", value_base64: null }],
binary: null,
method_addresses: [],
return_types: [],
field_types: [],
parameter_types: [],
limitations: [],
};
const parent = (normalizedResult: unknown, sealedResult: boolean) => ({
evidenceId: `ev_${"b".repeat(64)}`,
operation: "inspect_il2cpp_metadata",
normalizedResult,
sealedResult,
limitations: [],
});
const summarize = (source: ReturnType<typeof parent>) => {
const result = projectIl2cppMetadataView(source, { kind: "summary" });
if (!result.ok) throw result.error;
return result.value;
};
const page = {
kind: "il2cpp-metadata",
collection: "types",
offset: 0,
limit: 1,
} as const;
it("validates a sealed parent once across pages and keeps counts exact", () => {
const parse = vi.spyOn(il2cppMetadataParentSchema, "safeParse");
const sealed = parent(freezeJsonSnapshot(structuredClone(report)), true);
for (const offset of [0, 1]) {
const view = projectIl2cppMetadataView(sealed, { ...page, offset });
if (!view.ok) throw view.error;
expect(view.value).toMatchObject({
items: [{ index: offset, ...report.types[offset] }],
counts: {
sections: report.sections.length,
images: 1,
types: 2,
methods: 1,
nested_types: 1,
fields: 1,
properties: 0,
events: 0,
parameters: 1,
string_literals: 1,
method_addresses: null,
return_types: null,
field_types: null,
parameter_types: null,
},
});
}
expect(parse).toHaveBeenCalledTimes(1);
});
it("revalidates unsealed inline input on every page", () => {
const parse = vi.spyOn(il2cppMetadataParentSchema, "safeParse");
const inline = parent(structuredClone(report), false);
projectIl2cppMetadataView(inline, page);
projectIl2cppMetadataView(inline, page);
expect(parse).toHaveBeenCalledTimes(2);
});
it("preserves historical omission as unknown while keeping existing metadata queries usable", () => {
const historical = jsonObjectSchema.parse(report);
for (const key of [
"fields",
"properties",
"events",
"parameters",
"string_literals",
])
delete historical[key];
const source = parent(freezeJsonSnapshot(historical), true);
const summary = summarize(source);
expect(summary).toMatchObject({
summary: {
counts: { types: 2, methods: 1, fields: null, parameters: null },
},
unknowns: expect.arrayContaining([
expect.stringContaining("did not collect fields"),
]),
});
const existing = projectIl2cppMetadataView(source, page);
if (!existing.ok) throw existing.error;
expect(existing.value).toMatchObject({
items: [{ index: 0, name: "Outer" }],
});
const unavailable = projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "parameters",
offset: 0,
limit: 1,
});
expect(unavailable.ok).toBe(false);
});
it("treats Evidence retained before binary linking as unknown, not unlinked", () => {
const historical = jsonObjectSchema.parse(report);
for (const key of [
"binary",
"method_addresses",
"return_types",
"field_types",
"parameter_types",
])
delete historical[key];
const source = parent(freezeJsonSnapshot(historical), true);
const summary = summarize(source);
expect(summary).not.toHaveProperty("summary.binary");
expect(summary).toMatchObject({
summary: {
counts: {
fields: 1,
method_addresses: null,
method_address_states: null,
return_types: null,
},
},
unknowns: expect.arrayContaining([
expect.stringContaining("did not collect method_addresses"),
]),
});
expect(projectIl2cppMetadataView(source, page).ok).toBe(true);
const unavailable = projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "method_addresses",
offset: 0,
limit: 1,
});
expect(unavailable.ok).toBe(false);
const current = projectIl2cppMetadataView(parent(report, false), {
kind: "summary",
});
if (!current.ok) throw current.error;
expect(current.value).toMatchObject({
summary: {
binary: null,
counts: {
method_addresses: null,
method_address_states: null,
return_types: null,
},
},
});
expect(
projectIl2cppMetadataView(parent(report, false), {
kind: "il2cpp-metadata",
collection: "method_addresses",
offset: 0,
limit: 64,
}).ok,
).toBe(false);
});
const linkedBinary = {
artifact: {
path: "/fixture/libil2cpp.so",
sha256: "c".repeat(64),
bytes: 64,
},
format: "elf" as const,
architecture: "arm64",
image_base: "0x0",
code_gen_modules_field: "0x10",
code_gen_modules_array: "0x20",
code_gen_modules: [],
types_field: "0x30",
types_count: 4,
};
it("selects parameter types by owning method through the parameters collection", () => {
const linked = {
...report,
parameters: [
{ name: "a", method: 0 },
{ name: "b", method: 0 },
],
binary: { ...linkedBinary },
method_addresses: report.methods.map(() => ({
state: "no-body" as const,
address: null,
rva: null,
})),
return_types: report.methods.map(() => ({ type: -1, name: null })),
field_types: report.fields.map(() => ({ type: 1, name: "int" })),
parameter_types: [
{ type: 2, name: "string" },
{ type: 3, name: "bool" },
],
};
const select = (
source: ReturnType<typeof parent>,
method_indices: number[],
) =>
projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "parameter_types",
method_indices,
offset: 0,
limit: 8,
});
const owned = select(parent(linked, false), [0]);
if (!owned.ok) throw owned.error;
expect(owned.value).toMatchObject({
items: [
{ index: 0, type: 2, name: "string" },
{ index: 1, type: 3, name: "bool" },
],
});
const none = select(parent(linked, false), [1]);
if (!none.ok) throw none.error;
expect(none.value).toMatchObject({ items: [] });
// Without retained parameters the owners are unknown, so the filter is refused.
const historical = jsonObjectSchema.parse(linked);
delete historical.parameters;
expect(select(parent(freezeJsonSnapshot(historical), true), [0]).ok).toBe(
false,
);
});
it.each([{ method_addresses: undefined }, { binary: undefined }])(
"rejects a binary link field retained without its collections: %j",
(changed) => {
const result = projectIl2cppMetadataView(
parent({ ...report, ...changed }, false),
{ kind: "summary" },
);
expect(result.ok).toBe(false);
},
);
it.each([
{ fields: [{ name: "bad", declaring_type: 2 }] },
{ parameters: [{ name: "bad", method: 1 }] },
{ string_literals: [{ value: null, value_base64: null }] },
{ string_literals: [{ value: "Go", value_base64: "R28=" }] },
])(
"rejects inconsistent member ownership or literal representation in authenticated parent facts: %j",
(changed) => {
const result = projectIl2cppMetadataView(
parent({ ...report, ...changed }, false),
{ kind: "summary" },
);
expect(result.ok).toBe(false);
},
);
it("selects a batch of parameter owners before paging and intersects exact identities and names", () => {
const source = parent(
freezeJsonSnapshot({
...report,
parameters: [
{ name: "first", method: 0 },
{ name: "unclaimed", method: -1 },
{ name: "last", method: 0 },
],
}),
true,
);
const query = {
kind: "il2cpp-metadata",
collection: "parameters",
method_indices: [0, 0, 99],
fields: ["name", "method"],
offset: 1,
limit: 1,
} as const;
const result = projectIl2cppMetadataView(source, {
...query,
method_indices: [...query.method_indices],
fields: [...query.fields],
});
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
items: [{ index: 2, name: "last", method: 0 }],
coverage: { total: 2, exhausted: true, next_offset: null },
});
for (const selection of [
{ method_indices: [] },
{ method_indices: [99] },
{ method_indices: [0], indices: [1] },
{ method_indices: [0], names: ["unclaimed"] },
]) {
const empty = projectIl2cppMetadataView(source, {
...query,
...selection,
offset: 0,
fields: [...query.fields],
});
if (!empty.ok) throw empty.error;
expect(empty.value).toMatchObject({
items: [],
coverage: { total: 0, exhausted: true },
});
}
});
it("intersects batch identities, fields, image ranges and exact names in source order", () => {
const source = parent(freezeJsonSnapshot(structuredClone(report)), true);
const result = projectIl2cppMetadataView(source, {
...page,
indices: [1, 0, 1, 100],
image_indices: [0],
names: ["Inner"],
namespaces: [""],
declaring_types: [0],
fields: ["name"],
});
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
items: [{ index: 1, name: "Inner" }],
coverage: { total: 1, exhausted: true },
counts: { images: 1, types: 2, methods: 1 },
});
const identityOnly = projectIl2cppMetadataView(source, {
...page,
fields: [],
});
if (!identityOnly.ok) throw identityOnly.error;
expect(identityOnly.value).toMatchObject({ items: [{ index: 0 }] });
});
it.each([
"indices",
"image_indices",
"names",
"namespaces",
"declaring_types",
] as const)("keeps an empty %s selection empty", (field) => {
const result = projectIl2cppMetadataView(parent(report, false), {
...page,
[field]: [],
});
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
items: [],
coverage: { total: 0, exhausted: true },
});
});
it("does not trust a forged seal flag or a shallow frozen mutable report", () => {
const mutable = structuredClone(report);
Object.freeze(mutable);
const source = parent(mutable, true);
const first = projectIl2cppMetadataView(source, page);
if (!first.ok) throw first.error;
mutable.types[0]!.name = "Changed";
const second = projectIl2cppMetadataView(source, page);
if (!second.ok) throw second.error;
expect(second.value).toMatchObject({
items: [{ index: 0, name: "Changed" }],
});
});
it("refuses a parent whose subject contradicts the recorded artifact identity", () => {
const result = projectIl2cppMetadataView(
{
...parent(report, false),
subject: createEvidence(
{ path: report.artifact.path, format: "file", sha256: "c".repeat(64) },
{ id: "fixture", name: "fixture", version: "1" },
{
operation: "inspect_il2cpp_metadata",
parameters: {},
result: report,
},
).subject,
},
page,
);
expect(result.ok).toBe(false);
});
const linkedReport = (): Il2cppMetadata => ({
...structuredClone(report),
types: report.types.map((type, index) => ({
...type,
method_start: index * 2,
method_count: 2,
})),
methods: [
{ name: "A", declaring_type: 0 },
{ name: "B", declaring_type: 0 },
{ name: "Abstract", declaring_type: 1 },
{ name: "Unresolved", declaring_type: 1 },
],
binary: {
artifact: {
path: "/artifacts/GameAssembly.dll",
sha256: "c".repeat(64),
bytes: 4096,
},
format: "pe",
architecture: "amd64",
image_base: "0x1000",
code_gen_modules_field: "0x1200",
code_gen_modules_array: "0x1300",
code_gen_modules: [
{ image: 0, address: "0x1400", method_pointer_count: 4 },
],
types_field: "0x1500",
types_count: 1,
},
method_addresses: [
{ state: "code", address: "0x1100", rva: "0x100" },
{ state: "code", address: "0x1100", rva: "0x100" },
{ state: "no-body", address: null, rva: null },
{ state: "unknown", address: null, rva: null },
],
return_types: Array.from({ length: 4 }, () => ({ type: 0, name: "void" })),
field_types: [{ type: 0, name: "int" }],
parameter_types: [{ type: 0, name: "int" }],
});
it("selects every folded method by numeric address with stable indices and both source identities", () => {
const source = parent(freezeJsonSnapshot(linkedReport()), true);
const view = projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "method_addresses",
addresses: ["0x00001100", "0x1100"],
states: ["code"],
fields: [],
offset: 1,
limit: 1,
});
if (!view.ok) throw view.error;
expect(view.value).toMatchObject({
items: [{ index: 1 }],
artifact: { sha256: report.artifact.sha256 },
binary_source: {
artifact: { path: "/artifacts/GameAssembly.dll", sha256: "c".repeat(64) },
image_base: "0x1000",
},
coverage: { total: 2, exhausted: true },
});
});
it.each([
{ addresses: [] },
{ states: [] },
{ indices: [] },
{ addresses: ["0x1100"], indices: [2] },
{ states: ["no-body" as const], addresses: ["0x1100"] },
])(
"intersects native selectors and preserves empty selection: %j",
(selection) => {
const view = projectIl2cppMetadataView(parent(linkedReport(), false), {
kind: "il2cpp-metadata",
collection: "method_addresses",
...selection,
offset: 0,
limit: 64,
});
if (!view.ok) throw view.error;
expect(view.value).toMatchObject({
items: [],
coverage: { total: 0, exhausted: true },
});
},
);
it.each([
{ state: "no-body", address: "0x1100", rva: null },
{ state: "unknown", address: null, rva: "0x100" },
{ state: "code", address: "0x1100", rva: "0x101" },
{ state: "code", address: "0x10000000000000000", rva: "0x100" },
{ state: "code", address: "malformed", rva: "0x100" },
])(
"rejects malformed native facts without throwing during boundary validation: %j",
(row) => {
const value = jsonObjectSchema.parse(linkedReport());
value.method_addresses = [row, ...linkedReport().method_addresses.slice(1)];
const view = projectIl2cppMetadataView(parent(value, false), {
kind: "summary",
});
expect(view.ok).toBe(false);
},
);
it("keeps native summaries bounded while paging selected module facts", () => {
const value = linkedReport(),
source = parent(value, false);
const summary = summarize(source);
expect(summary).toMatchObject({
summary: {
counts: { code_gen_modules: 1 },
binary: { artifact: value.binary!.artifact },
},
});
expect("summary" in summary && summary.summary).not.toHaveProperty(
"binary.code_gen_modules",
);
const page = projectIl2cppMetadataView(source, {
kind: "il2cpp-metadata",
collection: "code_gen_modules",
image_indices: [0],
fields: ["image", "address"],
offset: 0,
limit: 1,
});
if (!page.ok) throw page.error;
expect(page.value).toMatchObject({
items: [{ index: 0, image: 0, address: "0x1400" }],
coverage: { total: 1, exhausted: true },
});
});