mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
feat(managed): select declaring types in inspect_managed_members
Select exact declaring types before managed signatures and CIL bodies are decoded, preserving caller choices in CLI/MCP Evidence. Keep unmatched names and selected coverage explicit; comparisons and graphs retain unknowns outside their observed scope. Reuse existing metadata ownership/accounting and advertise unique selections through the actual SDK Schema. Validated with 229 focused tests across 21 files, 11 managed conformance controls, fullstatic/docs checks and real Linux CLI and fresh MCP inspection of two unchanged DLLs. All 118 selected CIL hashes matched original PE-mapped bytes; result/caller-argument parity and unmatched controls passed. Full artifact/native-boundary metadata is complete; whole-assembly members retain the unchanged 64 MiB resource constraint. No target code executes. Refs #1878 Co-authored-by: skared <190618464+skoodster@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
@@ -58,6 +58,34 @@ reports `resource-limit` with unavailable or partial coverage; omitted facts do
|
||||
not become empty observations. Ambiguous PE section mappings are rejected
|
||||
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:
|
||||
|
||||
```bash
|
||||
rea inspect-managed-members /absolute/path/Assembly.dll --type Example.Parser
|
||||
```
|
||||
|
||||
MCP uses `inspect_managed_members` with
|
||||
`{"path":"/absolute/path/Assembly.dll","type_names":["Example.Parser"]}`.
|
||||
Repeat `--type` or supply multiple unique `type_names` for several types.
|
||||
Names match the reported `full_name` exactly. Top-level names are namespace
|
||||
qualified; nested types currently report bare names, which can select several
|
||||
types with that name.
|
||||
|
||||
The result preserves the requested names in `selection.type_names` and reports
|
||||
unmatched names separately. If metadata names are unreadable, limitations retain
|
||||
that uncertainty. With no selection, `selection` is null and whole-assembly
|
||||
inspection keeps its existing resource policy. Selection restricts types,
|
||||
fields, methods, call edges and field accesses; member references remain an
|
||||
assembly-wide inventory, so output size is not guaranteed to be small.
|
||||
|
||||
Coverage describes the selected members. Comparisons leave members outside the
|
||||
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.
|
||||
|
||||
## Analysis objective
|
||||
|
||||
REA's managed-code track is intended to answer five different questions without
|
||||
|
||||
@@ -128,7 +128,7 @@ export const runProviderAnalysis = async (
|
||||
]
|
||||
): Promise<JsonValue> =>
|
||||
isManagedToolName(tool)
|
||||
? runManagedProviderAnalysis(dependencies, path, tool, signal)
|
||||
? runManagedProviderAnalysis(dependencies, path, tool, arguments_, signal)
|
||||
: withProcessCancellation(signal, (operationSignal) =>
|
||||
runAnalysis(dependencies, path, tool, arguments_, {
|
||||
logger,
|
||||
@@ -143,6 +143,7 @@ export const runManagedProviderExecution = async (
|
||||
path: string,
|
||||
tool: ManagedToolName,
|
||||
signal?: AbortSignal,
|
||||
parameters: Readonly<Record<string, JsonValue>> = {},
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> =>
|
||||
withProcessCancellation(signal, async (operationSignal) => {
|
||||
const session = await dependencies.createManagedBinarySession();
|
||||
@@ -151,7 +152,9 @@ export const runManagedProviderExecution = async (
|
||||
async () => {
|
||||
const opened = await session.open(path, { signal: operationSignal });
|
||||
if (!opened.ok) return opened;
|
||||
return await session.execute(tool, {}, { signal: operationSignal });
|
||||
return await session.execute(tool, parameters, {
|
||||
signal: operationSignal,
|
||||
});
|
||||
},
|
||||
managedAnalysisCleanupFailure,
|
||||
);
|
||||
@@ -161,6 +164,7 @@ const runManagedProviderAnalysis = async (
|
||||
dependencies: DirectAnalysisDependencies,
|
||||
path: string,
|
||||
tool: ManagedToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JsonValue> => {
|
||||
const execution = await runManagedProviderExecution(
|
||||
@@ -168,12 +172,13 @@ const runManagedProviderAnalysis = async (
|
||||
path,
|
||||
tool,
|
||||
signal,
|
||||
arguments_,
|
||||
);
|
||||
if (!execution.ok) return cliError(execution.error);
|
||||
const value = execution.value;
|
||||
return createEvidence(value.subject ?? undefined, value.provider, {
|
||||
operation: tool,
|
||||
parameters: {},
|
||||
parameters: arguments_,
|
||||
result: value.result,
|
||||
rawResult: value.rawResult,
|
||||
limitations: value.limitations,
|
||||
|
||||
@@ -54,9 +54,22 @@ const registerInspectManagedMembers = (
|
||||
args: z.object({
|
||||
path: z.string().describe("Managed PE executable or assembly path"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
options: z.object({
|
||||
type: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Inspect only members declared by this exact type full name, such as System.Threading.Monitor; a nested type is named by its bare name, which selects every nested type of that name (repeatable)",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "inspect-managed-members", () =>
|
||||
runProviderAnalysis(args.path, "inspect_managed_members", {}, logger),
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
"inspect_managed_members",
|
||||
options.type === undefined ? {} : { type_names: options.type },
|
||||
logger,
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,6 +20,28 @@ export const managedTargetInputSchema = z.strictObject({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Member inspection may select declaring types before members are decoded. */
|
||||
export const managedMembersInputSchema = managedTargetInputSchema.extend({
|
||||
type_names: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Exact declaring-type full_name: namespace-qualified for top-level types (System.Threading.Monitor); nested types report their bare name, so it selects every nested type of that name",
|
||||
),
|
||||
)
|
||||
.min(1)
|
||||
.refine((names) => new Set(names).size === names.length, {
|
||||
message: "type_names must not repeat a name",
|
||||
})
|
||||
.meta({ uniqueItems: true })
|
||||
.describe(
|
||||
"Inspect only the fields, methods, call edges, and field accesses declared by these exact type full names; omit to inspect every type. Unmatched names are reported in selection.unmatched_type_names.",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const outputSchema = requireOutputSchema(
|
||||
managedOutputSchemas,
|
||||
"inspect_managed_artifact",
|
||||
@@ -54,15 +76,22 @@ export const MANAGED_TOOL_CONTRACTS = [
|
||||
name: "inspect_managed_members",
|
||||
...toolContractMetadata("inspect_managed_members"),
|
||||
description:
|
||||
"Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, and field-access anchors without loading or executing target code. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.",
|
||||
"Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, and field-access anchors without loading or executing target code. Select type_names to decode only the members of those exact declaring types, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.",
|
||||
kind: "managed-provider",
|
||||
inputSchema: managedTargetInputSchema,
|
||||
inputSchema: managedMembersInputSchema,
|
||||
outputSchema: memberOutputSchema,
|
||||
examples: [
|
||||
{
|
||||
title: "Example inspect managed members request",
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
title: "Example inspect selected managed types request",
|
||||
input: {
|
||||
path: "/tmp/mscorlib.dll",
|
||||
type_names: ["System.Threading.Monitor"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -53,6 +53,7 @@ const emptyManagedMembers = (
|
||||
member_refs: [],
|
||||
call_edges: [],
|
||||
field_accesses: [],
|
||||
selection: null,
|
||||
coverage: { state: "complete", issues: [] },
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
@@ -335,6 +335,11 @@ const projectionLimitations = (
|
||||
...(omissions.partialInput
|
||||
? ["At least one supplied managed Evidence record has partial coverage."]
|
||||
: []),
|
||||
...(omissions.selectedTypes === null
|
||||
? []
|
||||
: [
|
||||
`Managed member Evidence selected only these declaring types: ${omissions.selectedTypes.join(", ")}; type, method, and field nodes of other types are omitted, not absent.`,
|
||||
]),
|
||||
...(omissions.unscopedTokenRelationships
|
||||
? [
|
||||
"Managed boundary tokens were not linked to member nodes because matching module MVID scope was not established.",
|
||||
|
||||
@@ -19,8 +19,15 @@ export interface ManagedGraphCoverageSources {
|
||||
export interface ManagedGraphProjectionOmissions {
|
||||
readonly partialInput: boolean;
|
||||
readonly unscopedTokenRelationships: boolean;
|
||||
/** Declaring types the member Evidence selected; null when it inspected every type. */
|
||||
readonly selectedTypes: readonly string[] | null;
|
||||
}
|
||||
|
||||
const omitsAny = (omissions: ManagedGraphProjectionOmissions): boolean =>
|
||||
omissions.partialInput ||
|
||||
omissions.unscopedTokenRelationships ||
|
||||
omissions.selectedTypes !== null;
|
||||
|
||||
const completeCoverage = (): ApplicationGraphEvidence["coverage"] => ({
|
||||
status: "complete",
|
||||
truncated: false,
|
||||
@@ -61,13 +68,14 @@ export const assessManagedGraphOmissions = (
|
||||
sources.boundaries?.coverage.state,
|
||||
sources.artifact?.coverage.state,
|
||||
].some((state) => state !== undefined && state !== "complete"),
|
||||
selectedTypes: sources.members?.selection?.type_names ?? null,
|
||||
});
|
||||
|
||||
/** Derive graph coverage solely from the coverage of supplied source evidence. */
|
||||
export const managedGraphEvidenceCoverage = (
|
||||
omissions: ManagedGraphProjectionOmissions,
|
||||
): ApplicationGraphEvidence["coverage"] =>
|
||||
omissions.partialInput || omissions.unscopedTokenRelationships
|
||||
omitsAny(omissions)
|
||||
? {
|
||||
status: "partial",
|
||||
truncated: false,
|
||||
@@ -80,8 +88,7 @@ export const managedGraphEvidenceCoverage = (
|
||||
export const managedGraphResultCoverage = (
|
||||
omissions: ManagedGraphProjectionOmissions,
|
||||
) => ({
|
||||
status:
|
||||
omissions.partialInput || omissions.unscopedTokenRelationships
|
||||
? ("partial" as const)
|
||||
: ("complete-within-inputs" as const),
|
||||
status: omitsAny(omissions)
|
||||
? ("partial" as const)
|
||||
: ("complete-within-inputs" as const),
|
||||
});
|
||||
|
||||
@@ -440,6 +440,14 @@ export const managedMemberInspectionSchema = z.object({
|
||||
member_refs: z.array(managedMemberReferenceSchema),
|
||||
call_edges: z.array(managedCallEdgeSchema),
|
||||
field_accesses: z.array(managedFieldAccessSchema),
|
||||
/** Caller-selected declaring types; null when every type was inspected. */
|
||||
selection: z
|
||||
.object({
|
||||
type_names: z.array(z.string().min(1)).min(1),
|
||||
unmatched_type_names: z.array(z.string().min(1)),
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
coverage: z.object({
|
||||
state: z.enum(["complete", "partial", "unavailable"]),
|
||||
issues: z.array(managedParseIssueSchema),
|
||||
|
||||
@@ -213,6 +213,11 @@ export interface ManagedMemberComparisonSide {
|
||||
readonly result: ManagedMemberInspection;
|
||||
}
|
||||
|
||||
const selectedTypes = (
|
||||
result: ManagedMemberInspection,
|
||||
): ReadonlySet<string> | null =>
|
||||
result.selection === null ? null : new Set(result.selection.type_names);
|
||||
|
||||
/** Compare managed members with constrained exact and structural identities. */
|
||||
export const compareManagedMembers = (
|
||||
left: ManagedMemberComparisonSide,
|
||||
@@ -224,6 +229,8 @@ export const compareManagedMembers = (
|
||||
rightEvidenceId: right.evidenceId,
|
||||
leftComplete: left.result.metadata.status === "complete",
|
||||
rightComplete: right.result.metadata.status === "complete",
|
||||
leftSelection: selectedTypes(left.result),
|
||||
rightSelection: selectedTypes(right.result),
|
||||
};
|
||||
const methodItems = buildMethodItems(methodMatches, itemContext);
|
||||
const fieldItems = buildFieldItems(fieldMatches, itemContext);
|
||||
|
||||
@@ -16,7 +16,9 @@ export const buildComparisonCoverage = ({
|
||||
const leftStatus = sideCoverageStatus(left);
|
||||
const rightStatus = sideCoverageStatus(right);
|
||||
const unknownInput =
|
||||
left.coverage.state !== "complete" || right.coverage.state !== "complete";
|
||||
left.coverage.state !== "complete" ||
|
||||
right.coverage.state !== "complete" ||
|
||||
!sameSelection(left, right);
|
||||
return {
|
||||
status:
|
||||
!unknownInput && leftStatus === "complete" && rightStatus === "complete"
|
||||
@@ -27,6 +29,20 @@ export const buildComparisonCoverage = ({
|
||||
};
|
||||
};
|
||||
|
||||
/** Whether both observations inspected the same declaring types. */
|
||||
const sameSelection = (
|
||||
left: ManagedMemberInspection,
|
||||
right: ManagedMemberInspection,
|
||||
): boolean => {
|
||||
if (left.selection === null || right.selection === null)
|
||||
return left.selection === right.selection;
|
||||
const rightNames = new Set(right.selection.type_names);
|
||||
return (
|
||||
rightNames.size === new Set(left.selection.type_names).size &&
|
||||
left.selection.type_names.every((name) => rightNames.has(name))
|
||||
);
|
||||
};
|
||||
|
||||
const sideCoverageStatus = (
|
||||
result: ManagedMemberInspection,
|
||||
): ManagedMemberComparisonResult["coverage"]["left_status"] =>
|
||||
@@ -96,6 +112,14 @@ export const comparisonLimitations = (
|
||||
];
|
||||
if (left.coverage.state !== "complete" || right.coverage.state !== "complete")
|
||||
limitations.push("At least one managed member observation is partial.");
|
||||
if (!sameSelection(left, right))
|
||||
limitations.push(
|
||||
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
|
||||
);
|
||||
else if (left.selection !== null)
|
||||
limitations.push(
|
||||
`Both observations cover only the selected declaring types (${left.selection.type_names.join(", ")}); member totals and statuses describe those types, not the whole assembly.`,
|
||||
);
|
||||
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.",
|
||||
|
||||
@@ -26,8 +26,21 @@ interface ComparisonItemContext {
|
||||
readonly rightEvidenceId: string;
|
||||
readonly leftComplete: boolean;
|
||||
readonly rightComplete: boolean;
|
||||
/** Declaring types each side selected; null when it inspected every type. */
|
||||
readonly leftSelection: ReadonlySet<string> | null;
|
||||
readonly rightSelection: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
/** Whether an inventory observed every member of this member's declaring type. */
|
||||
const inventoryCovers = (
|
||||
complete: boolean,
|
||||
selection: ReadonlySet<string> | null,
|
||||
member: Method | Field,
|
||||
): boolean =>
|
||||
complete &&
|
||||
(selection === null ||
|
||||
(member.declaring_type !== null && selection.has(member.declaring_type)));
|
||||
|
||||
/** Declared type and name of each unpaired member, per side, whose signature was not decoded. */
|
||||
interface UndecodedOneSidedNames {
|
||||
readonly left: ReadonlySet<string>;
|
||||
@@ -123,7 +136,11 @@ const methodOnlyItem = (
|
||||
): MethodItem => {
|
||||
if (member.left !== undefined) {
|
||||
const limitations = absenceLimitations(member.left, {
|
||||
oppositeComplete: context.rightComplete,
|
||||
oppositeComplete: inventoryCovers(
|
||||
context.rightComplete,
|
||||
context.rightSelection,
|
||||
member.left,
|
||||
),
|
||||
oppositeUndecodedNames: undecoded.right,
|
||||
opposite: "right",
|
||||
inventory: "method",
|
||||
@@ -141,7 +158,11 @@ const methodOnlyItem = (
|
||||
}
|
||||
|
||||
const limitations = absenceLimitations(member.right, {
|
||||
oppositeComplete: context.leftComplete,
|
||||
oppositeComplete: inventoryCovers(
|
||||
context.leftComplete,
|
||||
context.leftSelection,
|
||||
member.right,
|
||||
),
|
||||
oppositeUndecodedNames: undecoded.left,
|
||||
opposite: "left",
|
||||
inventory: "method",
|
||||
@@ -233,7 +254,11 @@ const fieldOnlyItem = (
|
||||
): FieldItem => {
|
||||
if (member.left !== undefined) {
|
||||
const limitations = absenceLimitations(member.left, {
|
||||
oppositeComplete: context.rightComplete,
|
||||
oppositeComplete: inventoryCovers(
|
||||
context.rightComplete,
|
||||
context.rightSelection,
|
||||
member.left,
|
||||
),
|
||||
oppositeUndecodedNames: undecoded.right,
|
||||
opposite: "right",
|
||||
inventory: "field",
|
||||
@@ -250,7 +275,11 @@ const fieldOnlyItem = (
|
||||
}
|
||||
|
||||
const limitations = absenceLimitations(member.right, {
|
||||
oppositeComplete: context.leftComplete,
|
||||
oppositeComplete: inventoryCovers(
|
||||
context.leftComplete,
|
||||
context.leftSelection,
|
||||
member.right,
|
||||
),
|
||||
oppositeUndecodedNames: undecoded.left,
|
||||
opposite: "left",
|
||||
inventory: "field",
|
||||
|
||||
@@ -250,22 +250,74 @@ const groupBy = <Item>(
|
||||
return grouped;
|
||||
};
|
||||
|
||||
/** Whether a side's inventory observed members of this declaring type. */
|
||||
const observedBy =
|
||||
(side: ManagedMemberComparisonSide) =>
|
||||
({ declaring_type }: { readonly declaring_type: string | null }): boolean =>
|
||||
side.result.selection === null ||
|
||||
(declaring_type !== null &&
|
||||
side.result.selection.type_names.includes(declaring_type));
|
||||
|
||||
/**
|
||||
* Pair only members whose declaring type both sides observed. A member outside
|
||||
* the other side's selection stays one-sided, so a type selection never yields
|
||||
* a remap to a member of a type the other side did not inspect.
|
||||
*/
|
||||
const matchObserved = <Item extends { readonly declaring_type: string | null }>(
|
||||
left: readonly Item[],
|
||||
right: readonly Item[],
|
||||
sides: {
|
||||
readonly inLeft: (item: Item) => boolean;
|
||||
readonly inRight: (item: Item) => boolean;
|
||||
},
|
||||
match: (
|
||||
left: readonly Keyed<Item>[],
|
||||
right: readonly Keyed<Item>[],
|
||||
) => ReturnType<typeof matchByKeys<Item>>,
|
||||
key: (item: Item) => Keyed<Item>,
|
||||
): ReturnType<typeof matchByKeys<Item>> => {
|
||||
const matched = match(
|
||||
left.filter(sides.inRight).map(key),
|
||||
right.filter(sides.inLeft).map(key),
|
||||
);
|
||||
return {
|
||||
...matched,
|
||||
leftOnly: [
|
||||
...matched.leftOnly,
|
||||
...left.filter((item) => !sides.inRight(item)).map(key),
|
||||
],
|
||||
rightOnly: [
|
||||
...matched.rightOnly,
|
||||
...right.filter((item) => !sides.inLeft(item)).map(key),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const keyMembers = (
|
||||
left: ManagedMemberComparisonSide,
|
||||
right: ManagedMemberComparisonSide,
|
||||
): {
|
||||
readonly methodMatches: ReturnType<typeof matchMethods>;
|
||||
readonly fieldMatches: ReturnType<typeof matchFields>;
|
||||
} => ({
|
||||
methodMatches: matchMethods(
|
||||
left.result.methods.map(keyMethod),
|
||||
right.result.methods.map(keyMethod),
|
||||
),
|
||||
fieldMatches: matchFields(
|
||||
left.result.fields.map(keyField),
|
||||
right.result.fields.map(keyField),
|
||||
),
|
||||
});
|
||||
} => {
|
||||
const sides = { inLeft: observedBy(left), inRight: observedBy(right) };
|
||||
return {
|
||||
methodMatches: matchObserved(
|
||||
left.result.methods,
|
||||
right.result.methods,
|
||||
sides,
|
||||
matchMethods,
|
||||
keyMethod,
|
||||
),
|
||||
fieldMatches: matchObserved(
|
||||
left.result.fields,
|
||||
right.result.fields,
|
||||
sides,
|
||||
matchFields,
|
||||
keyField,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseManagedMemberEvidence = (
|
||||
evidence: unknown,
|
||||
|
||||
@@ -177,40 +177,54 @@ describe("managed artifact coded indexes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports null for the required CustomAttribute constructor coded index", () => {
|
||||
const bytes = buildManagedPeFixture({ customAttributeTypeRaw: 0 });
|
||||
const result = inspectManagedArtifactBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
const text = Buffer.from(".NETCoreApp,Version=v8.0", "utf8");
|
||||
const attributeValue = Buffer.concat([
|
||||
Buffer.from([1, 0, text.length]),
|
||||
text,
|
||||
Buffer.alloc(2),
|
||||
]);
|
||||
it.each([
|
||||
[
|
||||
0,
|
||||
"CustomAttribute constructor coded index 0x0 is null, but Type must reference a MethodDef or MemberRef row",
|
||||
],
|
||||
[
|
||||
19,
|
||||
"coded index 0x13 is invalid: coded index selects row 2 in table 10, which has 1 rows",
|
||||
],
|
||||
] as const)(
|
||||
"preserves readable attribute facts for constructor index %s",
|
||||
(rawType, detail) => {
|
||||
const bytes = buildManagedPeFixture({ customAttributeTypeRaw: rawType });
|
||||
const result = inspectManagedArtifactBytes(
|
||||
bytes,
|
||||
managedPeFixtureTarget(bytes),
|
||||
);
|
||||
const text = Buffer.from(".NETCoreApp,Version=v8.0", "utf8");
|
||||
const attributeValue = Buffer.concat([
|
||||
Buffer.from([1, 0, text.length]),
|
||||
text,
|
||||
Buffer.alloc(2),
|
||||
]);
|
||||
|
||||
expect(result.attributes).toMatchObject([
|
||||
{
|
||||
parent_token: "0x20000001",
|
||||
constructor_token: null,
|
||||
type_name: null,
|
||||
value_length: attributeValue.length,
|
||||
value_sha256: createHash("sha256").update(attributeValue).digest("hex"),
|
||||
token: "0x0c000001",
|
||||
},
|
||||
]);
|
||||
expect(result.coverage).toMatchObject({
|
||||
state: "partial",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
code: "invalid-row",
|
||||
detail:
|
||||
"CustomAttribute constructor coded index 0x0 is null, but Type must reference a MethodDef or MemberRef row",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
expect(result.attributes).toMatchObject([
|
||||
{
|
||||
parent_token: "0x20000001",
|
||||
constructor_token: null,
|
||||
type_name: null,
|
||||
value_length: attributeValue.length,
|
||||
value_sha256: createHash("sha256")
|
||||
.update(attributeValue)
|
||||
.digest("hex"),
|
||||
token: "0x0c000001",
|
||||
},
|
||||
]);
|
||||
expect(result.coverage).toMatchObject({
|
||||
state: "partial",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
code: "invalid-row",
|
||||
scope: "metadata.CustomAttribute:0x0c000001",
|
||||
detail: expect.stringContaining(detail),
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports coded-index column offsets after widened string and coded-index columns", () => {
|
||||
const references = Array.from(
|
||||
|
||||
@@ -859,3 +859,68 @@ it.each([
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe("managed member type selection", () => {
|
||||
const bytes = buildManagedPeFixture();
|
||||
const target = managedPeFixtureTarget(bytes);
|
||||
const everything = inspectManagedMembersBytes(bytes, target);
|
||||
|
||||
it("reports no selection when every type is inspected", () => {
|
||||
expect(everything.selection).toBeNull();
|
||||
});
|
||||
|
||||
it("decodes the selected type's members and reports unmatched names", () => {
|
||||
const result = inspectManagedMembersBytes(bytes, target, {
|
||||
typeNames: ["Fixture.Program", "Missing.Type"],
|
||||
});
|
||||
|
||||
expect(result.selection).toEqual({
|
||||
type_names: ["Fixture.Program", "Missing.Type"],
|
||||
unmatched_type_names: ["Missing.Type"],
|
||||
});
|
||||
expect(result.types).toEqual(everything.types);
|
||||
expect(result.fields).toEqual(everything.fields);
|
||||
expect(result.methods).toEqual(everything.methods);
|
||||
expect(result.call_edges).toEqual(everything.call_edges);
|
||||
expect(result.field_accesses).toEqual(everything.field_accesses);
|
||||
expect(result.coverage).toEqual(everything.coverage);
|
||||
expect(result.limitations).toContain(
|
||||
"Types, fields, methods, call edges, and field accesses cover only the selected declaring types; coverage describes that selection. Edge targets outside it keep their names but are not described.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat a name as absent when a TypeDef name is unreadable", () => {
|
||||
const corrupt = Buffer.from(bytes);
|
||||
const row = everything.types[0]?.row_offset ?? 0;
|
||||
// Point the TypeDef Name past the #Strings heap.
|
||||
corrupt.writeUInt16LE(0xffff, row + 4);
|
||||
const result = inspectManagedMembersBytes(
|
||||
corrupt,
|
||||
managedPeFixtureTarget(corrupt),
|
||||
{ typeNames: ["Fixture.Program"] },
|
||||
);
|
||||
|
||||
expect(result.selection?.unmatched_type_names).toEqual(["Fixture.Program"]);
|
||||
expect(result.coverage.state).toBe("partial");
|
||||
expect(result.limitations).toContain(
|
||||
"1 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.",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits members of unselected types without dropping member references", () => {
|
||||
const result = inspectManagedMembersBytes(bytes, target, {
|
||||
typeNames: ["Missing.Type"],
|
||||
});
|
||||
|
||||
expect(result.selection).toEqual({
|
||||
type_names: ["Missing.Type"],
|
||||
unmatched_type_names: ["Missing.Type"],
|
||||
});
|
||||
expect(result.types).toEqual([]);
|
||||
expect(result.fields).toEqual([]);
|
||||
expect(result.methods).toEqual([]);
|
||||
expect(result.call_edges).toEqual([]);
|
||||
expect(result.field_accesses).toEqual([]);
|
||||
expect(result.member_refs).toEqual(everything.member_refs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,19 +22,25 @@ import {
|
||||
ManagedReaderFailure,
|
||||
uniqueManagedParseIssues,
|
||||
} from "./ManagedReaderFailure.js";
|
||||
import { parseTypes, typeRanges } from "./ManagedMemberInspectorCore.js";
|
||||
import {
|
||||
parseTypes,
|
||||
typeRanges,
|
||||
type TypeRange,
|
||||
} from "./ManagedMemberInspectorCore.js";
|
||||
import {
|
||||
edges,
|
||||
parseExportedTypes,
|
||||
parseFields,
|
||||
parseMemberRefs,
|
||||
parseMethods,
|
||||
type SelectedTypeTokens,
|
||||
} from "./ManagedMemberRows.js";
|
||||
|
||||
const unavailable = (
|
||||
target: BinaryTarget,
|
||||
bytes: Buffer,
|
||||
issue: ManagedParseIssue | null,
|
||||
typeNames: readonly string[] | undefined,
|
||||
): ManagedMemberInspection =>
|
||||
managedMemberInspectionSchema.parse(
|
||||
admitManagedProjection({
|
||||
@@ -67,6 +73,11 @@ const unavailable = (
|
||||
member_refs: [],
|
||||
call_edges: [],
|
||||
field_accesses: [],
|
||||
// Nothing was examined, so no requested name is known to be unmatched.
|
||||
selection:
|
||||
typeNames === undefined
|
||||
? null
|
||||
: { type_names: [...typeNames], unmatched_type_names: [] },
|
||||
coverage: {
|
||||
state: "unavailable",
|
||||
issues: issue === null ? [] : [issue],
|
||||
@@ -105,23 +116,61 @@ const readMemberInventory = (
|
||||
return { layout, inventory };
|
||||
};
|
||||
|
||||
/** Inspect metadata members and method bodies without loading target code. */
|
||||
const selectTypes = (
|
||||
ranges: readonly TypeRange[],
|
||||
typeNames: readonly string[] | undefined,
|
||||
): {
|
||||
readonly tokens: SelectedTypeTokens;
|
||||
readonly selection: ManagedMemberInspection["selection"];
|
||||
readonly unreadableNames: number;
|
||||
} => {
|
||||
if (typeNames === undefined)
|
||||
return { tokens: null, selection: null, unreadableNames: 0 };
|
||||
const requested = new Set(typeNames);
|
||||
const tokens = new Set<string>();
|
||||
const matched = new Set<string>();
|
||||
for (const range of ranges) {
|
||||
if (range.fullName === null || !requested.has(range.fullName)) continue;
|
||||
tokens.add(range.token);
|
||||
matched.add(range.fullName);
|
||||
}
|
||||
return {
|
||||
tokens,
|
||||
unreadableNames: ranges.filter(({ fullName }) => fullName === null).length,
|
||||
selection: {
|
||||
type_names: [...typeNames],
|
||||
unmatched_type_names: typeNames.filter((name) => !matched.has(name)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Inspect metadata members and method bodies without loading target code.
|
||||
* Selected type names restrict full decoding to members of those exact types.
|
||||
*/
|
||||
export const inspectManagedMembersBytes = (
|
||||
bytes: Buffer,
|
||||
target: BinaryTarget,
|
||||
{ typeNames }: { readonly typeNames?: readonly string[] } = {},
|
||||
): ManagedMemberInspection => {
|
||||
const pe = readManagedPeLayout(bytes);
|
||||
if (pe.cli === null) return unavailable(target, bytes, pe.cliIssue);
|
||||
if (pe.cli === null)
|
||||
return unavailable(target, bytes, pe.cliIssue, typeNames);
|
||||
const issues: ManagedParseIssue[] = [];
|
||||
try {
|
||||
const { layout, inventory } = readMemberInventory(bytes, pe, issues);
|
||||
issues.push(...inventory.issues);
|
||||
const ranges = typeRanges(bytes, layout, issues);
|
||||
const {
|
||||
tokens: selected,
|
||||
selection,
|
||||
unreadableNames,
|
||||
} = selectTypes(ranges, typeNames);
|
||||
const parsedTypes = parseTypes(bytes, layout, ranges);
|
||||
issues.push(...parsedTypes.issues);
|
||||
const exportedTypes = parseExportedTypes(bytes, layout);
|
||||
issues.push(...exportedTypes.issues);
|
||||
const fields = parseFields(bytes, layout, ranges);
|
||||
const fields = parseFields(bytes, layout, ranges, selected);
|
||||
const memberRefs = parseMemberRefs(bytes, layout);
|
||||
issues.push(...memberRefs.issues);
|
||||
const methods = parseMethods({
|
||||
@@ -129,6 +178,7 @@ export const inspectManagedMembersBytes = (
|
||||
layout,
|
||||
pe,
|
||||
ranges,
|
||||
selected,
|
||||
});
|
||||
issues.push(...fields.issues, ...methods.issues);
|
||||
const coverageIssues = uniqueManagedParseIssues(issues);
|
||||
@@ -168,13 +218,17 @@ export const inspectManagedMembersBytes = (
|
||||
requires_artifact_sha256: target.sha256,
|
||||
requires_mvid: inventory.module?.mvid ?? null,
|
||||
},
|
||||
types: parsedTypes.types,
|
||||
types:
|
||||
selected === null
|
||||
? parsedTypes.types
|
||||
: parsedTypes.types.filter((type) => selected.has(type.token)),
|
||||
exported_types: exportedTypes.exportedTypes,
|
||||
fields: fields.fields,
|
||||
methods: methods.methods,
|
||||
member_refs: memberRefs.refs,
|
||||
call_edges: related.callEdges,
|
||||
field_accesses: related.fieldAccesses,
|
||||
selection,
|
||||
coverage: {
|
||||
state:
|
||||
issues.length === 0 && !incompleteMembers
|
||||
@@ -187,6 +241,16 @@ export const inspectManagedMembersBytes = (
|
||||
"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.",
|
||||
"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, and field accesses 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.`,
|
||||
]),
|
||||
],
|
||||
},
|
||||
managedDecodeBudget(layout),
|
||||
@@ -194,7 +258,7 @@ export const inspectManagedMembersBytes = (
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ManagedReaderFailure)
|
||||
return unavailable(target, bytes, cause.issue);
|
||||
return unavailable(target, bytes, cause.issue, typeNames);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,10 +32,23 @@ import { readManagedValue } from "./ManagedReaderFailure.js";
|
||||
import { methodBody } from "./ManagedMethodBodyReader.js";
|
||||
import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js";
|
||||
|
||||
/**
|
||||
* Declaring-type tokens whose members are decoded in full; null selects every
|
||||
* type. Unselected members keep only their name so edges stay labelled.
|
||||
*/
|
||||
export type SelectedTypeTokens = ReadonlySet<string> | null;
|
||||
|
||||
const isSelected = (
|
||||
selected: SelectedTypeTokens,
|
||||
declared: { readonly token: string } | undefined,
|
||||
): boolean =>
|
||||
selected === null || (declared !== undefined && selected.has(declared.token));
|
||||
|
||||
export const parseFields = (
|
||||
bytes: Buffer,
|
||||
layout: ManagedMetadataLayout,
|
||||
ranges: readonly TypeRange[],
|
||||
selected: SelectedTypeTokens = null,
|
||||
): {
|
||||
readonly fields: readonly ManagedField[];
|
||||
readonly core: ReadonlyMap<string, FieldCore>;
|
||||
@@ -59,18 +72,20 @@ export const parseFields = (
|
||||
cursor.readIndex(layout.stringIndexSize),
|
||||
layout.strings.size,
|
||||
);
|
||||
const sig = readMetadataBlob(
|
||||
bytes,
|
||||
layout,
|
||||
cursor.readIndex(layout.blobIndexSize),
|
||||
layout.blob.size,
|
||||
);
|
||||
const owner =
|
||||
ownerRow === undefined
|
||||
? 0
|
||||
: (readManagedValue(() => ownerRow(row), issues) ?? 0);
|
||||
const declared = ranges[owner - 1];
|
||||
const token = metadataToken(4, row);
|
||||
core.set(token, { token, name });
|
||||
if (!isSelected(selected, declared)) return;
|
||||
const sig = readMetadataBlob(
|
||||
bytes,
|
||||
layout,
|
||||
cursor.readIndex(layout.blobIndexSize),
|
||||
layout.blob.size,
|
||||
);
|
||||
fields.push({
|
||||
token,
|
||||
row_offset: cursor.start,
|
||||
@@ -80,7 +95,6 @@ export const parseFields = (
|
||||
flags,
|
||||
signature: budgetedSignature(sig, layout),
|
||||
});
|
||||
core.set(token, { token, name });
|
||||
}, issues);
|
||||
}
|
||||
return { fields, core, issues };
|
||||
@@ -227,6 +241,7 @@ interface ParseMethodsInput {
|
||||
readonly layout: ManagedMetadataLayout;
|
||||
readonly pe: ManagedPeLayout;
|
||||
readonly ranges: readonly TypeRange[];
|
||||
readonly selected?: SelectedTypeTokens;
|
||||
}
|
||||
|
||||
export const parseMethods = ({
|
||||
@@ -234,6 +249,7 @@ export const parseMethods = ({
|
||||
layout,
|
||||
pe,
|
||||
ranges,
|
||||
selected = null,
|
||||
}: ParseMethodsInput): {
|
||||
readonly methods: readonly ManagedMethod[];
|
||||
readonly core: ReadonlyMap<string, MethodCore>;
|
||||
@@ -259,6 +275,18 @@ export const parseMethods = ({
|
||||
cursor.readIndex(layout.stringIndexSize),
|
||||
layout.strings.size,
|
||||
);
|
||||
const owner =
|
||||
ownerRow === undefined
|
||||
? 0
|
||||
: (readManagedValue(() => ownerRow(row), issues) ?? 0);
|
||||
const declared = ranges[owner - 1];
|
||||
const token = metadataToken(6, row);
|
||||
core.set(token, {
|
||||
token,
|
||||
name,
|
||||
declaringType: declared?.fullName ?? null,
|
||||
});
|
||||
if (!isSelected(selected, declared)) return;
|
||||
const sig = readMetadataBlob(
|
||||
bytes,
|
||||
layout,
|
||||
@@ -266,12 +294,6 @@ export const parseMethods = ({
|
||||
layout.blob.size,
|
||||
);
|
||||
cursor.readIndex(layout.tableIndexSize(8));
|
||||
const owner =
|
||||
ownerRow === undefined
|
||||
? 0
|
||||
: (readManagedValue(() => ownerRow(row), issues) ?? 0);
|
||||
const declared = ranges[owner - 1];
|
||||
const token = metadataToken(6, row);
|
||||
methods.push({
|
||||
token,
|
||||
row_offset: cursor.start,
|
||||
@@ -290,11 +312,6 @@ export const parseMethods = ({
|
||||
managedDecodeBudget(layout),
|
||||
),
|
||||
});
|
||||
core.set(token, {
|
||||
token,
|
||||
name,
|
||||
declaringType: declared?.fullName ?? null,
|
||||
});
|
||||
}, issues);
|
||||
}
|
||||
return { methods, core, issues };
|
||||
|
||||
@@ -13,6 +13,7 @@ import { managedStaticCapabilities } from "./ManagedStaticProviderMetadata.js";
|
||||
import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import {
|
||||
MANAGED_TOOL_CONTRACTS,
|
||||
managedMembersInputSchema,
|
||||
managedTargetInputSchema,
|
||||
type ManagedToolName,
|
||||
} from "../contracts/managed/managedToolContracts.js";
|
||||
@@ -235,14 +236,20 @@ const inspectManagedOperation = (
|
||||
| ManagedArtifactInspection
|
||||
| ManagedMemberInspection
|
||||
| ManagedNativeBoundaryInspection => {
|
||||
if (operation === "inspect_managed_members") {
|
||||
const { type_names: typeNames } =
|
||||
managedMembersInputSchema.parse(parameters);
|
||||
return inspectManagedMembersBytes(
|
||||
bytes,
|
||||
target,
|
||||
typeNames === undefined ? {} : { typeNames },
|
||||
);
|
||||
}
|
||||
managedTargetInputSchema.parse(parameters);
|
||||
if (operation === "inspect_managed_artifact") {
|
||||
return inspectManagedArtifactBytes(bytes, target);
|
||||
}
|
||||
if (operation === "inspect_managed_native_boundaries") {
|
||||
return inspectManagedNativeBoundariesBytes(bytes, target);
|
||||
}
|
||||
return inspectManagedMembersBytes(bytes, target);
|
||||
return inspectManagedNativeBoundariesBytes(bytes, target);
|
||||
};
|
||||
|
||||
const managedLocations = (
|
||||
|
||||
@@ -52,6 +52,7 @@ export const registerManagedTools = (
|
||||
path,
|
||||
operation,
|
||||
executionOptions?.signal,
|
||||
parameters,
|
||||
);
|
||||
if (execution.ok && typeof requestedPath === "string")
|
||||
selectedManagedPath = requestedPath;
|
||||
|
||||
@@ -113,6 +113,7 @@ it("runs every managed static inspection independently of an active native targe
|
||||
operation: "inspect_managed_members",
|
||||
subject: { local_path: otherPath, format: "pe" },
|
||||
});
|
||||
await verifyManagedTypeSelection(client, path, otherPath);
|
||||
const boundaries = structured(
|
||||
await client.callTool({
|
||||
name: "inspect_managed_native_boundaries",
|
||||
@@ -445,6 +446,58 @@ const verifyImport = async (
|
||||
});
|
||||
};
|
||||
|
||||
/** Selected type names reach the provider and unmatched names stay visible. */
|
||||
const verifyManagedTypeSelection = async (
|
||||
client: Client,
|
||||
path: string,
|
||||
otherPath: string,
|
||||
): Promise<void> => {
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_managed_members",
|
||||
);
|
||||
if (advertised === undefined) throw new Error("missing managed member tool");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
expect(ajv.validateSchema(advertised.inputSchema)).toBe(true);
|
||||
const validate = ajv.compile(advertised.inputSchema);
|
||||
expect(
|
||||
validate({ path, type_names: ["Fixture.Program", "Missing.Type"] }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validate({ path, type_names: ["Fixture.Program", "Fixture.Program"] }),
|
||||
).toBe(false);
|
||||
const selected = structured(
|
||||
await client.callTool({
|
||||
name: "inspect_managed_members",
|
||||
arguments: { path, type_names: ["Fixture.Program", "Missing.Type"] },
|
||||
}),
|
||||
);
|
||||
expect(selected).toMatchObject({
|
||||
parameters: { path, type_names: ["Fixture.Program", "Missing.Type"] },
|
||||
normalized_result: {
|
||||
types: [expect.objectContaining({ full_name: "Fixture.Program" })],
|
||||
methods: [expect.objectContaining({ declaring_type: "Fixture.Program" })],
|
||||
selection: {
|
||||
type_names: ["Fixture.Program", "Missing.Type"],
|
||||
unmatched_type_names: ["Missing.Type"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const repeatedSelection = await client.callTool({
|
||||
name: "inspect_managed_members",
|
||||
arguments: { path, type_names: ["Fixture.Program", "Fixture.Program"] },
|
||||
});
|
||||
expect(repeatedSelection.isError).toBe(true);
|
||||
const reselectedOtherTarget = structured(
|
||||
await client.callTool({
|
||||
name: "inspect_managed_artifact",
|
||||
arguments: { path: otherPath },
|
||||
}),
|
||||
);
|
||||
expect(reselectedOtherTarget).toMatchObject({
|
||||
subject: { local_path: otherPath, format: "pe" },
|
||||
});
|
||||
};
|
||||
|
||||
const structured = (result: CallToolResult): Record<string, unknown> => {
|
||||
if (result.isError === true) return parseMcpToolError(result);
|
||||
if (
|
||||
|
||||
@@ -459,9 +459,83 @@ describe("managed member comparison of ambiguous undecoded signatures", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const inspect = (bytes: Buffer, path: string) => {
|
||||
describe("managed member comparison of selected types", () => {
|
||||
it("keeps members outside the other side's selection unknown", () => {
|
||||
const left = inspect(buildManagedPeFixture(), "/tmp/left-all.dll");
|
||||
const right = inspect(buildManagedPeFixture(), "/tmp/right-selected.dll", [
|
||||
"Missing.Type",
|
||||
]);
|
||||
const result = compareManagedMembers(
|
||||
{ evidenceId: left.evidenceId, result: left.result },
|
||||
{ evidenceId: right.evidenceId, result: right.result },
|
||||
);
|
||||
|
||||
expect(result.summary).toMatchObject({ added: 0, removed: 0, unknown: 2 });
|
||||
expect(result.methods[0]).toMatchObject({
|
||||
status: "unknown",
|
||||
left: { token: "0x06000001" },
|
||||
right: null,
|
||||
});
|
||||
expect(result.coverage.status).toBe("partial");
|
||||
expect(result.limitations).toContain(
|
||||
"The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not remap members across types neither side observed", () => {
|
||||
const left = inspect(buildManagedPeFixture(), "/tmp/left-a.dll", [
|
||||
"Fixture.Program",
|
||||
]);
|
||||
const right = inspect(
|
||||
buildManagedPeFixture({
|
||||
mvid: Buffer.from("00112233445566778899aabbccddeefe", "hex"),
|
||||
typeName: "A",
|
||||
typeNamespace: "X",
|
||||
}),
|
||||
"/tmp/right-b.dll",
|
||||
["X.A"],
|
||||
);
|
||||
const result = compareManagedMembers(
|
||||
{ evidenceId: left.evidenceId, result: left.result },
|
||||
{ evidenceId: right.evidenceId, result: right.result },
|
||||
);
|
||||
|
||||
expect(result.matching.exact_il_signature).toBe(0);
|
||||
expect(result.summary).toMatchObject({
|
||||
unchanged: 0,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
unknown: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("compares two identical selections within those types", () => {
|
||||
const left = inspect(buildManagedPeFixture(), "/tmp/left-sel.dll", [
|
||||
"Fixture.Program",
|
||||
]);
|
||||
const right = inspect(buildManagedPeFixture(), "/tmp/right-sel.dll", [
|
||||
"Fixture.Program",
|
||||
]);
|
||||
const result = compareManagedMembers(
|
||||
{ evidenceId: left.evidenceId, result: left.result },
|
||||
{ evidenceId: right.evidenceId, result: right.result },
|
||||
);
|
||||
|
||||
expect(result.methods[0]).toMatchObject({ status: "unchanged" });
|
||||
expect(result.coverage.status).toBe("complete-within-inputs");
|
||||
expect(result.limitations).toContain(
|
||||
"Both observations cover only the selected declaring types (Fixture.Program); member totals and statuses describe those types, not the whole assembly.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const inspect = (bytes: Buffer, path: string, typeNames?: string[]) => {
|
||||
const target = managedPeFixtureTarget(bytes, path);
|
||||
const result = inspectManagedMembersBytes(bytes, target);
|
||||
const result = inspectManagedMembersBytes(
|
||||
bytes,
|
||||
target,
|
||||
typeNames === undefined ? {} : { typeNames },
|
||||
);
|
||||
return {
|
||||
result,
|
||||
evidenceId: `ev_${hash(Buffer.from(path))}`,
|
||||
|
||||
@@ -629,3 +629,55 @@ describe("managed application graph coverage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed application graph type selection", () => {
|
||||
const project = (result: Parameters<typeof createEvidence>[2]["result"]) => {
|
||||
const bytes = buildManagedPeFixture();
|
||||
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedInterop.exe");
|
||||
const projection = projectManagedApplicationGraphEvidence({
|
||||
managed_members: createEvidence(binary, MANAGED_STATIC_PROVIDER, {
|
||||
operation: "inspect_managed_members",
|
||||
parameters: {},
|
||||
result,
|
||||
rawResult: null,
|
||||
limitations: [],
|
||||
}),
|
||||
});
|
||||
if (!projection.ok) throw new Error("projection failed");
|
||||
return managedApplicationGraphResultSchema.parse(
|
||||
parseEvidence(projection.value).normalized_result,
|
||||
);
|
||||
};
|
||||
|
||||
it("marks a graph from selected member inspection partial", () => {
|
||||
const bytes = buildManagedPeFixture();
|
||||
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedInterop.exe");
|
||||
const selected = inspectManagedMembersBytes(bytes, binary, {
|
||||
typeNames: ["Fixture.Program"],
|
||||
});
|
||||
|
||||
expect(selected.coverage.state).toBe("complete");
|
||||
const result = project(selected);
|
||||
expect(result.coverage).toMatchObject({ status: "partial" });
|
||||
expect(result.limitations).toContain(
|
||||
"Managed member Evidence selected only these declaring types: Fixture.Program; type, method, and field nodes of other types are omitted, not absent.",
|
||||
);
|
||||
expect(result.limitations).not.toContain(
|
||||
"At least one supplied managed Evidence record has partial coverage.",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts member Evidence recorded before type selection existed", () => {
|
||||
const bytes = buildManagedPeFixture();
|
||||
const binary = managedPeFixtureTarget(bytes, "/fixture/ManagedInterop.exe");
|
||||
const { selection: _selection, ...legacy } = inspectManagedMembersBytes(
|
||||
bytes,
|
||||
binary,
|
||||
);
|
||||
|
||||
expect(managedMemberInspectionSchema.parse(legacy).selection).toBeNull();
|
||||
expect(project(legacy).coverage).toMatchObject({
|
||||
status: "complete-within-inputs",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user