From 371f3d91aec6f0a9e92bc483f56dba534a2b0962 Mon Sep 17 00:00:00 2001 From: Can <159528514+eco-null@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:07:03 +0300 Subject: [PATCH] fix(managed): retain type discovery after member limits (#5312) fix(managed): retain types after member limits Admit type identity and a bounded recovery envelope before member decoding so callers can discover smaller selections after capacity exhaustion. Fixes #5146 Co-authored-by: eco-null Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/managed-code-analysis.md | 23 +- src/domain/managed/managedArtifact.ts | 2 +- .../managedMemberComparisonCoverage.ts | 2 +- src/dotnet/ManagedDecodeBudget.ts | 21 +- src/dotnet/ManagedMemberInspector.test.ts | 12 +- src/dotnet/ManagedMemberInspector.ts | 144 +++++++----- src/dotnet/ManagedMemberRecovery.ts | 70 ++++++ src/dotnet/ManagedMemberTypeInventory.test.ts | 208 ++++++++++++++++++ src/dotnet/ManagedMetadataInventory.ts | 27 ++- .../acceptance/managedMemberRecovery.test.ts | 179 +++++++++++++++ .../managed/managedMemberComparison.test.ts | 32 +++ tests/fixtures/managedMemberCapacity.ts | 18 ++ 12 files changed, 657 insertions(+), 81 deletions(-) create mode 100644 src/dotnet/ManagedMemberRecovery.ts create mode 100644 src/dotnet/ManagedMemberTypeInventory.test.ts create mode 100644 tests/acceptance/managedMemberRecovery.test.ts create mode 100644 tests/fixtures/managedMemberCapacity.ts diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index becd9aba19..0a293bbc51 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -200,8 +200,19 @@ rather than interpreted according to section order. ### Selected member inspection -When whole-assembly member decoding reaches the representation budget, select -known declaring types before their signatures and CIL bodies are decoded: +Member inspection admits type names, tokens, list ranges/counts, nesting facts, +module identity and their output representation before decoding members. If +later decoding or projection reaches the 64 MiB representation budget, the +result retains this type inventory with `coverage.state: "partial"` and the +original `resource-limit` coordinate. It does not return partially admitted +member collections: empty fields, methods, references and edges are unexamined, +not proof of absence; exported types, user strings and signature-resolution +attestation remain null. Summary and retained pages keep this partial coverage. +The capacity limit is unchanged. If metadata or the type inventory itself cannot +be admitted, the result remains unavailable. + +Use the returned `types[].full_name` values to select declaring types before +their signatures and CIL bodies are decoded: ```bash rea inspect-managed-members /absolute/path/Assembly.dll --type Example.Parser @@ -233,6 +244,14 @@ 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. +For MCP callers, request `detail: "summary"` and page the `types` collection +using the returned parent Evidence ID. Select one or more returned full names +in a subsequent `inspect_managed_members` request. Selection is a recovery path, +not a guarantee: a single type or the assembly-wide member references can still +exhaust the representation budget. A successful command exit means the Evidence +was returned; inspect `coverage.state` and `coverage.issues` before treating any +collection as complete. + ### User strings `ldstr` anchors keep their build-local `0x70` tokens. `user_strings.literals` diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index d6e00b3f52..7b129ab9e7 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -474,7 +474,7 @@ export const managedMemberInspectionSchema = z.object({ .nullable() .default(null) .describe( - "Signature type-name resolution was examined, including unresolved operands; null means historical Evidence without this attestation.", + "Signature type-name resolution was examined, including unresolved operands; null means no attestation, including historical or capacity-limited Evidence.", ), artifact: managedArtifactReferenceSchema, module: moduleIdentitySchema.nullable(), diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index ee6021f5c7..e2ca6529ff 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -150,7 +150,7 @@ export const comparisonLimitations = ( ); 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.", + "At least one managed member observation did not examine ExportedType rows; its exported and forwarded types are unknown.", ); if ( (left.exported_types?.length ?? 0) > 0 || diff --git a/src/dotnet/ManagedDecodeBudget.ts b/src/dotnet/ManagedDecodeBudget.ts index aca84f254f..cc1911a485 100644 --- a/src/dotnet/ManagedDecodeBudget.ts +++ b/src/dotnet/ManagedDecodeBudget.ts @@ -3,11 +3,23 @@ import { createHash } from "node:crypto"; import { jsonStringByteLength } from "../domain/jsonStringByteLength.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; -import { managedFailure } from "./ManagedReaderFailure.js"; +import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; /** Per-inspection capacity for eagerly retained metadata and its JSON projection. */ const MAX_METADATA_REPRESENTATION_BYTES = 64 * 1024 * 1024; +/** The capacity owner's diagnostic, also used to admit a recovery envelope. */ +export class ManagedRepresentationFailure extends ManagedReaderFailure { + constructor(offset: number | null) { + super({ + code: "resource-limit", + scope: "metadata.representation", + detail: `Managed metadata exceeds the ${String(MAX_METADATA_REPRESENTATION_BYTES)} byte representation budget`, + offset, + }); + } +} + interface CachedString { readonly value: string; readonly encodedBytes: number; @@ -56,12 +68,7 @@ export class ManagedDecodeBudget { bytes < 0 || bytes > this.#remaining - this.#transient ) - throw managedFailure( - "resource-limit", - "metadata.representation", - `Managed metadata exceeds the ${String(MAX_METADATA_REPRESENTATION_BYTES)} byte representation budget`, - offset, - ); + throw new ManagedRepresentationFailure(offset); } /** UTF-8 cannot decode to more UTF-16 units than bytes; JSON uses at most six bytes per unit. */ diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 8b65fc5924..bf50c56ed2 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -760,7 +760,7 @@ describe("managed exported types", () => { 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.", + "At least one managed member observation did not examine ExportedType rows; its exported and forwarded types are unknown.", ); }); @@ -1264,7 +1264,7 @@ describe("managed member decoding budget", () => { }); }); - it("still rejects dense one-byte instructions before exceeding capacity", () => { + it("rejects dense one-byte instructions while retaining admitted types", () => { // All NOPs: unlike wide instructions, each IL byte adds a decoded record. // A true oversized transient must remain an actionable resource limit. const il = Buffer.alloc(270_001); @@ -1282,12 +1282,12 @@ describe("managed member decoding budget", () => { ); expect(result.coverage).toMatchObject({ - state: "unavailable", + state: "partial", issues: [{ code: "resource-limit", scope: "metadata.representation" }], }); // Report the originating IL coordinate, not a generic decoding error. expect(result.coverage.issues[0]?.offset).toBeGreaterThan(0); - expect(result.types).toEqual([]); + expect(result.types).toHaveLength(1); expect(result.methods).toEqual([]); }); @@ -1338,9 +1338,11 @@ describe("managed member decoding budget", () => { ); expect(result.coverage).toMatchObject({ - state: "unavailable", + state: "partial", issues: [{ code: "resource-limit" }], }); + expect(result.methods).toEqual([]); + expect(result.types).toHaveLength(1); }); }); diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 48c36ff9fa..4f543edcd6 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -1,6 +1,7 @@ import { admitManagedProjection, managedDecodeBudget, + ManagedRepresentationFailure, } from "./ManagedDecodeBudget.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { @@ -15,6 +16,7 @@ import { import { readManagedResourceDirectory, readManagedMetadataInventory, + readManagedModuleIdentity, } from "./ManagedMetadataInventory.js"; import { managedPeInspectionEnvelope } from "./ManagedPeInspectionEnvelope.js"; import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js"; @@ -34,6 +36,7 @@ import { typeNameLookup } from "./ManagedTypeNames.js"; import { managedTypeReferences } from "./ManagedTypeReferences.js"; import { parseUserStrings } from "./ManagedUserStrings.js"; import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js"; +import { prepareManagedMemberRecovery } from "./ManagedMemberRecovery.js"; import { edges, parseExportedTypes, @@ -100,11 +103,7 @@ const unavailable = ( }), ); -const readMemberInventory = ( - bytes: Buffer, - pe: ManagedPeLayout, - issues: ManagedParseIssue[], -) => { +const readMemberLayout = (bytes: Buffer, pe: ManagedPeLayout) => { const cli = pe.cli; if (cli === null) throw new TypeError("Managed inventory requires CLI metadata"); @@ -119,13 +118,7 @@ const readMemberInventory = ( cli.metadata.size, ); const memberLists = readTypeMemberListFacts(bytes, layout); - const inventory = readManagedMetadataInventory( - bytes, - layout, - readManagedResourceDirectory(pe, issues), - memberLists, - ); - return { layout, inventory, memberLists }; + return { layout, memberLists }; }; const selectTypes = ( @@ -191,13 +184,11 @@ export const inspectManagedMembersBytes = ( if (pe.cli === null) return unavailable(target, bytes, pe.cliIssue, typeNames); const issues: ManagedParseIssue[] = []; + let recover: ReturnType | undefined; try { - const { layout, inventory, memberLists } = readMemberInventory( - bytes, - pe, - issues, - ); - appendManagedParseIssues(issues, inventory.issues); + const { layout, memberLists } = readMemberLayout(bytes, pe); + const identity = readManagedModuleIdentity(bytes, layout); + appendManagedParseIssues(issues, identity.issues); const ranges = typeRanges(bytes, layout, issues, memberLists); const { tokens: selected, @@ -206,8 +197,6 @@ export const inspectManagedMembersBytes = ( } = selectTypes(ranges, typeNames); const parsedTypes = parseTypes(bytes, layout, ranges); appendManagedParseIssues(issues, parsedTypes.issues); - const exportedTypes = parseExportedTypes(bytes, layout); - appendManagedParseIssues(issues, exportedTypes.issues); const typeReferences = managedTypeReferences(bytes, layout, issues); const signatureTypeName = managedTypeNameDigests( layout, @@ -215,6 +204,68 @@ export const inspectManagedMembersBytes = ( parsedTypes, typeReferences, ); + const types = parsedTypes.types + .filter((type) => selected === null || selected.has(type.token)) + .map((type) => { + managedDecodeBudget(layout).reserve(512, type.row_offset); + return { + ...type, + name_sha256: + signatureTypeName( + Number.parseInt(type.token.slice(4), 16) * 4, + )?.toString("hex") ?? null, + }; + }); + const envelope = managedPeInspectionEnvelope( + target, + bytes.length, + layout, + identity.module, + issues, + ); + const unnamedTypes = types.reduce( + (count, type) => count + Number(type.name_sha256 === null), + 0, + ); + const typeIdentityLimitations = + unnamedTypes === 0 + ? [] + : [ + `${String(unnamedTypes)} returned TypeDef name identity(s) remain unresolved; incomplete, missing, contradictory, cyclic or over-deep enclosing facts cannot be repaired by bare display names.`, + ]; + const selectionLimitations = [ + ...(selection === null + ? [] + : [ + "Types, fields, methods, call edges, field accesses, and user strings cover only the selected declaring types; coverage describes that selection. Edge targets outside it keep their names but are not described.", + ]), + ...(unreadableNames === 0 + ? [] + : [ + `${String(unreadableNames)} TypeDef name(s) could not be read; a name in selection.unmatched_type_names may belong to one of them, so its absence is not observed.`, + ]), + ]; + recover = prepareManagedMemberRecovery( + { + ...envelope, + types, + nesting_read: parsedTypes.nesting.complete, + selection, + limitations: [...typeIdentityLimitations, ...selectionLimitations], + }, + uniqueManagedParseIssues(issues), + managedDecodeBudget(layout), + ); + const inventory = readManagedMetadataInventory( + bytes, + layout, + readManagedResourceDirectory(pe, issues), + memberLists, + identity, + ); + appendManagedParseIssues(issues, inventory.issues); + const exportedTypes = parseExportedTypes(bytes, layout); + appendManagedParseIssues(issues, exportedTypes.issues); const fields = parseFields(bytes, layout, ranges, memberLists, { signatureTypeName, selected, @@ -266,33 +317,15 @@ export const inspectManagedMembersBytes = ( targets.members, managedDecodeBudget(layout), ); - const types = parsedTypes.types - .filter((type) => selected === null || selected.has(type.token)) - .map((type) => { - managedDecodeBudget(layout).reserve(512, type.row_offset); - return { - ...type, - name_sha256: - signatureTypeName( - Number.parseInt(type.token.slice(4), 16) * 4, - )?.toString("hex") ?? null, - }; - }); - const unnamedTypes = types.reduce( - (count, type) => count + Number(type.name_sha256 === null), - 0, - ); const coverageIssues = uniqueManagedParseIssues(issues); return managedMemberInspectionSchema.parse( admitManagedProjection( { - ...managedPeInspectionEnvelope( - target, - bytes.length, - layout, - inventory.module, - issues, - ), + ...envelope, + metadata: { + ...envelope.metadata, + status: issues.length === 0 ? "complete" : "partial", + }, types, signature_resolution: "type-names-v1", nesting_read: parsedTypes.nesting.complete, @@ -312,34 +345,27 @@ export const inspectManagedMembersBytes = ( issues: coverageIssues, }, limitations: [ - ...(unnamedTypes === 0 - ? [] - : [ - `${String(unnamedTypes)} returned TypeDef name identity(s) remain unresolved; incomplete, missing, contradictory, cyclic or over-deep enclosing facts cannot be repaired by bare display names.`, - ]), + ...typeIdentityLimitations, "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.", "User strings are the #US literals loaded by ldstr in the inspected method bodies, with their own coverage; instruction anchors and normalized CIL digests keep their build-local tokens.", "Member signatures decode ECMA-335 primitive, class, valuetype, generic instantiation, generic variable, pointer, byref, single-dimension and general array, function pointer, custom modifier, and typed reference forms; class and valuetype operands keep their encoded TypeDefOrRefOrSpec values, and unsupported forms such as vararg sentinels retain raw signature hashes.", - ...(selection === null - ? [] - : [ - "Types, fields, methods, call edges, field accesses, and user strings cover only the selected declaring types; coverage describes that selection. Edge targets outside it keep their names but are not described.", - ]), - ...(unreadableNames === 0 - ? [] - : [ - `${String(unreadableNames)} TypeDef name(s) could not be read; a name in selection.unmatched_type_names may belong to one of them, so its absence is not observed.`, - ]), + ...selectionLimitations, ], }, managedDecodeBudget(layout), ), ); } catch (cause: unknown) { - if (cause instanceof ManagedReaderFailure) + if (cause instanceof ManagedReaderFailure) { + if ( + recover !== undefined && + cause instanceof ManagedRepresentationFailure + ) + return recover(cause.issue.offset); return unavailable(target, bytes, cause.issue, typeNames); + } throw cause; } }; diff --git a/src/dotnet/ManagedMemberRecovery.ts b/src/dotnet/ManagedMemberRecovery.ts new file mode 100644 index 0000000000..be20b4452a --- /dev/null +++ b/src/dotnet/ManagedMemberRecovery.ts @@ -0,0 +1,70 @@ +import { + admitManagedProjection, + ManagedRepresentationFailure, + type ManagedDecodeBudget, +} from "./ManagedDecodeBudget.js"; +import { + managedMemberInspectionSchema, + type ManagedMemberInspection, + type ManagedParseIssue, +} from "../domain/managed/managedArtifact.js"; + +type TypeInventory = Pick< + ManagedMemberInspection, + | "artifact" + | "module" + | "metadata" + | "identity_scope" + | "types" + | "nesting_read" + | "selection" + | "limitations" +>; + +/** + * Admit recovery before spending capacity on members. The only later value is + * the capacity owner's byte coordinate: its longest safe-integer encoding is + * charged now, so recovery never needs a new budget or an unchecked allocation. + */ +export const prepareManagedMemberRecovery = ( + inventory: TypeInventory, + issues: readonly ManagedParseIssue[], + budget: ManagedDecodeBudget, +): ((offset: number | null) => ManagedMemberInspection) => { + const capacityIssue = new ManagedRepresentationFailure( + Number.MAX_SAFE_INTEGER, + ).issue; + const report: ManagedMemberInspection = { + ...inventory, + metadata: { ...inventory.metadata, status: "partial" }, + signature_resolution: null, + exported_types: null, + fields: [], + methods: [], + member_refs: [], + call_edges: [], + field_accesses: [], + user_strings: null, + coverage: { state: "partial", issues: [...issues, capacityIssue] }, + limitations: [ + ...inventory.limitations, + "Member decoding exceeded capacity after the type inventory was admitted; fields, methods, member references, call edges, field accesses, exported types and user strings are not examined to completion. Empty member collections do not establish absence.", + "Use the returned TypeDef full_name values with type_names (CLI --type) to inspect a smaller selection; a single selected type can still exceed capacity.", + "Metadata tokens are build-local coordinates and require the reported artifact SHA-256 and MVID. Type names and list ranges retain their original metadata and nesting uncertainty.", + ], + }; + admitManagedProjection(report, budget); + // A successful full report serializes these same immutable facts once. + // Transfer their existing charge, rather than charging the type list twice. + for (const value of Object.values(inventory)) { + if (value !== null && typeof value === "object") budget.prepay(value); + } + return (offset) => + managedMemberInspectionSchema.parse({ + ...report, + coverage: { + state: "partial", + issues: [...issues, { ...capacityIssue, offset }], + }, + }); +}; diff --git a/src/dotnet/ManagedMemberTypeInventory.test.ts b/src/dotnet/ManagedMemberTypeInventory.test.ts new file mode 100644 index 0000000000..f4eae02293 --- /dev/null +++ b/src/dotnet/ManagedMemberTypeInventory.test.ts @@ -0,0 +1,208 @@ +import { expect, it } from "vitest"; + +import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; +import { + buildManagedPeFixture, + managedPeFixtureTarget, +} from "./ManagedPe.fixture.js"; +import { buildManagedMemberCapacityFixture } from "../../tests/fixtures/managedMemberCapacity.js"; +import { readManagedPeLayout } from "./ManagedPeReader.js"; +import { + metadataRowOffset, + readManagedMetadataLayout, +} from "./ManagedMetadataLayout.js"; + +it("retains admitted type names, member counts and artifact identity", () => { + const bytes = buildManagedMemberCapacityFixture(); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.coverage.state).toBe("partial"); + expect(result.coverage.issues).toContainEqual( + expect.objectContaining({ + code: "resource-limit", + scope: "metadata.representation", + offset: expect.any(Number), + }), + ); + expect(result.types).toHaveLength(3); + expect( + result.types.find((type) => type.full_name === "Fixture.Program"), + ).toMatchObject({ + method_list: { count: 1 }, + field_list: { count: 1 }, + name_sha256: expect.any(String), + }); + expect(result.module).toMatchObject({ mvid: expect.any(String) }); + expect(result.identity_scope.requires_mvid).toBe(result.module?.mvid); + expect(result.metadata.table_row_counts).toMatchObject({ + TypeDef: 3, + MethodDef: 1, + }); + expect(result.metadata.version).not.toBeNull(); + expect(result.methods).toEqual([]); + expect(result.fields).toEqual([]); + expect(result.member_refs).toEqual([]); + expect(result.call_edges).toEqual([]); + expect(result.field_accesses).toEqual([]); + expect(result.user_strings).toBeNull(); + expect(result.exported_types).toBeNull(); + expect(result.signature_resolution).toBeNull(); + expect(result.limitations.join(" ")).toContain("not examined to completion"); +}); + +it("lets a caller select an inventoried empty type without decoding the oversized body", () => { + const bytes = buildManagedMemberCapacityFixture(); + const target = managedPeFixtureTarget(bytes); + const inventory = inspectManagedMembersBytes(bytes, target); + const empty = inventory.types.find((type) => type.method_list.count === 0); + expect(empty).toBeDefined(); + const result = inspectManagedMembersBytes(bytes, target, { + typeNames: [empty!.full_name], + }); + expect(result.coverage).toEqual({ state: "complete", issues: [] }); + expect(result.types).toHaveLength(1); + expect(result.types[0]?.token).toBe(empty?.token); + expect(result.methods).toEqual([]); +}); + +it("preserves explicit type selection when its selected body exhausts capacity", () => { + const bytes = buildManagedMemberCapacityFixture(); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + { + typeNames: ["Fixture.Program", "Missing.Type"], + }, + ); + expect(result.coverage.state).toBe("partial"); + expect(result.types.map((type) => type.full_name)).toEqual([ + "Fixture.Program", + ]); + expect(result.selection).toEqual({ + type_names: ["Fixture.Program", "Missing.Type"], + unmatched_type_names: ["Missing.Type"], + }); +}); + +it("retains types when a field signature exceeds the decoding bound", () => { + const bytes = buildManagedPeFixture({ + fieldSignature: Buffer.alloc(270_001, 0x06), + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.coverage.state).toBe("partial"); + expect(result.types[0]?.full_name).toBe("Fixture.Program"); + expect(result.coverage.issues).toContainEqual( + expect.objectContaining({ code: "resource-limit" }), + ); + expect(result.fields).toEqual([]); +}); + +it("keeps metadata admission failures unavailable rather than inventing an inventory", () => { + const bytes = buildManagedPeFixture({ corruptMetadataSignature: true }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.coverage.state).toBe("unavailable"); + expect(result.types).toEqual([]); + expect(result.metadata.status).toBe("malformed"); +}); + +it("retains a large type inventory without relaxing the member budget", () => { + const bytes = buildManagedMemberCapacityFixture({ + additionalEmptyTypes: 2_041, + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.coverage.state).toBe("partial"); + expect(result.types).toHaveLength(2_042); + expect(result.types.at(-1)?.full_name).toBe("Fixture.Program"); + expect(result.types.every((type) => type.name_sha256 !== null)).toBe(true); + expect(result.methods).toEqual([]); +}); + +it("does not recover an inventory whose names already exceed capacity", () => { + const bytes = buildManagedPeFixture({ + typeName: "T".repeat(12 * 1024 * 1024), + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.coverage.state).toBe("unavailable"); + expect(result.coverage.issues).toContainEqual( + expect.objectContaining({ code: "resource-limit" }), + ); + expect(result.types).toEqual([]); +}); + +it("keeps malformed type facts alongside the later capacity diagnostic", () => { + const bytes = buildManagedMemberCapacityFixture({ extendsRaw: 0xffff }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.coverage.state).toBe("partial"); + expect(result.types).toHaveLength(3); + expect(result.types.every((type) => type.extends_token === null)).toBe(true); + expect(result.coverage.issues).toContainEqual( + expect.objectContaining({ code: "invalid-row" }), + ); + expect(result.coverage.issues.at(-1)).toMatchObject({ + code: "resource-limit", + }); +}); + +it("does not repair cyclic nesting into a definite nominal identity", () => { + const bytes = buildManagedMemberCapacityFixture({ + additionalEmptyTypes: 1, + nestedTypes: [ + [1, 2], + [2, 1], + ], + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.coverage.state).toBe("partial"); + expect(result.types).toHaveLength(2); + expect(result.types.every((type) => type.name_sha256 === null)).toBe(true); + expect(result.types.map((type) => type.enclosing_type_token)).toEqual([ + "0x02000002", + "0x02000001", + ]); +}); + +it("does not turn an unreadable requested type name into observed absence during recovery", () => { + const bytes = buildManagedMemberCapacityFixture(); + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("Expected CLI fixture"); + const layout = readManagedMetadataLayout( + bytes, + pe.rvaToOffset(pe.cli.metadata.rva, pe.cli.metadata.size, "cli.metadata"), + pe.cli.metadata.size, + ); + expect(layout.stringIndexSize).toBe(2); + bytes.writeUInt16LE(0xffff, metadataRowOffset(layout, 2, 1) + 4); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + { + typeNames: ["Fixture.Program", "Fixture.Empty0"], + }, + ); + expect(result.coverage.state).toBe("partial"); + expect(result.selection?.unmatched_type_names).toEqual(["Fixture.Empty0"]); + expect(result.types.map((type) => type.full_name)).toEqual([ + "Fixture.Program", + ]); + expect(result.limitations.join(" ")).toContain("absence is not observed"); +}); diff --git a/src/dotnet/ManagedMetadataInventory.ts b/src/dotnet/ManagedMetadataInventory.ts index f2d5a90452..640b9c0d1c 100644 --- a/src/dotnet/ManagedMetadataInventory.ts +++ b/src/dotnet/ManagedMetadataInventory.ts @@ -101,6 +101,21 @@ const validateIdentityTableCounts = ( }); }; +/** Module identity is independent of assembly references, attributes and resources. */ +export const readManagedModuleIdentity = ( + bytes: Buffer, + layout: ManagedMetadataLayout, +): Pick => { + const issues: ManagedParseIssue[] = []; + validateIdentityTableCounts(layout, issues); + const module = + readManagedValue( + () => readModule(bytes, layout, heapExtent(layout)), + issues, + ) ?? null; + return { module, issues }; +}; + const readReferences = ( bytes: Buffer, layout: ManagedMetadataLayout, @@ -188,14 +203,14 @@ export const readManagedMetadataInventory = ( layout: ManagedMetadataLayout, resourceDirectory: ManagedResourceDirectory | null, memberLists: TypeMemberListFacts, + identity: Pick< + ManagedMetadataInventory, + "module" | "issues" + > = readManagedModuleIdentity(bytes, layout), ): ManagedMetadataInventory => { const issues: ManagedParseIssue[] = [...memberLists.issues]; - validateIdentityTableCounts(layout, issues); - const module = - readManagedValue( - () => readModule(bytes, layout, heapExtent(layout)), - issues, - ) ?? null; + issues.push(...identity.issues); + const { module } = identity; const assembly = readManagedValue( () => readAssembly(bytes, layout, heapExtent(layout)), diff --git a/tests/acceptance/managedMemberRecovery.test.ts b/tests/acceptance/managedMemberRecovery.test.ts new file mode 100644 index 0000000000..7ad6944577 --- /dev/null +++ b/tests/acceptance/managedMemberRecovery.test.ts @@ -0,0 +1,179 @@ +import { createHash } from "node:crypto"; +import { unlink, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../src/domain/evidence.js"; +import { managedMemberInspectionSchema } from "../../src/domain/managed/managedArtifact.js"; +import { + analysisViewResultSchema, + managedMemberSummaryViewResultSchema, +} from "../../src/domain/analysisView/analysisView.js"; +import { buildManagedMemberCapacityFixture } from "../fixtures/managedMemberCapacity.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const createTransport = () => + new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { + PATH: process.env.PATH ?? "", + REA_LOG_LEVEL: "silent", + ...(process.platform === "win32" + ? { + SystemRoot: process.env.SystemRoot ?? "", + TEMP: tmpdir(), + TMP: tmpdir(), + } + : {}), + }, + stderr: "pipe", + }); + +cliTest( + "CLI and stdio MCP recover type discovery and preserve partial retained pages", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-managed-member-recovery-"); + const path = join(root, "large-method.dll"); + const bytes = buildManagedMemberCapacityFixture(); + await writeFile(path, bytes); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const transport = createTransport(); + transport.stderr?.on("data", () => undefined); + const client = new Client({ name: "managed-type-recovery", version: "1" }); + try { + await client.connect(transport); + const definition = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_managed_members", + ); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + if (definition?.outputSchema === undefined) + throw new Error("Managed member schema is absent"); + const outputSchema: Record = definition.outputSchema; + expect(ajv.validateSchema(definition.inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + const output = await cli.run({ + arguments: ["inspect-managed-members", path, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const cliEvidence = parseEvidence(output.json); + const response = await client.callTool({ + name: "inspect_managed_members", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).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); + const report = managedMemberInspectionSchema.parse( + mcpEvidence.normalized_result, + ); + expect(report.coverage.state).toBe("partial"); + expect(report.types).toHaveLength(3); + expect(report.methods).toEqual([]); + const empty = report.types.find((type) => type.method_list.count === 0); + expect(empty).toBeDefined(); + const selectedCli = await cli.run({ + arguments: [ + "inspect-managed-members", + path, + "--type", + empty!.full_name, + "--json", + ], + }); + expect(selectedCli.exitCode, selectedCli.stderr).toBe(0); + const selected = await client.callTool({ + name: "inspect_managed_members", + arguments: { path, type_names: [empty!.full_name] }, + }); + expect(selected.isError).not.toBe(true); + const selectedEvidence = parseEvidence(selected.structuredContent); + expect(selectedEvidence.normalized_result).toEqual( + parseEvidence(selectedCli.json).normalized_result, + ); + expect(selectedEvidence.normalized_result).toMatchObject({ + coverage: { state: "complete", issues: [] }, + }); + + const summaryReply = await client.callTool({ + name: "inspect_managed_members", + arguments: { path, detail: "summary" }, + }); + expect(summaryReply.isError).not.toBe(true); + const summary = managedMemberSummaryViewResultSchema.parse( + parseEvidence(summaryReply.structuredContent).normalized_result, + ); + expect(summary.summary).toMatchObject({ + coverage: { state: "partial" }, + reported_counts: { + types: 3, + methods: 0, + exported_types: null, + user_strings: null, + }, + }); + await unlink(path); + for (const collection of ["types", "methods"] as const) { + const reply = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "managed-members", collection, offset: 0, limit: 10 }, + }, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const page = analysisViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + if (page.kind !== "managed-members") + throw new Error("Expected managed member page"); + expect(page.source_coverage.state).toBe("partial"); + expect(page.page.rows).toEqual( + collection === "types" ? report.types : [], + ); + } + const unknown = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { + kind: "managed-members", + collection: "exported_types", + offset: 0, + limit: 10, + }, + }, + }); + expect(unknown.isError).toBe(true); + expect(JSON.stringify(unknown)).toContain( + "did not examine exported_types", + ); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + }, +); diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index cafda31f5d..523f8e2a47 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -29,6 +29,38 @@ import { readManagedSnapshot, } from "../../../../src/dotnet/ManagedSnapshot.js"; import * as boundedFileBytes from "../../../../src/process/BoundedFileBytes.js"; +import { buildManagedMemberCapacityFixture } from "../../../fixtures/managedMemberCapacity.js"; + +it("keeps missing members unknown when the opposite inspection retains only types", async () => { + const directory = await createTestTempDirectory( + "rea-managed-type-recovery-comparison-", + ); + const completePath = join(directory, "complete.dll"); + const limitedPath = join(directory, "limited.dll"); + await writeFile( + completePath, + buildManagedPeFixture({ additionalEmptyTypes: 2 }), + ); + await writeFile(limitedPath, buildManagedMemberCapacityFixture()); + for (const [leftPath, rightPath] of [ + [completePath, limitedPath], + [limitedPath, completePath], + ]) { + const result = await compareManagedMemberPaths({ + leftPath: leftPath!, + rightPath: rightPath!, + }); + if (!result.ok) throw result.error; + const report = managedMemberComparisonResultSchema.parse( + result.value.normalized_result, + ); + expect(report.coverage.status).toBe("partial"); + expect(report.methods).toHaveLength(1); + expect(report.fields).toHaveLength(1); + expect(report.methods[0]?.status).toBe("unknown"); + expect(report.fields[0]?.status).toBe("unknown"); + } +}); it.each([ ["accessibility", 6, 0x0011], diff --git a/tests/fixtures/managedMemberCapacity.ts b/tests/fixtures/managedMemberCapacity.ts new file mode 100644 index 0000000000..8512d02c80 --- /dev/null +++ b/tests/fixtures/managedMemberCapacity.ts @@ -0,0 +1,18 @@ +import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; + +/** Valid dense CIL exceeds the unchanged decoding bound, after TypeDef admission. */ +export const buildManagedMemberCapacityFixture = ( + options: Parameters[0] = {}, +): Buffer => { + const il = Buffer.alloc(270_001); + il[il.length - 1] = 0x2a; + const header = Buffer.alloc(12); + header.writeUInt16LE(0x3003); + header.writeUInt16LE(8, 2); + header.writeUInt32LE(il.length, 4); + return buildManagedPeFixture({ + additionalEmptyTypes: 2, + ...options, + ilBody: Buffer.concat([header, il]), + }); +};