From d52af3b321a321964598fb302edeb26552c9dffd Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:15:13 -0700 Subject: [PATCH] fix(dotnet): compare members with undecoded signatures by exact identity `compare_managed_members` gave undecoded (unsupported or malformed) signatures no match key at all. In complete inventories every such member was then reported as removed on one side and added on the other, even for byte-identical assemblies: comparing PSReadLine.dll with itself reported 457 added and 457 removed members, mostly generic instantiations the signature decoder does not support. The raw signature blob is the exact signature, so methods and fields now pair by exact declared type, name, and raw signature without decoding. An undecoded member that still has no counterpart is reported as unknown with a `signature-not-decoded` limitation, never added or removed, because no shape round could look for a changed counterpart. Co-Authored-By: Claude Opus 5.5 --- src/domain/managedMemberComparisonCoverage.ts | 3 +- src/domain/managedMemberComparisonItems.ts | 76 ++++++++++++------- src/domain/managedMemberComparisonMatch.ts | 32 +++++--- ...anagedMemberComparisonFromMetadata.test.ts | 58 ++++++++++++++ 4 files changed, 129 insertions(+), 40 deletions(-) diff --git a/src/domain/managedMemberComparisonCoverage.ts b/src/domain/managedMemberComparisonCoverage.ts index d19d595f1..71c112af1 100644 --- a/src/domain/managedMemberComparisonCoverage.ts +++ b/src/domain/managedMemberComparisonCoverage.ts @@ -68,7 +68,7 @@ export const buildComparisonMatching = ( exact_il_signature: methodItems.filter( ({ match }) => match.basis === "exact-il-signature", ).length, - exact_signature: methodItems.filter( + exact_signature: [...methodItems, ...fieldItems].filter( ({ match }) => match.basis === "exact-signature", ).length, structural_method_shape: methodItems.filter( @@ -92,6 +92,7 @@ export const comparisonLimitations = ( const limitations: string[] = [ "Metadata tokens are build-local coordinates; matched pairs are remaps, not persistent identities.", "Methods pair by exact CIL/signature, then declared type, name, and signature, then structural shape; names alone are never a matching basis.", + "Fields pair by exact signature, then declared type, name, and signature; an undecoded signature is compared only by its raw bytes, and an unmatched member with one stays unknown.", ]; if (left.coverage.state !== "complete" || right.coverage.state !== "complete") limitations.push("At least one managed member observation is partial."); diff --git a/src/domain/managedMemberComparisonItems.ts b/src/domain/managedMemberComparisonItems.ts index 6fcb4f42e..39ff4c9c2 100644 --- a/src/domain/managedMemberComparisonItems.ts +++ b/src/domain/managedMemberComparisonItems.ts @@ -115,74 +115,94 @@ const methodOnlyItem = ( context: ComparisonItemContext, ): MethodItem => { if (member.left !== undefined) { - const absenceObserved = context.rightComplete; + const limitations = absenceLimitations( + member.left, + context.rightComplete, + "The right method inventory is incomplete, so absence was not observed.", + ); return { item_id: `mmc_method_${sha256({ token: member.left.token, side: "left" })}`, - status: absenceObserved ? "removed" : "unknown", + status: limitations.length === 0 ? "removed" : "unknown", left: methodIdentity(member.left), right: null, match: unmatchedComparison(), dimensions: ["availability"], evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: absenceObserved - ? [] - : [ - "unknown-within-incomplete-metadata: The right method inventory is incomplete, so absence was not observed.", - ], + limitations, }; } - const absenceObserved = context.leftComplete; + const limitations = absenceLimitations( + member.right, + context.leftComplete, + "The left method inventory is incomplete, so absence was not observed.", + ); return { item_id: `mmc_method_${sha256({ token: member.right.token, side: "right" })}`, - status: absenceObserved ? "added" : "unknown", + status: limitations.length === 0 ? "added" : "unknown", left: null, right: methodIdentity(member.right), match: unmatchedComparison(), dimensions: ["availability"], evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: absenceObserved - ? [] - : [ - "unknown-within-incomplete-metadata: The left method inventory is incomplete, so absence was not observed.", - ], + limitations, }; }; +/** + * Why a one-sided member's absence from the other inventory was not + * observed: that inventory is incomplete, or the member's own signature was + * not decoded, so no shape round could seek a renamed counterpart. + */ +const absenceLimitations = ( + member: Method | Field, + oppositeComplete: boolean, + incomplete: string, +): string[] => [ + ...(oppositeComplete + ? [] + : [`unknown-within-incomplete-metadata: ${incomplete}`]), + ...(member.signature.parse_status === "decoded" + ? [] + : [ + `signature-not-decoded: The member's ${member.signature.parse_status} signature was compared only by exact declared type, name, and raw bytes, so a changed or renamed counterpart could not be sought.`, + ]), +]; + const fieldOnlyItem = ( member: OneSided, context: ComparisonItemContext, ): FieldItem => { if (member.left !== undefined) { - const absenceObserved = context.rightComplete; + const limitations = absenceLimitations( + member.left, + context.rightComplete, + "The right field inventory is incomplete, so absence was not observed.", + ); return { item_id: `mmc_field_${sha256({ token: member.left.token, side: "left" })}`, - status: absenceObserved ? "removed" : "unknown", + status: limitations.length === 0 ? "removed" : "unknown", left: fieldIdentity(member.left), right: null, match: unmatchedComparison(), evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: absenceObserved - ? [] - : [ - "unknown-within-incomplete-metadata: The right field inventory is incomplete, so absence was not observed.", - ], + limitations, }; } - const absenceObserved = context.leftComplete; + const limitations = absenceLimitations( + member.right, + context.leftComplete, + "The left field inventory is incomplete, so absence was not observed.", + ); return { item_id: `mmc_field_${sha256({ token: member.right.token, side: "right" })}`, - status: absenceObserved ? "added" : "unknown", + status: limitations.length === 0 ? "added" : "unknown", left: null, right: fieldIdentity(member.right), match: unmatchedComparison(), evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: absenceObserved - ? [] - : [ - "unknown-within-incomplete-metadata: The left field inventory is incomplete, so absence was not observed.", - ], + limitations, }; }; diff --git a/src/domain/managedMemberComparisonMatch.ts b/src/domain/managedMemberComparisonMatch.ts index 76f889276..9a809a71b 100644 --- a/src/domain/managedMemberComparisonMatch.ts +++ b/src/domain/managedMemberComparisonMatch.ts @@ -58,15 +58,13 @@ const keyMethod = (item: Method): Keyed => ({ item.body.normalized_il_sha256, ]) : null, - signatureKey: - item.signature.parse_status === "decoded" - ? stableKey([ - "method-signature", - item.declaring_type, - item.name, - item.signature.raw_sha256, - ]) - : null, + // The raw blob is the exact signature; decoding only feeds shape keys. + signatureKey: stableKey([ + "method-signature", + item.declaring_type, + item.name, + item.signature.raw_sha256, + ]), structuralKey: item.signature.parse_status === "decoded" && item.body.status === "present" ? stableKey([ @@ -104,7 +102,14 @@ const keyField = (item: Field): Keyed => ({ item.signature.parse_status === "decoded" ? stableKey(["field-exact", item.signature.raw_sha256]) : null, - signatureKey: null, + // Only an undecoded field reaches this round: a decoded field with the + // same raw signature already met its counterpart in the exact round. + signatureKey: stableKey([ + "field-signature", + item.declaring_type, + item.name, + item.signature.raw_sha256, + ]), structuralKey: stableKey([ "field-structural", item.signature.kind, @@ -143,7 +148,12 @@ const matchFields = ( left, right, exactBasis: "field-signature", - fallbackBases: [], + fallbackBases: [ + { + basis: "exact-signature", + key: ({ signatureKey }) => signatureKey, + }, + ], }); export type ManagedMethodMatches = ReturnType; diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 44a90f636..83651b01a 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -251,6 +251,64 @@ describe("managed member comparison uncertainty", () => { }); }); +describe("managed member comparison of undecoded signatures", () => { + it("pairs undecoded signatures by exact declared type, name, and raw bytes", () => { + const undecoded = { + methodSignature: Buffer.from([0x00, 0x00, 0x7f]), + fieldSignature: Buffer.from([0x06, 0x15]), + }; + const left = inspect( + buildManagedPeFixture(undecoded), + "/tmp/undecoded-left.dll", + ); + const right = inspect( + buildManagedPeFixture(undecoded), + "/tmp/undecoded-right.dll", + ); + expect(left.result.coverage.state).toBe("complete"); + expect(left.result.methods[0]?.signature.parse_status).toBe("unsupported"); + expect(left.result.fields[0]?.signature.parse_status).toBe("unsupported"); + const result = compareManagedMembers(left, right); + expect(result.summary).toMatchObject({ + unchanged: 2, + added: 0, + removed: 0, + unknown: 0, + }); + for (const item of [...result.methods, ...result.fields]) + expect(item.match).toMatchObject({ + status: "matched", + basis: "exact-signature", + }); + }); + + it("keeps unmatched undecoded members unknown in complete inventories", () => { + const left = inspect( + buildManagedPeFixture({ + methodSignature: Buffer.from([0x00, 0x00, 0x7f]), + fieldSignature: Buffer.from([0x06, 0x15]), + }), + "/tmp/undecoded-before.dll", + ); + const right = inspect( + buildManagedPeFixture({ + methodSignature: Buffer.from([0x00, 0x00, 0x7e]), + fieldSignature: Buffer.from([0x06, 0x7e]), + }), + "/tmp/undecoded-after.dll", + ); + expect(right.result.coverage.state).toBe("complete"); + const result = compareManagedMembers(left, right); + expect(result.summary).toMatchObject({ added: 0, removed: 0, unknown: 4 }); + for (const item of [...result.methods, ...result.fields]) + expect(item).toMatchObject({ + status: "unknown", + match: { status: "unmatched" }, + limitations: [expect.stringContaining("signature-not-decoded")], + }); + }); +}); + const inspect = (bytes: Buffer, path: string) => { const target = managedPeFixtureTarget(bytes, path); const result = inspectManagedMembersBytes(bytes, target);