diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 6ae3c3e8cd..f7a7951917 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -5,6 +5,7 @@ import { IL2CPP_BINARY_BYTES, } from "./Il2cppBinaryFormat.js"; import { Il2cppMetadataFormatFailure } from "./Il2cppMetadataFormat.js"; +import { inspectIl2cppMetadataV108 } from "./Il2cppMetadataV108.js"; import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { setImmediate } from "node:timers/promises"; @@ -299,6 +300,7 @@ const metadataSourceEvidence = ( } | undefined = undefined, previous?: AnalysisPartialObservation, + v108?: NonNullable>>, ) => createEvidence( { @@ -351,10 +353,16 @@ const metadataSourceEvidence = ( ...(counts === undefined ? {} : { validated_section_row_counts: { ...counts } }), + ...(v108 === undefined ? {} : { version_108_partial_inventory: v108 }), }, confidence: "observed", limitations: [ "This partial source observation does not contain a complete metadata inventory. Section row counts, when available, are derived from the validated header extents and record layout; names and relationships may remain unexamined.", + ...(v108 === undefined + ? [] + : [ + "The version 108 header layout has not been checked against the reporter's file. Only section extents and a bounded sample of metadata strings were observed; type, method, literal, and binary layouts remain unverified and are not decoded.", + ]), ], locations: [ { kind: "artifact-path", path: artifact.path }, @@ -470,6 +478,29 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { const artifact = sourceArtifact(input.path, snapshot); observed.artifact = artifact; assertArtifactReadActive(options?.signal); + const v108 = await inspectIl2cppMetadataV108( + snapshot.bytes, + async () => { + await this.yieldControl(); + assertArtifactReadActive(options?.signal); + }, + ); + assertArtifactReadActive(options?.signal); + if (v108 !== null) { + const failure = new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + "unsupported-version: metadata version 108 has a partial header and string observation; type, method and binary layouts are not decoded", + ); + failure.partialObservation = metadataSourceEvidence( + artifact, + undefined, + selectedBinarySource(), + undefined, + v108, + ); + return err(failure); + } const limit = this.outputBytes; const admission = createIl2cppMetadataAdmission( limit, diff --git a/src/il2cpp/Il2cppMetadataV108.ts b/src/il2cpp/Il2cppMetadataV108.ts new file mode 100644 index 0000000000..e510d81637 --- /dev/null +++ b/src/il2cpp/Il2cppMetadataV108.ts @@ -0,0 +1,127 @@ +import { IL2CPP_METADATA_SECTION_NAMES } from "../domain/il2cpp/il2cppMetadata.js"; +import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js"; +import { Il2cppMetadataFormatFailure } from "./Il2cppMetadataFormat.js"; + +// Header order follows LibCpp2IL's Il2CppGlobalMetadataHeader (v108): +// https://github.com/SamboyCoding/Cpp2IL/blob/development/LibCpp2IL/Metadata/Il2CppGlobalMetadataHeader.cs +// Record layouts and native registration are deliberately outside this observation. +const SECTION_NAMES = [ + ...IL2CPP_METADATA_SECTION_NAMES.slice(0, 20), + "typeInlineArrays", + ...IL2CPP_METADATA_SECTION_NAMES.slice(20), + "methodSpecsOnGenericType", + "genericMethodSpecsOnType", + "methodSpecs", + "genericMethodFunctionsDefinitions", + "genericMethodFunctionsDefinitionsWithAdjustor", + "invokerIndices", + "rgctxRanges", + "rgctxValues", + "staticConstructorTypeIndices", +] as const; +const HEADER_BYTES = 8 + SECTION_NAMES.length * 12; +const SAMPLE_STRINGS = 16; +const SAMPLE_BYTES = 128; +const SCAN_BATCH_BYTES = 64 * 1024; +const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +/** A bounded, header-only observation; never a decoded metadata report. */ +export const inspectIl2cppMetadataV108 = async ( + bytes: Buffer, + afterBatch?: () => Promise, +) => { + const prefix = observeIl2cppMetadataHeaderPrefix(bytes); + if (prefix.kind !== "il2cpp" || prefix.version !== 108) return null; + if (bytes.length < HEADER_BYTES) + throw new Il2cppMetadataFormatFailure( + "unsupported-version", + `version 108 layout is unverified; the file has ${bytes.length} bytes, fewer than the assumed ${HEADER_BYTES}-byte header`, + ); + const sections = SECTION_NAMES.map((name, index) => { + const at = 8 + index * 12; + return { + name, + offset: bytes.readInt32LE(at), + size: bytes.readInt32LE(at + 4), + count: bytes.readInt32LE(at + 8), + }; + }); + for (const section of sections) { + if ( + section.offset < 0 || + section.size < 0 || + section.count < 0 || + section.offset + section.size > bytes.length || + (section.size > 0 && section.offset < HEADER_BYTES) + ) + throw new Il2cppMetadataFormatFailure( + "unsupported-version", + `version 108 layout is unverified; assumed section ${section.name} (offset ${section.offset}, size ${section.size}, count ${section.count}) is invalid for the ${bytes.length}-byte file`, + ); + } + const occupied = sections + .filter((section) => section.size > 0) + .sort((a, b) => a.offset - b.offset); + for (let index = 1; index < occupied.length; index++) { + const previous = occupied[index - 1]!; + const current = occupied[index]!; + if (previous.offset + previous.size > current.offset) + throw new Il2cppMetadataFormatFailure( + "unsupported-version", + `version 108 layout is unverified; assumed sections ${previous.name} and ${current.name} overlap at ${current.offset}`, + ); + } + const strings = sections[2]!; + const end = strings.offset + strings.size; + const sample: Array<{ + offset: number; + value: string | null; + value_base64: string | null; + truncated: boolean; + }> = []; + let start = strings.offset; + let terminatedCount = 0; + for (let batch = start; batch < end; batch += SCAN_BATCH_BYTES) { + const batchEnd = Math.min(end, batch + SCAN_BATCH_BYTES); + for (let at = batch; at < batchEnd; at++) { + if (bytes[at] !== 0) continue; + terminatedCount++; + if (sample.length < SAMPLE_STRINGS) { + const length = at - start; + const raw = bytes.subarray( + start, + start + Math.min(length, SAMPLE_BYTES), + ); + let value: string | null; + try { + value = utf8.decode(raw); + } catch { + value = null; + } + sample.push({ + offset: start - strings.offset, + value, + value_base64: value === null ? raw.toString("base64") : null, + truncated: length > SAMPLE_BYTES, + }); + } + start = at + 1; + } + await afterBatch?.(); + } + return { + magic: prefix.magic, + metadata_version: 108, + header_bytes: HEADER_BYTES, + layout_status: "unverified-with-real-v108-artifact", + sections, + strings: { + terminated_count: terminatedCount, + unterminated_tail_bytes: end - start, + sample, + sample_limit: SAMPLE_STRINGS, + sample_bytes_per_string: SAMPLE_BYTES, + }, + coverage: "header-and-bounded-string-sample-only", + } as const; +}; diff --git a/tests/acceptance/il2cppMetadataCli.test.ts b/tests/acceptance/il2cppMetadataCli.test.ts index 89c0530704..595968ad3b 100644 --- a/tests/acceptance/il2cppMetadataCli.test.ts +++ b/tests/acceptance/il2cppMetadataCli.test.ts @@ -56,6 +56,62 @@ const parseMetadataSummary = (value: unknown) => parseEvidence(value).normalized_result, ); +cliTest( + "returns a v108 header inventory in CLI and MCP refusal evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-il2cpp-v108-cli-"); + const path = join(root, "global-metadata.dat"); + const strings = Buffer.from("Player\0"); + const bytes = Buffer.alloc(500 + strings.length); + bytes.writeUInt32LE(0xfab11baf, 0); + bytes.writeInt32LE(108, 4); + for (let index = 0; index < 41; index++) { + bytes.writeInt32LE( + 500 + (index > 2 ? strings.length : 0), + 8 + index * 12, + ); + bytes.writeInt32LE(index === 2 ? strings.length : 0, 12 + index * 12); + bytes.writeInt32LE(index === 2 ? strings.length : 0, 16 + index * 12); + } + strings.copy(bytes, 500); + await writeFile(path, bytes); + const result = await runFileCli(cli, { + arguments: ["inspect-il2cpp-metadata", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "unsupported_target", + details: { + partial_observation: { + normalized_result: { + artifact: { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + version_108_partial_inventory: { + coverage: "header-and-bounded-string-sample-only", + strings: { sample: [{ value: "Player" }] }, + }, + }, + }, + }, + }); + const { client, call } = await connectLocalToolsMcp(); + const response = await call("inspect_il2cpp_metadata", { path }); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "unsupported_target", + details: { + partial_observation: { + kind: "retained-evidence", + evidence_id: expect.any(String), + }, + }, + }); + await client.ping(); + }, + 30000, +); + cliTest( "rejects out-of-range module image references through CLI and MCP", async ({ cli }) => { diff --git a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts index 8b4a6f537c..8051bcadb1 100644 --- a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts @@ -9,9 +9,11 @@ import { AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, } from "../../../src/domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { Il2cppMetadataProvider } from "../../../src/il2cpp/Il2cppMetadataProvider.js"; +import { inspectIl2cppMetadataV108 } from "../../../src/il2cpp/Il2cppMetadataV108.js"; import { Il2cppGhidraSeedWriter } from "../../../src/il2cpp/Il2cppGhidraSeedWriter.js"; import { Il2cppMetadataService } from "../../../src/application/il2cpp/Il2cppMetadataService.js"; import { il2cppMetadataSchema } from "../../../src/domain/il2cpp/il2cppMetadata.js"; @@ -41,6 +43,149 @@ const inspectMetadata = async (path: string, limit?: number) => { } }; +/** Synthetic v108 header: checks bounded observations, not record-layout compatibility. */ +const version108Header = (stringBytes: Buffer) => { + const header = Buffer.alloc(500); + header.writeUInt32LE(0xfab11baf, 0); + header.writeInt32LE(108, 4); + let cursor = header.length; + for (let index = 0; index < 41; index++) { + const size = index === 2 ? stringBytes.length : 0; + header.writeInt32LE(cursor, 8 + index * 12); + header.writeInt32LE(size, 12 + index * 12); + header.writeInt32LE(size, 16 + index * 12); + cursor += size; + } + return Buffer.concat([header, stringBytes]); +}; + +it("retains a bounded v108 header and string observation without claiming decoded rows", async () => { + const root = await createTestTempDirectory("rea-il2cpp-v108-"); + const path = join(root, "global-metadata.dat"); + await writeFile(path, version108Header(Buffer.from("Alpha\0Beta\0"))); + const result = await inspectMetadata(path); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + inventory_status: "not-returned-as-complete", + version_108_partial_inventory: { + metadata_version: 108, + header_bytes: 500, + coverage: "header-and-bounded-string-sample-only", + sections: expect.arrayContaining([ + { name: "stringLiterals", offset: 500, size: 0, count: 0 }, + { name: "stringLiteralData", offset: 500, size: 0, count: 0 }, + { name: "strings", offset: 500, size: 11, count: 11 }, + ]), + strings: { + terminated_count: 2, + unterminated_tail_bytes: 0, + sample: [ + { offset: 0, value: "Alpha", truncated: false }, + { offset: 6, value: "Beta", truncated: false }, + ], + }, + }, + }, + }); + expect(JSON.stringify(result.error.partialObservation)).not.toContain( + '"images":[]', + ); +}); + +it("accepts padding after the assumed v108 header", async () => { + const root = await createTestTempDirectory("rea-il2cpp-v108-padding-"); + const path = join(root, "global-metadata.dat"); + const bytes = version108Header(Buffer.from("Name\0")); + const padded = Buffer.concat([ + bytes.subarray(0, 500), + Buffer.alloc(16), + bytes.subarray(500), + ]); + for (let index = 0; index < 41; index++) { + padded.writeInt32LE(bytes.readInt32LE(8 + index * 12) + 16, 8 + index * 12); + } + await writeFile(path, padded); + const result = await inspectMetadata(path); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisUnsupportedTargetError); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + version_108_partial_inventory: { + strings: { sample: [{ value: "Name" }] }, + }, + }, + }); +}); + +it("retains source evidence when a v108 section conflicts with the unverified layout", async () => { + const root = await createTestTempDirectory("rea-il2cpp-v108-invalid-"); + const path = join(root, "global-metadata.dat"); + const bytes = version108Header(Buffer.from("Name\0")); + bytes.writeInt32LE(bytes.length + 1, 12 + 2 * 12); + await writeFile(path, bytes); + const result = await inspectMetadata(path); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisUnsupportedTargetError); + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { artifact: { path, bytes: bytes.length } }, + }); + expect(JSON.stringify(result.error.partialObservation)).not.toContain( + "version_108_partial_inventory", + ); +}); + +it("bounds v108 string samples and preserves undecodable bytes and leading BOMs", async () => { + const repeated = await inspectIl2cppMetadataV108( + version108Header(Buffer.from("x\0".repeat(20))), + ); + expect(repeated?.strings.terminated_count).toBe(20); + expect(repeated?.strings.sample).toHaveLength(16); + const invalid = await inspectIl2cppMetadataV108( + version108Header(Buffer.from([0xff, 0])), + ); + expect(invalid?.strings.sample).toEqual([ + { offset: 0, value: null, value_base64: "/w==", truncated: false }, + ]); + const bom = await inspectIl2cppMetadataV108( + version108Header(Buffer.from([0xef, 0xbb, 0xbf, 0x41, 0])), + ); + expect(bom?.strings.sample).toEqual([ + { offset: 0, value: "\uFEFFA", value_base64: null, truncated: false }, + ]); +}); + +it("yields during a large v108 string scan and honors cancellation before evidence", async () => { + const root = await createTestTempDirectory("rea-il2cpp-v108-cancel-"); + const path = join(root, "global-metadata.dat"); + await writeFile(path, version108Header(Buffer.alloc(128 * 1024, 65))); + const controller = new AbortController(); + let batches = 0; + const provider = new Il2cppMetadataProvider(undefined, async () => { + batches++; + controller.abort("cancel v108 scan"); + await setImmediate(); + }); + try { + const result = await provider.inspect( + { path }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(batches).toBe(1); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(JSON.stringify(result.error.partialObservation)).not.toContain( + "version_108_partial_inventory", + ); + } finally { + await provider.close(); + } +}); + /** Version 29 file whose methods all share one long name. */ const sharedNameMetadata = ( nameBytes: number | Buffer,