From 271ef32f0c23749a2f4da6bb3037a4e42f5ba66f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:20:37 +0800 Subject: [PATCH 1/2] refactor(managed): retain decoded metadata token facts --- src/dotnet/ManagedCilDecoding.test.ts | 139 ++++++++++++++++++ .../ManagedExceptionRegionValidation.ts | 3 +- src/dotnet/ManagedMemberInspector.test.ts | 93 +++++++----- src/dotnet/ManagedMemberInspector.ts | 62 ++++---- src/dotnet/ManagedMemberInspectorCore.ts | 24 ++- src/dotnet/ManagedMemberInstructionDecoder.ts | 96 ++++++++---- src/dotnet/ManagedMemberRows.ts | 78 ++++++---- src/dotnet/ManagedMetadataHeaps.test.ts | 3 +- src/dotnet/ManagedMetadataHeaps.ts | 18 ++- src/dotnet/ManagedMethodBodyReader.ts | 63 ++++++-- src/dotnet/ManagedMethodSpecs.ts | 42 +++--- src/dotnet/ManagedTypeNames.ts | 12 +- src/dotnet/ManagedUserStrings.ts | 42 +++--- tests/acceptance/managedCliOutput.test.ts | 80 ++++++++++ 14 files changed, 557 insertions(+), 198 deletions(-) diff --git a/src/dotnet/ManagedCilDecoding.test.ts b/src/dotnet/ManagedCilDecoding.test.ts index 91cfe755f4..eb2e283170 100644 --- a/src/dotnet/ManagedCilDecoding.test.ts +++ b/src/dotnet/ManagedCilDecoding.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; import { buildManagedPeFixture, + fixtureUserStringHeap, managedPeFixtureTarget, } from "./ManagedPe.fixture.js"; import { parseExceptionRegions } from "./ManagedMemberInstructionDecoder.js"; @@ -490,6 +491,65 @@ describe("managed complete operand decoding", () => { }); }); + it("retains named references and loaded literals before a truncated operand", () => { + const { heap, tokens } = fixtureUserStringHeap(["observed"]); + const literalToken = tokens[0]!; + const il = Buffer.from([ + 0x28, 1, 0, 0, 0x0a, 0x7e, 1, 0, 0, 0x04, 0x26, 0x72, 0, 0, 0, 0, 0x26, + 0x20, + ]); + il.writeUInt32LE(Number.parseInt(literalToken, 16), 12); + const bytes = buildManagedPeFixture({ + memberRefParentRaw: 8, + memberRefName: "ObservedCall", + fieldName: "ObservedField", + ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]), + userStringHeap: heap, + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.methods[0]?.body).toMatchObject({ + status: "malformed", + il_size: il.length, + il_sha256: createHash("sha256").update(il).digest("hex"), + normalized_il_sha256: null, + decoded_instruction_count: 5, + anchors: [ + { opcode: "call", operand: "0x0a000001" }, + { opcode: "ldsfld", operand: "0x04000001" }, + { opcode: "pop", operand: null }, + { opcode: "ldstr", operand: literalToken }, + { opcode: "pop", operand: null }, + ], + issue: "Instruction ldc.i4 at IL offset 17 leaves method body", + }); + expect(result.call_edges).toMatchObject([ + { + opcode: "call", + target_token: "0x0a000001", + target_name: "ObservedCall", + target_declaring_type: "Fixture.Program", + }, + ]); + expect(result.field_accesses).toMatchObject([ + { + opcode: "ldsfld", + field_token: "0x04000001", + field_name: "ObservedField", + field_declaring_type: "Fixture.Program", + }, + ]); + expect(result.user_strings).toMatchObject({ + state: "partial", + literals: [{ token: literalToken, value: "observed" }], + issues: [], + }); + expect(result.coverage.state).toBe("partial"); + }); + it("keeps a complete operand-bearing body decodable", () => { expect( fatBody(Buffer.from([0x00, 0x20, 0x01, 0x00, 0x00, 0x00, 0x2a])), @@ -894,3 +954,82 @@ describe("managed exception section decoding", () => { }); }); }); + +describe("managed catch fact retention", () => { + it.each([ + { + name: "local TypeDef", + classToken: 0x0200_0001, + typeRefScopeRaw: 0, + classType: "Fixture.Program", + identityResolved: true, + }, + { + name: "TypeRef with an unavailable AssemblyRef scope", + classToken: 0x0100_0001, + typeRefScopeRaw: (9 << 2) | 2, + classType: "System.Runtime.Versioning.TargetFrameworkAttribute", + identityResolved: false, + }, + { + name: "TypeRef with a cyclic scope", + classToken: 0x0100_0001, + typeRefScopeRaw: (1 << 2) | 3, + classType: "System.Runtime.Versioning.TargetFrameworkAttribute", + identityResolved: false, + }, + ])( + "retains $name catch facts before a malformed later EH section", + ({ classToken, typeRefScopeRaw, classType, identityResolved }) => { + const header = Buffer.alloc(12); + header.writeUInt16LE(0x300b, 0); + header.writeUInt16LE(8, 2); + header.writeUInt32LE(1, 4); + const first = Buffer.alloc(16); + first[0] = 0x81; + first[1] = first.length; + first.writeUInt32LE(classToken, 12); + const later = Buffer.alloc(17); + later[0] = 0x01; + later[1] = later.length; + const il = Buffer.from([0x2a]); + const bytes = buildManagedPeFixture({ + typeRefScopeRaw, + ilBody: Buffer.concat([header, il, Buffer.alloc(3), first, later]), + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.methods[0]?.body).toMatchObject({ + status: "malformed", + il_sha256: createHash("sha256").update(il).digest("hex"), + normalized_il_sha256: null, + decoded_instruction_count: 1, + anchors: [{ opcode: "ret", operand: null }], + exception_regions: [ + { + flags: 0, + class_token: `0x${classToken.toString(16).padStart(8, "0")}`, + class_type: classType, + class_type_name_sha256: identityResolved + ? expect.any(String) + : null, + class_type_identity_resolved: identityResolved, + }, + ], + issue: "Exception section size does not contain whole clauses", + }); + expect(result.coverage.state).toBe("partial"); + if (!identityResolved) + expect(result.coverage.issues).toContainEqual( + expect.objectContaining({ + code: "invalid-row", + scope: "metadata.TypeRef:0x01000001", + }), + ); + expect(result.user_strings?.state).toBe("complete"); + }, + ); +}); diff --git a/src/dotnet/ManagedExceptionRegionValidation.ts b/src/dotnet/ManagedExceptionRegionValidation.ts index e535c84f57..4b74a6a2ff 100644 --- a/src/dotnet/ManagedExceptionRegionValidation.ts +++ b/src/dotnet/ManagedExceptionRegionValidation.ts @@ -79,8 +79,7 @@ export const validateExceptionRegionRanges = ( const boundaries = new Set([...instructionOffsets, ilSize]); for (const region of parsed.regions) { const issue = clauseIssue(region, ilSize, boundaries); - if (issue !== null) - return { status: "malformed", regions: parsed.regions, issue }; + if (issue !== null) return { ...parsed, status: "malformed", issue }; } return parsed; }; diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 02cb1f2e54..9341035f57 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -15,7 +15,13 @@ import { readManagedPeLayout } from "./ManagedPeReader.js"; import { memberListIndexSize } from "./ManagedMemberPointers.js"; import { edges } from "./ManagedMemberRows.js"; import { parseUserStrings } from "./ManagedUserStrings.js"; -import { signature, type ManagedMethod } from "./ManagedMemberInspectorCore.js"; +import { + signature, + isParsedTokenInstruction, + type DecodedMethod, + type ManagedMethod, +} from "./ManagedMemberInspectorCore.js"; +import { decodeInstructions } from "./ManagedMemberInstructionDecoder.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; import { managedTypeReferences } from "./ManagedTypeReferences.js"; import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; @@ -31,6 +37,19 @@ import { managedPeFixtureTarget, } from "./ManagedPe.fixture.js"; +/** Decode real token operands for capacity tests that do not need a PE method body. */ +const decodedTokenInstructions = ( + opcode: number, + tokens: readonly number[], +) => { + const il = Buffer.alloc(tokens.length * 5); + for (const [index, token] of tokens.entries()) { + il.writeUInt8(opcode, index * 5); + il.writeUInt32LE(token, index * 5 + 1); + } + return decodeInstructions(il).parsed.filter(isParsedTokenInstruction); +}; + /** A tiny-header body that loads each `ldstr` token, discards it, and returns. */ const ldstrBody = (tokens: readonly string[]): Buffer => { const il = Buffer.concat([ @@ -1265,16 +1284,14 @@ describe("managed user strings", () => { const layout = { userStrings: null } as unknown as ManagedMetadataLayout; // Leave 1 KiB of the 64 MiB budget: too little for 100 distinct tokens. managedDecodeBudget(layout).reserve(64 * 1024 * 1024 - 1024, null); - const method = { - body: { - anchors: Array.from({ length: 100 }, (_, index) => ({ - il_offset: index * 5, - opcode: "ldstr", - operand_kind: "string", - operand: `0x70${(index + 1).toString(16).padStart(6, "0")}`, - })), - }, - } as unknown as ManagedMethod; + const method: DecodedMethod = { + method: {} as ManagedMethod, + tokenInstructions: decodedTokenInstructions( + 0x72, + Array.from({ length: 100 }, (_, index) => 0x70000001 + index), + ), + catchTokens: new Map(), + }; expect(() => parseUserStrings(Buffer.alloc(0), layout, { @@ -1448,16 +1465,14 @@ describe("managed user string issues", () => { } as unknown as ManagedMetadataLayout; // Leave 24 KiB: enough for the failures, not for 512 bytes leaked each. managedDecodeBudget(layout).reserve(64 * 1024 * 1024 - 24 * 1024, null); - const method = { - body: { - anchors: Array.from({ length: 64 }, (_, index) => ({ - il_offset: index * 5, - opcode: "ldstr", - operand_kind: "string", - operand: `0x70${(1 + index * 2).toString(16).padStart(6, "0")}`, - })), - }, - } as unknown as ManagedMethod; + const method: DecodedMethod = { + method: {} as ManagedMethod, + tokenInstructions: decodedTokenInstructions( + 0x72, + Array.from({ length: 64 }, (_, index) => 0x70000001 + index * 2), + ), + catchTokens: new Map(), + }; expect( parseUserStrings(heap, layout, { methods: [method], unscannedMethods: 0 }) @@ -1737,23 +1752,25 @@ describe("managed member decoding budget", () => { describe("managed derived edges", () => { it("admits call edges before the derived graph exceeds the budget", () => { - const anchor = { - il_offset: 0, - opcode: "call", - operand_kind: "method", - operand: "0x0a000001", - }; const method = { token: "0x06000001", declaring_type: "T".repeat(2000), name: "m", - body: { anchors: Array.from({ length: 16 }, () => anchor) }, } as unknown as ManagedMethod; + const decoded: DecodedMethod = { + method, + tokenInstructions: decodedTokenInstructions( + 0x28, + Array(16).fill(0x0a000001), + ), + catchTokens: new Map(), + }; + const budget = new ManagedDecodeBudget(); // Exercise the same capacity boundary without constructing 64 MiB of edges. budget.reserve(64 * 1024 * 1024 - 16 * 1024, null); - expect(() => edges([method], new Map(), budget)).toThrow( + expect(() => edges([decoded], new Map(), budget)).toThrow( ManagedReaderFailure, ); }); @@ -1775,22 +1792,18 @@ describe("managed derived caller names", () => { token: "0x06000001", declaring_type: declaringType, name: "m", - body: { - anchors: [ - { - il_offset: 0, - opcode: "call", - operand_kind: "method", - operand: "0x0a000001", - }, - ], - }, } as unknown as ManagedMethod; + const decoded: DecodedMethod = { + method, + tokenInstructions: decodedTokenInstructions(0x28, [0x0a000001]), + catchTokens: new Map(), + }; + const budget = new ManagedDecodeBudget(); // Only the remaining capacity matters to refusal before interpolation. budget.reserve(64 * 1024 * 1024 - 1024, null); - expect(() => edges([method], new Map(), budget)).toThrow( + expect(() => edges([decoded], new Map(), budget)).toThrow( ManagedReaderFailure, ); expect(built).toBe(false); diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index e0c14b10b6..c47f5f8bd2 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -318,38 +318,35 @@ export const inspectManagedMembersBytes = ( methodLimit, }); appendManagedParseIssues(issues, fields.issues, methods.issues); - const resolvedMethods = methods.methods.map((method) => ({ + const resolvedMethods = methods.methods.map(({ method, catchTokens }) => ({ ...method, body: { ...method.body, - exception_regions: method.body.exception_regions.map((region) => { - const classType = resolveTypeName(region.class_token); - const tokenTable = - region.class_token === null - ? null - : Number.parseInt(region.class_token.slice(2, 4), 16); - const tokenRow = - region.class_token === null - ? 0 - : Number.parseInt(region.class_token.slice(4), 16); - const identityResolved = - region.class_token === null - ? null - : classType !== null && tokenTable === 2 - ? declarationTypeName(2, tokenRow) !== null - : false; - return { - ...region, - class_type: classType, - class_type_name_sha256: - region.class_token !== null && - (tokenTable === 1 || tokenTable === 2) - ? (declarationTypeName(tokenTable, tokenRow)?.toString("hex") ?? - null) - : null, - class_type_identity_resolved: identityResolved, - }; - }), + exception_regions: method.body.exception_regions.map( + (region, ordinal) => { + const reference = catchTokens.get(ordinal) ?? null; + const classType = resolveTypeName(reference); + const identityResolved = + reference === null + ? null + : classType !== null && reference.table === 2 + ? declarationTypeName(2, reference.row) !== null + : false; + return { + ...region, + class_type: classType, + class_type_name_sha256: + reference !== null && + (reference.table === 1 || reference.table === 2) + ? (declarationTypeName( + reference.table, + reference.row, + )?.toString("hex") ?? null) + : null, + class_type_identity_resolved: identityResolved, + }; + }, + ), }, })); // #US literals sit beside the member inventory and carry their own @@ -369,16 +366,19 @@ export const inspectManagedMembersBytes = ( ); appendManagedParseIssues(issues, targets.issues); const incompleteMembers = - [...fields.fields, ...memberRefs.refs, ...methods.methods].some( + [...fields.fields, ...memberRefs.refs].some( (member) => member.signature.parse_status !== "decoded", ) || + resolvedMethods.some( + (method) => method.signature.parse_status !== "decoded", + ) || resolvedMethods.some( (method) => method.body.status === "partial" || method.body.status === "malformed", ); const related = edges( - resolvedMethods, + methods.methods, targets.members, managedDecodeBudget(layout), ); diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index 7ffe2baddc..1811045556 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -27,6 +27,7 @@ import { decodeMetadataCodedIndex, metadataCodedIndexCoordinate, type MetadataCodedIndexCoordinate, + type MetadataTokenFact, metadataToken, readMetadataString, sha256Bytes, @@ -91,19 +92,19 @@ export interface MemberCore { export const memberTargetIdentity = ( name: string, - declaringToken: string | null, + declaringType: Pick | null, signature: ManagedSignature, typeName: DeclarationTypeName, ): string | null => { if ( - declaringToken === null || + declaringType === null || signature.resolved_sha256 === null || signature.parse_status !== "decoded" ) return null; - const table = Number.parseInt(declaringToken.slice(2, 4), 16); + const { table, row } = declaringType; if (table !== 1 && table !== 2) return null; - const owner = typeName(table, Number.parseInt(declaringToken.slice(4), 16)); + const owner = typeName(table, row); return owner === null ? null : createHash("sha256") @@ -119,6 +120,21 @@ export interface ParsedInstruction { readonly opcode: string; readonly operandKind: ManagedInstructionAnchor["operand_kind"]; readonly operand: string | null; + readonly reference: MetadataTokenFact | null; +} + +export type ParsedTokenInstruction = ParsedInstruction & { + readonly reference: MetadataTokenFact; +}; + +export const isParsedTokenInstruction = ( + instruction: ParsedInstruction, +): instruction is ParsedTokenInstruction => instruction.reference !== null; + +export interface DecodedMethod { + readonly method: ManagedMethod; + readonly tokenInstructions: readonly ParsedTokenInstruction[]; + readonly catchTokens: ReadonlyMap; } const rowRange = ( diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts index 7526207b23..e8989f4865 100644 --- a/src/dotnet/ManagedMemberInstructionDecoder.ts +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -1,3 +1,7 @@ +import { + metadataTokenFact, + type MetadataTokenFact, +} from "./ManagedMetadataHeaps.js"; import type { ManagedDecodeBudget } from "./ManagedDecodeBudget.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; import type { @@ -240,20 +244,14 @@ const OPCODES = new Map< [0xfe1e, { name: "readonly.", operand: "none" }], ]); -const tokenKind = (token: string): ManagedInstructionAnchor["operand_kind"] => { - if (token.startsWith("0x06") || token.startsWith("0x2b")) return "method"; - if (token.startsWith("0x04")) return "field"; - if (token.startsWith("0x70")) return "string"; - if (token.startsWith("0x11")) return "signature"; - // MemberRef rows can name either fields or methods; the table alone cannot - // establish the kind. Callers with parsed metadata may supply that fact. - if (token.startsWith("0x0a")) return "token"; - if ( - token.startsWith("0x01") || - token.startsWith("0x02") || - token.startsWith("0x1b") - ) - return "type"; +const tokenKind = (table: number): ManagedInstructionAnchor["operand_kind"] => { + if (table === 6 || table === 0x2b) return "method"; + if (table === 4) return "field"; + if (table === 0x70) return "string"; + if (table === 0x11) return "signature"; + // MemberRefs need their signature to distinguish methods from fields. + if (table === 0x0a) return "token"; + if (table === 1 || table === 2 || table === 0x1b) return "type"; return "token"; }; @@ -261,6 +259,7 @@ type OperandResult = { readonly next: number; readonly anchorKind: ManagedInstructionAnchor["operand_kind"]; readonly value: string | null; + readonly reference?: MetadataTokenFact; }; type OperandReader = (il: Buffer, offset: number) => OperandResult; @@ -425,21 +424,22 @@ const readOperand = ( } if (!TOKEN_OPERAND_KINDS.has(kind) && kind !== "token") return { next: offset, anchorKind: "unknown", value: null }; - const raw = il.readUInt32LE(offset); - const token = `0x${raw.toString(16).padStart(8, "0")}`; + const reference = metadataTokenFact(il.readUInt32LE(offset)); + const { token } = reference; return { next: offset + TOKEN_OPERAND_WIDTH, anchorKind: TOKEN_OPERAND_KINDS.get(kind) ?? (kind === "token" ? memberRefKind?.(token) : undefined) ?? - tokenKind(token), + tokenKind(reference.table), value: token, + reference, }; }; export const decodeInstructions = ( il: Buffer, - admitInstruction?: (offset: number) => void, + admitInstruction?: (offset: number, hasReference: boolean) => void, memberRefKind?: (token: string) => "field" | "method" | undefined, ): { readonly parsed: readonly ParsedInstruction[]; @@ -476,15 +476,18 @@ export const decodeInstructions = ( issue = `Instruction ${descriptor.name} at IL offset ${String(start)} leaves method body`; break; } + // The extent is admitted; bound transient records before allocating token facts. + admitInstruction?.( + start, + TOKEN_OPERAND_KINDS.has(descriptor.operand) || + descriptor.operand === "token", + ); const operand = readOperand( il, offset, descriptor.operand, memberRefKind, ); - // 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; @@ -497,6 +500,7 @@ export const decodeInstructions = ( opcode: descriptor.name, operandKind: operand.anchorKind, operand: operand.value, + reference: operand.reference ?? null, }); } } catch (cause: unknown) { @@ -517,6 +521,7 @@ export const parseExceptionRegions = ( budget?: ManagedDecodeBudget, ): ExceptionRegionParseResult => { const regions: ManagedExceptionRegion[] = []; + const catchTokens = new Map(); let sectionOffset = offset; let hasMoreSections = true; while (hasMoreSections) { @@ -529,6 +534,7 @@ export const parseExceptionRegions = ( return malformedExceptionRegions( "Exception section header leaves artifact", regions, + catchTokens, ); const kind = bytes.readUInt8(sectionOffset); @@ -536,6 +542,7 @@ export const parseExceptionRegions = ( return malformedExceptionRegions( `Unsupported method data section kind ${String(kind & 0x3f)}`, regions, + catchTokens, ); const fat = (kind & 0x40) !== 0; const size = fat @@ -552,6 +559,7 @@ export const parseExceptionRegions = ( return malformedExceptionRegions( "Exception section size leaves artifact", regions, + catchTokens, ); let count: number; let sectionSize = size; @@ -560,6 +568,7 @@ export const parseExceptionRegions = ( return malformedExceptionRegions( "Exception section size does not contain whole clauses", regions, + catchTokens, ); count = (size - 4) / clauseSize; } else if ((size - 4) % clauseSize === 0) { @@ -573,6 +582,7 @@ export const parseExceptionRegions = ( return malformedExceptionRegions( "Exception section size does not contain whole clauses", regions, + catchTokens, ); } if ( @@ -582,6 +592,7 @@ export const parseExceptionRegions = ( return malformedExceptionRegions( "Exception section size leaves artifact", regions, + catchTokens, ); if ( !fat && @@ -590,45 +601,64 @@ export const parseExceptionRegions = ( return malformedExceptionRegions( "Small exception section reserved bytes are nonzero", regions, + catchTokens, ); // Accumulates with the method IL bound; the body reserves what it retains. budget?.admitTransient(count * 256, sectionOffset); for (const region of readExceptionClauses( bytes, - sectionOffset + 4, - count, - fat, + { start: sectionOffset + 4, count, fat, budget }, + catchTokens, + regions.length, )) regions.push(region); hasMoreSections = (kind & 0x80) !== 0; sectionOffset += sectionSize; } - return { status: "complete", regions }; + return { status: "complete", regions, catchTokens }; }; export type ExceptionRegionParseResult = | { readonly status: "complete"; readonly regions: ManagedExceptionRegion[]; + readonly catchTokens: ReadonlyMap; } | { readonly status: "malformed"; readonly regions: ManagedExceptionRegion[]; + readonly catchTokens: ReadonlyMap; readonly issue: string; }; const malformedExceptionRegions = ( issue: string, - regions: ManagedExceptionRegion[] = [], -): ExceptionRegionParseResult => ({ status: "malformed", regions, issue }); + regions: ManagedExceptionRegion[], + catchTokens: ReadonlyMap, +): ExceptionRegionParseResult => ({ + status: "malformed", + regions, + catchTokens, + issue, +}); const readExceptionClauses = ( bytes: Buffer, - start: number, - count: number, - fat: boolean, + { + start, + count, + fat, + budget, + }: { + readonly start: number; + readonly count: number; + readonly fat: boolean; + readonly budget: ManagedDecodeBudget | undefined; + }, + catchTokens: Map, + firstOrdinal: number, ): ManagedExceptionRegion[] => { const regions: ManagedExceptionRegion[] = []; const clauseSize = fat ? 24 : 12; @@ -636,6 +666,9 @@ const readExceptionClauses = ( const clause = start + index * clauseSize; const flags = fat ? bytes.readUInt32LE(clause) : bytes.readUInt16LE(clause); const extra = bytes.readUInt32LE(clause + (fat ? 20 : 8)); + if (flags === 0) budget?.admitTransient(256, clause); + const reference = flags === 0 ? metadataTokenFact(extra) : null; + if (reference !== null) catchTokens.set(firstOrdinal + index, reference); regions.push({ flags, try_offset: fat @@ -650,8 +683,7 @@ const readExceptionClauses = ( handler_length: fat ? bytes.readUInt32LE(clause + 16) : bytes.readUInt8(clause + 7), - class_token: - flags === 0 ? `0x${extra.toString(16).padStart(8, "0")}` : null, + class_token: reference?.token ?? null, class_type: null, class_type_name_sha256: null, class_type_identity_resolved: null, diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index f227f82130..b3dc4454e5 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -19,6 +19,7 @@ import { type ManagedFieldAccess, type ManagedMemberRef, type ManagedMethod, + type DecodedMethod, type MemberCore, type SignatureTypeName, type DeclarationTypeName, @@ -26,6 +27,7 @@ import { } from "./ManagedMemberInspectorCore.js"; import { metadataToken, + type MetadataTokenFact, metadataRowCursor, decodeMetadataCodedIndex, readMetadataBlob, @@ -121,7 +123,7 @@ export const parseFields = ( kind: "field", identitySha256: memberTargetIdentity( name, - declared?.token ?? null, + declared === undefined ? null : { table: 2, row: owner }, decoded, declarationTypeName, ), @@ -215,7 +217,9 @@ export const parseMemberRefs = ( signatureTypeName, declarationTypeName, }: { - readonly typeName: (token: string | null) => string | null; + readonly typeName: ( + reference: Pick | null, + ) => string | null; readonly signatureTypeName: SignatureTypeName; readonly declarationTypeName: DeclarationTypeName; }, @@ -264,7 +268,7 @@ export const parseMemberRefs = ( : `MemberRef parent coded index 0x${parentRaw.toString(16)} is invalid: ${parent.reason}`, }); const parentToken = parent.kind === "valid" ? parent.token : null; - const parentName = typeName(parentToken); + const parentName = typeName(parent.kind === "valid" ? parent : null); const decoded = budgetedSignature( sig, layout, @@ -288,7 +292,7 @@ export const parseMemberRefs = ( genericArity: signatureGenericArity(decoded), identitySha256: memberTargetIdentity( name, - parentToken, + parent.kind === "valid" ? parent : null, decoded, declarationTypeName, ), @@ -325,7 +329,7 @@ export const parseMethods = ({ methodOffset = 0, methodLimit = Number.MAX_SAFE_INTEGER, }: ParseMethodsInput): { - readonly methods: readonly ManagedMethod[]; + readonly methods: readonly DecodedMethod[]; readonly core: ReadonlyMap; readonly issues: readonly ManagedParseIssue[]; /** @@ -334,7 +338,7 @@ export const parseMethods = ({ */ readonly unscannedMethods: number; } => { - const methods: ManagedMethod[] = []; + const methods: DecodedMethod[] = []; const core = new Map(); const issues: ManagedParseIssue[] = []; let unscannedMethods = 0; @@ -403,7 +407,9 @@ export const parseMethods = ({ genericArity: signatureGenericArity(decoded), identitySha256: memberTargetIdentity( name, - declared?.token ?? null, + declared === undefined || owner === undefined + ? null + : { table: 2, row: owner }, decoded, declarationTypeName, ), @@ -417,17 +423,22 @@ export const parseMethods = ({ }); appendManagedParseIssues(issues, read.issues); if (!read.scanned) unscannedMethods += 1; + managedDecodeBudget(layout).reserve(128, rowOffset); methods.push({ - token, - row_offset: rowOffset, - declaring_type_token: declared?.token ?? null, - declaring_type: declared?.fullName ?? null, - name, - rva, - impl_flags: implFlags, - flags, - signature: decoded, - body: read.body, + tokenInstructions: read.tokenInstructions, + catchTokens: read.catchTokens, + method: { + token, + row_offset: rowOffset, + declaring_type_token: declared?.token ?? null, + declaring_type: declared?.fullName ?? null, + name, + rva, + impl_flags: implFlags, + flags, + signature: decoded, + body: read.body, + }, }); }, issues); if (mayBeSelected && methods.length === produced) unscannedMethods += 1; @@ -444,12 +455,12 @@ const NAMEABLE_KINDS = new Map>([ ["field", new Set(["field"])], ]); -const targetKind = (token: string): ManagedCallEdge["target_kind"] => - token.startsWith("0x06") +const targetKind = (table: number): ManagedCallEdge["target_kind"] => + table === 6 ? "method-def" - : token.startsWith("0x0a") + : table === 0x0a ? "member-ref" - : token.startsWith("0x2b") + : table === 0x2b ? "method-spec" : "unknown"; @@ -467,7 +478,7 @@ const namedTarget = ( }; export const edges = ( - methods: readonly ManagedMethod[], + methods: readonly DecodedMethod[], members: ReadonlyMap, budget: ManagedDecodeBudget, ): { @@ -476,14 +487,23 @@ export const edges = ( } => { const callEdges: ManagedCallEdge[] = []; const fieldAccesses: ManagedFieldAccess[] = []; - for (const method of methods) { - const derives = method.body.anchors.some( - isManagedMemberReferenceInstruction, + for (const { method, tokenInstructions } of methods) { + const derives = tokenInstructions.some((instruction) => + isManagedMemberReferenceInstruction({ + opcode: instruction.opcode, + operand_kind: instruction.operandKind, + operand: instruction.operand, + }), ); if (derives) budget.admitTransient(callerNameBytes(method), null); // Built once per method, only after its exact size was admitted. const caller = derives ? callerName(method) : method.name; - for (const anchor of method.body.anchors) { + for (const instruction of tokenInstructions) { + const anchor = { + opcode: instruction.opcode, + operand_kind: instruction.operandKind, + operand: instruction.operand, + }; if (!isManagedMemberReferenceInstruction(anchor)) continue; const { operand } = anchor; const target = namedTarget(members, anchor.operand_kind, operand); @@ -493,12 +513,12 @@ export const edges = ( admitManagedProjection( { caller_token: method.token, - il_offset: anchor.il_offset, + il_offset: instruction.offset, target_identity_sha256: target?.identitySha256 ?? null, caller, opcode: anchor.opcode, target_token: operand, - target_kind: targetKind(operand), + target_kind: targetKind(instruction.reference.table), target_name: target?.name ?? null, target_declaring_type: target?.declaringType ?? null, }, @@ -511,7 +531,7 @@ export const edges = ( admitManagedProjection( { method_token: method.token, - il_offset: anchor.il_offset, + il_offset: instruction.offset, target_identity_sha256: target?.identitySha256 ?? null, method: caller, opcode: anchor.opcode, diff --git a/src/dotnet/ManagedMetadataHeaps.test.ts b/src/dotnet/ManagedMetadataHeaps.test.ts index 0c39ac83df..9bb3321210 100644 --- a/src/dotnet/ManagedMetadataHeaps.test.ts +++ b/src/dotnet/ManagedMetadataHeaps.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { readMetadataBlob, + metadataTokenFact, readMetadataString, readMetadataUserString, } from "./ManagedMetadataHeaps.js"; @@ -97,7 +98,7 @@ describe("managed metadata heaps", () => { } as unknown as ManagedMetadataLayout; expect(() => - readMetadataUserString(bytes, layout, "0x70000001"), + readMetadataUserString(bytes, layout, metadataTokenFact(0x70000001)), ).toThrowError( expect.objectContaining({ issue: expect.objectContaining({ diff --git a/src/dotnet/ManagedMetadataHeaps.ts b/src/dotnet/ManagedMetadataHeaps.ts index a212f6aacf..c512b8dbaa 100644 --- a/src/dotnet/ManagedMetadataHeaps.ts +++ b/src/dotnet/ManagedMetadataHeaps.ts @@ -235,13 +235,14 @@ export const readMetadataBlob = ( export const readMetadataUserString = ( bytes: Buffer, layout: ManagedMetadataLayout, - token: string, + reference: Pick, ): { readonly offset: number; readonly value: string; /** Conformance differences observed beside the readable literal. */ readonly issues: readonly ManagedParseIssue[]; } => { + const { token, row: index } = reference; const heap = layout.userStrings; const scope = `metadata.#US:${token}`; if (heap === null) @@ -250,7 +251,6 @@ export const readMetadataUserString = ( scope, "ldstr references #US, but the metadata has no #US stream", ); - const index = Number.parseInt(token.slice(4), 16); if (index >= heap.size) throw managedFailure( "invalid-heap-index", @@ -378,6 +378,20 @@ export const readMetadataGuid = ( export const metadataToken = (table: number, row: number): string => `0x${(table * 0x01_00_00_00 + row).toString(16).padStart(8, "0")}`; +/** Full-token coordinates do not establish that a table row or heap item exists. */ +export interface MetadataTokenFact { + readonly raw: number; + readonly table: number; + readonly row: number; + readonly token: string; +} + +export const metadataTokenFact = (raw: number): MetadataTokenFact => { + const table = raw >>> 24; + const row = raw & 0x00ff_ffff; + return { raw, table, row, token: metadataToken(table, row) }; +}; + /** Hash bytes without exposing their contents. */ export const sha256Bytes = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex"); diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index 65d2ffa420..e7aacddc8b 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -3,7 +3,10 @@ import { type ManagedDecodeBudget, } from "./ManagedDecodeBudget.js"; import type { ManagedPeLayout } from "./ManagedPeReader.js"; -import type { ManagedMethodBody } from "./ManagedMemberInspectorCore.js"; +import type { + ManagedMethodBody, + ParsedTokenInstruction, +} from "./ManagedMemberInspectorCore.js"; import { decodeInstructions, parseExceptionRegions, @@ -11,14 +14,21 @@ import { import { validateExceptionRegionRanges } from "./ManagedExceptionRegionValidation.js"; import { metadataRowCursor, + metadataTokenFact, + type MetadataTokenFact, readMetadataBlob, sha256Bytes, } from "./ManagedMetadataHeaps.js"; -import { validateLocalSignature } from "./ManagedMemberInspectorCore.js"; +import { + validateLocalSignature, + isParsedTokenInstruction, +} from "./ManagedMemberInspectorCore.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; +const EMPTY_CATCH_TOKENS: ReadonlyMap = new Map(); + const bodyStatus = ( decodedIssue: string | null, truncatedInstructions: number, @@ -92,6 +102,8 @@ export const methodBody = ( context: MethodBodyContext, ): { readonly body: ManagedMethodBody; + readonly tokenInstructions: readonly ParsedTokenInstruction[]; + readonly catchTokens: ReadonlyMap; readonly issues: readonly ManagedParseIssue[]; /** * Whether every CIL instruction was decoded, independent of exception-region @@ -104,6 +116,8 @@ export const methodBody = ( if (rva === 0) return { body: emptyMethodBody(rva, "absent", null), + tokenInstructions: [], + catchTokens: EMPTY_CATCH_TOKENS, scanned: true, issues, }; @@ -115,6 +129,8 @@ export const methodBody = ( "CIL decoding is unavailable for native, runtime, unmanaged, or P/Invoke implementations"; return { body: emptyMethodBody(rva, "partial", issue), + tokenInstructions: [], + catchTokens: EMPTY_CATCH_TOKENS, scanned: true, issues, }; @@ -137,7 +153,10 @@ export const methodBody = ( // separate instruction for every byte. Retained output is charged below. const decoded = decodeInstructions( il, - (start) => context.budget.admitTransient(256, ilOffset + start), + (start, hasReference) => { + context.budget.admitTransient(256, ilOffset + start); + if (hasReference) context.budget.admitTransient(256, ilOffset + start); + }, (token) => context.memberRefKinds.get(token), ); const opcodeCounts: Record = {}; @@ -179,12 +198,13 @@ export const methodBody = ( : null; let localSigIssue: string | null = null; let localSigFailure: "malformed" | "partial" | null = null; - if (header.localSig !== 0) { - const table = header.localSig >>> 24; - const row = header.localSig & 0x00ff_ffff; + const localSignature = + header.localSig === 0 ? null : metadataTokenFact(header.localSig); + if (localSignature !== null) { + const { table, row, token } = localSignature; const rows = context.metadataLayout.table(17)?.rowCount ?? 0; if (table !== 17 || row === 0 || row > rows) { - localSigIssue = `LocalVarSigTok 0x${header.localSig.toString(16).padStart(8, "0")} does not name an existing StandAloneSig row`; + localSigIssue = `LocalVarSigTok ${token} does not name an existing StandAloneSig row`; localSigFailure = "malformed"; issues.push({ code: "invalid-row", @@ -193,7 +213,7 @@ export const methodBody = ( detail: localSigIssue, }); } else { - const signatureScope = `metadata.StandAloneSig:0x${header.localSig.toString(16).padStart(8, "0")}.signature`; + const signatureScope = `metadata.StandAloneSig:${token}.signature`; try { const cursor = metadataRowCursor( bytes, @@ -247,10 +267,7 @@ export const methodBody = ( file_offset: offset, max_stack: header.maxStack, init_locals: (header.flags & 0x10) !== 0, - local_var_sig_token: - header.localSig === 0 - ? null - : `0x${header.localSig.toString(16).padStart(8, "0")}`, + local_var_sig_token: localSignature?.token ?? null, il_size: header.ilSize, il_sha256: sha256Bytes(il), normalized_il_sha256: @@ -271,8 +288,26 @@ export const methodBody = ( ? "Instruction decoding stopped before the end of the method body" : null), }; + const admittedBody = admitManagedProjection(body, context.budget); + const tokenInstructions: ParsedTokenInstruction[] = []; + // These records outlive transient decoding; the public projection contains only strings. + for (const instruction of decoded.parsed) { + if (!isParsedTokenInstruction(instruction)) continue; + context.budget.reserve( + 256 + (tokenInstructions.length === 0 ? 64 : 0), + ilOffset + instruction.offset, + ); + tokenInstructions.push(instruction); + } + const catchTokens = exceptionRegions?.catchTokens.size + ? exceptionRegions.catchTokens + : EMPTY_CATCH_TOKENS; + if (catchTokens.size > 0) + context.budget.reserve(64 + catchTokens.size * 256, sectionOffset); return { - body: admitManagedProjection(body, context.budget), + body: admittedBody, + tokenInstructions, + catchTokens, scanned: decoded.issue === null && decoded.truncated === 0, issues, }; @@ -293,6 +328,8 @@ export const methodBody = ( "malformed", cause instanceof Error ? cause.message : "Method body parse failed", ), + tokenInstructions: [], + catchTokens: EMPTY_CATCH_TOKENS, scanned: false, issues, }; diff --git a/src/dotnet/ManagedMethodSpecs.ts b/src/dotnet/ManagedMethodSpecs.ts index 2dd88ef6eb..6a2f897dd5 100644 --- a/src/dotnet/ManagedMethodSpecs.ts +++ b/src/dotnet/ManagedMethodSpecs.ts @@ -5,12 +5,13 @@ import { methodGenericArity, specSignatureFacts, type SignatureTypeName, - type ManagedMethod, + type DecodedMethod, type MemberCore, } from "./ManagedMemberInspectorCore.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { decodeMetadataCodedIndex, + type MetadataTokenFact, metadataRowCursor, readMetadataBlob, } from "./ManagedMetadataHeaps.js"; @@ -24,7 +25,7 @@ import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js"; export const edgeTargets = ( bytes: Buffer, layout: ManagedMetadataLayout, - methods: readonly ManagedMethod[], + methods: readonly DecodedMethod[], cores: readonly ReadonlyMap[], signatureTypeName: SignatureTypeName, ): { @@ -36,15 +37,15 @@ export const edgeTargets = ( const seen = new Set(); // Generic arity by method token, so instantiations of one method decode it once. const arities = new Map(); - for (const method of methods) - for (const { operand } of method.body.anchors) { - if (operand?.startsWith("0x2b") !== true || seen.has(operand)) continue; - seen.add(operand); + for (const { tokenInstructions } of methods) + for (const { reference } of tokenInstructions) { + if (reference.table !== 0x2b || seen.has(reference.token)) continue; + seen.add(reference.token); readManagedValue( () => readMethodSpec( { bytes, layout, members, issues, arities, signatureTypeName }, - operand, + reference, ), issues, ); @@ -73,15 +74,11 @@ const readMethodSpec = ( readonly arities: Map; readonly signatureTypeName: SignatureTypeName; }, - token: string, + reference: MetadataTokenFact, ): void => { + const { token, row } = reference; const scope = `metadata.MethodSpec:${token}`; - const cursor = metadataRowCursor( - bytes, - layout, - 0x2b, - Number.parseInt(token.slice(4), 16), - ); + const cursor = metadataRowCursor(bytes, layout, 0x2b, row); const methodRaw = cursor.readIndex(layout.codedIndexSize("MethodDefOrRef")); const instantiation = cursor.readIndex(layout.blobIndexSize); const method = decodeMetadataCodedIndex( @@ -100,8 +97,9 @@ const readMethodSpec = ( ? "MethodSpec method coded index 0x0 is null, but the Method column must reference a row" : `MethodSpec method coded index 0x${methodRaw.toString(16)} is invalid: ${method.reason}`, }); - const generic = method.kind === "valid" ? method.token : null; - const target = generic === null ? undefined : members.get(generic); + const methodReference = method.kind === "valid" ? method : null; + const target = + methodReference === null ? undefined : members.get(methodReference.token); // A MethodSpec instantiates a method, so a field target never lends it a // name, even to a field operand. if (target !== undefined && target.kind !== "field") @@ -113,13 +111,17 @@ const readMethodSpec = ( signatureTypeName, ); const { types } = instantiationFacts; - if (generic === null) return; + if (methodReference === null) return; + const generic = methodReference.token; // Prefer the arity the target's owner decoded; read the raw signature only // for a target nothing decoded, such as a MethodDef outside the selection. if (target?.genericArity === undefined && !arities.has(generic)) arities.set( generic, - methodGenericArity(layout, methodSignatureBlob(bytes, layout, generic)), + methodGenericArity( + layout, + methodSignatureBlob(bytes, layout, methodReference), + ), ); const arity = target?.genericArity === undefined @@ -155,10 +157,8 @@ const readMethodSpec = ( const methodSignatureBlob = ( bytes: Buffer, layout: ManagedMetadataLayout, - token: string, + { table, row }: Pick, ): Buffer => { - const table = Number.parseInt(token.slice(2, 4), 16); - const row = Number.parseInt(token.slice(4), 16); let signatureIndex: number; if (table === 6) { signatureIndex = readMethodDefinitionRow(bytes, layout, row).signatureIndex; diff --git a/src/dotnet/ManagedTypeNames.ts b/src/dotnet/ManagedTypeNames.ts index bf52cea625..350c7be3c4 100644 --- a/src/dotnet/ManagedTypeNames.ts +++ b/src/dotnet/ManagedTypeNames.ts @@ -11,6 +11,7 @@ import { } from "./ManagedMetadataLayout.js"; import { metadataRowCursor, + type MetadataTokenFact, metadataToken, readMetadataBlob, } from "./ManagedMetadataHeaps.js"; @@ -29,7 +30,9 @@ export const typeNameLookup = ( ranges: readonly TypeRange[], issues: ManagedParseIssue[], references: ManagedTypeReferenceLookup, -): ((token: string | null) => string | null) => { +): (( + reference: Pick | null, +) => string | null) => { const names = new Map(); const definitionName = (table: 1 | 2, row: number): string | null => { if (table === 2) { @@ -59,11 +62,10 @@ export const typeNameLookup = ( ? null : definitionName(nominalGenericType.table, nominalGenericType.row); }; - return (token) => { - if (token === null) return null; + return (reference) => { + if (reference === null) return null; + const { token, table, row } = reference; if (names.has(token)) return names.get(token) ?? null; - const table = Number.parseInt(token.slice(2, 4), 16); - const row = Number.parseInt(token.slice(4), 16); const name = readManagedValue( () => diff --git a/src/dotnet/ManagedUserStrings.ts b/src/dotnet/ManagedUserStrings.ts index cce2f0b65d..a2a703bc2d 100644 --- a/src/dotnet/ManagedUserStrings.ts +++ b/src/dotnet/ManagedUserStrings.ts @@ -7,11 +7,14 @@ import type { ManagedParseIssue, } from "../domain/managed/managedArtifact.js"; import type { - ManagedMethod, + DecodedMethod, ManagedUserString, } from "./ManagedMemberInspectorCore.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; -import { readMetadataUserString } from "./ManagedMetadataHeaps.js"; +import { + readMetadataUserString, + type MetadataTokenFact, +} from "./ManagedMetadataHeaps.js"; import { readManagedValue } from "./ManagedReaderFailure.js"; type UserStringInventory = NonNullable; @@ -40,7 +43,7 @@ export const parseUserStrings = ( bytes: Buffer, layout: ManagedMetadataLayout, parsed: { - readonly methods: readonly ManagedMethod[]; + readonly methods: readonly DecodedMethod[]; /** Methods that may be in scope but whose CIL was not fully scanned. */ readonly unscannedMethods: number; /** Unreadable selected type names may hide a requested type. */ @@ -54,27 +57,27 @@ export const parseUserStrings = ( (parsed.unreadableSelectedTypes ?? 0) === 0; const userStrings: ManagedUserString[] = []; const issues: ManagedParseIssue[] = []; - const tokens = new Set(); + const tokens = new Map(); try { // Only ldstr loads a literal; a malformed ldtoken can carry a 0x70 token. - for (const method of methods) - for (const { opcode, operand } of method.body.anchors) - if (opcode === "ldstr" && operand !== null && !tokens.has(operand)) { + for (const { tokenInstructions } of methods) + for (const { opcode, reference } of tokenInstructions) + if (opcode === "ldstr" && !tokens.has(reference.raw)) { budget.admitTransient(USER_STRING_TOKEN_BYTES, null); - tokens.add(operand); + tokens.set(reference.raw, reference); } - // Tokens are fixed-width lowercase hex, so string order is token order. - const references: string[] = []; - for (const token of [...tokens].sort()) { - if (token.startsWith("0x70")) references.push(token); - // An operand from another table names no #US item, with or without - // the stream. + // Unsigned token order is the same as their fixed-width lowercase hex order. + const references: MetadataTokenFact[] = []; + for (const reference of [...tokens.values()].sort( + (left, right) => left.raw - right.raw, + )) { + if (reference.table === 0x70) references.push(reference); else issues.push( admitManagedProjection( { code: "invalid-heap-index", - scope: `metadata.#US:${token}`, + scope: `metadata.#US:${reference.token}`, offset: null, detail: "ldstr operand does not name an item inside #US", }, @@ -97,17 +100,20 @@ export const parseUserStrings = ( ); return inventory(userStrings, issues, scanned); } - for (const token of references) { + for (const reference of references) { const failures: ManagedParseIssue[] = []; readManagedValue(() => { const { offset, value, issues: observed, - } = readMetadataUserString(bytes, layout, token); + } = readMetadataUserString(bytes, layout, reference); for (const issue of observed) failures.push(issue); userStrings.push( - admitManagedProjection({ token, offset, value }, budget), + admitManagedProjection( + { token: reference.token, offset, value }, + budget, + ), ); }, failures); for (const failure of failures) diff --git a/tests/acceptance/managedCliOutput.test.ts b/tests/acceptance/managedCliOutput.test.ts index 0eb91e9853..351ade5f97 100644 --- a/tests/acceptance/managedCliOutput.test.ts +++ b/tests/acceptance/managedCliOutput.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -53,6 +54,85 @@ cliTest( }, ); +cliTest( + "managed CLI inspects malformed MethodSpecs used by isolated non-call instructions", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-managed-methodspec-operands-", + ); + for (const [opcode, name, operandKind] of [ + [0x71, "ldobj", "type"], + [0x7e, "ldsfld", "field"], + [0xd0, "ldtoken", "method"], + ] as const) { + const path = join(root, `${name}.dll`); + // Each body has one token operand; no call can trigger MethodSpec + // inspection on behalf of the non-call instruction under observation. + const il = Buffer.from([opcode, 1, 0, 0, 0x2b, 0x2a]); + await writeFile( + path, + buildManagedPeFixture({ + memberRefName: "NotAMethod", + memberRefSignature: Buffer.from([0x06, 0x0e]), + methodSpecMethods: [(1 << 1) | 1], + ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]), + }), + ); + const result = await cli.run({ + arguments: ["inspect-managed-members", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + operation: "inspect_managed_members", + subject: { local_path: path }, + normalized_result: { + methods: [ + expect.objectContaining({ + body: expect.objectContaining({ + status: "present", + il_sha256: createHash("sha256").update(il).digest("hex"), + normalized_il_sha256: expect.any(String), + anchors: [ + expect.objectContaining({ + opcode: name, + operand_kind: operandKind, + operand: "0x2b000001", + }), + expect.objectContaining({ opcode: "ret", operand: null }), + ], + issue: null, + }), + }), + ], + call_edges: [], + field_accesses: + name === "ldsfld" + ? [ + expect.objectContaining({ + opcode: "ldsfld", + field_token: "0x2b000001", + field_name: null, + field_declaring_type: null, + }), + ] + : [], + coverage: { + state: "partial", + issues: expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-row", + scope: "metadata.MethodSpec:0x2b000001", + detail: + "MethodSpec method does not have a decodable method signature", + }), + ]), + }, + }, + }); + } + }, +); + cliTest( "managed CLI retains metadata and CIL after repeated local constructor owner lookups", async ({ cli }) => { From f4179f511fc78f14d422c42408ecff7336a8d1eb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:20:51 +0800 Subject: [PATCH 2/2] test(managed): scope cleanup failures to snapshot reads --- .../managed/managedMemberComparison.test.ts | 60 +++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index 36143b90bd..ade38c3483 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -775,6 +775,25 @@ describe("managed comparison post-admission read failures", () => { }); describe("managed comparison snapshot admission and cleanup", () => { + const snapshotReaderWithClose = + ( + closeHandle: (owner: OwnedFileHandle) => Promise, + ): typeof readManagedSnapshot => + async (path, operation, options) => { + // Target resolution owns separate handles. Inject only while the real + // snapshot reader owns the descriptor whose retry this test observes. + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(function (this: OwnedFileHandle) { + return closeHandle(this); + }); + try { + return await readManagedSnapshot(path, operation, options); + } finally { + close.mockRestore(); + } + }; + it("rejects a sparse oversized snapshot through the default reader before reading it", async () => { const directory = await createTestTempDirectory( "rea-managed-oversized-snapshot-", @@ -825,26 +844,28 @@ describe("managed comparison snapshot admission and cleanup", () => { const originalClose = OwnedFileHandle.prototype.close; const owners: OwnedFileHandle[] = []; let attempts = 0; - const close = vi - .spyOn(OwnedFileHandle.prototype, "close") - .mockImplementation(async function (this: OwnedFileHandle) { - owners.push(this); - attempts += 1; - if (attempts === 1) throw new Error("injected transient close failure"); - await originalClose.call(this); - }); try { const result = await compareManagedMemberPaths( { leftPath, rightPath }, - {}, + { + readSnapshot: snapshotReaderWithClose(async (owner) => { + owners.push(owner); + attempts += 1; + if (attempts === 1) + throw new Error("injected transient close failure"); + await originalClose.call(owner); + }), + }, resources, ); expect(result.ok).toBe(true); expect(owners[0]).toBe(owners[1]); expect(attempts).toBe(3); + expect(owners[2]).not.toBe(owners[0]); + expect(owners.map((owner) => owner.handle.fd)).toEqual([-1, -1, -1]); + expect(resources.hasPending).toBe(false); } finally { - close.mockRestore(); await resources.close(); } }); @@ -860,17 +881,16 @@ describe("managed comparison snapshot admission and cleanup", () => { const resources = new ArtifactResourceScope(); const cleanupCause = new Error("injected persistent close failure"); const owners: OwnedFileHandle[] = []; - const close = vi - .spyOn(OwnedFileHandle.prototype, "close") - .mockImplementation(async function (this: OwnedFileHandle) { - owners.push(this); - throw cleanupCause; - }); let result: Awaited>; try { result = await compareManagedMemberPaths( { leftPath, rightPath }, - {}, + { + readSnapshot: snapshotReaderWithClose(async (owner) => { + owners.push(owner); + throw cleanupCause; + }), + }, resources, ); expect(result.ok).toBe(false); @@ -879,12 +899,16 @@ describe("managed comparison snapshot admission and cleanup", () => { code: "cleanup_incomplete", details: { cleanup: "incomplete", resources: [leftPath] }, }); + expect(owners).toHaveLength(2); expect(owners[0]).toBe(owners[1]); expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0); + expect(resources.hasPending).toBe(true); + expect(resources.cleanupResources).toEqual([leftPath]); } finally { - close.mockRestore(); await resources.close(); } + expect(owners[0]?.handle.fd).toBe(-1); + expect(resources.hasPending).toBe(false); }); });