From a8b8ff2acfdd5677bf3028c7a94eef85063577ad Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:05:31 +0800 Subject: [PATCH] fix(dotnet): preserve managed metadata comparison facts Decode VARARG sentinels and allow omitted empty blob heaps while retaining failures for referenced missing data. Compare field and catch targets using owning parsed facts instead of build-local tokens. Fixes #6720 Fixes #6686 Fixes #6713 Fixes #6712 --- docs/managed-code-analysis.md | 24 ++- src/domain/managed/managedArtifact.ts | 15 +- src/domain/managed/managedMemberComparison.ts | 23 +++ .../managed/managedMemberComparisonItems.ts | 170 ++++++++++++++++-- src/dotnet/ManagedMemberInspector.test.ts | 38 +++- src/dotnet/ManagedMemberInspector.ts | 47 ++++- src/dotnet/ManagedMemberInspectorCore.ts | 51 +++++- src/dotnet/ManagedMemberInstructionDecoder.ts | 2 + src/dotnet/ManagedMemberRows.ts | 11 +- src/dotnet/ManagedMetadataHeaps.test.ts | 55 +++++- src/dotnet/ManagedMetadataHeaps.ts | 23 ++- src/dotnet/ManagedMetadataInventory.ts | 2 +- src/dotnet/ManagedMetadataInventoryRows.ts | 7 +- src/dotnet/ManagedMetadataLayout.ts | 5 +- src/dotnet/ManagedMethodBodyReader.ts | 2 +- src/dotnet/ManagedMethodSpecs.ts | 4 +- src/dotnet/ManagedNativeBoundaryInspector.ts | 2 +- src/dotnet/ManagedTypeNames.ts | 2 +- ...anagedMemberComparisonFromMetadata.test.ts | 110 ++++++++++++ 19 files changed, 548 insertions(+), 45 deletions(-) diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 9c3ea2eed3..eeb9f17fe4 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -51,6 +51,11 @@ This keeps the complete source in the session and returns a distinct linked evidence, metadata status, module/MVID, assembly, frameworks, returned-row counts, coverage/issues and limitations. No assembly is loaded or executed. +ECMA-335 permits empty metadata streams to be omitted. An absent `#Blob` +stream therefore does not prevent module or assembly identity from being read +when all blob indexes are zero; a nonzero reference to that missing heap is +reported as a missing-stream issue. + Use `normalized_result.parent_evidence_id` to page original rows: ```json @@ -386,12 +391,19 @@ constants, and bounded shape context. It reports all candidates at the winning score and remains ambiguous when the evidence does not distinguish them. The digest does not itself remap metadata tokens. -Matched methods also compare aligned call sites. `call-shape` reports opcode and -operand-kind differences; `call-target` reports differences in the observed -target declaring-type or method name from `call_edges`. Target tokens remain -build-local and are not compared across artifacts. Missing or unaligned call -edges and missing target names leave that facet unknown; equal names do not -establish overload identity or runtime dispatch. +Matched methods also compare aligned references. `call-shape` and +`field-shape` report opcode and operand-kind differences; `call-target` and +`field-target` report differences in the observed target declaring type or +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` compares each resolved catch type name. +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; equal TypeRef names with invalid or unresolved scopes 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. diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index 7b129ab9e7..7b29be71da 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -177,7 +177,20 @@ const managedExceptionRegionSchema = z.object({ try_length: z.int().min(0), handler_offset: z.int().min(0), handler_length: z.int().min(0), - class_token: managedMetadataTokenSchema.nullable(), + class_token: managedMetadataTokenSchema + .nullable() + .describe( + "Raw build-local catch type token; not a cross-artifact identity", + ), + /** Resolved from this artifact's own metadata; raw token remains build-local. */ + class_type: z.string().nullable().default(null), + class_type_identity_resolved: z + .boolean() + .nullable() + .default(null) + .describe( + "Whether this artifact's local token identifies the reported catch type; null when unexamined", + ), filter_offset: z.int().min(0).nullable(), }); diff --git a/src/domain/managed/managedMemberComparison.ts b/src/domain/managed/managedMemberComparison.ts index 73b0277cc9..02b5b70802 100644 --- a/src/domain/managed/managedMemberComparison.ts +++ b/src/domain/managed/managedMemberComparison.ts @@ -134,7 +134,9 @@ const methodComparisonItemSchema = comparisonItemSchema( "call-shape", "call-target", "field-shape", + "field-target", "exception-shape", + "exception-target", "availability", "body-coverage", ]), @@ -236,6 +238,25 @@ const callEdgesByCaller = ( return edges; }; +const fieldAccessesByCaller = ( + result: ManagedMemberInspection, +): ReadonlyMap< + string, + readonly ManagedMemberInspection["field_accesses"][number][] +> => { + const accesses = new Map< + string, + ManagedMemberInspection["field_accesses"][number][] + >(); + for (const access of result.field_accesses) { + const callerAccesses = accesses.get(access.method_token); + if (callerAccesses === undefined) + accesses.set(access.method_token, [access]); + else callerAccesses.push(access); + } + return accesses; +}; + /** Compare managed members with constrained exact and structural identities. */ export const compareManagedMembers = ( left: ManagedMemberComparisonSide, @@ -254,6 +275,8 @@ export const compareManagedMembers = ( hasSignatureResolution(right.result), leftCallEdgesByCaller: callEdgesByCaller(left.result), rightCallEdgesByCaller: callEdgesByCaller(right.result), + leftFieldAccessesByCaller: fieldAccessesByCaller(left.result), + rightFieldAccessesByCaller: fieldAccessesByCaller(right.result), }; const methodItems = buildMethodItems(methodMatches, itemContext); const fieldItems = buildFieldItems(fieldMatches, itemContext); diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index 583f2d3bee..666fafdf1f 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -16,6 +16,10 @@ type CallEdgesByCaller = ReadonlyMap< string, readonly ManagedMemberInspection["call_edges"][number][] >; +type FieldAccessesByCaller = ReadonlyMap< + string, + readonly ManagedMemberInspection["field_accesses"][number][] +>; type MethodItem = ManagedMemberComparisonResult["methods"][number]; type FieldItem = ManagedMemberComparisonResult["fields"][number]; type UnmatchedComparison = Extract< @@ -39,6 +43,8 @@ interface ComparisonItemContext { readonly signatureResolutionExamined: boolean; readonly leftCallEdgesByCaller: CallEdgesByCaller; readonly rightCallEdgesByCaller: CallEdgesByCaller; + readonly leftFieldAccessesByCaller: FieldAccessesByCaller; + readonly rightFieldAccessesByCaller: FieldAccessesByCaller; } /** Whether an inventory observed every member of this member's declaring type. */ @@ -70,10 +76,14 @@ const methodDimensions = ( right: Method, leftCallEdgesByCaller: CallEdgesByCaller, rightCallEdgesByCaller: CallEdgesByCaller, + leftFieldAccessesByCaller: FieldAccessesByCaller, + rightFieldAccessesByCaller: FieldAccessesByCaller, ): { readonly dimensions: MethodItem["dimensions"]; readonly bodyUnknown: boolean; readonly callTargetUnknown: boolean; + readonly fieldTargetUnknown: boolean; + readonly exceptionTargetUnknown: boolean; } => { const dimensions: MethodItem["dimensions"] = []; const headerFields = ["max_stack", "init_locals"] as const; @@ -109,7 +119,13 @@ const methodDimensions = ( ) dimensions.push("availability"); if (bothAbsent) - return { dimensions, bodyUnknown: false, callTargetUnknown: false }; + return { + dimensions, + bodyUnknown: false, + callTargetUnknown: false, + fieldTargetUnknown: false, + exceptionTargetUnknown: false, + }; const sameCallShape = callShape(left.body.anchors) === callShape(right.body.anchors); const callTargets = sameCallShape @@ -120,12 +136,35 @@ const methodDimensions = ( rightCallEdgesByCaller.get(right.token) ?? [], ) : { changed: false, unknown: false }; + const sameFieldShape = + fieldShape(left.body.anchors) === fieldShape(right.body.anchors); + const fieldTargets = sameFieldShape + ? compareFieldTargets( + left, + right, + leftFieldAccessesByCaller.get(left.token) ?? [], + rightFieldAccessesByCaller.get(right.token) ?? [], + ) + : { changed: false, unknown: false }; + const sameExceptionShape = + exceptionShape(left.body.exception_regions) === + exceptionShape(right.body.exception_regions); + const exceptionTargets = sameExceptionShape + ? compareExceptionTargets( + left.body.exception_regions, + right.body.exception_regions, + ) + : { changed: false, unknown: false }; if (callTargets.changed) dimensions.push("call-target"); + if (fieldTargets.changed) dimensions.push("field-target"); + if (exceptionTargets.changed) dimensions.push("exception-target"); if (!leftBodyComplete || !rightBodyComplete) return { dimensions, bodyUnknown: true, callTargetUnknown: callTargets.unknown, + fieldTargetUnknown: fieldTargets.unknown, + exceptionTargetUnknown: exceptionTargets.unknown, }; if (!sameCallShape) dimensions.push("call-shape"); // The decoded fingerprint omits switch targets and is not exact CIL identity. @@ -139,22 +178,113 @@ const methodDimensions = ( canonicalize(right.body.opcode_counts) ) dimensions.push("opcode-shape"); - if (fieldShape(left.body.anchors) !== fieldShape(right.body.anchors)) - dimensions.push("field-shape"); - if ( - canonicalize(left.body.exception_regions) !== - canonicalize(right.body.exception_regions) - ) - dimensions.push("exception-shape"); + if (!sameFieldShape) dimensions.push("field-shape"); + if (!sameExceptionShape) dimensions.push("exception-shape"); return { dimensions, callTargetUnknown: callTargets.unknown, + fieldTargetUnknown: fieldTargets.unknown, + exceptionTargetUnknown: exceptionTargets.unknown, bodyUnknown: headerFields.some( (field) => left.body[field] === null || right.body[field] === null, ), }; }; +const exceptionShape = (regions: Method["body"]["exception_regions"]): string => + JSON.stringify( + regions.map( + ({ + flags, + try_offset, + try_length, + handler_offset, + handler_length, + filter_offset, + }) => [ + flags, + try_offset, + try_length, + handler_offset, + handler_length, + filter_offset, + ], + ), + ); + +const compareExceptionTargets = ( + left: Method["body"]["exception_regions"], + right: Method["body"]["exception_regions"], +): { readonly changed: boolean; readonly unknown: boolean } => { + let changed = false; + let unknown = false; + for (let index = 0; index < left.length; index += 1) { + const leftRegion = left[index]; + const rightRegion = right[index]; + if (leftRegion === undefined || rightRegion === undefined) { + unknown = true; + continue; + } + if (leftRegion.flags !== 0) continue; + if (leftRegion.class_type === null || rightRegion.class_type === null) + unknown = true; + else if (leftRegion.class_type !== rightRegion.class_type) changed = true; + else if ( + leftRegion.class_type_identity_resolved !== true || + rightRegion.class_type_identity_resolved !== true + ) + unknown = true; + } + return { changed, unknown }; +}; + +const compareFieldTargets = ( + left: Method, + right: Method, + leftAccesses: readonly ManagedMemberInspection["field_accesses"][number][], + rightAccesses: readonly ManagedMemberInspection["field_accesses"][number][], +): { readonly changed: boolean; readonly unknown: boolean } => { + const fieldAnchors = (method: Method) => + method.body.anchors.filter( + (instruction) => + isManagedMemberReferenceInstruction(instruction) && + instruction.operand_kind === "field", + ); + const leftAnchors = fieldAnchors(left); + const rightAnchors = fieldAnchors(right); + if (leftAnchors.length !== rightAnchors.length) + return { changed: false, unknown: true }; + if ( + leftAccesses.length !== leftAnchors.length || + rightAccesses.length !== rightAnchors.length || + leftAccesses.some( + (edge, index) => edge.opcode !== leftAnchors[index]?.opcode, + ) || + rightAccesses.some( + (edge, index) => edge.opcode !== rightAnchors[index]?.opcode, + ) + ) + return { changed: false, unknown: true }; + + let changed = false; + let unknown = false; + for (let index = 0; index < leftAccesses.length; index += 1) { + const leftAccess = leftAccesses[index]; + const rightAccess = rightAccesses[index]; + if (leftAccess === undefined || rightAccess === undefined) { + unknown = true; + continue; + } + for (const field of ["field_name", "field_declaring_type"] as const) { + const leftValue = leftAccess[field]; + const rightValue = rightAccess[field]; + if (leftValue === null || rightValue === null) unknown = true; + else if (leftValue !== rightValue) changed = true; + } + } + return { changed, unknown }; +}; + const compareCallTargets = ( left: Method, right: Method, @@ -530,16 +660,22 @@ export const buildMethodItems = ( dimensions: observedDimensions, bodyUnknown, callTargetUnknown, + fieldTargetUnknown, + exceptionTargetUnknown, } = methodDimensions( pair.left.item, pair.right.item, context.leftCallEdgesByCaller, context.rightCallEdgesByCaller, + context.leftFieldAccessesByCaller, + context.rightFieldAccessesByCaller, ); const dimensions = [ ...observedDimensions, ...(bodyUnknown ? (["body-coverage"] as const) : []), - ...(callTargetUnknown ? (["availability"] as const) : []), + ...(callTargetUnknown || fieldTargetUnknown || exceptionTargetUnknown + ? (["availability"] as const) + : []), ]; const observedChange = observedDimensions.length > 0; items.push({ @@ -553,7 +689,11 @@ export const buildMethodItems = ( )}`, status: observedChange ? "changed" - : bodyUnknown || callTargetUnknown || signatureLimitations.length > 0 + : bodyUnknown || + callTargetUnknown || + fieldTargetUnknown || + exceptionTargetUnknown || + signatureLimitations.length > 0 ? "unknown" : "unchanged", left: methodIdentity(pair.left.item), @@ -579,6 +719,16 @@ export const buildMethodItems = ( "One or more call targets are unavailable because the observed method edges do not cover aligned call instructions or lack a target name or declaring type.", ] : []), + ...(fieldTargetUnknown + ? [ + "One or more field targets are unavailable because observed field accesses do not cover aligned field instructions or lack a target name or declaring type.", + ] + : []), + ...(exceptionTargetUnknown + ? [ + "One or more catch types are unavailable because the class token could not be resolved in its own metadata.", + ] + : []), ], }); } diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index aeac0c8f57..c42e45d0a8 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -281,7 +281,7 @@ it.each([ bytes, layout, row.readIndex(layout.blobIndexSize), - layout.blob.size, + layout.blob?.size ?? 0, ); const blobOffset = blob.byteOffset - bytes.byteOffset; const result = inspectManagedMembersBytes( @@ -2107,6 +2107,42 @@ it("decodes typed references in parameter and return positions", () => { }); }); +it("decodes VARARG optional parameters in method and function-pointer signatures", () => { + expect( + signature(Buffer.from([0x05, 0x02, 0x01, 0x08, 0x41, 0x0e])), + ).toMatchObject({ + kind: "method", + parse_status: "decoded", + parameter_count: 2, + parameter_types: ["i4", "string"], + }); + expect( + signature(Buffer.from([0x06, 0x1b, 0x05, 0x02, 0x01, 0x08, 0x41, 0x0e])), + ).toMatchObject({ + kind: "field", + parse_status: "decoded", + field_type: "fnptr[vararg](i4,string)->void", + }); +}); + +it.each([ + [ + "sentinel in a DEFAULT signature", + [0x00, 0x01, 0x01, 0x41, 0x08], + "unsupported", + ], + [ + "duplicate VARARG sentinels", + [0x05, 0x02, 0x01, 0x41, 0x41, 0x08], + "malformed", + ], +])("does not accept %s as a valid method signature", (_name, bytes, status) => { + expect(signature(Buffer.from(bytes))).toMatchObject({ + kind: "unknown", + parse_status: status, + }); +}); + it.each([ ["by-reference field", [0x06, 0x10, 0x08], { field_type: "i4&" }], ["void pointer field", [0x06, 0x0f, 0x01], { field_type: "void*" }], diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index d937ff73f6..ceaa1fb739 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -270,10 +270,17 @@ export const inspectManagedMembersBytes = ( signatureTypeName, selected, }); + const resolveTypeName = typeNameLookup( + bytes, + layout, + ranges, + issues, + typeReferences, + ); const memberRefs = parseMemberRefs( bytes, layout, - typeNameLookup(bytes, layout, ranges, issues, typeReferences), + resolveTypeName, signatureTypeName, ); appendManagedParseIssues(issues, memberRefs.issues); @@ -297,6 +304,36 @@ export const inspectManagedMembersBytes = ( methodLimit, }); appendManagedParseIssues(issues, fields.issues, methods.issues); + const resolvedMethods = methods.methods.map((method) => ({ + ...method, + body: { + ...method.body, + exception_regions: method.body.exception_regions.map((region) => { + const classType = resolveTypeName(region.class_token); + const tokenTable = + region.class_token === null + ? null + : Number.parseInt(region.class_token.slice(2, 4), 16); + const tokenRow = + region.class_token === null + ? 0 + : Number.parseInt(region.class_token.slice(4), 16); + const typeDefOrRefTag = + tokenTable === 2 ? 0 : tokenTable === 1 ? 1 : null; + const identityResolved = + region.class_token === null + ? null + : classType !== null && typeDefOrRefTag !== null + ? signatureTypeName(tokenRow * 4 + typeDefOrRefTag) !== null + : false; + return { + ...region, + class_type: classType, + class_type_identity_resolved: identityResolved, + }; + }), + }, + })); // #US literals sit beside the member inventory and carry their own // coverage, so their issues never make member facts partial. const userStrings = readUserStrings @@ -315,13 +352,13 @@ export const inspectManagedMembersBytes = ( [...fields.fields, ...memberRefs.refs, ...methods.methods].some( (member) => member.signature.parse_status !== "decoded", ) || - methods.methods.some( + resolvedMethods.some( (method) => method.body.status === "partial" || method.body.status === "malformed", ); const related = edges( - methods.methods, + resolvedMethods, targets.members, managedDecodeBudget(layout), ); @@ -339,7 +376,7 @@ export const inspectManagedMembersBytes = ( nesting_read: parsedTypes.nesting.complete, exported_types: exportedTypes.exportedTypes, fields: fields.fields, - methods: methods.methods, + methods: resolvedMethods, member_refs: memberRefs.refs, call_edges: related.callEdges, field_accesses: related.fieldAccesses, @@ -358,7 +395,7 @@ export const inspectManagedMembersBytes = ( "CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.", "Exported types are reported from ExportedType rows as declared; forwarded types are not resolved in their implementing assemblies.", "User strings are the #US literals loaded by ldstr in the inspected method bodies, with their own coverage; instruction anchors and normalized CIL digests keep their build-local tokens.", - "Member signatures decode ECMA-335 primitive, class, valuetype, generic instantiation, generic variable, pointer, byref, single-dimension and general array, function pointer, custom modifier, and typed reference forms; class and valuetype operands keep their encoded TypeDefOrRefOrSpec values, and unsupported forms such as vararg sentinels retain raw signature hashes.", + "Member signatures decode ECMA-335 primitive, class, valuetype, generic instantiation, generic variable, pointer, byref, single-dimension and general array, function pointer including VARARG sentinels, custom modifier, and typed reference forms; class and valuetype operands keep their encoded TypeDefOrRefOrSpec values.", ...selectionLimitations, ], }, diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index b684fa91e0..8d9e45ec19 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -345,6 +345,8 @@ type TypeContinuation = // Function pointer items are a RetType then Params; generic // arguments are Types. readonly signatureItems: boolean; + vararg?: boolean; + sentinelSeen?: boolean; readonly render: (items: readonly string[]) => string; }; @@ -546,6 +548,36 @@ const readTypeSignature = ( const kind = blob[cursor]; if (kind === undefined) return malformedSignature("truncated type signature", cursor); + if (kind === 0x41) { + const signature = stack.at(-1); + if (signature?.kind !== "list" || !signature.signatureItems) + throw managedFailure( + "unsupported-signature", + "signature", + "unsupported element type 0x41", + cursor, + ); + if (!signature.vararg) + throw managedFailure( + "unsupported-signature", + "signature", + "unsupported element type 0x41", + cursor, + ); + if (signature.sentinelSeen || signature.remaining === 0) + return malformedSignature( + "VARARG function pointer has an invalid sentinel position", + cursor, + ); + signature.sentinelSeen = true; + cursor += 1; + if (blob[cursor] === undefined || blob[cursor] === 0x41) + return malformedSignature( + "VARARG function pointer has adjacent sentinels", + cursor, + ); + continue; + } const positions = POSITION_ELEMENTS.get(kind); if ( positions !== undefined && @@ -658,6 +690,8 @@ const readTypeSignature = ( remaining: header.parameterCount + 1, items: [], signatureItems: true, + vararg: (header.first & 0x0f) === 5, + sentinelSeen: false, render: ([returnType, ...parameters]) => `fnptr[${head}](${parameters.join(",")})->${returnType ?? ""}`, }); @@ -871,7 +905,19 @@ const decodeSignature = ( }); let offset = returnType.next; const parameters: string[] = []; + let sentinelSeen = false; for (let index = 0; index < header.parameterCount; index += 1) { + if (blob[offset] === 0x41 && (first & 0x0f) === 5) { + if (sentinelSeen) + malformedSignature( + "VARARG signature has an invalid sentinel position", + offset, + ); + sentinelSeen = true; + offset += 1; + if (blob[offset] === undefined || blob[offset] === 0x41) + malformedSignature("VARARG signature has adjacent sentinels", offset); + } const parameter = readTypeSignature(blob, offset, "parameter", { operands, ...(layout === undefined ? {} : { layout }), @@ -927,7 +973,10 @@ export const methodGenericArity = ( blob: Buffer, ): number | null => { const budget = managedDecodeBudget(layout); - budget.admitTransient(blob.length * 256, layout.blob.offset); + budget.admitTransient( + blob.length * 256, + layout.blob?.offset ?? layout.rootOffset, + ); try { return signatureGenericArity(decodeSignature(blob)); } finally { diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts index 5f8d814de3..e33abf96c6 100644 --- a/src/dotnet/ManagedMemberInstructionDecoder.ts +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -652,6 +652,8 @@ const readExceptionClauses = ( : bytes.readUInt8(clause + 7), class_token: flags === 0 ? `0x${extra.toString(16).padStart(8, "0")}` : null, + class_type: null, + class_type_identity_resolved: null, filter_offset: flags === 1 ? extra : null, }); } diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 0f5cb86076..290e31e7a5 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -101,7 +101,7 @@ export const parseFields = ( bytes, layout, cursor.readIndex(layout.blobIndexSize), - layout.blob.size, + layout.blob?.size ?? 0, ); fields.push({ token, @@ -224,7 +224,7 @@ export const parseMemberRefs = ( bytes, layout, cursor.readIndex(layout.blobIndexSize), - layout.blob.size, + layout.blob?.size ?? 0, ); const token = metadataToken(10, row); const parentReason = metadataCodedTokenInvalidReason( @@ -362,7 +362,7 @@ export const parseMethods = ({ bytes, layout, cursor.readIndex(layout.blobIndexSize), - layout.blob.size, + layout.blob?.size ?? 0, ); cursor.readIndex(layout.tableIndexSize(8)); const decoded = budgetedSignature( @@ -515,7 +515,10 @@ const budgetedSignature = ( scope: string, ): ReturnType => { const budget = managedDecodeBudget(layout); - budget.admitTransient(bytes.length * 256, layout.blob.offset); + budget.admitTransient( + bytes.length * 256, + layout.blob?.offset ?? layout.rootOffset, + ); try { return admitManagedProjection( metadataSignature(bytes, { diff --git a/src/dotnet/ManagedMetadataHeaps.test.ts b/src/dotnet/ManagedMetadataHeaps.test.ts index 0795455c7b..0f1faa40df 100644 --- a/src/dotnet/ManagedMetadataHeaps.test.ts +++ b/src/dotnet/ManagedMetadataHeaps.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + readManagedMetadataLayout, readMetadataBlob, readMetadataString, readMetadataUserString, @@ -8,6 +9,54 @@ import { import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; describe("managed metadata heaps", () => { + it("admits a metadata root that omits its empty #Blob stream", () => { + const bytes = Buffer.alloc(108); + bytes.writeUInt32LE(0x424a_5342, 0); + bytes.writeUInt16LE(1, 4); + bytes.writeUInt16LE(1, 6); + const version = Buffer.from("v4.0.30319\0\0", "ascii"); + bytes.writeUInt32LE(version.length, 12); + version.copy(bytes, 16); + bytes.writeUInt16LE(0, 28); + bytes.writeUInt16LE(3, 30); + const streams = [ + { name: "#~", offset: 80, size: 24, nameSize: 4 }, + { name: "#Strings", offset: 104, size: 4, nameSize: 12 }, + { name: "#GUID", offset: 108, size: 0, nameSize: 8 }, + ] as const; + let header = 32; + for (const stream of streams) { + bytes.writeUInt32LE(stream.offset, header); + bytes.writeUInt32LE(stream.size, header + 4); + bytes.write(`${stream.name}\0`, header + 8, "ascii"); + header += 8 + stream.nameSize; + } + bytes[104] = 0; + + const layout = readManagedMetadataLayout(bytes, 0, bytes.length); + expect(layout.streamNames).toEqual(["#GUID", "#Strings", "#~"]); + expect(layout.blob).toBeNull(); + }); + + it("accepts the empty blob entry when an empty #Blob stream is omitted", () => { + const layout = { + rootOffset: 16, + blob: null, + } as unknown as ManagedMetadataLayout; + + expect(readMetadataBlob(Buffer.alloc(0), layout, 0, 0)).toEqual( + Buffer.alloc(0), + ); + expect(() => readMetadataBlob(Buffer.alloc(0), layout, 1, 0)).toThrowError( + expect.objectContaining({ + issue: expect.objectContaining({ + code: "missing-stream", + scope: "metadata.#Blob", + }), + }), + ); + }); + it("reads a blob item's compressed length from its ECMA-335 prefix", () => { const itemLength = 1_048_577; const bytes = Buffer.alloc(itemLength + 5); @@ -31,9 +80,9 @@ describe("managed metadata heaps", () => { codedIndexSize: () => 2, }; - expect(readMetadataBlob(bytes, layout, 1, layout.blob.size)).toHaveLength( - itemLength, - ); + expect( + readMetadataBlob(bytes, layout, 1, layout.blob?.size ?? 0), + ).toHaveLength(itemLength); }); it.each([[[0x80]], [[0xc0, 0x00, 0x00]]])( diff --git a/src/dotnet/ManagedMetadataHeaps.ts b/src/dotnet/ManagedMetadataHeaps.ts index 706980a633..50e5d56a83 100644 --- a/src/dotnet/ManagedMetadataHeaps.ts +++ b/src/dotnet/ManagedMetadataHeaps.ts @@ -167,14 +167,29 @@ export const readMetadataBlob = ( heapExtent: number, ): Buffer => { if (index === 0) return Buffer.alloc(0); - if (index < 0 || index >= layout.blob.size) + if (index < 0) + throw managedFailure( + "invalid-heap-index", + "metadata.#Blob", + "Blob heap index is negative", + null, + ); + const heap = layout.blob; + if (heap === null) + throw managedFailure( + "missing-stream", + "metadata.#Blob", + `Blob index ${String(index)} references an absent #Blob stream`, + null, + ); + if (index >= heap.size) throw managedFailure( "invalid-heap-index", "metadata.#Blob", "Blob heap index is outside #Blob", - layout.blob.offset + Math.max(index, 0), + heap.offset + Math.max(index, 0), ); - const start = layout.blob.offset + index; + const start = heap.offset + index; const decoded = compressedLength(bytes, start, "metadata.#Blob", "blob"); if (decoded.length > heapExtent) throw managedFailure( @@ -184,7 +199,7 @@ export const readMetadataBlob = ( start, ); const content = start + decoded.prefix; - if (content > layout.blob.offset + layout.blob.size - decoded.length) + if (content > heap.offset + heap.size - decoded.length) throw managedFailure( "invalid-blob", "metadata.#Blob", diff --git a/src/dotnet/ManagedMetadataInventory.ts b/src/dotnet/ManagedMetadataInventory.ts index 640b9c0d1c..1ffec64051 100644 --- a/src/dotnet/ManagedMetadataInventory.ts +++ b/src/dotnet/ManagedMetadataInventory.ts @@ -76,7 +76,7 @@ const readRows = ( }; const heapExtent = (layout: ManagedMetadataLayout): number => - Math.max(layout.strings.size, layout.blob.size); + Math.max(layout.strings.size, layout.blob?.size ?? 0); const validateIdentityTableCounts = ( layout: ManagedMetadataLayout, diff --git a/src/dotnet/ManagedMetadataInventoryRows.ts b/src/dotnet/ManagedMetadataInventoryRows.ts index 38d2df94b5..4e3559388a 100644 --- a/src/dotnet/ManagedMetadataInventoryRows.ts +++ b/src/dotnet/ManagedMetadataInventoryRows.ts @@ -367,7 +367,10 @@ const decodeFixedString = ( if (length === undefined) return null; const start = 2 + length.prefix; if (start > blob.length - length.length) return null; - managedDecodeBudget(layout).reserveString(length.length, layout.blob.offset); + managedDecodeBudget(layout).reserveString( + length.length, + layout.blob?.offset ?? layout.rootOffset, + ); try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( blob.subarray(start, start + length.length), @@ -489,7 +492,7 @@ export const readResource = ({ bytes, layout, cursor.readIndex(layout.stringIndexSize), - Math.max(layout.strings.size, layout.blob.size), + Math.max(layout.strings.size, layout.blob?.size ?? 0), ); const implementationOffset = cursor.offset; const implementationRaw = cursor.readIndex( diff --git a/src/dotnet/ManagedMetadataLayout.ts b/src/dotnet/ManagedMetadataLayout.ts index 3d7e3cf094..083a79f00a 100644 --- a/src/dotnet/ManagedMetadataLayout.ts +++ b/src/dotnet/ManagedMetadataLayout.ts @@ -77,7 +77,8 @@ export interface ManagedMetadataLayout { readonly streamNames: readonly string[]; readonly strings: MetadataStream; readonly guid: MetadataStream; - readonly blob: MetadataStream; + /** Empty blob heaps may omit the stream; index zero remains the empty blob. */ + readonly blob: MetadataStream | null; /** Optional: assemblies without `ldstr` literals may omit #US. */ readonly userStrings: MetadataStream | null; readonly tables: ReadonlyMap; @@ -461,7 +462,7 @@ export const readManagedMetadataLayout = ( streamNames: [...streams.keys()].sort(), strings: requireStream(streams, "#Strings"), guid: requireStream(streams, "#GUID"), - blob: requireStream(streams, "#Blob"), + blob: streams.get("#Blob") ?? null, userStrings: streams.get("#US") ?? null, tables, rowCounts, diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index 55988fed33..65d2ffa420 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -205,7 +205,7 @@ export const methodBody = ( bytes, context.metadataLayout, cursor.readIndex(context.metadataLayout.blobIndexSize), - context.metadataLayout.blob.size, + context.metadataLayout.blob?.size ?? 0, ); const signatureIssue = validateLocalSignature( context.metadataLayout, diff --git a/src/dotnet/ManagedMethodSpecs.ts b/src/dotnet/ManagedMethodSpecs.ts index 0570908625..959e119b7f 100644 --- a/src/dotnet/ManagedMethodSpecs.ts +++ b/src/dotnet/ManagedMethodSpecs.ts @@ -100,7 +100,7 @@ const readMethodSpec = ( members.set(token, { ...target, token }); const types = specSignature( layout, - readMetadataBlob(bytes, layout, instantiation, layout.blob.size), + readMetadataBlob(bytes, layout, instantiation, layout.blob?.size ?? 0), { form: "instantiation", scope, offset: cursor.start }, ); if (generic === null) return; @@ -149,6 +149,6 @@ const methodSignatureBlob = ( bytes, layout, cursor.readIndex(layout.blobIndexSize), - layout.blob.size, + layout.blob?.size ?? 0, ); }; diff --git a/src/dotnet/ManagedNativeBoundaryInspector.ts b/src/dotnet/ManagedNativeBoundaryInspector.ts index 42e057e74c..9d9c7521e9 100644 --- a/src/dotnet/ManagedNativeBoundaryInspector.ts +++ b/src/dotnet/ManagedNativeBoundaryInspector.ts @@ -147,7 +147,7 @@ export const inspectManagedNativeBoundariesBytes = ( }); } try { - const heapExtent = Math.max(layout.strings.size, layout.blob.size); + const heapExtent = Math.max(layout.strings.size, layout.blob?.size ?? 0); issues.push(...inventory.issues); const moduleRefs = parseModuleRefs(bytes, layout, heapExtent, issues); const members = new Map([ diff --git a/src/dotnet/ManagedTypeNames.ts b/src/dotnet/ManagedTypeNames.ts index ef4a964725..d4cae10b56 100644 --- a/src/dotnet/ManagedTypeNames.ts +++ b/src/dotnet/ManagedTypeNames.ts @@ -48,7 +48,7 @@ export const typeNameLookup = ( bytes, layout, cursor.readIndex(layout.blobIndexSize), - layout.blob.size, + layout.blob?.size ?? 0, ); const owner = { form: "type", diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 8dd25e13a7..a4b47fac53 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -144,6 +144,116 @@ describe("managed reference instruction comparison", () => { }); describe("managed member structural matching", () => { + it("compares observed field targets for aligned field instructions", () => { + const il = Buffer.from([0x7b, 1, 0, 0, 4, 0x26, 0x2a]); + const bytes = buildManagedPeFixture({ + ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]), + }); + const base = inspect(bytes, "/tmp/field-target-base.dll"); + const counterpart = inspect(bytes, "/tmp/field-target-counterpart.dll"); + const changedTarget = { + ...counterpart, + result: managedMemberInspectionSchema.parse({ + ...counterpart.result, + field_accesses: counterpart.result.field_accesses.map((access) => ({ + ...access, + field_name: "differentField", + })), + }), + }; + + const comparison = compareManagedMembers(base, changedTarget); + expect(comparison.methods[0]).toMatchObject({ + status: "changed", + dimensions: expect.arrayContaining(["field-target"]), + }); + + const unavailableTarget = { + ...counterpart, + result: managedMemberInspectionSchema.parse({ + ...counterpart.result, + field_accesses: counterpart.result.field_accesses.map((access) => ({ + ...access, + field_name: null, + field_declaring_type: null, + })), + }), + }; + expect( + compareManagedMembers(base, unavailableTarget).methods[0], + ).toMatchObject({ + status: "unknown", + dimensions: expect.arrayContaining(["availability"]), + }); + }); + + it("compares resolved catch names without comparing build-local tokens", () => { + const bytes = buildManagedPeFixture({ ilBody: Buffer.from([0x06, 0x2a]) }); + const base = inspect(bytes, "/tmp/catch-target-base.dll"); + const counterpart = inspect(bytes, "/tmp/catch-target-counterpart.dll"); + const withCatch = ( + source: typeof base, + token: string, + classType: string | null, + identityResolved = true, + ) => ({ + ...source, + result: managedMemberInspectionSchema.parse({ + ...source.result, + methods: source.result.methods.map((method) => ({ + ...method, + body: { + ...method.body, + exception_regions: [ + { + flags: 0, + try_offset: 0, + try_length: 1, + handler_offset: 1, + handler_length: 1, + class_token: token, + class_type: classType, + class_type_identity_resolved: identityResolved, + filter_offset: null, + }, + ], + }, + })), + }), + }); + const sameType = compareManagedMembers( + withCatch(base, "0x02000001", "Fixture.Error"), + withCatch(counterpart, "0x02000002", "Fixture.Error"), + ); + expect(sameType.methods[0]).toMatchObject({ status: "unchanged" }); + expect(sameType.methods[0]?.dimensions).not.toContain("exception-shape"); + expect(sameType.methods[0]?.dimensions).not.toContain("exception-target"); + + const changedType = compareManagedMembers( + withCatch(base, "0x02000001", "Fixture.Error"), + withCatch(counterpart, "0x02000001", "Fixture.OtherError"), + ); + expect(changedType.methods[0]).toMatchObject({ + status: "changed", + dimensions: expect.arrayContaining(["exception-target"]), + }); + + const differentNamesWithUnresolvedScope = compareManagedMembers( + withCatch(base, "0x01000001", "Fixture.Error", false), + withCatch(counterpart, "0x01000002", "Fixture.OtherError", false), + ); + expect(differentNamesWithUnresolvedScope.methods[0]).toMatchObject({ + status: "changed", + dimensions: expect.arrayContaining(["exception-target"]), + }); + + const unresolved = compareManagedMembers( + withCatch(base, "0x02000001", "Fixture.Error"), + withCatch(counterpart, "0x01000002", "Fixture.Error", false), + ); + expect(unresolved.methods[0]).toMatchObject({ status: "unknown" }); + }); + it("keeps unequal constants from becoming a structural remap", () => { const observe = ( typeName: string,