fix(il2cpp): decode valid shared-suffix string offsets
Preserve bounds, termination and UTF-8 checks; verify CLI and stdio MCP preserve names and selected artifact identity. Fixes #6808
This commit is contained in:
1 parent
fc63fe8682
commit
4c5fae5179
4 files changed
+106
-46
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user