diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 9f86551d7..05782b4a2 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -209,19 +209,27 @@ 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 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 -undecoded signatures pair by that tuple too, but they never enter structural -rounds. An unmatched member is therefore reported as `unknown`, not added or -removed, when its own signature was not decoded or when the other side has an -unpaired member, one-sided or ambiguous, with the same declared type and name -whose signature was not decoded. +Cross-version matching first pairs members of the same declared type and name: +methods by exact CIL/signature identity, then by raw signature, then by +structural body shape; fields by raw signature. Exact CIL/signature identity +requires equal raw CIL digests and decoded fingerprints. Equal decoded +fingerprints alone cannot establish an exact match: they omit switch targets +and collapse distinct NaN encodings. Only then may a remap pair a +renamed or moved member across declared types or names: a method by exact +CIL/signature identity or structural body shape, a field by exact decoded +signature. A remap never takes a member whose own declared type and name remain +unpaired on the other side, so a member is never taken from its own changed +counterpart. A member whose declaring type is unresolved could belong to any +type, so it pairs by name only when no other member shares its exact +CIL/signature key, never by structural shape alone, and any remap group it +joins stays ambiguous. 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. The raw signature is exact whether or +not REA decoded it, so undecoded signatures pair by that tuple too, but they +never enter structural rounds. An unmatched member is therefore reported as +`unknown`, not added or removed, when its own signature was not decoded or when +the other side has an 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. For complete bodies, diff --git a/src/contracts/managed/managedWorkflowToolContracts.ts b/src/contracts/managed/managedWorkflowToolContracts.ts index 519c2e125..18d78a310 100644 --- a/src/contracts/managed/managedWorkflowToolContracts.ts +++ b/src/contracts/managed/managedWorkflowToolContracts.ts @@ -98,7 +98,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [ name: "compare_managed_members", ...toolContractMetadata("compare_managed_members"), description: - "Compare two authenticated inspect_managed_members Evidence records using unique-only tiers: exact CIL/signature, exact declared type, name, and raw signature (decoded or not), and decoded structural method shape. Names alone are never a matching basis; a member whose signature was not decoded, or whose same-named counterpart's was not, stays unknown rather than added or removed. Metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.", + "Compare two authenticated inspect_managed_members Evidence records using unique-only tiers: exact declared type, name, and CIL/signature; exact declared type, name, and raw signature (decoded or not); exact declared type, name, and decoded structural method shape; then exact CIL/signature or decoded structural method shape alone, which never takes a member whose own declared type and name remain unpaired on the other side or whose declaring type is unresolved. Names alone are never a matching basis; a member whose signature was not decoded, or whose same-named counterpart's was not, stays unknown rather than added or removed. Metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.", kind: "application", inputSchema: compareManagedMembersReferenceInputSchema, outputSchema: comparisonOutputSchema, diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index 85da3b4eb..fce7b25fb 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -107,8 +107,8 @@ export const comparisonLimitations = ( ): string[] => { 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.", + "Methods pair within one declared type and name by exact CIL/signature, then signature, then structural shape; only then may exact CIL/signature or structural shape alone remap across declared types or names, never taking a member whose own declared type and name remain unpaired on the other side or whose declaring type is unresolved. Names alone are never a matching basis.", + "Fields pair by declared type, name, and signature, then remap by exact decoded signature alone under the same rule; 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/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index 26331be08..61bfd7c18 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -5,6 +5,7 @@ import type { ManagedMemberComparisonResult } from "./managedMemberComparison.js import { type ManagedFieldMatches, type ManagedMethodMatches, + identityOf, sha256, } from "./managedMemberComparisonMatch.js"; @@ -211,33 +212,36 @@ const absenceLimitations = ( `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.`, ]), ...(member.signature.parse_status === "decoded" && - opposite.oppositeUndecodedNames.has(declaredName(member)) + opposite.oppositeUndecodedNames.size > 0 && + opposite.oppositeUndecodedNames.has(identityOf(member)) ? [ `counterpart-signature-not-decoded: The ${opposite.opposite} ${opposite.inventory} inventory has an unpaired member with the same declared type and name whose signature was not decoded, so it may be this member with a changed signature.`, ] : []), ]; -const declaredName = (member: Method | Field): string => - JSON.stringify([member.declaring_type, member.name]); - /** * Declared names of unpaired members whose signature was not decoded: one-sided * members and candidates of ambiguous groups, which also remain unpaired. */ +interface Unpaired { + readonly item: Method | Field; + readonly identity: string; +} + const undecodedOneSidedNames = (members: { - readonly leftOnly: readonly { readonly item: Method | Field }[]; - readonly rightOnly: readonly { readonly item: Method | Field }[]; + readonly leftOnly: readonly Unpaired[]; + readonly rightOnly: readonly Unpaired[]; readonly ambiguous: readonly { - readonly left: readonly { readonly item: Method | Field }[]; - readonly right: readonly { readonly item: Method | Field }[]; + readonly left: readonly Unpaired[]; + readonly right: readonly Unpaired[]; }[]; }): UndecodedOneSidedNames => { - const names = (side: readonly { readonly item: Method | Field }[]) => + const names = (side: readonly Unpaired[]) => new Set( side .filter(({ item }) => item.signature.parse_status !== "decoded") - .map(({ item }) => declaredName(item)), + .map(({ identity }) => identity), ); return { left: names([ @@ -300,18 +304,20 @@ const fieldOnlyItem = ( }; /** - * Name the identity key an ambiguous group shares. The exact-signature key - * already includes the declared type and name, so names cannot separate it. + * Name only evidence shared by the whole group. Exact-signature ambiguities + * include both declared-identity collisions and unresolved raw-signature choices. */ const ambiguityLimitation = ( members: "methods" | "fields", basis: MethodItem["match"]["basis"], ): string => basis === "exact-signature" - ? `Multiple managed ${members} share the same declared type, name, and raw signature; REA did not guess a token remap.` - : members === "methods" - ? "Multiple managed methods share the same non-name identity key; REA did not guess a token remap." - : "Multiple managed fields share the same signature; REA did not guess a token remap from names."; + ? `Multiple managed ${members} share the same raw signature; REA did not guess declared ownership or a token remap.` + : basis === "exact-il-signature" + ? "Multiple managed methods share the same CIL and signature; REA did not guess a token remap." + : members === "methods" + ? "Multiple managed methods share the same structural shape; REA did not guess a token remap." + : "Multiple managed fields share the same signature; REA did not guess a token remap."; export const buildMethodItems = ( matches: ManagedMethodMatches, diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index 90106a17c..2cbe32a4a 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -28,8 +28,11 @@ type ConcreteMatchBasis = Exclude; interface Keyed { readonly item: Item; + /** Declared type and name; an unresolved type is null, the name remains. */ + readonly identity: string; readonly exactKey: string | null; readonly signatureKey: string | null; + readonly rawSignatureKey: string; readonly structuralKey: string | null; } @@ -48,6 +51,8 @@ interface Ambiguous { const keyMethod = (item: Method): Keyed => ({ item, + identity: identityOf(item), + rawSignatureKey: item.signature.raw_sha256, exactKey: item.body.status === "present" && item.body.il_sha256 !== null && @@ -99,6 +104,8 @@ const keyMethod = (item: Method): Keyed => ({ const keyField = (item: Field): Keyed => ({ item, + identity: identityOf(item), + rawSignatureKey: item.signature.raw_sha256, exactKey: item.signature.parse_status === "decoded" ? sha256(["field-exact", item.signature.raw_sha256]) @@ -126,15 +133,34 @@ const matchMethods = ( matchByKeys({ left, right, - exactBasis: "exact-il-signature", - fallbackBases: [ + rounds: [ + { + basis: "exact-il-signature", + key: declaredAs(({ exactKey }) => exactKey), + }, { basis: "exact-signature", key: ({ signatureKey }) => signatureKey, + competitionKey: ({ rawSignatureKey }) => rawSignatureKey, + }, + { + basis: "structural-method-shape", + // An unresolved owner has no declared shape match to prove. + key: declaredAs((keyed) => + keyed.item.declaring_type === null ? null : keyed.structuralKey, + ), + competitionKey: ({ structuralKey }) => structuralKey, + }, + { + basis: "exact-il-signature", + key: ({ exactKey }) => exactKey, + remap: true, }, { basis: "structural-method-shape", key: ({ structuralKey }) => structuralKey, + competitionKey: ({ structuralKey }) => structuralKey, + remap: true, }, ], }); @@ -146,11 +172,20 @@ const matchFields = ( matchByKeys({ left, right, - exactBasis: "field-signature", - fallbackBases: [ + rounds: [ + { + basis: "field-signature", + key: declaredAs(({ exactKey }) => exactKey), + }, { basis: "exact-signature", key: ({ signatureKey }) => signatureKey, + competitionKey: ({ rawSignatureKey }) => rawSignatureKey, + }, + { + basis: "field-signature", + key: ({ exactKey }) => exactKey, + remap: true, }, ], }); @@ -158,21 +193,47 @@ const matchFields = ( export type ManagedMethodMatches = ReturnType; export type ManagedFieldMatches = ReturnType; +interface MatchRound { + readonly basis: ConcreteMatchBasis; + readonly key: (item: Keyed) => string | null; + /** Name-free evidence paired with the retained member name for contesting. */ + readonly competitionKey?: (item: Keyed) => string | null; + /** + * The key omits the declared type, so a pair may join different declared + * identities; same-identity pairs were taken by earlier rounds. Contention + * still retains the member name, which unresolved owners keep as evidence. + * A member whose own identity is still unpaired on the other side does not + * take part, and a group with a member of an unresolved type stays ambiguous. + */ + readonly remap?: true; +} + interface MatchByKeysInput { readonly left: readonly Keyed[]; readonly right: readonly Keyed[]; - readonly exactBasis: ConcreteMatchBasis; - readonly fallbackBases: readonly { - readonly basis: ConcreteMatchBasis; - readonly key: (item: Keyed) => string | null; - }[]; + readonly rounds: readonly MatchRound[]; } -const matchByKeys = ({ +export const identityOf = ({ + declaring_type, + name, +}: { + readonly declaring_type: string | null; + readonly name: string; +}): string => sha256([declaring_type, name]); + +/** Narrow a key to members of one declared type and name. */ +const declaredAs = + (key: (item: Keyed) => string | null) => + (item: Keyed): string | null => { + const value = key(item); + return value === null ? null : sha256([item.identity, value]); + }; + +const matchByKeys = ({ left, right, - exactBasis, - fallbackBases, + rounds, }: MatchByKeysInput): { readonly pairs: readonly MatchedPair[]; readonly ambiguous: readonly Ambiguous[]; @@ -181,33 +242,58 @@ const matchByKeys = ({ } => { const usedLeft = new Set>(); const usedRight = new Set>(); + const pairedLeft = new Set>(); + const pairedRight = new Set>(); const pairs: MatchedPair[] = []; const ambiguous: Ambiguous[] = []; - const rounds: readonly { - readonly basis: ConcreteMatchBasis; - readonly key: (item: Keyed) => string | null; - }[] = [ - { basis: exactBasis, key: ({ exactKey }) => exactKey }, - ...fallbackBases, - ]; for (const round of rounds) { + const competitionKey = round.competitionKey ?? ((item) => item.exactKey); + const unpairedLeft = unpairedOf(left, pairedLeft); + const unpairedRight = unpairedOf(right, pairedRight); + const declarable = ownerlessDeclarable( + unpairedLeft, + unpairedRight, + competitionKey, + ); + const openLeft = left.filter((item) => !usedLeft.has(item)); + const openRight = right.filter((item) => !usedRight.has(item)); + // Only open candidates can justify overriding a held declared identity: + // consumed ambiguous rows remain identity barriers, but cannot join a + // later remap group to establish its ambiguity. + const remapContested = + round.remap === true + ? ownerlessContested([openLeft, openRight], competitionKey) + : new Set(); const leftGroups = groupBy( - left.filter((item) => !usedLeft.has(item)), + round.remap === true + ? remappable(openLeft, unpairedRight, competitionKey, remapContested) + : openLeft.filter(declarable), round.key, ); const rightGroups = groupBy( - right.filter((item) => !usedRight.has(item)), + round.remap === true + ? remappable(openRight, unpairedLeft, competitionKey, remapContested) + : openRight.filter(declarable), round.key, ); for (const [key, leftItems] of leftGroups) { const rightItems = rightGroups.get(key); if (rightItems === undefined) continue; - if (leftItems.length === 1 && rightItems.length === 1) { + // An unresolved owner may be either member's own counterpart, so a + // remap group it joins stays ambiguous. + const refused = + round.remap === true && + [...leftItems, ...rightItems].some( + ({ item }) => item.declaring_type === null, + ); + if (!refused && leftItems.length === 1 && rightItems.length === 1) { const [leftItem] = leftItems; const [rightItem] = rightItems; if (leftItem === undefined || rightItem === undefined) continue; usedLeft.add(leftItem); usedRight.add(rightItem); + pairedLeft.add(leftItem); + pairedRight.add(rightItem); pairs.push({ left: leftItem, right: rightItem, @@ -230,6 +316,22 @@ const matchByKeys = ({ } } } + // Contention can prevent a declared signature pair even when CIL/shape + // evidence cannot form a later remap. Held ambiguities still contest + // ownership; only proven pairs leave that evidence pool. Group only open + // candidates so held members retain their original ambiguity and provenance. + for (const group of unresolvedSignatureAmbiguities( + left.filter((item) => !usedLeft.has(item)), + right.filter((item) => !usedRight.has(item)), + ownerlessContested( + [unpairedOf(left, pairedLeft), unpairedOf(right, pairedRight)], + (member) => member.rawSignatureKey, + ), + )) { + for (const item of group.left) usedLeft.add(item); + for (const item of group.right) usedRight.add(item); + ambiguous.push(group); + } return { pairs, ambiguous, @@ -238,6 +340,132 @@ const matchByKeys = ({ }; }; +/** Open signature choices contested by unresolved owners, including held rows. */ +const unresolvedSignatureAmbiguities = ( + left: readonly Keyed[], + right: readonly Keyed[], + contested: ReadonlySet, +): Ambiguous[] => { + const key = (member: Keyed): string => + competitionIdentityKey(member, member.rawSignatureKey) ?? ""; + const leftGroups = groupBy(left, key); + const rightGroups = groupBy(right, key); + const groups: Ambiguous[] = []; + for (const [signature, leftItems] of leftGroups) { + const rightItems = rightGroups.get(signature); + if (!contested.has(signature) || rightItems === undefined) continue; + groups.push({ + left: leftItems, + right: rightItems, + basis: "exact-signature", + }); + } + return groups; +}; + +interface Declared { + readonly declaring_type: string | null; + readonly name: string; +} + +const unpairedOf = ( + items: readonly Keyed[], + paired: ReadonlySet>, +): Keyed[] => items.filter((item) => !paired.has(item)); + +/** + * Round-specific evidence keys an unresolved owner competes for, paired with + * its retained member name. Another same-name member on the same side can be + * the unresolved row's owner; a different name cannot. Contested candidates + * stay in remap rounds rather than pairing by declared identity. + */ +const competitionIdentityKey = ( + member: Keyed, + evidenceKey: string | null, +): string | null => + evidenceKey === null ? null : JSON.stringify([member.item.name, evidenceKey]); + +const ownerlessContested = ( + sides: readonly (readonly Keyed[])[], + keyOf: (item: Keyed) => string | null, +): ReadonlySet => { + const contested = new Set(); + for (const side of sides) { + const seen = new Map(); + const ownerless = new Set(); + for (const member of side) { + const key = competitionIdentityKey(member, keyOf(member)); + if (key === null) continue; + seen.set(key, (seen.get(key) ?? 0) + 1); + if (member.item.declaring_type === null) ownerless.add(key); + } + for (const key of ownerless) + if ((seen.get(key) ?? 0) > 1) contested.add(key); + } + return contested; +}; + +/** + * Members a remap round may group: an unresolved owner, which keeps its group + * ambiguous; a member whose selected key an unresolved owner competes for, so the + * group that preserved the base matcher's ambiguity can form; or no member of + * the same declared identity left unpaired on the other side, whether + * one-sided or in an ambiguous group. The sets are taken before grouping, so + * no group order decides a remap. + */ +const remappable = ( + items: readonly Keyed[], + others: readonly Keyed[], + keyOf: (item: Keyed) => string | null, + contested: ReadonlySet, +): Keyed[] => { + const kept = new Set(others.map(({ identity }) => identity)); + return items.filter((member) => { + const key = keyOf(member); + return ( + (key !== null && + contested.has(competitionIdentityKey(member, key) ?? "")) || + member.item.declaring_type === null || + !kept.has(member.identity) + ); + }); +}; + +/** + * Whether a member may pair in a declared-identity round. A member with an + * unresolved owner may, only when no other member on either side shares its + * selected evidence key: its owner might be any of them, so a shared key + * leaves it to the remap rounds, which keep such a group ambiguous. The + * resolved members that share such a key stay out too, so they cannot pair and + * consume the candidate the ownerless member might own. + */ +const ownerlessDeclarable = ( + left: readonly Keyed[], + right: readonly Keyed[], + keyOf: (item: Keyed) => string | null, +): ((member: Keyed) => boolean) => { + const count = (side: readonly Keyed[]) => { + const seen = new Map(); + for (const member of side) { + const key = keyOf(member); + if (key !== null) seen.set(key, (seen.get(key) ?? 0) + 1); + } + return seen; + }; + const leftSeen = count(left); + const rightSeen = count(right); + const contested = ownerlessContested([left, right], keyOf); + return (member) => { + const key = keyOf(member); + return key !== null && + contested.has(competitionIdentityKey(member, key) ?? "") + ? false + : member.item.declaring_type !== null || + key === null || + ((leftSeen.get(key) ?? 0) <= 1 && (rightSeen.get(key) ?? 0) <= 1); + }; +}; + const groupBy = ( items: readonly Item[], keyOf: (item: Item) => string | null, @@ -266,7 +494,7 @@ const observedBy = * the other side's selection stays one-sided, so a type selection never yields * a remap to a member of a type the other side did not inspect. */ -const matchObserved = ( +const matchObserved = ( left: readonly Item[], right: readonly Item[], sides: { diff --git a/tests/acceptance/providers/hopperStartupRecovery.test.ts b/tests/acceptance/providers/hopperStartupRecovery.test.ts index f35ec68d6..bd4a2d0f1 100644 --- a/tests/acceptance/providers/hopperStartupRecovery.test.ts +++ b/tests/acceptance/providers/hopperStartupRecovery.test.ts @@ -282,6 +282,8 @@ test.for(["handoff", "not-found"] as const)( const status = z.object({ result: z.object({ provider_operation_health: z.object({ + // A timeout observes incomplete startup; a failed launcher without + // confirmed ownership leaves the provider's state unknown. state: z.literal( launchMode === "handoff" ? "not_started" : "unknown", ), diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 7866b978b..5ffabdd59 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -47,7 +47,7 @@ describe("managed member comparison", () => { }), ]); expect(result.limitations).toContain( - "Methods pair by exact CIL/signature, then declared type, name, and signature, then structural shape; names alone are never a matching basis.", + "Methods pair within one declared type and name by exact CIL/signature, then signature, then structural shape; only then may exact CIL/signature or structural shape alone remap across declared types or names, never taking a member whose own declared type and name remain unpaired on the other side or whose declaring type is unresolved. Names alone are never a matching basis.", ); expect( managedMemberComparisonResultSchema.safeParse({ @@ -339,10 +339,12 @@ describe("managed member comparison uncertainty", () => { const rightField = right.result.fields[0]; expect(rightField).toBeDefined(); if (rightField === undefined) return; + // Both right fields are renamed, so neither left field keeps its declared + // identity and only the shared signature links them. const duplicatedRight = { ...right.result, fields: [ - rightField, + { ...rightField, name: "moved" }, { ...rightField, token: "0x04000002", @@ -365,6 +367,9 @@ describe("managed member comparison uncertainty", () => { candidate_left_tokens: ["0x04000001", "0x04000002"], candidate_right_tokens: ["0x04000001", "0x04000002"], }), + limitations: [ + "Multiple managed fields share the same signature; REA did not guess a token remap.", + ], }), ]); }); @@ -582,7 +587,7 @@ describe("managed member comparison of ambiguous undecoded signatures", () => { basis: "exact-il-signature", }), limitations: [ - "Multiple managed methods share the same non-name identity key; REA did not guess a token remap.", + "Multiple managed methods share the same CIL and signature; REA did not guess a token remap.", ], }), expect.objectContaining({ @@ -670,6 +675,891 @@ describe("managed member comparison of selected types", () => { }); }); +describe("managed member comparison of one signature in several types", () => { + it("pairs a file with itself when two types hold identical members", () => { + const inspected = withTwin( + inspect(buildManagedPeFixture(), "/tmp/twin.dll"), + ); + const result = compareManagedMembers(inspected, inspected); + + expect(result.matching.ambiguous).toBe(0); + expect(result.summary).toMatchObject({ + unchanged: 4, + changed: 0, + added: 0, + removed: 0, + unknown: 0, + }); + for (const item of [...result.methods, ...result.fields]) + expect(item.right?.token).toBe(item.left?.token); + }); + + it.each([1, 2])( + "keeps a member with its own changed counterpart beside %i identical members of other types", + (twins) => { + const left = inspect(buildManagedPeFixture(), "/tmp/own-before.dll"); + const changed = withTwin( + inspect( + buildManagedPeFixture({ + ilBody: Buffer.from([ + 0x32, 0x02, 0x7b, 0x02, 0x00, 0x00, 0x04, 0x28, 0x02, 0x00, 0x00, + 0x0a, 0x2a, + ]), + }), + "/tmp/own-after.dll", + ), + left, + twins, + ); + const result = compareManagedMembers(left, changed); + + expect(result.methods).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "changed", + left: expect.objectContaining({ + declaring_type: "Fixture.Program", + }), + right: expect.objectContaining({ + declaring_type: "Fixture.Program", + }), + match: expect.objectContaining({ basis: "exact-signature" }), + dimensions: expect.arrayContaining(["cil"]), + }), + expect.objectContaining({ + status: "added", + right: expect.objectContaining({ declaring_type: "Other.Twin" }), + }), + ]), + ); + expect(result.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "unchanged", + left: expect.objectContaining({ + declaring_type: "Fixture.Program", + }), + right: expect.objectContaining({ + declaring_type: "Fixture.Program", + }), + }), + expect.objectContaining({ + status: "added", + right: expect.objectContaining({ declaring_type: "Other.Twin" }), + }), + ]), + ); + expect(result.summary).toMatchObject({ unknown: 0, removed: 0 }); + }, + ); + + it("does not remap a member whose own identity remains on the other side", () => { + const left = inspect(buildManagedPeFixture(), "/tmp/sig-before.dll"); + const changed = withTwin( + inspect( + buildManagedPeFixture({ + methodSignature: Buffer.from([0x00, 0x00, 0x08]), + fieldSignature: Buffer.from([0x06, 0x0a]), + }), + "/tmp/sig-after.dll", + ), + left, + ); + // Either side may hold the member whose own identity is left behind. + const forward = compareManagedMembers(left, changed); + const backward = compareManagedMembers(changed, left); + + for (const item of [ + ...forward.methods, + ...forward.fields, + ...backward.methods, + ...backward.fields, + ]) + expect(item.match.status).not.toBe("matched"); + expect(forward.summary).toMatchObject({ removed: 2, added: 4 }); + expect(backward.summary).toMatchObject({ removed: 4, added: 2 }); + }); + + it("does not remap by structural shape a method whose own identity remains on the other side", () => { + const left = inspect(buildManagedPeFixture(), "/tmp/shape-before.dll"); + const changed = withTwin( + inspect( + buildManagedPeFixture({ + methodSignature: Buffer.from([0x00, 0x00, 0x08]), + }), + "/tmp/shape-after.dll", + ), + left, + ); + const [own, twin] = changed.result.methods; + if (own === undefined || twin === undefined) + throw new Error("Expected two methods"); + // Same body shape, different CIL digest: only the structural round links + // the left method to the twin. + const result = compareManagedMembers(left, { + ...changed, + result: { + ...changed.result, + methods: [ + own, + { + ...twin, + body: { ...twin.body, normalized_il_sha256: "0".repeat(64) }, + }, + ], + }, + }); + + for (const item of result.methods) + expect(item.match.status).not.toBe("matched"); + }); +}); + +describe("managed member comparison remaps around kept identities", () => { + it("does not hide a member whose own identity remains in an ambiguous remap group", () => { + const left = inspect(buildManagedPeFixture(), "/tmp/group-before.dll"); + const changed = withTwin( + inspect( + buildManagedPeFixture({ + methodSignature: Buffer.from([0x00, 0x00, 0x08]), + fieldSignature: Buffer.from([0x06, 0x0a]), + }), + "/tmp/group-after.dll", + ), + left, + 2, + ); + const result = compareManagedMembers(left, changed); + + expect(result.matching.ambiguous).toBe(0); + expect(result.summary).toMatchObject({ removed: 2, added: 6 }); + }); + + it("remaps to the one candidate whose own identity is not kept elsewhere", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/kept-base.dll"); + const changedSignature = inspect( + buildManagedPeFixture({ + methodSignature: Buffer.from([0x00, 0x00, 0x08]), + }), + "/tmp/kept-signature.dll", + ); + // Left: Fixture.Program.Main, plus Other.Twin.Main with another signature. + const left = withTwin(base, changedSignature); + // Right: Other.Twin.Main and Other.Twin3.Main, both identical to the + // left Fixture.Program.Main, which the right no longer has. + const twins = withTwin(base, base, 2); + const right = { + ...twins, + result: { ...twins.result, methods: twins.result.methods.slice(1) }, + }; + const result = compareManagedMembers(left, right); + + expect(result.methods).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + left: expect.objectContaining({ declaring_type: "Fixture.Program" }), + right: expect.objectContaining({ declaring_type: "Other.Twin3" }), + match: expect.objectContaining({ + status: "matched", + basis: "exact-il-signature", + }), + }), + ]), + ); + expect( + result.methods.filter(({ match }) => match.status === "ambiguous"), + ).toEqual([]); + }); + + it("names the shared structural shape of an ambiguous remap group", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/shape-group.dll"); + const twins = withTwin(base, base, 2); + // Two right methods share the left method's shape but not its CIL digest, + // and the right no longer declares the left method's identity. + const right = { + ...twins, + result: { + ...twins.result, + methods: twins.result.methods.slice(1).map((method) => ({ + ...method, + body: { ...method.body, normalized_il_sha256: "0".repeat(64) }, + })), + fields: [], + }, + }; + const left = { ...base, result: { ...base.result, fields: [] } }; + const result = compareManagedMembers(left, right); + + expect(result.methods).toEqual([ + expect.objectContaining({ + match: expect.objectContaining({ + status: "ambiguous", + basis: "structural-method-shape", + }), + limitations: [ + "Multiple managed methods share the same structural shape; REA did not guess a token remap.", + ], + }), + ]); + }); +}); + +describe("managed member comparison of unresolved and held identities", () => { + it("does not remap past an unresolved member that may be the counterpart", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/orphan-copy.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const left = { ...base, result: { ...base.result, fields: [] } }; + // The right keeps Main with an unresolved owner, and an identical copy + // in another type; either could be the left Main. + const right = { + ...base, + result: { + ...base.result, + methods: [ + { ...method, declaring_type: null, declaring_type_token: null }, + { + ...method, + declaring_type: "Other.Twin", + declaring_type_token: "0x02000003", + name: "Copy", + token: "0x06000002", + }, + ], + fields: [], + }, + }; + + for (const result of [ + compareManagedMembers(left, right), + compareManagedMembers(right, left), + ]) { + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toEqual([]); + expect(result.summary).toMatchObject({ added: 0, removed: 0 }); + } + }); +}); + +describe("managed member comparison of raw-signature contention", () => { + it("contests same-signature methods even when their bodies differ", () => { + const base = inspect( + buildManagedPeFixture(), + "/tmp/ownerless-signatures.dll", + ); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const changedBody = (hash: string, stack: number) => ({ + ...method.body, + normalized_il_sha256: hash.repeat(64), + max_stack: stack, + }); + const withOwnerless = { + ...base, + result: { + ...base.result, + fields: [], + methods: [ + { ...method, body: changedBody("a", 1) }, + { + ...method, + token: "0x06000002", + declaring_type: null, + declaring_type_token: null, + body: changedBody("b", 2), + }, + ], + }, + }; + const resolvedOnly = { + ...base, + result: { + ...base.result, + fields: [], + methods: [{ ...method, body: changedBody("c", 3) }], + }, + }; + for (const result of [ + compareManagedMembers(withOwnerless, resolvedOnly), + compareManagedMembers(resolvedOnly, withOwnerless), + ]) { + expect(result.methods).toHaveLength(1); + const [item] = result.methods; + if (item === undefined) + throw new Error("Expected uncertain signature group"); + expect(item).toMatchObject({ + status: "unknown", + match: { status: "ambiguous", basis: "exact-signature" }, + }); + expect(result.summary).toMatchObject({ + added: 0, + removed: 0, + unknown: 1, + }); + expect( + item.match.candidate_left_tokens.length + + item.match.candidate_right_tokens.length, + ).toBe(3); + } + }); + + it("contests ownerless undecoded fields by raw signature", () => { + const base = inspect( + buildManagedPeFixture({ fieldSignature: Buffer.from([0x06, 0x7f]) }), + "/tmp/ownerless-field-signatures.dll", + ); + const [field] = base.result.fields; + if (field === undefined) throw new Error("Expected a fixture field"); + expect(field.signature.parse_status).toBe("unsupported"); + const withOwnerless = { + ...base, + result: { + ...base.result, + methods: [], + fields: [ + field, + { + ...field, + token: "0x04000002", + declaring_type: null, + declaring_type_token: null, + }, + ], + }, + }; + const resolvedOnly = { + ...base, + result: { ...base.result, methods: [], fields: [field] }, + }; + for (const result of [ + compareManagedMembers(withOwnerless, resolvedOnly), + compareManagedMembers(resolvedOnly, withOwnerless), + ]) + expect( + result.fields.filter(({ match }) => match.status === "matched"), + ).toEqual([]); + }); +}); + +describe("managed member comparison of consumed ownerless ambiguity", () => { + it("keeps a consumed ownerless ambiguity from authorizing a later remap", () => { + const base = inspect( + buildManagedPeFixture(), + "/tmp/consumed-ownerless.dll", + ); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const candidate = ( + raw: string, + token: string, + owner: string | null, + name: string, + stack: number, + ) => ({ + ...method, + token, + declaring_type: owner, + declaring_type_token: owner === null ? null : method.declaring_type_token, + name, + signature: { ...method.signature, raw_sha256: raw.repeat(64) }, + body: { + ...method.body, + normalized_il_sha256: raw.repeat(64), + max_stack: stack, + }, + }); + const left = { + ...base, + result: { + ...base.result, + fields: [], + methods: [ + candidate("a", "0x06000001", "T", "M", 1), + candidate("b", "0x06000002", null, "M", 1), + candidate("b", "0x06000003", "D", "D", 2), + ], + }, + }; + const right = { + ...base, + result: { + ...base.result, + fields: [], + methods: [ + candidate("c", "0x06000001", "U", "N", 1), + candidate("b", "0x06000002", null, "M", 1), + candidate("d", "0x06000003", "T", "M", 3), + ], + }, + }; + for (const result of [ + compareManagedMembers(left, right), + compareManagedMembers(right, left), + ]) { + expect(result.matching.ambiguous).toBeGreaterThan(0); + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toEqual([]); + } + }); +}); + +describe("managed member comparison of retained ownership contention", () => { + it("keeps changed counterparts unknown after an ownerless candidate is held", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/held-ownerless.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const body = (hash: string, stack: number) => ({ + ...method.body, + il_sha256: hash.repeat(64), + normalized_il_sha256: hash.repeat(64), + max_stack: stack, + }); + const ownerless = { + ...method, + token: "0x06000002", + declaring_type: null, + declaring_type_token: null, + body: body("b", 2), + }; + const left = { + ...base, + result: { + ...base.result, + fields: [], + methods: [{ ...method, body: body("a", 1) }, ownerless], + }, + }; + const right = { + ...base, + result: { + ...base.result, + fields: [], + methods: [ + { ...method, body: body("c", 3) }, + { + ...method, + token: "0x06000002", + declaring_type: "Other.Twin", + name: "Copy", + body: ownerless.body, + }, + ], + }, + }; + for (const result of [ + compareManagedMembers(left, right), + compareManagedMembers(right, left), + ]) { + expect(result.summary).toEqual({ + unchanged: 0, + changed: 0, + added: 0, + removed: 0, + unknown: 2, + }); + expect(result.methods).toEqual([ + expect.objectContaining({ + status: "unknown", + match: { + status: "ambiguous", + basis: "exact-il-signature", + confidence: "unknown", + candidate_left_tokens: ["0x06000002"], + candidate_right_tokens: ["0x06000002"], + }, + }), + expect.objectContaining({ + status: "unknown", + match: { + status: "ambiguous", + basis: "exact-signature", + confidence: "unknown", + candidate_left_tokens: ["0x06000001"], + candidate_right_tokens: ["0x06000001"], + }, + }), + ]); + } + }); +}); + +describe("managed member comparison of ownerless structural competitors", () => { + it("keeps structural competitors of an ownerless method ambiguous in both directions", () => { + const base = inspect( + buildManagedPeFixture(), + "/tmp/ownerless-shape-competitors.dll", + ); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const withSignature = (raw: string, token: string) => ({ + ...method, + token, + signature: { ...method.signature, raw_sha256: raw.repeat(64) }, + }); + const withOwnerless = { + ...base, + result: { + ...base.result, + methods: [ + withSignature("a", "0x06000001"), + { + ...withSignature("b", "0x06000002"), + declaring_type: null, + declaring_type_token: null, + }, + ], + fields: [], + }, + }; + const resolvedOnly = { + ...base, + result: { + ...base.result, + methods: [withSignature("c", "0x06000001")], + fields: [], + }, + }; + for (const result of [ + compareManagedMembers(withOwnerless, resolvedOnly), + compareManagedMembers(resolvedOnly, withOwnerless), + ]) { + expect(result.matching.ambiguous).toBe(1); + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toEqual([]); + expect(result.summary).toMatchObject({ added: 0, removed: 0 }); + } + }); +}); + +describe("managed member comparison of ownerless members", () => { + it("keeps an ownerless member ambiguous beside a resolved copy, as main does", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-copy.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const ownerless = { + ...method, + declaring_type: null, + declaring_type_token: null, + }; + const left = { + ...base, + result: { ...base.result, methods: [ownerless], fields: [] }, + }; + // The right keeps the ownerless Foo and a resolved copy with the same + // CIL and signature; the left Foo's owner may be either one. + const right = { + ...base, + result: { + ...base.result, + methods: [ownerless, { ...method, token: "0x06000002" }], + fields: [], + }, + }; + + for (const result of [ + compareManagedMembers(left, right), + compareManagedMembers(right, left), + ]) { + expect(result.matching.ambiguous).toBe(1); + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toEqual([]); + expect(result.summary).toMatchObject({ added: 0, removed: 0 }); + } + }); + + it("still pairs a lone ownerless member with its lone counterpart", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-lone.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const ownerless = { + ...method, + declaring_type: null, + declaring_type_token: null, + }; + const side = { + ...base, + result: { ...base.result, methods: [ownerless], fields: [] }, + }; + + const result = compareManagedMembers(side, side); + + expect(result.matching.ambiguous).toBe(0); + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toHaveLength(1); + }); + + it("keeps a resolved copy out of the declared round when an ownerless competitor is on the left, as main does", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-left.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const ownerless = { + ...method, + declaring_type: null, + declaring_type_token: null, + }; + // The left holds the resolved Foo and an ownerless Foo with the same CIL + // and signature; the right holds only the resolved Foo. The ownerless + // Foo's owner may be the resolved one, so the three stay ambiguous. + const withOwnerless = { + ...base, + result: { ...base.result, methods: [method, ownerless], fields: [] }, + }; + const resolvedOnly = { + ...base, + result: { ...base.result, methods: [method], fields: [] }, + }; + + const result = compareManagedMembers(withOwnerless, resolvedOnly); + + expect(result.matching.ambiguous).toBe(1); + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toEqual([]); + expect(result.summary).toMatchObject({ added: 0, removed: 0 }); + }); + + it("keeps a resolved copy out of the declared round when an ownerless competitor is on the right, as main does", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-right.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const ownerless = { + ...method, + declaring_type: null, + declaring_type_token: null, + }; + const withOwnerless = { + ...base, + result: { ...base.result, methods: [method, ownerless], fields: [] }, + }; + const resolvedOnly = { + ...base, + result: { ...base.result, methods: [method], fields: [] }, + }; + + const result = compareManagedMembers(resolvedOnly, withOwnerless); + + expect(result.matching.ambiguous).toBe(1); + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toEqual([]); + expect(result.summary).toMatchObject({ added: 0, removed: 0 }); + }); + + it("keeps an ownerless method ambiguous beside a resolved method of the same shape", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/ownerless-shape.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const ownerless = { + ...method, + declaring_type: null, + declaring_type_token: null, + }; + // Raw signature digests differ, so only the decoded shape links them. + const reshaped = (raw: string, token: string) => ({ + signature: { ...method.signature, raw_sha256: raw.repeat(64) }, + token, + }); + const left = { + ...base, + result: { ...base.result, methods: [ownerless], fields: [] }, + }; + const right = { + ...base, + result: { + ...base.result, + methods: [ + { ...ownerless, ...reshaped("e", "0x06000001") }, + { ...method, name: "Bar", ...reshaped("d", "0x06000002") }, + ], + fields: [], + }, + }; + const result = compareManagedMembers(left, right); + + expect(result.matching.ambiguous).toBe(1); + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toEqual([]); + }); +}); + +describe("managed member comparison of unresolved owners", () => { + it("does not pair members of unresolved types under different names", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/orphan-names.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const orphan = { declaring_type: null, declaring_type_token: null }; + const side = (name: string) => ({ + ...base, + result: { + ...base.result, + methods: [{ ...method, ...orphan, name }], + fields: [], + }, + }); + const result = compareManagedMembers(side("Main"), side("Renamed")); + + for (const item of result.methods) + expect(item.match.status).not.toBe("matched"); + }); + + it("keeps ownerless members that share a body ambiguous, as main does", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/orphan-self.dll"); + const [method] = base.result.methods; + const [field] = base.result.fields; + if (method === undefined || field === undefined) + throw new Error("Expected a fixture method and field"); + const orphan = { declaring_type: null, declaring_type_token: null }; + const inspected = { + ...base, + result: { + ...base.result, + methods: [ + { ...method, ...orphan, name: "p" }, + { ...method, ...orphan, name: "q", token: "0x06000002" }, + ], + fields: [ + { ...field, ...orphan, name: "a" }, + { ...field, ...orphan, name: "b", token: "0x04000002" }, + ], + }, + }; + const result = compareManagedMembers(inspected, inspected); + + // Their owners are unknown, so `p` may be `q`; names alone cannot tell. + expect(result.matching.ambiguous).toBe(2); + expect( + [...result.methods, ...result.fields].filter( + ({ match }) => match.status === "matched", + ), + ).toEqual([]); + }); + + it("does not remap a member whose own identity is in an ambiguous group", () => { + const undecoded = { methodSignature: Buffer.from([0x00, 0x00, 0x7f]) }; + const decoded = inspect(buildManagedPeFixture(), "/tmp/held-decoded.dll"); + const held = inspect(buildManagedPeFixture(undecoded), "/tmp/held.dll"); + const [undecodedMethod] = held.result.methods; + const [decodedMethod] = decoded.result.methods; + if (undecodedMethod === undefined || decodedMethod === undefined) + throw new Error("Expected fixture methods"); + // The right keeps two identical undecoded Fixture.Program.Main, which + // stay ambiguous, and a copy of the decoded Main in another type. + const left = { + ...held, + result: { + ...held.result, + methods: [{ ...undecodedMethod, token: "0x06000002" }, decodedMethod], + }, + }; + const right = { + ...held, + result: { + ...held.result, + methods: [ + undecodedMethod, + { ...undecodedMethod, token: "0x06000002" }, + { + ...decodedMethod, + declaring_type: "Other.Twin", + declaring_type_token: "0x02000003", + token: "0x06000003", + }, + ], + }, + }; + + for (const result of [ + compareManagedMembers(left, right), + compareManagedMembers(right, left), + ]) + expect( + result.methods.filter(({ match }) => match.status === "matched"), + ).toEqual([]); + }); + + it("does not remap a member whose declaring type is unresolved", () => { + const base = inspect(buildManagedPeFixture(), "/tmp/orphan.dll"); + const [method] = base.result.methods; + if (method === undefined) throw new Error("Expected a fixture method"); + const left = { + ...base, + result: { + ...base.result, + methods: [ + { ...method, declaring_type: null, declaring_type_token: null }, + ], + fields: [], + }, + }; + const right = { + ...base, + result: { + ...base.result, + methods: [ + { ...method, declaring_type: "Other.Twin", token: "0x06000002" }, + ], + fields: [], + }, + }; + + for (const result of [ + compareManagedMembers(left, right), + compareManagedMembers(right, left), + ]) + for (const item of result.methods) + expect(item.match.status).not.toBe("matched"); + }); +}); + +/** + * Add copies of the fixture's method and field, each pair declared by another + * type, to an inspection. The copies come from `source`, so they can carry + * the bytes of a different build than the inspection they join. + */ +const withTwin = ( + inspected: ReturnType, + source: ReturnType = inspected, + count = 1, +): ReturnType => { + const [method] = source.result.methods; + const [field] = source.result.fields; + if (method === undefined || field === undefined) + throw new Error("Expected a fixture method and field"); + const rows = Array.from({ length: count }, (_, index) => index + 2); + const twin = (row: number) => ({ + declaring_type: row === 2 ? "Other.Twin" : `Other.Twin${String(row)}`, + declaring_type_token: `0x0200000${String(row + 1)}`, + }); + return { + ...inspected, + result: { + ...inspected.result, + methods: [ + ...inspected.result.methods, + ...rows.map((row) => ({ + ...method, + ...twin(row), + token: `0x0600000${String(row)}`, + })), + ], + fields: [ + ...inspected.result.fields, + ...rows.map((row) => ({ + ...field, + ...twin(row), + token: `0x0400000${String(row)}`, + })), + ], + }, + }; +}; + const inspect = (bytes: Buffer, path: string, typeNames?: string[]) => { const target = managedPeFixtureTarget(bytes, path); const result = inspectManagedMembersBytes(