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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-07 00:15:13 -07:00
parent dd114d3980
commit d52af3b321
4 changed files with 129 additions and 40 deletions

View File

@ -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.");

View File

@ -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<Field>,
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,
};
};

View File

@ -58,15 +58,13 @@ const keyMethod = (item: Method): Keyed<Method> => ({
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<Field> => ({
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<typeof matchMethods>;

View File

@ -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);