fix(managed): compare catch types by scoped nominal identity

Preserve boundary facts and uncertainty through their consumers. Add focused production-boundary regressions and update affected documentation.

Fixes #6760
This commit is contained in:
morluto
2026-10-23 23:49:26 +08:00
parent c409a8bbe8
commit ecd997e512
12 changed files with 220 additions and 13 deletions
+18 -6
View File
@@ -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
@@ -22,6 +22,7 @@ const emptyManagedMembers = (
mvid: string,
): ManagedMemberInspection => ({
signature_resolution: null,
assembly: null,
artifact: {
path: `/examples/${sha256.slice(0, 8)}.dll`,
sha256,
@@ -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(),
+7
View File
@@ -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),
@@ -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),
@@ -41,6 +41,8 @@ interface ComparisonItemContext {
readonly rightSelection: ReadonlySet<string> | 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<ManagedMemberInspection["assembly"]>,
right: NonNullable<ManagedMemberInspection["assembly"]>,
): 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,
+8
View File
@@ -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,
@@ -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,
});
+1
View File
@@ -38,6 +38,7 @@ export const prepareManagedMemberRecovery = (
...inventory,
metadata: { ...inventory.metadata, status: "partial" },
signature_resolution: null,
assembly: null,
exported_types: null,
fields: [],
methods: [],
+11 -3
View File
@@ -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");
@@ -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");
@@ -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<typeof buildManagedPeFixture>[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 = (