From 91830f1e0e68232e991902b0fa7617651d2eee15 Mon Sep 17 00:00:00 2001 From: Nelglor Date: Tue, 13 Oct 2026 09:12:15 -0400 Subject: [PATCH] feat(managed): name the declaring type of call and field targets (#2163) * feat(managed): name the declaring type of call and field targets Call edges named external targets by member name alone (Join, ToJson, WriteAllText), and method-spec edges had no name at all, so an analyst could not tell which library a method calls or find every use of a type without a decompiler. Field accesses through MemberRefs had no name either. Name each TypeDef, TypeRef, and generic-instantiation TypeSpec once, in the namespace-qualified form TypeDef full_name uses, and add member_refs parent_name, call_edges target_declaring_type, and field_accesses field_declaring_type. A method-spec target reports the generic method it instantiates. Fields, member references, methods, and method specs now resolve through one token-keyed MemberCore lookup instead of three maps. Evidence recorded before these fields reads them as null. Co-Authored-By: Claude Opus 5.5 * fix(managed): validate spec blobs and member kinds before naming targets Address review findings on declaring-type naming: - A generic instantiation must name a TypeDef or TypeRef, so a TypeSpec base is reported as malformed instead of followed. Naming no longer recurses. - TypeSpec and MethodSpec instantiation blobs are decoded with the member-signature grammar. Malformed blobs and out-of-range TypeDef rows are reported against their owning row and make coverage partial. - A MethodSpec reads both columns. An invalid Method coded index is reported; a readable Method keeps its name beside a malformed instantiation. - MemberCore carries its signature kind, so a field operand is named only by a field or field-signature MemberRef. This replaces the token-table guard. Co-Authored-By: Claude Opus 5.5 * fix(managed): validate MethodSpec arity and report unsupported spec blobs - A MethodSpec now checks that its method signature is generic and that the instantiation supplies as many type arguments as it declares. A non-generic target, a field reference, or a count mismatch is reported as invalid-row; the readable method keeps its name. - Unsupported TypeSpec and MethodSpec forms raise an owner-scoped unsupported-signature issue instead of reading as an intentional unknown. - MethodSpec reading moves to ManagedMethodSpecs.ts, and its tests get their own describe block. Co-Authored-By: Claude Opus 5.5 * fix(managed): reject non-method signature headers and invalid TypeRef scopes The method signature header reader now owns the calling-convention rule that only FNPTR applied: FIELD, LOCAL_SIG, PROPERTY, and GENERICINST headers, the reserved bit, and EXPLICITTHIS without HASTHIS are malformed. A MethodSpec whose method has a PROPERTY header is no longer accepted as a generic method, and member signatures with such headers no longer decode as methods. The type-name lookup validates a TypeRef's ResolutionScope coded index and reports an out-of-range scope as invalid-row, keeping the readable name. Co-Authored-By: Claude Opus 5.5 * fix(managed): hold member rows to MethodDefSig and FieldSig headers Method signature headers are now checked in context. MethodDef and MemberRef rows accept only the MethodDefSig/MethodRefSig headers: DEFAULT, VARARG, or GENERIC with DEFAULT. FNPTR keeps the unmanaged conventions compilers emit for function pointers. A field signature must be exactly FIELD, so a flagged FIELD header is malformed and never names a field. Co-Authored-By: Claude Opus 5.5 * fix(managed): accept MethodSpec arity only from a fully decoded signature The arity check read only the target's signature header, so a truncated generic method signature passed. Under type selection a MethodDef target outside the selection is never decoded elsewhere, so coverage stayed complete. The arity now comes from a transient full decode, and only a decoded method signature supplies it. signature() keeps its raw length and digest around the shared decodeSignature. Co-Authored-By: Claude Opus 5.5 * perf(managed): decode each MethodSpec target signature once edgeTargets caches the generic arity, including a failed decode, by target method token, so many instantiations of one generic method share a single signature decode. Unity.InputSystem has 472 distinct MethodSpecs over about 133 target methods. Co-Authored-By: Claude Opus 5.5 * fix(managed): validate spec type operands and transitive reference scopes * fix(managed): retain readable names when a scope row is unreadable * test(managed): pin owner-scoped spec operand and scope-chain checks e59217c83 validates spec type operands inside specSignature, so a malformed generic TypeSpec base is now invalid-row under its owning TypeSpec row; the three stale expectations follow that. New cases cover a TypeSpec argument and a MethodSpec argument that name a missing TypeRef, and a TypeRef whose ResolutionScope names itself. The scope cases move to their own describe block. Co-Authored-By: Claude Opus 5.5 * fix(managed): keep field targets from naming MethodSpecs A MethodSpec over a field-signature MemberRef inherited kind "field", so an ldsfld on that MethodSpec published a field name and declaring type for malformed IL. A MethodSpec now borrows its target's name only when the target is not a field; the arity check still reports it. Co-Authored-By: Claude Opus 5.5 * fix(managed): reuse owner-decoded signatures for MethodSpec arity MemberRef signatures, and MethodDef signatures in the type selection, are already fully decoded, but the MethodSpec arity check decoded them again and admitted a second transient allocation, which could exhaust the budget. MemberCore now carries the owner's genericArity, and only a target nothing decoded, such as an unselected MethodDef, falls back to the raw signature. signatureGenericArity is the one rule for both paths. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/managed-code-analysis.md | 22 ++ src/domain/managed/managedArtifact.ts | 19 +- src/dotnet/ManagedMemberInspector.test.ts | 394 +++++++++++++++++++++- src/dotnet/ManagedMemberInspector.ts | 18 +- src/dotnet/ManagedMemberInspectorCore.ts | 230 ++++++++++--- src/dotnet/ManagedMemberRows.ts | 113 +++++-- src/dotnet/ManagedMethodSpecs.ts | 154 +++++++++ src/dotnet/ManagedPe.fixture.ts | 81 ++++- src/dotnet/ManagedTypeNames.ts | 137 ++++++++ 9 files changed, 1060 insertions(+), 108 deletions(-) create mode 100644 src/dotnet/ManagedMethodSpecs.ts create mode 100644 src/dotnet/ManagedTypeNames.ts diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 060b44bc2a..d60603ce39 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -147,6 +147,28 @@ opposite observation's selection unknown, and application graphs report their selected scope as partial. Neither makes an absence claim about the whole assembly. Older member Evidence without `selection` remains readable. +### Edge target declaring types + +`member_refs[].parent_name`, `call_edges[].target_declaring_type`, and +`field_accesses[].field_declaring_type` name the type that declares each +target. Referenced spec signatures validate encoded type coordinates against +the metadata tables, including nested type arguments and custom modifiers. +TypeRef resolution scopes are checked transitively with cycle detection; +readable names survive malformed scopes as partial evidence. +TypeDef and TypeRef names use the namespace-qualified `full_name` +form, and nested types keep bare names. A generic-instantiation TypeSpec +parent names its generic type, such as ``System.Collections.Generic.List`1``. +Other well-formed TypeSpec forms and ModuleRef or MethodDef parents stay +`null`. A `method-spec` call edge reports the name and declaring type of the +generic method it instantiates. A field access is named only by a field or a +field-signature MemberRef. TypeSpec and MethodSpec blobs are decoded with the +member-signature grammar, and a MethodSpec must instantiate a generic method +with as many type arguments as it declares. Malformed or unsupported rows and +blobs, and arity mismatches, make coverage partial. A readable MethodSpec +method column that names a method, or a TypeRef whose resolution scope is +invalid, keeps its name. +Older member Evidence without these fields reads them as `null`: unknown. + ## Analysis objective Start by identifying the artifact with `inspect_managed_artifact`, then inspect diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index 297b60a4eb..94eff2398d 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -203,10 +203,18 @@ const managedMethodSchema = z.object({ body: managedMethodBodySchema, }); +// Declaring types are namespace-qualified like TypeDef `full_name`, nested +// types keep bare names, and a generic instantiation names its generic type. +// Evidence recorded before declaring types were named reads them as null. +const declaringTypeNameSchema = z.string().nullable().default(null); + const managedMemberReferenceSchema = z.object({ token: tokenSchema, row_offset: offsetSchema, parent_token: tokenSchema.nullable(), + parent_name: declaringTypeNameSchema.describe( + "Name of a TypeDef, TypeRef, or generic-instantiation TypeSpec parent; null for other parents or when unknown", + ), name: z.string(), signature: managedSignatureSchema, }); @@ -281,7 +289,13 @@ const managedCallEdgeSchema = z.object({ opcode: z.string(), target_token: tokenSchema, target_kind: z.enum(["method-def", "member-ref", "method-spec", "unknown"]), - target_name: z.string().nullable(), + target_name: z + .string() + .nullable() + .describe("Method name; a method-spec target reports its generic method"), + target_declaring_type: declaringTypeNameSchema.describe( + "Declaring type of the called method; null when unknown", + ), }); const managedFieldAccessSchema = z.object({ @@ -290,6 +304,9 @@ const managedFieldAccessSchema = z.object({ opcode: z.string(), field_token: tokenSchema, field_name: z.string().nullable(), + field_declaring_type: declaringTypeNameSchema.describe( + "Declaring type of the accessed field; null when unknown", + ), }); const managedParseIssueSchema = z.object({ diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 81d64f4a17..9ada1bf552 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -110,6 +110,8 @@ describe("managed member inspection", () => { expect(result.member_refs).toEqual([ expect.objectContaining({ token: "0x0a000001", + parent_token: "0x01000001", + parent_name: "System.Runtime.Versioning.TargetFrameworkAttribute", name: ".ctor", signature: expect.objectContaining({ kind: "method", @@ -126,6 +128,8 @@ describe("managed member inspection", () => { target_token: "0x0a000001", target_kind: "member-ref", target_name: ".ctor", + target_declaring_type: + "System.Runtime.Versioning.TargetFrameworkAttribute", }, ]); expect(result.field_accesses).toEqual([ @@ -135,12 +139,366 @@ describe("managed member inspection", () => { opcode: "ldfld", field_token: "0x04000001", field_name: "counter", + field_declaring_type: "Fixture.Program", }, ]); expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); }); }); +/** A tiny-header body of token-operand instructions followed by `ret`. */ +const tokenBody = ( + instructions: readonly (readonly [opcode: number, token: number])[], +): Buffer => { + const il = Buffer.concat([ + ...instructions.map(([opcode, token]) => { + const instruction = Buffer.alloc(5); + instruction.writeUInt8(opcode, 0); + instruction.writeUInt32LE(token, 1); + return instruction; + }), + Buffer.from([0x2a]), + ]); + return Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]); +}; + +const attribute = "System.Runtime.Versioning.TargetFrameworkAttribute"; +const inspect = (options: Parameters[0]) => { + const bytes = buildManagedPeFixture(options); + return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes)); +}; +const call = 0x28; +const ldsfld = 0x7e; +const memberRefOnTypeSpec = (signature: Buffer) => + inspect({ + typeSpecSignatures: [signature], + memberRefParentRaw: (1 << 3) | 4, + }); + +// HASTHIS | GENERIC, one generic parameter, no parameters, void. +const genericRef = Buffer.from([0x30, 0x01, 0x00, 0x01]); +// GENERIC, one generic parameter, no parameters, void. +const genericDef = Buffer.from([0x10, 0x01, 0x00, 0x01]); + +describe("managed edge target declaring types", () => { + it("names a generic TypeSpec parent by its generic type and leaves other TypeSpecs unknown", () => { + // GENERICINST CLASS TypeRef(1) + const generic = memberRefOnTypeSpec( + Buffer.from([0x15, 0x12, (1 << 2) | 1, 0x01, 0x0e]), + ); + expect(generic.member_refs[0]?.parent_name).toBe(attribute); + expect(generic.call_edges[0]?.target_declaring_type).toBe(attribute); + + // SZARRAY i4 + const array = memberRefOnTypeSpec(Buffer.from([0x1d, 0x08])); + expect(array.member_refs[0]?.parent_name).toBeNull(); + expect(array.coverage).toEqual({ state: "complete", issues: [] }); + }); + + it.each([ + [ + "names a TypeSpec as its generic type", + [0x15, 0x12, (1 << 2) | 2, 0x01, 0x0e], + "invalid-row", + "metadata.TypeSpec:0x1b000001", + ], + [ + "has no type arguments", + [0x15, 0x12, (1 << 2) | 1], + "invalid-blob", + "metadata.TypeSpec:0x1b000001", + ], + [ + "names a missing TypeRef", + [0x15, 0x12, (9 << 2) | 1, 0x01, 0x0e], + "invalid-row", + "metadata.TypeSpec:0x1b000001", + ], + [ + "names a missing TypeDef", + [0x15, 0x12, (9 << 2) | 0, 0x01, 0x0e], + "invalid-row", + "metadata.TypeSpec:0x1b000001", + ], + [ + "has a type argument naming a missing TypeRef", + [0x15, 0x12, (1 << 2) | 1, 0x01, 0x12, (9 << 2) | 1], + "invalid-row", + "metadata.TypeSpec:0x1b000001", + ], + ])( + "reports a generic TypeSpec parent that %s and leaves it unknown", + (_case, signature, code, scope) => { + const result = memberRefOnTypeSpec(Buffer.from(signature)); + + expect(result.member_refs[0]?.parent_name).toBeNull(); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [{ code, scope }], + }); + }, + ); + + it("reports unsupported TypeSpec and MethodSpec forms instead of treating them as unknown", () => { + const typeSpec = memberRefOnTypeSpec(Buffer.from([0xff])); + const methodSpec = inspect({ + memberRefSignature: genericRef, + methodSpecMethods: [(1 << 1) | 1], + methodSpecInstantiation: Buffer.from([0x0a, 0x01, 0xff]), + ilBody: tokenBody([[call, 0x2b00_0001]]), + }); + + expect(typeSpec.member_refs[0]?.parent_name).toBeNull(); + expect(typeSpec.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "unsupported-signature", + scope: "metadata.TypeSpec:0x1b000001", + }, + ], + }); + expect(methodSpec.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "unsupported-signature", + scope: "metadata.MethodSpec:0x2b000001", + }, + ], + }); + }); + + it("names field MemberRefs as fields and leaves mismatched member kinds unnamed", () => { + const result = inspect({ + memberRefName: "Empty", + // FIELD string + memberRefSignature: Buffer.from([0x06, 0x0e]), + ilBody: tokenBody([ + [ldsfld, 0x0a00_0001], + [call, 0x0400_0001], + ]), + }); + const methodRef = inspect({ ilBody: tokenBody([[ldsfld, 0x0a00_0001]]) }); + const flaggedField = inspect({ + memberRefName: "Empty", + // GENERIC | FIELD string: FieldSig allows no flags. + memberRefSignature: Buffer.from([0x16, 0x0e]), + ilBody: tokenBody([[ldsfld, 0x0a00_0001]]), + }); + + expect(result.field_accesses).toMatchObject([ + { field_name: "Empty", field_declaring_type: attribute }, + ]); + expect(methodRef.field_accesses).toMatchObject([ + { field_name: null, field_declaring_type: null }, + ]); + expect(flaggedField.field_accesses).toMatchObject([ + { field_name: null, field_declaring_type: null }, + ]); + expect(flaggedField.coverage.state).toBe("partial"); + expect(result.call_edges).toMatchObject([ + { + target_kind: "unknown", + target_name: null, + target_declaring_type: null, + }, + ]); + }); + + it("reads Evidence recorded before declaring types were named as unknown", () => { + const current = inspect({}); + const parsed = managedMemberInspectionSchema.parse({ + ...current, + member_refs: current.member_refs.map( + ({ parent_name: _omitted, ...legacy }) => legacy, + ), + call_edges: current.call_edges.map( + ({ target_declaring_type: _omitted, ...legacy }) => legacy, + ), + field_accesses: current.field_accesses.map( + ({ field_declaring_type: _omitted, ...legacy }) => legacy, + ), + }); + + expect(parsed.member_refs[0]?.parent_name).toBeNull(); + expect(parsed.call_edges[0]?.target_declaring_type).toBeNull(); + expect(parsed.field_accesses[0]?.field_declaring_type).toBeNull(); + }); +}); + +describe("managed TypeRef resolution scopes", () => { + it.each([ + // AssemblyRef 9 does not exist. + ["an invalid", (9 << 2) | 2, "is invalid"], + // The TypeRef is its own enclosing type. + ["a cyclic", (1 << 2) | 3, "contains a cycle"], + ])( + "keeps a TypeRef name beside %s resolution scope", + (_case, typeRefScopeRaw, detail) => { + const result = inspect({ typeRefScopeRaw }); + + expect(result.member_refs[0]?.parent_name).toBe(attribute); + expect(result.call_edges[0]?.target_declaring_type).toBe(attribute); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "invalid-row", + scope: "metadata.TypeRef:0x01000001", + detail: expect.stringContaining(detail), + }, + ], + }); + }, + ); +}); + +describe("managed MethodSpec call targets", () => { + it("names generic method calls after the method they instantiate", () => { + const result = inspect({ + memberRefSignature: genericRef, + methodSignature: genericDef, + methodSpecMethods: [(1 << 1) | 1, (1 << 1) | 0], + ilBody: tokenBody([ + [call, 0x2b00_0001], + [call, 0x2b00_0002], + ]), + }); + + expect( + result.call_edges.map((edge) => [ + edge.target_kind, + edge.target_name, + edge.target_declaring_type, + ]), + ).toEqual([ + ["method-spec", ".ctor", attribute], + ["method-spec", "Main", "Fixture.Program"], + ]); + expect(result.coverage).toEqual({ state: "complete", issues: [] }); + }); + + it.each([ + [ + "a non-generic method", + {}, + Buffer.from([0x0a, 0x01, 0x0e]), + "but its method declares 0 generic parameter(s)", + ], + [ + "the wrong number of type arguments", + { memberRefSignature: genericRef }, + Buffer.from([0x0a, 0x02, 0x0e, 0x08]), + "supplies 2 type argument(s), but its method declares 1", + ], + [ + "a field reference", + { memberRefSignature: Buffer.from([0x06, 0x0e]) }, + Buffer.from([0x0a, 0x01, 0x0e]), + "does not have a decodable method signature", + ], + [ + "a property signature", + // PROPERTY | GENERIC, one generic parameter, no parameters, void. + { memberRefSignature: Buffer.from([0x18, 0x01, 0x00, 0x01]) }, + Buffer.from([0x0a, 0x01, 0x0e]), + "does not have a decodable method signature", + ], + [ + "a truncated signature", + // HASTHIS | GENERIC, one generic parameter, no parameters, no return type. + { memberRefSignature: Buffer.from([0x30, 0x01, 0x00]) }, + Buffer.from([0x0a, 0x01, 0x0e]), + "does not have a decodable method signature", + ], + [ + "a type argument naming a missing TypeRef", + { memberRefSignature: genericRef }, + Buffer.from([0x0a, 0x01, 0x12, (9 << 2) | 1]), + "TypeDefOrRefOrSpec operand 0x25 is invalid", + ], + [ + "an unmanaged-convention signature", + // C | GENERIC: a function pointer convention, not a MethodRefSig one. + { memberRefSignature: Buffer.from([0x11, 0x01, 0x00, 0x01]) }, + Buffer.from([0x0a, 0x01, 0x0e]), + "does not have a decodable method signature", + ], + ])( + "reports a MethodSpec that instantiates %s", + (_case, options, methodSpecInstantiation, detail) => { + const result = inspect({ + ...options, + methodSpecMethods: [(1 << 1) | 1], + methodSpecInstantiation, + ilBody: tokenBody([[call, 0x2b00_0001]]), + }); + + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "invalid-row", + scope: "metadata.MethodSpec:0x2b000001", + detail: expect.stringContaining(detail), + }, + ], + }); + }, + ); + + it("never names a field through a MethodSpec", () => { + const result = inspect({ + memberRefName: "Empty", + // FIELD string: a MethodSpec on it is malformed. + memberRefSignature: Buffer.from([0x06, 0x0e]), + methodSpecMethods: [(1 << 1) | 1], + ilBody: tokenBody([ + [ldsfld, 0x2b00_0001], + [call, 0x2b00_0001], + ]), + }); + + expect(result.field_accesses).toMatchObject([ + { field_name: null, field_declaring_type: null }, + ]); + expect(result.call_edges).toMatchObject([ + { target_name: null, target_declaring_type: null }, + ]); + expect(result.coverage.state).toBe("partial"); + }); + + it("keeps a readable MethodSpec method beside a malformed instantiation", () => { + const result = inspect({ + methodSpecMethods: [(1 << 1) | 1, (9 << 1) | 1], + methodSpecInstantiation: Buffer.from([0x0a, 0x00]), + ilBody: tokenBody([ + [call, 0x2b00_0001], + [call, 0x2b00_0002], + ]), + }); + + expect( + result.call_edges.map(({ target_name, target_declaring_type }) => [ + target_name, + target_declaring_type, + ]), + ).toEqual([ + [".ctor", attribute], + [null, null], + ]); + expect(result.coverage.state).toBe("partial"); + expect( + result.coverage.issues.map(({ code, scope }) => [code, scope]), + ).toEqual([ + ["invalid-blob", "metadata.MethodSpec:0x2b000001"], + ["invalid-row", "metadata.MethodSpec:0x2b000002"], + ["invalid-blob", "metadata.MethodSpec:0x2b000002"], + ]); + }); +}); + describe("managed exported types", () => { it("reports type forwarders and nested exported types with their implementation scope", () => { const bytes = buildManagedPeFixture({ @@ -373,15 +731,9 @@ describe("managed derived edges", () => { body: { anchors: Array.from({ length: 40_000 }, () => anchor) }, } as unknown as ManagedMethod; - expect(() => - edges( - [method], - new Map(), - new Map(), - new Map(), - new ManagedDecodeBudget(), - ), - ).toThrow(ManagedReaderFailure); + expect(() => edges([method], new Map(), new ManagedDecodeBudget())).toThrow( + ManagedReaderFailure, + ); }); }); @@ -413,15 +765,9 @@ describe("managed derived caller names", () => { }, } as unknown as ManagedMethod; - expect(() => - edges( - [method], - new Map(), - new Map(), - new Map(), - new ManagedDecodeBudget(), - ), - ).toThrow(ManagedReaderFailure); + expect(() => edges([method], new Map(), new ManagedDecodeBudget())).toThrow( + ManagedReaderFailure, + ); expect(built).toBe(false); }); }); @@ -671,6 +1017,18 @@ it.each([ }); }); +it.each([ + ["a property header", [0x08, 0x00, 0x08]], + ["an explicit-this header without has-this", [0x40, 0x00, 0x01]], + ["a C calling convention header", [0x01, 0x00, 0x01]], + ["a generic vararg header", [0x15, 0x01, 0x00, 0x01]], +])("reports %s as a malformed member signature", (_name, blob) => { + expect(signature(Buffer.from(blob))).toMatchObject({ + kind: "unknown", + parse_status: "malformed", + }); +}); + it("rejects array ranks beyond the CLR limit before rendering dimensions", () => { expect( signature( diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 24b97f9ebe..dba20ac570 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -27,6 +27,8 @@ import { typeRanges, type TypeRange, } from "./ManagedMemberInspectorCore.js"; +import { edgeTargets } from "./ManagedMethodSpecs.js"; +import { typeNameLookup } from "./ManagedTypeNames.js"; import { edges, parseExportedTypes, @@ -171,7 +173,11 @@ export const inspectManagedMembersBytes = ( const exportedTypes = parseExportedTypes(bytes, layout); issues.push(...exportedTypes.issues); const fields = parseFields(bytes, layout, ranges, selected); - const memberRefs = parseMemberRefs(bytes, layout); + const memberRefs = parseMemberRefs( + bytes, + layout, + typeNameLookup(bytes, layout, ranges, issues), + ); issues.push(...memberRefs.issues); const methods = parseMethods({ bytes, @@ -181,6 +187,12 @@ export const inspectManagedMembersBytes = ( selected, }); issues.push(...fields.issues, ...methods.issues); + const targets = edgeTargets(bytes, layout, methods.methods, [ + fields.core, + memberRefs.core, + methods.core, + ]); + issues.push(...targets.issues); const coverageIssues = uniqueManagedParseIssues(issues); const incompleteMembers = [...fields.fields, ...memberRefs.refs, ...methods.methods].some( @@ -193,9 +205,7 @@ export const inspectManagedMembersBytes = ( ); const related = edges( methods.methods, - methods.core, - fields.core, - memberRefs.core, + targets.members, managedDecodeBudget(layout), ); return managedMemberInspectionSchema.parse( diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index f54a117699..7776c00909 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -7,6 +7,7 @@ import type { ManagedMemberInspection, ManagedParseIssue, } from "../domain/managed/managedArtifact.js"; +import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; import type { ManagedMetadataLayout, MetadataTableLayout, @@ -49,20 +50,22 @@ export interface TypeRange { readonly methodEnd: number; } -export interface MethodCore { +/** + * Name and declaring type of a field, method, member reference, or method + * specification, keyed by its token so edges resolve any target uniformly. + */ +export interface MemberCore { readonly token: string; readonly name: string; readonly declaringType: string | null; -} - -export interface FieldCore { - readonly token: string; - readonly name: string; -} - -export interface MemberRefCore { - readonly token: string; - readonly name: string; + /** Fields and methods by table; member references by decoded signature. */ + readonly kind: ManagedSignature["kind"]; + /** + * Generic arity from the owner's decoded signature (see + * `signatureGenericArity`); absent when no owner decoded it, as for a + * MethodDef outside the type selection. + */ + readonly genericArity?: number | null; } export interface ParsedInstruction { @@ -88,7 +91,7 @@ const rowRange = ( return { first: start, last: end - 1, count: end - start }; }; -const fullName = (namespace: string, name: string): string => +export const fullName = (namespace: string, name: string): string => namespace.length === 0 ? name : `${namespace}.${name}`; const readTypeRange = ( @@ -242,7 +245,7 @@ export const parseTypes = ( return { types: items, issues }; }; -const readCompressed = ( +export const readCompressed = ( bytes: Buffer, offset: number, ): { readonly value: number; readonly next: number } => { @@ -341,13 +344,38 @@ interface MethodSignatureHeader { readonly next: number; } +// MethodDefSig and MethodRefSig headers (ECMA-335 II.23.2.1-2) without +// HASTHIS and EXPLICITTHIS: DEFAULT, VARARG, or GENERIC, which implies DEFAULT. +const MEMBER_METHOD_HEADERS = new Set([0x00, 0x05, 0x10]); + +// FNPTR also carries the unmanaged C, STDCALL, THISCALL, FASTCALL, and +// UNMANAGED conventions that compilers emit for unmanaged function pointers. +const FUNCTION_POINTER_CONVENTIONS = new Set([0, 1, 2, 3, 4, 5, 9]); + +/** + * Read a method signature's calling convention and counts. A header that is + * not a method signature in its context (a MethodDef or MemberRef row, or an + * FNPTR type), sets the reserved bit, or sets EXPLICITTHIS without HASTHIS is + * malformed, so no caller consumes its counts. FIELD, LOCAL_SIG, PROPERTY, + * and GENERICINST headers are never method signatures. + */ const readMethodSignatureHeader = ( blob: Buffer, offset: number, + context: "member" | "function-pointer", ): MethodSignatureHeader => { const first = blob[offset]; if (first === undefined) return malformedSignature("truncated method signature", offset); + const allowed = + context === "member" + ? MEMBER_METHOD_HEADERS.has(first & 0x9f) + : (first & 0x80) === 0 && FUNCTION_POINTER_CONVENTIONS.has(first & 0x0f); + if (!allowed || ((first & 0x40) !== 0 && (first & 0x20) === 0)) + return malformedSignature( + `calling convention 0x${first.toString(16)} does not head a ${context} method signature`, + offset, + ); let next = offset + 1; let genericParameterCount: number | null = null; if ((first & 0x10) !== 0) { @@ -366,12 +394,6 @@ const readMethodSignatureHeader = ( const MAX_ARRAY_RANK = 32; -// FNPTR carries a method signature: managed DEFAULT/VARARG, or the unmanaged -// C, STDCALL, THISCALL, FASTCALL, and UNMANAGED conventions that compilers -// emit for unmanaged function pointers. FIELD, LOCAL_SIG, PROPERTY, and -// GENERICINST headers are not method signatures. -const FUNCTION_POINTER_CONVENTIONS = new Set([0, 1, 2, 3, 4, 5, 9]); - const arrayShape = ( blob: Buffer, offset: number, @@ -449,10 +471,40 @@ const POSITION_ELEMENTS: ReadonlyMap = [0x10, ["return", "parameter", "field"]], ]); +/** Decode a type operand once; spec callers also validate its metadata coordinate. */ +const readTypeOperand = ( + blob: Buffer, + offset: number, + layout?: ManagedMetadataLayout, + tables: readonly number[] = [2, 1, 27], +): ReturnType => { + const token = readCompressed(blob, offset); + if (layout !== undefined) { + const reason = + token.value === 0 + ? "type operand is null" + : metadataCodedTokenInvalidReason( + token.value, + 2, + tables, + layout.rowCounts, + ); + if (reason !== null) + throw managedFailure( + "invalid-row", + "signature", + `TypeDefOrRefOrSpec operand 0x${token.value.toString(16)} is invalid: ${reason}`, + offset, + ); + } + return token; +}; + const readTypeSignature = ( blob: Buffer, offset: number, base: SignaturePosition, + layout?: ManagedMetadataLayout, ): { readonly value: string; readonly next: number } => { const stack: TypeContinuation[] = []; let cursor = offset; @@ -479,7 +531,7 @@ const readTypeSignature = ( stack.push({ kind: "suffix", suffix }); cursor += 1; } else if (kind === 0x1f || kind === 0x20) { - const token = readCompressed(blob, cursor + 1); + const token = readTypeOperand(blob, cursor + 1, layout); const modifier = `${kind === 0x1f ? "modreq" : "modopt"}:${String(token.value)}`; // A modifier frame on top was pushed by the directly preceding byte; // keep an adjacent run together and in source order. @@ -488,7 +540,7 @@ const readTypeSignature = ( else stack.push({ kind: "modifiers", modifiers: [modifier] }); cursor = token.next; } else if (kind === 0x11 || kind === 0x12) { - const token = readCompressed(blob, cursor + 1); + const token = readTypeOperand(blob, cursor + 1, layout); value = `${kind === 0x11 ? "valuetype" : "class"}:${String(token.value)}`; cursor = token.next; } else if (kind === 0x1e || kind === 0x13) { @@ -508,7 +560,7 @@ const readTypeSignature = ( "generic instantiation does not name a class or value type", cursor + 1, ); - const token = readCompressed(blob, cursor + 2); + const token = readTypeOperand(blob, cursor + 2, layout, [2, 1]); const argumentCount = readCompressed(blob, token.next); if (argumentCount.value === 0) return malformedSignature( @@ -525,16 +577,11 @@ const readTypeSignature = ( }); cursor = argumentCount.next; } else if (kind === 0x1b) { - const header = readMethodSignatureHeader(blob, cursor + 1); - if ( - !FUNCTION_POINTER_CONVENTIONS.has(header.first & 0x0f) || - (header.first & 0x80) !== 0 || - ((header.first & 0x40) !== 0 && (header.first & 0x20) === 0) - ) - return malformedSignature( - `function pointer calling convention 0x${header.first.toString(16)} does not head a method signature`, - cursor + 1, - ); + const header = readMethodSignatureHeader( + blob, + cursor + 1, + "function-pointer", + ); const convention = callingConvention(header.first); const head = header.genericParameterCount === null @@ -602,22 +649,81 @@ const callingConvention = (value: number): string => { return flags.length === 0 ? name : `${name} ${flags.join(" ")}`; }; -export const signature = ( +/** + * Decode a whole TypeSpec blob, or a MethodSpec instantiation (GENERICINST, + * argument count, Types), with the member-signature grammar and return the + * rendered types. A malformed or unsupported blob throws invalid-blob or + * unsupported-signature under the owning row, so coverage records it. + */ +export const specSignature = ( + layout: ManagedMetadataLayout, blob: Buffer, - rawSha256?: string, -): ManagedSignature => { - const raw = { - raw_length: blob.length, - raw_sha256: rawSha256 ?? sha256Bytes(blob), - }; + owner: { + readonly form: "type" | "instantiation"; + readonly scope: string; + readonly offset: number; + }, +): readonly string[] => { + const budget = managedDecodeBudget(layout); + // Rendering is transient, so charge it as member signatures are charged. + budget.admitTransient(blob.length * 256, owner.offset); + try { + let cursor = 0; + let count = 1; + if (owner.form === "instantiation") { + if (blob[0] !== 0x0a) + malformedSignature("MethodSpec instantiation is not GENERICINST", 0); + const header = readCompressed(blob, 1); + if (header.value === 0) + malformedSignature("MethodSpec instantiation has no type arguments", 1); + count = header.value; + cursor = header.next; + } + const types: string[] = []; + // Each Type consumes at least one byte, so a false count fails on truncation. + for (let index = 0; index < count; index += 1) { + const type = readTypeSignature(blob, cursor, "type", layout); + types.push(type.value); + cursor = type.next; + } + if (cursor !== blob.length) + malformedSignature("Trailing signature data", cursor); + return types; + } catch (cause: unknown) { + if ( + !(cause instanceof ManagedReaderFailure) || + (cause.issue.code !== "invalid-blob" && + cause.issue.code !== "invalid-row" && + cause.issue.code !== "unsupported-signature") + ) + throw cause; + throw managedFailure( + cause.issue.code, + owner.scope, + cause.issue.detail, + owner.offset, + ); + } finally { + budget.releaseTransient(); + } +}; + +type DecodedSignature = Omit; + +/** + * Decode a field or member method signature. A malformed or unsupported blob + * decodes as kind `unknown` with its reason. + */ +const decodeSignature = (blob: Buffer): DecodedSignature => { try { if (blob.length === 0) throw managedFailure("invalid-blob", "signature", "empty signature", 0); const first = blob[0] ?? 0; - if ((first & 0x0f) === 6) { + // FieldSig (II.23.2.4) is exactly FIELD; a flagged FIELD header is not a + // method header either, so it is malformed below. + if (first === 0x06) { const fieldType = readTypeSignature(blob, 1, "field"); return { - ...raw, kind: "field", parse_status: fieldType.next === blob.length ? "decoded" : "partial", calling_convention: callingConvention(first), @@ -630,7 +736,7 @@ export const signature = ( fieldType.next === blob.length ? null : "Trailing signature data", }; } - const header = readMethodSignatureHeader(blob, 0); + const header = readMethodSignatureHeader(blob, 0, "member"); const returnType = readTypeSignature(blob, header.next, "return"); let offset = returnType.next; const parameters: string[] = []; @@ -640,7 +746,6 @@ export const signature = ( offset = parameter.next; } return { - ...raw, kind: "method", parse_status: offset === blob.length ? "decoded" : "partial", calling_convention: callingConvention(first), @@ -659,7 +764,6 @@ export const signature = ( ) throw cause; return { - ...raw, kind: "unknown", parse_status: cause.issue.code === "unsupported-signature" @@ -675,3 +779,41 @@ export const signature = ( }; } }; + +/** + * Generic parameter count of a method signature that decodes completely, 0 + * when it is not generic, or null when the blob is not a decodable method + * signature. The rendered decode is transient. + */ +export const methodGenericArity = ( + layout: ManagedMetadataLayout, + blob: Buffer, +): number | null => { + const budget = managedDecodeBudget(layout); + budget.admitTransient(blob.length * 256, layout.blob.offset); + try { + return signatureGenericArity(decodeSignature(blob)); + } finally { + budget.releaseTransient(); + } +}; + +/** + * Generic parameter count of a completely decoded method signature, 0 when it + * is not generic, or null for any other signature. + */ +export const signatureGenericArity = ( + decoded: DecodedSignature, +): number | null => + decoded.kind === "method" && decoded.parse_status === "decoded" + ? (decoded.generic_parameter_count ?? 0) + : null; + +export const signature = ( + blob: Buffer, + rawSha256?: string, +): ManagedSignature => ({ + raw_length: blob.length, + raw_sha256: rawSha256 ?? sha256Bytes(blob), + ...decodeSignature(blob), +}); diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index f4a63db5a7..201ff1c455 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -9,15 +9,14 @@ import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { signature, - type FieldCore, + signatureGenericArity, type ManagedCallEdge, type ManagedExportedType, type ManagedField, type ManagedFieldAccess, type ManagedMemberRef, type ManagedMethod, - type MemberRefCore, - type MethodCore, + type MemberCore, type TypeRange, } from "./ManagedMemberInspectorCore.js"; import { @@ -51,11 +50,11 @@ export const parseFields = ( selected: SelectedTypeTokens = null, ): { readonly fields: readonly ManagedField[]; - readonly core: ReadonlyMap; + readonly core: ReadonlyMap; readonly issues: readonly ManagedParseIssue[]; } => { const fields: ManagedField[] = []; - const core = new Map(); + const core = new Map(); const issues: ManagedParseIssue[] = []; const table = layout.table(4); const ownerRow = readManagedValue( @@ -78,7 +77,12 @@ export const parseFields = ( : (readManagedValue(() => ownerRow(row), issues) ?? 0); const declared = ranges[owner - 1]; const token = metadataToken(4, row); - core.set(token, { token, name }); + core.set(token, { + token, + name, + declaringType: declared?.fullName ?? null, + kind: "field", + }); if (!isSelected(selected, declared)) return; const sig = readMetadataBlob( bytes, @@ -174,13 +178,14 @@ export const parseExportedTypes = ( export const parseMemberRefs = ( bytes: Buffer, layout: ManagedMetadataLayout, + typeName: (token: string | null) => string | null, ): { readonly refs: readonly ManagedMemberRef[]; - readonly core: ReadonlyMap; + readonly core: ReadonlyMap; readonly issues: readonly ManagedParseIssue[]; } => { const refs: ManagedMemberRef[] = []; - const core = new Map(); + const core = new Map(); const issues: ManagedParseIssue[] = []; const table = layout.table(10); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { @@ -218,19 +223,29 @@ export const parseMemberRefs = ( ? "MemberRef parent coded index 0x0 is null, but the Class column must reference a row" : `MemberRef parent coded index 0x${parentRaw.toString(16)} is invalid: ${parentReason}`, }); + const parentToken = metadataCodedToken( + parentRaw, + 3, + [2, 1, 26, 6, 27], + layout.rowCounts, + ); + const parentName = typeName(parentToken); + const decoded = budgetedSignature(sig, layout); refs.push({ token, row_offset: cursor.start, - parent_token: metadataCodedToken( - parentRaw, - 3, - [2, 1, 26, 6, 27], - layout.rowCounts, - ), + parent_token: parentToken, + parent_name: parentName, name, - signature: budgetedSignature(sig, layout), + signature: decoded, + }); + core.set(token, { + token, + name, + declaringType: parentName, + kind: decoded.kind, + genericArity: signatureGenericArity(decoded), }); - core.set(token, { token, name }); }, issues); } return { refs, core, issues }; @@ -252,11 +267,11 @@ export const parseMethods = ({ selected = null, }: ParseMethodsInput): { readonly methods: readonly ManagedMethod[]; - readonly core: ReadonlyMap; + readonly core: ReadonlyMap; readonly issues: readonly ManagedParseIssue[]; } => { const methods: ManagedMethod[] = []; - const core = new Map(); + const core = new Map(); const issues: ManagedParseIssue[] = []; const table = layout.table(6); const ownerRow = readManagedValue( @@ -281,11 +296,13 @@ export const parseMethods = ({ : (readManagedValue(() => ownerRow(row), issues) ?? 0); const declared = ranges[owner - 1]; const token = metadataToken(6, row); - core.set(token, { + const member = { token, name, declaringType: declared?.fullName ?? null, - }); + kind: "method", + } as const; + core.set(token, member); if (!isSelected(selected, declared)) return; const sig = readMetadataBlob( bytes, @@ -294,6 +311,11 @@ export const parseMethods = ({ layout.blob.size, ); cursor.readIndex(layout.tableIndexSize(8)); + const decoded = budgetedSignature(sig, layout); + core.set(token, { + ...member, + genericArity: signatureGenericArity(decoded), + }); methods.push({ token, row_offset: cursor.start, @@ -303,7 +325,7 @@ export const parseMethods = ({ rva, impl_flags: implFlags, flags, - signature: budgetedSignature(sig, layout), + signature: decoded, body: methodBody( bytes, pe, @@ -317,6 +339,15 @@ export const parseMethods = ({ return { methods, core, issues }; }; +/** + * Member kinds each operand kind may name. A MemberRef whose signature does + * not decode keeps its name on calls, as before, but is never named a field. + */ +const NAMEABLE_KINDS = new Map>([ + ["method", new Set(["method", "unknown"])], + ["field", new Set(["field"])], +]); + const targetKind = (token: string): ManagedCallEdge["target_kind"] => token.startsWith("0x06") ? "method-def" @@ -326,11 +357,22 @@ const targetKind = (token: string): ManagedCallEdge["target_kind"] => ? "method-spec" : "unknown"; +/** The member an operand names, if the operand kind can name that member's kind. */ +const namedTarget = ( + members: ReadonlyMap, + operandKind: string, + operand: string, +): MemberCore | undefined => { + const member = members.get(operand); + return member !== undefined && + NAMEABLE_KINDS.get(operandKind)?.has(member.kind) === true + ? member + : undefined; +}; + export const edges = ( methods: readonly ManagedMethod[], - methodCore: ReadonlyMap, - fieldCore: ReadonlyMap, - refCore: ReadonlyMap, + members: ReadonlyMap, budget: ManagedDecodeBudget, ): { readonly callEdges: readonly ManagedCallEdge[]; @@ -350,11 +392,10 @@ export const edges = ( const caller = derives ? callerName(method) : method.name; for (const anchor of method.body.anchors) { if (anchor.opcode === "ldtoken") continue; - if (anchor.operand_kind === "method" && anchor.operand !== null) { - const named = - methodCore.get(anchor.operand)?.name ?? - refCore.get(anchor.operand)?.name ?? - null; + const { operand } = anchor; + if (operand === null) continue; + const target = namedTarget(members, anchor.operand_kind, operand); + if (anchor.operand_kind === "method") { // Each derived record is admitted before the graph grows further. callEdges.push( admitManagedProjection( @@ -362,23 +403,25 @@ export const edges = ( caller_token: method.token, caller, opcode: anchor.opcode, - target_token: anchor.operand, - target_kind: targetKind(anchor.operand), - target_name: named, + target_token: operand, + target_kind: targetKind(operand), + target_name: target?.name ?? null, + target_declaring_type: target?.declaringType ?? null, }, budget, ), ); } - if (anchor.operand_kind === "field" && anchor.operand !== null) { + if (anchor.operand_kind === "field") { fieldAccesses.push( admitManagedProjection( { method_token: method.token, method: caller, opcode: anchor.opcode, - field_token: anchor.operand, - field_name: fieldCore.get(anchor.operand)?.name ?? null, + field_token: operand, + field_name: target?.name ?? null, + field_declaring_type: target?.declaringType ?? null, }, budget, ), diff --git a/src/dotnet/ManagedMethodSpecs.ts b/src/dotnet/ManagedMethodSpecs.ts new file mode 100644 index 0000000000..0570908625 --- /dev/null +++ b/src/dotnet/ManagedMethodSpecs.ts @@ -0,0 +1,154 @@ +import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; +import { + methodGenericArity, + specSignature, + type ManagedMethod, + type MemberCore, +} from "./ManagedMemberInspectorCore.js"; +import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { + metadataCodedToken, + metadataCodedTokenInvalidReason, + metadataRowCursor, + readMetadataBlob, +} from "./ManagedMetadataHeaps.js"; +import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js"; + +/** + * Index fields, member references, and methods by token, then name each + * MethodSpec an inspected body calls after the generic method it instantiates, + * so every edge target resolves through one lookup. + */ +export const edgeTargets = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + methods: readonly ManagedMethod[], + cores: readonly ReadonlyMap[], +): { + readonly members: ReadonlyMap; + readonly issues: readonly ManagedParseIssue[]; +} => { + const members = new Map(cores.flatMap((core) => [...core])); + const issues: ManagedParseIssue[] = []; + 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); + readManagedValue( + () => + readMethodSpec({ bytes, layout, members, issues, arities }, operand), + issues, + ); + } + return { members, issues }; +}; + +/** + * Map one MethodSpec to its generic method, then check its instantiation + * decodes and matches that method's generic arity. A readable Method column + * keeps its name when the rest is malformed; the failure makes coverage partial. + */ +const readMethodSpec = ( + { + bytes, + layout, + members, + issues, + arities, + }: { + readonly bytes: Buffer; + readonly layout: ManagedMetadataLayout; + readonly members: Map; + readonly issues: ManagedParseIssue[]; + readonly arities: Map; + }, + token: string, +): void => { + const scope = `metadata.MethodSpec:${token}`; + const cursor = metadataRowCursor( + bytes, + layout, + 0x2b, + Number.parseInt(token.slice(4), 16), + ); + const methodRaw = cursor.readIndex(layout.codedIndexSize("MethodDefOrRef")); + const instantiation = cursor.readIndex(layout.blobIndexSize); + const methodReason = metadataCodedTokenInvalidReason( + methodRaw, + 1, + [6, 10], + layout.rowCounts, + ); + if (methodReason !== null || methodRaw === 0) + issues.push({ + code: "invalid-row", + scope, + offset: cursor.start, + detail: + methodReason === null + ? "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: ${methodReason}`, + }); + const generic = metadataCodedToken(methodRaw, 1, [6, 10], layout.rowCounts); + const target = generic === null ? undefined : members.get(generic); + // 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") + members.set(token, { ...target, token }); + const types = specSignature( + layout, + readMetadataBlob(bytes, layout, instantiation, layout.blob.size), + { form: "instantiation", scope, offset: cursor.start }, + ); + if (generic === null) return; + // 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)), + ); + const arity = + target?.genericArity === undefined + ? (arities.get(generic) ?? null) + : target.genericArity; + if (arity !== types.length) + throw managedFailure( + "invalid-row", + scope, + arity === null + ? "MethodSpec method does not have a decodable method signature" + : `MethodSpec supplies ${String(types.length)} type argument(s), but its method declares ${String(arity)} generic parameter(s)`, + cursor.start, + ); +}; + +/** The Signature column of a MethodDef or MemberRef row. */ +const methodSignatureBlob = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + token: string, +): Buffer => { + const table = Number.parseInt(token.slice(2, 4), 16); + const cursor = metadataRowCursor( + bytes, + layout, + table, + Number.parseInt(token.slice(4), 16), + ); + if (table === 6) { + cursor.readUInt32(); + cursor.readUInt16(); + cursor.readUInt16(); + } else cursor.readIndex(layout.codedIndexSize("MemberRefParent")); + cursor.readIndex(layout.stringIndexSize); + return readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + layout.blob.size, + ); +}; diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index 25f8199259..7ea2b06b47 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -29,6 +29,16 @@ interface ManagedPeFixtureOptions { readonly moduleRowCount?: number; readonly assemblyRowCount?: number; readonly memberRefParentRaw?: number; + readonly memberRefName?: string; + readonly memberRefSignature?: Buffer; + /** Raw MethodDefOrRef coded values, one MethodSpec row each. */ + readonly methodSpecMethods?: readonly number[]; + /** Instantiation blob shared by every MethodSpec row. */ + readonly methodSpecInstantiation?: Buffer; + /** TypeSpec signature blobs, one TypeSpec row each. */ + readonly typeSpecSignatures?: readonly Buffer[]; + /** Raw ResolutionScope coded value of the TypeRef row; null by default. */ + readonly typeRefScopeRaw?: number; readonly mvid?: Buffer; readonly pinvoke?: { readonly importName?: string; @@ -174,10 +184,10 @@ const typeRefRow = ( name: number, namespace: number, stringSize: MetadataIndexSize, - resolutionScopeSize: MetadataIndexSize, + resolutionScope: Buffer, ): Buffer => Buffer.concat([ - metadataIndex(0, resolutionScopeSize), + resolutionScope, metadataIndex(name, stringSize), metadataIndex(namespace, stringSize), ]); @@ -524,6 +534,8 @@ interface FixtureMetadataRowContext { readonly methodSignature: number; readonly constructorSignature: number; readonly attributeBlob: number; + readonly typeSpecSignatures: readonly number[]; + readonly methodSpecInstantiation: number; }; readonly heapSizes: { readonly strings: MetadataIndexSize; @@ -595,6 +607,37 @@ const exportedTypeTable = ( ], ]; +const specTables = ( + options: ManagedPeFixtureOptions, + indexes: FixtureMetadataRowContext["indexes"], + heapSizes: FixtureMetadataRowContext["heapSizes"], + widths: FixtureIndexWidths, +): readonly (readonly [number, readonly Buffer[]])[] => [ + ...(indexes.typeSpecSignatures.length === 0 + ? [] + : ([ + [ + 27, + indexes.typeSpecSignatures.map((signature) => + metadataIndex(signature, heapSizes.blobs), + ), + ], + ] as const)), + ...(options.methodSpecMethods === undefined + ? [] + : ([ + [ + 43, + options.methodSpecMethods.map((method) => + Buffer.concat([ + metadataIndex(method, widths.coded([6, 10], 1)), + metadataIndex(indexes.methodSpecInstantiation, heapSizes.blobs), + ]), + ), + ], + ] as const)), +]; + const fixtureIndexWidths = ({ options, referenceStringIndexes, @@ -616,10 +659,12 @@ const fixtureIndexWidths = ({ ], ...(indexes.pinvokeModuleName === null ? [] : ([[26, 1]] as const)), ...(indexes.pinvokeImportName === null ? [] : ([[28, 1]] as const)), + [27, indexes.typeSpecSignatures.length], [32, options.assemblyRowCount ?? 1], [35, referenceStringIndexes.length], [39, indexes.exportedTypes.length], [40, 1], + [43, options.methodSpecMethods?.length ?? 0], ]); const count = (table: number): number => rowCounts.get(table) ?? 0; const table = (index: number): MetadataIndexSize => indexSize(count(index)); @@ -673,7 +718,7 @@ const fixtureMetadataRows = ({ indexes.attributeName, indexes.attributeNamespace, heapSizes.strings, - widths.resolutionScope, + metadataIndex(options.typeRefScopeRaw ?? 0, widths.resolutionScope), ), ], ], @@ -780,6 +825,7 @@ const fixtureMetadataRows = ({ ), ], ...exportedTypeTable(options, indexes, heapSizes, widths), + ...specTables(options, indexes, heapSizes, widths), [ 40, [ @@ -795,6 +841,29 @@ const fixtureMetadataRows = ({ return rows; }; +/** Blob indexes for the MemberRef and optional spec rows; defaults leave the heap unchanged. */ +const referenceBlobIndexes = ( + blobs: BlobHeap, + options: ManagedPeFixtureOptions, +): Pick< + FixtureMetadataRowContext["indexes"], + "constructorSignature" | "typeSpecSignatures" | "methodSpecInstantiation" +> => ({ + constructorSignature: blobs.add( + options.memberRefSignature ?? Buffer.from([0x20, 0x01, 0x01, 0x0e]), + ), + typeSpecSignatures: (options.typeSpecSignatures ?? []).map((signature) => + blobs.add(signature), + ), + // Defaults to GENERICINST with one string type argument. + methodSpecInstantiation: + options.methodSpecMethods === undefined + ? 0 + : blobs.add( + options.methodSpecInstantiation ?? Buffer.from([0x0a, 0x01, 0x0e]), + ), +}); + const buildManagedFixtureMetadata = ( options: ManagedPeFixtureOptions, methodRva: number, @@ -805,7 +874,7 @@ const buildManagedFixtureMetadata = ( const assemblyName = strings.add("Fixture.Managed"); const attributeName = strings.add("TargetFrameworkAttribute"); const attributeNamespace = strings.add("System.Runtime.Versioning"); - const constructorName = strings.add(".ctor"); + const constructorName = strings.add(options.memberRefName ?? ".ctor"); const typeName = strings.add(options.typeName ?? "Program"); const additionalTypeNames = Array.from( { length: options.additionalEmptyTypes ?? 0 }, @@ -840,7 +909,7 @@ const buildManagedFixtureMetadata = ( const methodSignature = blobs.add( options.methodSignature ?? Buffer.from([0x00, 0x00, 0x01]), ); - const constructorSignature = blobs.add(Buffer.from([0x20, 0x01, 0x01, 0x0e])); + const referenceBlobs = referenceBlobIndexes(blobs, options); const attributeBlob = blobs.add( fixedStringAttributeBlob( options.targetFramework === null @@ -873,8 +942,8 @@ const buildManagedFixtureMetadata = ( tokenBlob, fieldSignature, methodSignature, - constructorSignature, attributeBlob, + ...referenceBlobs, }, heapSizes: { strings: stringSize, blobs: blobSize, guids: guidSize }, }); diff --git a/src/dotnet/ManagedTypeNames.ts b/src/dotnet/ManagedTypeNames.ts new file mode 100644 index 0000000000..ab3b34c8f0 --- /dev/null +++ b/src/dotnet/ManagedTypeNames.ts @@ -0,0 +1,137 @@ +import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; +import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; +import { + fullName, + readCompressed, + specSignature, + type TypeRange, +} from "./ManagedMemberInspectorCore.js"; +import { + metadataRowOffset, + type ManagedMetadataLayout, +} from "./ManagedMetadataLayout.js"; +import { + metadataCodedTokenInvalidReason, + metadataRowCursor, + metadataToken, + readMetadataBlob, + readMetadataString, +} from "./ManagedMetadataHeaps.js"; +import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js"; + +/** + * Name the type a TypeDef, TypeRef, or generic-instantiation TypeSpec token + * denotes, in the namespace-qualified form TypeDef `full_name` uses, once per + * token. Other decoded TypeSpecs stay null: the type is unknown. Malformed or + * unsupported rows and blobs are reported to `issues` and also stay null, + * except that a TypeRef with an invalid ResolutionScope keeps its readable name. + */ +export const typeNameLookup = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + ranges: readonly TypeRange[], + issues: ManagedParseIssue[], +): ((token: string | null) => string | null) => { + const names = new Map(); + const checkedScopes = new Set(); + const validateScopeChain = (row: number): void => { + const path = new Set(); + let current = row; + while (!checkedScopes.has(current)) { + const cursor = metadataRowCursor(bytes, layout, 1, current); + if (path.has(current)) { + issues.push({ + code: "invalid-row", + scope: `metadata.TypeRef:${metadataToken(1, current)}`, + offset: cursor.start, + detail: "TypeRef ResolutionScope chain contains a cycle", + }); + break; + } + // Admit the chain's visited entry and retained validation-cache entry. + managedDecodeBudget(layout).reserve(128, cursor.start); + path.add(current); + const raw = cursor.readIndex(layout.codedIndexSize("ResolutionScope")); + const reason = metadataCodedTokenInvalidReason( + raw, + 2, + [0, 26, 35, 1], + layout.rowCounts, + ); + if (reason !== null) { + issues.push({ + code: "invalid-row", + scope: `metadata.TypeRef:${metadataToken(1, current)}`, + offset: cursor.start, + detail: `TypeRef ResolutionScope coded index 0x${raw.toString(16)} is invalid: ${reason}`, + }); + break; + } + // Null scopes are legal; non-TypeRef scopes terminate local validation. + if (raw === 0 || (raw & 3) !== 3) break; + current = Math.floor(raw / 4); + } + for (const visited of path) checkedScopes.add(visited); + }; + const definitionName = (table: 1 | 2, row: number): string | null => { + if (table === 2) { + metadataRowOffset(layout, 2, row); + return ranges[row - 1]?.fullName ?? null; + } + const cursor = metadataRowCursor(bytes, layout, 1, row); + cursor.readIndex(layout.codedIndexSize("ResolutionScope")); + readManagedValue(() => validateScopeChain(row), issues); + const name = cursor.readIndex(layout.stringIndexSize); + const namespace = cursor.readIndex(layout.stringIndexSize); + return fullName( + readMetadataString(bytes, layout, namespace, layout.strings.size), + readMetadataString(bytes, layout, name, layout.strings.size), + ); + }; + const specName = (row: number): string | null => { + const cursor = metadataRowCursor(bytes, layout, 27, row); + const spec = readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + layout.blob.size, + ); + const owner = { + form: "type", + scope: `metadata.TypeSpec:${metadataToken(27, row)}`, + offset: cursor.start, + } as const; + specSignature(layout, spec, owner); + if (spec[0] !== 0x15) return null; + // GENERICINST (CLASS | VALUETYPE) TypeDefOrRefEncoded: never a TypeSpec, + // so naming never recurses. + const encoded = readCompressed(spec, 2).value; + const table = ([2, 1] as const)[encoded & 3]; + if (table === undefined) + throw managedFailure( + "invalid-blob", + owner.scope, + "Generic instantiation does not name a TypeDef or TypeRef", + owner.offset, + ); + return definitionName(table, Math.floor(encoded / 4)); + }; + return (token) => { + if (token === null) return null; + 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( + () => + table === 27 + ? specName(row) + : table === 1 || table === 2 + ? definitionName(table, row) + : null, + issues, + ) ?? null; + names.set(token, name); + return name; + }; +};