diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 017b0e40db..3d32989ece 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -368,16 +368,15 @@ message names one reason: | `nested-type-conflict` | A type is listed as nested in two types, in itself, or in a cycle of declaring types. | | `declaring-type-mismatch` | A method inside a type's method range names a different type, or a method lies outside its type's range. | | `image-range-overlap` | Two images claim the same type definitions. | -| `invalid-string` | A name index does not start a string, or the name is unterminated or not valid UTF-8. | +| `invalid-string` | A name index is outside the strings section, its string is unterminated, or it is not valid UTF-8. | 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`. -Two checks are stricter than the IL2CPP runtime: a name index must start a -string, and an empty range's start must lie between -1 and the collection size. -Every Unity-written file checked so far satisfies both; metadata rewritten by -other tools may be rejected. +An empty range's start must lie between -1 and the collection size, which is +stricter than some runtime paths. String indices are byte offsets; a valid +offset may select a suffix of another NUL-terminated name. An incomplete inventory is never reported as complete. Failures after a verified snapshot retain partial source Evidence with its SHA-256, byte length and any diff --git a/src/il2cpp/Il2cppMetadataFile.ts b/src/il2cpp/Il2cppMetadataFile.ts index ea4858ce7b..2685474bb0 100644 --- a/src/il2cpp/Il2cppMetadataFile.ts +++ b/src/il2cpp/Il2cppMetadataFile.ts @@ -341,10 +341,9 @@ const recordCounts = ( }; /** - * Decode NUL-terminated UTF-8 names once each from the strings section. An - * index must start a string, so a shifted section or a pointer into another - * name fails instead of returning a different name, and total decoding stays - * bounded by the section size. + * Decode NUL-terminated UTF-8 names once each from the strings section. Indices + * are byte offsets and may refer to a suffix of another stored name. Bounds, + * termination, UTF-8 validity, and the total decoding budget remain enforced. */ const createStringReader = ( bytes: Buffer, @@ -364,11 +363,6 @@ const createStringReader = ( "invalid-string", `${label} string index ${index} has no terminated entry inside the ${strings.size}-byte string section`, ); - if (index > 0 && pool[index - 1] !== 0) - throw new Il2cppMetadataFormatFailure( - "invalid-string", - `${label} string index ${index} starts inside another string`, - ); beforeText?.(end - index); let value: string; try { diff --git a/tests/boundary/cli/il2cppSharedSuffixNames.test.ts b/tests/boundary/cli/il2cppSharedSuffixNames.test.ts new file mode 100644 index 0000000000..f8166b4abb --- /dev/null +++ b/tests/boundary/cli/il2cppSharedSuffixNames.test.ts @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { il2cppMetadataSchema } from "../../../src/domain/il2cpp/il2cppMetadata.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const parseResult = (value: unknown) => + il2cppMetadataSchema.parse( + toolContract("inspect_il2cpp_metadata").outputSchema.parse(value) + .normalized_result, + ); + +cliTest( + "decodes shared-suffix IL2CPP names through CLI and MCP", + async ({ cli }) => { + const directory = await createTestTempDirectory( + "rea-il2cpp-shared-suffix-", + ); + const { call } = await connectLocalToolsMcp(); + for (const version of [29, 39] as const) { + const fixture = createIl2cppMetadataFixture({ + version, + typeNames: { 0: "xPlayer" }, + }); + const bytes = Buffer.from(fixture.bytes); + const nameOffsetLocation = fixture.record("typeDefinitions", 0); + bytes.writeInt32LE( + bytes.readInt32LE(nameOffsetLocation) + 1, + nameOffsetLocation, + ); + const path = join(directory, `metadata-${version}.dat`); + await writeFile(path, bytes); + + const cliOutput = await cli.run({ + arguments: ["inspect-il2cpp-metadata", path, "--json"], + environment: { + REA_LOG_LEVEL: "silent", + HOME: directory, + USERPROFILE: directory, + }, + }); + expect(cliOutput.exitCode, cliOutput.stderr).toBe(0); + const cliResult = parseResult(cliOutput.json); + expect(cliResult.types[0]?.name).toBe("Player"); + expect(cliResult.artifact.sha256).toBe( + createHash("sha256").update(bytes).digest("hex"), + ); + + const mcpOutput = await call("inspect_il2cpp_metadata", { + path, + detail: "complete", + }); + expect(mcpOutput.isError, JSON.stringify(mcpOutput)).not.toBe(true); + const mcpResult = parseResult(mcpOutput.structuredContent); + expect(mcpResult).toEqual(cliResult); + expect(await readFile(path)).toEqual(bytes); + } + }, +); diff --git a/tests/boundary/il2cpp/il2cppMetadataFile.test.ts b/tests/boundary/il2cpp/il2cppMetadataFile.test.ts index 8418e3313a..6fdc381cd0 100644 --- a/tests/boundary/il2cpp/il2cppMetadataFile.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataFile.test.ts @@ -408,40 +408,44 @@ describe("rejected IL2CPP metadata references", () => { ); }); - 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, - ); + it("rejects a string section overlapping literal data", () => { rejects( - mutated((fixture, bytes) => + mutated((fixture, bytes) => { bytes.writeInt32LE( - fixture.stringOffset("Update") + - 1 - - fixture.section("strings").offset, - fixture.record("methods", 0), - ), - ), - "invalid-string", + fixture.section("strings").offset - 1, + fixture.header("strings", "offset"), + ); + }), + "section-overlap", ); }); + it("decodes a valid string suffix from its byte offset", () => { + const fixture = createIl2cppMetadataFixture({ + typeNames: { 0: "xPlayer" }, + }); + const bytes = Buffer.from(fixture.bytes); + const originalOffset = fixture.record("typeDefinitions", 0); + bytes.writeInt32LE(bytes.readInt32LE(originalOffset) + 1, originalOffset); + + expect(readIl2cppMetadata(bytes).types[0]?.name).toBe("Player"); + }); + + it("accepts a UTF-8 code-point boundary suffix and rejects a split code point", () => { + const fixture = createIl2cppMetadataFixture({ + typeNames: { 0: "xéclair" }, + }); + const nameOffset = fixture.record("typeDefinitions", 0); + const sourceOffset = fixture.bytes.readInt32LE(nameOffset); + const validSuffix = Buffer.from(fixture.bytes); + validSuffix.writeInt32LE(sourceOffset + 1, nameOffset); + expect(readIl2cppMetadata(validSuffix).types[0]?.name).toBe("éclair"); + + const splitCodePoint = Buffer.from(fixture.bytes); + splitCodePoint.writeInt32LE(sourceOffset + 2, nameOffset); + rejects(splitCodePoint, "invalid-string"); + }); + it("rejects nesting cycles and self-nesting", () => { // Type 0 owns nestedTypes entry 0; listing itself there is a self-cycle. rejects( @@ -576,9 +580,7 @@ describe("IL2CPP global-metadata versions 29 and 31", () => { mutated( (target, bytes) => bytes.writeInt32LE( - target.stringOffset("Update") + - 1 - - target.section("strings").offset, + target.section("strings").size + 1, target.record("methods", 0), ), fixture,