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