diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 0b89ddf7a0..d221c5c8cd 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -528,6 +528,12 @@ message names one reason: | `image-range-overlap` | Two images claim the same type definitions. | | `invalid-string` | A name index is outside the strings section, its string is unterminated, or it is not valid UTF-8. | +Record extents are checked for the decoded images, types, methods, fields, +properties, events, parameters, string literals, nested types, generic +containers, generic parameters and interface offsets. Other header sections +are checked for file bounds and overlap; their internal record layouts are not +decoded or validated by this inventory. + A version field that disagrees with the file's layout, for example a version 39 file relabeled as 29, usually surfaces as `unexpected-header-size` or `record-size-mismatch`. diff --git a/src/il2cpp/Il2cppMetadataFile.ts b/src/il2cpp/Il2cppMetadataFile.ts index f71a6acd0d..8e54589d8f 100644 --- a/src/il2cpp/Il2cppMetadataFile.ts +++ b/src/il2cpp/Il2cppMetadataFile.ts @@ -1,5 +1,6 @@ import { checkRange, + checkedSectionCount, readIndex, Il2cppMetadataFormatFailure, } from "./Il2cppMetadataFormat.js"; @@ -236,16 +237,6 @@ const recordBytes = (section: Il2cppMetadataSection): number | null => { return section.size / count; }; -/** Entry count of a section whose header records only its size. */ -const entriesIn = (section: Il2cppMetadataSection, entryBytes: number) => { - if (section.size % entryBytes !== 0) - throw new Il2cppMetadataFormatFailure( - "record-size-mismatch", - `section ${section.name} size ${section.size} is not a whole multiple of its ${entryBytes}-byte records`, - ); - return section.size / entryBytes; -}; - const FIXED_WIDTHS: Il2cppMetadataDecoded["index_widths"] = { type_definition: 4, generic_container: 4, @@ -260,15 +251,6 @@ const resolveWidths = ( section: (name: SectionName) => Il2cppMetadataSection, ): Il2cppMetadataDecoded["index_widths"] => { const genericContainers = section("genericContainers"); - const genericContainerBytes = recordBytes(genericContainers); - if ( - genericContainerBytes !== null && - genericContainerBytes !== GENERIC_CONTAINER_RECORD_BYTES - ) - throw new Il2cppMetadataFormatFailure( - "record-size-mismatch", - `section genericContainers records are ${genericContainerBytes} bytes, but each record is ${GENERIC_CONTAINER_RECORD_BYTES}`, - ); const type_definition = il2cppIndexWidth( headerCount(section("typeDefinitions")), ); @@ -308,36 +290,54 @@ const recordCounts = ( version: Il2cppMetadataVersion, section: (name: SectionName) => Il2cppMetadataSection, expected: Readonly>, -): Readonly> => { + widths: Il2cppMetadataDecoded["index_widths"], +): Readonly< + Record< + RecordSection | "nestedTypes" | "genericContainers" | "genericParameters", + number + > +> => { + const genericContainers = checkedSectionCount( + section("genericContainers"), + GENERIC_CONTAINER_RECORD_BYTES, + ); + const genericParameters = checkedSectionCount( + section("genericParameters"), + widths.generic_container + 12, + ); const names = ["typeDefinitions", "images", "methods"] as const; if (VERSION_LAYOUTS[version].indexing === "fixed") { + checkedSectionCount(section("interfaceOffsets"), widths.type + 4); const sized = Object.fromEntries( - names.map((name) => [name, entriesIn(section(name), expected[name])]), + names.map((name) => [ + name, + checkedSectionCount(section(name), expected[name]), + ]), ) as Record; return { ...sized, - nestedTypes: entriesIn(section("nestedTypes"), NESTED_TYPE_ENTRY_BYTES), + nestedTypes: checkedSectionCount( + section("nestedTypes"), + NESTED_TYPE_ENTRY_BYTES, + ), + genericContainers, + genericParameters, }; } - for (const name of names) { - const actual = recordBytes(section(name)); - if (actual !== null && actual !== expected[name]) - throw new Il2cppMetadataFormatFailure( - "record-size-mismatch", - `section ${name} records are ${actual} bytes, but the index widths imply ${expected[name]}`, - ); - } - const nestedTypes = section("nestedTypes"); - if (nestedTypes.size !== headerCount(nestedTypes) * NESTED_TYPE_ENTRY_BYTES) - throw new Il2cppMetadataFormatFailure( - "record-size-mismatch", - `section nestedTypes size ${nestedTypes.size} does not hold ${String(nestedTypes.count)} 4-byte entries`, - ); + const counted = Object.fromEntries( + names.map((name) => [ + name, + checkedSectionCount(section(name), expected[name]), + ]), + ) as Record; return { - typeDefinitions: headerCount(section("typeDefinitions")), - images: headerCount(section("images")), - methods: headerCount(section("methods")), - nestedTypes: headerCount(nestedTypes), + ...counted, + nestedTypes: checkedSectionCount( + section("nestedTypes"), + NESTED_TYPE_ENTRY_BYTES, + ), + genericContainers, + genericParameters, }; }; @@ -522,9 +522,7 @@ function* readTypes(tables: MetadataTables): Generator< events: [] as MetadataOwnerRange[], }; const genericContainerCount = - tables.linkFacts === undefined - ? 0 - : entriesIn(section("genericContainers"), GENERIC_CONTAINER_RECORD_BYTES); + tables.linkFacts === undefined ? 0 : tables.counts.genericContainers; for (let index = 0; index < counts.typeDefinitions; index++) { if (index % 256 === 0) yield; const at = typeDefinitions.offset + index * sizes.typeDefinitions; @@ -810,11 +808,8 @@ const readGenericContainer = (tables: MetadataTables, index: number) => { /** Type and method container indices and container records, read on demand. */ const createGenericContainerReaders = (tables: MetadataTables) => { const typeCount = tables.counts.typeDefinitions; - const containers = tables.section("genericContainers"); const containerCount = - tables.indexing === "counted" - ? 0 - : Math.floor(containers.size / GENERIC_CONTAINER_RECORD_BYTES); + tables.indexing === "counted" ? 0 : tables.counts.genericContainers; const inRange = (index: number, count: number) => Number.isInteger(index) && index >= 0 && index < count; const methods = tables.section("methods"), @@ -845,12 +840,7 @@ const createGenericContainerReaders = (tables: MetadataTables) => { const createGenericParameterReader = (tables: MetadataTables) => { const parameters = tables.section("genericParameters"), width = tables.widths.generic_container + 12; - const count = entriesIn(parameters, width); - if (parameters.count !== null && count !== parameters.count) - throw new Il2cppMetadataFormatFailure( - "record-size-mismatch", - "generic parameter count disagrees with its extent", - ); + const count = tables.counts.genericParameters; return (index: number): string | undefined => { if (!Number.isInteger(index) || index < 0 || index >= count) return undefined; @@ -911,7 +901,7 @@ function* readMetadataCoreSteps( section, widths, recordBytes: expected, - counts: recordCounts(version, section, expected), + counts: recordCounts(version, section, expected, widths), typeDefinitionTypeIndices: layout.typeDefinitionTypeIndices, indexing: layout.indexing, methodReturnParameterToken: layout.methodReturnParameterToken, diff --git a/src/il2cpp/Il2cppMetadataFormat.ts b/src/il2cpp/Il2cppMetadataFormat.ts index f40fa36070..290e9c4eeb 100644 --- a/src/il2cpp/Il2cppMetadataFormat.ts +++ b/src/il2cpp/Il2cppMetadataFormat.ts @@ -38,6 +38,25 @@ export class Il2cppMetadataFormatFailure extends Error { } } +/** Validate a section against a known serialized record width once. */ +export const checkedSectionCount = ( + section: Il2cppMetadataSection, + recordBytes: number, +): number => { + if (section.size % recordBytes !== 0) + throw new Il2cppMetadataFormatFailure( + "record-size-mismatch", + `section ${section.name} size ${section.size} is not a whole multiple of its ${recordBytes}-byte records`, + ); + const count = section.size / recordBytes; + if (section.count !== null && count !== section.count) + throw new Il2cppMetadataFormatFailure( + "record-size-mismatch", + `section ${section.name} size ${section.size} does not hold ${section.count} ${recordBytes}-byte records`, + ); + return count; +}; + export const checkRange = ( label: string, start: number, @@ -64,3 +83,4 @@ export const readIndex = (bytes: Buffer, offset: number, width: 1 | 2 | 4) => { } return bytes.readInt32LE(offset); }; +import type { Il2cppMetadataSection } from "../domain/il2cpp/il2cppMetadata.js"; diff --git a/src/il2cpp/Il2cppMetadataMembers.ts b/src/il2cpp/Il2cppMetadataMembers.ts index 7b93ca81fc..74d4c231af 100644 --- a/src/il2cpp/Il2cppMetadataMembers.ts +++ b/src/il2cpp/Il2cppMetadataMembers.ts @@ -1,9 +1,7 @@ -import type { - Il2cppMetadata, - Il2cppMetadataSection, -} from "../domain/il2cpp/il2cppMetadata.js"; +import type { Il2cppMetadata } from "../domain/il2cpp/il2cppMetadata.js"; import { checkRange, + checkedSectionCount, readIndex, Il2cppMetadataFormatFailure, } from "./Il2cppMetadataFormat.js"; @@ -24,21 +22,6 @@ export interface MetadataMemberOwners { readonly parameters: readonly MetadataOwnerRange[]; } -const entries = ( - indexing: MetadataTables["indexing"], - section: Il2cppMetadataSection, - recordBytes: number, -) => { - const count = - indexing === "counted" ? (section.count ?? 0) : section.size / recordBytes; - if (!Number.isInteger(count) || section.size !== count * recordBytes) - throw new Il2cppMetadataFormatFailure( - "record-size-mismatch", - `section ${section.name} size ${section.size} does not hold whole ${recordBytes}-byte records`, - ); - return count; -}; - /** Own member record widths and validate all counts before any row allocation. */ export const readIl2cppMemberTables = ({ widths, @@ -52,8 +35,7 @@ export const readIl2cppMemberTables = ({ parameters: 8 + widths.type, stringLiterals: indexing === "counted" ? 4 : 8, }; - const literalRecords = entries( - indexing, + const literalRecords = checkedSectionCount( section("stringLiterals"), recordBytes.stringLiterals, ); @@ -61,15 +43,13 @@ export const readIl2cppMemberTables = ({ recordBytes, literalRecords, counts: { - fields: entries(indexing, section("fields"), recordBytes.fields), - properties: entries( - indexing, + fields: checkedSectionCount(section("fields"), recordBytes.fields), + properties: checkedSectionCount( section("properties"), recordBytes.properties, ), - events: entries(indexing, section("events"), recordBytes.events), - parameters: entries( - indexing, + events: checkedSectionCount(section("events"), recordBytes.events), + parameters: checkedSectionCount( section("parameters"), recordBytes.parameters, ), diff --git a/tests/acceptance/il2cppStructuralExtentsCli.test.ts b/tests/acceptance/il2cppStructuralExtentsCli.test.ts new file mode 100644 index 0000000000..d2754a2c15 --- /dev/null +++ b/tests/acceptance/il2cppStructuralExtentsCli.test.ts @@ -0,0 +1,61 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { createIl2cppMetadataFixture } from "../fixtures/il2cpp/metadata.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest, runFileCli } from "../support/cli/cliFixture.js"; + +cliTest( + "rejects partial IL2CPP table extents through CLI and MCP without a binary", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-il2cpp-extent-"); + const { call } = await connectLocalToolsMcp(); + for (const [version, name] of [ + [31, "genericContainers"], + [31, "genericParameters"], + [31, "interfaceOffsets"], + [39, "genericParameters"], + ] as const) { + const fixture = createIl2cppMetadataFixture({ + version, + genericOwners: true, + }); + const path = join(root, `${version}-${name}.dat`); + await writeFile(path, fixture.bytes); + const intact = await runFileCli(cli, { + arguments: ["inspect-il2cpp-metadata", path, "--json"], + }); + expect(intact.exitCode, intact.stderr).toBe(0); + + const damaged = Buffer.from(fixture.bytes); + damaged.writeInt32LE( + fixture.section(name).size - 1, + fixture.header(name, "size"), + ); + await writeFile(path, damaged); + const invalid = await runFileCli(cli, { + arguments: ["inspect-il2cpp-metadata", path, "--json"], + }); + const expected = { + code: "invalid_request", + details: { + issues: [ + { + path: ["path"], + reason: "invalid_format", + message: expect.stringContaining("record-size-mismatch"), + }, + ], + }, + }; + expect(invalid.exitCode).toBe(1); + expect(invalid.json).toMatchObject(expected); + const result = await call("inspect_il2cpp_metadata", { path }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result).error).toMatchObject(expected); + } + }, + 30000, +); diff --git a/tests/boundary/il2cpp/il2cppMetadataStructuralExtents.test.ts b/tests/boundary/il2cpp/il2cppMetadataStructuralExtents.test.ts new file mode 100644 index 0000000000..317e7dc037 --- /dev/null +++ b/tests/boundary/il2cpp/il2cppMetadataStructuralExtents.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { Il2cppMetadataFormatFailure } from "../../../src/il2cpp/Il2cppMetadataFormat.js"; +import { createIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js"; +import { + readIl2cppMetadata, + readIl2cppMetadataForLinking, +} from "../../fixtures/il2cpp/readers.js"; + +describe("IL2CPP structural section extents", () => { + it.each([24, 27, 29, 31, 39] as const)( + "validates generic records before inventory or linking for version %i", + (version) => { + const fixture = createIl2cppMetadataFixture({ + version, + genericOwners: true, + }); + for (const reader of [readIl2cppMetadata, readIl2cppMetadataForLinking]) { + expect(() => reader(fixture.bytes)).not.toThrow(); + for (const name of [ + "genericContainers", + "genericParameters", + ] as const) { + const damaged = Buffer.from(fixture.bytes); + damaged.writeInt32LE( + fixture.section(name).size - 1, + fixture.header(name, "size"), + ); + expect(() => reader(damaged)).toThrow(Il2cppMetadataFormatFailure); + expect(() => reader(damaged)).toThrow(/record-size-mismatch/); + } + } + }, + ); + + it.each([24, 27, 29, 31] as const)( + "rejects partial fixed-width interface offsets in version %i", + (version) => { + const fixture = createIl2cppMetadataFixture({ + version, + genericOwners: true, + }); + const damaged = Buffer.from(fixture.bytes); + damaged.writeInt32LE(6, fixture.header("interfaceOffsets", "size")); + for (const reader of [readIl2cppMetadata, readIl2cppMetadataForLinking]) { + expect(() => reader(fixture.bytes)).not.toThrow(); + expect(() => reader(damaged)).toThrow(/record-size-mismatch/); + } + }, + ); + + it("rejects a counted generic-parameter extent with the wrong declared count", () => { + const fixture = createIl2cppMetadataFixture({ + version: 39, + genericOwners: true, + }); + const damaged = Buffer.from(fixture.bytes); + damaged.writeInt32LE(3, fixture.header("genericParameters", "count")); + expect(() => readIl2cppMetadata(damaged)).toThrow(/record-size-mismatch/); + }); + + it.each([31, 39] as const)( + "uses the same extent validation for decoded member sections in version %i", + (version) => { + const fixture = createIl2cppMetadataFixture({ + version, + genericOwners: true, + propertiesAndEvents: true, + }); + for (const name of [ + "fields", + "properties", + "events", + "parameters", + "stringLiterals", + "nestedTypes", + ] as const) { + expect(fixture.section(name).size, name).toBeGreaterThan(0); + const damaged = Buffer.from(fixture.bytes); + damaged.writeInt32LE( + fixture.section(name).size - 1, + fixture.header(name, "size"), + ); + expect(() => readIl2cppMetadata(damaged), name).toThrow( + /record-size-mismatch/, + ); + } + }, + ); +});