From 1438422dfedf1de29368b595e09a04f1fc10750d Mon Sep 17 00:00:00 2001 From: JANG BYUNGKUN Date: Thu, 15 Oct 2026 00:20:07 +0900 Subject: [PATCH] fix(managed): preserve enclosing identity in member comparisons (#3079) Keep same-named nested members paired by their source-owned enclosing identity, including selected inventories that omit the parent TypeDefs. Share the cached, bounded nominal type-name digest owner with signature resolution; preserve current unknown identity through matching, remapping and absence instead of repairing it with display names or historical compatibility. Verification: 457 related tests passed after preserving TypeDef visibility/parent applicability and coherent fixtures; the complete static/architecture gate and real pinned-SDK MCP nested/official .NET DLL workflows passed after a sigrok-experiment-only main refresh. Documentation checks passed after the compatibility fix. Full CoreLib inspection retains the pre-existing explicit representation limit. No target assembly execution; local verification used two CPUs and an 8 GiB RSS guard. Co-authored-by: 8rulerstar <190670431+8rulerstar@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- docs/managed-code-analysis.md | 27 +- .../managed/managedWorkflowExamples.ts | 1 + src/domain/analysisView/managedMemberView.ts | 3 + src/domain/managed/managedArtifact.ts | 22 ++ .../managedMemberComparisonCoverage.ts | 4 + .../managed/managedMemberComparisonItems.ts | 122 +++++- .../managed/managedMemberComparisonMatch.ts | 127 ++++--- src/dotnet/ManagedMemberInspector.test.ts | 106 ++++++ src/dotnet/ManagedMemberInspector.ts | 34 +- src/dotnet/ManagedMemberInspectorCore.ts | 8 + src/dotnet/ManagedPe.fixture.ts | 133 ++++++- src/dotnet/ManagedSignatureResolution.test.ts | 8 +- ...test.ts => ManagedTypeNameDigests.test.ts} | 2 +- ...TypeNames.ts => ManagedTypeNameDigests.ts} | 23 +- .../managed/managedMemberComparison.test.ts | 355 +++++++++++++++++- ...anagedMemberComparisonFromMetadata.test.ts | 127 +++++-- 16 files changed, 977 insertions(+), 125 deletions(-) rename src/dotnet/{ManagedSignatureTypeNames.test.ts => ManagedTypeNameDigests.test.ts} (98%) rename src/dotnet/{ManagedSignatureTypeNames.ts => ManagedTypeNameDigests.ts} (89%) diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index def21a2916..548d3e43d1 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -167,7 +167,14 @@ MCP uses `inspect_managed_members` with 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. +types with that name. Each type's `enclosing_type_token` records its enclosing +TypeDef. `nesting_read` distinguishes complete source rows (true), incomplete +current rows (false), and historical Evidence (null). A null enclosing token +alone does not establish that a type is top-level. +Each selected type also retains `name_sha256`, a framed digest of its namespace, +name and enclosing names. It uses the same bounded, cached owner as signature +type operands and remains available when selection omits the enclosing types. +It adds one fixed-size digest per returned type rather than repeated name paths. The result preserves the requested names in `selection.type_names` and reports unmatched names separately. If metadata names are unreadable, limitations retain @@ -243,7 +250,14 @@ native/runtime questions retain their own authority and coverage. Names and semantic labels do not establish durable member identity. Comparison preserves observed differences, structural inferences, and unknowns separately. -Cross-version matching first pairs members of the same declared type and name: +Cross-version matching first pairs members of the same declared type and name. +A current declared type uses its observed `name_sha256`, so same-named nested +types in different enclosing types stay distinct even in selected inventories. +Malformed, missing, contradictory, cyclic or over-deep enclosing identity remains unknown; +the comparison preserves display names without using them to repair that gap. +Historical observations retain an explicitly limited bare-name mode when either +side predates the identity facts. A known current identity gap still remains +unknown beside a historical observation. Matching then pairs methods by exact CIL/signature identity, then by signature, then by structural body shape; fields by signature. Exact CIL/signature identity requires equal raw CIL digests and decoded fingerprints. Equal decoded @@ -253,10 +267,11 @@ renamed or moved member across declared types or names: a method by exact CIL/signature identity or structural body shape, a field by exact decoded signature. A remap never takes a member whose own declared type and name remain unpaired on the other side, so a member is never taken from its own changed -counterpart. A member whose declaring type is unresolved could belong to any -type, so it pairs by name only when no other member shares its exact -CIL/signature key, never by structural shape alone, and any remap group it -joins stays ambiguous. The exact-signature key uses names only as part of that +counterpart. A member whose declared identity is unresolved could belong to any type. It +never makes a declared-identity pair, and any remap group it joins stays +ambiguous. Unresolved same-named counterparts prevent definitive added/removed +claims, while observed names and tokens remain available in the result. +The exact-signature key uses names only as part of that full tuple; names alone never select a pair. Duplicate tuples remain ambiguous rather than being paired by token order. Every round compares signatures by `resolved_sha256`: the raw signature with each TypeDef or TypeRef operand diff --git a/src/contracts/managed/managedWorkflowExamples.ts b/src/contracts/managed/managedWorkflowExamples.ts index 96f1c72671..83ed496902 100644 --- a/src/contracts/managed/managedWorkflowExamples.ts +++ b/src/contracts/managed/managedWorkflowExamples.ts @@ -48,6 +48,7 @@ const emptyManagedMembers = ( requires_mvid: mvid, }, types: [], + nesting_read: true, exported_types: [], fields: [], methods: [], diff --git a/src/domain/analysisView/managedMemberView.ts b/src/domain/analysisView/managedMemberView.ts index 75eb14a0f7..d6eff1dd36 100644 --- a/src/domain/analysisView/managedMemberView.ts +++ b/src/domain/analysisView/managedMemberView.ts @@ -65,6 +65,7 @@ const nullableCount = z.number().int().nonnegative().nullable(); /** Keep identity, selection and coverage; member collections remain retained. */ export const managedMemberSummarySchema = z.strictObject({ signature_resolution: member.signature_resolution, + nesting_read: member.nesting_read, artifact: member.artifact, module: member.module, metadata: member.metadata, @@ -92,6 +93,7 @@ export const managedMemberSummarySchema = z.strictObject({ export const managedMembersViewContextSchema = z.strictObject({ signature_resolution: member.signature_resolution, + nesting_read: member.nesting_read, identity_scope: member.identity_scope, selection: member.selection.unwrap(), source_metadata_status: member.metadata.shape.status, @@ -272,6 +274,7 @@ export const projectManagedMemberView = ( page, ...shared, signature_resolution: report.signature_resolution, + nesting_read: report.nesting_read, identity_scope: report.identity_scope, selection: report.selection, source_metadata_status: report.metadata.status, diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index d41f01026a..1988050d13 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -103,8 +103,20 @@ const managedTypeSchema = z.object({ namespace: z.string(), name: z.string(), full_name: z.string(), + name_sha256: digestSchema + .nullable() + .default(null) + .describe( + "Framed nominal namespace/name/enclosing-name digest, independent of build-local rows. Null for historical Evidence or unresolved nesting; defining assemblies and CLR runtime identity are not established.", + ), flags: z.number().int().min(0).max(0xffff_ffff), extends_token: tokenSchema.nullable(), + enclosing_type_token: tokenSchema + .nullable() + .default(null) + .describe( + "Observed enclosing TypeDef from NestedClass; null when no enclosing entry was available, which alone does not establish top-level identity.", + ), field_list: z.object({ first_row: z.number().int().min(1).nullable(), last_row: z.number().int().min(0).nullable(), @@ -473,6 +485,16 @@ export const managedMemberInspectionSchema = z.object({ requires_mvid: cliMetadataGuidSchema.nullable(), }), types: z.array(managedTypeSchema), + // Member Evidence recorded before NestedClass rows were read omits this + // field; null is historical uncertainty. False means a current NestedClass + // row was unreadable or invalid, not that no nested types exist. + nesting_read: z + .boolean() + .nullable() + .default(null) + .describe( + "Whether all NestedClass rows were read and valid. Null for historical Evidence; false means current nesting is incomplete. Individual name digests can remain unresolved for missing, cyclic or over-deep enclosing chains.", + ), // Member Evidence recorded before ExportedType rows were read omits this // field; null means the table was not examined, not that it was empty. exported_types: z.array(managedExportedTypeSchema).nullable().default(null), diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index cb87004743..ee6021f5c7 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -144,6 +144,10 @@ export const comparisonLimitations = ( limitations.push( "At least one managed member observation does not attest signature type-name resolution, so signatures compare by raw bytes, which name types by build-local metadata row.", ); + if (left.nesting_read === null || right.nesting_read === null) + limitations.push( + "At least one managed member observation predates enclosing-name identity. The legacy bare-name comparison does not establish the enclosing owner of a nested type.", + ); 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.", diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index ea252a46fa..c4bd1e65e2 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -5,7 +5,6 @@ import type { ManagedMemberComparisonResult } from "./managedMemberComparison.js import { type ManagedFieldMatches, type ManagedMethodMatches, - identityOf, sha256, } from "./managedMemberComparisonMatch.js"; import { signatureIdentity } from "./managedMemberSignatureIdentity.js"; @@ -54,6 +53,9 @@ interface UndecodedOneSidedNames { readonly resolved: boolean; readonly undecoded: Readonly>>; readonly unresolved: Readonly>>; + readonly unknownOwners: Readonly< + Record<"left" | "right", ReadonlySet> + >; } const methodDimensions = ( @@ -144,17 +146,20 @@ const unmatchedComparison = (): UnmatchedComparison => ({ const methodOnlyItem = ( member: OneSided, + identity: string, + identityUnavailable: boolean, context: ComparisonItemContext, undecoded: UndecodedOneSidedNames, ): MethodItem => { if (member.left !== undefined) { - const limitations = absenceLimitations(member.left, { + const limitations = absenceLimitations(member.left, identity, { oppositeComplete: inventoryCovers( context.rightComplete, context.rightSelection, member.left, ), unpaired: undecoded, + ownIdentityUnavailable: identityUnavailable, opposite: "right", inventory: "method", }); @@ -170,13 +175,14 @@ const methodOnlyItem = ( }; } - const limitations = absenceLimitations(member.right, { + const limitations = absenceLimitations(member.right, identity, { oppositeComplete: inventoryCovers( context.leftComplete, context.leftSelection, member.right, ), unpaired: undecoded, + ownIdentityUnavailable: identityUnavailable, opposite: "left", inventory: "method", }); @@ -204,13 +210,25 @@ const methodOnlyItem = ( */ const absenceLimitations = ( member: Method | Field, + identity: string, opposite: { readonly oppositeComplete: boolean; + readonly ownIdentityUnavailable: boolean; readonly unpaired: UndecodedOneSidedNames; readonly opposite: "left" | "right"; readonly inventory: "method" | "field"; }, ): string[] => [ + ...(opposite.ownIdentityUnavailable + ? [ + "declaring-type-name-not-resolved: Declared type identity could not be resolved; a bare display name cannot establish a counterpart or absence.", + ] + : []), + ...(opposite.unpaired.unknownOwners[opposite.opposite].has(member.name) + ? [ + `counterpart-declaring-type-name-not-resolved: The ${opposite.opposite} ${opposite.inventory} inventory has an unpaired same-named member with unresolved enclosing-name identity, so absence was not established.`, + ] + : []), ...(opposite.oppositeComplete ? [] : [ @@ -222,7 +240,7 @@ const absenceLimitations = ( `signature-not-decoded: The member's ${member.signature.parse_status} signature was compared only by exact declared type, name, and signature bytes, so a changed or renamed counterpart could not be sought.`, ]), ...(member.signature.parse_status === "decoded" && - opposite.unpaired.undecoded[opposite.opposite].has(identityOf(member)) + opposite.unpaired.undecoded[opposite.opposite].has(identity) ? [ `counterpart-signature-not-decoded: The ${opposite.opposite} ${opposite.inventory} inventory has an unpaired member with the same declared type and name whose signature was not decoded, so it may be this member with a changed signature.`, ] @@ -234,7 +252,7 @@ const absenceLimitations = ( : []), ...(member.signature.parse_status === "decoded" && !unresolved(member, opposite.unpaired.resolved) && - opposite.unpaired.unresolved[opposite.opposite].has(identityOf(member)) + opposite.unpaired.unresolved[opposite.opposite].has(identity) ? [ `counterpart-signature-not-resolved: The ${opposite.opposite} ${opposite.inventory} inventory has an unpaired member with the same declared type and name whose signature names a type REA could not name, so it may be this member.`, ] @@ -266,6 +284,7 @@ const pairedSignatureLimitations = ( interface Unpaired { readonly item: Method | Field; readonly identity: string; + readonly identityUnavailable: boolean; } const undecodedOneSidedNames = ( @@ -301,6 +320,16 @@ const undecodedOneSidedNames = ( const notResolved = (item: Method | Field) => unresolved(item, resolved); return { resolved, + unknownOwners: { + left: new Set( + sides.left.filter((x) => x.identityUnavailable).map((x) => x.item.name), + ), + right: new Set( + sides.right + .filter((x) => x.identityUnavailable) + .map((x) => x.item.name), + ), + }, undecoded: { left: names(sides.left, undecoded), right: names(sides.right, undecoded), @@ -314,17 +343,20 @@ const undecodedOneSidedNames = ( const fieldOnlyItem = ( member: OneSided, + identity: string, + identityUnavailable: boolean, context: ComparisonItemContext, undecoded: UndecodedOneSidedNames, ): FieldItem => { if (member.left !== undefined) { - const limitations = absenceLimitations(member.left, { + const limitations = absenceLimitations(member.left, identity, { oppositeComplete: inventoryCovers( context.rightComplete, context.rightSelection, member.left, ), unpaired: undecoded, + ownIdentityUnavailable: identityUnavailable, opposite: "right", inventory: "field", }); @@ -339,13 +371,14 @@ const fieldOnlyItem = ( }; } - const limitations = absenceLimitations(member.right, { + const limitations = absenceLimitations(member.right, identity, { oppositeComplete: inventoryCovers( context.leftComplete, context.leftSelection, member.right, ), unpaired: undecoded, + ownIdentityUnavailable: identityUnavailable, opposite: "left", inventory: "field", }); @@ -367,14 +400,17 @@ const fieldOnlyItem = ( const ambiguityLimitation = ( members: "methods" | "fields", basis: MethodItem["match"]["basis"], + identityUnavailable: boolean, ): string => - basis === "exact-signature" - ? `Multiple managed ${members} share the same signature; REA did not guess declared ownership or a token remap.` - : basis === "exact-il-signature" - ? "Multiple managed methods share the same CIL and signature; REA did not guess a token remap." - : members === "methods" - ? "Multiple managed methods share the same structural shape; REA did not guess a token remap." - : "Multiple managed fields share the same signature; REA did not guess a token remap."; + identityUnavailable + ? "declaring-type-name-not-resolved: Declared type identity remains unresolved; REA did not guess a counterpart from display names, signatures or CIL." + : basis === "exact-signature" + ? `Multiple managed ${members} share the same signature; REA did not guess declared ownership or a token remap.` + : basis === "exact-il-signature" + ? "Multiple managed methods share the same CIL and signature; REA did not guess a token remap." + : members === "methods" + ? "Multiple managed methods share the same structural shape; REA did not guess a token remap." + : "Multiple managed fields share the same signature; REA did not guess a token remap."; export const buildMethodItems = ( matches: ManagedMethodMatches, @@ -446,7 +482,14 @@ export const buildMethodItems = ( }, dimensions: ["availability"], evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: [ambiguityLimitation("methods", ambiguous.basis)], + limitations: [ + ambiguityLimitation( + "methods", + ambiguous.basis, + ambiguous.left.some((item) => item.identityUnavailable) || + ambiguous.right.some((item) => item.identityUnavailable), + ), + ], }); } const undecoded = undecodedOneSidedNames( @@ -454,9 +497,25 @@ export const buildMethodItems = ( context.signatureResolutionExamined, ); for (const item of matches.leftOnly) - items.push(methodOnlyItem({ left: item.item }, context, undecoded)); + items.push( + methodOnlyItem( + { left: item.item }, + item.identity, + item.identityUnavailable, + context, + undecoded, + ), + ); for (const item of matches.rightOnly) - items.push(methodOnlyItem({ right: item.item }, context, undecoded)); + items.push( + methodOnlyItem( + { right: item.item }, + item.identity, + item.identityUnavailable, + context, + undecoded, + ), + ); return items; }; @@ -515,15 +574,38 @@ export const buildFieldItems = ( candidate_right_tokens: ambiguous.right.map(({ item }) => item.token), }, evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: [ambiguityLimitation("fields", ambiguous.basis)], + limitations: [ + ambiguityLimitation( + "fields", + ambiguous.basis, + ambiguous.left.some((item) => item.identityUnavailable) || + ambiguous.right.some((item) => item.identityUnavailable), + ), + ], }); const undecoded = undecodedOneSidedNames( matches, context.signatureResolutionExamined, ); for (const item of matches.leftOnly) - items.push(fieldOnlyItem({ left: item.item }, context, undecoded)); + items.push( + fieldOnlyItem( + { left: item.item }, + item.identity, + item.identityUnavailable, + context, + undecoded, + ), + ); for (const item of matches.rightOnly) - items.push(fieldOnlyItem({ right: item.item }, context, undecoded)); + items.push( + fieldOnlyItem( + { right: item.item }, + item.identity, + item.identityUnavailable, + context, + undecoded, + ), + ); return items; }; diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index a9b2035997..aa932500f9 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -35,6 +35,8 @@ interface Keyed { readonly item: Item; /** Declared type and name; an unresolved type is null, the name remains. */ readonly identity: string; + /** Declared identity could not be resolved; display facts remain. */ + readonly identityUnavailable: boolean; readonly exactKey: string | null; readonly signatureKey: string | null; /** The name-free exact signature, resolved where both sides resolve it. */ @@ -55,9 +57,13 @@ interface Ambiguous { readonly basis: ConcreteMatchBasis; } -const keyMethod = (item: Method): Keyed => ({ +/** A declared type: a historical bare name or a tagged observed nominal digest. */ +type DeclaredName = string | string[] | null; +type DeclaredNameOf = (item: Method | Field) => DeclaredName; +const keyMethod = (item: Method, declared: DeclaredNameOf): Keyed => ({ item, - identity: identityOf(item), + identity: identityOf({ declaring_type: declared(item), name: item.name }), + identityUnavailable: declared(item) === null, signatureId: signatureIdentity(item.signature), exactKey: item.body.status === "present" && @@ -72,12 +78,15 @@ const keyMethod = (item: Method): Keyed => ({ : null, // The blob, with type operands named, is the exact signature; decoding // only gates the shape keys. - signatureKey: sha256([ - "method-signature", - item.declaring_type, - item.name, - signatureIdentity(item.signature), - ]), + signatureKey: + declared(item) === null + ? null + : sha256([ + "method-signature", + declared(item), + item.name, + signatureIdentity(item.signature), + ]), structuralKey: item.signature.parse_status === "decoded" && item.body.status === "present" ? sha256([ @@ -106,9 +115,10 @@ const keyMethod = (item: Method): Keyed => ({ : null, }); -const keyField = (item: Field): Keyed => ({ +const keyField = (item: Field, declared: DeclaredNameOf): Keyed => ({ item, - identity: identityOf(item), + identity: identityOf({ declaring_type: declared(item), name: item.name }), + identityUnavailable: declared(item) === null, signatureId: signatureIdentity(item.signature), exactKey: item.signature.parse_status === "decoded" @@ -116,12 +126,15 @@ const keyField = (item: Field): Keyed => ({ : null, // Only an undecoded field reaches this round: a decoded field with the // same signature already met its counterpart in the exact round. - signatureKey: sha256([ - "field-signature", - item.declaring_type, - item.name, - signatureIdentity(item.signature), - ]), + signatureKey: + declared(item) === null + ? null + : sha256([ + "field-signature", + declared(item), + item.name, + signatureIdentity(item.signature), + ]), structuralKey: sha256([ "field-structural", item.signature.kind, @@ -151,7 +164,7 @@ const matchMethods = ( basis: "structural-method-shape", // An unresolved owner has no declared shape match to prove. key: declaredAs((keyed) => - keyed.item.declaring_type === null ? null : keyed.structuralKey, + keyed.identityUnavailable ? null : keyed.structuralKey, ), competitionKey: ({ structuralKey }) => structuralKey, }, @@ -222,7 +235,7 @@ export const identityOf = ({ declaring_type, name, }: { - readonly declaring_type: string | null; + readonly declaring_type: DeclaredName; readonly name: string; }): string => sha256([declaring_type, name]); @@ -231,7 +244,9 @@ const declaredAs = (key: (item: Keyed) => string | null) => (item: Keyed): string | null => { const value = key(item); - return value === null ? null : sha256([item.identity, value]); + return value === null || item.identityUnavailable + ? null + : sha256([item.identity, value]); }; const matchByKeys = ({ @@ -254,7 +269,7 @@ const matchByKeys = ({ const competitionKey = round.competitionKey ?? ((item) => item.exactKey); const unpairedLeft = unpairedOf(left, pairedLeft); const unpairedRight = unpairedOf(right, pairedRight); - const declarable = ownerlessDeclarable( + const declarable = declaredCandidates( unpairedLeft, unpairedRight, competitionKey, @@ -288,7 +303,7 @@ const matchByKeys = ({ const refused = round.remap === true && [...leftItems, ...rightItems].some( - ({ item }) => item.declaring_type === null, + ({ identityUnavailable }) => identityUnavailable, ); if (!refused && leftItems.length === 1 && rightItems.length === 1) { const [leftItem] = leftItems; @@ -401,7 +416,7 @@ const ownerlessContested = ( const key = competitionIdentityKey(member, keyOf(member)); if (key === null) continue; seen.set(key, (seen.get(key) ?? 0) + 1); - if (member.item.declaring_type === null) ownerless.add(key); + if (member.identityUnavailable) ownerless.add(key); } for (const key of ownerless) if ((seen.get(key) ?? 0) > 1) contested.add(key); @@ -429,44 +444,23 @@ const remappable = ( return ( (key !== null && contested.has(competitionIdentityKey(member, key) ?? "")) || - member.item.declaring_type === null || + member.identityUnavailable || !kept.has(member.identity) ); }); }; -/** - * Whether a member may pair in a declared-identity round. A member with an - * unresolved owner may, only when no other member on either side shares its - * selected evidence key: its owner might be any of them, so a shared key - * leaves it to the remap rounds, which keep such a group ambiguous. The - * resolved members that share such a key stay out too, so they cannot pair and - * consume the candidate the ownerless member might own. - */ -const ownerlessDeclarable = ( +/** A declared round needs a resolved owner and no unresolved contender. */ +const declaredCandidates = ( left: readonly Keyed[], right: readonly Keyed[], keyOf: (item: Keyed) => string | null, ): ((member: Keyed) => boolean) => { - const count = (side: readonly Keyed[]) => { - const seen = new Map(); - for (const member of side) { - const key = keyOf(member); - if (key !== null) seen.set(key, (seen.get(key) ?? 0) + 1); - } - return seen; - }; - const leftSeen = count(left); - const rightSeen = count(right); const contested = ownerlessContested([left, right], keyOf); return (member) => { - const key = keyOf(member); - return key !== null && - contested.has(competitionIdentityKey(member, key) ?? "") - ? false - : member.item.declaring_type !== null || - key === null || - ((leftSeen.get(key) ?? 0) <= 1 && (rightSeen.get(key) ?? 0) <= 1); + if (member.identityUnavailable) return false; + const key = competitionIdentityKey(member, keyOf(member)); + return key === null || !contested.has(key); }; }; @@ -509,21 +503,22 @@ const matchObserved = ( left: readonly Keyed[], right: readonly Keyed[], ) => ReturnType>, - key: (item: Item) => Keyed, + keyLeft: (item: Item) => Keyed, + keyRight: (item: Item) => Keyed, ): ReturnType> => { const matched = match( - left.filter(sides.inRight).map(key), - right.filter(sides.inLeft).map(key), + left.filter(sides.inRight).map(keyLeft), + right.filter(sides.inLeft).map(keyRight), ); return { ...matched, leftOnly: [ ...matched.leftOnly, - ...left.filter((item) => !sides.inRight(item)).map(key), + ...left.filter((item) => !sides.inRight(item)).map(keyLeft), ], rightOnly: [ ...matched.rightOnly, - ...right.filter((item) => !sides.inLeft(item)).map(key), + ...right.filter((item) => !sides.inLeft(item)).map(keyRight), ], }; }; @@ -537,20 +532,40 @@ export const keyMembers = ( } => { const [left, right] = comparableSides(observedLeft, observedRight); const sides = { inLeft: observedBy(left), inRight: observedBy(right) }; + const modern = + left.result.nesting_read !== null && right.result.nesting_read !== null; + const declared = (inspection: ManagedMemberInspection): DeclaredNameOf => { + const names = new Map( + inspection.types.map((type) => [type.token, type.name_sha256]), + ); + return (item) => { + if (inspection.nesting_read === false) return null; + const digest = names.get(item.declaring_type_token ?? ""); + // A historical counterpart cannot repair an observed current name gap. + if (inspection.nesting_read !== null && digest == null) return null; + // Historical observations retain their explicitly limited bare-name mode. + if (!modern) return item.declaring_type; + return digest == null ? null : ["nominal-name-sha256", digest]; + }; + }; + const declaredLeft = declared(left.result); + const declaredRight = declared(right.result); return { methodMatches: matchObserved( left.result.methods, right.result.methods, sides, matchMethods, - keyMethod, + (item) => keyMethod(item, declaredLeft), + (item) => keyMethod(item, declaredRight), ), fieldMatches: matchObserved( left.result.fields, right.result.fields, sides, matchFields, - keyField, + (item) => keyField(item, declaredLeft), + (item) => keyField(item, declaredRight), ), }; }; diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 924679511b..e059579b36 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -21,6 +21,7 @@ import { comparisonLimitations } from "../domain/managed/managedMemberComparison import { buildManagedPeFixture as buildMultiMethodManagedPeFixture } from "../../scripts/fixtures/managed/pe.mjs"; import { + buildManagedNestedTypeFixture, buildManagedPeFixture, buildNativePeFixture, fixtureUserStringHeap, @@ -525,6 +526,111 @@ describe("managed MethodSpec call targets", () => { }); }); +describe("managed nested types", () => { + const inspectNested = ( + options: Parameters[0] = {}, + ) => { + const bytes = buildManagedNestedTypeFixture(options); + return inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes, "/fixture/nested.dll"), + ); + }; + + it("reports the enclosing type of each nested type", () => { + const result = inspectNested(); + + expect( + result.types.map(({ full_name, enclosing_type_token }) => [ + full_name, + enclosing_type_token, + ]), + ).toEqual([ + ["", null], + ["Fixture.A", null], + ["Fixture.B", null], + ["Inner", "0x02000002"], + ["Inner", "0x02000003"], + ["Leaf", "0x02000004"], + ]); + expect(result.nesting_read).toBe(true); + expect(result.coverage.issues).toEqual([]); + }); + + it.each([2, 3, 4, 5, 6, 7])( + "keeps nested visibility %i unresolved when its parent entry is missing", + (visibility) => { + const bytes = buildManagedNestedTypeFixture({ nestedClassRows: [] }); + const target = () => managedPeFixtureTarget(bytes, "/fixture/nested.dll"); + const row = inspectManagedMembersBytes(bytes, target()).types.find( + (type) => type.token === "0x02000004", + )!.row_offset; + bytes.writeUInt32LE((bytes.readUInt32LE(row) & ~7) | visibility, row); + const result = inspectManagedMembersBytes(bytes, target()); + expect(result.nesting_read).toBe(true); + expect( + result.types.find((type) => type.token === "0x02000004"), + ).toMatchObject({ enclosing_type_token: null, name_sha256: null }); + expect(result.coverage.issues).toContainEqual( + expect.objectContaining({ + code: "invalid-row", + scope: "metadata.TypeDef:0x02000004", + offset: row, + }), + ); + }, + ); + + it("retains a selected child's contradictory parent issue exactly once", () => { + const bytes = buildManagedNestedTypeFixture(); + const target = () => managedPeFixtureTarget(bytes, "/fixture/nested.dll"); + const row = inspectManagedMembersBytes(bytes, target()).types.find( + (type) => type.token === "0x02000004", + )!.row_offset; + bytes.writeUInt32LE((bytes.readUInt32LE(row) & ~7) | 1, row); + const result = inspectManagedMembersBytes(bytes, target(), { + typeNames: ["Leaf"], + }); + expect(result.types).toHaveLength(1); + expect(result.types[0]?.name_sha256).toBeNull(); + expect(result.coverage.state).toBe("partial"); + expect( + result.coverage.issues.filter( + (issue) => issue.scope === "metadata.TypeDef:0x02000004", + ), + ).toEqual([expect.objectContaining({ code: "invalid-row", offset: row })]); + }); + + it("reports NestedClass rows that do not pair distinct existing types once", () => { + const result = inspectNested({ + nestedClassRows: [ + [4, 4], + [5, 3], + [5, 2], + [6, 9], + [0, 2], + [4, 0], + [9, 2], + ], + }); + + expect( + result.types.map(({ enclosing_type_token }) => enclosing_type_token), + ).toEqual([null, null, null, null, "0x02000003", null]); + expect(result.nesting_read).toBe(false); + expect( + result.coverage.issues.map(({ code, scope }) => [code, scope]), + ).toEqual([ + ["invalid-row", "metadata.NestedClass:0x29000001"], + ["invalid-row", "metadata.NestedClass:0x29000003"], + ["invalid-row", "metadata.NestedClass:0x29000004"], + ["invalid-row", "metadata.NestedClass:0x29000005"], + ["invalid-row", "metadata.NestedClass:0x29000006"], + ["invalid-row", "metadata.NestedClass:0x29000007"], + ]); + }); +}); + describe("managed exported types", () => { it("reports type forwarders and nested exported types with their implementation scope", () => { const bytes = buildManagedPeFixture({ diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 9a70f0cd5f..36f6137ba1 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -29,7 +29,7 @@ import { type TypeRange, } from "./ManagedMemberInspectorCore.js"; import { edgeTargets } from "./ManagedMethodSpecs.js"; -import { signatureTypeNames } from "./ManagedSignatureTypeNames.js"; +import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js"; import { typeNameLookup } from "./ManagedTypeNames.js"; import { managedTypeReferences } from "./ManagedTypeReferences.js"; import { parseUserStrings } from "./ManagedUserStrings.js"; @@ -75,6 +75,7 @@ const unavailable = ( }, types: [], signature_resolution: "type-names-v1", + nesting_read: false, exported_types: null, user_strings: null, fields: [], @@ -208,7 +209,7 @@ export const inspectManagedMembersBytes = ( const exportedTypes = parseExportedTypes(bytes, layout); appendManagedParseIssues(issues, exportedTypes.issues); const typeReferences = managedTypeReferences(bytes, layout, issues); - const signatureTypeName = signatureTypeNames( + const signatureTypeName = managedTypeNameDigests( layout, issues, parsedTypes, @@ -251,7 +252,6 @@ export const inspectManagedMembersBytes = ( methods.core, ]); appendManagedParseIssues(issues, targets.issues); - const coverageIssues = uniqueManagedParseIssues(issues); const incompleteMembers = [...fields.fields, ...memberRefs.refs, ...methods.methods].some( (member) => member.signature.parse_status !== "decoded", @@ -266,6 +266,23 @@ 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( { @@ -286,11 +303,9 @@ export const inspectManagedMembersBytes = ( requires_artifact_sha256: target.sha256, requires_mvid: inventory.module?.mvid ?? null, }, - types: - selected === null - ? parsedTypes.types - : parsedTypes.types.filter((type) => selected.has(type.token)), + types, signature_resolution: "type-names-v1", + nesting_read: parsedTypes.nesting.complete, exported_types: exportedTypes.exportedTypes, fields: fields.fields, methods: methods.methods, @@ -307,6 +322,11 @@ 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.`, + ]), "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.", diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index eeb280c0ad..5e9a7ef40e 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -157,6 +157,10 @@ export const typeRanges = ( return ranges; }; +/** + * Enclosing TypeDef row of each nested TypeDef row, from the NestedClass + * table. Nesting is complete only when every row was read and valid. + */ export const parseTypes = ( bytes: Buffer, layout: ManagedMetadataLayout, @@ -216,6 +220,7 @@ export const parseTypes = ( offset: extendsOffset, detail: `TypeDef Extends coded index 0x${extendsRaw.toString(16)} is invalid: ${extendsReason}`, }); + const enclosingRow = nesting.enclosing.get(row); items.push({ token: metadataToken(2, row), row_offset: cursor.start, @@ -229,6 +234,9 @@ export const parseTypes = ( [2, 1, 27], layout.rowCounts, ), + enclosing_type_token: + enclosingRow === undefined ? null : metadataToken(2, enclosingRow), + name_sha256: null, field_list: { first_row: fieldRange.first, last_row: fieldRange.last, diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index 89347edbf7..7487544015 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -826,6 +826,9 @@ const fixtureMetadataRows = ({ indexes, heapSizes, }); + const nestedRows = new Set( + (options.nestedTypes ?? []).map(([nested]) => nested), + ); const rows = new Map([ [ 0, @@ -836,9 +839,10 @@ const fixtureMetadataRows = ({ typeRefTable(options, indexes, heapSizes, widths), [ 2, - [...indexes.additionalTypeNames, indexes.typeName].map((name) => + [...indexes.additionalTypeNames, indexes.typeName].map((name, index) => typeDefRow({ name, + flags: nestedRows.has(index + 1) ? 0x0010_0002 : 0x0010_0001, namespace: indexes.typeNamespace, fieldList: 1, methodList: 1, @@ -1326,6 +1330,133 @@ export const buildManagedMemberPointerFixture = ( return image; }; +/** + * Two top-level types A and B, each enclosing a nested type named Inner with + * one method M and one field F; the first Inner also encloses Leaf with a + * method M. Every method shares the fixture body. Swapping the enclosing + * types keeps every row but moves each Inner, with its Leaf, under the other + * top-level type. NestedClass rows pair a nested TypeDef row with its + * enclosing row and may be replaced to exercise malformed metadata. + */ +export const buildManagedNestedTypeFixture = ({ + swapEnclosing = false, + nestedClassRows, + literalTopLevelName, + topLevelTypes = false, +}: { + readonly swapEnclosing?: boolean; + readonly nestedClassRows?: readonly (readonly [number, number])[]; + readonly literalTopLevelName?: string; + readonly topLevelTypes?: boolean; +} = {}): Buffer => { + const image = buildManagedFixtureImage({}, (methodRva) => { + const strings = new StringHeap(); + const blobs = new BlobHeap(); + const moduleName = strings.add("Nested.dll"); + const assemblyName = strings.add("Fixture.Nested"); + const namespace = strings.add("Fixture"); + const typeNames = [ + "", + "A", + "B", + "Inner", + literalTopLevelName ?? (topLevelTypes ? "OtherInner" : "Inner"), + "Leaf", + ].map((name) => strings.add(name)); + const methodName = strings.add("M"); + const fieldName = strings.add("F"); + const objectName = strings.add("Object"); + const systemNamespace = strings.add("System"); + const runtimeName = strings.add("System.Runtime"); + const methodSignature = blobs.add(Buffer.from([0x20, 0, 0x08])); + const fieldSignature = blobs.add(Buffer.from([0x06, 0x08])); + // TypeDef rows 4 and 5 own one method and one field each; row 6 owns a method. + const memberList = [1, 1, 1, 1, 2, 3]; + const rows = new Map([ + [0, [moduleRow(moduleName, 2, 2)]], + [ + 1, + [ + Buffer.concat([ + u16((1 << 2) | 2), + u16(objectName), + u16(systemNamespace), + ]), + ], + ], + [ + 2, + typeNames.map((name, index) => + typeDefRow({ + name, + namespace: index === 1 || index === 2 ? namespace : 0, + fieldList: Math.min(memberList[index] ?? 1, 3), + methodList: memberList[index] ?? 1, + stringSize: 2, + typeDefOrRefSize: 2, + fieldListSize: 2, + methodListSize: 2, + extendsRaw: index === 0 ? 0 : (1 << 2) | 1, + flags: + index <= 2 || + topLevelTypes || + (index === 4 && literalTopLevelName !== undefined) + ? 0x0010_0001 + : 0x0010_0002, + }), + ), + ], + [4, [1, 2].map(() => fieldRow(fieldName, fieldSignature, 2, 2))], + [ + 6, + [1, 2, 3].map(() => + methodDefRow({ + name: methodName, + signature: methodSignature, + stringSize: 2, + blobSize: 2, + parameterSize: 2, + parameterList: 1, + rva: methodRva, + flags: 0x0086, + }), + ), + ], + [ + 41, + ( + nestedClassRows ?? + (topLevelTypes + ? [] + : [ + [4, swapEnclosing ? 3 : 2], + [5, swapEnclosing ? 2 : 3], + [6, 4], + ]) + ) + .filter( + ([nested]) => literalTopLevelName === undefined || nested !== 5, + ) + .map(([nested, enclosing]) => + Buffer.concat([u16(nested), u16(enclosing)]), + ), + ], + [32, [assemblyRow(assemblyName, 2, 2)]], + [35, [assemblyRefRow(runtimeName, 0, false, 2, 2)]], + ]); + return metadataRoot({ + tables: tablesStream(rows, 0n, 0), + strings: strings.toBuffer(), + guid: guidHeap(), + blob: blobs.toBuffer(), + }); + }); + // The shared PE shell defaults to an executable; this fixture is a DLL. + image.writeUInt16LE(image.readUInt16LE(0x84 + 18) | 0x2000, 0x84 + 18); + image.writeUInt32LE(0, 0x0200 + 20); + return image; +}; + /** Build a syntactically valid PE32 fixture with no CLI directory. */ export const buildNativePeFixture = (): Buffer => { const image = buildManagedPeFixture(); diff --git a/src/dotnet/ManagedSignatureResolution.test.ts b/src/dotnet/ManagedSignatureResolution.test.ts index d4711c33a6..4154b4fa90 100644 --- a/src/dotnet/ManagedSignatureResolution.test.ts +++ b/src/dotnet/ManagedSignatureResolution.test.ts @@ -21,11 +21,11 @@ import { managedPeFixtureTarget, } from "./ManagedPe.fixture.js"; import { readManagedPeLayout } from "./ManagedPeReader.js"; -import { signatureTypeNames } from "./ManagedSignatureTypeNames.js"; +import { managedTypeNameDigests } from "./ManagedTypeNameDigests.js"; import { managedTypeReferences } from "./ManagedTypeReferences.js"; import { readTypeMemberListFacts } from "./ManagedTypeMemberLists.js"; -// A top-level type's name digest, as ManagedSignatureTypeNames forms it. +// A top-level type's name digest, as ManagedTypeNameDigests forms it. const nameDigest = (name: string): Buffer => { const dot = name.lastIndexOf("."); return createHash("sha256") @@ -127,7 +127,7 @@ describe("resolved member signature digests", () => { const layout = metadataLayout(bytes); const budget = new ManagedDecodeBudget(); attachManagedDecodeBudget(layout, budget); - const typeName = signatureTypeNames( + const typeName = managedTypeNameDigests( layout, [], { @@ -176,7 +176,7 @@ describe("resolved member signature digests", () => { layout, typeRanges(bytes, layout, issues, readTypeMemberListFacts(bytes, layout)), ); - const resolve = signatureTypeNames( + const resolve = managedTypeNameDigests( layout, issues, parsedTypes, diff --git a/src/dotnet/ManagedSignatureTypeNames.test.ts b/src/dotnet/ManagedTypeNameDigests.test.ts similarity index 98% rename from src/dotnet/ManagedSignatureTypeNames.test.ts rename to src/dotnet/ManagedTypeNameDigests.test.ts index 4c6665e705..f309a722c3 100644 --- a/src/dotnet/ManagedSignatureTypeNames.test.ts +++ b/src/dotnet/ManagedTypeNameDigests.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { MAX_TYPE_NESTING_DEPTH, nestedTypeDigests, -} from "./ManagedSignatureTypeNames.js"; +} from "./ManagedTypeNameDigests.js"; /** Rows 1..length, each nested in the row before it. */ const chain = (length: number) => { diff --git a/src/dotnet/ManagedSignatureTypeNames.ts b/src/dotnet/ManagedTypeNameDigests.ts similarity index 89% rename from src/dotnet/ManagedSignatureTypeNames.ts rename to src/dotnet/ManagedTypeNameDigests.ts index 8313565d97..dff47b5f4d 100644 --- a/src/dotnet/ManagedSignatureTypeNames.ts +++ b/src/dotnet/ManagedTypeNameDigests.ts @@ -137,11 +137,11 @@ export const nestedTypeDigests = ( * the same nominal name, and a TypeRef's resolution scope assembly is not part of its * name, so a type forwarded between assemblies keeps its name. A TypeSpec, an * unreadable row, cyclic nesting, or nesting deeper than the cap has no name, - * so its signature has no resolved digest; an unreadable row's failure is + * so its declaration and signature operands have no resolved digest; an unreadable row's failure is * 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 signatureTypeNames = ( +export const managedTypeNameDigests = ( layout: ManagedMetadataLayout, issues: ManagedParseIssue[], parsedTypes: Pick, "types" | "nesting">, @@ -168,10 +168,27 @@ export const signatureTypeNames = ( return null; } if (type === undefined) return null; + const enclosingRow = parsedTypes.nesting.enclosing.get(row) ?? null; + // ECMA-335 TypeAttributes.VisibilityMask: 0/1 are top-level, 2..7 nested. + const nested = (type.flags & 0x7) >= 2; + if (nested !== (enclosingRow !== null)) { + managedDecodeBudget(layout).reserve(512, type.row_offset); + readManagedValue(() => { + throw managedFailure( + "invalid-row", + `metadata.TypeDef:${type.token}`, + nested + ? `Nested TypeDef ${type.token} has no NestedClass enclosing entry` + : `Top-level TypeDef ${type.token} has a NestedClass enclosing entry`, + type.row_offset, + ); + }, issues); + return null; + } return { namespace: type.namespace, name: type.name, - enclosingRow: parsedTypes.nesting.enclosing.get(row) ?? null, + enclosingRow, }; }, retain); const references = nestedTypeDigests((row) => { diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index 402f2c1223..ed3430731f 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -20,7 +20,10 @@ import { jsonValueSchema } from "../../../../src/domain/jsonValue.js"; import { compareManagedMembersInputSchema } from "../../../../src/domain/managed/managedMemberComparison.js"; import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; import { managedPeFixtureTarget } from "../../../../src/dotnet/ManagedPe.fixture.js"; -import { buildManagedPeFixture } from "../../../../src/dotnet/ManagedPe.fixture.js"; +import { + buildManagedNestedTypeFixture, + buildManagedPeFixture, +} from "../../../../src/dotnet/ManagedPe.fixture.js"; import { MAX_MANAGED_SNAPSHOT_BYTES, readManagedSnapshot, @@ -62,6 +65,356 @@ it.each([ }, ); +type Inspection = ReturnType; +const nestedInspection = ( + options: Parameters[0] = {}, + typeNames?: readonly string[], +) => { + const bytes = buildManagedNestedTypeFixture(options); + const target = managedPeFixtureTarget( + bytes, + `/fixture/nested-${JSON.stringify(options)}.dll`, + ); + return { + target, + inspection: inspectManagedMembersBytes( + bytes, + target, + typeNames === undefined ? {} : { typeNames }, + ), + }; +}; +// Historical Evidence has neither nesting facts nor declared-name digests. +const recordedBefore = ({ nesting_read, types, ...rest }: Inspection) => ({ + ...rest, + types: types.map(({ enclosing_type_token, name_sha256, ...type }) => type), +}); +const compare = ( + left: ReturnType, + right: ReturnType, + edit: { + readonly left?: (inspection: Inspection) => unknown; + readonly right?: (inspection: Inspection) => unknown; + } = {}, +) => { + const observe = ( + { target, inspection }: ReturnType, + change: ((inspection: Inspection) => unknown) | undefined, + ) => + createEvidence( + target, + { id: "nested-fixture", name: "Nested fixture", version: "1" }, + { + operation: "inspect_managed_members", + parameters: {}, + result: jsonValueSchema.parse(change?.(inspection) ?? inspection), + confidence: "observed", + authority: "shipped-artifact", + }, + ); + const result = compareManagedMembersEvidenceValidated( + compareManagedMembersInputSchema.parse({ + left: observe(left, edit.left), + right: observe(right, edit.right), + }), + ); + if (!result.ok) throw result.error; + return managedMemberComparisonResultSchema.parse( + result.value.normalized_result, + ); +}; +const withCilDigests = + (byToken: Readonly>) => (inspection: Inspection) => ({ + ...inspection, + methods: inspection.methods.map((method) => { + const digest = byToken[method.token]; + return digest === undefined || method.body.status !== "present" + ? method + : { + ...method, + body: { + ...method.body, + il_sha256: digest, + normalized_il_sha256: digest, + }, + }; + }), + }); + +const pairs = (comparison: ReturnType) => { + const of = ( + items: readonly { + readonly left: { readonly token: string } | null; + readonly right: { readonly token: string } | null; + readonly status: string; + readonly match: { readonly basis: string; readonly confidence: string }; + }[], + ) => + items.map(({ left, right, status, match }) => [ + left?.token ?? null, + right?.token ?? null, + status, + match.basis, + match.confidence, + ]); + return { methods: of(comparison.methods), fields: of(comparison.fields) }; +}; +const original = nestedInspection(); +const swapped = nestedInspection({ swapEnclosing: true }); + +it("does not equate missing nested parents with genuinely top-level types", () => { + const missing = nestedInspection({ nestedClassRows: [] }); + const topLevel = nestedInspection({ topLevelTypes: true }); + const result = compare(missing, topLevel); + expect(result.summary.unchanged).toBe(0); + expect( + [...result.methods, ...result.fields].every( + ({ status }) => status === "unknown", + ), + ).toBe(true); +}); + +it.each(["left", "right"] as const)( + "preserves a current enclosing-cycle gap beside %s historical Evidence", + (historicalSide) => { + const cyclic = nestedInspection({ + nestedClassRows: [ + [4, 5], + [5, 4], + [6, 4], + ], + }); + expect(cyclic.inspection.nesting_read).toBe(true); + const result = + historicalSide === "left" + ? compare(original, cyclic, { left: recordedBefore }) + : compare(cyclic, original, { right: recordedBefore }); + expect(result.summary.unchanged).toBe(0); + expect( + [...result.methods, ...result.fields].every( + ({ status }) => status === "unknown", + ), + ).toBe(true); + }, +); + +describe("managed member comparison of nested types", () => { + it("uses owned type identity while preserving omitted display labels", () => { + const result = compare(original, swapped, { + left: (inspection) => ({ + ...inspection, + methods: inspection.methods.map((method) => ({ + ...method, + declaring_type: null, + })), + fields: inspection.fields.map((field) => ({ + ...field, + declaring_type: null, + })), + }), + }); + expect(pairs(result)).toEqual(pairs(compare(original, swapped))); + expect( + [...result.methods, ...result.fields].every( + (item) => item.left?.declaring_type === null, + ), + ).toBe(true); + }); + it("retains nested identity when selected inventories omit enclosing types", () => { + const left = nestedInspection({}, ["Inner"]); + const right = nestedInspection({ swapEnclosing: true }, ["Inner"]); + expect(left.inspection.types.map(({ full_name }) => full_name)).toEqual([ + "Inner", + "Inner", + ]); + expect(pairs(compare(left, right))).toEqual({ + methods: pairs(compare(original, swapped)).methods.slice(0, 2), + fields: pairs(compare(original, swapped)).fields, + }); + }); + + it("withholds unchanged claims when current nesting rows are malformed", () => { + const malformed = nestedInspection({ + swapEnclosing: true, + nestedClassRows: [ + [4, 3], + [5, 2], + [6, 4], + [6, 5], + ], + }); + const comparison = compare(original, malformed); + expect(comparison.methods.every(({ status }) => status === "unknown")).toBe( + true, + ); + expect(comparison.fields.every(({ status }) => status === "unknown")).toBe( + true, + ); + }); + + it("withholds unchanged claims for a current enclosing cycle", () => { + const cyclic = nestedInspection({ + nestedClassRows: [ + [4, 5], + [5, 4], + [6, 4], + ], + }); + const comparison = compare(original, cyclic); + expect(comparison.methods.every(({ status }) => status === "unknown")).toBe( + true, + ); + expect(comparison.fields.every(({ status }) => status === "unknown")).toBe( + true, + ); + }); + + it("pairs members of same-named nested types by their enclosing types", () => { + // Moving each Inner under the other top-level type turns Fixture.A+Inner + // from TypeDef 4 on the left into TypeDef 5 on the right. + expect(pairs(compare(original, swapped))).toEqual({ + methods: [ + [ + "0x06000001", + "0x06000002", + "unchanged", + "exact-il-signature", + "exact", + ], + [ + "0x06000002", + "0x06000001", + "unchanged", + "exact-il-signature", + "exact", + ], + [ + "0x06000003", + "0x06000003", + "unchanged", + "exact-il-signature", + "exact", + ], + ], + fields: [ + ["0x04000001", "0x04000002", "unchanged", "field-signature", "exact"], + ["0x04000002", "0x04000001", "unchanged", "field-signature", "exact"], + ], + }); + }); + + it("reports bodies swapped between same-named nested types as changed", () => { + // Give each Inner.M its own CIL, then swap the bodies between A and B. + // Fixture.A+Inner.M is MethodDef 1 on the left and MethodDef 2 on the right. + const comparison = compare(original, swapped, { + left: withCilDigests({ + "0x06000001": "a".repeat(64), + "0x06000002": "b".repeat(64), + }), + right: withCilDigests({ + "0x06000002": "b".repeat(64), + "0x06000001": "a".repeat(64), + }), + }); + + expect(pairs(comparison).methods.slice(0, 2)).toEqual([ + ["0x06000001", "0x06000002", "changed", "exact-signature", "exact"], + ["0x06000002", "0x06000001", "changed", "exact-signature", "exact"], + ]); + expect( + comparison.methods.slice(0, 2).map(({ dimensions }) => dimensions), + ).toEqual([["cil"], ["cil"]]); + expect(comparison.summary).toMatchObject({ changed: 2 }); + }); + + it("retains explicitly limited bare names for historical observations", () => { + const bare = pairs( + compare(original, swapped, { + left: recordedBefore, + right: recordedBefore, + }), + ); + + expect(bare).not.toEqual(pairs(compare(original, swapped))); + expect(pairs(compare(original, swapped, { left: recordedBefore }))).toEqual( + bare, + ); + expect( + pairs(compare(original, swapped, { right: recordedBefore })), + ).toEqual(bare); + expect( + compare(original, swapped, { left: recordedBefore }).limitations, + ).toContainEqual(expect.stringContaining("legacy bare-name comparison")); + }); + + it("does not pair a nested type with a top-level type of the same name", () => { + const topLevel = nestedInspection({ topLevelTypes: true }); + + expect( + topLevel.inspection.types.find((type) => type.token === "0x02000004") + ?.name_sha256, + ).toMatch(/^[a-f0-9]{64}$/u); + expect( + pairs(compare(original, topLevel)).methods.filter( + ([left, right]) => left !== null && right !== null, + ), + ).toEqual([]); + }); +}); + +describe("managed nested type names", () => { + it("keeps a top-level name containing the separator apart from nesting", () => { + // Produce the literal plus sign in PE metadata, which C# identifiers disallow. + const literal = nestedInspection({ + literalTopLevelName: "Fixture.A+Inner", + }); + const spelled = compare(original, literal); + expect( + literal.inspection.types.find((type) => type.token === "0x02000005"), + ).toMatchObject({ + full_name: "Fixture.A+Inner", + enclosing_type_token: null, + }); + + // Joined names would make both sides' Fixture.A+Inner one ambiguous group. + expect(pairs(spelled).methods).toEqual([ + ["0x06000001", "0x06000001", "unchanged", "exact-il-signature", "exact"], + ["0x06000003", "0x06000003", "unchanged", "exact-il-signature", "exact"], + ["0x06000002", "0x06000002", "unchanged", "exact-il-signature", "exact"], + ]); + }); + + it("warns of an undecoded counterpart only for the same enclosing type", () => { + // Keep only Fixture.B+Inner.M on the right, with an undecoded signature. + const comparison = compare(original, original, { + right: (inspection) => ({ + ...inspection, + methods: inspection.methods + .filter(({ token }) => token === "0x06000002") + .map((method) => ({ + ...method, + signature: { + ...method.signature, + raw_sha256: "c".repeat(64), + resolved_sha256: null, + parse_status: "malformed", + }, + })), + }), + }); + const warned = comparison.methods.map(({ left, right, limitations }) => [ + left?.token ?? null, + right?.token ?? null, + limitations.some((limitation) => + limitation.startsWith("counterpart-signature-not-decoded"), + ), + ]); + + expect(warned).toContainEqual(["0x06000001", null, false]); + expect(warned).toContainEqual(["0x06000002", null, true]); + }); +}); + describe("managed member comparison subject identity", () => { it("rejects authenticated member Evidence whose subject digest differs from its artifact", () => { const bytes = buildManagedPeFixture(); diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index fe4a835968..29cc3cb799 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -986,6 +986,10 @@ describe("managed member comparison of unresolved and held identities", () => { ...base, result: { ...base.result, + types: [ + ...base.result.types, + namedTypeFact("Twin", "Other", "0x02000003"), + ], methods: [ { ...method, declaring_type: null, declaring_type_token: null }, { @@ -1120,6 +1124,12 @@ describe("managed member comparison of consumed ownerless ambiguity", () => { ); const [method] = base.result.methods; if (method === undefined) throw new Error("Expected a fixture method"); + const ownerTypes = new Map( + ["T", "D", "U"].map((name, index) => [ + name, + namedTypeFact(name, "", `0x0200000${String(index + 3)}`), + ]), + ); const candidate = ( raw: string, token: string, @@ -1130,7 +1140,8 @@ describe("managed member comparison of consumed ownerless ambiguity", () => { ...method, token, declaring_type: owner, - declaring_type_token: owner === null ? null : method.declaring_type_token, + declaring_type_token: + owner === null ? null : ownerTypes.get(owner)!.token, name, // An operand-free signature: its resolved digest is the raw one. signature: { @@ -1148,6 +1159,7 @@ describe("managed member comparison of consumed ownerless ambiguity", () => { ...base, result: { ...base.result, + types: [...base.result.types, ...ownerTypes.values()], fields: [], methods: [ candidate("a", "0x06000001", "T", "M", 1), @@ -1160,6 +1172,7 @@ describe("managed member comparison of consumed ownerless ambiguity", () => { ...base, result: { ...base.result, + types: [...base.result.types, ...ownerTypes.values()], fields: [], methods: [ candidate("c", "0x06000001", "U", "N", 1), @@ -1210,6 +1223,10 @@ describe("managed member comparison of retained ownership contention", () => { ...base, result: { ...base.result, + types: [ + ...base.result.types, + namedTypeFact("Twin", "Other", "0x02000003"), + ], fields: [], methods: [ { ...method, body: body("c", 3) }, @@ -1217,6 +1234,7 @@ describe("managed member comparison of retained ownership contention", () => { ...method, token: "0x06000002", declaring_type: "Other.Twin", + declaring_type_token: "0x02000003", name: "Copy", body: ownerless.body, }, @@ -1309,6 +1327,45 @@ describe("managed member comparison of ownerless structural competitors", () => }); }); +it("keeps a lone ownerless member unknown despite an identical payload", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-lone.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const ownerless = { + ...method, + declaring_type: null, + declaring_type_token: null, + }; + const side = { + ...base, + result: { ...base.result, methods: [ownerless], fields: [] }, + }; + + const result = compareManagedMembers(side, side); + + expect(result.matching.ambiguous).toBe(1); + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toHaveLength(0); + expect(result.methods[0]).toMatchObject({ + status: "unknown", + match: { + status: "ambiguous", + candidate_left_tokens: [ownerless.token], + candidate_right_tokens: [ownerless.token], + }, + limitations: [ + expect.stringContaining("Declared type identity remains unresolved"), + ], + }); + expect(result.summary).toMatchObject({ + unchanged: 0, + added: 0, + removed: 0, + unknown: 1, + }); +}); + describe("managed member comparison of ownerless members", () => { it("keeps an ownerless member ambiguous beside a resolved copy, as main does", () => { const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-copy.dll"); @@ -1346,28 +1403,6 @@ describe("managed member comparison of ownerless members", () => { } }); - it("still pairs a lone ownerless member with its lone counterpart", () => { - const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-lone.dll"); - const [method] = base.result.methods; - if (method === undefined) throw new Error("Expected a fixture method"); - const ownerless = { - ...method, - declaring_type: null, - declaring_type_token: null, - }; - const side = { - ...base, - result: { ...base.result, methods: [ownerless], fields: [] }, - }; - - const result = compareManagedMembers(side, side); - - expect(result.matching.ambiguous).toBe(0); - expect( - result.methods.filter(({ match }) => match.status === "matched"), - ).toHaveLength(1); - }); - it("keeps a resolved copy out of the declared round when an ownerless competitor is on the left, as main does", () => { const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-left.dll"); const [method] = base.result.methods; @@ -1536,6 +1571,10 @@ describe("managed member comparison of unresolved owners", () => { ...held, result: { ...held.result, + types: [ + ...held.result.types, + namedTypeFact("Twin", "Other", "0x02000003"), + ], methods: [ undecodedMethod, { ...undecodedMethod, token: "0x06000002" }, @@ -1576,8 +1615,17 @@ describe("managed member comparison of unresolved owners", () => { ...base, result: { ...base.result, + types: [ + ...base.result.types, + namedTypeFact("Twin", "Other", "0x02000003"), + ], methods: [ - { ...method, declaring_type: "Other.Twin", token: "0x06000002" }, + { + ...method, + declaring_type: "Other.Twin", + declaring_type_token: "0x02000003", + token: "0x06000002", + }, ], fields: [], }, @@ -2152,6 +2200,22 @@ describe("managed member comparison of resolved-signature contention", () => { * type, to an inspection. The copies come from `source`, so they can carry * the bytes of a different build than the inspection they join. */ +const namedTypeFact = ( + typeName: string, + typeNamespace: string, + token: string, +) => { + const type = inspect( + buildManagedPeFixture({ typeName, typeNamespace }), + `/fixture/${typeNamespace}/${typeName}.dll`, + ).result.types.find( + (type) => type.name === typeName && type.namespace === typeNamespace, + ); + if (type === undefined || type.name_sha256 === null) + throw new Error("Expected a named fixture TypeDef"); + return { ...type, token }; +}; + const withTwin = ( inspected: ReturnType, source: ReturnType = inspected, @@ -2162,14 +2226,25 @@ const withTwin = ( if (method === undefined || field === undefined) throw new Error("Expected a fixture method and field"); const rows = Array.from({ length: count }, (_, index) => index + 2); + // This consumer fixture needs matching TypeDef facts as well as member + // facts. Obtain nominal digests from the production metadata owner; copying + // only display names leaves declaring_type_token pointing at a missing row. + const types = rows.map((row) => + namedTypeFact( + row === 2 ? "Twin" : `Twin${String(row)}`, + "Other", + `0x0200000${String(row + 1)}`, + ), + ); const twin = (row: number) => ({ - declaring_type: row === 2 ? "Other.Twin" : `Other.Twin${String(row)}`, - declaring_type_token: `0x0200000${String(row + 1)}`, + declaring_type: types[row - 2]!.full_name, + declaring_type_token: types[row - 2]!.token, }); return { ...inspected, result: { ...inspected.result, + types: [...inspected.result.types, ...types], methods: [ ...inspected.result.methods, ...rows.map((row) => ({