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 <eco-null@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Can
2026-10-23 01:07:03 +03:00
committed by GitHub
parent 8b478d0f15
commit 371f3d91ae
12 changed files with 657 additions and 81 deletions
+21 -2
View File
@@ -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`
+1 -1
View File
@@ -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(),
@@ -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 ||
+14 -7
View File
@@ -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. */
+7 -5
View File
@@ -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);
});
});
+85 -59
View File
@@ -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<typeof prepareManagedMemberRecovery> | 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;
}
};
+70
View File
@@ -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 }],
},
});
};
@@ -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");
});
+21 -6
View File
@@ -101,6 +101,21 @@ const validateIdentityTableCounts = (
});
};
/** Module identity is independent of assembly references, attributes and resources. */
export const readManagedModuleIdentity = (
bytes: Buffer,
layout: ManagedMetadataLayout,
): Pick<ManagedMetadataInventory, "module" | "issues"> => {
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)),
@@ -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<string, unknown> = 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();
}
}
},
);
@@ -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],
+18
View File
@@ -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<typeof buildManagedPeFixture>[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]),
});
};