diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 242a8d5732..e349f6e9ad 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -398,14 +398,16 @@ name from `call_edges` and `field_accesses`. Target tokens remain build-local and are not compared across artifacts. Missing or unaligned edges and missing target names leave that facet unknown; equal names do not establish overload identity or runtime dispatch. `exception-shape` compares clause flags and -ranges, while `exception-target` records changes to observed catch type names. -When those names differ but scoped identity is unavailable, the change remains -visible alongside `availability` and an identity limitation. +ranges. `exception-target` compares the nested nominal catch-type name digest; +for resolved TypeDefs it also compares the enclosing assembly identity. A +different known name or assembly scope establishes a change. Equal TypeRef +names remain unknown because their resolution scope is not resolved here. Exception tokens remain in the source facts but are not cross-build identity; an unresolved catch type leaves that facet unknown. The method body retains the -resolved `class_type` name and whether its local token identity could be -resolved. TypeRef names omit resolution-scope identity, so equal TypeRef names -leave the comparison unknown even when each local scope is valid. +display `class_type`, nested `class_type_name_sha256`, and whether its local +token identifies a TypeDef. A TypeDef digest alone does not prove identity +without matching assembly identity. Missing assembly identity and equal TypeRef +names leave the comparison unknown. Tokens are always remapped through observed structure. A caller cannot carry `0x06001234` into a new MVID and assume it names the same method. @@ -461,6 +463,16 @@ only when both identities are available and their format-specific comparison establishes a difference. ELF module names retain case, PE module names compare case-insensitively, and a case-only match for Mach-O or an unknown format stays unresolved because the available Evidence does not establish case behavior. +For PE imports, `.dll`, `.exe`, a trailing period, and absolute paths suppress +appending `.dll`; other names may also be tried with `.dll`. Unix loaders use +the platform suffix (`.so` or `.dylib`) and `lib` prefix variations. A suffix +already present at the end of the name or before a period still gets an +appended-suffix variation, so Linux can match `nativedep.so.6` with +`libnativedep.so.6`. The `lib` prefix is omitted when the name contains `/`, and +absolute paths are used as supplied. POSIX paths keep backslashes as literal +characters. These candidates follow [Microsoft's native library loading rules](https://learn.microsoft.com/en-us/dotnet/standard/native-interop/native-library-loading). +These filename rules compare declared and observed identities; they do not +establish which library the runtime loaded. Metadata tokens are not native addresses. Native body mapping for C++/CLI, ReadyToRun, NativeAOT, and IL2CPP requires separately verified format and provider diff --git a/src/contracts/managed/managedWorkflowExamples.ts b/src/contracts/managed/managedWorkflowExamples.ts index 83ed496902..367ee9a88f 100644 --- a/src/contracts/managed/managedWorkflowExamples.ts +++ b/src/contracts/managed/managedWorkflowExamples.ts @@ -22,6 +22,7 @@ const emptyManagedMembers = ( mvid: string, ): ManagedMemberInspection => ({ signature_resolution: null, + assembly: null, artifact: { path: `/examples/${sha256.slice(0, 8)}.dll`, sha256, diff --git a/src/domain/analysisView/managedMemberView.ts b/src/domain/analysisView/managedMemberView.ts index 1acf369d34..0c878aa231 100644 --- a/src/domain/analysisView/managedMemberView.ts +++ b/src/domain/analysisView/managedMemberView.ts @@ -68,6 +68,7 @@ export const managedMemberSummarySchema = z.strictObject({ nesting_read: member.nesting_read, artifact: member.artifact, module: member.module, + assembly: member.assembly, metadata: member.metadata, identity_scope: member.identity_scope, selection: member.selection.unwrap(), diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index 7b29be71da..daea732d4e 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -184,6 +184,12 @@ const managedExceptionRegionSchema = z.object({ ), /** Resolved from this artifact's own metadata; raw token remains build-local. */ class_type: z.string().nullable().default(null), + class_type_name_sha256: digestSchema + .nullable() + .default(null) + .describe( + "Nested nominal TypeDef/TypeRef name digest; assembly scope is compared separately and local tokens are not cross-artifact identities", + ), class_type_identity_resolved: z .boolean() .nullable() @@ -491,6 +497,7 @@ export const managedMemberInspectionSchema = z.object({ ), artifact: managedArtifactReferenceSchema, module: moduleIdentitySchema.nullable(), + assembly: assemblyIdentitySchema.nullable().default(null), metadata: managedMetadataSummarySchema, identity_scope: managedTokenIdentityScopeSchema, types: z.array(managedTypeSchema), diff --git a/src/domain/managed/managedMemberComparison.ts b/src/domain/managed/managedMemberComparison.ts index 02b5b70802..82d8b6f634 100644 --- a/src/domain/managed/managedMemberComparison.ts +++ b/src/domain/managed/managedMemberComparison.ts @@ -273,6 +273,8 @@ export const compareManagedMembers = ( signatureResolutionExamined: hasSignatureResolution(left.result) && hasSignatureResolution(right.result), + leftAssembly: left.result.assembly, + rightAssembly: right.result.assembly, leftCallEdgesByCaller: callEdgesByCaller(left.result), rightCallEdgesByCaller: callEdgesByCaller(right.result), leftFieldAccessesByCaller: fieldAccessesByCaller(left.result), diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index 56dd7d95d3..d960257f3e 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -41,6 +41,8 @@ interface ComparisonItemContext { readonly rightSelection: ReadonlySet | null; /** Whether matching keyed signatures by resolved digests, not raw bytes. */ readonly signatureResolutionExamined: boolean; + readonly leftAssembly: ManagedMemberInspection["assembly"]; + readonly rightAssembly: ManagedMemberInspection["assembly"]; readonly leftCallEdgesByCaller: CallEdgesByCaller; readonly rightCallEdgesByCaller: CallEdgesByCaller; readonly leftFieldAccessesByCaller: FieldAccessesByCaller; @@ -78,6 +80,8 @@ const methodDimensions = ( rightCallEdgesByCaller: CallEdgesByCaller, leftFieldAccessesByCaller: FieldAccessesByCaller, rightFieldAccessesByCaller: FieldAccessesByCaller, + leftAssembly: ManagedMemberInspection["assembly"], + rightAssembly: ManagedMemberInspection["assembly"], ): { readonly dimensions: MethodItem["dimensions"]; readonly bodyUnknown: boolean; @@ -153,6 +157,8 @@ const methodDimensions = ( ? compareExceptionTargets( left.body.exception_regions, right.body.exception_regions, + leftAssembly, + rightAssembly, ) : { changed: false, unknown: false }; if (callTargets.changed) dimensions.push("call-target"); @@ -215,6 +221,8 @@ const exceptionShape = (regions: Method["body"]["exception_regions"]): string => const compareExceptionTargets = ( left: Method["body"]["exception_regions"], right: Method["body"]["exception_regions"], + leftAssembly: ManagedMemberInspection["assembly"], + rightAssembly: ManagedMemberInspection["assembly"], ): { readonly changed: boolean; readonly unknown: boolean } => { let changed = false; let unknown = false; @@ -226,18 +234,53 @@ const compareExceptionTargets = ( continue; } if (leftRegion.flags !== 0) continue; - if (leftRegion.class_type === null || rightRegion.class_type === null) + const leftName = leftRegion.class_type_name_sha256; + const rightName = rightRegion.class_type_name_sha256; + if (leftName !== null && rightName !== null && leftName !== rightName) { + changed = true; + if ( + leftRegion.class_type_identity_resolved !== true || + rightRegion.class_type_identity_resolved !== true + ) + unknown = true; + continue; + } + if (leftName === null || rightName === null) { + if ( + leftRegion.class_type !== null && + rightRegion.class_type !== null && + leftRegion.class_type !== rightRegion.class_type + ) + changed = true; unknown = true; - else if (leftRegion.class_type !== rightRegion.class_type) changed = true; + continue; + } if ( leftRegion.class_type_identity_resolved !== true || - rightRegion.class_type_identity_resolved !== true - ) + rightRegion.class_type_identity_resolved !== true || + leftAssembly === null || + rightAssembly === null + ) { unknown = true; + continue; + } + if (!sameAssemblyIdentity(leftAssembly, rightAssembly)) changed = true; } return { changed, unknown }; }; +const sameAssemblyIdentity = ( + left: NonNullable, + right: NonNullable, +): boolean => + left.name.toLowerCase() === right.name.toLowerCase() && + left.version === right.version && + left.culture?.toLowerCase() === right.culture?.toLowerCase() && + (left.flags & 0x0f00) === (right.flags & 0x0f00) && + left.public_key.kind === right.public_key.kind && + left.public_key.sha256 === right.public_key.sha256 && + left.public_key.token === right.public_key.token; + const compareFieldTargets = ( left: Method, right: Method, @@ -669,6 +712,8 @@ export const buildMethodItems = ( context.rightCallEdgesByCaller, context.leftFieldAccessesByCaller, context.rightFieldAccessesByCaller, + context.leftAssembly, + context.rightAssembly, ); const dimensions = [ ...observedDimensions, diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index de67e2f704..1720e08e4f 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -327,6 +327,13 @@ export const inspectManagedMembersBytes = ( return { ...region, class_type: classType, + class_type_name_sha256: + region.class_token !== null && + (tokenTable === 1 || tokenTable === 2) + ? (signatureTypeName( + tokenRow * 4 + Number(tokenTable === 1), + )?.toString("hex") ?? null) + : null, class_type_identity_resolved: identityResolved, }; }), @@ -369,6 +376,7 @@ export const inspectManagedMembersBytes = ( ...envelope.metadata, status: issues.length === 0 ? "complete" : "partial", }, + assembly: inventory.assembly, types, signature_resolution: "type-names-v1", nesting_read: parsedTypes.nesting.complete, diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts index e33abf96c6..7526207b23 100644 --- a/src/dotnet/ManagedMemberInstructionDecoder.ts +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -653,6 +653,7 @@ const readExceptionClauses = ( class_token: flags === 0 ? `0x${extra.toString(16).padStart(8, "0")}` : null, class_type: null, + class_type_name_sha256: null, class_type_identity_resolved: null, filter_offset: flags === 1 ? extra : null, }); diff --git a/src/dotnet/ManagedMemberRecovery.ts b/src/dotnet/ManagedMemberRecovery.ts index be20b4452a..bf2e1395f8 100644 --- a/src/dotnet/ManagedMemberRecovery.ts +++ b/src/dotnet/ManagedMemberRecovery.ts @@ -38,6 +38,7 @@ export const prepareManagedMemberRecovery = ( ...inventory, metadata: { ...inventory.metadata, status: "partial" }, signature_resolution: null, + assembly: null, exported_types: null, fields: [], methods: [], diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index 7c61a6d5a8..f296ed7162 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -28,6 +28,8 @@ interface ManagedPeFixtureOptions { readonly methodSignature?: Buffer; readonly moduleRowCount?: number; readonly assemblyRowCount?: number; + readonly assemblyName?: string; + readonly assemblyFlags?: number; readonly memberRefParentRaw?: number; readonly memberRefRowCount?: number; readonly memberRefName?: string; @@ -326,6 +328,7 @@ const assemblyRow = ( name: number, stringSize: MetadataIndexSize, blobSize: MetadataIndexSize, + flags = 0, ): Buffer => Buffer.concat([ u32(0x0000_8004), @@ -333,7 +336,7 @@ const assemblyRow = ( u16(2), u16(3), u16(4), - u32(0), + u32(flags), metadataIndex(0, blobSize), metadataIndex(name, stringSize), metadataIndex(0, stringSize), @@ -940,7 +943,12 @@ const fixtureMetadataRows = ({ [ 32, repeatedRows(options.assemblyRowCount ?? 1, () => - assemblyRow(indexes.assemblyName, heapSizes.strings, heapSizes.blobs), + assemblyRow( + indexes.assemblyName, + heapSizes.strings, + heapSizes.blobs, + options.assemblyFlags, + ), ), ], [ @@ -1003,7 +1011,7 @@ const buildManagedFixtureMetadata = ( const strings = new StringHeap(); const blobs = new BlobHeap(); const moduleName = strings.add("Fixture.dll"); - const assemblyName = strings.add("Fixture.Managed"); + const assemblyName = strings.add(options.assemblyName ?? "Fixture.Managed"); const attributeName = strings.add("TargetFrameworkAttribute"); const attributeNamespace = strings.add("System.Runtime.Versioning"); const constructorName = strings.add(options.memberRefName ?? ".ctor"); diff --git a/tests/boundary/cli/managedMemberSummaryCli.test.ts b/tests/boundary/cli/managedMemberSummaryCli.test.ts index 37d6bb57cf..d8849d210b 100644 --- a/tests/boundary/cli/managedMemberSummaryCli.test.ts +++ b/tests/boundary/cli/managedMemberSummaryCli.test.ts @@ -193,6 +193,7 @@ cliTest( }); expect(summary.summary.coverage).toEqual(report.coverage); expect(summary.summary.identity_scope).toEqual(report.identity_scope); + expect(summary.summary.assembly).toEqual(report.assembly); expect(summary.summary).not.toHaveProperty("member_refs"); expect(summary.summary).not.toHaveProperty("methods"); diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 8e43debde4..b754b55d5d 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -215,6 +215,8 @@ describe("managed exception target comparison", () => { handler_length: 1, class_token: token, class_type: classType, + class_type_name_sha256: + classType === null ? null : hash(Buffer.from(classType)), class_type_identity_resolved: identityResolved, filter_offset: null, }, @@ -254,6 +256,12 @@ describe("managed exception target comparison", () => { withCatch(counterpart, "0x01000002", "Fixture.Error", false), ); expect(unresolved.methods[0]).toMatchObject({ status: "unknown" }); + + const missingDisplay = compareManagedMembers( + withCatch(base, "0x02000001", null), + withCatch(counterpart, "0x02000002", "Fixture.Error"), + ); + expect(missingDisplay.methods[0]).toMatchObject({ status: "unknown" }); }); it("keeps equal TypeRef catch names unknown when valid AssemblyRef scopes differ", () => { @@ -306,6 +314,118 @@ describe("managed exception target comparison", () => { }); }); +describe("managed exception target scoped identity", () => { + it("compares catch TypeDef nesting and assembly scope separately", () => { + const catchBody = () => { + const header = Buffer.alloc(12); + header.writeUInt16LE(0x300b, 0); + header.writeUInt16LE(8, 2); + header.writeUInt32LE(1, 4); + const section = Buffer.alloc(16); + section[0] = 0x01; + section[1] = section.length; + section[8] = 0; + section.writeUInt16LE(0, 9); + section[11] = 1; + section.writeUInt32LE(0x0200_0002, 12); + return Buffer.concat([ + header, + Buffer.from([0x2a]), + Buffer.alloc(3), + section, + ]); + }; + const observe = ( + path: string, + options: Parameters[0], + ) => + inspect(buildManagedPeFixture({ ilBody: catchBody(), ...options }), path); + const topLevelOuter = observe("/tmp/catch-nested-base.dll", { + typeName: "Outer", + typeNamespace: "Game", + additionalEmptyTypes: 2, + nestedTypes: [[2, 1]], + }); + const nestedOuter = observe("/tmp/catch-nested-parent-change.dll", { + typeName: "Outer", + typeNamespace: "Game", + additionalEmptyTypes: 2, + nestedTypes: [ + [1, 3], + [2, 1], + ], + }); + const otherAssembly = observe("/tmp/catch-nested-other-assembly.dll", { + typeName: "Outer", + typeNamespace: "Game", + additionalEmptyTypes: 2, + nestedTypes: [[2, 1]], + assemblyName: "Fixture.Other", + }); + const differentJitFlags = observe("/tmp/catch-nested-jit-flags.dll", { + typeName: "Outer", + typeNamespace: "Game", + additionalEmptyTypes: 2, + nestedTypes: [[2, 1]], + assemblyFlags: 0x0000_4000, + }); + const retargetableAssembly = observe("/tmp/catch-nested-retargetable.dll", { + typeName: "Outer", + typeNamespace: "Game", + additionalEmptyTypes: 2, + nestedTypes: [[2, 1]], + assemblyFlags: 0x0000_0100, + }); + const noAssembly = observe("/tmp/catch-nested-no-assembly.dll", { + typeName: "Outer", + typeNamespace: "Game", + additionalEmptyTypes: 2, + nestedTypes: [[2, 1]], + assemblyRowCount: 0, + }); + expect(differentJitFlags.result.assembly).toMatchObject({ + flags: 0x4000, + hash_algorithm: 0x8004, + }); + expect(retargetableAssembly.result.assembly?.flags).toBe(0x100); + const baseRegion = + topLevelOuter.result.methods[0]?.body.exception_regions[0]; + const nestedRegion = + nestedOuter.result.methods[0]?.body.exception_regions[0]; + expect(baseRegion?.class_type).toBe(nestedRegion?.class_type); + expect(baseRegion?.class_type_name_sha256).not.toBe( + nestedRegion?.class_type_name_sha256, + ); + expect( + compareManagedMembers(topLevelOuter, nestedOuter).methods[0], + ).toMatchObject({ + status: "changed", + dimensions: expect.arrayContaining(["exception-target"]), + }); + expect( + compareManagedMembers(topLevelOuter, otherAssembly).methods[0], + ).toMatchObject({ + status: "changed", + dimensions: expect.arrayContaining(["exception-target"]), + }); + expect( + compareManagedMembers(topLevelOuter, differentJitFlags).methods[0], + ).toMatchObject({ status: "unchanged" }); + expect( + compareManagedMembers(topLevelOuter, retargetableAssembly).methods[0], + ).toMatchObject({ + status: "changed", + dimensions: expect.arrayContaining(["exception-target"]), + }); + expect( + compareManagedMembers(topLevelOuter, noAssembly).methods[0], + ).toMatchObject({ + status: "unknown", + dimensions: expect.arrayContaining(["availability"]), + }); + }); +}); + describe("managed member structural matching", () => { it("keeps unequal constants from becoming a structural remap", () => { const observe = (