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,