From ef9ab91e42c5e2c42d3f5d82150e5ef24a74e2d1 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:16:57 +0800 Subject: [PATCH] fix(il2cpp): validate retained declaring type relationships (#7946) --- .../analysisView/il2cppMetadataView.test.ts | 4 ++ src/domain/il2cpp/il2cppDeclaringTypes.ts | 26 ++++++++ src/domain/il2cpp/il2cppMetadata.ts | 25 +++++++- src/il2cpp/Il2cppMetadataFile.ts | 30 +++------ tests/acceptance/il2cppMetadataCli.test.ts | 63 +++++++++++++++++++ 5 files changed, 124 insertions(+), 24 deletions(-) create mode 100644 src/domain/il2cpp/il2cppDeclaringTypes.ts diff --git a/src/domain/analysisView/il2cppMetadataView.test.ts b/src/domain/analysisView/il2cppMetadataView.test.ts index 1ac3c4f01f..67d1109700 100644 --- a/src/domain/analysisView/il2cppMetadataView.test.ts +++ b/src/domain/analysisView/il2cppMetadataView.test.ts @@ -278,6 +278,10 @@ it.each([{ method_addresses: undefined }, { binary: undefined }])( ); it.each([ + { types: [{ ...report.types[0]!, declaring_type: 0 }, report.types[1]!] }, + { types: [{ ...report.types[0]!, declaring_type: 1 }, report.types[1]!] }, + { types: [report.types[0]!, { ...report.types[1]!, declaring_type: 999 }] }, + { methods: [{ name: "Run", declaring_type: 999 }] }, { fields: [{ name: "bad", declaring_type: 2 }] }, { parameters: [{ name: "bad", method: 1 }] }, { string_literals: [{ value: null, value_base64: null }] }, diff --git a/src/domain/il2cpp/il2cppDeclaringTypes.ts b/src/domain/il2cpp/il2cppDeclaringTypes.ts new file mode 100644 index 0000000000..d59d1d3c6c --- /dev/null +++ b/src/domain/il2cpp/il2cppDeclaringTypes.ts @@ -0,0 +1,26 @@ +/** Find a cycle in positional declaring-type links, yielding during long walks. */ +export function* firstDeclaringTypeCycle( + types: readonly { readonly declaring_type: number }[], +): Generator { + // 0 unvisited, 1 on the current chain, 2 already examined. + const state = new Uint8Array(types.length); + let work = 0; + for (let first = 0; first < types.length; first += 1) { + if (work++ % 256 === 0) yield; + const chain: number[] = []; + let current = first; + while (current >= 0 && current < types.length && state[current] === 0) { + if (work++ % 256 === 0) yield; + state[current] = 1; + chain.push(current); + current = types[current]!.declaring_type; + } + if (current >= 0 && current < types.length && state[current] === 1) + return current; + for (const index of chain) { + if (work++ % 256 === 0) yield; + state[index] = 2; + } + } + return null; +} diff --git a/src/domain/il2cpp/il2cppMetadata.ts b/src/domain/il2cpp/il2cppMetadata.ts index 5d21a659f4..2599df5f9a 100644 --- a/src/domain/il2cpp/il2cppMetadata.ts +++ b/src/domain/il2cpp/il2cppMetadata.ts @@ -5,6 +5,7 @@ import { localPathStringSchema, } from "../localPath.js"; import { canonicalBase64Schema } from "../stringPatterns.js"; +import { firstDeclaringTypeCycle } from "./il2cppDeclaringTypes.js"; /** Unsigned little-endian signature observed at file offset zero. */ export const IL2CPP_METADATA_MAGIC = 0xfab11baf; @@ -452,7 +453,29 @@ const validateMetadataFacts = ( "Only a code row carries an address and RVA; both are then present.", }); }); - for (const collection of ["fields", "properties", "events"] as const) + report.types.forEach((row, index) => { + if (row.declaring_type >= report.types.length) + context.addIssue({ + code: "custom", + path: ["types", index, "declaring_type"], + message: "Enclosing type is outside the retained type definitions.", + }); + }); + const cycleWalk = firstDeclaringTypeCycle(report.types); + let cycle = cycleWalk.next(); + while (!cycle.done) cycle = cycleWalk.next(); + if (cycle.value !== null) + context.addIssue({ + code: "custom", + path: ["types", cycle.value, "declaring_type"], + message: "Enclosing type relationships contain a cycle.", + }); + for (const collection of [ + "methods", + "fields", + "properties", + "events", + ] as const) report[collection]?.forEach((row, index) => { if (row.declaring_type >= report.types.length) context.addIssue({ diff --git a/src/il2cpp/Il2cppMetadataFile.ts b/src/il2cpp/Il2cppMetadataFile.ts index 8e54589d8f..b8687e3208 100644 --- a/src/il2cpp/Il2cppMetadataFile.ts +++ b/src/il2cpp/Il2cppMetadataFile.ts @@ -12,6 +12,7 @@ import { type IL2cppMetadataSectionName, } from "../domain/il2cpp/il2cppMetadata.js"; import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js"; +import { firstDeclaringTypeCycle } from "../domain/il2cpp/il2cppDeclaringTypes.js"; import { readIl2cppMembers, readIl2cppMemberTables, @@ -475,29 +476,12 @@ function* assignNesting( /** Each enclosing chain must reach a top-level type. */ function* rejectNestingCycles(types: Il2cppMetadataDecoded["types"]) { - // 0 unvisited, 1 on the current chain, 2 reaches a top-level type. - let work = 0; - const state = new Uint8Array(types.length); - for (let first = 0; first < types.length; first += 1) { - if (work++ % 256 === 0) yield; - const chain: number[] = []; - let current = first; - while (current !== -1 && state[current] === 0) { - if (work++ % 256 === 0) yield; - state[current] = 1; - chain.push(current); - current = types[current]!.declaring_type; - } - if (current !== -1 && state[current] === 1) - throw new Il2cppMetadataFormatFailure( - "nested-type-conflict", - `type ${current} is nested inside itself through its declaring types`, - ); - for (const index of chain) { - if (work++ % 256 === 0) yield; - state[index] = 2; - } - } + const cycle = yield* firstDeclaringTypeCycle(types); + if (cycle !== null) + throw new Il2cppMetadataFormatFailure( + "nested-type-conflict", + `type ${cycle} is nested inside itself through its declaring types`, + ); } function* readTypes(tables: MetadataTables): Generator< diff --git a/tests/acceptance/il2cppMetadataCli.test.ts b/tests/acceptance/il2cppMetadataCli.test.ts index 18d69fbcc3..1d7b847c2e 100644 --- a/tests/acceptance/il2cppMetadataCli.test.ts +++ b/tests/acceptance/il2cppMetadataCli.test.ts @@ -23,6 +23,7 @@ import { } from "../../src/domain/evidenceBundle.js"; import { jsonObjectSchema } from "../../src/domain/jsonValue.js"; import { il2cppMetadataSchema } from "../../src/domain/il2cpp/il2cppMetadata.js"; +import { createEmptyIl2cppMetadataFixture } from "../../src/domain/il2cpp/il2cppMetadata.fixture.js"; import { createIl2cppMetadataFixture, createLinkedIl2cppMetadataFixture, @@ -55,6 +56,68 @@ const parseMetadataSummary = (value: unknown) => parseEvidence(value).normalized_result, ); +cliTest( + "rejects impossible imported metadata owners through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-il2cpp-owner-view-"); + const path = join(root, "metadata.dat"); + const report = createEmptyIl2cppMetadataFixture(); + const type = { + name: "Type", + namespace: "", + method_start: -1, + method_count: 0, + nested_type_start: -1, + nested_type_count: 0, + declaring_type: -1, + }; + report.types = [type, { ...type, name: "Nested", declaring_type: 0 }]; + report.methods = [{ name: "Run", declaring_type: 0 }]; + const { call } = await connectLocalToolsMcp(); + for (const scenario of [ + "valid", + "self", + "cycle", + "type-range", + "method-range", + ] as const) { + const value = structuredClone(report); + if (scenario === "self") value.types[0]!.declaring_type = 0; + if (scenario === "cycle") value.types[0]!.declaring_type = 1; + if (scenario === "type-range") value.types[1]!.declaring_type = 999; + if (scenario === "method-range") value.methods[0]!.declaring_type = 999; + const evidence = createEvidence( + { path, format: "file", sha256: value.artifact.sha256 }, + { id: "il2cpp-metadata", name: "IL2CPP", version: "1" }, + { + operation: "inspect_il2cpp_metadata", + parameters: { path }, + result: value, + }, + ); + const request = { + source: { kind: "inline", evidence }, + view: { kind: "il2cpp-metadata", collection: "methods" }, + }; + const query = join(root, `${scenario}.json`); + await writeFile(query, JSON.stringify(request)); + const cliResult = await runFileCli(cli, { + arguments: ["inspect-analysis-view", query, "--json"], + }); + const mcp = await call("inspect_analysis_view", request); + if (scenario === "valid") { + expect(cliResult.exitCode, cliResult.stderr).toBe(0); + expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true); + } else { + expect(cliResult.exitCode, scenario).toBe(1); + expect(cliResult.json).toMatchObject({ code: "invalid_request" }); + expect(parseMcpToolError(mcp).error.code).toBe("invalid_request"); + } + } + }, + 30000, +); + cliTest( "validates generic-container extents before returning CLI or MCP metadata", async ({ cli }) => {