From a9195c3a9804d8b815eddb4431c486b048548dfe Mon Sep 17 00:00:00 2001 From: Devulapalli Naga Sri Vaishnavi Date: Wed, 14 Oct 2026 00:37:12 +0530 Subject: [PATCH] fix(managed): retain raw CIL changes in member comparison (#2912) --- docs/managed-code-analysis.md | 19 ++++--- .../managed/managedMemberComparisonItems.ts | 6 +- .../managed/managedMemberComparisonMatch.ts | 5 +- ...anagedMemberComparisonFromMetadata.test.ts | 57 +++++++++++++++++++ 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 167d947c8e..9f86551d7b 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -209,9 +209,11 @@ 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 prefers exact CIL/signature identity. When that -does not match, it pairs an exact declared type, method name, and raw signature -before trying structural body shape. The exact-signature key uses names only +Cross-version matching first prefers exact raw CIL/signature identity, with +equal decoded fingerprints. Equal decoded fingerprints alone cannot establish +an exact match: they omit switch targets and collapse distinct NaN encodings. +When that does not match, it pairs an exact declared type, method name, and raw +signature before trying structural body shape. 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. Fields follow the same exact tiers. The raw signature is exact whether or not REA decoded it, so @@ -222,8 +224,11 @@ unpaired member, one-sided or ambiguous, with the same declared type and name whose signature was not decoded. For a matched method, an unavailable or partial body makes body-shape facets -unknown while preserving observed signature differences. Structural identity -compares normalized signatures, the limited decoded-CIL tuple fingerprint, +unknown while preserving observed signature differences. For complete bodies, +a difference in either the raw CIL digest or the decoded fingerprint is reported +in the `cil` dimension, including reordered switch targets with equal decoded +fingerprints. Structural identity compares normalized signatures, the limited +decoded-CIL tuple fingerprint, constants, and bounded shape context. It reports all candidates at the winning score and remains ambiguous when the evidence does not distinguish them. The digest does not itself remap metadata tokens. @@ -360,8 +365,8 @@ Opcode names retain short/long forms and prefix opcodes. This makes the value a reproducible decoded-tuple fingerprint, not a canonical semantic CIL identity: it does not resolve tokens across MVIDs, does not commit full control flow or exception semantics, and does not replace the raw CIL digest. Cross-build -comparison uses the value only as one unique-only structural tier and reports -ambiguity when that evidence is insufficient. +comparison requires equal raw CIL digests as well for exact CIL/signature +matches and reports ambiguity when structural evidence is insufficient. For example, a decoded `nop; ret` body serializes exactly as `[["nop","none",null],["ret","none",null]]` and has digest diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index 23b4d600bd..26331be083 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -73,7 +73,11 @@ const methodDimensions = ( if (bothAbsent) return { dimensions, bodyUnknown: false }; if (!leftBodyComplete || !rightBodyComplete) return { dimensions, bodyUnknown: true }; - if (left.body.normalized_il_sha256 !== right.body.normalized_il_sha256) + // The decoded fingerprint omits switch targets and is not exact CIL identity. + if ( + left.body.il_sha256 !== right.body.il_sha256 || + left.body.normalized_il_sha256 !== right.body.normalized_il_sha256 + ) dimensions.push("cil"); if ( canonicalize(left.body.opcode_counts) !== diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index f8e2b1d430..90106a17cd 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -49,10 +49,13 @@ interface Ambiguous { const keyMethod = (item: Method): Keyed => ({ item, exactKey: - item.body.status === "present" && item.body.normalized_il_sha256 !== null + item.body.status === "present" && + item.body.il_sha256 !== null && + item.body.normalized_il_sha256 !== null ? sha256([ "method-exact", item.signature.raw_sha256, + item.body.il_sha256, item.body.normalized_il_sha256, ]) : null, diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index c4c4e1e1ae..7866b978b0 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -116,6 +116,63 @@ describe("managed member comparison", () => { }); }); +describe("managed member comparison with equal decoded fingerprints", () => { + it.each([ + { name: "Main", basis: "exact-signature" }, + { name: "Renamed", basis: "structural-method-shape" }, + ])("detects reordered switch targets in $name", ({ name, basis }) => { + const observe = (targets: readonly number[], methodName: string) => { + // ldarg.0; switch (case 0, case 1); ldc.i4.0; ret; ldc.i4.1; ret. + // Both cases target instruction starts, relative to the end of the table. + const il = Buffer.alloc(18); + il[0] = 0x02; + il[1] = 0x45; + il.writeUInt32LE(2, 2); + targets.forEach((target, index) => + il.writeInt32LE(target, 6 + index * 4), + ); + Buffer.from([0x16, 0x2a, 0x17, 0x2a]).copy(il, 14); + return inspect( + buildManagedPeFixture({ + methodName, + methodSignature: Buffer.from([0x00, 0x01, 0x08, 0x08]), + ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]), + }), + `/tmp/${methodName}-${targets.join("-")}.dll`, + ); + }; + const left = observe([0, 2], "Main"); + const right = observe([2, 0], name); + const leftBody = left.result.methods[0]?.body; + const rightBody = right.result.methods[0]?.body; + expect(leftBody?.status).toBe("present"); + expect(rightBody?.status).toBe("present"); + expect(leftBody?.normalized_il_sha256).toBe( + rightBody?.normalized_il_sha256, + ); + expect(leftBody?.il_sha256).not.toBe(rightBody?.il_sha256); + + const result = compareManagedMembers(left, right); + expect(result.matching.exact_il_signature).toBe(0); + expect(result.methods[0]).toMatchObject({ + status: "changed", + match: { status: "matched", basis }, + dimensions: ["cil"], + }); + expect( + compareManagedMembers(right, observe([2, 0], name)).methods[0], + ).toMatchObject({ + status: "unchanged", + match: { + status: "matched", + basis: "exact-il-signature", + confidence: "exact", + }, + dimensions: [], + }); + }); +}); + describe("managed member comparison floating-point constants", () => { it.each([ {