diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 6157e7b03f..8ffa82ae65 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -126,6 +126,17 @@ Member inspection binds build-local tokens, signatures, row offsets, method bodies, and decoded relationships to that artifact and MVID. Missing or malformed facts remain explicit in coverage and per-member status. +For unoptimized `#-` metadata, nonempty `FieldPtr` and `MethodPtr` tables +translate TypeDef list positions to physical member definitions. REA resolves +these indirections for member ownership and local attribute-constructor types; +field and method tokens still identify the original definition rows. +`field_list` and `method_list` retain the metadata's list coordinates, which +refer to the pointer table when present rather than necessarily contiguous +definition rows. Malformed or ambiguous pointers leave the owner unknown with +partial coverage while preserving readable member and attribute facts. +The retained inverse indexes are admitted against the existing representation +budget before allocation. + `exported_types` reports ExportedType rows as declared. Facade assemblies such as .NET `System.Runtime.dll` provide their types this way: `forwarder: true` marks a type forwarder, and `implementation_token` names the AssemblyRef, File, diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index 7d54d5f075..54dd49976d 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -19,6 +19,10 @@ import { readMetadataString, sha256Bytes, } from "./ManagedMetadataHeaps.js"; +import { + memberListIndexSize, + memberListTable, +} from "./ManagedMemberPointers.js"; export type ManagedType = ManagedMemberInspection["types"][number]; export type ManagedExportedType = NonNullable< @@ -93,8 +97,8 @@ const readTypeRange = ( row: number, issues: ManagedParseIssue[], ): TypeRange => { - const methods = layout.table(6); - const fields = layout.table(4); + const methods = memberListTable(layout, 6); + const fields = memberListTable(layout, 4); const cursor = metadataRowCursor(bytes, layout, 2, row); cursor.readUInt32(); const nameIndex = cursor.readIndex(layout.stringIndexSize); @@ -114,8 +118,8 @@ const readTypeRange = ( issues, ) ?? null; cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); - const fieldStart = cursor.readIndex(layout.tableIndexSize(4)); - const methodStart = cursor.readIndex(layout.tableIndexSize(6)); + const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4)); + const methodStart = cursor.readIndex(memberListIndexSize(layout, 6)); let nextField = (fields?.rowCount ?? 0) + 1; let nextMethod = (methods?.rowCount ?? 0) + 1; const typeTable = layout.table(2); @@ -125,8 +129,8 @@ const readTypeRange = ( next.readIndex(layout.stringIndexSize); next.readIndex(layout.stringIndexSize); next.readIndex(layout.codedIndexSize("TypeDefOrRef")); - nextField = next.readIndex(layout.tableIndexSize(4)); - nextMethod = next.readIndex(layout.tableIndexSize(6)); + nextField = next.readIndex(memberListIndexSize(layout, 4)); + nextMethod = next.readIndex(memberListIndexSize(layout, 6)); } return { token: metadataToken(2, row), @@ -209,8 +213,8 @@ export const parseTypes = ( readonly issues: readonly ManagedParseIssue[]; } => { const typeTable = layout.table(2); - const fields = layout.table(4); - const methods = layout.table(6); + const fields = memberListTable(layout, 4); + const methods = memberListTable(layout, 6); const items: ManagedType[] = []; const issues: ManagedParseIssue[] = []; for (let row = 1; row <= (typeTable?.rowCount ?? 0); row += 1) { @@ -233,8 +237,8 @@ export const parseTypes = ( const extendsRaw = cursor.readIndex( layout.codedIndexSize("TypeDefOrRef"), ); - const fieldStart = cursor.readIndex(layout.tableIndexSize(4)); - const methodStart = cursor.readIndex(layout.tableIndexSize(6)); + const fieldStart = cursor.readIndex(memberListIndexSize(layout, 4)); + const methodStart = cursor.readIndex(memberListIndexSize(layout, 6)); const nextRange = ranges[row] ?? null; const fieldRange = rowRange( fields, diff --git a/src/dotnet/ManagedMemberPointerBudget.test.ts b/src/dotnet/ManagedMemberPointerBudget.test.ts new file mode 100644 index 0000000000..dcde2670b4 --- /dev/null +++ b/src/dotnet/ManagedMemberPointerBudget.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + attachManagedDecodeBudget, + ManagedDecodeBudget, +} from "./ManagedDecodeBudget.js"; +import { memberListRowLookup } from "./ManagedMemberPointers.js"; +import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { buildManagedMemberPointerFixture } from "./ManagedPe.fixture.js"; +import { readManagedPeLayout } from "./ManagedPeReader.js"; +import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; + +const representationCapacity = 64 * 1024 * 1024; + +const pointerFixture = () => { + const bytes = buildManagedMemberPointerFixture(); + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("Fixture has no CLI metadata"); + const layout = readManagedMetadataLayout( + bytes, + pe.rvaToOffset(pe.cli.metadata.rva, pe.cli.metadata.size, "cli.metadata"), + pe.cli.metadata.size, + ); + // A fresh owner lets the test set exact remaining capacity independently of + // the metadata version/string admission already exercised by the parser. + const budget = new ManagedDecodeBudget(); + attachManagedDecodeBudget(layout, budget); + const indexBytes = + ((layout.table(6)?.rowCount ?? 0) + 1) * Uint32Array.BYTES_PER_ELEMENT + + 256; + return { bytes, layout, budget, indexBytes }; +}; + +describe("managed member pointer index capacity", () => { + it("rejects insufficient capacity before allocating the inverse index", () => { + const { bytes, layout, budget, indexBytes } = pointerFixture(); + budget.reserve(representationCapacity - indexBytes + 1, null); + let allocations = 0; + let failure: unknown; + // Proxy forwards static BYTES_PER_ELEMENT too: losing it would produce a + // NaN admission failure and falsely pass a before-allocation assertion. + vi.stubGlobal( + "Uint32Array", + new Proxy(Uint32Array, { + construct(target, arguments_, newTarget) { + allocations += 1; + return Reflect.construct(target, arguments_, newTarget); + }, + }), + ); + try { + memberListRowLookup(bytes, layout, 6); + } catch (cause: unknown) { + failure = cause; + } finally { + vi.unstubAllGlobals(); + } + + expect(failure).toBeInstanceOf(ManagedReaderFailure); + expect(failure).toMatchObject({ + issue: { + code: "resource-limit", + scope: "metadata.representation", + offset: layout.table(5)?.offset, + }, + }); + expect(allocations).toBe(0); + }); + + it("reuses one layout's index without allocating or charging it again", () => { + const { bytes, layout, budget, indexBytes } = pointerFixture(); + budget.reserve(representationCapacity - indexBytes, null); + let allocations = 0; + let observed: number[]; + vi.stubGlobal( + "Uint32Array", + new Proxy(Uint32Array, { + construct(target, arguments_, newTarget) { + allocations += 1; + return Reflect.construct(target, arguments_, newTarget); + }, + }), + ); + try { + const first = memberListRowLookup(bytes, layout, 6); + const repeated = memberListRowLookup(bytes, layout, 6); + observed = [first(1), first(2), repeated(1), repeated(2)]; + } finally { + vi.unstubAllGlobals(); + } + + expect(observed).toEqual([2, 1, 2, 1]); + expect(allocations).toBe(1); + expect(() => budget.reserve(1, null)).toThrow(ManagedReaderFailure); + }); +}); diff --git a/src/dotnet/ManagedMemberPointers.test.ts b/src/dotnet/ManagedMemberPointers.test.ts new file mode 100644 index 0000000000..35764177f7 --- /dev/null +++ b/src/dotnet/ManagedMemberPointers.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; + +import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js"; +import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; +import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { readManagedPeLayout } from "./ManagedPeReader.js"; +import { + buildManagedMemberPointerFixture, + managedPeFixtureTarget, +} from "./ManagedPe.fixture.js"; + +const metadataLayout = (bytes: Buffer) => { + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("Fixture omitted CLI metadata"); + return readManagedMetadataLayout( + bytes, + pe.rvaToOffset(pe.cli.metadata.rva, pe.cli.metadata.size, "cli.metadata"), + pe.cli.metadata.size, + ); +}; + +describe("managed member pointer ownership", () => { + it.each([ + { table: "fields" as const, prefix: "0x040000" }, + { table: "methods" as const, prefix: "0x060000" }, + ])("maps reordered pointer slots to physical $table", ({ table, prefix }) => { + const bytes = buildManagedMemberPointerFixture(); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.coverage).toEqual({ state: "complete", issues: [] }); + expect(result.types.map((type) => type.full_name)).toEqual([ + "", + "Fixture.A", + "Fixture.B", + ]); + expect( + result[table].map(({ token, declaring_type, declaring_type_token }) => ({ + token, + declaring_type, + declaring_type_token, + })), + ).toEqual([ + { + token: `${prefix}01`, + declaring_type: "Fixture.B", + declaring_type_token: "0x02000003", + }, + { + token: `${prefix}02`, + declaring_type: "Fixture.A", + declaring_type_token: "0x02000002", + }, + ]); + }); + + it("resolves a local custom-attribute constructor through MethodPtr", () => { + const bytes = buildManagedMemberPointerFixture(); + const target = managedPeFixtureTarget(bytes); + const artifact = inspectManagedArtifactBytes(bytes, target); + const members = inspectManagedMembersBytes(bytes, target); + + expect(artifact.metadata.stream_names).toContain("#-"); + expect(artifact.coverage).toEqual({ state: "complete", issues: [] }); + expect(artifact.attributes).toEqual([ + expect.objectContaining({ + parent_token: "0x20000001", + constructor_token: "0x06000001", + type_name: "Fixture.B", + }), + ]); + expect(artifact.attributes[0]?.type_name).toBe( + members.methods[0]?.declaring_type, + ); + }); + + it.each([ + { + label: "identity pointer tables", + fieldPointers: [1, 2], + methodPointers: [1, 2], + streamName: "#-" as const, + }, + { + label: "ordinary direct lists", + fieldPointers: null, + methodPointers: null, + streamName: "#~" as const, + }, + { + label: "empty pointer tables with direct lists", + fieldPointers: [], + methodPointers: [], + streamName: "#-" as const, + }, + ])("preserves $label", ({ fieldPointers, methodPointers, streamName }) => { + const bytes = buildManagedMemberPointerFixture({ + fieldPointers, + methodPointers, + streamName, + }); + const target = managedPeFixtureTarget(bytes); + const members = inspectManagedMembersBytes(bytes, target); + const artifact = inspectManagedArtifactBytes(bytes, target); + + expect(members.coverage).toEqual({ state: "complete", issues: [] }); + expect(members.fields.map((field) => field.declaring_type)).toEqual([ + "Fixture.A", + "Fixture.B", + ]); + expect(members.methods.map((method) => method.declaring_type)).toEqual([ + "Fixture.A", + "Fixture.B", + ]); + expect(artifact.attributes[0]?.type_name).toBe("Fixture.A"); + }); +}); + +describe("invalid managed member pointers", () => { + describe.each([ + { + table: "fields" as const, + pointers: "fieldPointers" as const, + scope: "metadata.FieldPtr", + prefix: "0x040000", + names: ["firstField", "secondField"], + independent: "methods" as const, + }, + { + table: "methods" as const, + pointers: "methodPointers" as const, + scope: "metadata.MethodPtr", + prefix: "0x060000", + names: [".ctor", ".ctor"], + independent: "fields" as const, + }, + ])( + "invalid $table pointers", + ({ table, pointers, scope, prefix, names, independent }) => { + it.each([ + { label: "zero row", rows: [0, 1], owners: [null, null] }, + { label: "out-of-range row", rows: [3, 1], owners: [null, null] }, + { label: "duplicate row", rows: [1, 1], owners: [null, null] }, + { label: "omitted row", rows: [1], owners: ["Fixture.A", null] }, + ])( + "preserves member facts and independent ownership for $label", + ({ rows, owners }) => { + const bytes = buildManagedMemberPointerFixture({ [pointers]: rows }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.coverage.state).toBe("partial"); + expect(result.coverage.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-row", + scope, + offset: expect.any(Number), + }), + ]), + ); + expect( + result[table].map( + ({ token, name, declaring_type, declaring_type_token }) => ({ + token, + name, + declaring_type, + declaring_type_token, + }), + ), + ).toEqual( + owners.map((owner, index) => ({ + token: `${prefix}0${index + 1}`, + name: names[index], + declaring_type: owner, + declaring_type_token: owner === null ? null : "0x02000002", + })), + ); + expect( + result[independent].map((member) => member.declaring_type), + ).toEqual(["Fixture.B", "Fixture.A"]); + expect( + result.coverage.issues.some( + (issue) => + issue.scope === + (table === "fields" + ? "metadata.MethodPtr" + : "metadata.FieldPtr"), + ), + ).toBe(false); + }, + ); + }, + ); + + it.each([ + { label: "zero row", methodPointers: [2, 0] }, + { label: "duplicate row", methodPointers: [2, 2] }, + { label: "omitted constructor row", methodPointers: [2] }, + ])( + "preserves attribute facts with an unknown constructor owner for $label", + ({ methodPointers }) => { + const controlBytes = buildManagedMemberPointerFixture(); + const control = inspectManagedArtifactBytes( + controlBytes, + managedPeFixtureTarget(controlBytes), + ).attributes[0]; + if (control === undefined) + throw new Error("Fixture omitted custom attribute"); + expect(control.type_name).toBe("Fixture.B"); + const bytes = buildManagedMemberPointerFixture({ methodPointers }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.coverage.state).toBe("partial"); + expect(result.coverage.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "invalid-row", + scope: "metadata.MethodPtr", + }), + ]), + ); + expect(result.attributes).toEqual([ + { ...control, row_offset: expect.any(Number), type_name: null }, + ]); + }, + ); + + it.each([ + { name: "FieldPtr", table: 3 }, + { name: "MethodPtr", table: 5 }, + ])("rejects a table stream ending inside a $name row", ({ table }) => { + const bytes = buildManagedMemberPointerFixture(); + const layout = metadataLayout(bytes); + const pointers = layout.table(table); + if (pointers === undefined) + throw new Error("Fixture omitted pointer table"); + const versionLength = bytes.readUInt32LE(layout.rootOffset + 12); + const firstStreamHeader = + ((layout.rootOffset + 16 + versionLength + 3) & ~3) + 4; + const streamNameEnd = bytes.indexOf(0, firstStreamHeader + 8); + expect(bytes.toString("ascii", firstStreamHeader + 8, streamNameEnd)).toBe( + "#-", + ); + const tablesOffset = + layout.rootOffset + bytes.readUInt32LE(firstStreamHeader); + bytes.writeUInt32LE( + pointers.offset + pointers.rowSize - 1 - tablesOffset, + firstStreamHeader + 4, + ); + + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.coverage).toMatchObject({ + state: "unavailable", + issues: [ + expect.objectContaining({ + code: "invalid-metadata-root", + scope: "metadata.table-data", + }), + ], + }); + expect(result.fields).toEqual([]); + expect(result.methods).toEqual([]); + }); +}); diff --git a/src/dotnet/ManagedMemberPointers.ts b/src/dotnet/ManagedMemberPointers.ts new file mode 100644 index 0000000000..3517d0a111 --- /dev/null +++ b/src/dotnet/ManagedMemberPointers.ts @@ -0,0 +1,101 @@ +import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; +import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { + managedFailure, + ManagedReaderFailure, +} from "./ManagedReaderFailure.js"; + +type MemberTable = 4 | 6; +type ListRowLookup = (definitionRow: number) => number; +const lookups = new WeakMap< + ManagedMetadataLayout, + Map +>(); + +/** TypeDef list coordinates refer to the nonempty pointer table when present. */ +export const memberListTable = ( + layout: ManagedMetadataLayout, + table: MemberTable, +) => { + const pointers = layout.table(table - 1); + return pointers !== undefined && pointers.rowCount > 0 + ? pointers + : layout.table(table); +}; + +/** List columns must accommodate either physical definition or pointer rows. */ +export const memberListIndexSize = ( + layout: ManagedMetadataLayout, + table: MemberTable, +): 2 | 4 => + layout.tableIndexSize(table - 1) === 4 || layout.tableIndexSize(table) === 4 + ? 4 + : 2; + +/** Resolve physical definition rows to list positions without changing token identity. */ +export const memberListRowLookup = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + table: MemberTable, +): ListRowLookup => { + const pointers = layout.table(table - 1); + if (pointers === undefined || pointers.rowCount === 0) return (row) => row; + let byTable = lookups.get(layout); + const cached = byTable?.get(table); + if (cached instanceof ManagedReaderFailure) throw cached; + if (cached !== undefined) return cached; + + const definitionCount = layout.table(table)?.rowCount ?? 0; + // The layout already admitted the source table extent. Reserve retained index + // storage and its fixed bookkeeping before allocating either container. + managedDecodeBudget(layout).reserve( + (definitionCount + 1) * Uint32Array.BYTES_PER_ELEMENT + 256, + pointers.offset, + ); + if (byTable === undefined) { + byTable = new Map(); + lookups.set(layout, byTable); + } + const positions = new Uint32Array(definitionCount + 1); + try { + const width = layout.tableIndexSize(table); + for (let row = 1; row <= pointers.rowCount; row += 1) { + const offset: number = pointers.offset + (row - 1) * pointers.rowSize; + const definition = + width === 2 ? bytes.readUInt16LE(offset) : bytes.readUInt32LE(offset); + if (definition === 0 || definition > definitionCount) + throw managedFailure( + "invalid-row", + `metadata.${pointers.name}`, + `Pointer row ${String(row)} names an out-of-range definition row ${String(definition)}`, + offset, + ); + if (positions[definition] !== 0) + throw managedFailure( + "invalid-row", + `metadata.${pointers.name}`, + `Definition row ${String(definition)} has more than one pointer row; its declaring type is ambiguous`, + offset, + ); + positions[definition] = row; + } + const lookup: ListRowLookup = (definition) => { + const position = positions[definition] ?? 0; + if (position === 0) + throw managedFailure( + "invalid-row", + `metadata.${pointers.name}`, + `Definition row ${String(definition)} has no pointer row identifying its declaring type`, + pointers.offset, + ); + return position; + }; + byTable.set(table, lookup); + return lookup; + } catch (cause: unknown) { + // Invalid metadata must not rebuild and charge the same retained index for + // every attribute/member that asks for its owner. + if (cause instanceof ManagedReaderFailure) byTable.set(table, cause); + throw cause; + } +}; diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 896a6bc217..bc982d81f7 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -31,6 +31,7 @@ import { } from "./ManagedMetadataHeaps.js"; import { readManagedValue } from "./ManagedReaderFailure.js"; import { methodBody } from "./ManagedMethodBodyReader.js"; +import { memberListRowLookup } from "./ManagedMemberPointers.js"; export const parseFields = ( bytes: Buffer, @@ -46,6 +47,10 @@ export const parseFields = ( const issues: ManagedParseIssue[] = []; const table = layout.table(4); const declaringType = createDeclaringTypeLookup(ranges, "field"); + const listRow = readManagedValue( + () => memberListRowLookup(bytes, layout, 4), + issues, + ); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { readManagedValue(() => { const cursor = metadataRowCursor(bytes, layout, 4, row); @@ -62,7 +67,11 @@ export const parseFields = ( cursor.readIndex(layout.blobIndexSize), layout.blob.size, ); - const declared = declaringType(row); + const position = + listRow === undefined + ? undefined + : readManagedValue(() => listRow(row), issues); + const declared = position === undefined ? null : declaringType(position); const token = metadataToken(4, row); fields.push({ token, @@ -237,6 +246,10 @@ export const parseMethods = ({ const issues: ManagedParseIssue[] = []; const table = layout.table(6); const declaringType = createDeclaringTypeLookup(ranges, "method"); + const listRow = readManagedValue( + () => memberListRowLookup(bytes, layout, 6), + issues, + ); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { readManagedValue(() => { const cursor = metadataRowCursor(bytes, layout, 6, row); @@ -256,7 +269,11 @@ export const parseMethods = ({ layout.blob.size, ); cursor.readIndex(layout.tableIndexSize(8)); - const declared = declaringType(row); + const position = + listRow === undefined + ? undefined + : readManagedValue(() => listRow(row), issues); + const declared = position === undefined ? null : declaringType(position); const token = metadataToken(6, row); methods.push({ token, diff --git a/src/dotnet/ManagedMetadataInventory.ts b/src/dotnet/ManagedMetadataInventory.ts index 9540047dea..c31059d575 100644 --- a/src/dotnet/ManagedMetadataInventory.ts +++ b/src/dotnet/ManagedMetadataInventory.ts @@ -155,7 +155,7 @@ const readAttributes = ( } => { const attributes = readRows(layout.table(12), (row) => readManagedValue( - () => readCustomAttribute(bytes, layout, row, heapExtent(layout)), + () => readCustomAttribute(bytes, layout, row, heapExtent(layout), issues), issues, ), ); diff --git a/src/dotnet/ManagedMetadataInventoryRows.ts b/src/dotnet/ManagedMetadataInventoryRows.ts index 32b5f8d251..3bcaada9d5 100644 --- a/src/dotnet/ManagedMetadataInventoryRows.ts +++ b/src/dotnet/ManagedMetadataInventoryRows.ts @@ -1,4 +1,9 @@ import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; +import { + memberListIndexSize, + memberListRowLookup, + memberListTable, +} from "./ManagedMemberPointers.js"; import type { ManagedArtifactInspection, @@ -17,7 +22,7 @@ import { readMetadataGuid, readMetadataString, } from "./ManagedMetadataHeaps.js"; -import { managedFailure } from "./ManagedReaderFailure.js"; +import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js"; import type { ManagedResourceDirectory } from "./ManagedMetadataInventory.js"; type ModuleIdentity = NonNullable; @@ -242,21 +247,24 @@ const declaringTypeForMethod = ( heapExtent: number, ): string | null => { const types = layout.table(2); - const methods = layout.table(6); + const methods = memberListTable(layout, 6); if (types === undefined || methods === undefined) return null; + if (methodRow < 1 || methodRow > (layout.table(6)?.rowCount ?? 0)) + return null; + const position = memberListRowLookup(bytes, layout, 6)(methodRow); for (let row = 1; row <= types.rowCount; row += 1) { const cursor = metadataRowCursor(bytes, layout, 2, row); cursor.readUInt32(); const nameIndex = cursor.readIndex(layout.stringIndexSize); const namespaceIndex = cursor.readIndex(layout.stringIndexSize); cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); - cursor.readIndex(layout.tableIndexSize(4)); - const methodStart = cursor.readIndex(layout.tableIndexSize(6)); + cursor.readIndex(memberListIndexSize(layout, 4)); + const methodStart = cursor.readIndex(memberListIndexSize(layout, 6)); const methodEnd = row === types.rowCount ? methods.rowCount + 1 : methodListForType(bytes, layout, row + 1); - if (methodRow < methodStart || methodRow >= methodEnd) continue; + if (position < methodStart || position >= methodEnd) continue; const name = readMetadataString(bytes, layout, nameIndex, heapExtent); const namespace = readMetadataString( bytes, @@ -279,8 +287,8 @@ const methodListForType = ( cursor.readIndex(layout.stringIndexSize); cursor.readIndex(layout.stringIndexSize); cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); - cursor.readIndex(layout.tableIndexSize(4)); - return cursor.readIndex(layout.tableIndexSize(6)); + cursor.readIndex(memberListIndexSize(layout, 4)); + return cursor.readIndex(memberListIndexSize(layout, 6)); }; const attributeTypeName = ( @@ -377,6 +385,7 @@ export const readCustomAttribute = ( layout: ManagedMetadataLayout, row: number, heapExtent: number, + issues: ManagedParseIssue[], ): CustomAttribute => { const cursor = metadataRowCursor(bytes, layout, 12, row); const parentRaw = cursor.readIndex( @@ -432,7 +441,11 @@ export const readCustomAttribute = ( : `CustomAttribute constructor coded index 0x${typeRaw.toString(16)} is invalid: ${typeReason}`, typeOffset, ); - const typeName = attributeTypeName(bytes, layout, typeRaw, heapExtent); + const typeName = + readManagedValue( + () => attributeTypeName(bytes, layout, typeRaw, heapExtent), + issues, + ) ?? null; return { parent_token: parent, constructor_token: metadataCodedToken( diff --git a/src/dotnet/ManagedMetadataRowSizes.ts b/src/dotnet/ManagedMetadataRowSizes.ts index 738defb44e..900d30e338 100644 --- a/src/dotnet/ManagedMetadataRowSizes.ts +++ b/src/dotnet/ManagedMetadataRowSizes.ts @@ -15,7 +15,7 @@ const ROW_SIZE_FACTORIES: readonly RowSizeFactory[] = [ ({ stringSize: s, guidSize: g }) => 2 + s + g * 3, ({ codedSize: c, stringSize: s }) => c("ResolutionScope") + s * 2, ({ tableSize: t, codedSize: c, stringSize: s }) => - 4 + s * 2 + c("TypeDefOrRef") + t(4) + t(6), + 4 + s * 2 + c("TypeDefOrRef") + Math.max(t(3), t(4)) + Math.max(t(5), t(6)), ({ tableSize: t }) => t(4), ({ stringSize: s, blobSize: b }) => 2 + s + b, ({ tableSize: t }) => t(6), diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index f930e3391f..fffba9e8fa 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -190,6 +190,7 @@ const typeDefRow = ({ fieldListSize, methodListSize, extendsRaw = (1 << 2) | 1, + flags = 0x0010_0001, }: { readonly name: number; readonly namespace: number; @@ -200,9 +201,10 @@ const typeDefRow = ({ readonly fieldListSize: MetadataIndexSize; readonly methodListSize: MetadataIndexSize; readonly extendsRaw?: number; + readonly flags?: number; }): Buffer => Buffer.concat([ - u32(0x0010_0001), + u32(flags), metadataIndex(name, stringSize), metadataIndex(namespace, stringSize), metadataIndex(extendsRaw, typeDefOrRefSize), @@ -231,6 +233,7 @@ interface MethodDefRowInput { readonly rva?: number; readonly flags?: number; readonly implFlags?: number; + readonly parameterList?: number; } const methodDefRow = ({ @@ -242,6 +245,7 @@ const methodDefRow = ({ rva = 0, flags = 0x0016, implFlags = 0, + parameterList = 0, }: MethodDefRowInput): Buffer => Buffer.concat([ u32(rva), @@ -249,7 +253,7 @@ const methodDefRow = ({ u16(flags), metadataIndex(name, stringSize), metadataIndex(signature, blobSize), - metadataIndex(0, parameterSize), + metadataIndex(parameterList, parameterSize), ]); const memberRefRow = ({ @@ -416,6 +420,7 @@ const metadataRoot = ( strings: Buffer, guid: Buffer, blob: Buffer, + tableStreamName: "#~" | "#-" = "#~", ): Buffer => { const version = Buffer.from("v4.0.30319\0\0", "utf8"); const header = Buffer.concat([ @@ -429,7 +434,7 @@ const metadataRoot = ( u16(4), ]); const streamHeaderSize = - metadataStreamHeader(0, 0, "#~").length + + metadataStreamHeader(0, 0, tableStreamName).length + metadataStreamHeader(0, 0, "#Strings").length + metadataStreamHeader(0, 0, "#GUID").length + metadataStreamHeader(0, 0, "#Blob").length; @@ -442,7 +447,7 @@ const metadataRoot = ( offset = align4(offset + guid.length); const blobOffset = offset; const headers = Buffer.concat([ - metadataStreamHeader(tablesOffset, tables.length, "#~"), + metadataStreamHeader(tablesOffset, tables.length, tableStreamName), metadataStreamHeader(stringsOffset, strings.length, "#Strings"), metadataStreamHeader(guidOffset, guid.length, "#GUID"), metadataStreamHeader(blobOffset, blob.length, "#Blob"), @@ -990,6 +995,130 @@ export const buildManagedPeFixture = ( buildManagedFixtureMetadata(options, methodRva), ); +/** Two attribute classes whose pointer-table slots can reorder physical members. */ +export const buildManagedMemberPointerFixture = ( + options: { + readonly fieldPointers?: readonly number[] | null; + readonly methodPointers?: readonly number[] | null; + readonly streamName?: "#~" | "#-"; + } = {}, +): Buffer => { + const fieldPointers = + options.fieldPointers === undefined ? [2, 1] : options.fieldPointers; + const methodPointers = + options.methodPointers === undefined ? [2, 1] : options.methodPointers; + // Both instance constructors call System.Attribute::.ctor(), then return. + const body = Buffer.from([0x1e, 0x02, 0x28, 0x01, 0, 0, 0x0a, 0x2a]); + const image = buildManagedFixtureImage({ ilBody: body }, (methodRva) => { + const strings = new StringHeap(); + const blobs = new BlobHeap(); + const moduleName = strings.add("MemberPointers.dll"); + const assemblyName = strings.add("Fixture.MemberPointers"); + const namespace = strings.add("Fixture"); + const typeNames = ["", "A", "B"].map((name) => strings.add(name)); + const fieldNames = ["firstField", "secondField"].map((name) => + strings.add(name), + ); + const constructorName = strings.add(".ctor"); + const attributeName = strings.add("Attribute"); + const systemNamespace = strings.add("System"); + const runtimeName = strings.add("System.Runtime"); + const fieldSignature = blobs.add(Buffer.from([0x06, 0x08])); + const constructorSignature = blobs.add( + Buffer.from([0x20, 0x01, 0x01, 0x0e]), + ); + const baseConstructorSignature = blobs.add(Buffer.from([0x20, 0, 0x01])); + const attributeValue = blobs.add(fixedStringAttributeBlob("fixture")); + const rows = new Map([ + [0, [moduleRow(moduleName, 2, 2)]], + // ResolutionScope selects AssemblyRef 1, which contains System.Attribute. + [ + 1, + [ + Buffer.concat([ + u16((1 << 2) | 2), + u16(attributeName), + u16(systemNamespace), + ]), + ], + ], + [ + 2, + typeNames.map((name, index) => + typeDefRow({ + name, + namespace: index === 0 ? 0 : namespace, + fieldList: index === 2 ? 2 : 1, + methodList: index === 2 ? 2 : 1, + stringSize: 2, + typeDefOrRefSize: 2, + fieldListSize: 2, + methodListSize: 2, + extendsRaw: index === 0 ? 0 : (1 << 2) | 1, + flags: index === 0 ? 0 : 0x0010_0001, + }), + ), + ], + [4, fieldNames.map((name) => fieldRow(name, fieldSignature, 2, 2))], + [ + 6, + [1, 2].map(() => + methodDefRow({ + name: constructorName, + signature: constructorSignature, + stringSize: 2, + blobSize: 2, + parameterSize: 2, + parameterList: 1, + rva: methodRva, + flags: 0x1886, + }), + ), + ], + [ + 10, + [ + memberRefRow({ + name: constructorName, + signature: baseConstructorSignature, + stringSize: 2, + blobSize: 2, + parentSize: 2, + }), + ], + ], + // The assembly attribute constructor is physical MethodDef row 1. + [ + 12, + [ + customAttributeRow( + attributeValue, + { customAttributeTypeRaw: (1 << 3) | 2 }, + 2, + 2, + 2, + ), + ], + ], + [32, [assemblyRow(assemblyName, 2, 2)]], + [35, [assemblyRefRow(runtimeName, 0, false, 2, 2)]], + ]); + if (fieldPointers !== null) rows.set(3, fieldPointers.map(u16)); + if (methodPointers !== null) rows.set(5, methodPointers.map(u16)); + return metadataRoot( + tablesStream(rows, 0n, 0), + strings.toBuffer(), + guidHeap(), + blobs.toBuffer(), + options.streamName ?? "#-", + ); + }); + // The shared PE shell defaults to an executable; this fixture is a DLL. + image.writeUInt16LE(image.readUInt16LE(0x84 + 18) | 0x2000, 0x84 + 18); + image.writeUInt32LE(0, 0x0200 + 20); + return image; +}; + /** Build a syntactically valid PE32 fixture with no CLI directory. */ export const buildNativePeFixture = (): Buffer => { const image = buildManagedPeFixture(); diff --git a/tests/acceptance/managedMemberPointers.test.ts b/tests/acceptance/managedMemberPointers.test.ts new file mode 100644 index 0000000000..53fad72573 --- /dev/null +++ b/tests/acceptance/managedMemberPointers.test.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../src/domain/evidence.js"; +import { buildManagedMemberPointerFixture } from "../../src/dotnet/ManagedPe.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "CLI and stdio MCP retain physical member identities through reordered pointers", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-managed-member-pointers-"); + const path = join(root, "reordered.dll"); + const bytes = buildManagedMemberPointerFixture(); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await writeFile(path, bytes); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" }, + stderr: "pipe", + }); + transport.stderr?.on("data", () => undefined); + const client = new Client({ name: "managed-pointer-parity", version: "1" }); + try { + await client.connect(transport); + for (const name of [ + "inspect_managed_artifact", + "inspect_managed_members", + ] as const) { + const cliResult = await cli.run({ + arguments: [name.replaceAll("_", "-"), path, "--json"], + }); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); + const cliEvidence = parseEvidence(cliResult.json); + const response = await client.callTool({ name, arguments: { path } }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcpEvidence = parseEvidence(response.structuredContent); + expect(mcpEvidence.normalized_result).toEqual( + cliEvidence.normalized_result, + ); + expect(mcpEvidence.subject?.digest.sha256).toBe(sha256); + expect(cliEvidence.subject?.digest.sha256).toBe(sha256); + expect(mcpEvidence.normalized_result).toMatchObject({ + coverage: { state: "complete", issues: [] }, + }); + if (name === "inspect_managed_artifact") { + expect(mcpEvidence.normalized_result).toMatchObject({ + attributes: [ + expect.objectContaining({ + constructor_token: "0x06000001", + type_name: "Fixture.B", + }), + ], + }); + } else { + expect(mcpEvidence.normalized_result).toMatchObject({ + fields: [ + expect.objectContaining({ + token: "0x04000001", + declaring_type: "Fixture.B", + }), + expect.objectContaining({ + token: "0x04000002", + declaring_type: "Fixture.A", + }), + ], + methods: [ + expect.objectContaining({ + token: "0x06000001", + declaring_type: "Fixture.B", + }), + expect.objectContaining({ + token: "0x06000002", + declaring_type: "Fixture.A", + }), + ], + }); + } + } + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + }, +);