mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ export const prepareManagedMemberRecovery = (
|
||||
...inventory,
|
||||
metadata: { ...inventory.metadata, status: "partial" },
|
||||
signature_resolution: null,
|
||||
assembly: null,
|
||||
exported_types: null,
|
||||
fields: [],
|
||||
methods: [],
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user