diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 16fbcacac4..0b342332e1 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -248,6 +248,63 @@ describe("managed exported types", () => { }); describe("managed member decoding budget", () => { + it("admits a wide-instruction method whose actual records fit the budget", () => { + // Five-byte branches all target the next instruction. A final ret leaves + // the method stack-neutral, despite over 256 KiB of valid IL bytes. + const il = Buffer.alloc(270_001); + for (let offset = 0; offset < il.length - 1; offset += 5) { + il[offset] = 0x38; + } + il[il.length - 1] = 0x2a; + const header = Buffer.alloc(12); + header.writeUInt16LE(0x3003, 0); + header.writeUInt16LE(8, 2); + header.writeUInt32LE(il.length, 4); + const bytes = buildManagedPeFixture({ + ilBody: Buffer.concat([header, il]), + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); + expect(result.types).toHaveLength(1); + expect(result.methods[0]?.body).toMatchObject({ + status: "present", + il_size: il.length, + decoded_instruction_count: 54_001, + opcode_counts: { br: 54_000, ret: 1 }, + }); + }); + + it("still rejects dense one-byte instructions before exceeding capacity", () => { + // All NOPs: unlike wide instructions, each IL byte adds a decoded record. + // A true oversized transient must remain an actionable resource limit. + const il = Buffer.alloc(270_001); + il[il.length - 1] = 0x2a; + const header = Buffer.alloc(12); + header.writeUInt16LE(0x3003, 0); + header.writeUInt16LE(8, 2); + header.writeUInt32LE(il.length, 4); + const bytes = buildManagedPeFixture({ + ilBody: Buffer.concat([header, il]), + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.coverage).toMatchObject({ + state: "unavailable", + issues: [{ code: "resource-limit", scope: "metadata.representation" }], + }); + // Report the originating IL coordinate, not a generic decoding error. + expect(result.coverage.issues[0]?.offset).toBeGreaterThan(0); + expect(result.types).toEqual([]); + expect(result.methods).toEqual([]); + }); + it("charges decoded output rather than accumulated decoding bounds", () => { // Each bound fits alone, but 256 bytes per IL and signature byte together // exceed the 64 MiB budget although the retained members are small. diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts index 464a5c01be..f638e3e836 100644 --- a/src/dotnet/ManagedMemberInstructionDecoder.ts +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -1,4 +1,5 @@ import type { ManagedDecodeBudget } from "./ManagedDecodeBudget.js"; +import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; import type { ManagedExceptionRegion, ManagedInstructionAnchor, @@ -381,6 +382,7 @@ const readOperand = ( export const decodeInstructions = ( il: Buffer, + admitInstruction?: (offset: number) => void, ): { readonly parsed: readonly ParsedInstruction[]; readonly instructionStarts: readonly number[]; @@ -409,6 +411,9 @@ export const decodeInstructions = ( issue = `Instruction ${descriptor.name} at IL offset ${String(start)} leaves method body`; break; } + // Charge each actual instruction before growing the decoded arrays. + // A multi-byte operand must not consume a full record per raw IL byte. + admitInstruction?.(start); offset = operand.next; if (PREFIX_OPCODES.has(descriptor.name)) { prefixStart ??= start; @@ -424,6 +429,7 @@ export const decodeInstructions = ( }); } } catch (cause: unknown) { + if (cause instanceof ManagedReaderFailure) throw cause; issue = cause instanceof Error ? cause.message : "Instruction decode failed"; } diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index 047c2846a4..fdbda8008e 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -100,11 +100,15 @@ export const methodBody = ( if (ilOffset > bytes.length - header.ilSize) throw new RangeError("Method IL bytes leave artifact"); const il = bytes.subarray(ilOffset, ilOffset + header.ilSize); - // One byte can produce an instruction record, start index, normalized tuple, - // and anchor. Bound that bookkeeping before decoding; the retained body is - // reserved once it is known. - budget?.admitTransient(il.length * 256, ilOffset); - const decoded = decodeInstructions(il); + // Bound the actual instruction records before allocating them. One raw + // byte is only an upper bound on count; wide operands do not create a + // separate instruction for every byte. Retained output is charged below. + const decoded = decodeInstructions( + il, + budget === undefined + ? undefined + : (start) => budget.admitTransient(256, ilOffset + start), + ); const opcodeCounts: Record = {}; for (const instruction of decoded.parsed) opcodeCounts[instruction.opcode] =