import { Il2cppMetadataFormatFailure, type Il2cppMetadataFailureReason, } from "../../../src/il2cpp/Il2cppMetadataFormat.js"; import { describe, expect, it } from "vitest"; import { il2cppIndexWidth, il2cppMetadataRecordBytes, readIl2cppMetadata, readIl2cppMetadataSteps, } from "../../../src/il2cpp/Il2cppMetadataFile.js"; import { createIl2cppMetadataFixture, FIXTURE_IMAGES, fixtureImages, FIXTURE_METHODS, FIXTURE_FIELDS, FIXTURE_PROPERTIES, FIXTURE_EVENTS, FIXTURE_PARAMETERS, FIXTURE_STRING_LITERALS, 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("yields through a large owner range before emitting its first parameter fact", () => { const fixture = createIl2cppMetadataFixture({ wide: { fillerTypes: 0, genericContainers: 0, parameters: 65_000 }, }); const bytes = Buffer.from(fixture.bytes), total = fixture.section("parameters").count!; bytes.writeUInt16LE( total, fixture.record("methods", 2) + fixture.recordBytes.methods - 2, ); let firstParameter = false, yieldsBeforeParameter = 0; const steps = readIl2cppMetadataSteps(bytes, { onRow: (row) => { if ("method" in row) firstParameter = true; }, }); for (;;) { const step = steps.next(); if (step.done) break; if (!firstParameter) yieldsBeforeParameter++; } expect(firstParameter).toBe(true); expect(yieldsBeforeParameter).toBeGreaterThan(200); }); it.each([-1, 0x7fffffff])( "rejects an invalid empty-literal sentinel %i", (sentinel) => { rejects( mutated((fixture, bytes) => { bytes.writeInt32LE(4, fixture.header("stringLiterals", "size")); bytes.writeInt32LE(1, fixture.header("stringLiterals", "count")); bytes.writeInt32LE( sentinel, fixture.section("stringLiterals").offset, ); }), "index-out-of-range", ); }, ); it.each([1, 2, 4] as const)( "decodes names, ranges and nesting with a %i-byte TypeIndex in about 1 KiB", (typeIndexWidth) => { const fixture = createIl2cppMetadataFixture({ typeIndexWidth }); expect(fixture.bytes.length).toBeLessThan(1100); 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: 12, count: 3, }); }, ); 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 overlap the literal data. for (const [delta, reason] of [ [-1, "section-overlap"], [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: 16, 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)); }, ); }); describe("IL2CPP metadata members and string literals", () => { it.each([ { version: 24, typeIndexWidth: 4 }, { version: 27, typeIndexWidth: 4 }, { version: 29, typeIndexWidth: 4 }, { version: 31, typeIndexWidth: 4 }, { version: 39, typeIndexWidth: 1 }, { version: 39, typeIndexWidth: 2 }, { version: 39, typeIndexWidth: 4 }, ] as const)( "decodes nonempty properties and events with distinct owners: %j", (options) => { const fixture = createIl2cppMetadataFixture({ ...options, propertiesAndEvents: true, }); const decoded = readIl2cppMetadata(fixture.bytes); expect(decoded.properties).toEqual(FIXTURE_PROPERTIES); expect(decoded.events).toEqual(FIXTURE_EVENTS); expect(fixture.section("properties").size).toBe(3 * 20); expect(fixture.section("events").size).toBe( 2 * (20 + options.typeIndexWidth), ); }, ); it.each([29, 31, 39] as const)( "pairs version %i members with their owners and keeps undecodable literals as bytes", (version) => { const decoded = readIl2cppMetadata( createIl2cppMetadataFixture({ version }).bytes, ); expect(decoded.fields).toEqual(FIXTURE_FIELDS); expect(decoded.parameters).toEqual(FIXTURE_PARAMETERS); expect(decoded.properties).toEqual([]); expect(decoded.events).toEqual([]); expect(decoded.string_literals).toEqual(FIXTURE_STRING_LITERALS); }, ); it("rejects member ranges claimed twice or outside their table", () => { // Type 1 claims field 0 as well, which type 0 already owns. rejects( mutated((fixture, bytes) => { const starts = fixture.record("typeDefinitions", 1) + 8 + 3 + 1 + 4; bytes.writeInt32LE(0, starts); }), "declaring-type-mismatch", ); rejects( mutated((fixture, bytes) => { const starts = fixture.record("typeDefinitions", 1) + 8 + 3 + 1 + 4; bytes.writeInt32LE(2, starts); }), "index-out-of-range", ); }); it("rejects string literals outside the literal data", () => { rejects( mutated((fixture, bytes) => bytes.writeInt32LE(99, fixture.section("stringLiterals").offset + 4), ), "index-out-of-range", ); }); }); describe("IL2CPP global-metadata version 27", () => { it("decodes the version 29 record layout with version 27 section names", () => { const decoded = readIl2cppMetadata( createIl2cppMetadataFixture({ version: 27 }).bytes, ); expect(decoded.metadata_version).toBe(27); expect(decoded.index_widths.type_width_source).toBe("fixed-width-version"); expect(decoded.sections.slice(24, 28).map(({ name }) => name)).toEqual([ "attributesInfo", "attributeTypes", "unresolvedVirtualCallParameterTypes", "unresolvedVirtualCallParameterRanges", ]); expect(decoded.images).toEqual(FIXTURE_IMAGES); expect(decoded.types).toEqual(FIXTURE_TYPES); expect(decoded.methods).toEqual(FIXTURE_METHODS); }); }); describe("IL2CPP global-metadata version 24", () => { it("decodes the 32-section header and five-type-index records", () => { const fixture = createIl2cppMetadataFixture({ version: 24 }); const decoded = readIl2cppMetadata(fixture.bytes); expect(decoded.metadata_version).toBe(24); expect(decoded.sections).toHaveLength(32); expect(decoded.sections[0]?.offset).toBe(264); expect(decoded.sections[22]?.name).toBe("metadataUsageLists"); expect(decoded.images).toEqual(FIXTURE_IMAGES); expect(decoded.types).toEqual(FIXTURE_TYPES); expect(decoded.methods).toEqual(FIXTURE_METHODS); }); it("rejects version 24 records labeled as version 27", () => { rejects( mutated( (_, bytes) => bytes.writeInt32LE(27, 4), createIl2cppMetadataFixture({ version: 24 }), ), "unexpected-header-size", ); }); });