mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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`
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -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]),
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user