From 2705aad3654d143562fa29ca20f3cb991308c044 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:58:48 +0800 Subject: [PATCH] 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 --- docs/il2cpp-metadata.md | 28 ++-- src/domain/il2cpp/il2cppMetadata.ts | 19 ++- src/il2cpp/Il2cppBinaryFile.ts | 3 +- src/il2cpp/Il2cppBinaryFormat.ts | 3 - src/il2cpp/Il2cppBinaryImage.ts | 102 ++++++-------- src/il2cpp/Il2cppMetadataProvider.ts | 17 ++- src/native/metadata/Il2cppContainer.test.ts | 4 +- src/native/metadata/MachoSliceSelection.ts | 63 +++++++-- tests/acceptance/il2cppMetadataCli.test.ts | 11 +- .../boundary/il2cpp/il2cppBinaryFile.test.ts | 130 +++++++++++++----- .../il2cpp/il2cppMappingOwnership.test.ts | 2 +- 11 files changed, 243 insertions(+), 139 deletions(-) diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 5c0aff16d0..f9154a8ba8 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -156,14 +156,18 @@ preserves every decoded row for subsequent queries without the original file. exactly one slice of a universal Mach-O and `--generic-instances` adds generic method instances. The report retains the native reader's `amd64` spelling for x86-64; loader CPU checks use the corresponding `x86_64` -identity. Multiple slices with the same architecture label are refused as -`ambiguous-slice-selection`, including -distinct ARM subtypes that both map to `arm64`. On macOS, use `lipo -info` -to enumerate exact subtypes, then extract the intended subtype into a separate -file, for example `lipo universal -thin arm64e -output thin-arm64e`. Inspect -that thin file without `--architecture`. An exact `arm64e` slice is not -selected by `lipo -thin arm64`. Thinning resolves only selection ambiguity; -the metadata must still match and unsupported pointer encodings remain unsupported. +identity. Slices are selected as for the other native inspectors: a CPU family +with one slice is read by its family name, and when several slices share a +family the value must name one slice's subtype exactly, so `arm64` reads the +`arm64` slice beside an `arm64e` one. Selection problems are invalid +`binary_architecture` requests, and a damaged FAT table is an invalid +`binary_path`. `arm64e` is not a `binary_architecture` value: when the +intended slice is the only arm64e slice among several arm64-family slices, use +`lipo -info` to enumerate exact subtypes on macOS, extract the slice into a +separate file, for example `lipo universal -thin arm64e -output thin-arm64e`, +and inspect that thin file without `--architecture`. Thinning resolves only +selection; the metadata must still match and unsupported pointer encodings +remain unsupported. ## Observations @@ -209,7 +213,8 @@ unneeded rows out of agent context without discarding the source Evidence. `GameAssembly.dylib` (Mach-O), Windows `GameAssembly.dll` (64-bit PE) or Android `libil2cpp.so` (64-bit ELF, AArch64 or x86-64). A universal Mach-O needs `binary_architecture` (`arm64` or `amd64`); a thin binary -rejects it. The file is read, never loaded or executed. +rejects it. A universal result lists other slices of the selected CPU family +that were not read in `limitations`. The file is read, never loaded or executed. A position-independent `libil2cpp.so` stores zero in most pointer slots and keeps the value in `R_AARCH64_RELATIVE` or `R_X86_64_RELATIVE` relocations @@ -434,9 +439,6 @@ The builds checked are listed after the module search rules above. | Reason | Category | Meaning | | --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------ | | `unsupported-container` | unsupported | Not a 64-bit little-endian Mach-O, PE or ELF image (ELF: AArch64 or x86-64 with dynamic section). | -| `slice-selection-required` | unsupported | A universal Mach-O was given without `binary_architecture`. | -| `ambiguous-slice-selection` | unsupported | More than one universal slice matches the selected architecture label; select an exact subtype externally. | -| `slice-not-found` | unsupported | The requested slice is absent, or a thin binary was given an architecture. | | `unsupported-pointer-encoding` | unsupported | Mach-O chained fixups or Android packed (APS2) ELF relocations; pointers not decoded. | | `malformed-container` | input error | The container headers are damaged. | | `code-gen-modules-not-found` | input error | An image has no module record; often a binary from a different build. | @@ -451,7 +453,7 @@ The builds checked are listed after the module search rules above. | `generic-method-registration-not-found` | input error | No CodeRegistration layout bounds every genericMethodTable row, or the generic tables are unreadable. | | `ambiguous-generic-method-registration` | input error | More than one CodeRegistration layout bounds every genericMethodTable row. | -Format and selection failures name `binary_path`; capacity refusals remain typed resource errors. Failed linking preserves the metadata source identity, validated counts, selected binary path/architecture and its SHA-256 when observed, plus prior producer observations. It never publishes an incomplete native table as complete. The binary input is bounded to 1 GiB. The virtual-pointer index has the same 1 GiB estimated representation allowance, reserving 2 KiB per distinct virtual destination before insertion for retained Maps and finish-time lookup storage. Repeated declarations of one destination share that reservation. This conservative allocation accounting is separate from actual process RSS and the report allowance; exhaustion returns a typed `native-index-capacity` refusal before another destination is retained. +Format failures name `binary_path` and slice-selection failures name `binary_architecture`; capacity refusals remain typed resource errors. Failed linking preserves the metadata source identity, validated counts, selected binary path/architecture and its SHA-256 when observed, plus prior producer observations. It never publishes an incomplete native table as complete. The binary input is bounded to 1 GiB. The virtual-pointer index has the same 1 GiB estimated representation allowance, reserving 2 KiB per distinct virtual destination before insertion for retained Maps and finish-time lookup storage. Repeated declarations of one destination share that reservation. This conservative allocation accounting is separate from actual process RSS and the report allowance; exhaustion returns a typed `native-index-capacity` refusal before another destination is retained. Scans yield cooperatively in bounded batches. Name-address retention and reverse-trie construction are each limited to 65,536 entries/nodes and fallback module-array candidates to 64 (`native-index-capacity`); exceeding capacity does not prove absence. Rendered type text has a 64 MiB UTF-8 cache-workspace guard, separate from the 64 MiB encoded-report admission, and 64 nested rendering frames (`native-type-text-capacity` / `type-nesting-capacity`). Genuine cycles are malformed; unsupported type kinds are explicit coverage failures. Native row/text admission runs before allocation or retention. Unused generic parameter names are read on demand from the same stable metadata snapshot. There is no cross-inspection file cache; validated immutable Evidence is reused for session views. diff --git a/src/domain/il2cpp/il2cppMetadata.ts b/src/domain/il2cpp/il2cppMetadata.ts index 7366458f77..a2fe0f12b5 100644 --- a/src/domain/il2cpp/il2cppMetadata.ts +++ b/src/domain/il2cpp/il2cppMetadata.ts @@ -5,8 +5,25 @@ import { localPathStringSchema, } from "../localPath.js"; import { canonicalBase64Schema } from "../stringPatterns.js"; +import type { MachoSliceArchitecture } from "../apple/machoTargetSelection.js"; import { firstDeclaringTypeCycle } from "./il2cppDeclaringTypes.js"; +/** The public `binary_architecture` values, in the native reader's spelling. */ +const IL2CPP_BINARY_ARCHITECTURES = ["arm64", "amd64"] as const; + +/** + * Each public `binary_architecture` value as the shared Mach-O slice + * selector it means; `amd64` is the x86_64 family. + */ +export const IL2CPP_SLICE_SELECTORS = { + arm64: "arm64", + amd64: "x86_64", +} as const satisfies Record< + (typeof IL2CPP_BINARY_ARCHITECTURES)[number], + MachoSliceArchitecture +>; +export type Il2cppBinaryArchitecture = keyof typeof IL2CPP_SLICE_SELECTORS; + /** Unsigned little-endian signature observed at file offset zero. */ export const IL2CPP_METADATA_MAGIC = 0xfab11baf; @@ -106,7 +123,7 @@ export const inspectIl2cppMetadataInputSchema = z "Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed", ), binary_architecture: z - .enum(["arm64", "amd64"]) + .enum(IL2CPP_BINARY_ARCHITECTURES) .optional() .describe( "Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise", diff --git a/src/il2cpp/Il2cppBinaryFile.ts b/src/il2cpp/Il2cppBinaryFile.ts index 9dc93f4e3a..16102a197e 100644 --- a/src/il2cpp/Il2cppBinaryFile.ts +++ b/src/il2cpp/Il2cppBinaryFile.ts @@ -82,14 +82,13 @@ export function* linkIl2cppBinarySteps( bytes: Buffer, metadata: Il2cppMetadataDecoded, facts: Il2cppMetadataLinkFacts, - architecture?: string, options: Il2cppBinaryReadOptions = {}, ): Generator { const version = metadata.metadata_version; // Refuse an undecoded version before any table is read. if (options.genericInstances === true) genericMethodLayouts(version); yield; - const image = yield* openImageSteps(bytes, architecture); + const image = yield* openImageSteps(bytes); const modules = yield* findModules(image, metadata); const registration_ = yield* findCodeRegistration(image, modules); const methodImage = new Int32Array(metadata.methods.length).fill(-1); diff --git a/src/il2cpp/Il2cppBinaryFormat.ts b/src/il2cpp/Il2cppBinaryFormat.ts index 41c387a58c..07d70f5176 100644 --- a/src/il2cpp/Il2cppBinaryFormat.ts +++ b/src/il2cpp/Il2cppBinaryFormat.ts @@ -4,9 +4,6 @@ export const IL2CPP_BINARY_BYTES = 1024 * 1024 * 1024; export const IL2CPP_BINARY_FAILURE_REASONS = { unsupported: [ "unsupported-container", - "slice-selection-required", - "slice-not-found", - "ambiguous-slice-selection", "unsupported-pointer-encoding", "unsupported-type-kind", "generic-methods-unsupported", diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index e83a01b110..9c0607bdb1 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -1,8 +1,8 @@ -import { - readUniversalMachoImage, - readUniversalMachoSlice, -} from "../domain/apple/machoImageSelection.js"; import { binaryMetadataArchitecture } from "../domain/binaryMetadataArchitecture.js"; +import { + IL2CPP_SLICE_SELECTORS, + type Il2cppBinaryArchitecture, +} from "../domain/il2cpp/il2cppMetadata.js"; import { BinaryFormatFailure, BinaryMappingFailure, @@ -14,6 +14,11 @@ import { type FileMappingIndex, } from "../native/metadata/BinaryContainer.js"; import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; +import { + requireSelectedNativeImage, + type SelectedNativeImage, + type SliceSelectionInput, +} from "../native/metadata/MachoSliceSelection.js"; import { parsePointerFixupsSteps, type PointerFixups, @@ -31,60 +36,40 @@ import { Il2cppBinaryFormatFailure, IL2CPP_BINARY_BYTES, } from "./Il2cppBinaryFormat.js"; -const MACHO_CPU = new Map([ - [0x01000007, "amd64"], - [0x0100000c, "arm64"], -]); -/** Select one thin image; a universal Mach-O needs the caller's architecture. */ -const selectImage = ( - bytes: Buffer, - architecture: string | undefined, -): Buffer => { - const container = readUniversalMachoImage(bytes); - if (!container.ok) - throw new Il2cppBinaryFormatFailure( - "malformed-container", - container.error.message, - ); - const fat = container.value; - if (fat === null) { - if (architecture !== undefined) - throw new Il2cppBinaryFormatFailure( - "slice-not-found", - `architecture ${architecture} was requested but the binary is not a universal Mach-O`, - ); - return bytes; - } - const available = fat.slices.map( - (slice) => MACHO_CPU.get(slice.cpuType) ?? `macho-cpu-${slice.cpuType}`, - ); - if (architecture === undefined) - throw new Il2cppBinaryFormatFailure( - "slice-selection-required", - `universal Mach-O holds ${available.join(", ")}; select one architecture`, - ); - if (available.filter((value) => value === architecture).length > 1) - throw new Il2cppBinaryFormatFailure( - "ambiguous-slice-selection", - `multiple universal slices declare ${architecture}; no slice was selected`, - ); - const index = available.indexOf(architecture); - const slice = fat.slices[index]; - if (slice === undefined) - throw new Il2cppBinaryFormatFailure( - "slice-not-found", - `universal Mach-O holds ${available.join(", ")}, not ${architecture}`, - ); - const selected = readUniversalMachoSlice(bytes, fat, slice); - if (!selected.ok) - throw new Il2cppBinaryFormatFailure( - "malformed-container", - selected.error.message, - ); - return selected.value; +/** Selection failures name the binary's inputs and IL2CPP's public spellings. */ +const IL2CPP_SLICE_SELECTION_INPUT: SliceSelectionInput = { + pathField: "binary_path", + architectureField: "binary_architecture", + spelling: Object.fromEntries( + Object.entries(IL2CPP_SLICE_SELECTORS).map(([publicName, selector]) => [ + selector, + publicName, + ]), + ), }; +/** + * Select the thin image of the caller's binary: IL2CPP's `binary_architecture` + * maps onto the shared slice selector here, once, and a selection failure + * names `binary_architecture` or `binary_path`. + */ +export const requireIl2cppBinaryImage = ( + operation: string, + path: string, + bytes: Buffer, + architecture: Il2cppBinaryArchitecture | undefined, +): SelectedNativeImage => + requireSelectedNativeImage( + operation, + path, + bytes, + architecture === undefined + ? undefined + : IL2CPP_SLICE_SELECTORS[architecture], + IL2CPP_SLICE_SELECTION_INPUT, + ); + export interface NativeImage { readonly bytes: Buffer; readonly container: BinaryContainer; @@ -97,11 +82,8 @@ export interface NativeImage { readonly machoPointerFixups?: PointerFixups; } -export function* openImageSteps( - bytes: Buffer, - architecture: string | undefined, -): Generator { - const image = selectImage(bytes, architecture); +/** Open one thin image; a universal Mach-O slice is selected before this. */ +export function* openImageSteps(image: Buffer): Generator { let container: BinaryContainer; try { container = readBinaryContainer(image); diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index dcd6c18720..3933611e54 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -32,7 +32,10 @@ import type { Il2cppMetadataPort } from "../application/il2cpp/Il2cppMetadataPor import { AnalysisError } from "../domain/analysisErrorBase.js"; import { createEvidence } from "../domain/evidence.js"; import { createIl2cppMetadataAdmission } from "./Il2cppMetadataAdmission.js"; -import { hexAddress as hex } from "./Il2cppBinaryImage.js"; +import { + hexAddress as hex, + requireIl2cppBinaryImage, +} from "./Il2cppBinaryImage.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; import { AnalysisCapabilityUnavailableError, @@ -508,6 +511,7 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { const decoded = "metadata" in value ? value.metadata : value; const link = "metadata" in value ? value.link : undefined; const binaryPath = input.binary_path; + let sliceLimitations: readonly string[] = []; let linked: Pick< Il2cppMetadata, | "binary" @@ -540,11 +544,17 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { }, ); assertArtifactReadActive(options?.signal); - const linking = linkIl2cppBinarySteps( + const selected = requireIl2cppBinaryImage( + OPERATION, + binaryPath, binary.bytes, + input.binary_architecture, + ); + sliceLimitations = selected.limitations; + const linking = linkIl2cppBinarySteps( + selected.bytes, decoded, link, - input.binary_architecture, { ...admission, genericInstances: input.generic_instances === true, @@ -613,6 +623,7 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort { : [ "The binary/metadata pair is caller-selected. Module name/count/token agreement is structural evidence, not a cryptographic proof that both files come from the same build.", ]), + ...sliceLimitations, "declaring_type for types is derived from the nestedTypes section, and member and parameter owners from the owners' index ranges; row positions are the metadata indices.", ], }; diff --git a/src/native/metadata/Il2cppContainer.test.ts b/src/native/metadata/Il2cppContainer.test.ts index a9bea21e3c..e14a75db91 100644 --- a/src/native/metadata/Il2cppContainer.test.ts +++ b/src/native/metadata/Il2cppContainer.test.ts @@ -19,7 +19,7 @@ it.each(["unmapped", "zero-fill", "duplicate", "out-of-file"] as const)( readBinaryContainer(control).load_regions, ); - const image = openImageSteps(goNamed, undefined).next(); + const image = openImageSteps(goNamed).next(); expect(image.done).toBe(true); if (!image.done) throw new Error("container admission unexpectedly yielded"); @@ -39,7 +39,7 @@ it.each(["unmapped", "zero-fill", "duplicate", "out-of-file"] as const)( ), ).toHaveLength(fault === "duplicate" ? 2 : 1); - const controlImage = openImageSteps(control, undefined).next(); + const controlImage = openImageSteps(control).next(); expect(controlImage.done).toBe(true); if (!controlImage.done) throw new Error("control container admission unexpectedly yielded"); diff --git a/src/native/metadata/MachoSliceSelection.ts b/src/native/metadata/MachoSliceSelection.ts index 95cdccb7e4..d2fff0dfde 100644 --- a/src/native/metadata/MachoSliceSelection.ts +++ b/src/native/metadata/MachoSliceSelection.ts @@ -35,6 +35,30 @@ export interface SelectedNativeImage { readonly limitations: readonly string[]; } +/** + * The caller's input fields and spellings for a slice selection. Messages name + * selectors through `spelling`, and a selector without a spelling is never + * offered as a choice. + */ +export interface SliceSelectionInput { + readonly pathField: string; + readonly architectureField: string; + readonly spelling: Readonly>>; +} + +/** The shared `path` and `architecture` inputs, spelled as the selector values. */ +export const SHARED_SLICE_SELECTION_INPUT: SliceSelectionInput = { + pathField: "path", + architectureField: "architecture", + spelling: { + x86: "x86", + x86_64: "x86_64", + arm: "arm", + arm64: "arm64", + arm64e: "arm64e", + }, +}; + /** Why no image could be selected; `architecture` issues are the caller's to correct. */ export interface MachoSliceSelectionIssue { readonly kind: "architecture" | "malformed"; @@ -87,7 +111,11 @@ const addressedSlices = ( export const selectNativeImage = ( bytes: Buffer, architecture: MachoSliceArchitecture | undefined, + input: SliceSelectionInput = SHARED_SLICE_SELECTION_INPUT, ): Result => { + const field = input.architectureField; + const spelled = (value: MachoSliceArchitecture): string => + input.spelling[value] ?? value; const container = readUniversalMachoImage(bytes); if (!container.ok) return container; const fat = container.value; @@ -96,7 +124,7 @@ export const selectNativeImage = ( ? ok({ bytes, fileOffset: 0, selection: null, limitations: [] }) : err({ kind: "architecture", - message: `architecture ${architecture} was selected, but the file is not a universal Mach-O; omit architecture.`, + message: `${field} ${spelled(architecture)} was selected, but the file is not a universal Mach-O; omit ${field}.`, }); const available = fat.slices .map(({ cpuType, cpuSubtype }) => cpuName(cpuType, cpuSubtype)) @@ -131,20 +159,22 @@ export const selectNativeImage = ( if (slice === undefined || matches.length > 1) { const choices = familyValues(architecture).filter( (value) => - value !== architecture && verifiedSliceFor(value) !== undefined, + value !== architecture && + input.spelling[value] !== undefined && + verifiedSliceFor(value) !== undefined, ); const select = choices.length === 0 ? "" - : ` Select architecture ${choices.join(" or ")}.`; + : ` Select ${field} ${choices.map(spelled).join(" or ")}.`; return err({ kind: "architecture", message: sameFamily.length === 0 - ? `Universal Mach-O holds ${available}, not ${architecture}.${select}` + ? `Universal Mach-O holds ${available}, not ${spelled(architecture)}.${select}` : matches.length === 0 && namesSubtype(architecture) - ? `Universal Mach-O holds ${available}; no slice has the ${architecture} subtype.${select}` - : `Universal Mach-O holds several ${architecture} slices (${available}); no slice was selected.${select}`, + ? `Universal Mach-O holds ${available}; no slice has the ${spelled(architecture)} subtype.${select}` + : `Universal Mach-O holds several ${spelled(architecture)} slices (${available}); no slice was selected.${select}`, }); } const selected = readUniversalMachoSlice(bytes, fat, slice); @@ -155,11 +185,13 @@ export const selectNativeImage = ( const place = `${cpuName(other.cpuType, other.cpuSubtype)} at file offset ${String(other.offset)}`; const issue = identityIssues.get(other); if (issue !== undefined) - return `${place} (unverified: ${issue}; no architecture value reads it)`; + return `${place} (unverified: ${issue}; no ${field} value reads it)`; const selector = familyValues(architecture).find( - (value) => verifiedSliceFor(value) === other, + (value) => + input.spelling[value] !== undefined && + verifiedSliceFor(value) === other, ); - return `${place} (${selector === undefined ? "no architecture value selects it" : `architecture ${selector} selects it`})`; + return `${place} (${selector === undefined ? `no ${field} value selects it` : `${field} ${spelled(selector)} selects it`})`; }); return ok({ bytes: selected.value, @@ -179,30 +211,31 @@ export const selectNativeImage = ( unread.length === 0 ? [] : [ - `Only the selected ${architecture} slice (${cpuName(slice.cpuType, slice.cpuSubtype)} at file offset ${String(slice.offset)}) was read; this universal Mach-O also holds ${unread.join(", ")}.`, + `Only the selected ${spelled(architecture)} slice (${cpuName(slice.cpuType, slice.cpuSubtype)} at file offset ${String(slice.offset)}) was read; this universal Mach-O also holds ${unread.join(", ")}.`, ], }); }; /** * Select the image for one inspection, or fail with the input to correct: - * `architecture` for a missing or mismatched selection, `path` for a malformed - * universal container. + * the architecture field for a missing or mismatched selection, the path field + * for a malformed universal container. */ export const requireSelectedNativeImage = ( operation: string, path: string, bytes: Buffer, architecture: MachoSliceArchitecture | undefined, + input: SliceSelectionInput = SHARED_SLICE_SELECTION_INPUT, ): SelectedNativeImage => { - const selected = selectNativeImage(bytes, architecture); + const selected = selectNativeImage(bytes, architecture, input); if (selected.ok) return selected.value; const { kind, message } = selected.error; throw new AnalysisInputError(operation, undefined, [ kind === "architecture" - ? { path: ["architecture"], reason: "invalid_value", message } + ? { path: [input.architectureField], reason: "invalid_value", message } : { - path: ["path"], + path: [input.pathField], reason: "invalid_format", message: `Selected artifact ${path}: ${message}`, }, diff --git a/tests/acceptance/il2cppMetadataCli.test.ts b/tests/acceptance/il2cppMetadataCli.test.ts index 9e162c60cd..eb6e4ba0e3 100644 --- a/tests/acceptance/il2cppMetadataCli.test.ts +++ b/tests/acceptance/il2cppMetadataCli.test.ts @@ -995,8 +995,15 @@ cliTest( binary_path: binaryPath, }); expect(parseMcpToolError(missing).error).toMatchObject({ - code: "unsupported_target", - message: expect.stringContaining("slice-selection-required"), + code: "invalid_request", + details: { + issues: [ + { + path: ["binary_architecture"], + message: expect.stringContaining("select one architecture"), + }, + ], + }, }); const summarized = await call("inspect_il2cpp_metadata", { path, diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index d7bff95e11..e51521b883 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -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(/(? { @@ -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) => { diff --git a/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts b/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts index c6ec232a78..cf1e0c867c 100644 --- a/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts +++ b/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts @@ -23,7 +23,7 @@ const { metadata, link: facts } = readIl2cppMetadataForLinking( createLinkedIl2cppMetadataFixture().bytes, ); const link = (bytes: Buffer) => linkIl2cppBinary(bytes, metadata, facts); -const open = (bytes: Buffer) => drain(openImageSteps(bytes, undefined)); +const open = (bytes: Buffer) => drain(openImageSteps(bytes)); /** Replace PT_LOAD declarations, keeping the original PT_DYNAMIC and payload. */ const withLoadRanges = (