feat(managed)!: show readable type names in member signatures (#5309)

feat(managed): show readable type names in member signatures

Co-authored-by: gensokyo-dev <[email protected]>
This commit is contained in:
LN Bodicherlaandgensokyo-dev authored and GitHub committed 2026-10-29 14:34:42 +08:00
1 parent afc18131f4
commit c9bf6a9df7
12 files changed
+566 -75

No files matched your search

+12 -2
View File
@@ -476,8 +476,18 @@ raw digest stands in. Missing or unreadable TypeDef and TypeRef operands are
reported as coverage issues. Invalid NestedClass rows retain their failure
reasons and withhold TypeDef signature identities; type names and nesting are
parsed once by their shared owner. Each current inspection records
`signature_resolution: "type-names-v1"`, even when none of its operands can be
resolved. Historical Evidence defaults this attestation to `null`. When either
`signature_resolution: "type-names-v2"`, even when none of its operands can be
resolved. Version 2 also appends readable names to signature operands, for
example `class:7772 (AnalysisJob)`. Coded indexes remain build-local references;
names are display text and do not change raw or resolved signature digests.
Nested names use `Outer/Inner`. Generic instantiations keep their decoded
arguments, for example ``class:9 (System.Collections.Generic.List`1)<string>``;
custom modifiers are named too. Valid but unresolved TypeSpecs, invalid scopes,
unreadable type names, cycles and nesting beyond the cap keep their raw operand
spelling. Invalid or missing metadata coordinates retain the existing malformed
signature result and source issue; rendering does not bypass validation.
Version 1 Evidence remains readable and compares using the same digest policy.
Historical Evidence defaults this attestation to `null`. When either
observation predates resolution, both sides compare raw digests, which pair only where type rows agree,
and the comparison reports that limitation. The signature is exact whether or
not REA decoded it, so undecoded signatures pair by that tuple too, but they
+2 -2
View File
@@ -514,11 +514,11 @@ export const managedArtifactInspectionSchema = z.object({
/** Provider-neutral, execution-free metadata/signature/IL member inspection. */
export const managedMemberInspectionSchema = z.object({
signature_resolution: z
.literal("type-names-v1")
.enum(["type-names-v1", "type-names-v2"])
.nullable()
.default(null)
.describe(
"Signature type-name resolution was examined, including unresolved operands; null means no attestation, including historical or capacity-limited Evidence.",
"Signature type-name resolution was examined, including unresolved operands; v2 also renders readable operand names, v1 retains raw operand text; null means no attestation, including historical or capacity-limited Evidence.",
),
artifact: managedArtifactReferenceSchema,
module: moduleIdentitySchema.nullable(),
+2 -2
View File
@@ -2504,12 +2504,12 @@ it("decodes generic instantiation member signatures as complete coverage", () =>
expect(result.methods[0]?.signature).toMatchObject({
parse_status: "decoded",
return_type: "void",
parameter_types: ["class:9<string>"],
parameter_types: ["class:9 (System.Func`1)<string>"],
issue: null,
});
expect(result.fields[0]?.signature).toMatchObject({
parse_status: "decoded",
field_type: "valuetype:13<var:0,mvar:1>",
field_type: "valuetype:13 (System.ValueTuple`2)<var:0,mvar:1>",
});
expect(result.coverage).toMatchObject({ state: "complete", issues: [] });
});
+12 -6
View File
@@ -34,7 +34,7 @@ import {
type DecodedTypeDefinition,
} from "./ManagedMemberInspectorCore.js";
import { edgeTargets } from "./ManagedMethodSpecs.js";
import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js";
import { managedSignatureTypeNames } from "./ManagedSignatureTypeNames.js";
import { typeNameLookup } from "./ManagedTypeNames.js";
import { managedTypeReferences } from "./ManagedTypeReferences.js";
import { parseUserStrings } from "./ManagedUserStrings.js";
@@ -81,7 +81,7 @@ const unavailable = (
requires_mvid: null,
},
types: [],
signature_resolution: "type-names-v1",
signature_resolution: "type-names-v2",
nesting_read: false,
exported_types: null,
user_strings: null,
@@ -211,8 +211,11 @@ export const inspectManagedMembersBytes = (
typeNames === undefined ? 0 : ranges.length - parsedTypes.types.length;
appendManagedParseIssues(issues, parsedTypes.issues);
const typeReferences = managedTypeReferences(bytes, layout, issues);
const { operand: signatureTypeName, declaration: declarationTypeName } =
managedTypeNameDigests(layout, issues, parsedTypes, typeReferences);
const {
operand: signatureTypeName,
declaration: declarationTypeName,
displayName: displayTypeName,
} = managedSignatureTypeNames(layout, issues, parsedTypes, typeReferences);
const types = parsedTypes.types
.filter(({ type }) => selected === null || selected.has(type.token))
.map(({ row, type }) => {
@@ -282,6 +285,7 @@ export const inspectManagedMembersBytes = (
appendManagedParseIssues(issues, exportedTypes.issues);
const fields = parseFields(bytes, layout, ranges, memberLists, {
signatureTypeName,
displayTypeName,
declarationTypeName,
selected,
});
@@ -295,6 +299,7 @@ export const inspectManagedMembersBytes = (
const memberRefs = parseMemberRefs(bytes, layout, {
typeName: resolveTypeName,
signatureTypeName,
displayTypeName,
declarationTypeName,
});
appendManagedParseIssues(issues, memberRefs.issues);
@@ -304,6 +309,7 @@ export const inspectManagedMembersBytes = (
pe,
ranges,
signatureTypeName,
displayTypeName,
declarationTypeName,
memberRefKinds: new Map(
memberRefs.refs.flatMap((memberRef) =>
@@ -394,7 +400,7 @@ export const inspectManagedMembersBytes = (
},
assembly: inventory.assembly,
types,
signature_resolution: "type-names-v1",
signature_resolution: "type-names-v2",
nesting_read: parsedTypes.nesting.complete,
exported_types: exportedTypes.exportedTypes,
fields: fields.fields,
@@ -418,7 +424,7 @@ 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.",
"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 including VARARG sentinels, custom modifier, and typed reference forms; class and valuetype operands keep their encoded TypeDefOrRefOrSpec values.",
"Member signatures decode ECMA-335 primitive, class, valuetype, generic instantiation, generic variable, pointer, byref, single-dimension and general array, function pointer including VARARG sentinels, custom modifier, and typed reference forms; type operands keep their encoded TypeDefOrRefOrSpec values and append readable TypeDef/TypeRef names when resolved.",
...selectionLimitations,
],
},
+39 -4
View File
@@ -578,6 +578,7 @@ const readTypeSignature = (
layout,
operands,
onElement,
displayTypeName,
}: {
/** Validate each type operand against these tables, as specs require. */
readonly layout?: ManagedMetadataLayout;
@@ -585,6 +586,7 @@ const readTypeSignature = (
readonly operands?: TypeOperand[];
/** Observe parsed element kinds without reinterpreting signature bytes. */
readonly onElement?: (kind: number) => void;
readonly displayTypeName?: SignatureDisplayTypeName | undefined;
} = {},
): {
readonly value: string;
@@ -592,6 +594,15 @@ const readTypeSignature = (
readonly nominalGenericType: NominalGenericType | null;
} => {
const stack: TypeContinuation[] = [];
const namedOperand = (
kind: string,
operand: Parameters<SignatureTypeName>[0],
): string => {
// Enclosing continuations copy names during rendering; admit first.
const name = displayTypeName?.(operand, (stack.length + 1) * 2);
const raw = `${kind}:${String(operand.value)}`;
return name === undefined || name === null ? raw : `${raw} (${name})`;
};
let cursor = offset;
let nominalGenericType: NominalGenericType | null = null;
let pinned = false;
@@ -676,7 +687,7 @@ const readTypeSignature = (
);
const token = readTypeOperand(blob, cursor + 1, layout);
operands?.push({ offset: cursor + 1, ...token });
const modifier = `${kind === 0x1f ? "modreq" : "modopt"}:${String(token.value)}`;
const modifier = namedOperand(kind === 0x1f ? "modreq" : "modopt", token);
// A modifier frame on top was pushed by the directly preceding byte;
// keep an adjacent run together and in source order.
const top = stack.at(-1);
@@ -686,7 +697,7 @@ const readTypeSignature = (
} else if (kind === 0x11 || kind === 0x12) {
const token = readTypeOperand(blob, cursor + 1, layout);
operands?.push({ offset: cursor + 1, ...token });
value = `${kind === 0x11 ? "valuetype" : "class"}:${String(token.value)}`;
value = namedOperand(kind === 0x11 ? "valuetype" : "class", token);
cursor = token.next;
} else if (kind === 0x1e || kind === 0x13) {
const variable = readCompressed(blob, cursor + 1);
@@ -731,7 +742,10 @@ const readTypeSignature = (
"generic instantiation has no type arguments",
token.next,
);
const head = `${generic === 0x11 ? "valuetype" : "class"}:${String(token.value)}`;
const head = namedOperand(
generic === 0x11 ? "valuetype" : "class",
token,
);
stack.push({
kind: "list",
remaining: argumentCount.value,
@@ -975,6 +989,7 @@ const decodeSignature = (
operands: TypeOperand[] = [],
issueSink?: (issue: ManagedParseIssue) => void,
scope = "signature",
displayTypeName?: SignatureDisplayTypeName,
): DecodedSignature => {
let kind: DecodedSignature["kind"] = "unknown";
try {
@@ -987,6 +1002,7 @@ const decodeSignature = (
kind = "field";
const fieldType = readTypeSignature(blob, 1, "field", {
operands,
displayTypeName,
...(layout === undefined ? {} : { layout }),
});
return {
@@ -1006,6 +1022,7 @@ const decodeSignature = (
kind = "method";
const returnType = readTypeSignature(blob, header.next, "return", {
operands,
displayTypeName,
...(layout === undefined ? {} : { layout }),
});
let offset = returnType.next;
@@ -1025,6 +1042,7 @@ const decodeSignature = (
}
const parameter = readTypeSignature(blob, offset, "parameter", {
operands,
displayTypeName,
...(layout === undefined ? {} : { layout }),
});
parameters.push(parameter.value);
@@ -1110,6 +1128,12 @@ export type SignatureTypeName = (
export type DeclarationTypeName = (table: 1 | 2, row: number) => Buffer | null;
/** Display an already decoded operand, admitting its transient copies first. */
export type SignatureDisplayTypeName = (
operand: Parameters<SignatureTypeName>[0],
copies: number,
) => string | null;
/**
* Each type operand is replaced by 0xff, which never starts a compressed
* coded index, then the 32-byte name digest; a blob without type operands
@@ -1165,13 +1189,21 @@ export const signature = (
blob: Buffer,
rawSha256?: string,
typeName: SignatureTypeName = () => null,
displayTypeName?: SignatureDisplayTypeName,
): ManagedSignature => {
const operands: TypeOperand[] = [];
return projectSignature(
blob,
rawSha256,
typeName,
decodeSignature(blob, undefined, operands),
decodeSignature(
blob,
undefined,
operands,
undefined,
"signature",
displayTypeName,
),
operands,
);
};
@@ -1187,6 +1219,7 @@ export const metadataSignature = (
scope,
blobOffset,
expectedKind,
displayTypeName,
}: {
readonly rawSha256: string;
readonly typeName: SignatureTypeName;
@@ -1196,6 +1229,7 @@ export const metadataSignature = (
/** Artifact file offset of the blob's first byte. */
readonly blobOffset: number;
readonly expectedKind?: "field" | "method";
readonly displayTypeName?: SignatureDisplayTypeName | undefined;
},
): ManagedSignature => {
const operands: TypeOperand[] = [];
@@ -1205,6 +1239,7 @@ export const metadataSignature = (
operands,
(issue) => issues.push(artifactSignatureIssue(issue, scope, blobOffset)),
scope,
displayTypeName,
);
if (
expectedKind !== undefined &&
+20
View File
@@ -27,6 +27,7 @@ import {
type DecodedMethod,
type MemberCore,
type SignatureTypeName,
type SignatureDisplayTypeName,
type DeclarationTypeName,
type TypeRange,
} from "./ManagedMemberInspectorCore.js";
@@ -65,10 +66,12 @@ export const parseFields = (
memberLists: TypeMemberListFacts,
{
signatureTypeName,
displayTypeName,
declarationTypeName,
selected = null,
}: {
readonly signatureTypeName: SignatureTypeName;
readonly displayTypeName: SignatureDisplayTypeName;
readonly declarationTypeName: DeclarationTypeName;
readonly selected?: SelectedTypeTokens;
},
@@ -117,6 +120,7 @@ export const parseFields = (
sig,
layout,
signatureTypeName,
displayTypeName,
issues,
{ scope: `Field:${token}.signature`, kind: "field", artifact: bytes },
);
@@ -219,12 +223,14 @@ export const parseMemberRefs = (
{
typeName,
signatureTypeName,
displayTypeName,
declarationTypeName,
}: {
readonly typeName: (
reference: Pick<MetadataTokenFact, "table" | "row" | "token"> | null,
) => string | null;
readonly signatureTypeName: SignatureTypeName;
readonly displayTypeName: SignatureDisplayTypeName;
readonly declarationTypeName: DeclarationTypeName;
},
): {
@@ -268,6 +274,7 @@ export const parseMemberRefs = (
sig,
layout,
signatureTypeName,
displayTypeName,
issues,
{ scope: `MemberRef:${token}.signature`, artifact: bytes },
);
@@ -303,6 +310,7 @@ interface ParseMethodsInput {
readonly pe: ManagedPeLayout;
readonly ranges: readonly TypeRange[];
readonly signatureTypeName: SignatureTypeName;
readonly displayTypeName: SignatureDisplayTypeName;
readonly declarationTypeName: DeclarationTypeName;
readonly memberRefKinds: ReadonlyMap<string, "field" | "method">;
readonly memberLists: TypeMemberListFacts;
@@ -317,6 +325,7 @@ export const parseMethods = ({
pe,
ranges,
signatureTypeName,
displayTypeName,
declarationTypeName,
memberRefKinds,
memberLists,
@@ -396,6 +405,7 @@ export const parseMethods = ({
sig,
layout,
signatureTypeName,
displayTypeName,
issues,
{
scope: `MethodDef:${token}.signature`,
@@ -568,6 +578,7 @@ const budgetedSignature = (
bytes: Buffer,
layout: ManagedMetadataLayout,
signatureTypeName: SignatureTypeName,
displayTypeName: SignatureDisplayTypeName,
issues: ManagedParseIssue[],
owner: {
readonly scope: string;
@@ -586,6 +597,15 @@ const budgetedSignature = (
metadataSignature(bytes, {
rawSha256: budget.digest(bytes, "sha256"),
typeName: signatureTypeName,
displayTypeName: (operand, copies) => {
const name = displayTypeName(operand, copies);
if (name !== null)
budget.admitTransient(
(name.length * 6 + 2) * copies,
layout.blob?.offset ?? layout.rootOffset,
);
return name;
},
layout,
issues,
scope: owner.scope,
+1 -1
View File
@@ -2,7 +2,7 @@ import { readMemberReferenceRowFacts } from "./ManagedMemberReferenceRows.js";
import { readTypeDefinitionRowFacts } from "./ManagedTypeDefinitionRows.js";
import { managedDecodeBudget } from "./ManagedDecodeBudget.js";
import { managedTypeReferences } from "./ManagedTypeReferences.js";
import { MAX_TYPE_NESTING_DEPTH } from "./ManagedTypeNameDigests.js";
import { MAX_TYPE_NESTING_DEPTH } from "./ManagedSignatureTypeNames.js";
import { readManagedTypeNesting } from "./ManagedTypeNesting.js";
import { memberDeclaringTypeRowLookup } from "./ManagedMemberOwnership.js";
import type { TypeMemberListFacts } from "./ManagedTypeMemberLists.js";
+350 -5
View File
@@ -1,3 +1,4 @@
import { fc } from "@fast-check/vitest";
import { createHash } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
@@ -23,12 +24,14 @@ import {
managedPeFixtureTarget,
} from "./ManagedPe.fixture.js";
import { readManagedPeLayout } from "./ManagedPeReader.js";
import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js";
import { compareManagedMembers } from "../domain/managed/managedMemberComparison.js";
import { managedMemberInspectionSchema } from "../domain/managed/managedArtifact.js";
import { managedSignatureTypeNames } from "./ManagedSignatureTypeNames.js";
import { metadataCodedIndexCoordinate } from "./ManagedMetadataHeaps.js";
import { managedTypeReferences } from "./ManagedTypeReferences.js";
import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js";
// A top-level type's name digest, as ManagedTypeNameDigests forms it.
// A top-level type's name digest, as ManagedSignatureTypeNames forms it.
const nameDigest = (name: string): Buffer => {
const dot = name.lastIndexOf(".");
return createHash("sha256")
@@ -149,7 +152,7 @@ describe("metadata type-name digest ownership", () => {
const bytes = buildManagedPeFixture();
const layout = metadataLayout(bytes);
const issues: ManagedParseIssue[] = [];
const names = managedTypeNameDigests(
const names = managedSignatureTypeNames(
layout,
issues,
{ types: [], nesting: { enclosing: new Map(), complete: true } },
@@ -187,7 +190,7 @@ describe("metadata type-name digest ownership", () => {
const layout = metadataLayout(bytes);
const budget = new ManagedDecodeBudget();
attachManagedDecodeBudget(layout, budget);
const typeNames = managedTypeNameDigests(
const typeNames = managedSignatureTypeNames(
layout,
[],
{
@@ -206,6 +209,7 @@ describe("metadata type-name digest ownership", () => {
const reserve = vi.spyOn(budget, "reserve");
expect(typeNames.operand(operand)).toEqual(nameDigest("System.Uri"));
expect(typeNames.declaration(1, 2)).toEqual(nameDigest("System.Uri"));
expect(typeNames.displayName(operand)).toBe("System.Uri");
expect(reserve).not.toHaveBeenCalled();
});
@@ -246,7 +250,7 @@ describe("metadata type-name digest ownership", () => {
readTypeMemberListFacts(bytes, layout),
),
);
const resolve = managedTypeNameDigests(
const resolve = managedSignatureTypeNames(
layout,
issues,
parsedTypes,
@@ -404,3 +408,344 @@ describe("type operand names", () => {
},
);
});
const inspectDisplayFixture = (
options: Parameters<typeof buildManagedPeFixture>[0],
) => {
const bytes = buildManagedPeFixture({
typeSpecSignatures: [Buffer.from([0x08])],
...options,
});
return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes));
};
const displayReferences = [
{ namespace: "N", name: "G`1" },
{ namespace: "N", name: "Value" },
];
describe("readable signature operands", () => {
it("names fields, method returns and parameters, and member references", () => {
const result = inspectDisplayFixture({
fieldSignature: Buffer.from([0x06, 0x12, 0x04]),
methodSignature: Buffer.from([0x00, 0x01, 0x12, 0x04, 0x11, 0x09]),
memberRefSignature: Buffer.from([0x00, 0x01, 0x12, 0x09, 0x12, 0x04]),
typeReferences: displayReferences,
});
expect(result.signature_resolution).toBe("type-names-v2");
expect(result.fields[0]?.signature.field_type).toBe(
"class:4 (Fixture.Program)",
);
expect(result.methods[0]?.signature).toMatchObject({
return_type: "class:4 (Fixture.Program)",
parameter_types: ["valuetype:9 (N.G`1)"],
});
expect(result.member_refs[0]?.signature).toMatchObject({
return_type: "class:9 (N.G`1)",
parameter_types: ["class:4 (Fixture.Program)"],
});
expect(result.coverage).toEqual({ state: "complete", issues: [] });
});
it.each([
["class", [0x12, 0x09], "class:9 (N.G`1)"],
["value type", [0x11, 0x0d], "valuetype:13 (N.Value)"],
["pointer", [0x0f, 0x12, 0x09], "class:9 (N.G`1)*"],
["byref", [0x10, 0x12, 0x09], "class:9 (N.G`1)&"],
["vector", [0x1d, 0x12, 0x09], "class:9 (N.G`1)[]"],
[
"ranked array",
[0x14, 0x12, 0x09, 0x02, 0x00, 0x00],
"class:9 (N.G`1)[,]",
],
[
"required modifier",
[0x1f, 0x0d, 0x12, 0x09],
"class:9 (N.G`1) modreq:13 (N.Value)",
],
[
"optional modifier",
[0x20, 0x0d, 0x12, 0x09],
"class:9 (N.G`1) modopt:13 (N.Value)",
],
[
"generic argument",
[0x15, 0x12, 0x09, 0x01, 0x11, 0x0d],
"class:9 (N.G`1)<valuetype:13 (N.Value)>",
],
[
"generic variable",
[0x15, 0x12, 0x09, 0x01, 0x13, 0x00],
"class:9 (N.G`1)<var:0>",
],
[
"function pointer",
[0x1b, 0x00, 0x01, 0x12, 0x09, 0x11, 0x0d],
"fnptr[default](valuetype:13 (N.Value))->class:9 (N.G`1)",
],
[
"mixed resolved and unresolved",
[0x15, 0x12, 0x09, 0x01, 0x12, 0x06],
"class:9 (N.G`1)<class:6>",
],
])("preserves %s grammar and signature hashes", (_label, type, expected) => {
const blob = Buffer.from([0x06, ...(type as number[])]);
const result = inspectDisplayFixture({
fieldSignature: blob,
typeReferences: displayReferences,
});
const decoded = result.fields[0]?.signature;
const names = named({ 9: "N.G`1", 13: "N.Value" });
const original = signature(blob, undefined, names);
expect(decoded?.field_type).toBe(expected);
expect(decoded?.parse_status).toBe(original.parse_status);
expect(decoded?.raw_sha256).toBe(original.raw_sha256);
expect(decoded?.resolved_sha256).toBe(original.resolved_sha256);
});
});
describe("signature display name validity", () => {
it("resolves types outside the selected type inventory", () => {
const result = inspectDisplayFixture({
additionalEmptyTypes: 1,
fieldSignature: Buffer.from([0x06, 0x12, 0x04]),
});
expect(result.fields[0]?.signature.field_type).toBe(
"class:4 (Fixture.Empty0)",
);
const bytes = buildManagedPeFixture({
additionalEmptyTypes: 1,
fieldSignature: Buffer.from([0x06, 0x12, 0x04]),
});
const selected = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
{ typeNames: ["Fixture.Program"] },
);
expect(selected.fields[0]?.signature).toEqual(result.fields[0]?.signature);
});
it("uses the enclosing path for nested TypeDefs", () => {
const result = inspectDisplayFixture({
additionalEmptyTypes: 1,
nestedTypes: [[2, 1]],
fieldSignature: Buffer.from([0x06, 0x12, 0x08]),
});
expect(result.fields[0]?.signature.field_type).toBe(
"class:8 (Fixture.Empty0/Fixture.Program)",
);
});
it("uses the enclosing path for nested TypeRefs", () => {
const result = inspectDisplayFixture({
typeReferences: [
{ namespace: "N", name: "Outer" },
{ namespace: "", name: "Inner", enclosingRow: 2 },
],
fieldSignature: Buffer.from([0x06, 0x12, 0x0d]),
});
expect(result.fields[0]?.signature.field_type).toBe(
"class:13 (N.Outer/Inner)",
);
});
it.each(["", "名", "A.B", "G`1", "A (B)<C>/D", "\uFEFFName"])(
"preserves source name %j",
(name) => {
const result = inspectDisplayFixture({
typeReferences: [{ namespace: "", name }],
fieldSignature: Buffer.from([0x06, 0x12, 0x09]),
});
expect(result.fields[0]?.signature.field_type).toBe(`class:9 (${name})`);
},
);
it.each([
["TypeSpec", 0x06, "class:6", "decoded"],
["missing TypeRef", 0x25, null, "malformed"],
["missing TypeDef", 0x24, null, "malformed"],
["invalid tag", 0x07, null, "malformed"],
["null TypeDef", 0x00, null, "malformed"],
] as const)(
"preserves validity of unresolved %s operands",
(_label, operand, display, status) => {
const result = inspectDisplayFixture({
fieldSignature: Buffer.from([0x06, 0x12, operand]),
});
expect(result.fields[0]?.signature.field_type).toBe(display);
expect(result.fields[0]?.signature.parse_status).toBe(status);
expect(result.fields[0]?.signature.resolved_sha256).toBeNull();
},
);
it("keeps invalid-scope TypeRefs raw and retains their issue once", () => {
const result = inspectDisplayFixture({
typeRefScopeRaw: (99 << 2) | 2,
fieldSignature: Buffer.from([0x06, 0x12, 0x05]),
methodSignature: Buffer.from([0x00, 0x01, 0x12, 0x05, 0x12, 0x05]),
});
expect(result.fields[0]?.signature.field_type).toBe("class:5");
expect(result.methods[0]?.signature.parameter_types).toEqual(["class:5"]);
expect(result.coverage.issues).toHaveLength(1);
});
it("keeps cyclic TypeRefs raw", () => {
const result = inspectDisplayFixture({
typeReferences: [
{ namespace: "", name: "A", enclosingRow: 3 },
{ namespace: "", name: "B", enclosingRow: 2 },
],
fieldSignature: Buffer.from([0x06, 0x12, 0x09]),
});
expect(result.fields[0]?.signature.field_type).toBe("class:9");
expect(result.coverage.state).toBe("partial");
});
it.each([
["truncated", [0x06, 0x12], "malformed"],
["truncated compressed index", [0x06, 0x12, 0x80], "malformed"],
["unknown element", [0x06, 0x50], "unsupported"],
["trailing bytes", [0x06, 0x12, 0x04, 0x08], "partial"],
])("retains %s parse status and raw hash", (_label, bytes, status) => {
const blob = Buffer.from(bytes as number[]);
const result = inspectDisplayFixture({ fieldSignature: blob });
expect(result.fields[0]?.signature.parse_status).toBe(status);
expect(result.fields[0]?.signature.raw_sha256).toBe(
signature(blob).raw_sha256,
);
});
});
describe("signature display compatibility and capacity", () => {
it("reads historical v1 Evidence without relabelling its raw display", () => {
const current = inspectDisplayFixture({
fieldSignature: Buffer.from([0x06, 0x12, 0x04]),
});
const old = {
...current,
signature_resolution: "type-names-v1",
fields: current.fields.map((field) => ({
...field,
signature: { ...field.signature, field_type: "class:4" },
})),
};
const parsed = managedMemberInspectionSchema.parse(old);
const comparison = compareManagedMembers(
{ evidenceId: `ev_${"a".repeat(64)}`, result: parsed },
{ evidenceId: `ev_${"b".repeat(64)}`, result: current },
);
expect(comparison.fields[0]?.status).toBe("unchanged");
expect(comparison.methods[0]?.status).toBe("unchanged");
expect(parsed.signature_resolution).toBe("type-names-v1");
expect(parsed.fields[0]?.signature.field_type).toBe("class:4");
expect(parsed.fields[0]?.signature.resolved_sha256).toBe(
current.fields[0]?.signature.resolved_sha256,
);
});
it.each([64, 65])("bounds a real %i-level TypeRef display path", (depth) => {
const typeReferences = Array.from({ length: depth }, (_, index) => ({
namespace: "",
name: `T${index}`,
...(index === 0 ? {} : { enclosingRow: index + 1 }),
}));
const index = ((depth + 1) << 2) | 1;
const encoded = [0x80 | (index >> 8), index & 0xff];
const result = inspectDisplayFixture({
typeReferences,
fieldSignature: Buffer.from([0x06, 0x12, ...encoded]),
});
const expected =
depth === 64
? `class:${index} (${typeReferences.map((ref) => ref.name).join("/")})`
: `class:${index}`;
expect(result.fields[0]?.signature.field_type).toBe(expected);
});
it("bounds repeated display-name expansion before allocation", () => {
const bytes = buildManagedPeFixture({
typeReferences: [{ namespace: "N", name: "X".repeat(200_000) }],
methodSignature: Buffer.from([
0x00,
0x40,
0x01,
...Array.from({ length: 64 }, () => [0x12, 0x09]).flat(),
]),
});
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.coverage.state).not.toBe("complete");
expect(result.coverage.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "resource-limit" }),
]),
);
});
});
describe("signature display property regressions", () => {
const assertIdentity = (blob: Buffer) => {
const digest = ({ value }: Parameters<SignatureTypeName>[0]) =>
nameDigest(`N.Type${value}`);
const old = signature(blob, undefined, digest);
const current = signature(
blob,
undefined,
digest,
({ value }) => `N.Type${value}`,
);
const identity = ({
field_type: _field,
parameter_types: _parameters,
return_type: _return,
...rest
}: typeof old) => rest;
expect(identity(current)).toEqual(identity(old));
};
it("keeps parse status, hashes and counts for 1000 arbitrary blobs", () => {
fc.assert(
fc.property(fc.uint8Array({ maxLength: 128 }), (bytes) => {
assertIdentity(Buffer.from(bytes));
}),
{ seed: 5149, numRuns: 1000 },
);
});
it("keeps identity through 1000 generated valid nested signatures", () => {
fc.assert(
fc.property(
fc.array(
fc.constantFrom(
"pointer",
"vector",
"array",
"generic",
"modifier",
"fnptr",
),
{ maxLength: 30 },
),
fc.constantFrom([0x12, 0x09], [0x11, 0x0d], [0x08]),
(wrappers, leaf) => {
let type: number[] = [...leaf];
for (const wrapper of wrappers) {
if (wrapper === "pointer") type = [0x0f, ...type];
else if (wrapper === "vector") type = [0x1d, ...type];
else if (wrapper === "array")
type = [0x14, ...type, 0x02, 0x00, 0x00];
else if (wrapper === "generic")
type = [0x15, 0x12, 0x09, 0x01, ...type];
else if (wrapper === "modifier") type = [0x1f, 0x0d, ...type];
else type = [0x1b, 0x00, 0x00, ...type];
}
const blob = Buffer.from([0x06, ...type]);
expect(signature(blob).parse_status).toBe("decoded");
assertIdentity(blob);
},
),
{ seed: 5149, numRuns: 1000 },
);
});
});
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import {
MAX_TYPE_NESTING_DEPTH,
nestedTypeDigests,
} from "./ManagedTypeNameDigests.js";
nestedTypeNames,
} from "./ManagedSignatureTypeNames.js";
/** Rows 1..length, each nested in the row before it. */
const chain = (length: number) => {
@@ -18,14 +18,14 @@ const chain = (length: number) => {
}
: null;
};
return { calls, digest: nestedTypeDigests(link, () => undefined) };
return { calls, digest: nestedTypeNames(link, () => undefined) };
};
describe("nested signature type digests", () => {
it("reads each row of a chain within the nesting cap once", () => {
const { calls, digest } = chain(MAX_TYPE_NESTING_DEPTH);
for (let row = MAX_TYPE_NESTING_DEPTH; row >= 1; row -= 1)
expect(digest(row)).toBeInstanceOf(Buffer);
expect(digest(row)?.digest).toBeInstanceOf(Buffer);
expect(calls).toHaveLength(MAX_TYPE_NESTING_DEPTH);
});
@@ -35,7 +35,7 @@ describe("nested signature type digests", () => {
expect(digest(4_000)).toBeNull();
expect(calls.length).toBeLessThanOrEqual(MAX_TYPE_NESTING_DEPTH + 1);
expect(digest(MAX_TYPE_NESTING_DEPTH)).toBeInstanceOf(Buffer);
expect(digest(MAX_TYPE_NESTING_DEPTH)?.digest).toBeInstanceOf(Buffer);
expect(digest(MAX_TYPE_NESTING_DEPTH + 1)).toBeNull();
});
@@ -61,7 +61,7 @@ describe("nested signature type digests", () => {
}
: null;
};
const digest = nestedTypeDigests(link, () => undefined);
const digest = nestedTypeNames(link, () => undefined);
for (let row = 1_001; row <= 1_000 + children; row += 1)
expect(digest(row)).toBeNull();
@@ -82,7 +82,7 @@ describe("nested signature type digests", () => {
});
it("does not name a type whose nesting is cyclic", () => {
const digest = nestedTypeDigests(
const digest = nestedTypeNames(
(row) => ({
namespace: "",
name: `T${String(row)}`,
@@ -96,18 +96,24 @@ describe("nested signature type digests", () => {
});
it("tells a nested type from a top-level type with its joined name", () => {
const nested = nestedTypeDigests(
const nested = nestedTypeNames(
(row) =>
row === 1
? { namespace: "", name: "Outer", enclosingRow: null }
: { namespace: "", name: "Inner", enclosingRow: 1 },
() => undefined,
);
const flat = nestedTypeDigests(
const flat = nestedTypeNames(
() => ({ namespace: "", name: "Outer/Inner", enclosingRow: null }),
() => undefined,
);
expect(nested(2)).not.toEqual(flat(1));
const nestedName = nested(2);
const flatName = flat(1);
if (nestedName === null || flatName === null)
throw new Error("Expected both type names to resolve");
expect(nestedName.name).toBe("Outer/Inner");
expect(flatName.name).toBe(nestedName.name);
expect(nestedName.digest.equals(flatName.digest)).toBe(false);
});
});
@@ -39,6 +39,7 @@ const NAME_SEPARATOR = Buffer.from([0]);
interface NamedRow {
readonly digest: Buffer;
readonly name: string;
readonly depth: number;
}
@@ -51,10 +52,10 @@ interface NamedRow {
* A gap, a cycle, or nesting deeper than `MAX_TYPE_NESTING_DEPTH` has no
* digest. `retain` is charged once per row read and once per remembered row.
*/
export const nestedTypeDigests = (
export const nestedTypeNames = (
link: (row: number) => TypeLink | null,
retain: () => void,
): ((row: number) => Buffer | null) => {
retain: (bytes?: number) => void,
): ((row: number) => NamedRow | null) => {
const named = new Map<number, NamedRow | null>();
const remember = (row: number, value: NamedRow | null): void => {
retain();
@@ -124,10 +125,21 @@ export const nestedTypeDigests = (
.update(step.link.namespace)
.update(NAME_SEPARATOR)
.update(step.link.name);
inner = { digest: hash.digest(), depth };
// Charge each retained expanded path before allocation, including parents.
const ownLength =
step.link.namespace.length +
step.link.name.length +
Number(step.link.namespace.length > 0);
retain(((inner?.name.length ?? -1) + 1 + ownLength) * 6 + 2);
const ownName =
step.link.namespace.length === 0
? step.link.name
: `${step.link.namespace}.${step.link.name}`;
const name = inner === null ? ownName : `${inner.name}/${ownName}`;
inner = { digest: hash.digest(), name, depth };
remember(step.row, inner);
}
return exceeded ? null : (inner?.digest ?? null);
return exceeded ? null : inner;
};
};
@@ -142,7 +154,7 @@ export const nestedTypeDigests = (
* retained in `issues`, once per row. Each coded index and each row is
* named and digested once, so repeated operands cost a lookup.
*/
export const managedTypeNameDigests = (
export const managedSignatureTypeNames = (
layout: ManagedMetadataLayout,
issues: ManagedParseIssue[],
parsedTypes: Pick<ReturnType<typeof parseTypes>, "types" | "nesting">,
@@ -150,12 +162,16 @@ export const managedTypeNameDigests = (
): {
readonly operand: SignatureTypeName;
readonly declaration: DeclarationTypeName;
readonly displayName: (
operand: Parameters<SignatureTypeName>[0],
) => string | null;
} => {
const retain = () => managedDecodeBudget(layout).reserve(128, null);
const retain = (bytes = 128) =>
managedDecodeBudget(layout).reserve(bytes, null);
managedDecodeBudget(layout).reserve(parsedTypes.types.length * 128, null);
const types = new Map<number, ManagedType>();
for (const { row, type } of parsedTypes.types) types.set(row, type);
const definitions = nestedTypeDigests((row) => {
const definitions = nestedTypeNames((row) => {
if (!parsedTypes.nesting.complete) return null;
const type = types.get(row);
if (
@@ -195,33 +211,35 @@ export const managedTypeNameDigests = (
enclosingRow,
};
}, retain);
const references = nestedTypeDigests((row) => {
const references = nestedTypeNames((row) => {
const value = reference(row);
return value?.scopeValid === true ? value : null;
}, retain);
const digests = new Map<number, Buffer | null>();
const digestFor = (
const names = new Map<number, NamedRow | null>();
const nameFor = (
key: number,
table: number | undefined,
row: number,
): Buffer | null => {
let digest = digests.get(key);
if (digest === undefined) {
): NamedRow | null => {
let named = names.get(key);
if (named === undefined) {
managedDecodeBudget(layout).reserve(256, null);
digest =
named =
table === TYPE_DEF
? definitions(row)
: table === TYPE_REF
? references(row)
: null;
digests.set(key, digest);
names.set(key, named);
}
return digest;
return named;
};
return {
operand: ({ value, coordinate }) =>
digestFor(value, coordinate.table, coordinate.row),
nameFor(value, coordinate.table, coordinate.row)?.digest ?? null,
declaration: (table, row) =>
digestFor(row * 4 + Number(table === TYPE_REF), table, row),
nameFor(row * 4 + Number(table === TYPE_REF), table, row)?.digest ?? null,
displayName: ({ value, coordinate }) =>
nameFor(value, coordinate.table, coordinate.row)?.name ?? null,
};
};
+44
View File
@@ -646,3 +646,47 @@ cliTest(
}
},
);
cliTest(
"managed CLI renders type names and preserves unresolved operands",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-managed-signature-names-");
const path = join(root, "names.dll");
await writeFile(
path,
buildManagedPeFixture({
fieldSignature: Buffer.from([0x06, 0x12, 0x04]),
typeSpecSignatures: [Buffer.from([0x08])],
methodSignature: Buffer.from([
0x00, 0x02, 0x12, 0x04, 0x12, 0x09, 0x12, 0x06,
]),
typeReferences: [{ namespace: "System", name: "Uri" }],
}),
);
const result = await cli.run({
arguments: ["inspect-managed-members", path, "--json"],
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
normalized_result: {
signature_resolution: "type-names-v2",
fields: [
expect.objectContaining({
signature: expect.objectContaining({
field_type: "class:4 (Fixture.Program)",
}),
}),
],
methods: [
expect.objectContaining({
signature: expect.objectContaining({
return_type: "class:4 (Fixture.Program)",
parameter_types: ["class:9 (System.Uri)", "class:6"],
resolved_sha256: null,
}),
}),
],
},
});
},
);
@@ -146,6 +146,29 @@ const verifyProjection = async ({
});
};
// ldarg.0; ldfld field; call member ref; ldstr "ok"; pop; ret
const SUMMARY_METHOD_BODY = Buffer.from([
(18 << 2) | 2,
0x02,
0x7b,
1,
0,
0,
0x04,
0x28,
1,
0,
0,
0x0a,
0x72,
1,
0,
0,
0x70,
0x26,
0x2a,
]);
it("summarizes selected members and pages every original collection after the file disappears", async () => {
const directory = await createTestTempDirectory("rea-member-summary-");
const path = join(directory, "fixture.dll");
@@ -153,28 +176,9 @@ it("summarizes selected members and pages every original collection after the fi
path,
buildManagedPeFixture({
methodRowCount: 2,
// ldarg.0; ldfld field; call member ref; ldstr "ok"; pop; ret
ilBody: Buffer.from([
(18 << 2) | 2,
0x02,
0x7b,
1,
0,
0,
0x04,
0x28,
1,
0,
0,
0x0a,
0x72,
1,
0,
0,
0x70,
0x26,
0x2a,
]),
fieldSignature: Buffer.from([0x06, 0x12, 0x04]),
methodSignature: Buffer.from([0x00, 0x00, 0x12, 0x04]),
ilBody: SUMMARY_METHOD_BODY,
userStringHeap: Buffer.from([0, 5, 0x6f, 0, 0x6b, 0, 0]),
exportedTypes: [
{
@@ -225,13 +229,16 @@ it("summarizes selected members and pages every original collection after the fi
expect(parent.parameters).toEqual({ path, type_names });
expect(receipt.evidence_links).toEqual([parent.evidence_id]);
const report = managedMemberInspectionSchema.parse(parent.normalized_result);
expect(report.fields[0]?.signature.field_type).toBe(
"class:4 (Fixture.Program)",
);
expect(view.summary.selection).toEqual({
type_names,
unmatched_type_names: ["Missing.Type"],
});
expect(view.summary.coverage).toEqual(report.coverage);
expect(view.summary.method_rows).toEqual({ offset: 0, count: 2, total: 2 });
expect(view.summary.signature_resolution).toBe("type-names-v1");
expect(view.summary.signature_resolution).toBe("type-names-v2");
expect(view.summary.identity_scope).toEqual(report.identity_scope);
expect(view.summary).not.toHaveProperty("methods");
for (const detail of [undefined, "complete"]) {
@@ -276,7 +283,7 @@ it("summarizes selected members and pages every original collection after the fi
if (page.kind !== "managed-members")
throw new Error("Unexpected page kind");
expect(page.page.collection).toBe(collection);
expect(page.signature_resolution).toBe("type-names-v1");
expect(page.signature_resolution).toBe("type-names-v2");
expect(page.selection).toEqual(report.selection);
expect(page.coverage.total).toBe(rows.length);
expect(page.method_rows).toEqual(report.method_rows);