From ed1519ddee02739f57136e356786f64c7f74fed0 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:02:16 -0700 Subject: [PATCH] fix(managed): report ExportedType rows in managed member inspection (#1828) * fix(managed): report ExportedType rows in managed member inspection Facade assemblies such as System.Runtime.dll provide their types only through ExportedType rows (type forwarders), which member inspection never read, so they reported complete coverage with no types. Report each row as declared with its flags, forwarder bit, TypeDefId hint, and implementation token, and record invalid or null implementation coded indexes as partial coverage. Comparisons now state that exported types are not compared when either side has them. Fixes #1812 Co-Authored-By: Claude Opus 5.5 * fix(managed): read earlier member Evidence without exported types Member Evidence recorded before ExportedType rows were read omits the field. Parse it as null (not examined) instead of rejecting it, and state in comparisons that such an observation's exported types are unknown. Co-Authored-By: Claude Opus 5.5 * fix(managed): leave exported types unknown when metadata is unavailable Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/managed-code-analysis.md | 9 ++ .../managed/managedWorkflowExamples.ts | 1 + src/domain/managed/managedArtifact.ts | 15 +++ .../managedMemberComparisonCoverage.ts | 11 ++ src/dotnet/ManagedMemberInspector.test.ts | 109 ++++++++++++++++++ src/dotnet/ManagedMemberInspector.ts | 6 + src/dotnet/ManagedMemberInspectorCore.ts | 3 + src/dotnet/ManagedMemberRows.ts | 72 ++++++++++++ src/dotnet/ManagedPe.fixture.ts | 57 +++++++++ 9 files changed, 283 insertions(+) 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,