fix(il2cpp): select universal binary slices through the shared Mach-O selection (#10661)

* fix(il2cpp): select universal binary slices through the shared Mach-O selection

* test(il2cpp): assert selection facts instead of full sentences
This commit is contained in:
morluto authored and GitHub committed 2026-11-14 18:58:48 -05:00
1 parent ef15353c69
commit 2705aad365
11 files changed
+243 -139

No files matched your search

+93 -37
View File
@@ -4,7 +4,10 @@ import { readBinaryContainer } from "../../../src/native/metadata/readBinaryCont
import {
openImageSteps,
readUInt32,
requireIl2cppBinaryImage,
} from "../../../src/il2cpp/Il2cppBinaryImage.js";
import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js";
import type { Il2cppBinaryArchitecture } from "../../../src/domain/il2cpp/il2cppMetadata.js";
import { expect, it } from "vitest";
import { linkIl2cppBinarySteps } from "../../../src/il2cpp/Il2cppBinaryFile.js";
import {
@@ -51,8 +54,18 @@ const expectedAddress = (value: bigint | "no-body" | "unknown") =>
}
: { state: value, address: null, rva: null };
const expectedAddresses = FIXTURE_METHOD_ADDRESSES.map(expectedAddress);
const link = (bytes: Buffer, architecture?: string) =>
linkIl2cppBinary(bytes, metadata, facts, architecture);
// The provider selects a universal slice before linking, as here.
const link = (bytes: Buffer, architecture?: Il2cppBinaryArchitecture) =>
linkIl2cppBinary(
requireIl2cppBinaryImage(
"inspect_il2cpp_metadata",
"/GameAssembly.dylib",
bytes,
architecture,
).bytes,
metadata,
facts,
);
// A type-table fallback needs the metadata to reference most of the table, as
// real builds do; referencing type 3 requires all four fixture entries.
const wholeTable = {
@@ -61,9 +74,9 @@ const wholeTable = {
};
const linkWholeTable = (bytes: Buffer) =>
linkIl2cppBinary(bytes, metadata, wholeTable);
const failure = (bytes: Buffer, architecture?: string) => {
const failure = (bytes: Buffer) => {
try {
link(bytes, architecture);
link(bytes);
} catch (cause) {
if (cause instanceof Il2cppBinaryFormatFailure)
return { reason: cause.reason, kind: cause.kind };
@@ -71,6 +84,19 @@ const failure = (bytes: Buffer, architecture?: string) => {
}
throw new Error("expected a link failure");
};
/** The input a slice selection asks the caller to correct, and why. */
const selectionIssue = (
bytes: Buffer,
architecture?: Il2cppBinaryArchitecture,
) => {
try {
link(bytes, architecture);
} catch (cause) {
if (cause instanceof AnalysisInputError) return cause.issues[0];
throw cause;
}
throw new Error("expected a selection failure");
};
const addElfLoadAlias = (
bytes: Buffer,
fileOffset: number,
@@ -213,7 +239,14 @@ it("does not infer universal Mach-O identity from a FAT-shaped non-Mach-O table"
reason: "unsupported-container",
kind: "unsupported",
});
expect(failure(bytes, "arm64").reason).toBe("slice-not-found");
// The message names IL2CPP's field and value, never the shared spelling.
const issue = selectionIssue(bytes, "arm64");
expect(issue).toMatchObject({
path: ["binary_architecture"],
reason: "invalid_value",
});
expect(issue?.message).toContain("binary_architecture arm64");
expect(issue?.message).not.toMatch(/(?<!binary_)architecture arm64/u);
});
it("requires an explicit slice of a universal Mach-O and reads only that slice", () => {
@@ -222,13 +255,41 @@ it("requires an explicit slice of a universal Mach-O and reads only that slice",
{ cpu: 0x01000007, bytes: createIl2cppBinaryFixture() },
{ cpu: 0x0100000c, bytes: thin },
]);
expect(failure(universal)).toEqual({
reason: "slice-selection-required",
kind: "unsupported",
expect(selectionIssue(universal)).toMatchObject({
path: ["binary_architecture"],
reason: "invalid_value",
message: expect.stringContaining("select one architecture"),
});
expect(link(universal, "arm64").method_addresses).toEqual(expectedAddresses);
expect(failure(universal, "ppc64").reason).toBe("slice-not-found");
expect(failure(thin, "arm64").reason).toBe("slice-not-found");
expect(selectionIssue(thin, "arm64")).toMatchObject({
path: ["binary_architecture"],
});
});
it("selects an exact arm64 subtype beside arm64e and names only IL2CPP selectors", () => {
const arm64 = createIl2cppBinaryFixture();
const arm64e = createIl2cppBinaryFixture();
arm64e.writeUInt32LE(2, 8);
const universal = createUniversalFixture([
{ cpu: 0x0100000c, bytes: arm64e },
{ cpu: 0x0100000c, bytes: arm64 },
]);
// Match each FAT declaration to the CPU subtype in its slice header.
universal.writeUInt32BE(2, 8 + 4);
expect(link(universal, "arm64").method_addresses).toEqual(expectedAddresses);
// arm64e is not an IL2CPP value, so no selector is offered for that slice.
expect(
requireIl2cppBinaryImage(
"inspect_il2cpp_metadata",
"/GameAssembly.dylib",
universal,
"arm64",
).limitations,
).toEqual([
expect.stringContaining(
"arm64 (subtype 0x2) at file offset 4096 (no binary_architecture value selects it)",
),
]);
});
it("rejects a universal Mach-O whose slices overlap before selecting one", () => {
@@ -241,9 +302,9 @@ it("rejects a universal Mach-O whose slices overlap before selecting one", () =>
const first = universal.readUInt32BE(8 + 8);
const second = universal.readUInt32BE(8 + 20 + 8);
universal.writeUInt32BE(second - first + 16, 8 + 12);
expect(failure(universal, "arm64")).toEqual({
reason: "malformed-container",
kind: "malformed",
expect(selectionIssue(universal, "arm64")).toMatchObject({
path: ["binary_path"],
reason: "invalid_format",
});
});
@@ -1118,17 +1179,11 @@ it("guards a shared generic expansion before concatenating unchecked text", () =
}
bytes.writeBigUInt64LE(prior, 0x900);
const stopped = new Error("type text admission");
const steps = linkIl2cppBinarySteps(
bytes,
expanded.metadata,
expanded.link,
undefined,
{
beforeText: (size) => {
if (size > 1024) throw stopped;
},
const steps = linkIl2cppBinarySteps(bytes, expanded.metadata, expanded.link, {
beforeText: (size) => {
if (size > 1024) throw stopped;
},
);
});
expect(() => {
for (;;) {
if (steps.next().done) break;
@@ -1194,10 +1249,7 @@ it.each([
] as const)(
"uses the four-byte count span for a dyld bind at %s",
(slot, raw, expected) => {
const steps = openImageSteps(
createIl2cppDyldBindFixture(raw, slot),
undefined,
);
const steps = openImageSteps(createIl2cppDyldBindFixture(raw, slot));
let step = steps.next();
while (!step.done) step = steps.next();
expect(readUInt32(step.value, FIXTURE_BINARY_BASE + 0x600n)).toBe(expected);
@@ -1305,13 +1357,19 @@ it("refuses an ambiguous architecture instead of choosing the first FAT slice",
{ cpu: 0x0100000c, bytes: createIl2cppBinaryFixture() },
{ cpu: 0x0100000c, bytes: createIl2cppBinaryFixture() },
]);
expect(failure(bytes, "arm64").reason).toBe("ambiguous-slice-selection");
expect(selectionIssue(bytes, "arm64")).toMatchObject({
path: ["binary_architecture"],
message: expect.stringContaining("no slice was selected"),
});
});
it("checks the observed slice header against the caller-selected FAT architecture", () => {
const bytes = createUniversalFixture([
{ cpu: 0x01000007, bytes: createIl2cppBinaryFixture() },
]);
expect(failure(bytes, "amd64").reason).toBe("malformed-container");
expect(selectionIssue(bytes, "amd64")).toMatchObject({
path: ["binary_path"],
reason: "invalid_format",
});
});
it.each([1, 0x80000000])(
"checks the complete FAT CPU subtype including capability bits (%i)",
@@ -1319,7 +1377,10 @@ it.each([1, 0x80000000])(
const thin = createIl2cppBinaryFixture();
thin.writeUInt32LE(subtype, 8);
const bytes = createUniversalFixture([{ cpu: 0x0100000c, bytes: thin }]);
expect(failure(bytes, "arm64").reason).toBe("malformed-container");
expect(selectionIssue(bytes, "arm64")).toMatchObject({
path: ["binary_path"],
reason: "invalid_format",
});
bytes.writeUInt32BE(subtype, 12);
expect(link(bytes, "arm64").method_addresses).toEqual(expectedAddresses);
},
@@ -1381,7 +1442,7 @@ const linkGeneric = (
bytes = createIl2cppBinaryFixture({ genericMethods: options }),
) => {
const decoded = genericFacts(version);
return linkIl2cppBinary(bytes, decoded.metadata, decoded.link, undefined, {
return linkIl2cppBinary(bytes, decoded.metadata, decoded.link, {
genericInstances: true,
});
};
@@ -1437,7 +1498,6 @@ it("admits every generic row and issue before retention", () => {
createIl2cppBinaryFixture({ genericMethods: { firstSpecMethod: 9 } }),
decoded.metadata,
decoded.link,
undefined,
{
genericInstances: true,
onRow: (row) => rows.push(row),
@@ -1461,7 +1521,6 @@ it("admits every generic row and issue before retention", () => {
createIl2cppBinaryFixture({ genericMethods: {} }),
decoded.metadata,
decoded.link,
undefined,
{
genericInstances: true,
onRow: (row) => refused.push(row),
@@ -1597,7 +1656,6 @@ it("refuses generic linking when the type table was inferred", () => {
...decoded.link,
returnTypes: Int32Array.from(decoded.link.returnTypes, () => 3),
},
undefined,
{ genericInstances: true },
),
).toThrow(
@@ -1726,7 +1784,6 @@ it("keeps a method spec unnamed when its owner's container is out of range", ()
createIl2cppBinaryFixture({ genericMethods: {} }),
decoded.metadata,
link,
undefined,
{ genericInstances: true },
).generic_methods!;
expect(generic.instances).toEqual([
@@ -1826,7 +1883,7 @@ const linkLowBase = (
const decoded = genericFacts(31);
return drain(
linkGenericMethodsSteps(
drain(openImageSteps(fixture.bytes, undefined)),
drain(openImageSteps(fixture.bytes)),
decoded.metadata,
decoded.link,
fixture.modulesField,
@@ -1906,7 +1963,6 @@ it("keeps a capacity refusal while naming a generic row a refusal", () => {
createIl2cppBinaryFixture({ genericMethods: {} }),
decoded.metadata,
decoded.link,
undefined,
{
genericInstances: true,
beforeText: (bytes) => {