import { describe, expect, it } from "vitest"; import { il2cppIndexWidth, il2cppMetadataRecordBytes, Il2cppMetadataFormatFailure, readIl2cppMetadata, type Il2cppMetadataFailureReason, } from "../../../src/il2cpp/Il2cppMetadataFile.js"; import { createIl2cppMetadataFixture, FIXTURE_IMAGES, fixtureImages, FIXTURE_METHODS, FIXTURE_TYPES, fixtureRows, type Il2cppMetadataFixture, } from "../../fixtures/il2cpp/metadata.js"; const failure = (bytes: Buffer) => { try { readIl2cppMetadata(bytes); } catch (cause: unknown) { if (cause instanceof Il2cppMetadataFormatFailure) return cause; throw cause; } throw new Error("Expected the metadata to be rejected"); }; const rejects = ( bytes: Buffer, reason: Il2cppMetadataFailureReason, kind: "unsupported" | "corrupt" = "corrupt", ) => { const rejected = failure(bytes); expect(rejected.reason).toBe(reason); expect(rejected.kind).toBe(kind); expect(rejected.message.startsWith(`${reason}: `)).toBe(true); }; const mutated = ( edit: (fixture: Il2cppMetadataFixture, bytes: Buffer) => void, fixture = createIl2cppMetadataFixture(), ) => { const bytes = Buffer.from(fixture.bytes); edit(fixture, bytes); return bytes; }; describe("IL2CPP global-metadata version 39", () => { it.each([1, 2, 4] as const)( "decodes names, ranges and nesting with a %i-byte TypeIndex under 1 KiB", (typeIndexWidth) => { const fixture = createIl2cppMetadataFixture({ typeIndexWidth }); expect(fixture.bytes.length).toBeLessThan(1024); const decoded = readIl2cppMetadata(fixture.bytes); expect(decoded.magic).toBe(fixture.bytes.readUInt32LE(0)); expect(decoded.metadata_version).toBe(39); expect(decoded.index_widths).toEqual({ type_definition: 1, generic_container: 1, parameter: 1, type: typeIndexWidth, type_width_source: "interface-offsets-record", }); expect(decoded.images).toEqual(FIXTURE_IMAGES); expect(decoded.types).toEqual(FIXTURE_TYPES); expect(decoded.methods).toEqual(FIXTURE_METHODS); expect(decoded.sections).toHaveLength(31); expect(decoded.sections[0]).toEqual({ name: "stringLiterals", offset: 380, size: 0, count: 0, }); }, ); it("recovers the TypeIndex width from type records without interfaceOffsets", () => { const fixture = createIl2cppMetadataFixture({ typeIndexWidth: 2, interfaceOffsets: false, }); const decoded = readIl2cppMetadata(fixture.bytes); expect(decoded.index_widths.type).toBe(2); expect(decoded.index_widths.type_width_source).toBe( "type-definition-record", ); expect(decoded.methods).toEqual(FIXTURE_METHODS); }); it("accepts empty sections that share an offset with occupied sections and padding gaps", () => { const bytes = mutated((fixture, target) => { const methods = fixture.section("methods"); target.writeInt32LE( methods.offset + 3, fixture.header("windowsRuntimeStrings", "offset"), ); // Grow strings into trailing padding that no other section occupies. target.writeInt32LE( fixture.section("strings").size, fixture.header("strings", "size"), ); }); expect(readIl2cppMetadata(bytes).types).toEqual(FIXTURE_TYPES); }); }); describe("rejected IL2CPP global-metadata", () => { it("rejects an unrecognized magic and other versions as unsupported", () => { rejects( mutated((_, bytes) => bytes.writeUInt32LE(0x12345678, 0)), "unrecognized-magic", "unsupported", ); rejects(Buffer.alloc(4), "unrecognized-magic", "unsupported"); rejects( mutated((_, bytes) => bytes.writeInt32LE(30, 4)), "unsupported-version", "unsupported", ); }); it("rejects a truncated header and an unexpected header size", () => { rejects( createIl2cppMetadataFixture().bytes.subarray(0, 200), "truncated-header", ); rejects( mutated((fixture, bytes) => bytes.writeInt32LE(384, fixture.header("stringLiterals", "offset")), ), "unexpected-header-size", ); }); it("rejects sections outside the file or with negative fields", () => { rejects( mutated((fixture, bytes) => bytes.writeInt32LE(bytes.length, fixture.header("methods", "size")), ), "section-out-of-bounds", ); rejects( mutated((fixture, bytes) => bytes.writeInt32LE(-1, fixture.header("events", "count")), ), "section-out-of-bounds", ); }); it("rejects overlapping occupied sections", () => { rejects( mutated((fixture, bytes) => bytes.writeInt32LE( fixture.section("typeDefinitions").offset + 4, fixture.header("images", "offset"), ), ), "section-overlap", ); }); it("rejects record sizes that disagree with counts or index widths", () => { // Not a whole multiple of the record count. rejects( mutated((fixture, bytes) => bytes.writeInt32LE( fixture.section("methods").count! + 1, fixture.header("methods", "count"), ), ), "record-size-mismatch", ); // 300 generic containers widen GenericContainerIndex to two bytes. rejects( mutated((fixture, bytes) => bytes.writeInt32LE(300, fixture.header("genericContainers", "count")), ), "record-size-mismatch", ); rejects( mutated((fixture, bytes) => bytes.writeInt32LE(2, fixture.header("nestedTypes", "count")), ), "record-size-mismatch", ); rejects( mutated((fixture, bytes) => bytes.writeInt32LE(0, fixture.header("methods", "count")), ), "record-size-mismatch", ); }); it("rejects an interfaceOffsets record that implies no valid TypeIndex width", () => { rejects( createIl2cppMetadataFixture({ interfaceOffsetRecordBytes: 7 }).bytes, "type-index-width", ); }); }); describe("rejected IL2CPP metadata references", () => { it("rejects image, type, method and nested-type indices outside their tables", () => { rejects( mutated((fixture, bytes) => bytes.writeUInt32LE(9, fixture.record("images", 1) + 9), ), "index-out-of-range", ); rejects( mutated((fixture, bytes) => { // methodStart follows fieldStart in the int32 start block. const at = fixture.record("typeDefinitions", 0) + 8 + 3 + 1 + 4 + 4; bytes.writeInt32LE(4, at); }), "index-out-of-range", ); rejects( mutated((fixture, bytes) => bytes.writeUInt8(4, fixture.record("methods", 2) + 4), ), "index-out-of-range", ); rejects( mutated((fixture, bytes) => bytes.writeInt32LE(4, fixture.section("nestedTypes").offset), ), "index-out-of-range", ); }); it("rejects a type nested in two declaring types", () => { rejects( mutated((fixture, bytes) => { // Two string indices, three 1-byte type indices, generic container, flags. const starts = fixture.record("typeDefinitions", 3) + 8 + 3 + 1 + 4; bytes.writeInt32LE(0, starts + 16); bytes.writeUInt16LE(1, starts + 32 + 8); }), "nested-type-conflict", ); }); it("rejects unterminated, out-of-range and non-UTF-8 strings", () => { rejects( mutated((fixture, bytes) => bytes.writeInt32LE( fixture.section("strings").size, fixture.record("methods", 0), ), ), "invalid-string", ); rejects( mutated((fixture, bytes) => bytes.writeInt32LE( fixture.section("strings").size - 1, fixture.header("strings", "size"), ), ), "invalid-string", ); rejects( mutated((fixture, bytes) => bytes.writeUInt8(0xff, fixture.stringOffset("Update")), ), "invalid-string", ); }); it("rejects a shifted string section and names that start inside another string", () => { // Pulled back, the fixture's strings would start inside the header. for (const [delta, reason] of [ [-1, "section-out-of-bounds"], [1, "invalid-string"], ] as const) rejects( mutated((fixture, bytes) => { const strings = fixture.section("strings"); bytes.writeInt32LE( strings.offset + delta, fixture.header("strings", "offset"), ); // Shrink a forward shift so it stays clear of the next section. bytes.writeInt32LE( strings.size - Math.max(delta, 0), fixture.header("strings", "size"), ); }), reason, ); rejects( mutated((fixture, bytes) => bytes.writeInt32LE( fixture.stringOffset("Update") + 1 - fixture.section("strings").offset, fixture.record("methods", 0), ), ), "invalid-string", ); }); it("rejects nesting cycles and self-nesting", () => { // Type 0 owns nestedTypes entry 0; listing itself there is a self-cycle. rejects( mutated((fixture, bytes) => bytes.writeInt32LE(0, fixture.section("nestedTypes").offset), ), "nested-type-conflict", ); rejects( createIl2cppMetadataFixture({ nestingCycle: true }).bytes, "nested-type-conflict", ); }); it("rejects methods whose declaring type disagrees with the owning type range", () => { rejects( mutated((fixture, bytes) => bytes.writeUInt8(3, fixture.record("methods", 1) + 4), ), "declaring-type-mismatch", ); }); }); describe("IL2CPP index widths", () => { it("switches widths at the runtime's 255 and 65535 entry boundaries", () => { expect( [0, 255, 256, 65535, 65536].map((entries) => il2cppIndexWidth(entries)), ).toEqual([1, 1, 2, 2, 4]); expect( il2cppMetadataRecordBytes( { type_definition: 2, generic_container: 2, parameter: 4, type: 2, }, 39, ), ).toEqual({ typeDefinitions: 76, images: 36, methods: 30 }); const fixed = { type_definition: 4, generic_container: 4, parameter: 4, type: 4, } as const; expect(il2cppMetadataRecordBytes(fixed, 29)).toEqual({ typeDefinitions: 88, images: 40, methods: 32, }); expect(il2cppMetadataRecordBytes(fixed, 31).methods).toBe(36); }); }); describe("IL2CPP global-metadata versions 29 and 31", () => { it.each([29, 31] as const)( "decodes version %i with fixed 4-byte indices and size-only headers", (version) => { const fixture = createIl2cppMetadataFixture({ version }); const decoded = readIl2cppMetadata(fixture.bytes); expect(decoded.metadata_version).toBe(version); expect(decoded.magic).toBe(fixture.bytes.readUInt32LE(0)); expect(decoded.index_widths).toEqual({ type_definition: 4, generic_container: 4, parameter: 4, type: 4, type_width_source: "fixed-width-version", }); expect(decoded.sections[0]).toEqual({ name: "stringLiterals", offset: 256, size: 0, count: null, }); expect(decoded.sections.every((section) => section.count === null)).toBe( true, ); expect(decoded.images).toEqual(FIXTURE_IMAGES); expect(decoded.types).toEqual(FIXTURE_TYPES); expect(decoded.methods).toEqual(FIXTURE_METHODS); }, ); it("rejects method records sized for the other version", () => { for (const [built, labeled] of [ [29, 31], [31, 29], ] as const) rejects( mutated( (_, bytes) => bytes.writeInt32LE(labeled, 4), createIl2cppMetadataFixture({ version: built }), ), "record-size-mismatch", ); }); it("rejects a version 29 header that does not end at the first section", () => { rejects( createIl2cppMetadataFixture({ version: 29 }).bytes.subarray(0, 200), "truncated-header", ); rejects( mutated( (fixture, bytes) => bytes.writeInt32LE(380, fixture.header("stringLiterals", "offset")), createIl2cppMetadataFixture({ version: 29 }), ), "unexpected-header-size", ); }); it("rejects size-only sections that do not hold whole records", () => { rejects( mutated( (fixture, bytes) => bytes.writeInt32LE( fixture.section("typeDefinitions").size - 1, fixture.header("typeDefinitions", "size"), ), createIl2cppMetadataFixture({ version: 31 }), ), "record-size-mismatch", ); }); it("applies the shared string and ownership checks", () => { const fixture = createIl2cppMetadataFixture({ version: 31 }); rejects( mutated( (target, bytes) => bytes.writeInt32LE( target.stringOffset("Update") + 1 - target.section("strings").offset, target.record("methods", 0), ), fixture, ), "invalid-string", ); rejects( mutated( (target, bytes) => bytes.writeInt32LE(3, target.record("methods", 1) + 4), fixture, ), "declaring-type-mismatch", ); }); }); describe("IL2CPP metadata coverage checks", () => { it("rejects occupied sections that overlap the header", () => { rejects( mutated( (fixture, bytes) => bytes.writeInt32LE(27, fixture.header("strings", "offset")), createIl2cppMetadataFixture({ version: 29 }), ), "section-out-of-bounds", ); }); it("rejects a method outside its declaring type's range", () => { // Type 3 owns methods [3, 5); shrinking it strands method 4. rejects( mutated((fixture, bytes) => { const starts = fixture.record("typeDefinitions", 3) + 8 + 3 + 1 + 4; bytes.writeUInt16LE(1, starts + 32); }), "declaring-type-mismatch", ); }); it("rejects images whose type ranges overlap", () => { rejects( mutated((fixture, bytes) => bytes.writeUInt8(2, fixture.record("images", 1) + 8), ), "image-range-overlap", ); }); it("preserves a leading byte order mark in names", () => { const fixture = createIl2cppMetadataFixture(); const bytes = Buffer.from(fixture.bytes); // Replace the three bytes of "Add" with a lone UTF-8 byte order mark. const at = fixture.stringOffset("Add"); bytes.set([0xef, 0xbb, 0xbf], at); expect(readIl2cppMetadata(bytes).methods[2]?.name).toBe("\ufeff"); }); }); describe("IL2CPP version 39 wide indices", () => { it.each([ [2, { fillerTypes: 300, genericContainers: 300, parameters: 300 }], [4, { fillerTypes: 65_540, genericContainers: 65_540, parameters: 65_540 }], ] as const)( "decodes %i-byte type-definition, generic-container and parameter indices", (width, wide) => { const decoded = readIl2cppMetadata( createIl2cppMetadataFixture({ wide }).bytes, ); expect(decoded.index_widths).toMatchObject({ type_definition: width, generic_container: width, parameter: width, }); const rows = fixtureRows({ wide }); expect(decoded.types).toEqual(rows.types); expect(decoded.methods).toEqual(rows.methods); // The last method declares a type index beyond the narrower widths. expect(decoded.methods.at(-1)?.declaring_type).toBe( rows.types.length - 1, ); expect(decoded.images).toEqual(FIXTURE_IMAGES); }, ); }); describe("IL2CPP narrow index sentinels", () => { it.each([ [1, undefined], [2, { fillerTypes: 300, genericContainers: 0, parameters: 0 }], ] as const)( "reads an empty image's all-ones %i-byte type start as -1", (width, wide) => { const options = { emptyImage: true, ...(wide === undefined ? {} : { wide }), }; const decoded = readIl2cppMetadata( createIl2cppMetadataFixture(options).bytes, ); expect(decoded.index_widths.type_definition).toBe(width); expect(decoded.images).toEqual(fixtureImages(options)); }, ); });