Files
rea/tests/boundary/il2cpp/il2cppMetadataFile.test.ts
T

547 lines
16 KiB
TypeScript
Raw Normal View History

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));
},
);
});