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:
parent
dd114d3980
commit
d52af3b321
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue