From a090fa70983839e223cdd1e2949ecbd06d5bfa98 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:44:54 +0800 Subject: [PATCH] fix(il2cpp): report the exceeded native-link maximum in the public refusal (#10741) * fix(il2cpp): report the exceeded native-link maximum in the public refusal * fix(il2cpp): report each native-link budget's maximum in the unit its producer counts * refactor(elf): report pointer relocation failures through ParseFailure (#10748) --- docs/il2cpp-metadata.md | 2 +- src/il2cpp/Il2cppBinaryFormat.ts | 8 ++++ src/il2cpp/Il2cppBinaryImage.ts | 14 ++++--- src/il2cpp/Il2cppBinaryRegistration.ts | 4 ++ src/il2cpp/Il2cppBinaryTypes.ts | 2 + src/il2cpp/Il2cppMetadataProvider.ts | 13 +++++- src/native/android/AndroidJniLibrary.ts | 6 +-- src/native/initializers/ElfInitializers.ts | 14 +++---- .../NativeInitializersProvider.ts | 4 +- .../metadata/ElfPointerRelocations.test.ts | 7 +++- src/native/metadata/ElfPointerRelocations.ts | 41 ++++++++++++++++--- .../boundary/il2cpp/il2cppBinaryFile.test.ts | 1 + .../il2cpp/il2cppMetadataProvider.test.ts | 37 +++++++++++++++++ 13 files changed, 124 insertions(+), 29 deletions(-) diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index f9154a8ba8..591d1474cb 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -453,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 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. +Format failures name `binary_path` and slice-selection failures name `binary_architecture`; capacity refusals remain typed resource errors whose reported limits name the exceeded budget (`boundary`, the capacity reason) and its maximum in the unit the budget counts (`maximum_entries`, `maximum_bytes` or `maximum_frames`). 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/il2cpp/Il2cppBinaryFormat.ts b/src/il2cpp/Il2cppBinaryFormat.ts index da242590f4..570b7b834b 100644 --- a/src/il2cpp/Il2cppBinaryFormat.ts +++ b/src/il2cpp/Il2cppBinaryFormat.ts @@ -39,6 +39,9 @@ type Il2cppBinaryFormatReason = Exclude< Il2cppBinaryCapacityReason >; +/** What a native-link budget counts, so its maximum is reported with its unit. */ +export type NativeBudgetUnit = "entries" | "bytes" | "frames"; + /** The ParseFailure format of a native image linked against IL2CPP metadata. */ export const IL2CPP_BINARY_FORMAT = "il2cpp-binary"; @@ -55,16 +58,20 @@ const isUnsupported = (reason: Il2cppBinaryFormatReason) => export class Il2cppBinaryFormatFailure extends ParseFailureError< "malformed" | "unsupported" | "limit" > { + /** What the exceeded maximum counts; set only for a capacity reason. */ + readonly unit: NativeBudgetUnit | undefined; constructor(reason: Il2cppBinaryFormatReason, message: string); constructor( reason: Il2cppBinaryCapacityReason, message: string, maximum: number, + unit: NativeBudgetUnit, ); constructor( readonly reason: Il2cppBinaryFailureReason, message: string, maximum?: number, + unit?: NativeBudgetUnit, ) { const detail = `${reason}: ${message}`; super( @@ -84,6 +91,7 @@ export class Il2cppBinaryFormatFailure extends ParseFailureError< } : { kind: "malformed", format: IL2CPP_BINARY_FORMAT, detail }, ); + this.unit = unit; this.name = "Il2cppBinaryFormatFailure"; } } diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index d834e3c2a9..13f98e5145 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -94,6 +94,7 @@ export function* openImageSteps(image: Buffer): Generator { "native-index-capacity", cause.message, cause.failure.limit.maximum, + "bytes", ); if (cause instanceof BinaryFormatFailure) throw new Il2cppBinaryFormatFailure( @@ -126,14 +127,15 @@ export function* openImageSteps(image: Buffer): Generator { ); } catch (cause) { if (cause instanceof ElfPointerRelocationFailure) { + if (cause.failure.kind === "limit" && cause.unit !== undefined) + throw new Il2cppBinaryFormatFailure( + "native-index-capacity", + cause.message, + cause.failure.limit.maximum, + cause.unit, + ); if (cause.reason !== "native-index-capacity") throw new Il2cppBinaryFormatFailure(cause.reason, cause.message); - if (cause.maximum !== undefined) - throw new Il2cppBinaryFormatFailure( - cause.reason, - cause.message, - cause.maximum, - ); } if (cause instanceof BinaryFormatFailure) throw new Il2cppBinaryFormatFailure("malformed-container", cause.message); diff --git a/src/il2cpp/Il2cppBinaryRegistration.ts b/src/il2cpp/Il2cppBinaryRegistration.ts index ea82038c3e..15974b4229 100644 --- a/src/il2cpp/Il2cppBinaryRegistration.ts +++ b/src/il2cpp/Il2cppBinaryRegistration.ts @@ -113,6 +113,7 @@ function* nameAddresses( "native-index-capacity", `module-name trie exceeds its ${TOTAL_NAME_OCCURRENCES}-node workspace`, TOTAL_NAME_OCCURRENCES, + "entries", ); let node = root; const bytes = Buffer.from(name, "utf8"); @@ -125,6 +126,7 @@ function* nameAddresses( "native-index-capacity", `module-name trie exceeds its ${TOTAL_NAME_OCCURRENCES}-node workspace`, TOTAL_NAME_OCCURRENCES, + "entries", ); next = { next: new Map(), owners: [] }; node.next.set(bytes[at]!, next); @@ -165,6 +167,7 @@ function* nameAddresses( "native-index-capacity", `native name-address index exceeds ${TOTAL_NAME_OCCURRENCES} entries`, TOTAL_NAME_OCCURRENCES, + "entries", ); found.set(address, node.owners); } @@ -535,6 +538,7 @@ function* findModuleArrayByCode( "native-index-capacity", `module-array index exceeds ${MODULE_ARRAY_CANDIDATES} entries`, MODULE_ARRAY_CANDIDATES, + "entries", ); } } diff --git a/src/il2cpp/Il2cppBinaryTypes.ts b/src/il2cpp/Il2cppBinaryTypes.ts index 63826fb2b3..85dca2b51f 100644 --- a/src/il2cpp/Il2cppBinaryTypes.ts +++ b/src/il2cpp/Il2cppBinaryTypes.ts @@ -107,6 +107,7 @@ const createTypeRenderer = ( "native-type-text-capacity", `rendered type text workspace exceeds ${TYPE_TEXT_BYTES} UTF-8 bytes`, TYPE_TEXT_BYTES, + "bytes", ); beforeText?.(bytes); }; @@ -133,6 +134,7 @@ const createTypeRenderer = ( "type-nesting-capacity", `type rendering exceeds ${TYPE_DEPTH} nested frames`, TYPE_DEPTH, + "frames", ); active.add(address); try { diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 17984e092a..6f0e3fe42b 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -83,12 +83,21 @@ const failureForPrimary = ( const { cause, origin } = facts; if (origin === "owned-or-classified" && cause instanceof AnalysisError) return cause; - if (cause instanceof Il2cppBinaryFormatFailure && cause.kind === "limit") + if ( + cause instanceof Il2cppBinaryFormatFailure && + cause.failure.kind === "limit" + ) return new AnalysisResourceConstraintError( OPERATION, cause.reason === "native-type-text-capacity" ? "memory" : "capacity", cause.message, - {}, + // The producing site states what its maximum counts. + { + boundary: cause.reason, + ...(cause.unit === undefined + ? {} + : { [`maximum_${cause.unit}`]: cause.failure.limit.maximum }), + }, { cause, ...fixedAllowanceOptions( diff --git a/src/native/android/AndroidJniLibrary.ts b/src/native/android/AndroidJniLibrary.ts index 5d31e698dc..874582873c 100644 --- a/src/native/android/AndroidJniLibrary.ts +++ b/src/native/android/AndroidJniLibrary.ts @@ -501,8 +501,7 @@ export const inspectAndroidJniLibrary = async ( }); if ( cause instanceof BinaryResourceFailure || - (cause instanceof ElfPointerRelocationFailure && - cause.reason === "native-index-capacity") + (cause instanceof ElfPointerRelocationFailure && cause.kind === "limit") ) return err({ kind: "capacity", message: cause.message }); if (cause instanceof BinaryFormatFailure) @@ -512,8 +511,7 @@ export const inspectAndroidJniLibrary = async ( }); if (cause instanceof ElfPointerRelocationFailure) return err({ - kind: - cause.reason === "malformed-container" ? "malformed" : "unsupported", + kind: cause.kind === "malformed" ? "malformed" : "unsupported", message: cause.message, }); throw cause; diff --git a/src/native/initializers/ElfInitializers.ts b/src/native/initializers/ElfInitializers.ts index 9ae8e111b6..13d1cb4f71 100644 --- a/src/native/initializers/ElfInitializers.ts +++ b/src/native/initializers/ElfInitializers.ts @@ -645,13 +645,13 @@ const loadWrites = async ( } return { writes, clobbered, trustRaw: true }; } catch (cause) { - if (!(cause instanceof ElfPointerRelocationFailure)) throw cause; - if (cause.reason === "native-index-capacity") - throw new BinaryResourceFailure( - "structure", - 16 * 1024 * 1024, - cause.message, - ); + // A budget stop is not a malformed table; the provider reports it with + // the maximum the failing index declared. + if ( + !(cause instanceof ElfPointerRelocationFailure) || + cause.kind === "limit" + ) + throw cause; note(scan, cause.message); return { writes: new Map(), clobbered: new Set(), trustRaw: false }; } diff --git a/src/native/initializers/NativeInitializersProvider.ts b/src/native/initializers/NativeInitializersProvider.ts index 79083c6af5..bd0921eda9 100644 --- a/src/native/initializers/NativeInitializersProvider.ts +++ b/src/native/initializers/NativeInitializersProvider.ts @@ -199,13 +199,13 @@ const failurePrimary = ( if (identityFailure !== undefined) return identityFailure; if ( cause instanceof ElfPointerRelocationFailure && - cause.reason === "native-index-capacity" + cause.failure.kind === "limit" ) return new AnalysisResourceConstraintError( OPERATION, "capacity", `Selected artifact ${input.path}: ${cause.message}`, - { boundary: "structure" }, + { boundary: "structure", maximum: cause.failure.limit.maximum }, { cause, ...fixedAllowanceOptions( diff --git a/src/native/metadata/ElfPointerRelocations.test.ts b/src/native/metadata/ElfPointerRelocations.test.ts index fcf2692e8d..7ac0410b79 100644 --- a/src/native/metadata/ElfPointerRelocations.test.ts +++ b/src/native/metadata/ElfPointerRelocations.test.ts @@ -397,7 +397,12 @@ it("refuses pointer workspace growth before inserting the next virtual destinati expect(() => pointerValues(relativeValues(), 4096)).toThrow( expect.objectContaining({ reason: "native-index-capacity", - maximum: 4096, + failure: expect.objectContaining({ + kind: "limit", + format: "elf-pointer-relocations", + limit: { name: "native-index-capacity", maximum: 4096 }, + }), + unit: "bytes", message: expect.stringContaining("4096 estimated representation bytes"), }), ); diff --git a/src/native/metadata/ElfPointerRelocations.ts b/src/native/metadata/ElfPointerRelocations.ts index ff351a3556..f470afe97f 100644 --- a/src/native/metadata/ElfPointerRelocations.ts +++ b/src/native/metadata/ElfPointerRelocations.ts @@ -11,11 +11,21 @@ import { isZeroFilledSpan, type ElfDynamicSegment, } from "./BinaryContainer.js"; +import { ParseFailureError } from "../../domain/parseFailure.js"; -/** Shared loader interpretation; language adapters choose their public errors. */ -export class ElfPointerRelocationFailure extends Error { - /** The exceeded workspace bound, set only for `native-index-capacity`. */ - readonly maximum: number | undefined; +/** The ParseFailure format of ELF loader pointer interpretation. */ +export const ELF_POINTER_RELOCATION_FORMAT = "elf-pointer-relocations"; + +/** + * Shared loader interpretation; language adapters choose their public errors. + * The stable `reason` names the loader rule; `native-index-capacity` is a + * ParseFailure `limit` carrying the exceeded maximum. + */ +export class ElfPointerRelocationFailure extends ParseFailureError< + "malformed" | "unsupported" | "limit" +> { + /** What the limit's maximum counts, set only for `native-index-capacity`. */ + readonly unit: "entries" | "bytes" | undefined; constructor( reason: | "malformed-container" @@ -27,6 +37,7 @@ export class ElfPointerRelocationFailure extends Error { reason: "native-index-capacity", message: string, maximum: number, + unit: "entries" | "bytes", ); constructor( readonly reason: @@ -36,9 +47,23 @@ export class ElfPointerRelocationFailure extends Error { | "native-index-capacity", message: string, maximum?: number, + unit?: "entries" | "bytes", ) { - super(message); - this.maximum = maximum; + const facts = { format: ELF_POINTER_RELOCATION_FORMAT, detail: message }; + super( + maximum !== undefined + ? { ...facts, kind: "limit", limit: { name: reason, maximum } } + : reason === "malformed-container" + ? { ...facts, kind: "malformed" } + : { + ...facts, + kind: "unsupported", + // A pointer the loader encodes in an unread form still ships. + undecodedContent: reason === "unsupported-pointer-encoding", + }, + ); + this.unit = unit; + this.name = "ElfPointerRelocationFailure"; } } export interface ElfPointerRelocationSource { @@ -269,6 +294,7 @@ function* dynamicTags( "native-index-capacity", "ELF dynamic directory exceeds the 16 MiB structural allowance", 16 * 1024 * 1024, + "bytes", ); if ( declaration.size % BigInt(width) !== 0n || @@ -677,6 +703,7 @@ class ElfVirtualWriteIndex { "native-index-capacity", `ELF virtual pointer index exceeds ${this.maximumBytes} estimated representation bytes (${ELF_POINTER_INDEX_ENTRY_BYTES} reserved per virtual address, including lookup workspace)`, this.maximumBytes, + "bytes", ); this.#reservedBytes += ELF_POINTER_INDEX_ENTRY_BYTES; } @@ -828,6 +855,7 @@ export function* readElfPointerRelocationsSteps( "native-index-capacity", `ELF sparse relocation index exceeds ${MAX_RELOCATION_RECORDS} retained writes`, MAX_RELOCATION_RECORDS, + "entries", ); writes.push(write); index.add(write); @@ -895,6 +923,7 @@ export function* readElfPointerValuesSteps( "native-index-capacity", `ELF unresolved and zero-fill overlay exceeds ${MAX_RELOCATION_RECORDS} entries`, MAX_RELOCATION_RECORDS, + "entries", ); } next = steps.next(); diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index 9b9ec5f578..f74aab543f 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -1977,6 +1977,7 @@ it("keeps a capacity refusal while naming a generic row a refusal", () => { "native-type-text-capacity", "budget", 1, + "bytes", ); tableChecked = bytes === 3 * minimumGenericRow; }, diff --git a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts index 0ee51abb39..6fa220f78e 100644 --- a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts @@ -1053,3 +1053,40 @@ it("refuses generic rows that exceed the report budget and keeps the requested p await provider.close(); } }); + +it("reports the exceeded native-link maximum in the public refusal", async () => { + const root = await createTestTempDirectory("rea-il2cpp-link-limit-"); + const path = join(root, "global-metadata.dat"); + const binaryPath = join(root, "GameAssembly.dylib"); + await writeFile(path, createLinkedIl2cppMetadataFixture().bytes); + // 80 copies of the two-slot module array exceed the 64 candidate arrays + // the fallback search compares; x86-64 makes that fallback run. + const base = createIl2cppBinaryFixture({ + codeRegistration: false, + codeReference: true, + }); + const crafted = Buffer.concat([ + base, + ...Array.from({ length: 80 }, () => base.subarray(0x500, 0x510)), + ]); + crafted.writeBigUInt64LE(BigInt(crafted.length), 64); + crafted.writeBigUInt64LE(BigInt(crafted.length), 80); + crafted.writeUInt32LE(0x01000007, 4); + crafted.writeUInt32LE(3, 8); + await writeFile(binaryPath, crafted); + const provider = new Il2cppMetadataProvider(); + try { + const result = await provider.inspect({ path, binary_path: binaryPath }); + if (result.ok) throw new Error("Expected the link budget refusal"); + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(result.error).toMatchObject({ + resource: "capacity", + reportedLimits: { + boundary: "native-index-capacity", + maximum_entries: 64, + }, + }); + } finally { + await provider.close(); + } +});