diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 72c55e6da..6157e7b03 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -126,6 +126,15 @@ 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. +`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, +or enclosing ExportedType row that implements it. REA does not load the +implementing assembly to resolve the forwarded definition, and +`compare_managed_members` does not compare exported types. Member Evidence +recorded before this field existed reads back with `exported_types: null`, +which means the table was not examined. + CLI `#GUID` heap values retain their exact 16-byte GUID text. REA does not impose RFC 4122 UUID version or variant bits on MVID, EncId, or EncBaseId because ECMA-335 metadata does not require those bit patterns. Tokens and RVAs are diff --git a/src/contracts/managed/managedWorkflowExamples.ts b/src/contracts/managed/managedWorkflowExamples.ts index e975c5d20..2a0176dfe 100644 --- a/src/contracts/managed/managedWorkflowExamples.ts +++ b/src/contracts/managed/managedWorkflowExamples.ts @@ -47,6 +47,7 @@ const emptyManagedMembers = ( requires_mvid: mvid, }, types: [], + exported_types: [], fields: [], methods: [], member_refs: [], diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index bbc4f63f6..66a3b1761 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -110,6 +110,18 @@ const managedTypeSchema = z.object({ }), }); +/** One ExportedType row: a type this assembly exports from another module or forwards. */ +const managedExportedTypeSchema = z.object({ + token: tokenSchema, + row_offset: offsetSchema, + namespace: z.string(), + name: z.string(), + flags: z.number().int().min(0).max(0xffff_ffff), + forwarder: z.boolean(), + type_def_id: z.number().int().min(0).max(0xffff_ffff), + implementation_token: tokenSchema.nullable(), +}); + const managedFieldSchema = z.object({ token: tokenSchema, row_offset: offsetSchema, @@ -420,6 +432,9 @@ export const managedMemberInspectionSchema = z.object({ requires_mvid: cliMetadataGuidSchema.nullable(), }), types: z.array(managedTypeSchema), + // Member Evidence recorded before ExportedType rows were read omits this + // field; null means the table was not examined, not that it was empty. + exported_types: z.array(managedExportedTypeSchema).nullable().default(null), fields: z.array(managedFieldSchema), methods: z.array(managedMethodSchema), member_refs: z.array(managedMemberReferenceSchema), diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index 71c112af1..9d3daafe0 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -96,5 +96,16 @@ export const comparisonLimitations = ( ]; if (left.coverage.state !== "complete" || right.coverage.state !== "complete") limitations.push("At least one managed member observation is partial."); + if (left.exported_types === null || right.exported_types === null) + limitations.push( + "At least one managed member observation predates ExportedType inspection; its exported and forwarded types are unknown.", + ); + if ( + (left.exported_types?.length ?? 0) > 0 || + (right.exported_types?.length ?? 0) > 0 + ) + limitations.push( + "ExportedType rows, including type forwarders, are observed but not compared; an unchanged comparison does not establish an unchanged exported or forwarded type set.", + ); return limitations; }; diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 9440a6bf7..b91407969 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -5,9 +5,12 @@ import { ManagedDecodeBudget } from "./ManagedDecodeBudget.js"; import { edges } from "./ManagedMemberRows.js"; import type { ManagedMethod } from "./ManagedMemberInspectorCore.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; +import { managedMemberInspectionSchema } from "../domain/managed/managedArtifact.js"; +import { comparisonLimitations } from "../domain/managed/managedMemberComparisonCoverage.js"; import { buildManagedPeFixture, + buildNativePeFixture, managedPeFixtureTarget, } from "./ManagedPe.fixture.js"; @@ -138,6 +141,112 @@ describe("managed member inspection", () => { }); }); +describe("managed exported types", () => { + it("reports type forwarders and nested exported types with their implementation scope", () => { + const bytes = buildManagedPeFixture({ + exportedTypes: [ + { + namespace: "System", + name: "Object", + flags: 0x0020_0001, + implementationRaw: (1 << 2) | 1, + }, + { + namespace: "", + name: "Enumerator", + flags: 0x0000_0002, + implementationRaw: (1 << 2) | 2, + }, + ], + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.metadata.table_row_counts).toMatchObject({ + ExportedType: 2, + }); + expect(result.exported_types).toMatchObject([ + { + token: "0x27000001", + namespace: "System", + name: "Object", + flags: 0x0020_0001, + forwarder: true, + type_def_id: 0, + implementation_token: "0x23000001", + }, + { + token: "0x27000002", + namespace: "", + name: "Enumerator", + forwarder: false, + implementation_token: "0x27000001", + }, + ]); + expect(result.coverage).toEqual({ state: "complete", issues: [] }); + }); + + it("leaves exported types unknown when CLI metadata is unavailable", () => { + const bytes = buildNativePeFixture(); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.coverage.state).toBe("unavailable"); + expect(result.exported_types).toBeNull(); + }); + + it("reads member Evidence recorded before exported types as unexamined", () => { + const bytes = buildManagedPeFixture(); + const { exported_types: _omitted, ...legacy } = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + const parsed = managedMemberInspectionSchema.parse(legacy); + + expect(parsed.exported_types).toBeNull(); + expect(comparisonLimitations(parsed, parsed)).toContain( + "At least one managed member observation predates ExportedType inspection; its exported and forwarded types are unknown.", + ); + }); + + it("retains an exported type whose implementation is out of range as partial", () => { + const bytes = buildManagedPeFixture({ + exportedTypes: [ + { + namespace: "System", + name: "Object", + flags: 0x0020_0001, + implementationRaw: (9 << 2) | 1, + }, + ], + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.exported_types).toMatchObject([ + { token: "0x27000001", name: "Object", implementation_token: null }, + ]); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "invalid-row", + scope: "metadata.ExportedType:0x27000001", + detail: expect.stringContaining( + "ExportedType implementation coded index 0x25 is invalid", + ), + }, + ], + }); + }); +}); + describe("managed member decoding budget", () => { it("charges decoded output rather than accumulated decoding bounds", () => { // Each bound fits alone, but 256 bytes per IL and signature byte together diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 95cae8fae..3a55932a7 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -25,6 +25,7 @@ import { import { parseTypes, typeRanges } from "./ManagedMemberInspectorCore.js"; import { edges, + parseExportedTypes, parseFields, parseMemberRefs, parseMethods, @@ -60,6 +61,7 @@ const unavailable = ( requires_mvid: null, }, types: [], + exported_types: null, fields: [], methods: [], member_refs: [], @@ -117,6 +119,8 @@ export const inspectManagedMembersBytes = ( const ranges = typeRanges(bytes, layout, issues); const parsedTypes = parseTypes(bytes, layout, ranges); issues.push(...parsedTypes.issues); + const exportedTypes = parseExportedTypes(bytes, layout); + issues.push(...exportedTypes.issues); const fields = parseFields(bytes, layout, ranges); const memberRefs = parseMemberRefs(bytes, layout); issues.push(...memberRefs.issues); @@ -165,6 +169,7 @@ export const inspectManagedMembersBytes = ( requires_mvid: inventory.module?.mvid ?? null, }, types: parsedTypes.types, + exported_types: exportedTypes.exportedTypes, fields: fields.fields, methods: methods.methods, member_refs: memberRefs.refs, @@ -180,6 +185,7 @@ export const inspectManagedMembersBytes = ( limitations: [ "Metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.", "CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.", + "Exported types are reported from ExportedType rows as declared; forwarded types are not resolved in their implementing assemblies.", "Signatures are decoded for common ECMA-335 primitive, class, valuetype, pointer, byref, array, and generic variable forms; unsupported forms retain raw signature hashes.", ], }, diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index 0c5b4b3ab..970ce3d78 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -21,6 +21,9 @@ import { } from "./ManagedMetadataHeaps.js"; export type ManagedType = ManagedMemberInspection["types"][number]; +export type ManagedExportedType = NonNullable< + ManagedMemberInspection["exported_types"] +>[number]; export type ManagedField = ManagedMemberInspection["fields"][number]; export type ManagedMethod = ManagedMemberInspection["methods"][number]; export type ManagedMemberRef = ManagedMemberInspection["member_refs"][number]; diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index ab8b40724..896a6bc21 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -12,6 +12,7 @@ import { signature, type FieldCore, type ManagedCallEdge, + type ManagedExportedType, type ManagedField, type ManagedFieldAccess, type ManagedMemberRef, @@ -78,6 +79,77 @@ export const parseFields = ( return { fields, core, issues }; }; +const TYPE_FORWARDER_FLAG = 0x0020_0000; + +/** Read ExportedType rows, including type forwarders, as declared. */ +export const parseExportedTypes = ( + bytes: Buffer, + layout: ManagedMetadataLayout, +): { + readonly exportedTypes: readonly ManagedExportedType[]; + readonly issues: readonly ManagedParseIssue[]; +} => { + const exportedTypes: ManagedExportedType[] = []; + const issues: ManagedParseIssue[] = []; + const table = layout.table(39); + for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + readManagedValue(() => { + const cursor = metadataRowCursor(bytes, layout, 39, row); + const flags = cursor.readUInt32(); + const typeDefId = cursor.readUInt32(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + layout.strings.size, + ); + const namespace = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + layout.strings.size, + ); + const implementationOffset = cursor.offset; + const implementationRaw = cursor.readIndex( + layout.codedIndexSize("Implementation"), + ); + const token = metadataToken(39, row); + const implementationReason = metadataCodedTokenInvalidReason( + implementationRaw, + 2, + [38, 35, 39], + layout.rowCounts, + ); + if (implementationReason !== null || implementationRaw === 0) + issues.push({ + code: "invalid-row", + scope: `metadata.ExportedType:${token}`, + offset: implementationOffset, + detail: + implementationReason === null + ? "ExportedType implementation coded index 0x0 is null, but the Implementation column must reference a row" + : `ExportedType implementation coded index 0x${implementationRaw.toString(16)} is invalid: ${implementationReason}`, + }); + exportedTypes.push({ + token, + row_offset: cursor.start, + namespace, + name, + flags, + forwarder: (flags & TYPE_FORWARDER_FLAG) !== 0, + type_def_id: typeDefId, + implementation_token: metadataCodedToken( + implementationRaw, + 2, + [38, 35, 39], + layout.rowCounts, + ), + }); + }, issues); + } + return { exportedTypes, issues }; +}; + export const parseMemberRefs = ( bytes: Buffer, layout: ManagedMetadataLayout, diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index 2ce3bbae0..9f1c4cdde 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -7,6 +7,12 @@ interface ManagedPeFixtureOptions { readonly corruptMetadataSignature?: boolean; readonly customAttributeTypeRaw?: number; readonly extendsRaw?: number; + readonly exportedTypes?: readonly { + readonly namespace: string; + readonly name: string; + readonly flags: number; + readonly implementationRaw: number; + }[]; readonly fieldName?: string; readonly fieldSignature?: Buffer; readonly ilBody?: Buffer; @@ -361,6 +367,21 @@ const manifestResourceRow = ( metadataIndex(implementationRaw, implementationSize), ]); +const exportedTypeRow = ( + exported: { readonly name: number; readonly namespace: number }, + flags: number, + stringSize: MetadataIndexSize, + implementationSize: MetadataIndexSize, + implementationRaw: number, +): Buffer => + Buffer.concat([ + u32(flags), + u32(0), + metadataIndex(exported.name, stringSize), + metadataIndex(exported.namespace, stringSize), + metadataIndex(implementationRaw, implementationSize), + ]); + const fixedStringAttributeBlob = (value: string): Buffer => { const bytes = Buffer.from(textEncoder.encode(value)); if (bytes.length > 0x7f) @@ -469,6 +490,10 @@ interface FixtureMetadataRowContext { readonly pinvokeModuleName: number | null; readonly pinvokeImportName: number | null; readonly resourceName: number; + readonly exportedTypes: readonly { + readonly name: number; + readonly namespace: number; + }[]; readonly tokenBlob: number; readonly fieldSignature: number; readonly methodSignature: number; @@ -521,6 +546,29 @@ const customAttributeRows = ( ), ]; +const exportedTypeTable = ( + options: ManagedPeFixtureOptions, + indexes: FixtureMetadataRowContext["indexes"], + heapSizes: FixtureMetadataRowContext["heapSizes"], + widths: FixtureIndexWidths, +): readonly (readonly [number, readonly Buffer[]])[] => + indexes.exportedTypes.length === 0 + ? [] + : [ + [ + 39, + indexes.exportedTypes.map((exported, index) => + exportedTypeRow( + exported, + options.exportedTypes?.[index]?.flags ?? 0, + heapSizes.strings, + widths.implementation, + options.exportedTypes?.[index]?.implementationRaw ?? 0, + ), + ), + ], + ]; + const fixtureIndexWidths = ({ options, referenceStringIndexes, @@ -539,6 +587,7 @@ const fixtureIndexWidths = ({ ...(indexes.pinvokeImportName === null ? [] : ([[28, 1]] as const)), [32, options.assemblyRowCount ?? 1], [35, referenceStringIndexes.length], + [39, indexes.exportedTypes.length], [40, 1], ]); const count = (table: number): number => rowCounts.get(table) ?? 0; @@ -697,6 +746,7 @@ const fixtureMetadataRows = ({ ), ), ], + ...exportedTypeTable(options, indexes, heapSizes, widths), [ 40, [ @@ -736,6 +786,12 @@ const buildManagedFixtureMetadata = ( ? null : strings.add(options.pinvoke.importName ?? "MessageBoxW"); const resourceName = strings.add("Fixture.resources"); + const exportedTypes = (options.exportedTypes ?? []).map( + ({ namespace, name }) => ({ + name: strings.add(name), + namespace: strings.add(namespace), + }), + ); const referenceNames = options.references ?? ["System.Runtime"]; const referenceStringIndexes = referenceNames.map((name) => strings.add(name), @@ -775,6 +831,7 @@ const buildManagedFixtureMetadata = ( pinvokeModuleName, pinvokeImportName, resourceName, + exportedTypes, tokenBlob, fieldSignature, methodSignature,